feat: own the tailscale serve mapping (supersedes #26)#36
Merged
Conversation
`cmd_unserve` ran a blind `tailscale serve --https=443 off` (or `--http=$PORT off`) keyed only off COLLIE_SERVE_MODE. If anything else on the host owned that root mount, `unserve`/`uninstall` silently unpublished it. Publishing had the mirror problem: `tailscale serve --bg … /` REPLACES an existing root handler, so `start` could take over a mapping Collie never created. Record the one mapping we publish in `<config-dir>/tailscale-managed-handler` (`<mode>:<port>|<HostPort>|<proxy>`), and make both directions prove ownership: - publishing refuses when the target root is occupied by a handler we don't own, or when the listener already speaks the opposite protocol; - teardown removes the recorded mapping only when the live root still matches the record, clears the record when the root is already gone, and refuses (keeping the record) when it was replaced out from under us. Also adds scripts/collie-ctl.test.sh, the first test coverage the control script has had. It fakes `tailscale` and `systemctl` on a scratch PATH with a throwaway $HOME, so the lifecycle runs anywhere and touches nothing real. Extracted from #26, which wrapped this in a NetBird front door that isn't being taken. The NetBird track, COLLIE_FRONT_DOOR and their tests are omitted; COLLIE_SKIP_SERVE stays the proxy switch. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The ownership check refused to publish over ANY existing root mount on the target port. Every install that predates ownership tracking has exactly one: Collie's own mount, and no record of it, because no version that writes the record has run there. So `serve` failed on precisely the deployments that already worked: tailscale serve: no Collie-managed mapping recorded error: Tailscale serve already has an unowned root mount on :8787; refusing to overwrite it Reproduced against this host's real `tailscale serve status --json` and in the sandbox in both HTTP and HTTPS modes, with a fresh-host control that succeeds. Decide ownership by where the mount points, not by whether we have a record of it. A root already proxying to our own `http://127.0.0.1:$PORT` is adopted: republishing over it is a no-op, and we record it on the way through. A root pointing anywhere else is still refused, and a foreground serve session is never adopted — it belongs to a live process that is not us. Also stop a failed front door from aborting `start`. cmd_serve can now return nonzero, and under `set -e` a bare call exited before print_status_banner, so a serve problem left the operator with no banner and no URL — while the bridge was up on loopback the whole time. That inverted the README's troubleshooting flow, which says the bridge is fine and only the ingress failed. Wires the lifecycle suite into the pre-push hook. `bun run test` alone doesn't cover it: the hook calls `bun test ./bridge` directly, so the suite would only ever run when someone remembered to. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The README read as though Tailscale were a requirement. It isn't: the bridge's entire Tailscale coupling is one header read, and `COLLIE_SKIP_SERVE=1` has always been the "you own the ingress" switch — it was just filed under "reverse proxy", so a NetBird or ZeroTier user had no reason to find it. Variant E states the general case: point any tunnel at 127.0.0.1:$COLLIE_PORT, carry the Variant B proxy requirements over, know that COLLIE_TRUSTED_USER is inert outside `tailscale serve`, and pin a stable hostname before installing the PWA. Plus the reason the funnel rule isn't about Tailscale — it's about reachability, and any public URL inherits it. Records in CLAUDE.md why Collie manages exactly one front door, so the next proposal doesn't have to re-derive it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracts the Tailscale ownership tracking from #26, fixes the upgrade path it broke, and documents the bring-your-own-tunnel case that #26 was really reaching for. The NetBird front door itself is not taken — reasoning in #26 (comment).
What changes
Ownership tracking (
a004449, authored by @iamtimmy).cmd_unserveused to run a blindtailscale serve --https=443 offkeyed only offCOLLIE_SERVE_MODE, sounserve/uninstallcould silently unpublish a mapping Collie never created. Publishing had the mirror problem:tailscale serve --bg … /replaces an existing root handler. Both directions now prove ownership against a recorded<config-dir>/tailscale-managed-handler.Adopt-on-match (
65889da). The check as written refused to publish over any existing root mount, which is the state of every install that predates ownership tracking — Collie's own mount, no record of it. Verified against this host's real serve config and in the sandbox in both modes:Ownership is now decided by where the mount points. A root already proxying to our own
http://127.0.0.1:$PORTis adopted; anything else is still refused; a foreground serve session is never adopted. Also stops a failed front door from abortingstart— the bridge is up on loopback and the banner has to print.Variant E (
6550041).COLLIE_SKIP_SERVE=1was always the bring-your-own-ingress switch, just filed under "reverse proxy" where a NetBird or ZeroTier user wouldn't find it. Documents the general case, and records in CLAUDE.md why Collie manages exactly one front door.Tests
scripts/collie-ctl.test.shis the first coverage the control script has had: faketailscale/systemctlon a scratch PATH, throwaway$HOME, so it runs anywhere and touches nothing real. Wired into the pre-push hook (bun run testalone wouldn't have run it — the hook callsbun test ./bridgedirectly).Every assertion negative-controlled by sabotaging a value and confirming the failure, then confirming restore is green.
Co-authored with @iamtimmy, whose commit this builds on.