diff --git a/.env.example b/.env.example index 21e1158..b0d54b3 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,14 @@ COLLIE_HOST=127.0.0.1 # (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). +# COLLIE_SKIP_SERVE=1 +# Your public URL behind the proxy — shown as the "proxy" address by `collie-ctl.sh status`. +# COLLIE_PUBLIC_URL=https://collie.example.com # --- Herdr connection --- # Usually injected by the systemd unit; override only if your socket is elsewhere. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7dac42c..d2fdb86 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,7 +26,10 @@ 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, -- is exposed **tailnet-only via `tailscale serve`** (HTTPS + MagicDNS), installable as a **PWA**. +- 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**. The browser never touches the socket directly; the bridge is the only thing that does. @@ -34,7 +37,7 @@ The browser never touches the socket directly; the bridge is the only thing that phone / laptop (PWA) │ HTTPS over tailnet (https://herd..ts.net) ▼ - tailscale serve ── injects identity headers, terminates TLS + tailscale serve ── injects identity headers, terminates TLS (Variant C: a reverse proxy instead) │ 127.0.0.1:PORT (bridge binds loopback ONLY) ▼ Collie (this project) @@ -150,13 +153,18 @@ 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 + tailnet-only. These four are genuine RCE vectors -and are **load-bearing — do not regress them:** - -- **The bridge binds `127.0.0.1` only** and lets `tailscale serve` proxy it. Binding `0.0.0.0` makes - the whole identity check theater. Tailscale identity headers (`Tailscale-User-Login`) are trusted - **only** when the request source is loopback (i.e. it came from tailscaled), and the specific owner - login is asserted — any other tailnet user is rejected. +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:** + +- **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 + ([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. - **`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 @@ -165,9 +173,11 @@ and are **load-bearing — do not regress them:** 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), 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). + 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)), + 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 "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. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc6b024..c0555c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to Collie are recorded here. The format follows `version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by `scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy. +## [0.12.0] - 2026-07-17 + +### Added +- `COLLIE_SKIP_SERVE=1` env var to disable tailscale serve entirely — bridge stays on loopback only, ideal for deployments behind a reverse proxy (Caddy, Nginx, etc.) — thanks @diogenesc (ad5833a) +- `COLLIE_PUBLIC_URL` — `collie-ctl.sh status` banner shows your real reverse-proxy URL instead of a placeholder (4b043be) +- Bridge startup warning when `COLLIE_TRUSTED_USER` is set under `COLLIE_SKIP_SERVE=1` — the identity gate is inert without tailscale serve injecting `Tailscale-User-Login`; use `COLLIE_DEVICE_HEADER` (4b043be) +- README Variant C — reverse proxy as the only front door (no Tailscale), with Caddy example and required env (76019f7) + +### Changed +- `collie-ctl.sh unserve`/`uninstall` always attempt serve teardown, even under `COLLIE_SKIP_SERVE=1` — a stale mapping from before the flag flip would keep publishing the app (4b043be) +- Security posture docs: "tailscale serve is the sole ingress" → "exactly one hardened front door" (tailscale serve or a conforming reverse proxy) across README, ARCHITECTURE, CLAUDE.md (76019f7) + ## [0.11.1] - 2026-07-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 3b899ea..f9596b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,7 @@ the unit name; the Herdr action runs from anywhere. ## Security posture (don't regress) -Loopback bind only · `tailscale serve` is the sole ingress (never `funnel`) · same-origin gate · -optional `COLLIE_TRUSTED_USER` identity check · 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`) 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 9e0da87..9795cf4 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,10 @@ Three sharp edges: It's built single-user and tailnet-only. The defenses: - **Loopback bind only** (`127.0.0.1`) — never `0.0.0.0`. -- **`tailscale serve` is the sole ingress** — terminates TLS, injects the identity header. +- **Exactly one hardened front door** — either `tailscale serve` (default, Variant A: terminates + TLS, injects the identity header) 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 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; @@ -117,7 +120,7 @@ On the **host** (the tailnet node your agents run on): | --- | --- | | [**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) | Sole ingress (`tailscale serve`); without it, the bridge is `127.0.0.1`-only. | +| [**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. | | **git** | Clone, and the `update` command. | Soft dependencies: **Node.js** (the control script uses it to extract your MagicDNS name from @@ -281,9 +284,11 @@ The bridge reads `.env` only at startup — after any edit, `scripts/collie-ctl. `COLLIE_SERVE_MODE=http` (Headscale / `.internal` domains; read by the control script when it runs `tailscale serve`). -**Custom domain or reverse proxy?** Collie is same-origin only. A plain `tailscale serve` on your -MagicDNS name works as-is, but a different hostname or TLS terminator makes API calls fail with `403 -cross-origin` (page loads, stays empty). Allow the exact origin: +**Custom domain or reverse proxy?** See +[Variant C](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) for the full reverse-proxy +front-door setup. The one rule to know here: Collie is same-origin only. A plain `tailscale serve` on +your MagicDNS name works as-is, but a different hostname or TLS terminator makes API calls fail with +`403 cross-origin` (page loads, stays empty). Allow the exact origin: ```bash COLLIE_ALLOWED_ORIGINS=https://collie.example.com @@ -375,8 +380,8 @@ 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*. There are two supported shapes — they gate access at -different levels (**person** vs **device**). Pick one. +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. ### Variant A — `tailscale serve` + person identity (default) @@ -454,6 +459,48 @@ Revoke a device by dropping its id from `COLLIE_DEVICE_ALLOWLIST` and `systemctl --user restart collie`. With the header set but the allowlist **empty**, every device is read-only (fail-closed). +### Variant C — reverse proxy as the only front door (no Tailscale) + +A reverse proxy (Caddy, Nginx, …) is the **sole ingress** — no Tailscale in the path. Choose this +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`. + +The **four proxy requirements from +[Variant B](#variant-b--identity-aware-proxy--per-device-authorisation) apply verbatim** — the proxy +*is* the identity-aware front door here. A minimal Caddy front door: + +```caddyfile +collie.example.com { + # TLS is automatic (Let's Encrypt). Put YOUR access control here + # (forward_auth / mTLS / SSO) — it also yields the per-device id below. + reverse_proxy 127.0.0.1:8787 { + header_up X-Device-Id {your_device_id} # SET from your auth — overrides any client-supplied copy + header_up Host {host} # forward the public Host → same-origin gate passes + } +} +``` + +Required env (`.env`): + +```bash +COLLIE_SKIP_SERVE=1 # proxy is ingress; never run tailscale 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… +COLLIE_DEVICE_ALLOWLIST=my-phone,my-laptop # …and the ids allowed to drive; others → read-only +# COLLIE_PUBLIC_URL=https://collie.example.com # optional — shown in the collie-ctl.sh status banner +``` + +> ⚠️ **`COLLIE_TRUSTED_USER` does nothing here.** It gates on `Tailscale-User-Login`, which only +> `tailscale serve` injects — with no Tailscale in the path there is no injector, and the bridge +> logs a startup warning saying so. **Per-device auth (`COLLIE_DEVICE_HEADER`) is the write gate**, +> and the **proxy must provide TLS and its own access control** — anyone who reaches the proxy gets +> read access to every pane. Give the proxy the same respect you'd give the tailnet. + ## Web Push (optional) Off unless you opt in: @@ -464,9 +511,11 @@ bunx web-push generate-vapid-keys # set COLLIE_VAPID_PUBLIC / _PRIVATE / _SUBJECT in your .env, then restart ``` -Push needs HTTPS — the default `tailscale serve` already provides it (Tailscale manages the MagicDNS -cert; nothing to obtain or renew). `COLLIE_SERVE_MODE=http` is **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 — 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`. 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 +594,9 @@ A small Bun process sits between your phone and Herdr — the browser never touc Herdr server owns the panes, agents and terminal state ``` +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. + - **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. - **Actions are plain HTTP** — a reply or key `POST`s to `/api/pane/:id/{reply,keys}` → Herdr `pane.send_keys`, which types into a real terminal (hence the security posture). diff --git a/bridge/config.test.ts b/bridge/config.test.ts index 2244fd1..209c610 100644 --- a/bridge/config.test.ts +++ b/bridge/config.test.ts @@ -23,6 +23,7 @@ const KEYS = [ "COLLIE_VAPID_SUBJECT", "COLLIE_STATE_DIR", "COLLIE_MULTI_SESSION", + "COLLIE_SKIP_SERVE", "HERDR_SOCKET_PATH", "HERDR_PLUGIN_STATE_DIR", ]; @@ -64,6 +65,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). + expect(cfg.skipServe).toBe(false); }); test("parses COLLIE_MULTI_SESSION as a boolean toggle (default on)", () => { @@ -84,6 +87,24 @@ 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). + for (const on of ["on", "1", "true", "yes", "ON", " True "]) { + process.env.COLLIE_SKIP_SERVE = on; + expect(loadConfig().skipServe).toBe(true); + } + // Falsey spellings keep it off (the default tailscale serve path). + for (const off of ["off", "0", "false", "no", "OFF", " False "]) { + process.env.COLLIE_SKIP_SERVE = off; + expect(loadConfig().skipServe).toBe(false); + } + // Garbage and empty fall back to the default (off). + process.env.COLLIE_SKIP_SERVE = "banana"; + expect(loadConfig().skipServe).toBe(false); + process.env.COLLIE_SKIP_SERVE = ""; + expect(loadConfig().skipServe).toBe(false); + }); + 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 e63d8fd..221dbe1 100644 --- a/bridge/config.ts +++ b/bridge/config.ts @@ -129,6 +129,14 @@ export interface Config { * already-discovered session; they never build a filesystem path. */ 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). + */ + skipServe: boolean; } export function loadConfig(): Config { @@ -158,5 +166,6 @@ 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), }; } diff --git a/bridge/server.test.ts b/bridge/server.test.ts index 0bb3414..41736f8 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -7,6 +7,7 @@ import { paneReadResponse, resolveStaticPath, sendReplySteps, + startupWarnings, type ReplySender, } from "./server.ts"; import type { Config } from "./config.ts"; @@ -43,6 +44,7 @@ function cfg(overrides: Partial = {}): Config { vapidSubject: "mailto:admin@example.com", stateDir: "/tmp/state", multiSession: true, + skipServe: false, ...overrides, }; } @@ -387,3 +389,44 @@ 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" })); + 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); + // The Variant-A empty-trustedUser nag must NOT also fire (it's meaningless behind a proxy). + 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: "" })); + 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: "" })); + 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" })); + expect(has(ws, "COLLIE_TRUSTED_USER")).toBe(false); + }); + + test("empty publicHosts: the Host-validation warning fires and no longer names COLLIE_SERVE_MODE", () => { + const ws = startupWarnings(cfg({ publicHosts: [] })); + expect(has(ws, "COLLIE_PUBLIC_HOSTS is empty")).toBe(true); + // The reworded clause must not reference the script-only COLLIE_SERVE_MODE var. + expect(has(ws, "COLLIE_SERVE_MODE")).toBe(false); + }); + + test("populated publicHosts: no Host-validation warning", () => { + const ws = startupWarnings(cfg({ publicHosts: ["collie.example.ts.net"] })); + expect(has(ws, "COLLIE_PUBLIC_HOSTS")).toBe(false); + }); +}); diff --git a/bridge/server.ts b/bridge/server.ts index 70c07e1..2c98a7e 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -260,31 +260,56 @@ export function startServer(opts: { }); console.log(`[bridge] listening on http://${cfg.host}:${cfg.port} (poll ${cfg.pollMs}ms)`); - if (cfg.host !== "127.0.0.1" && cfg.host !== "localhost") { - console.warn(`[bridge] WARNING: bound to ${cfg.host}, not loopback — identity checks may be bypassable`); - } if (cfg.deviceHeader) { console.log( `[bridge] per-device auth ON: trusting '${cfg.deviceHeader}', ${cfg.deviceAllowlist.length} device(s) allowlisted`, ); - if (cfg.deviceAllowlist.length === 0) { - console.warn( - `[bridge] WARNING: COLLIE_DEVICE_HEADER set but COLLIE_DEVICE_ALLOWLIST is empty — every device is read-only`, + } + for (const w of startupWarnings(cfg)) console.warn(w); + + return server; +} + +/** + * 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. + */ +export function startupWarnings(cfg: Config): string[] { + const warnings: string[] = []; + if (cfg.host !== "127.0.0.1" && cfg.host !== "localhost") { + warnings.push( + `[bridge] WARNING: bound to ${cfg.host}, not loopback — identity checks may be bypassable`, + ); + } + if (cfg.deviceHeader && cfg.deviceAllowlist.length === 0) { + warnings.push( + `[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) { + 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).`, ); } - } - if (!cfg.trustedUser) { - console.warn( + } else if (!cfg.trustedUser) { + 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).`, ); } if (cfg.publicHosts.length === 0) { - console.warn( - `[bridge] WARNING: COLLIE_PUBLIC_HOSTS is empty — Host-header validation is OFF (DNS rebinding not blocked). Set it to your MagicDNS name, especially under COLLIE_SERVE_MODE=http.`, + 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.`, ); } - - return server; + return warnings; } async function readPane( diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 982dad1..0662448 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "herdr.collie" name = "Collie" -version = "0.11.1" +version = "0.12.0" min_herdr_version = "0.7.0" description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale" platforms = ["linux", "macos"] diff --git a/package.json b/package.json index 3dca6f6..f5db2fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "collie", - "version": "0.11.1", + "version": "0.12.0", "private": true, "license": "MIT", "description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale", diff --git a/scripts/collie-ctl.sh b/scripts/collie-ctl.sh index 88763a3..be9053e 100755 --- a/scripts/collie-ctl.sh +++ b/scripts/collie-ctl.sh @@ -149,7 +149,15 @@ print_status_banner() { fi echo " service ${svc}" echo " local http://127.0.0.1:${PORT}" - echo " tailnet $(bridge_url)" + 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 echo } @@ -270,6 +278,10 @@ cmd_apply_update() { } 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 + fi command -v tailscale >/dev/null || { echo "note: tailscale not found; bridge is on 127.0.0.1:${PORT} only"; return; } local out="${CONFIG_DIR}/serve.out" if [ "$SERVE_MODE" = "http" ]; then @@ -293,6 +305,9 @@ cmd_serve() { # https:443 by default, or http:$PORT in http mode. Best-effort (|| true) so teardown is idempotent # when the mapping is already gone. 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 @@ -305,7 +320,11 @@ cmd_unserve() { cmd_status() { print_status_banner - echo " serve config:"; tailscale serve status 2>/dev/null | sed 's/^/ /' || true + 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 } cmd_logs() { diff --git a/web/package.json b/web/package.json index 6d49f6e..4922674 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "collie-web", - "version": "0.11.1", + "version": "0.12.0", "private": true, "license": "MIT", "type": "module",