diff --git a/.env.example b/.env.example index b0d54b30..ef7eec9d 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,49 @@ # Collie configuration. Copy to your plugin config dir as `.env`: # cp .env.example "$(herdr plugin config-dir herdr.collie)/.env" -# All values are optional; the defaults suit a single-user, tailnet-only setup. +# Values are optional; the default selects the Tailscale front door. Choose exactly one front door +# before the first `start`: Tailscale, NetBird, or your own authenticated proxy. # --- Networking --- -# Port the bridge listens on (loopback only; tailscale serve proxies to it). +# Port the bridge listens on (loopback only; the selected front door proxies to it). COLLIE_PORT=8787 # Bind host. Keep this loopback. Binding 0.0.0.0 makes identity checks meaningless. COLLIE_HOST=127.0.0.1 +# Which front door collie-ctl.sh manages: "tailscale" (default), "netbird", or "proxy". +# "proxy" starts only the loopback bridge; your Caddy/Nginx/etc. owns TLS, auth, and public access. +# Starting or switching tracks retires stale managed Tailscale/NetBird ingress; proxy retires both. +# COLLIE_FRONT_DOOR=tailscale +# # How the bridge is published on the tailnet (read by collie-ctl.sh when it runs `tailscale serve`, # not by the bridge itself): "https" (default — tailnet :443, Tailscale-managed cert) or "http" # (plain HTTP on :$COLLIE_PORT — for Headscale / `.internal` domains without HTTPS certs; then set # COLLIE_PUBLIC_HOSTS below, and note PWA install + Web Push need a secure context). # COLLIE_SERVE_MODE=https -# Skip tailscale serve entirely (set to 1 when using a reverse proxy like Caddy/Nginx). -# The bridge stays on 127.0.0.1 only — your proxy handles TLS, auth, and public access. -# With this enabled, set COLLIE_ALLOWED_ORIGINS and COLLIE_PUBLIC_HOSTS to match your proxy's hostname. -# In this mode COLLIE_TRUSTED_USER has no effect (no tailscale serve injects the identity header) — -# use COLLIE_DEVICE_HEADER for per-device auth instead (README → Variant C). +# +# NetBird prerequisites: CLI v0.66+, client status Connected, account Peer Expose enabled, and +# this peer's group allowed by the account's Peer Expose policy. Verify with `netbird version` and +# `netbird status` before selecting this track. +# Secure/PWA recipe: configure a stable custom domain in NetBird first. Set +# COLLIE_NETBIRD_CUSTOM_DOMAIN and COLLIE_PUBLIC_HOSTS to the hostname, and +# COLLIE_ALLOWED_ORIGINS to its full HTTPS origin (examples below). Generated expose names can +# change across sidecar sessions; do not use them for static allowlists or an installed PWA. +# NetBird expose is public unless protected. Collie refuses to start it unless one auth option below +# is set, or COLLIE_NETBIRD_ALLOW_PUBLIC=1 explicitly accepts the risk. +# With systemd --user, the expose sidecar is supervised/restarted; without it, `nohup` + a PID file +# has no automatic restart or boot start. +# COLLIE_NETBIRD_PIN=123456 +# COLLIE_NETBIRD_PASSWORD= +# COLLIE_NETBIRD_USER_GROUPS=operators +# COLLIE_NETBIRD_CUSTOM_DOMAIN=collie.example.com +# COLLIE_NETBIRD_NAME_PREFIX=collie +# COLLIE_NETBIRD_ALLOW_PUBLIC=0 +# +# Proxy track: set COLLIE_FRONT_DOOR=proxy, keep COLLIE_HOST loopback, and configure your external +# proxy for TLS/authentication, loopback upstream, Host forwarding, and a trusted per-device header. +# Set COLLIE_PUBLIC_HOSTS/COLLIE_ALLOWED_ORIGINS to the proxy hostname/origin (see README Variant C). +# +# Legacy proxy toggle (equivalent to COLLIE_FRONT_DOOR=proxy). # COLLIE_SKIP_SERVE=1 -# Your public URL behind the proxy — shown as the "proxy" address by `collie-ctl.sh status`. +# Public URL for proxy/NetBird modes — shown by `collie-ctl.sh status` and `url`. # COLLIE_PUBLIC_URL=https://collie.example.com # --- Herdr connection --- @@ -36,35 +61,39 @@ COLLIE_SUBMIT_KEYS=Enter # COLLIE_MULTI_SESSION=on # --- Security --- -# If set, requests must carry a matching Tailscale-User-Login header (from `tailscale serve`). +# Optional Tailscale person gate. If set, requests carrying a Tailscale-User-Login header (from +# `tailscale serve`) must match it. It has no effect for NetBird or proxy mode because those front +# doors do not inject that header; use their own authentication instead. # COLLIE_TRUSTED_USER=you@example.com # # Optional per-device authorisation (OFF by default). When Collie sits behind a reverse proxy that # authenticates the device and injects its identity as a request header, name that header here to # trust and enforce it. Proxy-agnostic — the value is just an opaque device id; Collie doesn't care # how the proxy derived it. The header is trusted only because the bridge binds loopback behind the -# proxy (a direct client can't set it). Leave unset to disable. +# proxy (a direct client can't set it). NetBird CLI expose does not inject such a header. Leave unset +# to disable. # COLLIE_DEVICE_HEADER=X-Device-Id # Device ids allowed to perform sensitive actions (typing into agents, creating tabs/spaces). Any # other device carrying the header is read-only; an absent header is the on-host operator (unchanged). -# To revoke a device, drop its id from this list and `systemctl --user restart collie`. With the -# header set but this list empty, EVERY device is read-only (fail-closed). +# To revoke a device, drop its id from this list and restart Collie. With the header set but this +# list empty, EVERY device is read-only (fail-closed). # COLLIE_DEVICE_ALLOWLIST=my-phone,my-laptop # Extra allowed browser origins (comma-separated, each a FULL origin incl. scheme). # The bridge is same-origin only: a request passes when the browser's Origin host equals the Host -# header the bridge receives (loopback always allowed). A plain `tailscale serve` on your MagicDNS -# name matches automatically, so you DON'T need this for a normal Tailscale setup. -# You DO need it when a *different* public hostname or an extra reverse proxy / TLS terminator fronts -# Collie (a custom/vanity domain, a load balancer, Headscale + upstream TLS) — the public origin then -# no longer matches the forwarded Host and API calls return 403 "cross-origin rejected". Add that -# exact origin here. e.g.: +# header the bridge receives (loopback always allowed). A plain `tailscale serve` on its MagicDNS +# name usually matches automatically, so you DON'T need this for a normal Tailscale setup. +# For NetBird, configure the stable custom domain first, then set this to its exact origin. Generated +# NetBird names can change between ephemeral sidecar sessions and are unsuitable for a static value +# or an installed PWA. You also need this when a proxy or TLS terminator changes the public hostname +# and API calls return 403 "cross-origin rejected". Add that exact origin here. e.g.: # COLLIE_ALLOWED_ORIGINS=https://collie.example.com # # Host allowlist (comma-separated host[:port] values). When set, a request whose Host header isn't # loopback, one of these, or an COLLIE_ALLOWED_ORIGINS host is rejected outright — this defeats DNS -# rebinding. Strongly recommended: set it to your MagicDNS name. Effectively MANDATORY when -# COLLIE_SERVE_MODE=http (no TLS = rebinding is otherwise same-origin). Unset = legacy behavior. -# COLLIE_PUBLIC_HOSTS=herd.your-tailnet.ts.net +# rebinding. Set it to the exact public hostname for the selected front door. It is effectively +# MANDATORY for the secure NetBird recipe, COLLIE_SERVE_MODE=http, or a reverse proxy. Configure +# the NetBird custom domain before copying it here. Unset = legacy behavior. +# COLLIE_PUBLIC_HOSTS=collie.example.com # --- Web Push (optional) --- # Generate with: bunx web-push generate-vapid-keys (after `bun add web-push`) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2be6b043..60b3fa6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,18 +26,19 @@ A Herdr web bridge — a long-lived local process that - connects to Herdr's Unix-socket API (`$HERDR_SOCKET_PATH`), - serves a **mobile-first web app**, with live state polled over HTTP (see §5), - translates browser actions → socket methods, -- sits behind **one hardened front door** — `tailscale serve` (default; tailnet-only HTTPS + - MagicDNS) or a conforming reverse proxy - ([README → Variant C](./README.md#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale)) — - installable as a **PWA**. +- sits behind **exactly one selected hardened front door** — `tailscale serve` (tailnet-only HTTPS + + MagicDNS), `netbird expose` (public HTTPS protected by NetBird auth), or a conforming reverse + proxy ([README → Variant C](./README.md#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale)) — + installable as a **PWA**. Starting or switching tracks retires stale managed ingress; proxy mode + leaves the operator's proxy as the sole ingress. The browser never touches the socket directly; the bridge is the only thing that does. ``` phone / laptop (PWA) - │ HTTPS over tailnet (https://herd..ts.net) + │ HTTPS over selected front door ▼ - tailscale serve ── injects identity headers, terminates TLS (Variant C: a reverse proxy instead) + tailscale serve / netbird expose / reverse proxy │ 127.0.0.1:PORT (bridge binds loopback ONLY) ▼ Collie (this project) @@ -157,18 +158,19 @@ app. Closing this needs the server-side blocking-message capture described above ## 6. Security model This socket equals **arbitrary code execution on the host** (`agent.send` / `pane.send_text` type -into live terminals). The posture is single-user, behind one hardened front door (tailnet-only by -default). These four are genuine RCE vectors and are **load-bearing — do not regress them:** +into live terminals). The posture is single-user, behind one hardened front door. These four are +genuine RCE vectors and are **load-bearing — do not regress them:** - **The bridge binds `127.0.0.1` only** and lets its single front door proxy it. Binding `0.0.0.0` makes the whole access check theater. Under `tailscale serve`, the `Tailscale-User-Login` header is the person gate — trusted **only** when the request source is loopback (i.e. it came from tailscaled), with the owner login asserted and any other tailnet user rejected. That header exists - **only** under `tailscale serve` ingress; under a reverse-proxy front door + **only** under `tailscale serve` ingress. Under `netbird expose` there is no Collie-readable + identity header; NetBird auth is the front-door gate. Under a reverse-proxy front door ([README → Variant C](./README.md#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale)) - there is none, and the equivalent write gate is **per-device auth** (`COLLIE_DEVICE_HEADER`) with - the proxy contract (README Variant B/C requirements) as the load-bearing piece. The loopback bind is - load-bearing either way. + the equivalent write gate is **per-device auth** (`COLLIE_DEVICE_HEADER`) with the proxy contract + (README Variant B/C requirements) as the load-bearing piece. The loopback bind is load-bearing + either way. - **`pane.read` output renders safely** — it's attacker-influenceable (filenames, agent output, fetched web content). Never `innerHTML`; it renders as React text nodes under a **strict CSP** (`default-src 'self'`), so an escaping miss can't run injected script that calls back into the @@ -177,12 +179,13 @@ default). These four are genuine RCE vectors and are **load-bearing — do not r the `Host` header the bridge receives (loopback always allowed), so a page on any other tailnet device can't CSRF the bridge. With a plain `tailscale serve` on the MagicDNS name these match automatically (no config). When Collie is fronted by a *different* public hostname or an extra - reverse proxy / TLS terminator (custom domain, load balancer, Headscale + upstream TLS, or a - reverse-proxy front door — [README → Variant C](./README.md#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale)), + reverse proxy / TLS terminator (NetBird generated/custom domain, custom vanity domain, load + balancer, Headscale + upstream TLS, or a reverse-proxy front door — + [README → Variant C](./README.md#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale)), the public origin no longer matches the forwarded `Host` — list that exact origin in `COLLIE_ALLOWED_ORIGINS` (the only sanctioned way to widen the gate; never bind off-loopback to "fix" it). -- **Idle timeout.** Tailscale identity proves the *device*, not *who's holding it*. The PWA stays +- **Idle timeout.** The front door proves the *device/account*, not *who's holding it*. The PWA stays "signed in" with no session, so a stolen unlocked phone would be a root shell. The idle-lock unmounts the router — pausing all polling — until tapped. @@ -203,10 +206,12 @@ Considered, not built: Full passthrough (no command allow-list) is acceptable for a personal tool — an allow-list would defeat the purpose. **Never use `tailscale funnel`** (public exposure). -## 7. Tailscale & PWA +## 7. Front door & PWA - `tailscale serve` → tailnet-only HTTPS on a stable MagicDNS hostname; the node cert doesn't rotate, so the PWA stays signed in. No credential management, no login screen. +- `netbird expose` → HTTPS through NetBird's reverse proxy. Prefer a custom domain for an installed + PWA; generated expose names can change across sidecar restarts. - Install as a PWA (Add to Home Screen) → app icon, instant open, persistent. - Known failure mode (accept, don't engineer around): if `tailscaled` is down, the bridge is reachable on localhost but not via MagicDNS. On **Android specifically**, the OS backgrounds Tailscale diff --git a/CLAUDE.md b/CLAUDE.md index f9596b38..bc33a47b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,11 @@ the unit name; the Herdr action runs from anywhere. tsc), then build web to `dist-staging` and swap it in atomically — a failed build never empties a live `web/dist`. Bare `cd web && bun run build` still skips typechecking; don't ship from it. - **Tests:** frontend `cd web && bun run test` (Vitest + jsdom + Testing Library + MSW; no headless - browser); backend pure-logic `bun run test` at the root (Bun's own runner — covers `checkAccess`, - `StateEngine`, `loadConfig`). A **pre-push hook** (`scripts/git-hooks/pre-push`) runs **both** before - every push — override once with `SKIP_TESTS=1 git push`. The bits that genuinely need `Bun.serve` / + browser); root `bun run test` runs Bun's backend pure-logic suite (`checkAccess`, `StateEngine`, + `loadConfig`) plus `scripts/collie-ctl.test.sh` lifecycle mocks. The **pre-push hook** + (`scripts/git-hooks/pre-push`) runs the frontend Vitest and backend Bun suites; run root + `bun run test` explicitly to include the shell lifecycle suite. Override the hook once with + `SKIP_TESTS=1 git push`. The bits that genuinely need `Bun.serve` / `Bun.connect` (HTTP handlers, the socket client) stay unit-untested — Vitest-on-Node can't run them, so keep new backend logic pure/injectable enough for `bun test`, or exercise it through `web/`. - Service: `systemd --user` unit `collie` on the deployment host; logs `journalctl --user -u collie -f`. @@ -96,7 +98,7 @@ the unit name; the Herdr action runs from anywhere. ## Security posture (don't regress) -Loopback bind only · exactly one hardened front door — `tailscale serve` (never `funnel`) or a -conforming reverse proxy per README Variant C (`COLLIE_SKIP_SERVE=1`) · same-origin gate · optional -identity/device gates · strict CSP. A socket call can type into a real terminal — treat the bridge as -remote shell access. +Loopback bind only · exactly one hardened front door — `tailscale serve` (never `funnel`), +`netbird expose` with NetBird auth (README Variant D), or a conforming reverse proxy per README +Variant C (`COLLIE_SKIP_SERVE=1`) · same-origin gate · optional identity/device gates · strict CSP. A +socket call can type into a real terminal — treat the bridge as remote shell access. diff --git a/README.md b/README.md index cd4a16af..8810e9fd 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,12 @@ one thumb. Collie is that. You, if you run [Herdr](https://herdr.dev) agents on a machine and want to resume a session from your phone — read what an agent is asking, type a reply, fire a special key — without SSHing in and -wrestling a TUI. It assumes a **[Tailscale](https://tailscale.com) tailnet (mesh) setup**: your -phone and the host are on the same tailnet, and `tailscale serve` is the only way in. It's -deliberately **single-user**: one operator, one tailnet, no multi-tenant auth. If that's your setup, -Collie fits. If you need shared or public access, it isn't built for that — and see the security -note below before you run it. +wrestling a TUI. The default track assumes a **[Tailscale](https://tailscale.com) tailnet (mesh) +setup**: your phone and the host are on the same tailnet, and `tailscale serve` is the default way in. +The NetBird and reverse-proxy tracks below use the same single-operator model. It's deliberately +**single-user**: one operator, one front door, no multi-tenant auth. If that's your setup, Collie fits. +If you need shared or public access, it isn't built for that — and see the security note below before +you run it. ## ⚠️ Security — read before you run it @@ -79,70 +80,78 @@ secrets, env, agent output) and run any command as your user. No sandbox, no com Three sharp edges: - **It acts as _you_**, with your full privileges — `~/.ssh`, `git push --force`, `rm -rf`, `sudo`. -- **Access is device-level, not person-level.** Tailscale proves the device, not who's holding it. - No password, no session — an unlocked or stolen phone (or anyone else on your tailnet) is an open - shell. The idle-lock is UX, not auth. Every write action (replies, keys, uploads, pane/tab - create/close) is appended to `/audit.log`, so there is at least a trail — but a trail - is not a gate. +- **Access is front-door-level, not person-at-keyboard-level.** Tailscale proves the device/user + header it injects; NetBird expose or your proxy proves whatever authentication you configured. + No Collie password, no Collie session — an unlocked or stolen phone (or anyone else allowed + through ingress) is an open shell. The idle-lock is UX, not auth. Every write action (replies, + keys, uploads, pane/tab create/close) is appended to `/audit.log`, so there is at + least a trail — but a trail is not a gate. - **One bridge fronts _every_ session.** With `COLLIE_MULTI_SESSION` on (the default), the bridge discovers and serves every named Herdr session under your config root — a private or sandbox session (e.g. `collie-demo`) is readable and drivable through the same URL as your primary, and the set is rescanned periodically. Set `COLLIE_MULTI_SESSION=0` to serve only the primary session. -It's built single-user and tailnet-only. The defenses: +It's built for one operator behind one hardened front door. The defenses: - **Loopback bind only** (`127.0.0.1`) — never `0.0.0.0`. -- **Exactly one hardened front door** — either `tailscale serve` (default, Variant A: terminates - TLS, injects the identity header) or a conforming reverse proxy +- **Exactly one hardened front door** — `tailscale serve` (default, Variant A: terminates TLS, + injects the identity header), `netbird expose` (Variant D: public URL protected by NetBird auth), + or a conforming reverse proxy ([Variant C](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale)). Never `tailscale funnel`, never a bare port. -- **Optional identity gate** — set `COLLIE_TRUSTED_USER` to reject anyone but you. +- **Optional Tailscale identity gate** — set `COLLIE_TRUSTED_USER` only with `tailscale serve`; + it rejects anyone whose injected `Tailscale-User-Login` does not match. - **Optional per-device gate** — behind a proxy that injects a device-identity header, set `COLLIE_DEVICE_HEADER` + `COLLIE_DEVICE_ALLOWLIST` so only allowlisted devices can drive agents; any other device is read-only. Off by default; revoke a device by dropping it from the list. See [Deployment variants](#deployment-variants) for the proxy this requires. +- **At most one Collie-managed ingress.** Starting or switching tracks removes stale managed + Tailscale or NetBird ingress before publishing the selected one. Proxy mode leaves your + operator-run external proxy in place; Collie does not stop it. `unserve` and `uninstall` remove + Collie's managed ingress. - **Same-origin gate + strict CSP**; pane output renders as React text nodes, never `innerHTML`. - **Optional Host allowlist** — set `COLLIE_PUBLIC_HOSTS` to the exact host(s) you serve on (e.g. your MagicDNS name) and the bridge rejects any request addressed to another Host before the origin logic runs. **Strongly recommended, and effectively mandatory with - `COLLIE_SERVE_MODE=http`** — without TLS, DNS rebinding can otherwise make a hostile page - same-origin with the bridge. + `COLLIE_SERVE_MODE=http`, NetBird expose, or any extra proxy** — otherwise DNS rebinding can make + a hostile page same-origin with the bridge. > 🚫 **Never `tailscale funnel` this** — funnel exposes it to the public internet; `serve` keeps it > tailnet-only. There is no scenario where funneling Collie is correct. -Narrow the blast radius with Tailscale ACLs and `COLLIE_TRUSTED_USER`. Provided as-is, no warranty. +Narrow the blast radius with Tailscale ACLs, NetBird auth restrictions, and the smallest possible +device set. Provided as-is, no warranty. ## Requirements -On the **host** (the tailnet node your agents run on): +On the **host** (the machine your agents run on): | Tool | Why | | --- | --- | | [**Bun**](https://bun.sh) | Runs the bridge and builds the web UI — the only hard dependency. | | [**Herdr**](https://herdr.dev) ≥ 0.7.0 | The herd Collie mirrors; its CLI registers the plugin. | -| [**Tailscale**](https://tailscale.com) | Front door for the default variant (`tailscale serve`); optional if you run [Variant C](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) behind your own reverse proxy. Without any front door, the bridge is `127.0.0.1`-only. | +| [**Tailscale**](https://tailscale.com) | Required only for the default Tailscale front door; optional for [Variant C](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) or [Variant D](#variant-d--netbird-expose--netbird-auth). | +| [**NetBird**](https://netbird.io) ≥ 0.66 | Required only for [Variant D](#variant-d--netbird-expose--netbird-auth). | | **git** | Clone, and the `update` command. | -Soft dependencies: **Node.js** (the control script uses it to extract your MagicDNS name from -`tailscale status --json`; without it the banner falls back to the loopback URL) and **`systemd ---user`** (supervises the service; falls back to a `nohup` process without it). You never install JS -deps by hand — the build runs `bun install` for you; the backend imports only Bun + `node:*`. -[`web-push`](https://www.npmjs.com/package/web-push) is optional and lazy (see [Web -Push](#web-push-optional)). +Soft dependency: **`systemd --user`** supervises the bridge and, for NetBird, the expose sidecar. +Without it the script uses `nohup` plus PID files. You never install JS deps by hand — the build +runs `bun install` for you; the backend imports only Bun + `node:*`. +[`web-push`](https://www.npmjs.com/package/web-push) is optional and lazy (see +[Web Push](#web-push-optional)). ## Install -On the host, not your phone. Two ways in. +On the host, not your phone. The default Tailscale path needs no config. -**From GitHub (turnkey)** — Herdr clones and builds for you: +**From GitHub (turnkey)** — Herdr clones and builds; then start it: ```bash herdr plugin install AltanS/collie herdr plugin action invoke start --plugin herdr.collie ``` -**From a local clone (for development)** — registered by path: +**From a local clone (for development)** — registered by path and built on first start: ```bash git clone https://github.com/AltanS/collie.git && cd collie @@ -150,13 +159,28 @@ herdr plugin link "$(pwd)" herdr plugin action invoke start --plugin herdr.collie ``` +Those commands use Tailscale ([Variant A](#variant-a--tailscale-serve--person-identity-default)). +For NetBird or your own proxy, omit the `start` line, create the config, add the selected variant's +required values, then start: + +```bash +config_dir="$(herdr plugin config-dir herdr.collie)" +mkdir -p "$config_dir" +touch "$config_dir/.env" +# Configure Variant D (NetBird) or Variant C (your proxy), then: +herdr plugin action invoke start --plugin herdr.collie +``` + +- [Variant C — your reverse proxy](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) +- [Variant D — NetBird expose](#variant-d--netbird-expose--netbird-auth) + They differ only in *when* the UI builds: a GitHub install builds at install time (the manifest's -`[[build]]` step); a linked clone builds on first `start`. Either way, `start` does four things: +`[[build]]` step); a linked clone builds on first `start`. Either way, `start`: 1. **builds** `web/dist` if it's missing (typechecked, staged, swapped in atomically), 2. **starts the bridge** as the `systemd --user` service `collie` (`nohup` fallback without systemd), -3. **publishes it on the tailnet** — literally `tailscale serve --bg 8787`: HTTPS on the host's - MagicDNS name, `:443 → 127.0.0.1:8787`, tailnet-only, +3. **removes stale managed ingress and publishes the selected front door** — Tailscale mapping, + NetBird expose sidecar, or neither for proxy mode, 4. **prints the banner** with the URL to open — walked through line by line in [First run](#first-run--what-youll-see). @@ -182,6 +206,9 @@ tailscale serve (https) → tailnet :443 -> 127.0.0.1:8787 tailnet https://myhost.tail1234.ts.net ``` +The NetBird track prints `netbird https://…` instead of `tailnet https://…` and also starts the +`collie-netbird-expose` sidecar. + The `✓` is a real probe — the script connected to the bridge's port and got an answer, not just "the unit is active". If you get `⚠ Collie isn't answering on :8787 yet` instead, see [Troubleshooting](#troubleshooting). @@ -196,23 +223,31 @@ The `✓` is a real probe — the script connected to the bridge's port and got `~/.config/systemd/user/collie.service`, enabled and started, auto-restarting on failure. Inspect it with `systemctl --user status collie`. (No usable systemd? A `nohup` process with a pidfile in the config dir instead.) -3. **A tailnet-only `tailscale serve` mapping** — the script ran `tailscale serve --bg 8787`: - HTTPS on the host's MagicDNS name, `:443 → 127.0.0.1:8787`. Tailscale terminates TLS (managed - cert, nothing to obtain or renew) and injects the identity header the bridge checks. Inspect - with `tailscale serve status`; remove just this mapping with `scripts/collie-ctl.sh unserve`. - -`stop` merely pauses the service; `uninstall` reverses 2 + 3 and keeps your `.env` and the checkout. +3. **The selected ingress**: + - Tailscale track: a tailnet-only `tailscale serve` mapping (`:443 → 127.0.0.1:8787`) that + injects `Tailscale-User-Login`. + - NetBird track: `collie-netbird-expose`, a second user service running `netbird expose 8787 …`. + Its expose session is ephemeral, so systemd keeps the sidecar alive and restarts it after a + failure; without systemd it is a `nohup` process with a PID file and has no automatic restart + or boot start. + - Proxy track: no managed ingress; your configured external proxy is the sole front door. + +Before publishing the selected track, `start` removes stale managed Tailscale and NetBird ingress. +Proxy mode leaves the operator-run external proxy alone; Collie never stops it. `stop` stops only +the bridge and leaves managed ingress running. `unserve` removes Collie's managed ingress, while +`uninstall` stops the bridge and removes managed ingress, service units, and PID files but keeps your +`.env` and checkout. ### Open it on your phone -The URL is the banner's `tailnet` line (print it again anytime with `scripts/collie-ctl.sh url`). -It resolves for any device on your tailnet — so the phone needs the Tailscale app installed and -connected to the same tailnet as the host. +The URL is the banner's `tailnet`, `netbird`, or `proxy` line (print it again anytime with +`scripts/collie-ctl.sh url`). Tailscale URLs require the phone to be on the same tailnet; NetBird +expose URLs require whatever NetBird auth option you configured. Then install it as an app: **iOS** — Safari → share sheet → *Add to Home Screen*. **Android** — -Chrome → ⋮ menu → *Add to Home screen* (or *Install app*). Installing (and Web Push) needs the -HTTPS origin the default serve mode already provides; over `COLLIE_SERVE_MODE=http` the page works, -but service worker and install silently no-op. +Chrome → ⋮ menu → *Add to Home screen* (or *Install app*). Installing (and Web Push) needs HTTPS: +Tailscale HTTPS mode, NetBird expose, or your reverse proxy. Plain HTTP modes work in-browser, but +service worker and install silently no-op. ### Is it actually working? @@ -250,15 +285,17 @@ see [Troubleshooting](#troubleshooting). ### Surviving reboots -A `systemd --user` service only runs while you have a login session. On a host that should serve +A `systemd --user` service runs while your user manager is available. On a host that should serve Collie unattended, enable lingering once: ```bash loginctl enable-linger $USER ``` -The unit is `enable`d, so with lingering it starts at boot with your user manager; the -`tailscale serve` mapping is persistent (`--bg`) and comes back on its own. +With lingering, the enabled bridge unit and (for NetBird) the enabled `collie-netbird-expose` sidecar +start at boot and restart on failure. Tailscale serve state is persistent (`--bg`). Without a usable +systemd user manager, both the bridge and NetBird sidecar fall back to `nohup` plus PID files: they +do not restart after a crash and do not start at boot, so start them manually after either event. ## Configure @@ -266,19 +303,15 @@ Out of the box Collie runs **open single-user**: anyone on your tailnet who can full control — that's exactly what the two startup WARNINGs are about. Close both in one sitting: ```bash -# in your .env -COLLIE_TRUSTED_USER=you@example.com # your tailnet login — the bridge rejects anyone else +# in your .env (Tailscale track) +COLLIE_TRUSTED_USER=you@example.com # Tailscale serve only; the bridge rejects anyone else COLLIE_PUBLIC_HOSTS=myhost.tail1234.ts.net # exact host(s) you serve on — blocks DNS rebinding ``` Config is a `.env` in the plugin's config dir — find it with `herdr plugin config-dir herdr.collie` (typically `~/.config/herdr/plugins/config/herdr.collie`; without Herdr, `~/.config/collie`). `collie-ctl.sh` resolves this same dir whether you run it -directly or via a Herdr action: - -```bash -cp .env.example "$(herdr plugin config-dir herdr.collie)/.env" -``` +directly or via a Herdr action. The bridge reads `.env` only at startup — after any edit, `scripts/collie-ctl.sh restart`. See [`.env.example`](./.env.example) for the full option list — commonly `COLLIE_PORT`, or @@ -303,11 +336,11 @@ below as `invoke `). The ones you'll actually use: | Action | Control script | Herdr action | | --- | --- | --- | -| **Start** — build if needed, serve, print the URL | `collie-ctl.sh start` | `invoke start` | -| **Stop** — pause the bridge; removes nothing | `collie-ctl.sh stop` | `invoke stop` | +| **Start** — build if needed, publish front door, print the URL | `collie-ctl.sh start` | `invoke start` | +| **Stop** — pause the bridge; managed ingress remains | `collie-ctl.sh stop` | `invoke stop` | | **Restart** | `collie-ctl.sh restart` | `invoke restart` | | **Status** — the *Collie is running* banner + URLs | `collie-ctl.sh status` | `invoke status` | -| **URL** — print the tailnet URL | `collie-ctl.sh url` | `invoke url` | +| **URL** — print the bridge URL | `collie-ctl.sh url` | `invoke url` | | **Version** — the running version (`0.x.y+sha`) | `collie-ctl.sh version` | `invoke version` | | **Update** — `git pull` + rebuild + restart | `collie-ctl.sh update` | `invoke update` | | **Uninstall** — remove the service; keep `.env` + checkout | `collie-ctl.sh uninstall` | `invoke uninstall` | @@ -330,11 +363,11 @@ Collie registers these actions in `herdr-plugin.toml`; invoke any with | `` | Title | What it does | | --- | --- | --- | -| `start` | Start web bridge | Build if needed, start the service, `tailscale serve`, print URL + banner | +| `start` | Start web bridge | Build if needed, start the service, publish the selected front door, print URL + banner | | `stop` | Stop web bridge | Pause the bridge; removes nothing | | `restart` | Restart web bridge | `stop` + `start` | | `status` | Bridge status | The *Collie is running* banner — readiness ✓/⚠, version, URLs | -| `url` | Show bridge URL | Print the tailnet URL | +| `url` | Show bridge URL | Print the selected front-door URL | | `version` | Show version | Print the running version (`0.x.y+sha`) | | `update` | Update plugin | `git pull --ff-only` + rebuild + restart | | `uninstall` | Uninstall web bridge (remove service) | Tear down the service (keeps `.env` + checkout) | @@ -343,15 +376,22 @@ Collie registers these actions in `herdr-plugin.toml`; invoke any with ### Stop or uninstall -Pause the bridge without removing anything (a later `start` brings it right back): +Pause the bridge without removing anything (a later `start` brings it right back). `stop` leaves +managed ingress running and does not stop an operator-run external proxy: ```bash scripts/collie-ctl.sh stop # or: herdr plugin action invoke stop --plugin herdr.collie ``` -To tear the service down completely — stop + disable it, remove the `systemd --user` unit, and remove -Collie's own `tailscale serve` mapping (port-scoped, so other tailnet mappings on the host survive) — -use `uninstall`. It leaves your `.env` and the checkout untouched: +Use `unserve` to remove Collie's managed Tailscale mapping or NetBird expose sidecar without +removing the bridge: + +```bash +scripts/collie-ctl.sh unserve +``` + +Use `uninstall` to stop and disable the bridge, remove Collie's managed ingress and its service +units/PID files, and leave your `.env` and checkout untouched: ```bash scripts/collie-ctl.sh uninstall # or: herdr plugin action invoke uninstall --plugin herdr.collie @@ -381,8 +421,9 @@ repo's pre-commit / pre-push checks. ## Deployment variants The bridge always binds **loopback only**; what changes between deployments is *what sits in front -of it* and *how a request proves who it is*. Three supported shapes — Tailscale by **person** (A), -Tailscale/proxy by **device** (B), or a reverse proxy as the sole front door (C). Pick one. +of it* and *how a request proves who it is*. Four supported shapes — Tailscale by **person** (A), +Tailscale/proxy by **device** (B), a reverse proxy as the sole front door (C), or NetBird expose +with NetBird auth (D). Pick one. ### Variant A — `tailscale serve` + person identity (default) @@ -440,7 +481,7 @@ COLLIE_HOST=127.0.0.1 # keep loopback (default) COLLIE_DEVICE_HEADER=X-Device-Id # the header your proxy injects COLLIE_DEVICE_ALLOWLIST=my-phone,my-laptop # ids allowed to drive agents; others → read-only # COLLIE_ALLOWED_ORIGINS=https://collie.example.com # only if the proxy does NOT forward the public Host -# COLLIE_TRUSTED_USER still composes on top if your ingress also injects Tailscale-User-Login +# Tailscale-only: COLLIE_TRUSTED_USER composes on top if the ingress also injects Tailscale-User-Login ``` Illustrative nginx — the auth layer is yours; the load-bearing lines are the **override** and the @@ -466,9 +507,9 @@ A reverse proxy (Caddy, Nginx, …) is the **sole ingress** — no Tailscale in when the host isn't on a tailnet, or when you already run a TLS-terminating proxy with its own access control (SSO, mTLS, a VPN gateway) and want Collie behind it like any other upstream. -Set `COLLIE_SKIP_SERVE=1` so `collie-ctl.sh start` builds, starts and supervises the bridge but -**never touches `tailscale serve`** — the proxy owns ingress. The bridge still binds loopback only; -your proxy reaches it on `127.0.0.1:$COLLIE_PORT`. +Set `COLLIE_FRONT_DOOR=proxy` (or legacy `COLLIE_SKIP_SERVE=1`) so `collie-ctl.sh start` builds, +starts and supervises the bridge but **never starts a managed ingress** — the proxy owns ingress. The +bridge still binds loopback only; your proxy reaches it on `127.0.0.1:$COLLIE_PORT`. The **four proxy requirements from [Variant B](#variant-b--identity-aware-proxy--per-device-authorisation) apply verbatim** — the proxy @@ -488,7 +529,7 @@ collie.example.com { Required env (`.env`): ```bash -COLLIE_SKIP_SERVE=1 # proxy is ingress; never run tailscale serve +COLLIE_FRONT_DOOR=proxy # proxy is ingress; never run tailscale/netbird serve COLLIE_PUBLIC_HOSTS=collie.example.com # Host allowlist — blocks DNS rebinding COLLIE_ALLOWED_ORIGINS=https://collie.example.com # exact public origin for the same-origin gate COLLIE_DEVICE_HEADER=X-Device-Id # the header your proxy injects… @@ -509,6 +550,56 @@ indefinitely — clients keep running old code with no way to notice. If your pr honor origin headers (Caddy and stock Nginx `proxy_cache` do by default; CDNs often need it enabled explicitly). +### Variant D — `netbird expose` + NetBird auth + +Use this when the host is on NetBird instead of Tailscale, or when you want NetBird's reverse proxy +to publish the loopback bridge. + +#### NetBird prerequisites + +Before starting, verify all of these on the host and in the NetBird account: + +- NetBird CLI **v0.66 or newer**: `netbird version`. +- The NetBird client is logged in and **Connected**: `netbird status`. +- **Peer Expose** is enabled for the account. +- The host peer belongs to a peer group allowed by that account's Peer Expose policy. + +**Configure a stable custom domain in NetBird first.** Do not build the secure recipe around the +generated expose name. Generated names can change when the ephemeral expose session or sidecar is +restarted; they are unsuitable for static `COLLIE_PUBLIC_HOSTS`/`COLLIE_ALLOWED_ORIGINS` values and +for a PWA installed from a stable origin. + +`collie-ctl.sh start` starts the Collie bridge and then publishes it through +`collie-netbird-expose`. With `systemd --user`, that sidecar is enabled and supervised with +`Restart=on-failure`; without systemd it is a `nohup` process with a PID file, with no automatic +restart or boot start. NetBird expose sessions are ephemeral; `unserve` and `uninstall` remove the +sidecar and its public URL. + +Required env (`.env`) for the authenticated, stable-domain recipe: + +```bash +COLLIE_FRONT_DOOR=netbird +COLLIE_NETBIRD_CUSTOM_DOMAIN=collie.example.com # configure this in NetBird before these allowlists +COLLIE_NETBIRD_USER_GROUPS=operators # or PIN/password; this is URL access auth +COLLIE_PUBLIC_HOSTS=collie.example.com # exact stable Host; blocks DNS rebinding +COLLIE_ALLOWED_ORIGINS=https://collie.example.com # exact stable browser origin +# COLLIE_PUBLIC_URL=https://collie.example.com # optional banner override +``` + +> ⚠️ **Do not run NetBird expose without auth.** NetBird says an expose URL is public unless +> protected by PIN, password, or user groups. Collie refuses to start the sidecar unless one auth +> env var is set. `COLLIE_NETBIRD_ALLOW_PUBLIC=1` bypasses the guard only when you explicitly accept +> public read/write access; it is not part of this secure recipe. + +> ⚠️ **`COLLIE_TRUSTED_USER` is Tailscale-only.** It gates on `Tailscale-User-Login`, which only +> `tailscale serve` injects. NetBird expose does not inject Collie's `COLLIE_DEVICE_HEADER` either; +> use NetBird auth as the front-door gate. Anyone who passes NetBird auth gets full Collie access. + +With a configured custom domain, `collie-ctl.sh status` and `url` report that stable origin. A +generated URL is shown only while the expose process/service is live; its log remains in the config +directory for diagnostics, including errors from the launched sidecar. Treat generated names as +temporary troubleshooting URLs, not values for static host/origin allowlists or PWA installation. + ## Web Push (optional) Off unless you opt in: @@ -519,11 +610,10 @@ bunx web-push generate-vapid-keys # set COLLIE_VAPID_PUBLIC / _PRIVATE / _SUBJECT in your .env, then restart ``` -Push needs a **secure context (HTTPS)**, which any HTTPS-terminating front door provides — the -default `tailscale serve` (Tailscale manages the MagicDNS cert; nothing to obtain or renew) or a -[Variant C](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) proxy that terminates TLS. -Plain-HTTP modes (`COLLIE_SERVE_MODE=http`) are **not** a secure context, so push silently won't fire -there — Settings flags it `insecure`. +Push needs a **secure context (HTTPS)**, which any HTTPS-terminating front door provides: Tailscale +HTTPS mode, NetBird expose, or a [Variant C](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) +proxy that terminates TLS. Plain-HTTP modes (`COLLIE_SERVE_MODE=http`) are **not** a secure context, +so push silently won't fire there — Settings flags it `insecure`. Collie pushes when an agent goes **blocked** or **done**, with the agent's message in the body; **tapping it opens Collie at that agent**. Test it without waiting for an agent to block: @@ -545,6 +635,14 @@ Herdr first (`herdr server &`, or just launch the Herdr TUI — it boots the ser `ls ~/.config/herdr/herdr.sock` now exists, then retry the install. `herdr plugin list` is a quick probe: if it throws the same error, the server is down. +**NetBird expose fails, says permission denied, or stays `URL pending`.** If `start` or a Herdr +action reports `netbird not found`, fix the CLI or its `PATH` from that command output; the sidecar +was not launched. Otherwise read `netbird-expose.log` in the plugin config directory. +`scripts/collie-ctl.sh status` shows the current sidecar state and recent log lines; under systemd, +`systemctl --user status collie-netbird-expose` shows supervision state. For generated URLs that +change between sessions, use the stable-domain recipe in +[Variant D](#variant-d--netbird-expose--netbird-auth). + **`start` prints `note: tailscale serve failed`.** The bridge itself is fine (still up on `127.0.0.1`) — only the tailnet ingress didn't come up, and the script prints tailscale's own error right below the note. Usual causes: your user isn't the Tailscale operator @@ -568,14 +666,15 @@ host is online — check `tailscale status` on the host, or ping the host from t app. **Page loads but stays empty; API calls fail `403 cross-origin rejected`.** You're reaching Collie -through an origin the bridge doesn't expect — a custom domain, or a proxy that rewrites `Host`. -Allow the exact public origin with `COLLIE_ALLOWED_ORIGINS` (see [Configure](#configure)), or make -the proxy forward `Host` unchanged (Variant B, rule 4). +through an origin the bridge doesn't expect — a custom domain, NetBird expose, or a proxy that +rewrites `Host`. Allow the exact public origin with `COLLIE_ALLOWED_ORIGINS` (see +[Configure](#configure)), or make the proxy forward `Host` unchanged (Variant B, rule 4). -**Collie is gone after a reboot.** A `systemd --user` unit only runs while you have a session — on a -headless host enable lingering once (`loginctl enable-linger $USER`) and the `collie` unit (already -`enable`d) starts at boot with your user manager. The `tailscale serve` mapping persists on its own -(`--bg`), so lingering is usually the whole fix. +**Collie is gone after a reboot.** With `systemd --user`, enable lingering once +(`loginctl enable-linger $USER`) so the enabled `collie` unit and, for NetBird, the +`collie-netbird-expose` sidecar start at boot. Without a usable systemd user manager, the `nohup` +bridge/sidecar fallback has no boot start or automatic restart; run `start` manually after reboot or +a crash. Tailscale serve state persists on its own (`--bg`). **Phone shows a stale UI after a rebuild.** A PWA's service-worker cache is per-origin, so reaching Collie at two origins (a custom domain *and* the raw `host:8787`) gives you two installs, each @@ -591,9 +690,9 @@ A small Bun process sits between your phone and Herdr — the browser never touc ``` phone (PWA) - │ HTTPS over the tailnet + │ HTTPS over the selected front door ▼ - tailscale serve terminates TLS, injects the identity header + tailscale serve / netbird expose / reverse proxy │ 127.0.0.1:PORT (the bridge binds loopback only) ▼ Collie bridge (Bun) serves the UI + a small JSON API; polls Herdr @@ -603,7 +702,7 @@ A small Bun process sits between your phone and Herdr — the browser never touc ``` Under [Variant C](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) a reverse proxy -replaces the `tailscale serve` box; everything below the front door is identical. +replaces the managed front door; everything below the front door is identical. - **One module touches the socket** (`bridge/herdr-client.ts`); everything else speaks the bridge's HTTP API. - **Polling is still the model** — the bridge polls Herdr (via `session.snapshot`, one RPC per tick) and the browser polls `/api/snapshot`; a long-lived Herdr event stream only pokes the bridge's poll to go faster, it never replaces it. No resync logic. diff --git a/bridge/config.test.ts b/bridge/config.test.ts index 209c610e..2297dc97 100644 --- a/bridge/config.test.ts +++ b/bridge/config.test.ts @@ -24,6 +24,7 @@ const KEYS = [ "COLLIE_STATE_DIR", "COLLIE_MULTI_SESSION", "COLLIE_SKIP_SERVE", + "COLLIE_FRONT_DOOR", "HERDR_SOCKET_PATH", "HERDR_PLUGIN_STATE_DIR", ]; @@ -65,7 +66,8 @@ describe("loadConfig", () => { expect(cfg.deviceAllowlist).toEqual([]); // Multi-session support is on by default. expect(cfg.multiSession).toBe(true); - // tailscale serve is used by default (reverse-proxy bypass is opt-in). + // Tailscale serve is the default front door. + expect(cfg.frontDoor).toBe("tailscale"); expect(cfg.skipServe).toBe(false); }); @@ -87,24 +89,47 @@ describe("loadConfig", () => { expect(loadConfig().multiSession).toBe(true); }); - test("parses COLLIE_SKIP_SERVE as a boolean toggle (default off)", () => { - // Truthy spellings turn it on (reverse-proxy mode; bypass tailscale serve). + test("parses COLLIE_SKIP_SERVE as the legacy proxy-front-door toggle", () => { + // Truthy spellings select proxy mode (bypass tailscale serve). for (const on of ["on", "1", "true", "yes", "ON", " True "]) { process.env.COLLIE_SKIP_SERVE = on; + expect(loadConfig().frontDoor).toBe("proxy"); expect(loadConfig().skipServe).toBe(true); } - // Falsey spellings keep it off (the default tailscale serve path). + // Falsey spellings keep the default tailscale path. for (const off of ["off", "0", "false", "no", "OFF", " False "]) { process.env.COLLIE_SKIP_SERVE = off; + expect(loadConfig().frontDoor).toBe("tailscale"); expect(loadConfig().skipServe).toBe(false); } - // Garbage and empty fall back to the default (off). + // Garbage and empty fall back to the default (tailscale). process.env.COLLIE_SKIP_SERVE = "banana"; + expect(loadConfig().frontDoor).toBe("tailscale"); expect(loadConfig().skipServe).toBe(false); process.env.COLLIE_SKIP_SERVE = ""; + expect(loadConfig().frontDoor).toBe("tailscale"); expect(loadConfig().skipServe).toBe(false); }); + test("parses COLLIE_FRONT_DOOR and treats non-tailscale tracks as skipServe", () => { + process.env.COLLIE_FRONT_DOOR = "netbird"; + expect(loadConfig().frontDoor).toBe("netbird"); + expect(loadConfig().skipServe).toBe(true); + + process.env.COLLIE_FRONT_DOOR = " proxy "; + expect(loadConfig().frontDoor).toBe("proxy"); + expect(loadConfig().skipServe).toBe(true); + + process.env.COLLIE_FRONT_DOOR = "banana"; + expect(loadConfig().frontDoor).toBe("tailscale"); + expect(loadConfig().skipServe).toBe(false); + + process.env.COLLIE_FRONT_DOOR = "netbird"; + process.env.COLLIE_SKIP_SERVE = "1"; + expect(loadConfig().frontDoor).toBe("proxy"); + expect(loadConfig().skipServe).toBe(true); + }); + test("reads the per-device auth header and allowlist", () => { process.env.COLLIE_DEVICE_HEADER = " X-Device-Id "; process.env.COLLIE_DEVICE_ALLOWLIST = " phone , laptop ,"; diff --git a/bridge/config.ts b/bridge/config.ts index 221dbe1d..aff6e7bd 100644 --- a/bridge/config.ts +++ b/bridge/config.ts @@ -2,8 +2,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; // All bridge configuration, resolved once at startup. Env-driven so the systemd unit and the -// plugin launcher can configure it without code changes. Defaults are safe for a single-user, -// tailnet-only deployment. +// plugin launcher can configure it without code changes. Defaults are safe for a single-user +// deployment behind one managed front door. /** * Read an integer env var, falling back to `fallback` (with one warning line) on anything invalid: @@ -54,10 +54,27 @@ function envBool(name: string, fallback: boolean): boolean { return fallback; } +export type FrontDoor = "tailscale" | "netbird" | "proxy"; + +function envFrontDoor(): FrontDoor { + if (envBool("COLLIE_SKIP_SERVE", false)) return "proxy"; + + const raw = process.env.COLLIE_FRONT_DOOR; + if (raw === undefined || raw.trim() === "") return "tailscale"; + + const v = raw.trim().toLowerCase(); + if (v === "tailscale" || v === "netbird" || v === "proxy") return v; + + console.warn( + `[config] COLLIE_FRONT_DOOR="${raw}" is not one of tailscale, netbird, proxy — using default tailscale`, + ); + return "tailscale"; +} + export interface Config { /** Path to Herdr's control socket. A non-Herdr-launched daemon must discover this itself. */ socketPath: string; - /** TCP port the bridge listens on (loopback only). `tailscale serve` proxies to it. */ + /** TCP port the bridge listens on (loopback only). The selected front door proxies to it. */ port: number; /** * Bind host. ALWAYS loopback by default — binding 0.0.0.0 would make the Tailscale identity @@ -86,8 +103,8 @@ export interface Config { * Tailscale identity gate. If set, any request carrying a `Tailscale-User-Login` header * (injected by `tailscale serve`) must match this login — a mismatching tailnet user is * rejected. A request with no such header still passes (direct-loopback callers don't get one), - * so this narrows *which* user is trusted rather than mandating the header. Empty = trust any - * loopback caller (fine when only tailscaled can reach the port). + * so this narrows *which* Tailscale user is trusted rather than mandating the header. Empty = trust + * any loopback caller (fine when only the selected front door can reach the port). */ trustedUser: string; /** @@ -130,11 +147,14 @@ export interface Config { */ multiSession: boolean; /** - * Whether `tailscale serve` is bypassed (COLLIE_SKIP_SERVE=1) because an operator-run reverse - * proxy (Caddy/Nginx) fronts the loopback bridge instead. The bridge itself handles every request - * identically either way — this flag only informs the startup warnings: without `tailscale serve` - * in front, the `Tailscale-User-Login` header is never injected, so {@link trustedUser} is inert - * and per-device auth ({@link deviceHeader}) becomes the way to gate writes (README → Variant C). + * Which ingress the control script is expected to put in front of the loopback bridge. The bridge + * handles requests the same way for all front doors; this only steers startup warnings for config + * that is inert without a matching identity-header injector. + */ + frontDoor: FrontDoor; + /** + * Back-compat alias for "do not run tailscale serve". True for the explicit proxy track and the + * NetBird expose track; old deployments can still set COLLIE_SKIP_SERVE=1 to select proxy mode. */ skipServe: boolean; } @@ -146,6 +166,7 @@ export function loadConfig(): Config { join(homedir(), ".local", "state", "collie"); const submitKeys = envList("COLLIE_SUBMIT_KEYS"); + const frontDoor = envFrontDoor(); return { socketPath: process.env.HERDR_SOCKET_PATH ?? join(homedir(), ".config", "herdr", "herdr.sock"), @@ -166,6 +187,7 @@ export function loadConfig(): Config { vapidSubject: process.env.COLLIE_VAPID_SUBJECT ?? "mailto:admin@example.com", stateDir, multiSession: envBool("COLLIE_MULTI_SESSION", true), - skipServe: envBool("COLLIE_SKIP_SERVE", false), + frontDoor, + skipServe: frontDoor !== "tailscale", }; } diff --git a/bridge/server.test.ts b/bridge/server.test.ts index 329255bc..3508ffbc 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -48,6 +48,7 @@ function cfg(overrides: Partial = {}): Config { vapidSubject: "mailto:admin@example.com", stateDir: "/tmp/state", multiSession: true, + frontDoor: "tailscale", skipServe: false, ...overrides, }; @@ -397,8 +398,10 @@ describe("deviceAuth — per-device authorisation", () => { describe("startupWarnings — security-posture nags", () => { const has = (ws: string[], needle: string) => ws.some((w) => w.includes(needle)); - test("skipServe + trustedUser: warns the identity gate is inert and points at the device header", () => { - const ws = startupWarnings(cfg({ skipServe: true, trustedUser: "me@example.com" })); + test("proxy front door + trustedUser: warns the identity gate is inert and points at the device header", () => { + const ws = startupWarnings( + cfg({ frontDoor: "proxy", skipServe: true, trustedUser: "me@example.com" }), + ); expect(has(ws, "COLLIE_TRUSTED_USER has no effect")).toBe(true); expect(has(ws, "COLLIE_DEVICE_HEADER")).toBe(true); expect(has(ws, "Variant C")).toBe(true); @@ -406,19 +409,36 @@ describe("startupWarnings — security-posture nags", () => { expect(has(ws, "any tailnet device/user")).toBe(false); }); - test("skipServe + empty trustedUser: no empty-trustedUser warning at all", () => { - const ws = startupWarnings(cfg({ skipServe: true, trustedUser: "" })); + test("proxy front door + empty trustedUser: no empty-trustedUser warning at all", () => { + const ws = startupWarnings(cfg({ frontDoor: "proxy", skipServe: true, trustedUser: "" })); expect(has(ws, "COLLIE_TRUSTED_USER")).toBe(false); }); - test("no skipServe + empty trustedUser: the existing Variant-A warning still fires", () => { - const ws = startupWarnings(cfg({ skipServe: false, trustedUser: "" })); + test("netbird front door + trustedUser: warns the identity gate is inert and names NetBird auth", () => { + const ws = startupWarnings( + cfg({ frontDoor: "netbird", skipServe: true, trustedUser: "me@example.com" }), + ); + expect(has(ws, "COLLIE_TRUSTED_USER has no effect")).toBe(true); + expect(has(ws, "COLLIE_FRONT_DOOR=netbird")).toBe(true); + expect(has(ws, "NetBird expose authentication")).toBe(true); + expect(has(ws, "Variant D")).toBe(true); + }); + + test("netbird front door + empty trustedUser: no Variant-A warning", () => { + const ws = startupWarnings(cfg({ frontDoor: "netbird", skipServe: true, trustedUser: "" })); + expect(has(ws, "COLLIE_TRUSTED_USER")).toBe(false); + }); + + test("tailscale front door + empty trustedUser: the existing Variant-A warning still fires", () => { + const ws = startupWarnings(cfg({ frontDoor: "tailscale", skipServe: false, trustedUser: "" })); expect(has(ws, "COLLIE_TRUSTED_USER is empty")).toBe(true); expect(has(ws, "Variant A")).toBe(true); }); - test("no skipServe + trustedUser set: no identity warning (correctly configured)", () => { - const ws = startupWarnings(cfg({ skipServe: false, trustedUser: "me@example.com" })); + test("tailscale front door + trustedUser set: no identity warning (correctly configured)", () => { + const ws = startupWarnings( + cfg({ frontDoor: "tailscale", skipServe: false, trustedUser: "me@example.com" }), + ); expect(has(ws, "COLLIE_TRUSTED_USER")).toBe(false); }); diff --git a/bridge/server.ts b/bridge/server.ts index 1bc89c6b..5aa7e630 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -297,10 +297,9 @@ export function startServer(opts: { * The security-posture warnings emitted once at startup, as plain strings (each already prefixed * `[bridge] WARNING:`). Pure + exported so the exact set that fires for a given {@link Config} is * unit-testable without standing up Bun.serve; the bootstrap in {@link startServer} just logs each - * via `console.warn`. The identity-gate advice forks on {@link Config.skipServe}: behind a reverse - * proxy the `Tailscale-User-Login` header is never injected, so trustedUser is inert (nag toward - * COLLIE_DEVICE_HEADER instead), whereas under `tailscale serve` an empty trustedUser is the open - * door Variant A closes. + * via `console.warn`. The identity-gate advice forks on {@link Config.frontDoor}: only + * `tailscale serve` injects `Tailscale-User-Login`; proxy and NetBird tracks must put their own + * access control in front of the loopback bridge. */ export function startupWarnings(cfg: Config): string[] { const warnings: string[] = []; @@ -314,22 +313,29 @@ export function startupWarnings(cfg: Config): string[] { `[bridge] WARNING: COLLIE_DEVICE_HEADER set but COLLIE_DEVICE_ALLOWLIST is empty — every device is read-only`, ); } - if (cfg.skipServe) { - // Reverse-proxy mode: no tailscale serve injects Tailscale-User-Login, so checkAccess never has - // an identity to enforce — trustedUser is dead config. Only nag when it's set (a likely mistake). - if (cfg.trustedUser) { + if (cfg.frontDoor === "tailscale") { + if (!cfg.trustedUser) { warnings.push( - `[bridge] WARNING: COLLIE_TRUSTED_USER has no effect under COLLIE_SKIP_SERVE=1 — without tailscale serve in front, the Tailscale-User-Login header is never injected. Use COLLIE_DEVICE_HEADER for per-device auth (see README → Variant C).`, + `[bridge] WARNING: COLLIE_TRUSTED_USER is empty — any tailnet device/user that reaches the bridge gets full write access. Set it to your tailnet login (see README → Variant A).`, ); } - } else if (!cfg.trustedUser) { + } else if (cfg.trustedUser) { + const frontDoor = + cfg.frontDoor === "netbird" + ? "COLLIE_FRONT_DOOR=netbird" + : "COLLIE_SKIP_SERVE=1 / COLLIE_FRONT_DOOR=proxy"; + const gate = + cfg.frontDoor === "netbird" + ? "NetBird expose authentication" + : "COLLIE_DEVICE_HEADER for per-device auth"; + const variant = cfg.frontDoor === "netbird" ? "Variant D" : "Variant C"; warnings.push( - `[bridge] WARNING: COLLIE_TRUSTED_USER is empty — any tailnet device/user that reaches the bridge gets full write access. Set it to your tailnet login (see README → Variant A).`, + `[bridge] WARNING: COLLIE_TRUSTED_USER has no effect under ${frontDoor} — without tailscale serve in front, the Tailscale-User-Login header is never injected. Use ${gate} (see README → ${variant}).`, ); } if (cfg.publicHosts.length === 0) { warnings.push( - `[bridge] WARNING: COLLIE_PUBLIC_HOSTS is empty — Host-header validation is OFF (DNS rebinding not blocked). Set it to your MagicDNS name, especially under plain-HTTP serve mode or behind a reverse proxy.`, + `[bridge] WARNING: COLLIE_PUBLIC_HOSTS is empty — Host-header validation is OFF (DNS rebinding not blocked). Set it to your public bridge hostname, especially under plain-HTTP serve mode, NetBird expose, or behind a reverse proxy.`, ); } return warnings; diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 77babf44..cffa9d20 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -36,9 +36,9 @@ title = "Restart web bridge" contexts = ["workspace"] command = ["bash", "scripts/collie-ctl.sh", "restart"] -# Full teardown: stops + disables the service, removes the systemd --user unit, and resets -# tailscale serve. Leaves the .env and the checkout (so `start` can bring it right back). See -# scripts/collie-ctl.sh → cmd_uninstall. +# Full teardown: stops + disables the service, removes the systemd --user unit, and removes Collie's +# managed ingress (Tailscale serve mapping or NetBird expose sidecar). Leaves the .env and the +# checkout (so `start` can bring it right back). See scripts/collie-ctl.sh → cmd_uninstall. [[actions]] id = "uninstall" title = "Uninstall web bridge (remove service)" diff --git a/package.json b/package.json index e481c6a8..47b7d012 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build": "bun run typecheck && cd web && bun install && bun run typecheck && bun run build", "build:web": "cd web && bun run build", "web:dev": "cd web && bun run dev", - "test": "bun test ./bridge", + "test": "bun test ./bridge && bash scripts/collie-ctl.test.sh", + "test:ctl": "bash scripts/collie-ctl.test.sh", "typecheck": "bunx tsc --noEmit" }, "devDependencies": { diff --git a/scripts/collie-ctl.sh b/scripts/collie-ctl.sh index 2627cd16..c39a073e 100755 --- a/scripts/collie-ctl.sh +++ b/scripts/collie-ctl.sh @@ -7,6 +7,8 @@ set -euo pipefail PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" UNIT="collie" UNIT_FILE="${HOME}/.config/systemd/user/${UNIT}.service" +NETBIRD_EXPOSE_UNIT="collie-netbird-expose" +NETBIRD_EXPOSE_UNIT_FILE="${HOME}/.config/systemd/user/${NETBIRD_EXPOSE_UNIT}.service" PLUGIN_ID="herdr.collie" # Resolve the plugin config dir (where .env lives) the SAME way no matter how we're launched. @@ -38,10 +40,33 @@ if [ -f "${CONFIG_DIR}/.env" ]; then set -a; . "${CONFIG_DIR}/.env"; set +a; fi PORT="${COLLIE_PORT:-8787}" SOCKET="${HERDR_SOCKET_PATH:-${HOME}/.config/herdr/herdr.sock}" -# How tailscale serve exposes the bridge: "https" (default, needs a cert from the control -# server) or "http" (plain HTTP over the tailnet — use this on Headscale / .internal domains). +# Which ingress fronts the loopback bridge: +# tailscale (default): durable tailnet-only `tailscale serve` +# netbird: supervised `netbird expose` sidecar (public URL; require NetBird auth) +# proxy: no managed ingress; an operator-run reverse proxy owns the front door +FRONT_DOOR="$(printf '%s' "${COLLIE_FRONT_DOOR:-tailscale}" | tr '[:upper:]' '[:lower:]')" +FRONT_DOOR="${FRONT_DOOR//[[:space:]]/}" +if [ "${COLLIE_SKIP_SERVE:-}" = "1" ]; then FRONT_DOOR="proxy"; fi +# Tailscale-only mode: "https" (default, needs a cert from the control server) or "http" +# (plain HTTP over the tailnet — use this on Headscale / .internal domains). SERVE_MODE="${COLLIE_SERVE_MODE:-https}" +NETBIRD_EXPOSE_LOG="${CONFIG_DIR}/netbird-expose.log" +NETBIRD_EXPOSE_PID="${CONFIG_DIR}/netbird-expose.pid" +NETBIRD_EXPOSE_IDENTITY="${CONFIG_DIR}/netbird-expose.identity" +NETBIRD_EXPOSE_RUNNER="${CONFIG_DIR}/netbird-expose.sh" +TAILSCALE_HANDLER_FILE="${CONFIG_DIR}/tailscale-managed-handler" BUN="$(command -v bun || true)" +resolve_netbird_bin() { + local path + path="$(type -P netbird || true)" + [ -n "$path" ] || return 0 + case "$path" in + /*) printf '%s\n' "$path" ;; + *) printf '%s/%s\n' "$(cd "$(dirname "$path")" && pwd -P)" "$(basename "$path")" ;; + esac +} +NETBIRD_BIN="$(resolve_netbird_bin)" + WEB_DIST="${PLUGIN_ROOT}/web/dist/index.html" have_systemd() { command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; } @@ -92,12 +117,79 @@ self_dnsname() { "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).Self.DNSName.replace(/\.\$/,''))}catch{}})" } -bridge_url() { +tailscale_bridge_url() { local name; name="$(self_dnsname)" if [ -z "$name" ]; then echo "http://127.0.0.1:${PORT} (Tailscale name unavailable)"; return; fi if [ "$SERVE_MODE" = "http" ]; then echo "http://${name}:${PORT}"; else echo "https://${name}"; fi } +netbird_process_running() { + local pid="$1" state + kill -0 "$pid" 2>/dev/null || return 1 + state="$(ps -o stat= -p "$pid" 2>/dev/null || true)" + [ -n "$state" ] || return 1 + case "$state" in + Z*) return 1 ;; + *) return 0 ;; + esac +} +netbird_process_identity() { + local pid="$1" stat rest started + if [ -r "/proc/${pid}/stat" ]; then + stat="$(cat "/proc/${pid}/stat" 2>/dev/null)" || return 1 + rest="${stat##*) }" + set -- $rest + [ "$#" -ge 20 ] || return 1 + printf 'proc:%s\n' "${20}" + return 0 + fi + started="$(ps -o lstart= -p "$pid" 2>/dev/null || true)" + [ -n "$started" ] || return 1 + printf 'ps:%s\n' "$started" +} + + +netbird_expose_running() { + if have_systemd; then + systemctl --user is-active "$NETBIRD_EXPOSE_UNIT" >/dev/null 2>&1 + return + fi + local pid expected_identity current_identity + [ -f "$NETBIRD_EXPOSE_PID" ] && [ -f "$NETBIRD_EXPOSE_IDENTITY" ] || return 1 + pid="$(cat "$NETBIRD_EXPOSE_PID" 2>/dev/null || true)" + case "$pid" in + ''|0|*[!0-9]*) return 1 ;; + esac + netbird_process_running "$pid" || return 1 + expected_identity="$(cat "$NETBIRD_EXPOSE_IDENTITY" 2>/dev/null || true)" + current_identity="$(netbird_process_identity "$pid" 2>/dev/null || true)" + [ -n "$expected_identity" ] && [ "$current_identity" = "$expected_identity" ] +} + +netbird_url_from_log() { + netbird_expose_running || return 0 + [ -f "$NETBIRD_EXPOSE_LOG" ] || return 0 + sed -n 's/^[[:space:]]*URL:[[:space:]]*//p' "$NETBIRD_EXPOSE_LOG" | tail -1 +} + +netbird_bridge_url() { + if [ -n "${COLLIE_PUBLIC_URL:-}" ]; then echo "$COLLIE_PUBLIC_URL"; return; fi + if [ -n "${COLLIE_NETBIRD_CUSTOM_DOMAIN:-}" ]; then echo "https://${COLLIE_NETBIRD_CUSTOM_DOMAIN}"; return; fi + local url; url="$(netbird_url_from_log)" + [ -n "$url" ] && echo "$url" || echo "NetBird URL unavailable yet (check 'collie-ctl.sh status')" +} + +bridge_url() { + case "$FRONT_DOOR" in + tailscale) tailscale_bridge_url ;; + netbird) netbird_bridge_url ;; + proxy) + [ -n "${COLLIE_PUBLIC_URL:-}" ] && echo "$COLLIE_PUBLIC_URL" || echo "http://127.0.0.1:${PORT} (set COLLIE_PUBLIC_URL to your proxy URL)" + ;; + *) echo "http://127.0.0.1:${PORT} (unknown COLLIE_FRONT_DOOR=${FRONT_DOOR})" ;; + esac +} + # The version Collie is actually serving — read from the built bundle's stamp # (web/dist/build-info.json, the same id the PWA footer and /api/config report), e.g. "0.16.0+3441656". # Falls back to the manifest version (tagged "web not built") when web/dist doesn't exist yet. This is @@ -149,15 +241,24 @@ print_status_banner() { fi echo " service ${svc}" echo " local http://127.0.0.1:${PORT}" - if [ "${COLLIE_SKIP_SERVE:-}" = "1" ]; then - if [ -n "${COLLIE_PUBLIC_URL:-}" ]; then - echo " proxy ${COLLIE_PUBLIC_URL}" - else - echo " proxy (COLLIE_SKIP_SERVE=1 — set COLLIE_PUBLIC_URL to your reverse-proxy URL)" - fi - else - echo " tailnet $(bridge_url)" - fi + case "$FRONT_DOOR" in + proxy) + if [ -n "${COLLIE_PUBLIC_URL:-}" ]; then + echo " proxy ${COLLIE_PUBLIC_URL}" + else + echo " proxy (COLLIE_FRONT_DOOR=proxy — set COLLIE_PUBLIC_URL to your reverse-proxy URL)" + fi + ;; + netbird) + echo " netbird $(netbird_bridge_url)" + ;; + tailscale) + echo " tailnet $(tailscale_bridge_url)" + ;; + *) + echo " ingress unknown COLLIE_FRONT_DOOR=${FRONT_DOOR}" + ;; + esac echo } @@ -184,6 +285,7 @@ NoNewPrivileges=yes PrivateTmp=yes Environment=HERDR_SOCKET_PATH=${SOCKET} Environment=COLLIE_PORT=${PORT} +Environment=COLLIE_FRONT_DOOR=${FRONT_DOOR} Environment=HERDR_PLUGIN_CONFIG_DIR=${CONFIG_DIR} EnvironmentFile=-${CONFIG_DIR}/.env @@ -203,8 +305,8 @@ cmd_start() { # Fallback: background process with a pidfile (e.g. macOS without lingering systemd). mkdir -p "$CONFIG_DIR" [ -n "$BUN" ] || { echo "error: bun not found" >&2; exit 1; } - HERDR_SOCKET_PATH="$SOCKET" COLLIE_PORT="$PORT" HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" \ - nohup "$BUN" run "${PLUGIN_ROOT}/bridge/index.ts" >>"${CONFIG_DIR}/collie.log" 2>&1 & + HERDR_SOCKET_PATH="$SOCKET" COLLIE_PORT="$PORT" COLLIE_FRONT_DOOR="$FRONT_DOOR" \ + HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" nohup "$BUN" run "${PLUGIN_ROOT}/bridge/index.ts" >>"${CONFIG_DIR}/collie.log" 2>&1 & echo $! > "${CONFIG_DIR}/collie.pid" echo "bridge started (pid $(cat "${CONFIG_DIR}/collie.pid"), no systemd)" fi @@ -225,20 +327,20 @@ cmd_stop() { cmd_restart() { cmd_stop; cmd_start; } # Tear the service down completely (the inverse of `start`): stop + disable it, remove the -# systemd --user unit, remove Collie's tailscale serve mapping, and drop the pidfile. Deliberately leaves your +# systemd --user unit, remove Collie's managed ingress, and drop the pidfile. Deliberately leaves your # config (${CONFIG_DIR}/.env) and the on-disk checkout in place — `uninstall` removes only what # `start` created. To remove the plugin registration too, run `herdr plugin uninstall herdr.collie` # (or, for a linked clone, just delete the checkout). cmd_uninstall() { cmd_stop cmd_unserve + rm -f "$UNIT_FILE" "$NETBIRD_EXPOSE_UNIT_FILE" if have_systemd; then - rm -f "$UNIT_FILE" systemctl --user daemon-reload 2>/dev/null || true - systemctl --user reset-failed "$UNIT" 2>/dev/null || true + systemctl --user reset-failed "$UNIT" "$NETBIRD_EXPOSE_UNIT" 2>/dev/null || true fi rm -f "${CONFIG_DIR}/collie.pid" - echo "✓ uninstalled: service stopped & disabled, systemd unit removed, Collie's tailscale serve mapping removed" + echo "✓ uninstalled: service stopped & disabled, systemd unit removed, Collie's managed ingress removed" echo " kept: ${CONFIG_DIR}/.env and the checkout — delete those to remove every trace" } @@ -277,54 +379,548 @@ cmd_apply_update() { echo "✓ update complete" } +write_netbird_expose_runner() { + mkdir -p "$CONFIG_DIR" + local netbird_bin_literal + printf -v netbird_bin_literal '%q' "$NETBIRD_BIN" + cat > "$NETBIRD_EXPOSE_RUNNER" <&2 + exit 2 +fi + +args=(expose "\$PORT" --with-name-prefix "\${COLLIE_NETBIRD_NAME_PREFIX:-collie}") +[ -n "\${COLLIE_NETBIRD_CUSTOM_DOMAIN:-}" ] && args+=(--with-custom-domain "\$COLLIE_NETBIRD_CUSTOM_DOMAIN") +[ -n "\${COLLIE_NETBIRD_PIN:-}" ] && args+=(--with-pin "\$COLLIE_NETBIRD_PIN") +[ -n "\${COLLIE_NETBIRD_PASSWORD:-}" ] && args+=(--with-password "\$COLLIE_NETBIRD_PASSWORD") +[ -n "\${COLLIE_NETBIRD_USER_GROUPS:-}" ] && args+=(--with-user-groups "\$COLLIE_NETBIRD_USER_GROUPS") + +exec "\$NETBIRD_BIN" "\${args[@]}" +EOF + chmod 700 "$NETBIRD_EXPOSE_RUNNER" +} + +write_netbird_expose_unit() { + write_netbird_expose_runner + mkdir -p "$(dirname "$NETBIRD_EXPOSE_UNIT_FILE")" + cat > "$NETBIRD_EXPOSE_UNIT_FILE" < 127.0.0.1:${PORT}" + return + fi + if [ -f "$NETBIRD_EXPOSE_LOG" ] && grep -qi '^error:' "$NETBIRD_EXPOSE_LOG"; then + echo "note: netbird expose failed:" + cat "$NETBIRD_EXPOSE_LOG" + return 1 + fi + if ! netbird_expose_running; then + echo "note: netbird expose exited before publishing a URL:" + cat "$NETBIRD_EXPOSE_LOG" 2>/dev/null || true + return 1 + fi + sleep 0.2 + done + echo "note: netbird expose did not publish a URL before the startup timeout" >&2 + return 1 +} + +discard_netbird_child() { + local pid="$1" stopped=0 i + kill "$pid" 2>/dev/null || true + for i in $(seq 1 25); do + if ! netbird_process_running "$pid"; then + stopped=1 + break + fi + sleep 0.1 + done + if [ "$stopped" -ne 1 ]; then + kill -KILL "$pid" 2>/dev/null || true + for i in $(seq 1 25); do + if ! netbird_process_running "$pid"; then + stopped=1 + break + fi + sleep 0.1 + done + fi + if [ "$stopped" -ne 1 ]; then + echo "error: spawned NetBird expose process ${pid} could not be stopped; retained partial state" >&2 + return 1 + fi + wait "$pid" 2>/dev/null || true + if ! rm -f "$NETBIRD_EXPOSE_PID" "$NETBIRD_EXPOSE_IDENTITY"; then + echo "error: could not remove partial NetBird expose state" >&2 + return 1 + fi +} + +cmd_netbird_serve() { + local pid identity + # Stop the old sidecar before checking for a replacement binary, so a missing CLI cannot leave + # stale credentials and a stale public URL active. + stop_netbird_expose || return 1 + if [ -z "$NETBIRD_BIN" ] || [ ! -x "$NETBIRD_BIN" ]; then + echo "error: netbird not found; cannot start NetBird expose" >&2 + return 1 + fi + : > "$NETBIRD_EXPOSE_LOG" + if have_systemd; then + write_netbird_expose_unit + if systemctl --user enable "$NETBIRD_EXPOSE_UNIT" >/dev/null && systemctl --user restart "$NETBIRD_EXPOSE_UNIT"; then + if ! wait_netbird_expose; then + if ! stop_netbird_expose; then + echo "error: NetBird expose failed and cleanup could not confirm it stopped" >&2 + fi + return 1 + fi + else + echo "note: netbird expose service failed to start:" + cat "$NETBIRD_EXPOSE_LOG" 2>/dev/null || true + if ! stop_netbird_expose; then + echo "error: failed NetBird systemd start left teardown incomplete" >&2 + fi + return 1 + fi + else + write_netbird_expose_runner + nohup "$NETBIRD_EXPOSE_RUNNER" "$CONFIG_DIR" >>"$NETBIRD_EXPOSE_LOG" 2>&1 & + pid=$! + if ! printf '%s\n' "$pid" > "$NETBIRD_EXPOSE_PID"; then + discard_netbird_child "$pid" || true + echo "error: could not record NetBird expose PID" >&2 + return 1 + fi + if ! identity="$(netbird_process_identity "$pid")"; then + discard_netbird_child "$pid" || true + echo "error: could not record NetBird expose process identity" >&2 + return 1 + fi + if ! printf '%s\n' "$identity" > "$NETBIRD_EXPOSE_IDENTITY"; then + discard_netbird_child "$pid" || true + echo "error: could not persist NetBird expose process identity" >&2 + return 1 + fi + if ! wait_netbird_expose; then + if ! stop_netbird_expose; then + echo "error: NetBird expose failed and cleanup could not confirm it stopped" >&2 + fi + return 1 + fi + fi +} + +stop_netbird_expose() { + local failed=0 pid="" stopped=0 i expected_identity="" current_identity="" + local active_state="" enabled_state="" + if have_systemd; then + systemctl --user disable --now "$NETBIRD_EXPOSE_UNIT" >/dev/null 2>&1 || true + active_state="$(systemctl --user is-active "$NETBIRD_EXPOSE_UNIT" 2>/dev/null || true)" + case "$active_state" in + inactive|failed|unknown) ;; + active|activating|reloading|deactivating) + echo "error: NetBird expose unit is still ${active_state}" >&2 + failed=1 + ;; + *) + echo "error: could not confirm NetBird expose unit is inactive" >&2 + failed=1 + ;; + esac + enabled_state="$(systemctl --user is-enabled "$NETBIRD_EXPOSE_UNIT" 2>/dev/null || true)" + case "$enabled_state" in + disabled|masked|not-found) ;; + enabled|enabled-runtime|static|indirect|generated|transient|linked|linked-runtime|alias) + echo "error: NetBird expose unit is not disabled (${enabled_state})" >&2 + failed=1 + ;; + *) + echo "error: could not confirm NetBird expose unit is disabled" >&2 + failed=1 + ;; + esac + elif [ -f "$NETBIRD_EXPOSE_UNIT_FILE" ]; then + echo "error: NetBird expose unit exists but the systemd user manager is inaccessible" >&2 + failed=1 + fi + if [ -f "$NETBIRD_EXPOSE_PID" ]; then + pid="$(cat "$NETBIRD_EXPOSE_PID" 2>/dev/null || true)" + case "$pid" in + ''|0|*[!0-9]*) + echo "error: invalid NetBird expose PID state; retained ${NETBIRD_EXPOSE_PID}" >&2 + failed=1 + ;; + *) + if ! netbird_process_running "$pid"; then + stopped=1 + elif [ ! -f "$NETBIRD_EXPOSE_IDENTITY" ]; then + echo "error: missing identity for live NetBird expose PID ${pid}; refusing to signal it" >&2 + failed=1 + else + expected_identity="$(cat "$NETBIRD_EXPOSE_IDENTITY" 2>/dev/null || true)" + current_identity="$(netbird_process_identity "$pid" 2>/dev/null || true)" + if [ -z "$expected_identity" ] || [ "$current_identity" != "$expected_identity" ]; then + echo "error: NetBird expose PID ${pid} identity mismatch; refusing to signal it" >&2 + failed=1 + elif ! kill "$pid" 2>/dev/null; then + if netbird_process_running "$pid"; then + echo "error: failed to stop NetBird expose process ${pid}; retained PID state" >&2 + failed=1 + else + stopped=1 + fi + else + for i in $(seq 1 25); do + if ! netbird_process_running "$pid"; then + stopped=1 + break + fi + sleep 0.1 + done + if [ "$stopped" -ne 1 ]; then + echo "error: NetBird expose process ${pid} did not stop; retained PID state" >&2 + failed=1 + fi + fi + fi + if [ "$stopped" -eq 1 ] && ! rm -f "$NETBIRD_EXPOSE_PID" "$NETBIRD_EXPOSE_IDENTITY"; then + echo "error: NetBird expose stopped but PID/identity state could not be removed" >&2 + failed=1 + fi + ;; + esac + elif [ -f "$NETBIRD_EXPOSE_IDENTITY" ]; then + echo "error: NetBird expose identity exists without PID state; retained identity for investigation" >&2 + failed=1 + fi + if [ "$failed" -ne 0 ]; then + return 1 + fi + echo "netbird expose: stopped Collie's expose session" +} + + +remove_tailscale_handler() { + local description="$1" output + shift + if output="$(tailscale serve "$@" off 2>&1)"; then + return 0 + fi + case "$output" in + *"handler does not exist"*) return 0 ;; + esac + [ -z "$output" ] || printf '%s\n' "$output" >&2 + echo "error: failed to remove Collie's ${description} mapping" >&2 + return 1 +} + +tailscale_root_fingerprint() { + local host_port="$1" port="$2" status_json result + [ -n "$BUN" ] || return 1 + status_json="$(tailscale serve status --json 2>/dev/null)" || return 1 + result="$( + printf '%s' "$status_json" | + COLLIE_SERVE_HOST_PORT="$host_port" COLLIE_SERVE_PORT="$port" "$BUN" -e ' + let data = ""; + process.stdin.on("data", chunk => data += chunk).on("end", () => { + try { + const config = JSON.parse(data || "{}"); + const hostPort = process.env.COLLIE_SERVE_HOST_PORT; + const port = process.env.COLLIE_SERVE_PORT; + const handlers = config?.Web?.[hostPort]?.Handlers ?? {}; + if (!Object.prototype.hasOwnProperty.call(handlers, "/")) { + process.stdout.write("absent"); + return; + } + const listener = config?.TCP?.[port]; + const protocol = listener?.HTTP === true ? "http" : + listener?.HTTPS === true ? "https" : "other"; + const proxy = handlers["/"]?.Proxy; + process.stdout.write(typeof proxy === "string" && proxy ? + `${protocol}|proxy:${proxy}` : `${protocol}|other`); + } catch { + process.exitCode = 2; + } + }); + ' + )" || return 1 + printf '%s\n' "$result" +} + +stop_tailscale_serve() { + local managed_state="" managed_handler="" managed_mode="" managed_port="" + local managed_host_port="" managed_proxy="" extra="" current_fingerprint="" + if [ -f "$TAILSCALE_HANDLER_FILE" ]; then + managed_state="$(cat "$TAILSCALE_HANDLER_FILE" 2>/dev/null || true)" + IFS='|' read -r managed_handler managed_host_port managed_proxy extra <<< "$managed_state" + case "$managed_handler" in + http:*) + managed_mode="http" + managed_port="${managed_handler#http:}" + case "$managed_port" in + ''|*[!0-9]*) managed_mode="" ;; + esac + ;; + https:443) + managed_mode="https" + managed_port="443" + ;; + esac + if [ -z "$managed_mode" ] || [ -z "$managed_host_port" ] || [ -z "$managed_proxy" ] || [ -n "$extra" ]; then + echo "error: invalid managed Tailscale handler state: ${managed_state}" >&2 + return 1 + fi + case "$managed_host_port" in + *":${managed_port}") ;; + *) + echo "error: managed Tailscale HostPort does not match its listener: ${managed_state}" >&2 + return 1 + ;; + esac + case "$managed_proxy" in + http://127.0.0.1:[0-9]*) ;; + *) + echo "error: invalid managed Tailscale proxy target: ${managed_state}" >&2 + return 1 + ;; + esac + else + echo "tailscale serve: no Collie-managed mapping recorded" + return 0 + fi + if ! command -v tailscale >/dev/null; then + echo "error: tailscale not found; retained the managed ${managed_handler} state for retry" >&2 + return 1 + fi + if ! current_fingerprint="$(tailscale_root_fingerprint "$managed_host_port" "$managed_port")"; then + echo "error: cannot inspect the managed Tailscale root; retained ownership state" >&2 + return 1 + fi + if [ "$current_fingerprint" = "absent" ]; then + if ! rm -f "$TAILSCALE_HANDLER_FILE"; then + echo "error: managed Tailscale root is absent but ownership state could not be removed" >&2 + return 1 + fi + echo "tailscale serve: managed root is already absent; cleared stale ownership state" + return 0 + fi + if [ "$current_fingerprint" != "${managed_mode}|proxy:${managed_proxy}" ]; then + echo "error: managed Tailscale root was replaced; refusing to remove the current handler" >&2 + return 1 + fi + if [ "$managed_mode" = "http" ]; then + remove_tailscale_handler "HTTP :${managed_port} root mount" --http="$managed_port" --set-path=/ || { + echo "error: managed ingress cleanup incomplete; retained ${TAILSCALE_HANDLER_FILE} for retry" >&2 + return 1 + } + else + remove_tailscale_handler "HTTPS :443 root mount" --https=443 --set-path=/ || { + echo "error: managed ingress cleanup incomplete; retained ${TAILSCALE_HANDLER_FILE} for retry" >&2 + return 1 + } + fi + if ! rm -f "$TAILSCALE_HANDLER_FILE"; then + echo "error: Tailscale root was removed but ownership state could not be removed" >&2 + return 1 + fi + echo "tailscale serve: removed Collie's managed ${managed_handler} mapping" +} + +ensure_tailscale_root_available() { + local port="$1" protocol="$2" status_json result + [ -n "$BUN" ] || { + echo "error: bun is required to inspect Tailscale serve ownership before publishing" >&2 + return 1 + } + if ! status_json="$(tailscale serve status --json 2>/dev/null)"; then + echo "error: cannot inspect Tailscale serve status; refusing to overwrite the root mount on :${port}" >&2 + return 1 + fi + if ! result="$( + printf '%s' "$status_json" | + COLLIE_SERVE_PORT="$port" COLLIE_SERVE_PROTOCOL="$protocol" "$BUN" -e ' + let data = ""; + process.stdin.on("data", chunk => data += chunk).on("end", () => { + try { + const config = JSON.parse(data || "{}"); + const port = process.env.COLLIE_SERVE_PORT; + const protocol = process.env.COLLIE_SERVE_PROTOCOL; + const hasRoot = serveConfig => + Object.entries(serveConfig?.Web ?? {}).some(([hostPort, server]) => { + const match = hostPort.match(/:(\d+)$/); + const handlers = server?.Handlers ?? {}; + return match?.[1] === port && Object.prototype.hasOwnProperty.call(handlers, "/"); + }) || + Object.values(serveConfig?.Foreground ?? {}).some(hasRoot); + const hasProtocolMismatch = serveConfig => { + const listener = serveConfig?.TCP?.[port]; + const mismatch = listener !== undefined && + (protocol === "http" ? listener?.HTTP !== true : listener?.HTTPS !== true); + return mismatch || + Object.values(serveConfig?.Foreground ?? {}).some(hasProtocolMismatch); + }; + if (hasProtocolMismatch(config)) { + process.stdout.write("protocol-mismatch"); + return; + } + const occupied = hasRoot(config); + process.stdout.write(occupied ? "occupied" : "free"); + } catch { + process.exitCode = 2; + } + }); + ' + )"; then + echo "error: invalid Tailscale serve status; refusing to overwrite the root mount on :${port}" >&2 + return 1 + fi + if [ "$result" = "protocol-mismatch" ]; then + echo "error: Tailscale serve :${port} already uses the opposite listener protocol" >&2 + return 1 + fi + if [ "$result" = "occupied" ]; then + echo "error: Tailscale serve already has an unowned root mount on :${port}; refusing to overwrite it" >&2 + return 1 + fi +} + cmd_serve() { - if [ "${COLLIE_SKIP_SERVE:-}" = "1" ]; then - echo "tailscale serve skipped (COLLIE_SKIP_SERVE=1) — bridge is on 127.0.0.1:${PORT} only" - return + local cleanup_failed=0 tailscale_host="" expected_proxy="" + case "$FRONT_DOOR" in + proxy) + stop_tailscale_serve || cleanup_failed=1 + stop_netbird_expose || cleanup_failed=1 + [ "$cleanup_failed" -eq 0 ] || return 1 + echo "managed serve skipped (COLLIE_FRONT_DOOR=proxy) — bridge is on 127.0.0.1:${PORT} only" + return + ;; + netbird) + stop_tailscale_serve || return 1 + cmd_netbird_serve + return + ;; + tailscale) + stop_netbird_expose || return 1 + stop_tailscale_serve || return 1 + ;; + *) + stop_tailscale_serve >/dev/null 2>&1 || true + stop_netbird_expose >/dev/null 2>&1 || true + echo "error: unknown COLLIE_FRONT_DOOR=${FRONT_DOOR} (expected tailscale, netbird, or proxy)" >&2 + return 1 + ;; + esac + command -v tailscale >/dev/null || { + echo "error: tailscale not found; cannot publish the selected Tailscale front door" >&2 + return 1 + } + tailscale_host="$(self_dnsname)" + if [ -z "$tailscale_host" ]; then + echo "error: cannot determine Tailscale hostname; refusing to publish an untrackable root mount" >&2 + return 1 fi - command -v tailscale >/dev/null || { echo "note: tailscale not found; bridge is on 127.0.0.1:${PORT} only"; return; } + expected_proxy="http://127.0.0.1:${PORT}" local out="${CONFIG_DIR}/serve.out" if [ "$SERVE_MODE" = "http" ]; then - if tailscale serve --bg --http="$PORT" "$PORT" >"$out" 2>&1; then + ensure_tailscale_root_available "$PORT" http || return 1 + printf '%s|%s|%s\n' "http:${PORT}" "${tailscale_host}:${PORT}" "$expected_proxy" > "$TAILSCALE_HANDLER_FILE" + if tailscale serve --bg --http="$PORT" --set-path=/ "$PORT" >"$out" 2>&1; then echo "tailscale serve (http) → tailnet :${PORT} -> 127.0.0.1:${PORT}" else - echo "note: tailscale serve failed (try 'sudo tailscale set --operator=\$USER'):"; cat "$out" + rm -f "$TAILSCALE_HANDLER_FILE" + echo "note: tailscale serve failed (try 'sudo tailscale set --operator=\$USER'):" + cat "$out" + return 1 fi else - if tailscale serve --bg "$PORT" >"$out" 2>&1; then + ensure_tailscale_root_available 443 https || return 1 + printf '%s|%s|%s\n' "https:443" "${tailscale_host}:443" "$expected_proxy" > "$TAILSCALE_HANDLER_FILE" + if tailscale serve --bg --set-path=/ "$PORT" >"$out" 2>&1; then echo "tailscale serve (https) → tailnet :443 -> 127.0.0.1:${PORT}" else - echo "note: tailscale serve (https) failed — on Headscale/.internal domains use COLLIE_SERVE_MODE=http:"; cat "$out" + rm -f "$TAILSCALE_HANDLER_FILE" + echo "note: tailscale serve (https) failed — on Headscale/.internal domains use COLLIE_SERVE_MODE=http:" + cat "$out" + return 1 fi fi } -# Remove ONLY Collie's tailscale serve mapping — the inverse of cmd_serve, NOT a blanket -# `tailscale serve reset` (which would wipe every unrelated mapping on the host). We turn off -# exactly the listener cmd_serve created, keyed off the same SERVE_MODE so the two stay symmetric: -# https:443 by default, or http:$PORT in http mode. Best-effort (|| true) so teardown is idempotent -# when the mapping is already gone. +# Remove Collie's managed ingress from both supported front-door implementations. This is deliberately +# not `tailscale serve reset`, which would wipe every unrelated mapping on the host. cmd_unserve() { - # Always attempt teardown, even under COLLIE_SKIP_SERVE=1: it's idempotent (|| true) and guarded by - # the `command -v tailscale` check, and skipping it would strand a stale serve mapping (from before - # the flag was flipped on) still publishing the app — a security hazard, not a convenience. - command -v tailscale >/dev/null || { echo "note: tailscale not found; no serve mapping to remove"; return; } - if [ "$SERVE_MODE" = "http" ]; then - tailscale serve --http="$PORT" off >/dev/null 2>&1 || true - echo "tailscale serve: removed Collie's http :${PORT} mapping" - else - tailscale serve --https=443 off >/dev/null 2>&1 || true - echo "tailscale serve: removed Collie's https :443 mapping" - fi + local failed=0 + stop_tailscale_serve || failed=1 + stop_netbird_expose || failed=1 + return "$failed" } + cmd_status() { print_status_banner - if [ "${COLLIE_SKIP_SERVE:-}" = "1" ]; then - echo " serve config: skipped (COLLIE_SKIP_SERVE=1)" - else - echo " serve config:"; tailscale serve status 2>/dev/null | sed 's/^/ /' || true - fi + case "$FRONT_DOOR" in + proxy) + echo " serve config: skipped (COLLIE_FRONT_DOOR=proxy)" + ;; + netbird) + echo " netbird expose:" + if have_systemd; then + echo " service $(systemctl --user is-active "$NETBIRD_EXPOSE_UNIT" 2>/dev/null || echo inactive)" + elif netbird_expose_running; then + echo " pid $(cat "$NETBIRD_EXPOSE_PID" 2>/dev/null)" + elif [ -f "$NETBIRD_EXPOSE_PID" ] || [ -f "$NETBIRD_EXPOSE_IDENTITY" ]; then + echo " process stale (stopped or identity mismatch; state retained)" + else + echo " process inactive" + fi + echo " url $(netbird_bridge_url)" + [ -f "$NETBIRD_EXPOSE_LOG" ] && tail -n 8 "$NETBIRD_EXPOSE_LOG" | sed 's/^/ /' || true + ;; + tailscale) + echo " serve config:"; tailscale serve status 2>/dev/null | sed 's/^/ /' || true + ;; + *) + echo " serve config: unknown COLLIE_FRONT_DOOR=${FRONT_DOOR}" + ;; + esac } cmd_logs() { @@ -342,6 +938,10 @@ cmd_push_test() { "$BUN" run "${PLUGIN_ROOT}/scripts/push-test.ts" "$@" } +if [ "${BASH_SOURCE[0]}" != "$0" ]; then + return 0 +fi + case "${1:-}" in start) cmd_start ;; stop) cmd_stop ;; diff --git a/scripts/collie-ctl.test.sh b/scripts/collie-ctl.test.sh new file mode 100755 index 00000000..ad2f5452 --- /dev/null +++ b/scripts/collie-ctl.test.sh @@ -0,0 +1,417 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CTL="${ROOT}/scripts/collie-ctl.sh" +BASE_PATH="$PATH" +TMP_ROOT="$(mktemp -d)" +PIDS=() + +cleanup() { + local pid + for pid in "${PIDS[@]:-}"; do + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_eq() { + [ "$1" = "$2" ] || fail "expected '$2', got '$1'" +} + +assert_contains() { + case "$1" in + *"$2"*) ;; + *) fail "expected output to contain '$2'" ;; + esac +} + +setup_case() { + CASE_DIR="${TMP_ROOT}/$1" + HOME_DIR="${CASE_DIR}/home" + CONFIG_DIR="${CASE_DIR}/config" + BIN_DIR="${CASE_DIR}/bin" + mkdir -p "$HOME_DIR" "$CONFIG_DIR" "$BIN_DIR" + cat > "${BIN_DIR}/systemctl" <<'EOF' +#!/bin/sh +exit 1 +EOF + chmod +x "${BIN_DIR}/systemctl" +} + +run_ctl() { + HOME="$HOME_DIR" \ + HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" \ + PATH="${BIN_DIR}:${BASE_PATH}" \ + bash "$CTL" "$@" +} + +install_fake_tailscale() { + TS_STATUS="${CASE_DIR}/tailscale-status.json" + TS_CALLS="${CASE_DIR}/tailscale.calls" + printf '{}\n' > "$TS_STATUS" + cat > "${BIN_DIR}/tailscale" <> "$TS_CALLS" +if [ "\${1:-}" = status ] && [ "\${2:-}" = --json ]; then + echo '{"Self":{"DNSName":"host.example."}}' + exit 0 +fi +if [ "\${1:-}" = serve ] && [ "\${2:-}" = status ] && [ "\${3:-}" = --json ]; then + cat "$TS_STATUS" + exit 0 +fi +if [ "\${1:-}" = serve ] && [[ " \$* " == *" --bg "* ]]; then + target="\${!#}" + listener=443 + protocol=HTTPS + for arg in "\$@"; do + case "\$arg" in + --http=*) listener="\${arg#--http=}"; protocol=HTTP ;; + esac + done + cat > "$TS_STATUS" < "$TS_STATUS" + exit 0 +fi +exit 2 +EOF + chmod +x "${BIN_DIR}/tailscale" +} + +test_tailscale_cutovers_and_collisions() { + setup_case tailscale + install_fake_tailscale + + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=tailscale +COLLIE_SERVE_MODE=http +COLLIE_PORT=8787 +EOF + run_ctl serve > "${CASE_DIR}/start-8787.out" + assert_eq "$(cat "${CONFIG_DIR}/tailscale-managed-handler")" \ + 'http:8787|host.example:8787|http://127.0.0.1:8787' + + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=tailscale +COLLIE_SERVE_MODE=http +COLLIE_PORT=9999 +EOF + run_ctl serve > "${CASE_DIR}/start-9999.out" + assert_eq "$(cat "${CONFIG_DIR}/tailscale-managed-handler")" \ + 'http:9999|host.example:9999|http://127.0.0.1:9999' + + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=proxy +COLLIE_PORT=9999 +EOF + run_ctl serve > "${CASE_DIR}/to-proxy.out" + [ ! -e "${CONFIG_DIR}/tailscale-managed-handler" ] || fail "Tailscale ownership survived proxy cutover" + assert_eq "$(cat "$TS_STATUS")" '{}' + + collision='{"TCP":{"8787":{"HTTP":true}},"Web":{"host.example:8787":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:7000"}}}}}' + printf '%s\n' "$collision" > "$TS_STATUS" + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=tailscale +COLLIE_SERVE_MODE=http +COLLIE_PORT=8787 +EOF + if run_ctl serve > "${CASE_DIR}/collision.out" 2>&1; then + fail "unowned Tailscale root collision was overwritten" + fi + assert_eq "$(cat "$TS_STATUS")" "$collision" + [ ! -e "${CONFIG_DIR}/tailscale-managed-handler" ] || fail "collision created ownership state" + + opposite_https='{"TCP":{"8787":{"HTTPS":true}},"Web":{"host.example:8787":{"Handlers":{"/other":{"Proxy":"http://127.0.0.1:7002"}}}}}' + printf '%s\n' "$opposite_https" > "$TS_STATUS" + if run_ctl serve > "${CASE_DIR}/opposite-https.out" 2>&1; then + fail "HTTP publication replaced an unrelated HTTPS sibling listener" + fi + assert_eq "$(cat "$TS_STATUS")" "$opposite_https" + + opposite_http='{"TCP":{"443":{"HTTP":true}},"Web":{"host.example:443":{"Handlers":{"/other":{"Proxy":"http://127.0.0.1:7003"}}}}}' + printf '%s\n' "$opposite_http" > "$TS_STATUS" + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=tailscale +COLLIE_SERVE_MODE=https +COLLIE_PORT=8787 +EOF + if run_ctl serve > "${CASE_DIR}/opposite-http.out" 2>&1; then + fail "HTTPS publication replaced an unrelated HTTP sibling listener" + fi + assert_eq "$(cat "$TS_STATUS")" "$opposite_http" + [ ! -e "${CONFIG_DIR}/tailscale-managed-handler" ] || fail "protocol mismatch created ownership state" + + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=tailscale +COLLIE_SERVE_MODE=http +COLLIE_PORT=8787 +EOF + + printf '{}\n' > "$TS_STATUS" + run_ctl serve > "${CASE_DIR}/owned.out" + owned_state="$(cat "${CONFIG_DIR}/tailscale-managed-handler")" + protocol_replacement='{"TCP":{"8787":{"HTTPS":true}},"Web":{"host.example:8787":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:8787"}}}}}' + printf '%s\n' "$protocol_replacement" > "$TS_STATUS" + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=proxy +COLLIE_PORT=8787 +EOF + if run_ctl serve > "${CASE_DIR}/protocol-replacement.out" 2>&1; then + fail "protocol-only Tailscale root replacement was removed" + fi + assert_eq "$(cat "$TS_STATUS")" "$protocol_replacement" + assert_eq "$(cat "${CONFIG_DIR}/tailscale-managed-handler")" "$owned_state" + replacement='{"TCP":{"8787":{"HTTP":true}},"Web":{"host.example:8787":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:7001"}}}}}' + printf '%s\n' "$replacement" > "$TS_STATUS" + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=proxy +COLLIE_PORT=8787 +EOF + if run_ctl serve > "${CASE_DIR}/replacement.out" 2>&1; then + fail "externally replaced Tailscale root was removed" + fi + assert_eq "$(cat "$TS_STATUS")" "$replacement" + assert_eq "$(cat "${CONFIG_DIR}/tailscale-managed-handler")" "$owned_state" +} + +test_netbird_identity_mismatch() { + setup_case netbird-identity + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=netbird +COLLIE_NETBIRD_PIN=123456 +EOF + + sleep 30 & + unrelated=$! + PIDS+=("$unrelated") + printf '%s\n' "$unrelated" > "${CONFIG_DIR}/netbird-expose.pid" + printf 'proc:stale-start-time\n' > "${CONFIG_DIR}/netbird-expose.identity" + printf 'URL: https://stale.example\n' > "${CONFIG_DIR}/netbird-expose.log" + + url_output="$(run_ctl url)" + assert_contains "$url_output" 'NetBird URL unavailable' + case "$url_output" in + *stale.example*) fail "stale NetBird URL was reported live" ;; + esac + + status_output="$(run_ctl status)" + assert_contains "$status_output" 'process stale' + assert_contains "$status_output" 'url NetBird URL unavailable' + + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=proxy +EOF + if run_ctl serve > "${CASE_DIR}/cutover.out" 2>&1; then + fail "proxy cutover ignored NetBird identity mismatch" + fi + kill -0 "$unrelated" 2>/dev/null || fail "identity mismatch signaled unrelated process" + [ -e "${CONFIG_DIR}/netbird-expose.pid" ] || fail "mismatched PID state was deleted" + [ -e "${CONFIG_DIR}/netbird-expose.identity" ] || fail "mismatched identity state was deleted" +} + +run_netbird_state_write_failure() { + local kind="$1" + setup_case "netbird-${kind}-write" + local child_pid_file="${CASE_DIR}/child.pid" + cat > "${BIN_DIR}/netbird" < "$child_pid_file" +while :; do sleep 1; done +EOF + chmod +x "${BIN_DIR}/netbird" + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=netbird +COLLIE_NETBIRD_PIN=123456 +EOF + + local pid_state="${CONFIG_DIR}/netbird-expose.pid" + local identity_state="${CONFIG_DIR}/netbird-expose.identity" + local state_blocker="${CONFIG_DIR}/state-blocker" + printf 'regular file, not a directory\n' > "$state_blocker" + if [ "$kind" = identity ]; then + identity_state="${state_blocker}/netbird-expose.identity" + else + pid_state="${state_blocker}/netbird-expose.pid" + fi + + local harness="${CASE_DIR}/harness.sh" + cat > "$harness" < "${CASE_DIR}/write-failure.out" 2>&1 + [ ! -e "$pid_state" ] || fail "$kind write failure retained partial PID state" + [ ! -e "$identity_state" ] || fail "$kind write failure retained partial identity state" + if [ -f "$child_pid_file" ]; then + child_pid="$(cat "$child_pid_file")" + if kill -0 "$child_pid" 2>/dev/null; then + kill -KILL "$child_pid" 2>/dev/null || true + fail "$kind write failure left NetBird child running" + fi + fi +} + +test_missing_tailscale_cli() { + setup_case tailscale-missing + ln -s "$(command -v dirname)" "${BIN_DIR}/dirname" + ln -s "$(command -v tr)" "${BIN_DIR}/tr" + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=tailscale +COLLIE_PORT=8787 +EOF + + set +e + HOME="$HOME_DIR" \ + HERDR_PLUGIN_CONFIG_DIR="$CONFIG_DIR" \ + PATH="$BIN_DIR" \ + /bin/bash "$CTL" serve > "${CASE_DIR}/missing.out" 2>&1 + rc=$? + set -e + + [ "$rc" -ne 0 ] || fail "missing Tailscale CLI reported success" + output="$(cat "${CASE_DIR}/missing.out")" + assert_contains "$output" 'tailscale not found' + case "$output" in + *"open:"*) fail "missing Tailscale CLI printed an open URL" ;; + esac +} + +test_state_delete_failures() { + setup_case state-delete-failures + cat > "${BIN_DIR}/tailscale" <<'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x "${BIN_DIR}/tailscale" + + local tailscale_state="${CONFIG_DIR}/tailscale-managed-handler" + local pid_state="${CONFIG_DIR}/netbird-expose.pid" + local identity_state="${CONFIG_DIR}/netbird-expose.identity" + printf 'http:8787|host.example:8787|http://127.0.0.1:8787\n' > "$tailscale_state" + printf '99999999\n' > "$pid_state" + printf 'proc:dead\n' > "$identity_state" + + local harness="${CASE_DIR}/harness.sh" + cat > "$harness" < "${CASE_DIR}/delete-failure.out" 2>&1 +} + +test_systemd_query_states() { + setup_case systemd-query-states + cat > "${CONFIG_DIR}/.env" <<'EOF' +COLLIE_FRONT_DOOR=proxy +EOF + + cat > "${BIN_DIR}/systemctl" <<'EOF' +#!/bin/sh +case "$*" in + "--user show-environment") exit 0 ;; + "--user disable --now collie-netbird-expose") exit 0 ;; + "--user is-active collie-netbird-expose") echo "Failed to connect to bus" >&2; exit 1 ;; + "--user is-enabled collie-netbird-expose") echo disabled; exit 1 ;; + *) exit 0 ;; +esac +EOF + chmod +x "${BIN_DIR}/systemctl" + if run_ctl serve > "${CASE_DIR}/query-failure.out" 2>&1; then + fail "systemd query failure was treated as stopped" + fi + assert_contains "$(cat "${CASE_DIR}/query-failure.out")" 'could not confirm NetBird expose unit is inactive' + + cat > "${BIN_DIR}/systemctl" <<'EOF' +#!/bin/sh +case "$*" in + "--user show-environment") echo "Failed to connect to bus" >&2; exit 1 ;; + *) exit 1 ;; +esac +EOF + chmod +x "${BIN_DIR}/systemctl" + mkdir -p "${HOME_DIR}/.config/systemd/user" + printf '[Service]\nExecStart=/bin/false\n' > "${HOME_DIR}/.config/systemd/user/collie-netbird-expose.service" + if run_ctl serve > "${CASE_DIR}/inaccessible-bus.out" 2>&1; then + fail "inaccessible systemd bus with a known unit was treated as absent" + fi + assert_contains "$(cat "${CASE_DIR}/inaccessible-bus.out")" 'systemd user manager is inaccessible' + rm -f "${HOME_DIR}/.config/systemd/user/collie-netbird-expose.service" + + cat > "${BIN_DIR}/systemctl" <<'EOF' +#!/bin/sh +case "$*" in + "--user show-environment") exit 0 ;; + "--user disable --now collie-netbird-expose") exit 0 ;; + "--user is-active collie-netbird-expose") echo inactive; exit 3 ;; + "--user is-enabled collie-netbird-expose") echo disabled; exit 1 ;; + *) exit 0 ;; +esac +EOF + chmod +x "${BIN_DIR}/systemctl" + run_ctl serve > "${CASE_DIR}/explicit-stopped.out" + assert_contains "$(cat "${CASE_DIR}/explicit-stopped.out")" 'managed serve skipped' +} + +test_tailscale_cutovers_and_collisions +test_netbird_identity_mismatch +run_netbird_state_write_failure pid +run_netbird_state_write_failure identity +test_missing_tailscale_cli +test_state_delete_failures +test_systemd_query_states + +echo "collie-ctl lifecycle tests: passed"