diff --git a/docs/configuration.md b/docs/configuration.md index 36f2942db..7166174e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -224,6 +224,8 @@ Defaults: mode: "local", // or "remote" bind: "loopback", // controlUi: { enabled: true } + // auth: { mode: "token" | "password" | "system" } + // tailscale: { mode: "off" | "serve" | "funnel" } } } ``` @@ -231,6 +233,16 @@ Defaults: Notes: - `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). +Auth and Tailscale: +- `gateway.auth.mode` sets the handshake requirements (`token`, `password`, or `system`/PAM). +- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). +- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended). +- `gateway.auth.username` defaults to the current OS user; override with `CLAWDIS_GATEWAY_USERNAME`. +- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth. +- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). +- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. +- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. + ### `canvasHost` (LAN/tailnet Canvas file server + live reload) The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it. diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 000000000..45d288a71 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,16 @@ +--- +summary: "Gateway dashboard (Control UI) access and auth" +read_when: + - Changing dashboard authentication or exposure modes +--- +# Dashboard (Control UI) + +The Gateway dashboard is the browser Control UI served at `/ui/`. + +Key references: +- `docs/control-ui.md` for usage and UI capabilities. +- `docs/tailscale.md` for Serve/Funnel automation. +- `docs/web.md` for bind modes and security notes. + +Authentication is enforced at the WebSocket handshake via `connect.params.auth` +(token or password). See `gateway.auth` in `docs/configuration.md`. diff --git a/docs/tailscale.md b/docs/tailscale.md new file mode 100644 index 000000000..4d0360d1d --- /dev/null +++ b/docs/tailscale.md @@ -0,0 +1,87 @@ +--- +summary: "Integrated Tailscale Serve/Funnel for the Gateway dashboard" +read_when: + - Exposing the Gateway Control UI outside localhost + - Automating tailnet or public dashboard access +--- +# Tailscale (Gateway dashboard) + +Clawdis can auto-configure Tailscale **Serve** (tailnet) or **Funnel** (public) for the +Gateway dashboard and WebSocket port. This keeps the Gateway bound to loopback while +Tailscale provides HTTPS, routing, and (for Serve) identity headers. + +## Modes + +- `serve`: Tailnet-only HTTPS via `tailscale serve`. The gateway stays on `127.0.0.1`. +- `funnel`: Public HTTPS via `tailscale funnel`. Requires auth. +- `off`: Default (no Tailscale automation). + +## Auth + +Set `gateway.auth.mode` to control the handshake: + +- `token` (default when `CLAWDIS_GATEWAY_TOKEN` is set) +- `password` (shared secret via `CLAWDIS_GATEWAY_PASSWORD` or config) +- `system` (PAM, validates your OS password) + +When `tailscale.mode = "serve"`, the gateway trusts Tailscale identity headers by +default unless you force `gateway.auth.mode` to `password`/`system` or set +`gateway.auth.allowTailscale: false`. + +## Config examples + +### Tailnet-only (Serve) + +```json5 +{ + gateway: { + bind: "loopback", + tailscale: { mode: "serve" } + } +} +``` + +Open: `https:///ui/` + +### Public internet (Funnel + system password) + +```json5 +{ + gateway: { + bind: "loopback", + tailscale: { mode: "funnel" }, + auth: { mode: "system" } + } +} +``` + +Open: `https:///ui/` (public) + +### Public internet (Funnel + shared password) + +```json5 +{ + gateway: { + bind: "loopback", + tailscale: { mode: "funnel" }, + auth: { mode: "password", password: "replace-me" } + } +} +``` + +Prefer `CLAWDIS_GATEWAY_PASSWORD` over committing a password to disk. + +## CLI examples + +```bash +clawdis gateway --tailscale serve +clawdis gateway --tailscale funnel --auth system +``` + +## Notes + +- Tailscale Serve/Funnel requires the `tailscale` CLI to be installed and logged in. +- System auth uses the optional `authenticate-pam` native module; install if missing. +- `tailscale.mode: "funnel"` refuses to start without auth to avoid public exposure. +- Set `gateway.tailscale.resetOnExit` if you want Clawdis to undo `tailscale serve` + or `tailscale funnel` configuration on shutdown. diff --git a/package.json b/package.json index 3a7863d6d..24b5b1610 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,9 @@ "ws": "^8.18.3", "zod": "^4.2.1" }, + "optionalDependencies": { + "authenticate-pam": "^1.0.5" + }, "devDependencies": { "@biomejs/biome": "^2.3.10", "@lit-labs/signals": "^0.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06240cdb4..5141bd556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,10 @@ importers: wireit: specifier: ^0.14.12 version: 0.14.12 + optionalDependencies: + authenticate-pam: + specifier: ^1.0.5 + version: 1.0.5 packages: @@ -1162,6 +1166,9 @@ packages: resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==} engines: {node: '>=14'} + authenticate-pam@1.0.5: + resolution: {integrity: sha512-zaPml3/19Sa3XLewuOoUNsxwnNz13mTNoO4Q09vr93cjTrH0dwXOU49Bcetk/XWl22bw9zO9WovSKkddGvBEsQ==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1907,6 +1914,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.24.0: + resolution: {integrity: sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3487,6 +3497,11 @@ snapshots: audio-type@2.2.1: optional: true + authenticate-pam@1.0.5: + dependencies: + nan: 2.24.0 + optional: true + balanced-match@1.0.2: {} balanced-match@3.0.1: {} @@ -4274,6 +4289,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.24.0: + optional: true + nanoid@3.3.11: {} negotiator@1.0.0: {} diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 13f6bc19b..a5e32fa02 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -17,6 +17,8 @@ import { forceFreePortAndWait } from "./ports.js"; type GatewayRpcOpts = { url?: string; token?: string; + username?: string; + password?: string; timeout?: string; expectFinal?: boolean; }; @@ -25,6 +27,8 @@ const gatewayCallOpts = (cmd: Command) => cmd .option("--url ", "Gateway WebSocket URL", "ws://127.0.0.1:18789") .option("--token ", "Gateway token (if required)") + .option("--username ", "Gateway username (system auth)") + .option("--password ", "Gateway password (password/system auth)") .option("--timeout ", "Timeout in ms", "10000") .option("--expect-final", "Wait for final response (agent)", false); @@ -36,6 +40,8 @@ const callGatewayCli = async ( callGateway({ url: opts.url, token: opts.token, + username: opts.username, + password: opts.password, method, params, expectFinal: Boolean(opts.expectFinal), @@ -57,6 +63,18 @@ export function registerGatewayCli(program: Command) { "--token ", "Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)", ) + .option("--auth ", 'Gateway auth mode ("token"|"password"|"system")') + .option("--password ", "Password for auth mode=password") + .option("--username ", "Default username for system auth") + .option( + "--tailscale ", + 'Tailscale exposure mode ("off"|"serve"|"funnel")', + ) + .option( + "--tailscale-reset-on-exit", + "Reset Tailscale serve/funnel configuration on shutdown", + false, + ) .option("--verbose", "Verbose logging to stdout/stderr", false) .option( "--ws-log