Skip to content
Closed
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
71 changes: 50 additions & 21 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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 ---
Expand All @@ -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`)
Expand Down
37 changes: 21 additions & 16 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<tailnet>.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)
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand Down
16 changes: 9 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Loading