diff --git a/.adr/0001-one-managed-front-door.md b/.adr/0001-one-managed-front-door.md new file mode 100644 index 0000000..5eb7494 --- /dev/null +++ b/.adr/0001-one-managed-front-door.md @@ -0,0 +1,82 @@ +# 0001 — Collie manages exactly one front door + +- **Status:** Accepted +- **Date:** 2026-07-27 +- **Shipped in:** 0.16.0 +- **Trail:** [PR #26](https://github.com/AltanS/collie/pull/26) (declined, + [reasoning](https://github.com/AltanS/collie/pull/26#issuecomment-5085567630)) · + [PR #36](https://github.com/AltanS/collie/pull/36) (what we kept from it) + +## Context + +Collie is remote shell access. Whatever sits in front of the loopback bridge is the only thing +between a stranger and a terminal running as you, so "which front door" is a security question +before it is a convenience one. + +**The bridge is already tunnel-agnostic.** Its entire Tailscale coupling is *one* line — +`req.headers.get("tailscale-user-login")` in `bridge/server.ts`. Everything else is a convenience in +`scripts/collie-ctl.sh` and the README's voice. The bridge binds loopback, speaks plain HTTP, and +gates on `Host`, `Origin`, and two optional headers, one of which (`COLLIE_DEVICE_HEADER`) is +deliberately vendor-neutral. + +PR #26 proposed a **second managed front door**: `COLLIE_FRONT_DOOR=tailscale|netbird|proxy`, a +supervised `netbird expose` sidecar with its own systemd unit and teardown, and config plumbing for +NetBird's auth flags. 1441 additions across 12 files. The work was careful, and it raised a fair +question — is Collie too tied to Tailscale, and are non-Tailscale users being hindered? + +Three things settled it, each checked rather than assumed: + +1. **Nobody was blocked.** `COLLIE_SKIP_SERVE=1` plus `netbird expose 8787` *is* the whole + integration. What the PR added on top was supervision and teardown of that one command — the same + thing Variant C already, deliberately, declines to do for anyone's Caddy. +2. **We would have been shipping blind.** NetBird isn't installed on the deployment host, there's no + CI for it, and shell code sat outside both test suites. Its CLI contract — flag names, the v0.66 + floor, what `expose` actually publishes — would have been maintained by reading a PR description. + Two prior PRs from a different contributor had described changes their diffs did not contain. +3. **Managing someone else's authenticated process means owning their credentials.** The PR + demonstrated the cost rather than hypothesising it: the generated runner passed `--with-pin` and + `--with-password` as command-line arguments, so `ps -eo args` and `/proc//cmdline` (mode + `444`) handed them to any local user. + +And the precedent doesn't scale. Cloudflare Tunnel, ZeroTier, Twingate and Nebula all have equal +claim, each with a different CLI and auth model, all as `case` branches in one bash script. That is a +plugin-shaped problem being solved in the wrong shape. + +## Decision + +**Collie manages exactly one front door: `tailscale serve`.** + +We own its lifecycle end to end — `collie-ctl.sh` publishes it, records the mapping in +`/tailscale-managed-handler`, and only ever tears down a mapping still matching that +record. + +**Every other tunnel is `COLLIE_SKIP_SERVE=1` plus [README Variant +E](../README.md#variant-e--any-other-mesh-or-tunnel-netbird-zerotier-cloudflare-tunnel).** The +operator owns the ingress; Collie publishes nothing, supervises nothing, and tears down nothing. + +The criterion is not popularity or quality. It is: **we manage only what we run and can test.** +`tailscale serve` is on the deployment host, so a regression surfaces the same day. + +## Consequences + +**Accepted costs.** Non-Tailscale operators supervise their own tunnel — no sidecar unit from us, and +`uninstall` won't remove it. That is exactly what Variant C has always promised for a reverse proxy, +so it is a consistency, not a new gap. + +**What we keep owning.** The ownership tracking extracted from #26 (0.16.0) makes the one managed +mapping precise in both directions: publishing refuses to replace a root mount we don't own, and +teardown refuses to remove one that was replaced out from under us. A blind +`tailscale serve --https=443 off` could previously unpublish a mapping Collie never created. + +**Documentation carries the load instead of code.** Variant E covers NetBird, ZeroTier, Cloudflare +Tunnel and anything not yet invented, for zero runtime surface. When the honest fix for "users feel +excluded" is a doc section, that is the fix. + +**The funnel prohibition generalises.** "Never `tailscale funnel`" was never about Tailscale; it is +about reachability. Any tunnel offering a public URL inherits it, and auth in front of a public URL +is not a substitute for not having one. A shared PIN guarding a root shell is a root password. + +**What would justify revisiting.** If the deployment host itself moves to another mesh, that mesh +becomes testable and its front door becomes ownable. The rule that survives is *exactly one managed +front door* — not one per vendor. Adding a second, whatever the vendor, means maintaining a CLI +contract we cannot exercise, and this ADR should be superseded rather than quietly ignored. diff --git a/.adr/README.md b/.adr/README.md new file mode 100644 index 0000000..918e742 --- /dev/null +++ b/.adr/README.md @@ -0,0 +1,45 @@ +# Architecture decision records + +Decisions with a **blast radius wider than the diff that made them** — the ones a future +contributor (or a future agent) would otherwise re-derive from scratch, or quietly reverse because +the reasoning lived only in a PR thread. + +One file per decision, numbered in the order they were accepted: + +``` +.adr/NNNN-kebab-case-title.md +``` + +Format is [Michael Nygard's](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions): +**Context** (the forces, including what was actually measured), **Decision** (what we do, in the +imperative), **Consequences** (what this costs, and what would justify revisiting it). + +## When to write one + +Write an ADR when a decision **closes off an option someone will reasonably propose again**. The +signal is that you find yourself explaining *why not* rather than *how*. + +- ✅ "We manage exactly one front door" — a NetBird PR, then a Cloudflare Tunnel PR, then a ZeroTier PR +- ✅ "Polling, not an event stream" — perennial, and the reasoning isn't obvious from the code +- ❌ "Use Vitest for the web suite" — that's just what the repo does; `CLAUDE.md` covers it +- ❌ Anything already legible from the code, a test name, or a commit message + +## Relationship to the other docs + +Nothing here restates what lives elsewhere; the point is the *reasoning*, once. + +| Where | What belongs there | +| --- | --- | +| [`CLAUDE.md`](../CLAUDE.md) | The **rule** — short, normative, linking here for why | +| [`ARCHITECTURE.md`](../ARCHITECTURE.md) | How the system is **built**, as it stands today | +| [`README.md`](../README.md) | How an operator **runs** it | +| `.adr/` | Why a road **wasn't** taken | + +A superseded ADR is never deleted or edited into agreement with the present. Mark it +`Superseded by NNNN` and write the new one — the wrong turn is the useful part. + +## Index + +| # | Decision | Status | +| --- | --- | --- | +| [0001](./0001-one-managed-front-door.md) | Collie manages exactly one front door | Accepted | diff --git a/CLAUDE.md b/CLAUDE.md index 74f427f..c6ff729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,18 @@ Tailscale. A mobile-first PWA (Vite + React + TS + Tailwind v4 + shadcn) plus a talks to Herdr's Unix socket, letting you monitor and reply to agents from a phone. The Herdr plugin id is `herdr.collie` (manifest: `herdr-plugin.toml`). Orientation: [`README.md`](./README.md) · [`ARCHITECTURE.md`](./ARCHITECTURE.md) · verified API -[`HERDR_API.md`](./HERDR_API.md). +[`HERDR_API.md`](./HERDR_API.md) · decisions [`.adr/`](./.adr/). + +## Decision records — read before reopening a settled question + +[`.adr/`](./.adr/) holds the decisions whose reasoning would otherwise live only in a PR thread — +specifically the ones that **close off an option someone will reasonably propose again**. If you're +about to argue *why not* rather than *how*, check there first; if the answer isn't there and the +decision is that shape, add one (numbering + format: [`.adr/README.md`](./.adr/README.md)). + +Rules elsewhere in this file stay short and normative and link to the ADR for the argument. Don't +restate an ADR's reasoning here, and don't edit a superseded ADR into agreement with the present — +mark it superseded and write the next one. ## Versioning — MANDATORY @@ -101,9 +112,8 @@ conforming reverse proxy per README Variant C (`COLLIE_SKIP_SERVE=1`) · same-or identity/device gates · strict CSP. A socket call can type into a real terminal — treat the bridge as remote shell access. -**Collie manages exactly one front door: `tailscale serve`.** That's the one this project runs and -tests, so it's the one whose lifecycle we own (`collie-ctl.sh` publishes it, records the mapping in -`tailscale-managed-handler`, and only ever tears down a mapping matching that record). Every other -tunnel — NetBird, ZeroTier, Cloudflare Tunnel — is `COLLIE_SKIP_SERVE=1` + README Variant E: the -operator owns the ingress and Collie publishes nothing. **Don't add a second managed front door**; -we'd be maintaining a CLI contract we can't test. See PR #26 for the full reasoning. +**Collie manages exactly one front door: `tailscale serve`** — `collie-ctl.sh` publishes it, records +the mapping in `tailscale-managed-handler`, and only ever tears down a mapping matching that record. +Every other tunnel (NetBird, ZeroTier, Cloudflare Tunnel) is `COLLIE_SKIP_SERVE=1` + README Variant +E: the operator owns the ingress, Collie publishes nothing. **Don't add a second managed front +door** — [ADR 0001](./.adr/0001-one-managed-front-door.md).