From 8ecfdcec484d7147c522e0c02faf8bd718155359 Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Mon, 27 Jul 2026 00:05:29 +0200 Subject: [PATCH] docs: add .adr/ and record the one-managed-front-door decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reasoning for declining a second managed front door lived in a PR thread, which is exactly where it would be lost. The next NetBird-shaped proposal — Cloudflare Tunnel, ZeroTier, Twingate — would have re-derived it or quietly reversed it. .adr/ is for decisions that close off an option someone will reasonably propose again: the ones where you find yourself explaining why NOT rather than how. Not for what the code, a test name or CLAUDE.md already says. ADR 0001 records the front-door decision with what was actually measured — the one-line Tailscale coupling in the bridge, the credential leak into argv that PR #26 demonstrated, and that COLLIE_SKIP_SERVE=1 already made every other tunnel a one-command integration. Also the reversal condition, so it can be superseded deliberately rather than ignored. CLAUDE.md keeps the rule and drops the argument, per the split the folder's README sets out: rules there, reasoning here. Doc-only, no version bump. Co-Authored-By: Claude Opus 5 (1M context) --- .adr/0001-one-managed-front-door.md | 82 +++++++++++++++++++++++++++++ .adr/README.md | 45 ++++++++++++++++ CLAUDE.md | 24 ++++++--- 3 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 .adr/0001-one-managed-front-door.md create mode 100644 .adr/README.md 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).