@dreb/dashboard 2.55.4 → 2.55.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/auth.d.ts +43 -5
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +170 -37
- package/dist/server/auth.js.map +1 -1
- package/dist/server/pairing-storage.d.ts +1 -1
- package/dist/server/pairing-storage.d.ts.map +1 -1
- package/dist/server/pairing-storage.js +20 -3
- package/dist/server/pairing-storage.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +41 -4
- package/dist/server/server.js.map +1 -1
- package/dist/shared/protocol.d.ts +10 -0
- package/dist/shared/protocol.d.ts.map +1 -1
- package/dist/shared/protocol.js.map +1 -1
- package/dist/static/assets/{index-MJ5nXWOn.js → index-CNuWaOpq.js} +2 -2
- package/dist/static/assets/index-DVDGUEPU.css +1 -0
- package/dist/static/index.html +2 -2
- package/dist/static/sw.js +1 -1
- package/package.json +1 -1
- package/dist/static/assets/index-DVt6aXhf.css +0 -1
package/README.md
CHANGED
|
@@ -81,7 +81,8 @@ Open `http://127.0.0.1:5343`.
|
|
|
81
81
|
(entropist.ca / Dim / Solarized / Gruvbox / Caves of Qud / Van Gogh /
|
|
82
82
|
Okabe-Ito / Paul Tol — the last two colorblind-safe — live preview cards,
|
|
83
83
|
system/light/dark mode selector, saved per browser), current pairing code,
|
|
84
|
-
|
|
84
|
+
the 1–3650 day lifetime used by future pairings (180 days by default), and
|
|
85
|
+
paired-device expiry/unpair management.
|
|
85
86
|
- **Pairing** — remote first-login rotating-code flow.
|
|
86
87
|
|
|
87
88
|
### Scoped models
|
|
@@ -255,13 +256,18 @@ dreb-dashboard --remote --allow you@example.com
|
|
|
255
256
|
|
|
256
257
|
Enforcement layers, all fail-closed:
|
|
257
258
|
|
|
258
|
-
1. Tailscale identity resolution
|
|
259
|
+
1. Peer-specific Tailscale identity resolution (`tailscale whois`); concurrent
|
|
260
|
+
requests for the same normalized peer share one in-flight lookup, while
|
|
261
|
+
resolver failures remain fail-closed and distinct from a clean unknown peer
|
|
259
262
|
2. Identity allowlist — empty allowlist denies everyone
|
|
260
263
|
3. First-login pairing code: 6 digits, rotates every 30 seconds, shown live in
|
|
261
264
|
the dashboard Settings tab on the host machine (also printed at startup as a
|
|
262
265
|
headless fallback)
|
|
263
|
-
4. Signed per-device cookie thereafter
|
|
264
|
-
|
|
266
|
+
4. Signed per-device cookie thereafter. New pairings last 180 days by default;
|
|
267
|
+
Settings accepts 1–3650 days for future pairings without rewriting existing
|
|
268
|
+
expiries. Devices in the final 10% of their recorded lifetime receive an
|
|
269
|
+
advance warning at most once per UTC day. Devices and expiry dates are listed
|
|
270
|
+
in settings and can be unpaired.
|
|
265
271
|
|
|
266
272
|
**There is no LAN mode.** Access from another device — even on the same LAN —
|
|
267
273
|
goes through Tailscale.
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]
|
|
12
12
|
*/
|
|
13
13
|
import { watch } from "node:fs";
|
|
14
|
-
export { DashboardAuth, TailscaleStatusResolver } from "./server/auth.js";
|
|
14
|
+
export { DashboardAuth, TailscaleStatusResolver, TailscaleWhoisResolver } from "./server/auth.js";
|
|
15
15
|
export { DashboardImageService } from "./server/dashboard-images.js";
|
|
16
16
|
export { EventHub } from "./server/event-hub.js";
|
|
17
17
|
export { canonicalizePath, FileApi } from "./server/files.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n/**\n * dreb-dashboard — launch the dreb web dashboard server.\n *\n * Modes (exactly two, no LAN mode):\n * default loopback bind (127.0.0.1), no auth, no Tailscale needed\n * --remote requires Tailscale; binds all interfaces but every request\n * passes identity allowlist + pairing code + device cookies\n *\n * Usage:\n * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]\n */\n\nimport { existsSync, readFileSync, watch } from \"node:fs\";\nimport { createServer as createHttpServer, type Server as HttpServer } from \"node:http\";\nimport { createServer as createHttpsServer, type Server as HttpsServer } from \"node:https\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { DashboardImageService } from \"./server/dashboard-images.js\";\nimport { ImagePreviewWorker } from \"./server/image-preview.js\";\nimport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nimport { RuntimePool } from \"./server/runtime-pool.js\";\nimport { createDashboardServer } from \"./server/server.js\";\n\nexport { DashboardAuth, TailscaleStatusResolver } from \"./server/auth.js\";\nexport { DashboardImageService } from \"./server/dashboard-images.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { ImagePreviewWorker } from \"./server/image-preview.js\";\nexport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nexport { RuntimePool, resolveDrebCliPath } from \"./server/runtime-pool.js\";\nexport { createDashboardServer, parseDeviceCookie } from \"./server/server.js\";\nexport type * from \"./shared/protocol.js\";\n\nconst DEFAULT_PORT = 5343;\n\ninterface CliArgs {\n\tport: number;\n\tremote: boolean;\n\tallow: string[];\n\thelp: boolean;\n\thttps: boolean;\n\tcert?: string;\n\tkey?: string;\n}\n\nexport function parseArgs(argv: string[]): CliArgs {\n\tconst args: CliArgs = { port: DEFAULT_PORT, remote: false, allow: [], help: false, https: false };\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst arg = argv[i];\n\t\tif (arg === \"--port\") {\n\t\t\tconst value = argv[++i];\n\t\t\tconst port = Number.parseInt(value ?? \"\", 10);\n\t\t\tif (!Number.isInteger(port) || port < 1 || port > 65535) {\n\t\t\t\tthrow new Error(`Invalid --port value: ${value}`);\n\t\t\t}\n\t\t\targs.port = port;\n\t\t} else if (arg === \"--remote\") {\n\t\t\targs.remote = true;\n\t\t} else if (arg === \"--allow\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--allow requires an identity (Tailscale login name)\");\n\t\t\targs.allow.push(value);\n\t\t} else if (arg === \"--https\") {\n\t\t\targs.https = true;\n\t\t} else if (arg === \"--cert\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--cert requires a path to a PEM certificate file\");\n\t\t\targs.cert = value;\n\t\t} else if (arg === \"--key\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--key requires a path to a PEM private key file\");\n\t\t\targs.key = value;\n\t\t} else if (arg === \"--help\" || arg === \"-h\") {\n\t\t\targs.help = true;\n\t\t} else {\n\t\t\tthrow new Error(`Unknown argument: ${arg}`);\n\t\t}\n\t}\n\tif (args.remote && args.allow.length === 0) {\n\t\tthrow new Error(\"--remote requires at least one --allow <tailscale-login> (empty allowlist denies everyone)\");\n\t}\n\t// Native TLS. `https` is opt-in; it requires cert AND key (no silent\n\t// fallback to plain HTTP). The dashboard terminates TLS itself — no reverse\n\t// proxy, no auth-model change. `req.socket.remoteAddress` stays the real\n\t// tailnet IP, so identity resolution + allowlist + pairing keep working.\n\tif (args.https) {\n\t\tif (!args.cert) throw new Error(\"--https requires --cert <path>\");\n\t\tif (!args.key) throw new Error(\"--https requires --key <path>\");\n\t}\n\treturn args;\n}\n\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\n}\n\nconst HELP = `dreb-dashboard — dreb web dashboard server\n\nUsage: dreb-dashboard [options]\n\nOptions:\n --port <n> Port to listen on (default ${DEFAULT_PORT})\n --remote Enable remote mode (requires Tailscale). Without this\n flag the server binds 127.0.0.1 only — no LAN access.\n Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\n --allow <identity> Tailscale login name allowed to pair (repeatable;\n required with --remote)\n --https Terminate TLS on the dashboard itself (native TLS). No\n reverse proxy, no auth-model change. Requires --cert\n and --key. Mainly for --remote (loopback is already a\n secure context): use 'tailscale cert' files for a\n tailnet hostname so mobile PWAs + notifications work.\n NOTE: with --https the server speaks TLS only, so the\n host's plain-http local tab (http://127.0.0.1) stops\n working — use the tailnet hostname (https://...) there.\n --cert <path> PEM certificate file (required with --https)\n --key <path> PEM private key file (required with --https)\n --help Show this help\n`;\n\nasync function main(): Promise<void> {\n\tlet args: CliArgs;\n\ttry {\n\t\targs = parseArgs(process.argv.slice(2));\n\t} catch (err) {\n\t\tconsole.error(`error: ${err instanceof Error ? err.message : String(err)}`);\n\t\tconsole.error(HELP);\n\t\tprocess.exit(1);\n\t}\n\tif (args.help) {\n\t\tconsole.log(HELP);\n\t\treturn;\n\t}\n\n\tconst agentDir = join(homedir(), \".dreb\", \"agent\");\n\tconst auth = new DashboardAuth({\n\t\tremoteEnabled: args.remote,\n\t\tallowedIdentities: args.allow,\n\t\tstorage: new FilePairingStorage(join(agentDir, \"dashboard-pairings.json\")),\n\t\tsecret: loadOrCreateDashboardSecret(join(agentDir, \"dashboard-auth-secret\")),\n\t\tlogger: (line) => console.warn(`[dashboard-auth] ${line}`),\n\t});\n\tconst pool = new RuntimePool();\n\tconst imageService = new DashboardImageService(new ImagePreviewWorker());\n\n\t// Static client assets live next to the compiled server (dist/static).\n\tconst staticDir = join(dirname(fileURLToPath(import.meta.url)), \"static\");\n\n\t// The server's own build version (dist/index.js → ../package.json) — surfaced\n\t// in the settings footer so a stale long-running service is spottable.\n\tlet serverVersion: string | undefined;\n\ttry {\n\t\tconst pkgPath = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\");\n\t\tserverVersion = JSON.parse(readFileSync(pkgPath, \"utf8\")).version;\n\t} catch {\n\t\tserverVersion = undefined;\n\t}\n\n\t// Restart hook: exit non-zero so a supervisor (systemd Restart=on-failure,\n\t// etc.) respawns the process with the freshly-built dist. Assigned the http\n\t// server below via a mutable holder so the closure can close it first.\n\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tlet closeDashboard = () => imageService.close();\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => Promise.all([pool.stopAll(), closeDashboard()]),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\timageService,\n\t\tstaticDir: existsSync(staticDir) ? staticDir : undefined,\n\t\tserverVersion,\n\t\tonRestart,\n\t\tlistAllSessions: () => SessionManager.listAll(),\n\t\tdeleteSession: async (path: string) => {\n\t\t\tconst result = await SessionManager.deleteSession(path, {});\n\t\t\tif (!result.ok) throw new Error(result.error ?? \"Unknown deletion error\");\n\t\t\treturn { method: result.method };\n\t\t},\n\t});\n\tcloseDashboard = () => app.closeDashboard();\n\n\tconst host = args.remote ? \"0.0.0.0\" : \"127.0.0.1\";\n\tconst scheme = args.https ? \"https\" : \"http\";\n\n\t// Native TLS: the dashboard terminates TLS itself (no reverse proxy). The\n\t// auth model is unchanged — `req.socket.remoteAddress` is still the real\n\t// tailnet IP, so Tailscale identity resolution + allowlist + pairing keep\n\t// working. Local mode never sets --https (loopback is already a secure\n\t// context), but it's allowed if the operator wants it.\n\tlet server: HttpServer | HttpsServer;\n\tif (args.https && args.cert && args.key) {\n\t\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\n\t\t} catch (err) {\n\t\t\tconsole.error(`error: failed to read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconst httpsServer = createHttpsServer(tlsOptions, app);\n\t\t// Hot-reload the cert on file change (zero-downtime renewal — e.g. a\n\t\t// systemd timer rewrites the `tailscale cert` files daily). New\n\t\t// connections pick up the new cert; existing ones finish on the old.\n\t\tconst reloadTls = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\n\t\tserver = httpsServer;\n\t} else {\n\t\tserver = createHttpServer(app);\n\t}\n\tserver.listen(args.port, host, () => {\n\t\tconsole.log(\n\t\t\t`dreb dashboard listening on ${scheme}://${host === \"0.0.0.0\" ? \"<tailscale-ip>\" : host}:${args.port}`,\n\t\t);\n\t\tif (args.remote) {\n\t\t\tconsole.log(`remote mode: allowed identities = ${args.allow.join(\", \")}`);\n\t\t\tconst { code, expiresInMs } = auth.currentPairingCode();\n\t\t\tconsole.log(\n\t\t\t\t`pairing code: ${code} (rotates every 30s; current code rolls in ${Math.ceil(expiresInMs / 1000)}s)`,\n\t\t\t);\n\t\t\tconsole.log(\"new devices enter this code; see it live in dashboard Settings on the host machine\");\n\t\t} else {\n\t\t\tconsole.log(\"local mode: loopback only — use --remote for Tailscale access\");\n\t\t}\n\t\tif (args.https) {\n\t\t\tconsole.log(\"tls: native HTTPS enabled (cert + key hot-reload on file change)\");\n\t\t} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\n\tprocess.on(\"SIGINT\", shutdown);\n\tprocess.on(\"SIGTERM\", shutdown);\n}\n\n// Only run when executed directly (not when imported for the library exports).\nconst entryPath = process.argv[1];\nif (entryPath && import.meta.url === new URL(`file://${entryPath}`).href) {\n\tmain().catch((err) => {\n\t\tconsole.error(`fatal: ${err instanceof Error ? err.message : String(err)}`);\n\t\tprocess.exit(1);\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n/**\n * dreb-dashboard — launch the dreb web dashboard server.\n *\n * Modes (exactly two, no LAN mode):\n * default loopback bind (127.0.0.1), no auth, no Tailscale needed\n * --remote requires Tailscale; binds all interfaces but every request\n * passes identity allowlist + pairing code + device cookies\n *\n * Usage:\n * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]\n */\n\nimport { existsSync, readFileSync, watch } from \"node:fs\";\nimport { createServer as createHttpServer, type Server as HttpServer } from \"node:http\";\nimport { createServer as createHttpsServer, type Server as HttpsServer } from \"node:https\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { DashboardImageService } from \"./server/dashboard-images.js\";\nimport { ImagePreviewWorker } from \"./server/image-preview.js\";\nimport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nimport { RuntimePool } from \"./server/runtime-pool.js\";\nimport { createDashboardServer } from \"./server/server.js\";\n\nexport { DashboardAuth, TailscaleStatusResolver, TailscaleWhoisResolver } from \"./server/auth.js\";\nexport { DashboardImageService } from \"./server/dashboard-images.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { ImagePreviewWorker } from \"./server/image-preview.js\";\nexport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nexport { RuntimePool, resolveDrebCliPath } from \"./server/runtime-pool.js\";\nexport { createDashboardServer, parseDeviceCookie } from \"./server/server.js\";\nexport type * from \"./shared/protocol.js\";\n\nconst DEFAULT_PORT = 5343;\n\ninterface CliArgs {\n\tport: number;\n\tremote: boolean;\n\tallow: string[];\n\thelp: boolean;\n\thttps: boolean;\n\tcert?: string;\n\tkey?: string;\n}\n\nexport function parseArgs(argv: string[]): CliArgs {\n\tconst args: CliArgs = { port: DEFAULT_PORT, remote: false, allow: [], help: false, https: false };\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst arg = argv[i];\n\t\tif (arg === \"--port\") {\n\t\t\tconst value = argv[++i];\n\t\t\tconst port = Number.parseInt(value ?? \"\", 10);\n\t\t\tif (!Number.isInteger(port) || port < 1 || port > 65535) {\n\t\t\t\tthrow new Error(`Invalid --port value: ${value}`);\n\t\t\t}\n\t\t\targs.port = port;\n\t\t} else if (arg === \"--remote\") {\n\t\t\targs.remote = true;\n\t\t} else if (arg === \"--allow\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--allow requires an identity (Tailscale login name)\");\n\t\t\targs.allow.push(value);\n\t\t} else if (arg === \"--https\") {\n\t\t\targs.https = true;\n\t\t} else if (arg === \"--cert\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--cert requires a path to a PEM certificate file\");\n\t\t\targs.cert = value;\n\t\t} else if (arg === \"--key\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--key requires a path to a PEM private key file\");\n\t\t\targs.key = value;\n\t\t} else if (arg === \"--help\" || arg === \"-h\") {\n\t\t\targs.help = true;\n\t\t} else {\n\t\t\tthrow new Error(`Unknown argument: ${arg}`);\n\t\t}\n\t}\n\tif (args.remote && args.allow.length === 0) {\n\t\tthrow new Error(\"--remote requires at least one --allow <tailscale-login> (empty allowlist denies everyone)\");\n\t}\n\t// Native TLS. `https` is opt-in; it requires cert AND key (no silent\n\t// fallback to plain HTTP). The dashboard terminates TLS itself — no reverse\n\t// proxy, no auth-model change. `req.socket.remoteAddress` stays the real\n\t// tailnet IP, so identity resolution + allowlist + pairing keep working.\n\tif (args.https) {\n\t\tif (!args.cert) throw new Error(\"--https requires --cert <path>\");\n\t\tif (!args.key) throw new Error(\"--https requires --key <path>\");\n\t}\n\treturn args;\n}\n\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\n}\n\nconst HELP = `dreb-dashboard — dreb web dashboard server\n\nUsage: dreb-dashboard [options]\n\nOptions:\n --port <n> Port to listen on (default ${DEFAULT_PORT})\n --remote Enable remote mode (requires Tailscale). Without this\n flag the server binds 127.0.0.1 only — no LAN access.\n Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\n --allow <identity> Tailscale login name allowed to pair (repeatable;\n required with --remote)\n --https Terminate TLS on the dashboard itself (native TLS). No\n reverse proxy, no auth-model change. Requires --cert\n and --key. Mainly for --remote (loopback is already a\n secure context): use 'tailscale cert' files for a\n tailnet hostname so mobile PWAs + notifications work.\n NOTE: with --https the server speaks TLS only, so the\n host's plain-http local tab (http://127.0.0.1) stops\n working — use the tailnet hostname (https://...) there.\n --cert <path> PEM certificate file (required with --https)\n --key <path> PEM private key file (required with --https)\n --help Show this help\n`;\n\nasync function main(): Promise<void> {\n\tlet args: CliArgs;\n\ttry {\n\t\targs = parseArgs(process.argv.slice(2));\n\t} catch (err) {\n\t\tconsole.error(`error: ${err instanceof Error ? err.message : String(err)}`);\n\t\tconsole.error(HELP);\n\t\tprocess.exit(1);\n\t}\n\tif (args.help) {\n\t\tconsole.log(HELP);\n\t\treturn;\n\t}\n\n\tconst agentDir = join(homedir(), \".dreb\", \"agent\");\n\tconst auth = new DashboardAuth({\n\t\tremoteEnabled: args.remote,\n\t\tallowedIdentities: args.allow,\n\t\tstorage: new FilePairingStorage(join(agentDir, \"dashboard-pairings.json\")),\n\t\tsecret: loadOrCreateDashboardSecret(join(agentDir, \"dashboard-auth-secret\")),\n\t\tlogger: (line) => console.warn(`[dashboard-auth] ${line}`),\n\t});\n\tconst pool = new RuntimePool();\n\tconst imageService = new DashboardImageService(new ImagePreviewWorker());\n\n\t// Static client assets live next to the compiled server (dist/static).\n\tconst staticDir = join(dirname(fileURLToPath(import.meta.url)), \"static\");\n\n\t// The server's own build version (dist/index.js → ../package.json) — surfaced\n\t// in the settings footer so a stale long-running service is spottable.\n\tlet serverVersion: string | undefined;\n\ttry {\n\t\tconst pkgPath = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\");\n\t\tserverVersion = JSON.parse(readFileSync(pkgPath, \"utf8\")).version;\n\t} catch {\n\t\tserverVersion = undefined;\n\t}\n\n\t// Restart hook: exit non-zero so a supervisor (systemd Restart=on-failure,\n\t// etc.) respawns the process with the freshly-built dist. Assigned the http\n\t// server below via a mutable holder so the closure can close it first.\n\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tlet closeDashboard = () => imageService.close();\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => Promise.all([pool.stopAll(), closeDashboard()]),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\timageService,\n\t\tstaticDir: existsSync(staticDir) ? staticDir : undefined,\n\t\tserverVersion,\n\t\tonRestart,\n\t\tlistAllSessions: () => SessionManager.listAll(),\n\t\tdeleteSession: async (path: string) => {\n\t\t\tconst result = await SessionManager.deleteSession(path, {});\n\t\t\tif (!result.ok) throw new Error(result.error ?? \"Unknown deletion error\");\n\t\t\treturn { method: result.method };\n\t\t},\n\t});\n\tcloseDashboard = () => app.closeDashboard();\n\n\tconst host = args.remote ? \"0.0.0.0\" : \"127.0.0.1\";\n\tconst scheme = args.https ? \"https\" : \"http\";\n\n\t// Native TLS: the dashboard terminates TLS itself (no reverse proxy). The\n\t// auth model is unchanged — `req.socket.remoteAddress` is still the real\n\t// tailnet IP, so Tailscale identity resolution + allowlist + pairing keep\n\t// working. Local mode never sets --https (loopback is already a secure\n\t// context), but it's allowed if the operator wants it.\n\tlet server: HttpServer | HttpsServer;\n\tif (args.https && args.cert && args.key) {\n\t\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\n\t\t} catch (err) {\n\t\t\tconsole.error(`error: failed to read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconst httpsServer = createHttpsServer(tlsOptions, app);\n\t\t// Hot-reload the cert on file change (zero-downtime renewal — e.g. a\n\t\t// systemd timer rewrites the `tailscale cert` files daily). New\n\t\t// connections pick up the new cert; existing ones finish on the old.\n\t\tconst reloadTls = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\n\t\tserver = httpsServer;\n\t} else {\n\t\tserver = createHttpServer(app);\n\t}\n\tserver.listen(args.port, host, () => {\n\t\tconsole.log(\n\t\t\t`dreb dashboard listening on ${scheme}://${host === \"0.0.0.0\" ? \"<tailscale-ip>\" : host}:${args.port}`,\n\t\t);\n\t\tif (args.remote) {\n\t\t\tconsole.log(`remote mode: allowed identities = ${args.allow.join(\", \")}`);\n\t\t\tconst { code, expiresInMs } = auth.currentPairingCode();\n\t\t\tconsole.log(\n\t\t\t\t`pairing code: ${code} (rotates every 30s; current code rolls in ${Math.ceil(expiresInMs / 1000)}s)`,\n\t\t\t);\n\t\t\tconsole.log(\"new devices enter this code; see it live in dashboard Settings on the host machine\");\n\t\t} else {\n\t\t\tconsole.log(\"local mode: loopback only — use --remote for Tailscale access\");\n\t\t}\n\t\tif (args.https) {\n\t\t\tconsole.log(\"tls: native HTTPS enabled (cert + key hot-reload on file change)\");\n\t\t} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\n\tprocess.on(\"SIGINT\", shutdown);\n\tprocess.on(\"SIGTERM\", shutdown);\n}\n\n// Only run when executed directly (not when imported for the library exports).\nconst entryPath = process.argv[1];\nif (entryPath && import.meta.url === new URL(`file://${entryPath}`).href) {\n\tmain().catch((err) => {\n\t\tconsole.error(`fatal: ${err instanceof Error ? err.message : String(err)}`);\n\t\tprocess.exit(1);\n\t});\n}\n"]}
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { ImagePreviewWorker } from "./server/image-preview.js";
|
|
|
22
22
|
import { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js";
|
|
23
23
|
import { RuntimePool } from "./server/runtime-pool.js";
|
|
24
24
|
import { createDashboardServer } from "./server/server.js";
|
|
25
|
-
export { DashboardAuth, TailscaleStatusResolver } from "./server/auth.js";
|
|
25
|
+
export { DashboardAuth, TailscaleStatusResolver, TailscaleWhoisResolver } from "./server/auth.js";
|
|
26
26
|
export { DashboardImageService } from "./server/dashboard-images.js";
|
|
27
27
|
export { EventHub } from "./server/event-hub.js";
|
|
28
28
|
export { canonicalizePath, FileApi } from "./server/files.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n/**\n * dreb-dashboard — launch the dreb web dashboard server.\n *\n * Modes (exactly two, no LAN mode):\n * default loopback bind (127.0.0.1), no auth, no Tailscale needed\n * --remote requires Tailscale; binds all interfaces but every request\n * passes identity allowlist + pairing code + device cookies\n *\n * Usage:\n * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]\n */\n\nimport { existsSync, readFileSync, watch } from \"node:fs\";\nimport { createServer as createHttpServer, type Server as HttpServer } from \"node:http\";\nimport { createServer as createHttpsServer, type Server as HttpsServer } from \"node:https\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { DashboardImageService } from \"./server/dashboard-images.js\";\nimport { ImagePreviewWorker } from \"./server/image-preview.js\";\nimport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nimport { RuntimePool } from \"./server/runtime-pool.js\";\nimport { createDashboardServer } from \"./server/server.js\";\n\nexport { DashboardAuth, TailscaleStatusResolver } from \"./server/auth.js\";\nexport { DashboardImageService } from \"./server/dashboard-images.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { ImagePreviewWorker } from \"./server/image-preview.js\";\nexport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nexport { RuntimePool, resolveDrebCliPath } from \"./server/runtime-pool.js\";\nexport { createDashboardServer, parseDeviceCookie } from \"./server/server.js\";\nexport type * from \"./shared/protocol.js\";\n\nconst DEFAULT_PORT = 5343;\n\ninterface CliArgs {\n\tport: number;\n\tremote: boolean;\n\tallow: string[];\n\thelp: boolean;\n\thttps: boolean;\n\tcert?: string;\n\tkey?: string;\n}\n\nexport function parseArgs(argv: string[]): CliArgs {\n\tconst args: CliArgs = { port: DEFAULT_PORT, remote: false, allow: [], help: false, https: false };\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst arg = argv[i];\n\t\tif (arg === \"--port\") {\n\t\t\tconst value = argv[++i];\n\t\t\tconst port = Number.parseInt(value ?? \"\", 10);\n\t\t\tif (!Number.isInteger(port) || port < 1 || port > 65535) {\n\t\t\t\tthrow new Error(`Invalid --port value: ${value}`);\n\t\t\t}\n\t\t\targs.port = port;\n\t\t} else if (arg === \"--remote\") {\n\t\t\targs.remote = true;\n\t\t} else if (arg === \"--allow\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--allow requires an identity (Tailscale login name)\");\n\t\t\targs.allow.push(value);\n\t\t} else if (arg === \"--https\") {\n\t\t\targs.https = true;\n\t\t} else if (arg === \"--cert\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--cert requires a path to a PEM certificate file\");\n\t\t\targs.cert = value;\n\t\t} else if (arg === \"--key\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--key requires a path to a PEM private key file\");\n\t\t\targs.key = value;\n\t\t} else if (arg === \"--help\" || arg === \"-h\") {\n\t\t\targs.help = true;\n\t\t} else {\n\t\t\tthrow new Error(`Unknown argument: ${arg}`);\n\t\t}\n\t}\n\tif (args.remote && args.allow.length === 0) {\n\t\tthrow new Error(\"--remote requires at least one --allow <tailscale-login> (empty allowlist denies everyone)\");\n\t}\n\t// Native TLS. `https` is opt-in; it requires cert AND key (no silent\n\t// fallback to plain HTTP). The dashboard terminates TLS itself — no reverse\n\t// proxy, no auth-model change. `req.socket.remoteAddress` stays the real\n\t// tailnet IP, so identity resolution + allowlist + pairing keep working.\n\tif (args.https) {\n\t\tif (!args.cert) throw new Error(\"--https requires --cert <path>\");\n\t\tif (!args.key) throw new Error(\"--https requires --key <path>\");\n\t}\n\treturn args;\n}\n\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\n}\n\nconst HELP = `dreb-dashboard — dreb web dashboard server\n\nUsage: dreb-dashboard [options]\n\nOptions:\n --port <n> Port to listen on (default ${DEFAULT_PORT})\n --remote Enable remote mode (requires Tailscale). Without this\n flag the server binds 127.0.0.1 only — no LAN access.\n Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\n --allow <identity> Tailscale login name allowed to pair (repeatable;\n required with --remote)\n --https Terminate TLS on the dashboard itself (native TLS). No\n reverse proxy, no auth-model change. Requires --cert\n and --key. Mainly for --remote (loopback is already a\n secure context): use 'tailscale cert' files for a\n tailnet hostname so mobile PWAs + notifications work.\n NOTE: with --https the server speaks TLS only, so the\n host's plain-http local tab (http://127.0.0.1) stops\n working — use the tailnet hostname (https://...) there.\n --cert <path> PEM certificate file (required with --https)\n --key <path> PEM private key file (required with --https)\n --help Show this help\n`;\n\nasync function main(): Promise<void> {\n\tlet args: CliArgs;\n\ttry {\n\t\targs = parseArgs(process.argv.slice(2));\n\t} catch (err) {\n\t\tconsole.error(`error: ${err instanceof Error ? err.message : String(err)}`);\n\t\tconsole.error(HELP);\n\t\tprocess.exit(1);\n\t}\n\tif (args.help) {\n\t\tconsole.log(HELP);\n\t\treturn;\n\t}\n\n\tconst agentDir = join(homedir(), \".dreb\", \"agent\");\n\tconst auth = new DashboardAuth({\n\t\tremoteEnabled: args.remote,\n\t\tallowedIdentities: args.allow,\n\t\tstorage: new FilePairingStorage(join(agentDir, \"dashboard-pairings.json\")),\n\t\tsecret: loadOrCreateDashboardSecret(join(agentDir, \"dashboard-auth-secret\")),\n\t\tlogger: (line) => console.warn(`[dashboard-auth] ${line}`),\n\t});\n\tconst pool = new RuntimePool();\n\tconst imageService = new DashboardImageService(new ImagePreviewWorker());\n\n\t// Static client assets live next to the compiled server (dist/static).\n\tconst staticDir = join(dirname(fileURLToPath(import.meta.url)), \"static\");\n\n\t// The server's own build version (dist/index.js → ../package.json) — surfaced\n\t// in the settings footer so a stale long-running service is spottable.\n\tlet serverVersion: string | undefined;\n\ttry {\n\t\tconst pkgPath = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\");\n\t\tserverVersion = JSON.parse(readFileSync(pkgPath, \"utf8\")).version;\n\t} catch {\n\t\tserverVersion = undefined;\n\t}\n\n\t// Restart hook: exit non-zero so a supervisor (systemd Restart=on-failure,\n\t// etc.) respawns the process with the freshly-built dist. Assigned the http\n\t// server below via a mutable holder so the closure can close it first.\n\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tlet closeDashboard = () => imageService.close();\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => Promise.all([pool.stopAll(), closeDashboard()]),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\timageService,\n\t\tstaticDir: existsSync(staticDir) ? staticDir : undefined,\n\t\tserverVersion,\n\t\tonRestart,\n\t\tlistAllSessions: () => SessionManager.listAll(),\n\t\tdeleteSession: async (path: string) => {\n\t\t\tconst result = await SessionManager.deleteSession(path, {});\n\t\t\tif (!result.ok) throw new Error(result.error ?? \"Unknown deletion error\");\n\t\t\treturn { method: result.method };\n\t\t},\n\t});\n\tcloseDashboard = () => app.closeDashboard();\n\n\tconst host = args.remote ? \"0.0.0.0\" : \"127.0.0.1\";\n\tconst scheme = args.https ? \"https\" : \"http\";\n\n\t// Native TLS: the dashboard terminates TLS itself (no reverse proxy). The\n\t// auth model is unchanged — `req.socket.remoteAddress` is still the real\n\t// tailnet IP, so Tailscale identity resolution + allowlist + pairing keep\n\t// working. Local mode never sets --https (loopback is already a secure\n\t// context), but it's allowed if the operator wants it.\n\tlet server: HttpServer | HttpsServer;\n\tif (args.https && args.cert && args.key) {\n\t\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\n\t\t} catch (err) {\n\t\t\tconsole.error(`error: failed to read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconst httpsServer = createHttpsServer(tlsOptions, app);\n\t\t// Hot-reload the cert on file change (zero-downtime renewal — e.g. a\n\t\t// systemd timer rewrites the `tailscale cert` files daily). New\n\t\t// connections pick up the new cert; existing ones finish on the old.\n\t\tconst reloadTls = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\n\t\tserver = httpsServer;\n\t} else {\n\t\tserver = createHttpServer(app);\n\t}\n\tserver.listen(args.port, host, () => {\n\t\tconsole.log(\n\t\t\t`dreb dashboard listening on ${scheme}://${host === \"0.0.0.0\" ? \"<tailscale-ip>\" : host}:${args.port}`,\n\t\t);\n\t\tif (args.remote) {\n\t\t\tconsole.log(`remote mode: allowed identities = ${args.allow.join(\", \")}`);\n\t\t\tconst { code, expiresInMs } = auth.currentPairingCode();\n\t\t\tconsole.log(\n\t\t\t\t`pairing code: ${code} (rotates every 30s; current code rolls in ${Math.ceil(expiresInMs / 1000)}s)`,\n\t\t\t);\n\t\t\tconsole.log(\"new devices enter this code; see it live in dashboard Settings on the host machine\");\n\t\t} else {\n\t\t\tconsole.log(\"local mode: loopback only — use --remote for Tailscale access\");\n\t\t}\n\t\tif (args.https) {\n\t\t\tconsole.log(\"tls: native HTTPS enabled (cert + key hot-reload on file change)\");\n\t\t} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\n\tprocess.on(\"SIGINT\", shutdown);\n\tprocess.on(\"SIGTERM\", shutdown);\n}\n\n// Only run when executed directly (not when imported for the library exports).\nconst entryPath = process.argv[1];\nif (entryPath && import.meta.url === new URL(`file://${entryPath}`).href) {\n\tmain().catch((err) => {\n\t\tconsole.error(`fatal: ${err instanceof Error ? err.message : String(err)}`);\n\t\tprocess.exit(1);\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n/**\n * dreb-dashboard — launch the dreb web dashboard server.\n *\n * Modes (exactly two, no LAN mode):\n * default loopback bind (127.0.0.1), no auth, no Tailscale needed\n * --remote requires Tailscale; binds all interfaces but every request\n * passes identity allowlist + pairing code + device cookies\n *\n * Usage:\n * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]\n */\n\nimport { existsSync, readFileSync, watch } from \"node:fs\";\nimport { createServer as createHttpServer, type Server as HttpServer } from \"node:http\";\nimport { createServer as createHttpsServer, type Server as HttpsServer } from \"node:https\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { DashboardImageService } from \"./server/dashboard-images.js\";\nimport { ImagePreviewWorker } from \"./server/image-preview.js\";\nimport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nimport { RuntimePool } from \"./server/runtime-pool.js\";\nimport { createDashboardServer } from \"./server/server.js\";\n\nexport { DashboardAuth, TailscaleStatusResolver, TailscaleWhoisResolver } from \"./server/auth.js\";\nexport { DashboardImageService } from \"./server/dashboard-images.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { ImagePreviewWorker } from \"./server/image-preview.js\";\nexport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nexport { RuntimePool, resolveDrebCliPath } from \"./server/runtime-pool.js\";\nexport { createDashboardServer, parseDeviceCookie } from \"./server/server.js\";\nexport type * from \"./shared/protocol.js\";\n\nconst DEFAULT_PORT = 5343;\n\ninterface CliArgs {\n\tport: number;\n\tremote: boolean;\n\tallow: string[];\n\thelp: boolean;\n\thttps: boolean;\n\tcert?: string;\n\tkey?: string;\n}\n\nexport function parseArgs(argv: string[]): CliArgs {\n\tconst args: CliArgs = { port: DEFAULT_PORT, remote: false, allow: [], help: false, https: false };\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst arg = argv[i];\n\t\tif (arg === \"--port\") {\n\t\t\tconst value = argv[++i];\n\t\t\tconst port = Number.parseInt(value ?? \"\", 10);\n\t\t\tif (!Number.isInteger(port) || port < 1 || port > 65535) {\n\t\t\t\tthrow new Error(`Invalid --port value: ${value}`);\n\t\t\t}\n\t\t\targs.port = port;\n\t\t} else if (arg === \"--remote\") {\n\t\t\targs.remote = true;\n\t\t} else if (arg === \"--allow\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--allow requires an identity (Tailscale login name)\");\n\t\t\targs.allow.push(value);\n\t\t} else if (arg === \"--https\") {\n\t\t\targs.https = true;\n\t\t} else if (arg === \"--cert\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--cert requires a path to a PEM certificate file\");\n\t\t\targs.cert = value;\n\t\t} else if (arg === \"--key\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--key requires a path to a PEM private key file\");\n\t\t\targs.key = value;\n\t\t} else if (arg === \"--help\" || arg === \"-h\") {\n\t\t\targs.help = true;\n\t\t} else {\n\t\t\tthrow new Error(`Unknown argument: ${arg}`);\n\t\t}\n\t}\n\tif (args.remote && args.allow.length === 0) {\n\t\tthrow new Error(\"--remote requires at least one --allow <tailscale-login> (empty allowlist denies everyone)\");\n\t}\n\t// Native TLS. `https` is opt-in; it requires cert AND key (no silent\n\t// fallback to plain HTTP). The dashboard terminates TLS itself — no reverse\n\t// proxy, no auth-model change. `req.socket.remoteAddress` stays the real\n\t// tailnet IP, so identity resolution + allowlist + pairing keep working.\n\tif (args.https) {\n\t\tif (!args.cert) throw new Error(\"--https requires --cert <path>\");\n\t\tif (!args.key) throw new Error(\"--https requires --key <path>\");\n\t}\n\treturn args;\n}\n\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\n}\n\nconst HELP = `dreb-dashboard — dreb web dashboard server\n\nUsage: dreb-dashboard [options]\n\nOptions:\n --port <n> Port to listen on (default ${DEFAULT_PORT})\n --remote Enable remote mode (requires Tailscale). Without this\n flag the server binds 127.0.0.1 only — no LAN access.\n Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\n --allow <identity> Tailscale login name allowed to pair (repeatable;\n required with --remote)\n --https Terminate TLS on the dashboard itself (native TLS). No\n reverse proxy, no auth-model change. Requires --cert\n and --key. Mainly for --remote (loopback is already a\n secure context): use 'tailscale cert' files for a\n tailnet hostname so mobile PWAs + notifications work.\n NOTE: with --https the server speaks TLS only, so the\n host's plain-http local tab (http://127.0.0.1) stops\n working — use the tailnet hostname (https://...) there.\n --cert <path> PEM certificate file (required with --https)\n --key <path> PEM private key file (required with --https)\n --help Show this help\n`;\n\nasync function main(): Promise<void> {\n\tlet args: CliArgs;\n\ttry {\n\t\targs = parseArgs(process.argv.slice(2));\n\t} catch (err) {\n\t\tconsole.error(`error: ${err instanceof Error ? err.message : String(err)}`);\n\t\tconsole.error(HELP);\n\t\tprocess.exit(1);\n\t}\n\tif (args.help) {\n\t\tconsole.log(HELP);\n\t\treturn;\n\t}\n\n\tconst agentDir = join(homedir(), \".dreb\", \"agent\");\n\tconst auth = new DashboardAuth({\n\t\tremoteEnabled: args.remote,\n\t\tallowedIdentities: args.allow,\n\t\tstorage: new FilePairingStorage(join(agentDir, \"dashboard-pairings.json\")),\n\t\tsecret: loadOrCreateDashboardSecret(join(agentDir, \"dashboard-auth-secret\")),\n\t\tlogger: (line) => console.warn(`[dashboard-auth] ${line}`),\n\t});\n\tconst pool = new RuntimePool();\n\tconst imageService = new DashboardImageService(new ImagePreviewWorker());\n\n\t// Static client assets live next to the compiled server (dist/static).\n\tconst staticDir = join(dirname(fileURLToPath(import.meta.url)), \"static\");\n\n\t// The server's own build version (dist/index.js → ../package.json) — surfaced\n\t// in the settings footer so a stale long-running service is spottable.\n\tlet serverVersion: string | undefined;\n\ttry {\n\t\tconst pkgPath = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\");\n\t\tserverVersion = JSON.parse(readFileSync(pkgPath, \"utf8\")).version;\n\t} catch {\n\t\tserverVersion = undefined;\n\t}\n\n\t// Restart hook: exit non-zero so a supervisor (systemd Restart=on-failure,\n\t// etc.) respawns the process with the freshly-built dist. Assigned the http\n\t// server below via a mutable holder so the closure can close it first.\n\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tlet closeDashboard = () => imageService.close();\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => Promise.all([pool.stopAll(), closeDashboard()]),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\timageService,\n\t\tstaticDir: existsSync(staticDir) ? staticDir : undefined,\n\t\tserverVersion,\n\t\tonRestart,\n\t\tlistAllSessions: () => SessionManager.listAll(),\n\t\tdeleteSession: async (path: string) => {\n\t\t\tconst result = await SessionManager.deleteSession(path, {});\n\t\t\tif (!result.ok) throw new Error(result.error ?? \"Unknown deletion error\");\n\t\t\treturn { method: result.method };\n\t\t},\n\t});\n\tcloseDashboard = () => app.closeDashboard();\n\n\tconst host = args.remote ? \"0.0.0.0\" : \"127.0.0.1\";\n\tconst scheme = args.https ? \"https\" : \"http\";\n\n\t// Native TLS: the dashboard terminates TLS itself (no reverse proxy). The\n\t// auth model is unchanged — `req.socket.remoteAddress` is still the real\n\t// tailnet IP, so Tailscale identity resolution + allowlist + pairing keep\n\t// working. Local mode never sets --https (loopback is already a secure\n\t// context), but it's allowed if the operator wants it.\n\tlet server: HttpServer | HttpsServer;\n\tif (args.https && args.cert && args.key) {\n\t\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\n\t\t} catch (err) {\n\t\t\tconsole.error(`error: failed to read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconst httpsServer = createHttpsServer(tlsOptions, app);\n\t\t// Hot-reload the cert on file change (zero-downtime renewal — e.g. a\n\t\t// systemd timer rewrites the `tailscale cert` files daily). New\n\t\t// connections pick up the new cert; existing ones finish on the old.\n\t\tconst reloadTls = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\n\t\tserver = httpsServer;\n\t} else {\n\t\tserver = createHttpServer(app);\n\t}\n\tserver.listen(args.port, host, () => {\n\t\tconsole.log(\n\t\t\t`dreb dashboard listening on ${scheme}://${host === \"0.0.0.0\" ? \"<tailscale-ip>\" : host}:${args.port}`,\n\t\t);\n\t\tif (args.remote) {\n\t\t\tconsole.log(`remote mode: allowed identities = ${args.allow.join(\", \")}`);\n\t\t\tconst { code, expiresInMs } = auth.currentPairingCode();\n\t\t\tconsole.log(\n\t\t\t\t`pairing code: ${code} (rotates every 30s; current code rolls in ${Math.ceil(expiresInMs / 1000)}s)`,\n\t\t\t);\n\t\t\tconsole.log(\"new devices enter this code; see it live in dashboard Settings on the host machine\");\n\t\t} else {\n\t\t\tconsole.log(\"local mode: loopback only — use --remote for Tailscale access\");\n\t\t}\n\t\tif (args.https) {\n\t\t\tconsole.log(\"tls: native HTTPS enabled (cert + key hot-reload on file change)\");\n\t\t} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\n\tprocess.on(\"SIGINT\", shutdown);\n\tprocess.on(\"SIGTERM\", shutdown);\n}\n\n// Only run when executed directly (not when imported for the library exports).\nconst entryPath = process.argv[1];\nif (entryPath && import.meta.url === new URL(`file://${entryPath}`).href) {\n\tmain().catch((err) => {\n\t\tconsole.error(`fatal: ${err instanceof Error ? err.message : String(err)}`);\n\t\tprocess.exit(1);\n\t});\n}\n"]}
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* There is no LAN mode. Any auth-subsystem error denies the request.
|
|
17
17
|
*/
|
|
18
|
+
export declare const DEFAULT_PAIRING_TTL_DAYS = 180;
|
|
19
|
+
export declare const MIN_PAIRING_TTL_DAYS = 1;
|
|
20
|
+
export declare const MAX_PAIRING_TTL_DAYS = 3650;
|
|
18
21
|
/** Normalize an address for comparison (strip IPv6-mapped IPv4 prefix and zone). */
|
|
19
22
|
export declare function normalizeAddress(address: string | undefined): string;
|
|
20
23
|
/** True when the (normalized) address is a loopback address. */
|
|
@@ -35,9 +38,23 @@ export interface TailscaleResolver {
|
|
|
35
38
|
/** Resolve a peer IP to a Tailscale identity, or null when unknown. */
|
|
36
39
|
resolve(address: string): Promise<TailscaleIdentity | null>;
|
|
37
40
|
}
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
export type TailscaleWhoisRunner = (address: string) => Promise<{
|
|
42
|
+
stdout: string;
|
|
43
|
+
}>;
|
|
44
|
+
export declare class TailscaleResolverError extends Error {
|
|
45
|
+
readonly kind: "timeout" | "execution" | "parse" | "schema";
|
|
46
|
+
constructor(kind: "timeout" | "execution" | "parse" | "schema");
|
|
47
|
+
}
|
|
48
|
+
/** Peer-specific Tailscale identity resolution with same-peer in-flight coalescing. */
|
|
49
|
+
export declare class TailscaleWhoisResolver implements TailscaleResolver {
|
|
50
|
+
private readonly runWhois;
|
|
51
|
+
private readonly inFlight;
|
|
52
|
+
constructor(runWhois?: TailscaleWhoisRunner);
|
|
40
53
|
resolve(address: string): Promise<TailscaleIdentity | null>;
|
|
54
|
+
private lookup;
|
|
55
|
+
}
|
|
56
|
+
/** @deprecated Use TailscaleWhoisResolver. Retained for API compatibility. */
|
|
57
|
+
export declare class TailscaleStatusResolver extends TailscaleWhoisResolver {
|
|
41
58
|
}
|
|
42
59
|
export interface PairedDevice {
|
|
43
60
|
id: string;
|
|
@@ -49,10 +66,20 @@ export interface PairedDevice {
|
|
|
49
66
|
export interface StoredPairing extends PairedDevice {
|
|
50
67
|
/** HMAC of the device token (raw token never stored). */
|
|
51
68
|
tokenHmac: string;
|
|
69
|
+
/** UTC date of the last expiry warning claimed by this pairing. */
|
|
70
|
+
lastExpiryWarningUtcDate?: string;
|
|
52
71
|
}
|
|
53
72
|
export interface PairingState {
|
|
54
73
|
pairings: StoredPairing[];
|
|
55
74
|
consumedPairingWindows: number[];
|
|
75
|
+
/** Whole-day lifetime for newly created pairings. Absent in legacy files. */
|
|
76
|
+
pairingTtlDays?: number;
|
|
77
|
+
}
|
|
78
|
+
export interface PairingExpiryStatus {
|
|
79
|
+
warning?: {
|
|
80
|
+
expiresAt: string;
|
|
81
|
+
};
|
|
82
|
+
nextCheckAt?: string;
|
|
56
83
|
}
|
|
57
84
|
export interface PairingStorage {
|
|
58
85
|
load(): Promise<PairingState>;
|
|
@@ -69,7 +96,7 @@ export interface DashboardAuthOptions {
|
|
|
69
96
|
remoteEnabled?: boolean;
|
|
70
97
|
/** Allowed Tailscale login names. Empty = deny all remote. */
|
|
71
98
|
allowedIdentities?: string[];
|
|
72
|
-
/**
|
|
99
|
+
/** Test/backward-compatible default when no persisted day setting exists. Production defaults to 180 days. */
|
|
73
100
|
pairingTtlMs?: number;
|
|
74
101
|
resolver?: TailscaleResolver;
|
|
75
102
|
storage?: PairingStorage;
|
|
@@ -91,6 +118,7 @@ export type AuthDecision = {
|
|
|
91
118
|
allowed: true;
|
|
92
119
|
mode: "remote";
|
|
93
120
|
identity: TailscaleIdentity;
|
|
121
|
+
pairing: PairedDevice;
|
|
94
122
|
} | {
|
|
95
123
|
allowed: false;
|
|
96
124
|
status: number;
|
|
@@ -110,7 +138,7 @@ export interface AuthRequestInfo {
|
|
|
110
138
|
export declare class DashboardAuth {
|
|
111
139
|
private readonly remoteEnabled;
|
|
112
140
|
private readonly allowedIdentities;
|
|
113
|
-
private readonly
|
|
141
|
+
private readonly defaultPairingTtlMs;
|
|
114
142
|
private readonly resolver;
|
|
115
143
|
private readonly storage;
|
|
116
144
|
private readonly secret;
|
|
@@ -122,6 +150,13 @@ export declare class DashboardAuth {
|
|
|
122
150
|
private readonly pairingFailures;
|
|
123
151
|
constructor(options?: DashboardAuthOptions);
|
|
124
152
|
get isRemoteEnabled(): boolean;
|
|
153
|
+
private defaultPairingTtlDays;
|
|
154
|
+
getPairingSettings(): Promise<{
|
|
155
|
+
pairingTtlDays: number;
|
|
156
|
+
}>;
|
|
157
|
+
setPairingSettings(pairingTtlDays: number): Promise<{
|
|
158
|
+
pairingTtlDays: number;
|
|
159
|
+
}>;
|
|
125
160
|
private hmac;
|
|
126
161
|
private pairingCodeForWindow;
|
|
127
162
|
/** Current RFC-6238-style rotating code for pairing new remote devices. */
|
|
@@ -153,11 +188,14 @@ export declare class DashboardAuth {
|
|
|
153
188
|
token: string;
|
|
154
189
|
device: PairedDevice;
|
|
155
190
|
}>;
|
|
191
|
+
private toPairedDevice;
|
|
156
192
|
/** List paired devices (live only). */
|
|
157
193
|
listDevices(): Promise<PairedDevice[]>;
|
|
158
194
|
/** Remove a paired device by id. Returns true when something was removed. */
|
|
159
195
|
unpair(deviceId: string): Promise<boolean>;
|
|
160
|
-
private
|
|
196
|
+
private findPairing;
|
|
197
|
+
/** Atomically claim any due warning and return the next useful browser check time. */
|
|
198
|
+
claimPairingExpiryStatus(pairingId: string): Promise<PairingExpiryStatus>;
|
|
161
199
|
private loadLive;
|
|
162
200
|
private loadLiveState;
|
|
163
201
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Dashboard auth — exactly two modes (this file is the authority):\n *\n * Mode A — local-only (default): the server binds loopback only. Requests are\n * additionally checked for loopback source address AND an allowlisted Host\n * header (DNS-rebinding defense: a malicious website can point its own domain\n * at 127.0.0.1 and drive the API from the victim's browser unless Host is\n * validated). No login, no pairing.\n *\n * Mode B — remote (explicit opt-in): requires Tailscale. Enforcement layers,\n * all fail-closed: (1) Tailscale identity resolution of the peer address,\n * (2) identity allowlist (empty allowlist = deny all), (3) first-login\n * rotating pairing code (visible only from the host/local dashboard),\n * (4) signed per-device cookie thereafter.\n *\n * There is no LAN mode. Any auth-subsystem error denies the request.\n */\n\nimport { execFile } from \"node:child_process\";\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\nconst PAIRING_CODE_STEP_MS = 30_000;\nconst DEFAULT_PAIRING_MAX_ATTEMPTS = 5;\nconst DEFAULT_PAIRING_LOCKOUT_MS = 60_000;\n\n// ---------------------------------------------------------------------------\n// Address / Host helpers\n// ---------------------------------------------------------------------------\n\n/** Normalize an address for comparison (strip IPv6-mapped IPv4 prefix and zone). */\nexport function normalizeAddress(address: string | undefined): string {\n\tif (!address) return \"\";\n\tlet a = address.trim();\n\tif (a.startsWith(\"[\") && a.endsWith(\"]\")) a = a.slice(1, -1);\n\tconst zone = a.indexOf(\"%\");\n\tif (zone !== -1) a = a.slice(0, zone);\n\tif (a.startsWith(\"::ffff:\")) a = a.slice(7);\n\treturn a;\n}\n\n/** True when the (normalized) address is a loopback address. */\nexport function isLoopbackAddress(address: string | undefined): boolean {\n\tconst a = normalizeAddress(address);\n\tif (!a) return false;\n\tif (a === \"::1\") return true;\n\treturn /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(a);\n}\n\n/**\n * Validate a Host header against the loopback allowlist. Rejecting foreign\n * hosts breaks DNS rebinding: the attacker's page can reach 127.0.0.1, but its\n * requests carry the attacker's hostname in Host.\n */\nexport function isAllowedLocalHost(hostHeader: string | undefined): boolean {\n\tif (!hostHeader) return false;\n\t// Strip port. IPv6 hosts arrive as \"[::1]:port\".\n\tlet host = hostHeader.trim().toLowerCase();\n\tconst v6 = host.match(/^\\[([^\\]]+)\\](?::\\d+)?$/);\n\tif (v6) {\n\t\thost = v6[1];\n\t} else {\n\t\tconst colon = host.lastIndexOf(\":\");\n\t\tif (colon !== -1 && /^\\d+$/.test(host.slice(colon + 1))) host = host.slice(0, colon);\n\t}\n\treturn host === \"localhost\" || host === \"::1\" || /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(host);\n}\n\n// ---------------------------------------------------------------------------\n// Tailscale identity\n// ---------------------------------------------------------------------------\n\nexport interface TailscaleIdentity {\n\t/** Login name (e.g. \"alice@example.com\") — the allowlist unit. */\n\tloginName: string;\n\t/** Device host name, when known. */\n\tdevice?: string;\n}\n\nexport interface TailscaleResolver {\n\t/** Resolve a peer IP to a Tailscale identity, or null when unknown. */\n\tresolve(address: string): Promise<TailscaleIdentity | null>;\n}\n\ninterface TailscaleStatusPeer {\n\tTailscaleIPs?: string[];\n\tHostName?: string;\n\tUserID?: number;\n}\n\ninterface TailscaleStatusJson {\n\tSelf?: TailscaleStatusPeer & { UserID?: number };\n\tPeer?: Record<string, TailscaleStatusPeer>;\n\tUser?: Record<string, { LoginName?: string }>;\n}\n\n/** Resolves identities via `tailscale status --json`. Any failure resolves null (deny). */\nexport class TailscaleStatusResolver implements TailscaleResolver {\n\tasync resolve(address: string): Promise<TailscaleIdentity | null> {\n\t\tconst target = normalizeAddress(address);\n\t\tif (!target) return null;\n\t\tlet status: TailscaleStatusJson;\n\t\ttry {\n\t\t\tconst { stdout } = await execFileAsync(\"tailscale\", [\"status\", \"--json\"], {\n\t\t\t\ttimeout: 3000,\n\t\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\t});\n\t\t\tstatus = JSON.parse(stdout) as TailscaleStatusJson;\n\t\t} catch {\n\t\t\t// Tailscale absent, not running, or unparseable — fail closed.\n\t\t\treturn null;\n\t\t}\n\t\tconst peers = Object.values(status.Peer ?? {});\n\t\tif (status.Self) peers.push(status.Self);\n\t\tfor (const peer of peers) {\n\t\t\tif (!peer.TailscaleIPs?.some((ip) => normalizeAddress(ip) === target)) continue;\n\t\t\tconst userId = peer.UserID;\n\t\t\tconst loginName = userId !== undefined ? status.User?.[String(userId)]?.LoginName : undefined;\n\t\t\tif (!loginName) return null; // identity unknown — deny rather than guess\n\t\t\treturn { loginName, device: peer.HostName };\n\t\t}\n\t\treturn null;\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Pairing store (rotating pairing codes + device tokens)\n// ---------------------------------------------------------------------------\n\nexport interface PairedDevice {\n\tid: string;\n\tidentity: string;\n\tdevice?: string;\n\tcreatedAt: string;\n\texpiresAt: string;\n}\n\nexport interface StoredPairing extends PairedDevice {\n\t/** HMAC of the device token (raw token never stored). */\n\ttokenHmac: string;\n}\n\nexport interface PairingState {\n\tpairings: StoredPairing[];\n\tconsumedPairingWindows: number[];\n}\n\nexport interface PairingStorage {\n\tload(): Promise<PairingState>;\n\tsave(state: PairingState): Promise<void>;\n}\n\n/** In-memory storage — used in tests and as the base for the file store. */\nexport class MemoryPairingStorage implements PairingStorage {\n\tprivate state: PairingState = { pairings: [], consumedPairingWindows: [] };\n\tasync load(): Promise<PairingState> {\n\t\treturn {\n\t\t\tpairings: [...this.state.pairings],\n\t\t\tconsumedPairingWindows: [...this.state.consumedPairingWindows],\n\t\t};\n\t}\n\tasync save(state: PairingState): Promise<void> {\n\t\tthis.state = {\n\t\t\tpairings: [...state.pairings],\n\t\t\tconsumedPairingWindows: [...state.consumedPairingWindows],\n\t\t};\n\t}\n}\n\nexport interface DashboardAuthOptions {\n\t/** Remote (Tailscale) mode. Default false — loopback only. */\n\tremoteEnabled?: boolean;\n\t/** Allowed Tailscale login names. Empty = deny all remote. */\n\tallowedIdentities?: string[];\n\t/** Device pairing validity. Default 30 days. */\n\tpairingTtlMs?: number;\n\tresolver?: TailscaleResolver;\n\tstorage?: PairingStorage;\n\t/** HMAC/TOTP secret for device tokens and pairing codes. Production passes a per-install persisted secret. */\n\tsecret?: Buffer;\n\t/** Failed PIN attempts before temporary lockout. Default 5. */\n\tpairingMaxAttempts?: number;\n\t/** Temporary lockout duration after too many failed PIN attempts. Default 60s. */\n\tpairingLockoutMs?: number;\n\t/** Security/audit log sink for repeated failed pairing attempts. */\n\tlogger?: (line: string) => void;\n\t/** Clock override for tests. */\n\tnow?: () => number;\n}\n\nexport type AuthDecision =\n\t| { allowed: true; mode: \"local\" }\n\t| { allowed: true; mode: \"remote\"; identity: TailscaleIdentity }\n\t| {\n\t\t\tallowed: false;\n\t\t\tstatus: number;\n\t\t\treason: string;\n\t\t\t/** Set when an allowed identity needs pairing-code entry. */\n\t\t\tneedsPairing?: boolean;\n\t\t\tidentity?: TailscaleIdentity;\n\t };\n\nexport interface AuthRequestInfo {\n\tremoteAddress: string | undefined;\n\thostHeader: string | undefined;\n\t/** Origin header when present. Non-loopback origins are rejected on local requests. */\n\toriginHeader: string | undefined;\n\t/** Value of the dashboard device cookie, when present. */\n\tdeviceToken: string | undefined;\n}\n\nfunction timingSafeEqualStr(a: string, b: string): boolean {\n\tconst ab = Buffer.from(a);\n\tconst bb = Buffer.from(b);\n\tif (ab.length !== bb.length) return false;\n\treturn timingSafeEqual(ab, bb);\n}\n\nexport class DashboardAuth {\n\tprivate readonly remoteEnabled: boolean;\n\tprivate readonly allowedIdentities: Set<string>;\n\tprivate readonly pairingTtlMs: number;\n\tprivate readonly resolver: TailscaleResolver;\n\tprivate readonly storage: PairingStorage;\n\tprivate readonly secret: Buffer;\n\tprivate readonly pairingMaxAttempts: number;\n\tprivate readonly pairingLockoutMs: number;\n\tprivate readonly logger: (line: string) => void;\n\tprivate readonly now: () => number;\n\tprivate pairingMutation: Promise<void> = Promise.resolve();\n\tprivate readonly pairingFailures = new Map<string, { count: number; lockedUntil?: number }>();\n\n\tconstructor(options: DashboardAuthOptions = {}) {\n\t\tthis.remoteEnabled = options.remoteEnabled ?? false;\n\t\tthis.allowedIdentities = new Set(options.allowedIdentities ?? []);\n\t\tthis.pairingTtlMs = options.pairingTtlMs ?? 30 * 24 * 60 * 60 * 1000;\n\t\tthis.resolver = options.resolver ?? new TailscaleStatusResolver();\n\t\tthis.storage = options.storage ?? new MemoryPairingStorage();\n\t\tthis.secret = options.secret ?? randomBytes(32);\n\t\tthis.pairingMaxAttempts = options.pairingMaxAttempts ?? DEFAULT_PAIRING_MAX_ATTEMPTS;\n\t\tthis.pairingLockoutMs = options.pairingLockoutMs ?? DEFAULT_PAIRING_LOCKOUT_MS;\n\t\tthis.logger = options.logger ?? (() => {});\n\t\tthis.now = options.now ?? Date.now;\n\t}\n\n\tget isRemoteEnabled(): boolean {\n\t\treturn this.remoteEnabled;\n\t}\n\n\tprivate hmac(value: string): string {\n\t\treturn createHmac(\"sha256\", this.secret).update(value).digest(\"hex\");\n\t}\n\n\tprivate pairingCodeForWindow(window: number): string {\n\t\tconst counter = Buffer.alloc(8);\n\t\tcounter.writeBigUInt64BE(BigInt(window));\n\t\tconst digest = createHmac(\"sha1\", this.secret).update(counter).digest();\n\t\tconst offset = digest[digest.length - 1]! & 0x0f;\n\t\tconst value =\n\t\t\t((digest[offset]! & 0x7f) << 24) |\n\t\t\t((digest[offset + 1]! & 0xff) << 16) |\n\t\t\t((digest[offset + 2]! & 0xff) << 8) |\n\t\t\t(digest[offset + 3]! & 0xff);\n\t\treturn String(value % 1_000_000).padStart(6, \"0\");\n\t}\n\n\t/** Current RFC-6238-style rotating code for pairing new remote devices. */\n\tcurrentPairingCode(): { code: string; expiresInMs: number } {\n\t\tconst nowMs = this.now();\n\t\tconst window = Math.floor(nowMs / PAIRING_CODE_STEP_MS);\n\t\tconst expiresInMs = (window + 1) * PAIRING_CODE_STEP_MS - nowMs;\n\t\treturn { code: this.pairingCodeForWindow(window), expiresInMs };\n\t}\n\n\tprivate currentPairingWindow(): number {\n\t\treturn Math.floor(this.now() / PAIRING_CODE_STEP_MS);\n\t}\n\n\tprivate matchingPairingCodeWindow(code: string): number | undefined {\n\t\tif (!/^\\d{6}$/.test(code)) return undefined;\n\t\tconst window = this.currentPairingWindow();\n\t\tfor (const candidateWindow of [window - 1, window, window + 1]) {\n\t\t\tif (candidateWindow < 0) continue;\n\t\t\tif (timingSafeEqualStr(code, this.pairingCodeForWindow(candidateWindow))) return candidateWindow;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate pruneConsumedPairingWindows(windows: Iterable<number>): number[] {\n\t\tconst minimumAcceptedWindow = Math.max(0, this.currentPairingWindow() - 1);\n\t\treturn [\n\t\t\t...new Set([...windows].filter((window) => Number.isSafeInteger(window) && window >= minimumAcceptedWindow)),\n\t\t].sort((a, b) => a - b);\n\t}\n\n\tprivate samePairingWindows(a: number[], b: number[]): boolean {\n\t\treturn a.length === b.length && a.every((window, index) => window === b[index]);\n\t}\n\n\tprivate pairingFailureKey(identity: TailscaleIdentity, remoteAddress: string | undefined): string {\n\t\treturn `${identity.loginName}|${normalizeAddress(remoteAddress)}`;\n\t}\n\n\tprivate assertPairingNotLocked(key: string): void {\n\t\tconst failure = this.pairingFailures.get(key);\n\t\tif (!failure?.lockedUntil) return;\n\t\tif (failure.lockedUntil > this.now()) {\n\t\t\tthrow Object.assign(new Error(\"Too many incorrect pairing attempts; try again later\"), { status: 429 });\n\t\t}\n\t\tthis.pairingFailures.delete(key);\n\t}\n\n\tprivate recordPairingFailure(key: string, identity: TailscaleIdentity): never {\n\t\tconst current = this.pairingFailures.get(key);\n\t\tconst count = (current?.count ?? 0) + 1;\n\t\tif (count >= this.pairingMaxAttempts) {\n\t\t\tconst lockedUntil = this.now() + this.pairingLockoutMs;\n\t\t\tthis.pairingFailures.set(key, { count, lockedUntil });\n\t\t\tthis.logger(\n\t\t\t\t`pairing locked after ${count} failed attempts for ${identity.loginName} until ${new Date(lockedUntil).toISOString()}`,\n\t\t\t);\n\t\t\tthrow Object.assign(new Error(\"Too many incorrect pairing attempts; try again later\"), { status: 429 });\n\t\t}\n\t\tthis.pairingFailures.set(key, { count, lockedUntil: current?.lockedUntil });\n\t\tif (count > 1) this.logger(`pairing failed attempt ${count} for ${identity.loginName}`);\n\t\tthrow Object.assign(new Error(\"Incorrect pairing code\"), { status: 401 });\n\t}\n\n\tprivate clearPairingFailures(key: string): void {\n\t\tthis.pairingFailures.delete(key);\n\t}\n\n\tprivate async withPairingMutation<T>(fn: () => Promise<T>): Promise<T> {\n\t\tconst previous = this.pairingMutation;\n\t\tlet release!: () => void;\n\t\tthis.pairingMutation = new Promise<void>((resolve) => {\n\t\t\trelease = resolve;\n\t\t});\n\t\tawait previous;\n\t\ttry {\n\t\t\treturn await fn();\n\t\t} finally {\n\t\t\trelease();\n\t\t}\n\t}\n\n\t/**\n\t * Authenticate a request. Fail-closed: any resolver/storage error results in\n\t * a deny, never a pass-through.\n\t */\n\tasync authenticate(info: AuthRequestInfo): Promise<AuthDecision> {\n\t\ttry {\n\t\t\treturn await this.authenticateInner(info);\n\t\t} catch (err) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 500,\n\t\t\t\treason: `Auth subsystem error — denying: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t};\n\t\t}\n\t}\n\n\tprivate async authenticateInner(info: AuthRequestInfo): Promise<AuthDecision> {\n\t\tif (isLoopbackAddress(info.remoteAddress)) {\n\t\t\tif (!isAllowedLocalHost(info.hostHeader)) {\n\t\t\t\treturn {\n\t\t\t\t\tallowed: false,\n\t\t\t\t\tstatus: 403,\n\t\t\t\t\treason: `Host header \"${info.hostHeader ?? \"(missing)\"}\" is not a loopback host — rejected (DNS-rebinding defense)`,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (info.originHeader) {\n\t\t\t\tlet originHost: string | undefined;\n\t\t\t\ttry {\n\t\t\t\t\toriginHost = new URL(info.originHeader).host;\n\t\t\t\t} catch {\n\t\t\t\t\toriginHost = undefined;\n\t\t\t\t}\n\t\t\t\tif (!originHost || !isAllowedLocalHost(originHost)) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tallowed: false,\n\t\t\t\t\t\tstatus: 403,\n\t\t\t\t\t\treason: `Origin \"${info.originHeader}\" is not a loopback origin — rejected (cross-site defense)`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn { allowed: true, mode: \"local\" };\n\t\t}\n\n\t\tif (!this.remoteEnabled) {\n\t\t\treturn { allowed: false, status: 403, reason: \"Remote dashboard access is disabled\" };\n\t\t}\n\n\t\tconst identity = await this.resolver.resolve(info.remoteAddress ?? \"\");\n\t\tif (!identity) {\n\t\t\treturn { allowed: false, status: 403, reason: \"Client is not a known Tailscale peer\" };\n\t\t}\n\t\tif (this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 403,\n\t\t\t\treason: `Tailscale identity \"${identity.loginName}\" is not on the dashboard allowlist`,\n\t\t\t\tidentity,\n\t\t\t};\n\t\t}\n\n\t\tif (!info.deviceToken || !(await this.isPaired(identity, info.deviceToken))) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 401,\n\t\t\t\treason: \"Device is not paired — pairing code required\",\n\t\t\t\tneedsPairing: true,\n\t\t\t\tidentity,\n\t\t\t};\n\t\t}\n\n\t\treturn { allowed: true, mode: \"remote\", identity };\n\t}\n\n\t/**\n\t * Complete pairing for an allowed remote identity using the current rotating\n\t * code. Returns the device token to set as a cookie. Throws (with `status`) on\n\t * any failure.\n\t */\n\tasync pair(info: AuthRequestInfo, code: string): Promise<{ token: string; device: PairedDevice }> {\n\t\tif (isLoopbackAddress(info.remoteAddress)) {\n\t\t\tthrow Object.assign(new Error(\"Loopback clients do not pair\"), { status: 400 });\n\t\t}\n\t\tif (!this.remoteEnabled) {\n\t\t\tthrow Object.assign(new Error(\"Remote dashboard access is disabled\"), { status: 403 });\n\t\t}\n\t\tconst identity = await this.resolver.resolve(info.remoteAddress ?? \"\");\n\t\tif (!identity || this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {\n\t\t\tthrow Object.assign(new Error(\"Identity is not on the dashboard allowlist\"), { status: 403 });\n\t\t}\n\t\tconst failureKey = this.pairingFailureKey(identity, info.remoteAddress);\n\t\tthis.assertPairingNotLocked(failureKey);\n\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst matchedWindow = this.matchingPairingCodeWindow(code);\n\t\t\tif (matchedWindow === undefined || state.consumedPairingWindows.includes(matchedWindow)) {\n\t\t\t\tthis.recordPairingFailure(failureKey, identity);\n\t\t\t}\n\n\t\t\tconst token = randomBytes(32).toString(\"base64url\");\n\t\t\tconst nowMs = this.now();\n\t\t\tconst device: PairedDevice = {\n\t\t\t\tid: randomBytes(8).toString(\"hex\"),\n\t\t\t\tidentity: identity.loginName,\n\t\t\t\tdevice: identity.device,\n\t\t\t\tcreatedAt: new Date(nowMs).toISOString(),\n\t\t\t\texpiresAt: new Date(nowMs + this.pairingTtlMs).toISOString(),\n\t\t\t};\n\t\t\tconst pairings = [...state.pairings, { ...device, tokenHmac: this.hmac(token) }];\n\t\t\tconst consumedPairingWindows = this.pruneConsumedPairingWindows([\n\t\t\t\t...state.consumedPairingWindows,\n\t\t\t\tmatchedWindow,\n\t\t\t]);\n\t\t\tawait this.storage.save({ pairings, consumedPairingWindows });\n\t\t\tthis.clearPairingFailures(failureKey);\n\t\t\treturn { token, device };\n\t\t});\n\t}\n\n\t/** List paired devices (live only). */\n\tasync listDevices(): Promise<PairedDevice[]> {\n\t\treturn this.withPairingMutation(async () =>\n\t\t\t(await this.loadLive()).map(({ tokenHmac: _tokenHmac, ...device }) => device),\n\t\t);\n\t}\n\n\t/** Remove a paired device by id. Returns true when something was removed. */\n\tasync unpair(deviceId: string): Promise<boolean> {\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst remaining = state.pairings.filter((p) => p.id !== deviceId);\n\t\t\tif (remaining.length === state.pairings.length) return false;\n\t\t\tawait this.storage.save({ ...state, pairings: remaining });\n\t\t\treturn true;\n\t\t});\n\t}\n\n\tprivate async isPaired(identity: TailscaleIdentity, token: string): Promise<boolean> {\n\t\tconst tokenHmac = this.hmac(token);\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst live = await this.loadLive();\n\t\t\treturn live.some((p) => p.identity === identity.loginName && timingSafeEqualStr(p.tokenHmac, tokenHmac));\n\t\t});\n\t}\n\n\t/** Load pairings, dropping (and persisting the removal of) expired entries. Caller must hold pairingMutation. */\n\tprivate async loadLive(saveExpiredRemoval = true): Promise<StoredPairing[]> {\n\t\treturn (await this.loadLiveState(saveExpiredRemoval)).pairings;\n\t}\n\n\t/** Load pairing state, dropping expired devices and consumed PIN windows that can no longer match. */\n\tprivate async loadLiveState(savePrunedState = true): Promise<PairingState> {\n\t\tconst state = await this.storage.load();\n\t\tconst nowMs = this.now();\n\t\tconst pairings = state.pairings.filter((p) => new Date(p.expiresAt).getTime() > nowMs);\n\t\tconst consumedPairingWindows = this.pruneConsumedPairingWindows(state.consumedPairingWindows);\n\t\tif (\n\t\t\tsavePrunedState &&\n\t\t\t(pairings.length !== state.pairings.length ||\n\t\t\t\t!this.samePairingWindows(consumedPairingWindows, state.consumedPairingWindows))\n\t\t) {\n\t\t\tawait this.storage.save({ pairings, consumedPairingWindows });\n\t\t}\n\t\treturn { pairings, consumedPairingWindows };\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Dashboard auth — exactly two modes (this file is the authority):\n *\n * Mode A — local-only (default): the server binds loopback only. Requests are\n * additionally checked for loopback source address AND an allowlisted Host\n * header (DNS-rebinding defense: a malicious website can point its own domain\n * at 127.0.0.1 and drive the API from the victim's browser unless Host is\n * validated). No login, no pairing.\n *\n * Mode B — remote (explicit opt-in): requires Tailscale. Enforcement layers,\n * all fail-closed: (1) Tailscale identity resolution of the peer address,\n * (2) identity allowlist (empty allowlist = deny all), (3) first-login\n * rotating pairing code (visible only from the host/local dashboard),\n * (4) signed per-device cookie thereafter.\n *\n * There is no LAN mode. Any auth-subsystem error denies the request.\n */\n\nimport { execFile } from \"node:child_process\";\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\nconst PAIRING_CODE_STEP_MS = 30_000;\nconst DAY_MS = 24 * 60 * 60 * 1000;\nexport const DEFAULT_PAIRING_TTL_DAYS = 180;\nexport const MIN_PAIRING_TTL_DAYS = 1;\nexport const MAX_PAIRING_TTL_DAYS = 3650;\nconst DEFAULT_PAIRING_MAX_ATTEMPTS = 5;\nconst DEFAULT_PAIRING_LOCKOUT_MS = 60_000;\n\n// ---------------------------------------------------------------------------\n// Address / Host helpers\n// ---------------------------------------------------------------------------\n\n/** Normalize an address for comparison (strip IPv6-mapped IPv4 prefix and zone). */\nexport function normalizeAddress(address: string | undefined): string {\n\tif (!address) return \"\";\n\tlet a = address.trim();\n\tif (a.startsWith(\"[\") && a.endsWith(\"]\")) a = a.slice(1, -1);\n\tconst zone = a.indexOf(\"%\");\n\tif (zone !== -1) a = a.slice(0, zone);\n\tif (a.startsWith(\"::ffff:\")) a = a.slice(7);\n\treturn a;\n}\n\n/** True when the (normalized) address is a loopback address. */\nexport function isLoopbackAddress(address: string | undefined): boolean {\n\tconst a = normalizeAddress(address);\n\tif (!a) return false;\n\tif (a === \"::1\") return true;\n\treturn /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(a);\n}\n\n/**\n * Validate a Host header against the loopback allowlist. Rejecting foreign\n * hosts breaks DNS rebinding: the attacker's page can reach 127.0.0.1, but its\n * requests carry the attacker's hostname in Host.\n */\nexport function isAllowedLocalHost(hostHeader: string | undefined): boolean {\n\tif (!hostHeader) return false;\n\t// Strip port. IPv6 hosts arrive as \"[::1]:port\".\n\tlet host = hostHeader.trim().toLowerCase();\n\tconst v6 = host.match(/^\\[([^\\]]+)\\](?::\\d+)?$/);\n\tif (v6) {\n\t\thost = v6[1];\n\t} else {\n\t\tconst colon = host.lastIndexOf(\":\");\n\t\tif (colon !== -1 && /^\\d+$/.test(host.slice(colon + 1))) host = host.slice(0, colon);\n\t}\n\treturn host === \"localhost\" || host === \"::1\" || /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(host);\n}\n\n// ---------------------------------------------------------------------------\n// Tailscale identity\n// ---------------------------------------------------------------------------\n\nexport interface TailscaleIdentity {\n\t/** Login name (e.g. \"alice@example.com\") — the allowlist unit. */\n\tloginName: string;\n\t/** Device host name, when known. */\n\tdevice?: string;\n}\n\nexport interface TailscaleResolver {\n\t/** Resolve a peer IP to a Tailscale identity, or null when unknown. */\n\tresolve(address: string): Promise<TailscaleIdentity | null>;\n}\n\ninterface TailscaleWhoisJson {\n\tNode?: { Name?: string };\n\tUserProfile?: { LoginName?: string } | null;\n}\n\nexport type TailscaleWhoisRunner = (address: string) => Promise<{ stdout: string }>;\n\nexport class TailscaleResolverError extends Error {\n\tconstructor(readonly kind: \"timeout\" | \"execution\" | \"parse\" | \"schema\") {\n\t\tsuper(`Tailscale identity resolver ${kind} failure`);\n\t\tthis.name = \"TailscaleResolverError\";\n\t}\n}\n\nasync function runTailscaleWhois(address: string): Promise<{ stdout: string }> {\n\treturn execFileAsync(\"tailscale\", [\"whois\", \"--json\", address], {\n\t\ttimeout: 3000,\n\t\tmaxBuffer: 1024 * 1024,\n\t});\n}\n\nfunction isPeerNotFoundError(error: unknown): boolean {\n\tconst stderr = (error as { stderr?: unknown })?.stderr;\n\treturn typeof stderr === \"string\" && stderr.trim().toLowerCase() === \"peer not found\";\n}\n\nfunction isTimeoutError(error: unknown): boolean {\n\tconst candidate = error as { killed?: unknown; signal?: unknown };\n\treturn candidate?.killed === true || candidate?.signal === \"SIGTERM\";\n}\n\n/** Peer-specific Tailscale identity resolution with same-peer in-flight coalescing. */\nexport class TailscaleWhoisResolver implements TailscaleResolver {\n\tprivate readonly inFlight = new Map<string, Promise<TailscaleIdentity | null>>();\n\n\tconstructor(private readonly runWhois: TailscaleWhoisRunner = runTailscaleWhois) {}\n\n\tasync resolve(address: string): Promise<TailscaleIdentity | null> {\n\t\tconst target = normalizeAddress(address);\n\t\tif (!target) return null;\n\t\tconst existing = this.inFlight.get(target);\n\t\tif (existing) return existing;\n\n\t\tconst lookup = this.lookup(target);\n\t\tthis.inFlight.set(target, lookup);\n\t\ttry {\n\t\t\treturn await lookup;\n\t\t} finally {\n\t\t\tif (this.inFlight.get(target) === lookup) this.inFlight.delete(target);\n\t\t}\n\t}\n\n\tprivate async lookup(target: string): Promise<TailscaleIdentity | null> {\n\t\tlet stdout: string;\n\t\ttry {\n\t\t\t({ stdout } = await this.runWhois(target));\n\t\t} catch (error) {\n\t\t\tif (isTimeoutError(error)) throw new TailscaleResolverError(\"timeout\");\n\t\t\tif (isPeerNotFoundError(error)) return null;\n\t\t\tthrow new TailscaleResolverError(\"execution\");\n\t\t}\n\n\t\tlet whois: TailscaleWhoisJson;\n\t\ttry {\n\t\t\tconst parsed: unknown = JSON.parse(stdout);\n\t\t\tif (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n\t\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t\t}\n\t\t\twhois = parsed as TailscaleWhoisJson;\n\t\t} catch (error) {\n\t\t\tif (error instanceof TailscaleResolverError) throw error;\n\t\t\tthrow new TailscaleResolverError(\"parse\");\n\t\t}\n\n\t\tif (!whois.Node || typeof whois.Node !== \"object\" || Array.isArray(whois.Node)) {\n\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t}\n\t\tif (whois.Node.Name !== undefined && typeof whois.Node.Name !== \"string\") {\n\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t}\n\t\tif (whois.UserProfile !== undefined && whois.UserProfile !== null) {\n\t\t\tif (typeof whois.UserProfile !== \"object\" || Array.isArray(whois.UserProfile)) {\n\t\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t\t}\n\t\t\tif (whois.UserProfile.LoginName !== undefined && typeof whois.UserProfile.LoginName !== \"string\") {\n\t\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t\t}\n\t\t}\n\n\t\tconst loginName = whois.UserProfile?.LoginName?.trim();\n\t\tif (!loginName) return null;\n\t\tconst device = whois.Node.Name?.replace(/\\.$/, \"\") || undefined;\n\t\treturn { loginName, device };\n\t}\n}\n\n/** @deprecated Use TailscaleWhoisResolver. Retained for API compatibility. */\nexport class TailscaleStatusResolver extends TailscaleWhoisResolver {}\n\n// ---------------------------------------------------------------------------\n// Pairing store (rotating pairing codes + device tokens)\n// ---------------------------------------------------------------------------\n\nexport interface PairedDevice {\n\tid: string;\n\tidentity: string;\n\tdevice?: string;\n\tcreatedAt: string;\n\texpiresAt: string;\n}\n\nexport interface StoredPairing extends PairedDevice {\n\t/** HMAC of the device token (raw token never stored). */\n\ttokenHmac: string;\n\t/** UTC date of the last expiry warning claimed by this pairing. */\n\tlastExpiryWarningUtcDate?: string;\n}\n\nexport interface PairingState {\n\tpairings: StoredPairing[];\n\tconsumedPairingWindows: number[];\n\t/** Whole-day lifetime for newly created pairings. Absent in legacy files. */\n\tpairingTtlDays?: number;\n}\n\nexport interface PairingExpiryStatus {\n\twarning?: { expiresAt: string };\n\tnextCheckAt?: string;\n}\n\nexport interface PairingStorage {\n\tload(): Promise<PairingState>;\n\tsave(state: PairingState): Promise<void>;\n}\n\n/** In-memory storage — used in tests and as the base for the file store. */\nexport class MemoryPairingStorage implements PairingStorage {\n\tprivate state: PairingState = { pairings: [], consumedPairingWindows: [] };\n\tasync load(): Promise<PairingState> {\n\t\treturn {\n\t\t\tpairings: this.state.pairings.map((pairing) => ({ ...pairing })),\n\t\t\tconsumedPairingWindows: [...this.state.consumedPairingWindows],\n\t\t\tpairingTtlDays: this.state.pairingTtlDays,\n\t\t};\n\t}\n\tasync save(state: PairingState): Promise<void> {\n\t\tthis.state = {\n\t\t\tpairings: state.pairings.map((pairing) => ({ ...pairing })),\n\t\t\tconsumedPairingWindows: [...state.consumedPairingWindows],\n\t\t\tpairingTtlDays: state.pairingTtlDays,\n\t\t};\n\t}\n}\n\nexport interface DashboardAuthOptions {\n\t/** Remote (Tailscale) mode. Default false — loopback only. */\n\tremoteEnabled?: boolean;\n\t/** Allowed Tailscale login names. Empty = deny all remote. */\n\tallowedIdentities?: string[];\n\t/** Test/backward-compatible default when no persisted day setting exists. Production defaults to 180 days. */\n\tpairingTtlMs?: number;\n\tresolver?: TailscaleResolver;\n\tstorage?: PairingStorage;\n\t/** HMAC/TOTP secret for device tokens and pairing codes. Production passes a per-install persisted secret. */\n\tsecret?: Buffer;\n\t/** Failed PIN attempts before temporary lockout. Default 5. */\n\tpairingMaxAttempts?: number;\n\t/** Temporary lockout duration after too many failed PIN attempts. Default 60s. */\n\tpairingLockoutMs?: number;\n\t/** Security/audit log sink for repeated failed pairing attempts. */\n\tlogger?: (line: string) => void;\n\t/** Clock override for tests. */\n\tnow?: () => number;\n}\n\nexport type AuthDecision =\n\t| { allowed: true; mode: \"local\" }\n\t| { allowed: true; mode: \"remote\"; identity: TailscaleIdentity; pairing: PairedDevice }\n\t| {\n\t\t\tallowed: false;\n\t\t\tstatus: number;\n\t\t\treason: string;\n\t\t\t/** Set when an allowed identity needs pairing-code entry. */\n\t\t\tneedsPairing?: boolean;\n\t\t\tidentity?: TailscaleIdentity;\n\t };\n\nexport interface AuthRequestInfo {\n\tremoteAddress: string | undefined;\n\thostHeader: string | undefined;\n\t/** Origin header when present. Non-loopback origins are rejected on local requests. */\n\toriginHeader: string | undefined;\n\t/** Value of the dashboard device cookie, when present. */\n\tdeviceToken: string | undefined;\n}\n\nfunction timingSafeEqualStr(a: string, b: string): boolean {\n\tconst ab = Buffer.from(a);\n\tconst bb = Buffer.from(b);\n\tif (ab.length !== bb.length) return false;\n\treturn timingSafeEqual(ab, bb);\n}\n\nexport class DashboardAuth {\n\tprivate readonly remoteEnabled: boolean;\n\tprivate readonly allowedIdentities: Set<string>;\n\tprivate readonly defaultPairingTtlMs: number;\n\tprivate readonly resolver: TailscaleResolver;\n\tprivate readonly storage: PairingStorage;\n\tprivate readonly secret: Buffer;\n\tprivate readonly pairingMaxAttempts: number;\n\tprivate readonly pairingLockoutMs: number;\n\tprivate readonly logger: (line: string) => void;\n\tprivate readonly now: () => number;\n\tprivate pairingMutation: Promise<void> = Promise.resolve();\n\tprivate readonly pairingFailures = new Map<string, { count: number; lockedUntil?: number }>();\n\n\tconstructor(options: DashboardAuthOptions = {}) {\n\t\tthis.remoteEnabled = options.remoteEnabled ?? false;\n\t\tthis.allowedIdentities = new Set(options.allowedIdentities ?? []);\n\t\tthis.defaultPairingTtlMs = options.pairingTtlMs ?? DEFAULT_PAIRING_TTL_DAYS * DAY_MS;\n\t\tthis.resolver = options.resolver ?? new TailscaleWhoisResolver();\n\t\tthis.storage = options.storage ?? new MemoryPairingStorage();\n\t\tthis.secret = options.secret ?? randomBytes(32);\n\t\tthis.pairingMaxAttempts = options.pairingMaxAttempts ?? DEFAULT_PAIRING_MAX_ATTEMPTS;\n\t\tthis.pairingLockoutMs = options.pairingLockoutMs ?? DEFAULT_PAIRING_LOCKOUT_MS;\n\t\tthis.logger = options.logger ?? (() => {});\n\t\tthis.now = options.now ?? Date.now;\n\t}\n\n\tget isRemoteEnabled(): boolean {\n\t\treturn this.remoteEnabled;\n\t}\n\n\tprivate defaultPairingTtlDays(): number {\n\t\tconst days = this.defaultPairingTtlMs / DAY_MS;\n\t\treturn Number.isSafeInteger(days) && days >= MIN_PAIRING_TTL_DAYS && days <= MAX_PAIRING_TTL_DAYS\n\t\t\t? days\n\t\t\t: DEFAULT_PAIRING_TTL_DAYS;\n\t}\n\n\tasync getPairingSettings(): Promise<{ pairingTtlDays: number }> {\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\treturn { pairingTtlDays: state.pairingTtlDays ?? this.defaultPairingTtlDays() };\n\t\t});\n\t}\n\n\tasync setPairingSettings(pairingTtlDays: number): Promise<{ pairingTtlDays: number }> {\n\t\tif (\n\t\t\t!Number.isSafeInteger(pairingTtlDays) ||\n\t\t\tpairingTtlDays < MIN_PAIRING_TTL_DAYS ||\n\t\t\tpairingTtlDays > MAX_PAIRING_TTL_DAYS\n\t\t) {\n\t\t\tthrow Object.assign(\n\t\t\t\tnew Error(\n\t\t\t\t\t`pairingTtlDays must be a whole number from ${MIN_PAIRING_TTL_DAYS} through ${MAX_PAIRING_TTL_DAYS}`,\n\t\t\t\t),\n\t\t\t\t{ status: 400 },\n\t\t\t);\n\t\t}\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tawait this.storage.save({ ...state, pairingTtlDays });\n\t\t\treturn { pairingTtlDays };\n\t\t});\n\t}\n\n\tprivate hmac(value: string): string {\n\t\treturn createHmac(\"sha256\", this.secret).update(value).digest(\"hex\");\n\t}\n\n\tprivate pairingCodeForWindow(window: number): string {\n\t\tconst counter = Buffer.alloc(8);\n\t\tcounter.writeBigUInt64BE(BigInt(window));\n\t\tconst digest = createHmac(\"sha1\", this.secret).update(counter).digest();\n\t\tconst offset = digest[digest.length - 1]! & 0x0f;\n\t\tconst value =\n\t\t\t((digest[offset]! & 0x7f) << 24) |\n\t\t\t((digest[offset + 1]! & 0xff) << 16) |\n\t\t\t((digest[offset + 2]! & 0xff) << 8) |\n\t\t\t(digest[offset + 3]! & 0xff);\n\t\treturn String(value % 1_000_000).padStart(6, \"0\");\n\t}\n\n\t/** Current RFC-6238-style rotating code for pairing new remote devices. */\n\tcurrentPairingCode(): { code: string; expiresInMs: number } {\n\t\tconst nowMs = this.now();\n\t\tconst window = Math.floor(nowMs / PAIRING_CODE_STEP_MS);\n\t\tconst expiresInMs = (window + 1) * PAIRING_CODE_STEP_MS - nowMs;\n\t\treturn { code: this.pairingCodeForWindow(window), expiresInMs };\n\t}\n\n\tprivate currentPairingWindow(): number {\n\t\treturn Math.floor(this.now() / PAIRING_CODE_STEP_MS);\n\t}\n\n\tprivate matchingPairingCodeWindow(code: string): number | undefined {\n\t\tif (!/^\\d{6}$/.test(code)) return undefined;\n\t\tconst window = this.currentPairingWindow();\n\t\tfor (const candidateWindow of [window - 1, window, window + 1]) {\n\t\t\tif (candidateWindow < 0) continue;\n\t\t\tif (timingSafeEqualStr(code, this.pairingCodeForWindow(candidateWindow))) return candidateWindow;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate pruneConsumedPairingWindows(windows: Iterable<number>): number[] {\n\t\tconst minimumAcceptedWindow = Math.max(0, this.currentPairingWindow() - 1);\n\t\treturn [\n\t\t\t...new Set([...windows].filter((window) => Number.isSafeInteger(window) && window >= minimumAcceptedWindow)),\n\t\t].sort((a, b) => a - b);\n\t}\n\n\tprivate samePairingWindows(a: number[], b: number[]): boolean {\n\t\treturn a.length === b.length && a.every((window, index) => window === b[index]);\n\t}\n\n\tprivate pairingFailureKey(identity: TailscaleIdentity, remoteAddress: string | undefined): string {\n\t\treturn `${identity.loginName}|${normalizeAddress(remoteAddress)}`;\n\t}\n\n\tprivate assertPairingNotLocked(key: string): void {\n\t\tconst failure = this.pairingFailures.get(key);\n\t\tif (!failure?.lockedUntil) return;\n\t\tif (failure.lockedUntil > this.now()) {\n\t\t\tthrow Object.assign(new Error(\"Too many incorrect pairing attempts; try again later\"), { status: 429 });\n\t\t}\n\t\tthis.pairingFailures.delete(key);\n\t}\n\n\tprivate recordPairingFailure(key: string, identity: TailscaleIdentity): never {\n\t\tconst current = this.pairingFailures.get(key);\n\t\tconst count = (current?.count ?? 0) + 1;\n\t\tif (count >= this.pairingMaxAttempts) {\n\t\t\tconst lockedUntil = this.now() + this.pairingLockoutMs;\n\t\t\tthis.pairingFailures.set(key, { count, lockedUntil });\n\t\t\tthis.logger(\n\t\t\t\t`pairing locked after ${count} failed attempts for ${identity.loginName} until ${new Date(lockedUntil).toISOString()}`,\n\t\t\t);\n\t\t\tthrow Object.assign(new Error(\"Too many incorrect pairing attempts; try again later\"), { status: 429 });\n\t\t}\n\t\tthis.pairingFailures.set(key, { count, lockedUntil: current?.lockedUntil });\n\t\tif (count > 1) this.logger(`pairing failed attempt ${count} for ${identity.loginName}`);\n\t\tthrow Object.assign(new Error(\"Incorrect pairing code\"), { status: 401 });\n\t}\n\n\tprivate clearPairingFailures(key: string): void {\n\t\tthis.pairingFailures.delete(key);\n\t}\n\n\tprivate async withPairingMutation<T>(fn: () => Promise<T>): Promise<T> {\n\t\tconst previous = this.pairingMutation;\n\t\tlet release!: () => void;\n\t\tthis.pairingMutation = new Promise<void>((resolve) => {\n\t\t\trelease = resolve;\n\t\t});\n\t\tawait previous;\n\t\ttry {\n\t\t\treturn await fn();\n\t\t} finally {\n\t\t\trelease();\n\t\t}\n\t}\n\n\t/**\n\t * Authenticate a request. Fail-closed: any resolver/storage error results in\n\t * a deny, never a pass-through.\n\t */\n\tasync authenticate(info: AuthRequestInfo): Promise<AuthDecision> {\n\t\ttry {\n\t\t\treturn await this.authenticateInner(info);\n\t\t} catch (err) {\n\t\t\tif (err instanceof TailscaleResolverError) {\n\t\t\t\tthis.logger(`identity resolver ${err.kind} failure — denying`);\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 500,\n\t\t\t\treason: `Auth subsystem error — denying: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t};\n\t\t}\n\t}\n\n\tprivate async authenticateInner(info: AuthRequestInfo): Promise<AuthDecision> {\n\t\tif (isLoopbackAddress(info.remoteAddress)) {\n\t\t\tif (!isAllowedLocalHost(info.hostHeader)) {\n\t\t\t\treturn {\n\t\t\t\t\tallowed: false,\n\t\t\t\t\tstatus: 403,\n\t\t\t\t\treason: `Host header \"${info.hostHeader ?? \"(missing)\"}\" is not a loopback host — rejected (DNS-rebinding defense)`,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (info.originHeader) {\n\t\t\t\tlet originHost: string | undefined;\n\t\t\t\ttry {\n\t\t\t\t\toriginHost = new URL(info.originHeader).host;\n\t\t\t\t} catch {\n\t\t\t\t\toriginHost = undefined;\n\t\t\t\t}\n\t\t\t\tif (!originHost || !isAllowedLocalHost(originHost)) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tallowed: false,\n\t\t\t\t\t\tstatus: 403,\n\t\t\t\t\t\treason: `Origin \"${info.originHeader}\" is not a loopback origin — rejected (cross-site defense)`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn { allowed: true, mode: \"local\" };\n\t\t}\n\n\t\tif (!this.remoteEnabled) {\n\t\t\treturn { allowed: false, status: 403, reason: \"Remote dashboard access is disabled\" };\n\t\t}\n\n\t\tconst identity = await this.resolver.resolve(info.remoteAddress ?? \"\");\n\t\tif (!identity) {\n\t\t\treturn { allowed: false, status: 403, reason: \"Client is not a known Tailscale peer\" };\n\t\t}\n\t\tif (this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 403,\n\t\t\t\treason: `Tailscale identity \"${identity.loginName}\" is not on the dashboard allowlist`,\n\t\t\t\tidentity,\n\t\t\t};\n\t\t}\n\n\t\tconst pairing = info.deviceToken ? await this.findPairing(identity, info.deviceToken) : undefined;\n\t\tif (!pairing) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 401,\n\t\t\t\treason: \"Device is not paired — pairing code required\",\n\t\t\t\tneedsPairing: true,\n\t\t\t\tidentity,\n\t\t\t};\n\t\t}\n\n\t\treturn { allowed: true, mode: \"remote\", identity, pairing: this.toPairedDevice(pairing) };\n\t}\n\n\t/**\n\t * Complete pairing for an allowed remote identity using the current rotating\n\t * code. Returns the device token to set as a cookie. Throws (with `status`) on\n\t * any failure.\n\t */\n\tasync pair(info: AuthRequestInfo, code: string): Promise<{ token: string; device: PairedDevice }> {\n\t\tif (isLoopbackAddress(info.remoteAddress)) {\n\t\t\tthrow Object.assign(new Error(\"Loopback clients do not pair\"), { status: 400 });\n\t\t}\n\t\tif (!this.remoteEnabled) {\n\t\t\tthrow Object.assign(new Error(\"Remote dashboard access is disabled\"), { status: 403 });\n\t\t}\n\t\tconst identity = await this.resolver.resolve(info.remoteAddress ?? \"\");\n\t\tif (!identity || this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {\n\t\t\tthrow Object.assign(new Error(\"Identity is not on the dashboard allowlist\"), { status: 403 });\n\t\t}\n\t\tconst failureKey = this.pairingFailureKey(identity, info.remoteAddress);\n\t\tthis.assertPairingNotLocked(failureKey);\n\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst matchedWindow = this.matchingPairingCodeWindow(code);\n\t\t\tif (matchedWindow === undefined || state.consumedPairingWindows.includes(matchedWindow)) {\n\t\t\t\tthis.recordPairingFailure(failureKey, identity);\n\t\t\t}\n\n\t\t\tconst token = randomBytes(32).toString(\"base64url\");\n\t\t\tconst nowMs = this.now();\n\t\t\tconst pairingTtlMs =\n\t\t\t\tstate.pairingTtlDays === undefined ? this.defaultPairingTtlMs : state.pairingTtlDays * DAY_MS;\n\t\t\tconst device: PairedDevice = {\n\t\t\t\tid: randomBytes(8).toString(\"hex\"),\n\t\t\t\tidentity: identity.loginName,\n\t\t\t\tdevice: identity.device,\n\t\t\t\tcreatedAt: new Date(nowMs).toISOString(),\n\t\t\t\texpiresAt: new Date(nowMs + pairingTtlMs).toISOString(),\n\t\t\t};\n\t\t\tconst pairings = [...state.pairings, { ...device, tokenHmac: this.hmac(token) }];\n\t\t\tconst consumedPairingWindows = this.pruneConsumedPairingWindows([\n\t\t\t\t...state.consumedPairingWindows,\n\t\t\t\tmatchedWindow,\n\t\t\t]);\n\t\t\tawait this.storage.save({ ...state, pairings, consumedPairingWindows });\n\t\t\tthis.clearPairingFailures(failureKey);\n\t\t\treturn { token, device };\n\t\t});\n\t}\n\n\tprivate toPairedDevice(pairing: StoredPairing): PairedDevice {\n\t\treturn {\n\t\t\tid: pairing.id,\n\t\t\tidentity: pairing.identity,\n\t\t\tdevice: pairing.device,\n\t\t\tcreatedAt: pairing.createdAt,\n\t\t\texpiresAt: pairing.expiresAt,\n\t\t};\n\t}\n\n\t/** List paired devices (live only). */\n\tasync listDevices(): Promise<PairedDevice[]> {\n\t\treturn this.withPairingMutation(async () =>\n\t\t\t(await this.loadLive()).map((pairing) => this.toPairedDevice(pairing)),\n\t\t);\n\t}\n\n\t/** Remove a paired device by id. Returns true when something was removed. */\n\tasync unpair(deviceId: string): Promise<boolean> {\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst remaining = state.pairings.filter((p) => p.id !== deviceId);\n\t\t\tif (remaining.length === state.pairings.length) return false;\n\t\t\tawait this.storage.save({ ...state, pairings: remaining });\n\t\t\treturn true;\n\t\t});\n\t}\n\n\tprivate async findPairing(identity: TailscaleIdentity, token: string): Promise<StoredPairing | undefined> {\n\t\tconst tokenHmac = this.hmac(token);\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst live = await this.loadLive();\n\t\t\treturn live.find((p) => p.identity === identity.loginName && timingSafeEqualStr(p.tokenHmac, tokenHmac));\n\t\t});\n\t}\n\n\t/** Atomically claim any due warning and return the next useful browser check time. */\n\tasync claimPairingExpiryStatus(pairingId: string): Promise<PairingExpiryStatus> {\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst pairing = state.pairings.find((candidate) => candidate.id === pairingId);\n\t\t\tif (!pairing) return {};\n\n\t\t\tconst nowMs = this.now();\n\t\t\tconst createdAt = Date.parse(pairing.createdAt);\n\t\t\tconst expiresAt = Date.parse(pairing.expiresAt);\n\t\t\tconst originalValidity = expiresAt - createdAt;\n\t\t\tconst remaining = expiresAt - nowMs;\n\t\t\tif (!Number.isFinite(originalValidity) || originalValidity <= 0 || remaining <= 0) return {};\n\n\t\t\tconst warningStartsAt = expiresAt - originalValidity * 0.1;\n\t\t\tif (nowMs < warningStartsAt) return { nextCheckAt: new Date(warningStartsAt).toISOString() };\n\n\t\t\tconst utcDate = new Date(nowMs).toISOString().slice(0, 10);\n\t\t\tconst nextUtcMidnight = Date.parse(`${utcDate}T00:00:00.000Z`) + DAY_MS;\n\t\t\tconst nextCheckAt = nextUtcMidnight < expiresAt ? new Date(nextUtcMidnight).toISOString() : undefined;\n\t\t\tif (pairing.lastExpiryWarningUtcDate === utcDate) return { nextCheckAt };\n\n\t\t\tconst pairings = state.pairings.map((candidate) =>\n\t\t\t\tcandidate.id === pairingId ? { ...candidate, lastExpiryWarningUtcDate: utcDate } : candidate,\n\t\t\t);\n\t\t\tawait this.storage.save({ ...state, pairings });\n\t\t\treturn { warning: { expiresAt: pairing.expiresAt }, nextCheckAt };\n\t\t});\n\t}\n\n\t/** Load pairings, dropping (and persisting the removal of) expired entries. Caller must hold pairingMutation. */\n\tprivate async loadLive(saveExpiredRemoval = true): Promise<StoredPairing[]> {\n\t\treturn (await this.loadLiveState(saveExpiredRemoval)).pairings;\n\t}\n\n\t/** Load pairing state, dropping expired devices and consumed PIN windows that can no longer match. */\n\tprivate async loadLiveState(savePrunedState = true): Promise<PairingState> {\n\t\tconst state = await this.storage.load();\n\t\tconst nowMs = this.now();\n\t\tconst pairings = state.pairings.filter((p) => new Date(p.expiresAt).getTime() > nowMs);\n\t\tconst consumedPairingWindows = this.pruneConsumedPairingWindows(state.consumedPairingWindows);\n\t\tif (\n\t\t\tsavePrunedState &&\n\t\t\t(pairings.length !== state.pairings.length ||\n\t\t\t\t!this.samePairingWindows(consumedPairingWindows, state.consumedPairingWindows))\n\t\t) {\n\t\t\tawait this.storage.save({ ...state, pairings, consumedPairingWindows });\n\t\t}\n\t\treturn { ...state, pairings, consumedPairingWindows };\n\t}\n}\n"]}
|