Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 22 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,18 @@ 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.

```
phone / laptop (PWA)
│ HTTPS over tailnet (https://herd.<tailnet>.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)
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
72 changes: 62 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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).
Expand Down
21 changes: 21 additions & 0 deletions bridge/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down Expand Up @@ -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)", () => {
Expand All @@ -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 ,";
Expand Down
9 changes: 9 additions & 0 deletions bridge/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
};
}
Loading
Loading