diff --git a/FEATURES.md b/FEATURES.md index 1b452d0f..a0cec37c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -563,6 +563,10 @@ separate for desktop/mobile. Jump from a diff to its file preview. - Manage [user services](docs/DEPLOYMENT.md#run-as-a-user-service), use checksum-verified standalone updates under a supported supervisor, and probe `/health` or `/healthz`. +- [Command-line management](docs/DEPLOYMENT.md#command-line-management): + `roamgate doctor` diagnoses an installation, `url --qr` opens it on a phone, + and `config`, `auth`, `connections`, `push test`, `update`/`rollback`, + `service logs` and shell completions cover routine operations. **Normal runtime requires a token or password, including loopback.** The local development exception, password policy, and login limits are described in diff --git a/README.md b/README.md index 5b323560..1e84430c 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ Click any screenshot to open the full-resolution image. [latest release](https://github.com/powerfooI/roamgate/releases/latest). 3. On Linux/macOS, add `~/.local/bin` to `PATH` and run `roamgate`. On Windows, extract the archive and run `roamgate.exe`. Open the printed URL. + `roamgate url --qr` shows a QR code for a phone, and `roamgate doctor` + checks the setup when something does not work. See [deployment](./docs/DEPLOYMENT.md) for checksums, configuration, updates, and services, or [historical installation](./docs/DEPLOYMENT.md#install-historical-herdr-studio) diff --git a/bun.lock b/bun.lock index 026ef77f..ff242343 100644 --- a/bun.lock +++ b/bun.lock @@ -18,13 +18,14 @@ }, "server": { "name": "roamgate-server", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "@earendil-works/chord": "1.1.0", "@earendil-works/pi-ai": "1.1.0", "@earendil-works/pi-coding-agent": "1.1.0", "@earendil-works/pi-durable": "1.1.0", "proper-lockfile": "4.1.2", + "qrcode-generator": "2.0.4", "typebox": "1.3.34", "undici": "8.11.2", "web-push": "^3.6.7", @@ -32,7 +33,7 @@ }, "web": { "name": "roamgate-web", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-cpp": "^6.0.3", @@ -844,6 +845,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], + "quickjs-wasi": ["quickjs-wasi@3.6.2", "", {}, "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg=="], "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5dbb29dc..49819924 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -75,7 +75,8 @@ curl -fsSL \ ``` Add `~/.local/bin` to PATH, run `roamgate --version`, then `roamgate` and open the -printed URL. Rerun the installer to update. +printed URL. Rerun the installer or run `roamgate update` to update; +`roamgate rollback` restores the previous executable. On Windows, download matching `roamgate-windows-.tar.xz` and `.sha256` files from the [latest release](https://github.com/powerfooI/roamgate/releases/latest), @@ -597,6 +598,7 @@ stating that they require a Unix SSH host. See local-only users should toggle off/on once. Server push is enabled by default; every device still needs enrollment. +`roamgate push test` sends a test notification to every enrolled device. `ROAMGATE_WEB_PUSH_SUBJECT` optionally sets a `mailto:`/HTTPS operator contact, not a delivery destination. Default: `https://github.com/powerfooI/roamgate/issues`. An explicitly empty value disables push; restart after changes. Removing the @@ -677,6 +679,7 @@ Herdr sockets. It does not establish browser or system notification delivery. Logs use one line per event: timestamp, severity, scope, bounded key/value context. `info` covers lifecycle/failures, not routine RPC/events/frames/successful auto-sync. +`roamgate service logs -f` follows a managed service's output. Use `roamgate --log-level debug` or `ROAMGATE_LOG_LEVEL=debug` temporarily; restart services after editing their environment. Debug can expose paths/IDs; return to `info` afterwards. Logs omit URL auth tokens, which remain in protected files. @@ -730,6 +733,7 @@ combine `--profile` with Bun's other CPU sampling profilers in the same process. The title's selector adds/tests/connects/disconnects/edits/removes shared profiles; each browser selects independently. Local profiles attach to sockets, not start Herdr. The first saved profile retains the default as writable `Local`. +`roamgate connections` lists, tests and edits the same profiles from a terminal. ![Connection selector with local and SSH profiles](./screenshots/multi-connection-selector.png) @@ -908,6 +912,7 @@ not establish trust: review configuration and scripts before worktree operations | `roamgate service restart` | Restart after environment changes | | `roamgate service reload` | Reload definition, then restart | | `roamgate service uninstall` | Stop/remove service; retain configuration/tokens | +| `roamgate service logs [-f]` | Show recent service output | | Platform | Definition / behavior | | --- | --- | @@ -919,7 +924,8 @@ Stop/remove legacy services first via the [migration procedure](#transition-from **New services bind `0.0.0.0:8787`**, generate a persistent token, and print localhost/LAN token URLs. Config lives in `~/.config/roamgate/roamgate.env` or `%APPDATA%\roamgate\roamgate.env`, preserved on reinstall/uninstall. Edit HOST, -PORT, password, and Herdr settings there, then restart. For local-only installation, +PORT, password, and Herdr settings there, then restart, or use +`roamgate config set NAME=value --restart`. For local-only installation, set `HOST=127.0.0.1` first. On Windows, allow Private networks only if prompted; on Linux, `sudo loginctl enable-linger "$USER"` keeps services after logout. On macOS, installation checks the configured listener before registering the @@ -978,8 +984,11 @@ restarting. A busy lock fails closed after a bounded wait; it is never stolen from a potentially live writer. A `?token=...` visit sets an HttpOnly cookie and removes the URL token. To rotate, -stop the service, replace the file with a fresh 64-character lowercase hexadecimal -secret (mode `0600`), then restart. **Deleting only the new file can restore a +run `roamgate auth rotate`, which also restarts a running managed service and +prints the new URLs. Manually, stop the service, replace the file with a fresh +64-character lowercase hexadecimal secret (mode `0600`), then restart. +`roamgate auth revoke-sessions` removes the signing state and restarts the +service to sign out every browser without changing credentials. **Deleting only the new file can restore a readable legacy token**, not rotate it. Manual templates live under `deploy/`. Keep systemd as restart owner for wrappers: @@ -994,6 +1003,26 @@ The updater saves `roamgate.previous`, atomically installs a verified binary, an exits; it never starts its replacement. Reinstall preserves custom `ExecStart` in a managed unit when it still invokes the same binary. +## Command-line management + +Management commands act on the [user service](#run-as-a-user-service)'s +environment file and data directory, not on the shell's flags or variables. +`roamgate --help` lists them; `roamgate --help` describes each. +They exit 0 on success, 1 on failure and 2 on invalid arguments. Commands with +`--json` print machine-readable output for scripts. + +| Command | Purpose | +| --- | --- | +| `roamgate doctor [--offline]` | Check the config, credentials, TLS certificate, service, listener, network exposure, local Herdr, connections, Web Push and updates; print a fix for each problem. Exits 1 on any failure. The output contains no secrets. | +| `roamgate url [--qr] [--address ]` | Print tokenized login URLs; `--qr` draws a terminal QR code for a phone, and `--address` substitutes a VPN or Tailscale name. The code contains the token. | +| `roamgate config show\|get\|set\|unset\|edit\|path` | Read or change `roamgate.env`. Changes are validated as a whole and written atomically, preserving comments; `--restart` restarts a running service. `KEY=-` reads a secret from standard input. | +| `roamgate auth show\|rotate\|revoke-sessions` | Show the login method, replace the generated token (which also signs out every browser), or sign out every browser without changing credentials; both restart a running service unless `--no-restart`. | +| `roamgate connections list\|test\|add\|remove\|set-default` | Manage the profiles the [connection selector](#multiple-and-remote-herdr-connections) edits. A running Roamgate keeps its own copy, so edits are refused while it serves unless `--restart` restarts the managed service. Only the service config's address is checked; stop other processes sharing the registry, such as a foreground run on another port, first. | +| `roamgate push test` | Send a test notification to every enrolled [Web Push](#web-push-notifications) device and report each provider's answer. | +| `roamgate update [--check]` / `roamgate rollback` | Install the latest release with the same verification as the in-app updater, or swap back to `roamgate.previous`; a running service using this executable restarts. Linux and macOS only. | +| `roamgate service logs [-f] [-n ]` | Show service output: the user journal on Linux, `~/Library/Logs` on macOS. Windows tasks do not capture output. | +| `roamgate completion bash\|zsh\|fish` | Print a shell completion script; its help shows where to install it. | + ## Build a standalone executable ```bash @@ -1038,6 +1067,7 @@ map-free executable does not restore source-level stack traces. | Symptom | Action | | --- | --- | +| Not sure what is wrong | Run `roamgate doctor`; it checks each part below and prints a fix. | | Cannot connect to Herdr | Check server and both socket paths; default local setup can use `roamgate herdr setup`. Override with `--socket-path /path/to/herdr.sock` only deliberately. | | Another device cannot open the page | Check bind, token URL, network/firewall, and [private access setup](./TUTORIAL.md#networking). | | SSH connects locally | Remove explicit socket flags/`HERDR_SOCKET_PATH`/`HERDR_CLIENT_SOCKET_PATH`; they override tunnel paths. | diff --git a/server/package.json b/server/package.json index ce6e754c..74d2aa49 100644 --- a/server/package.json +++ b/server/package.json @@ -34,6 +34,7 @@ "@earendil-works/pi-coding-agent": "1.1.0", "@earendil-works/pi-durable": "1.1.0", "proper-lockfile": "4.1.2", + "qrcode-generator": "2.0.4", "typebox": "1.3.34", "undici": "8.11.2", "web-push": "^3.6.7" diff --git a/server/src/cli/auth.test.ts b/server/src/cli/auth.test.ts new file mode 100644 index 00000000..1d7e3cb2 --- /dev/null +++ b/server/src/cli/auth.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadOrCreateSessionSecret } from "../config/session-secret"; +import { EXIT_FAILURE, EXIT_OK } from "./command"; +import { runCli } from "./index"; +import { createTestCli, type TestCli } from "./test-context"; + +const TOKEN = "b".repeat(64); +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +function tokenPath(testCli: TestCli) { + return join(testCli.home, ".config", "roamgate", "auth-token"); +} + +describe("roamgate auth", () => { + test("show masks the token unless revealed", async () => { + cli = createTestCli(); + cli.writeConfig("ROAMGATE_PIN=123456\n"); + writeFileSync(tokenPath(cli), `${TOKEN}\n`); + await runCli(["auth", "show", "--json"], cli.context); + expect(JSON.parse(cli.output.join("\n"))).toMatchObject({ + method: "token", + token: "bbbb...bbbb", + pin_enabled: true, + }); + cli.output.length = 0; + await runCli(["auth", "show", "--json", "--reveal"], cli.context); + expect(JSON.parse(cli.output.join("\n")).token).toBe(TOKEN); + }); + + test("rotate writes a fresh valid token and prints new URLs", async () => { + cli = createTestCli(); + cli.writeConfig("HOST=127.0.0.1\n"); + writeFileSync(tokenPath(cli), `${TOKEN}\n`); + expect(await runCli(["auth", "rotate"], cli.context)).toBe(EXIT_OK); + const rotated = readFileSync(tokenPath(cli), "utf8").trim(); + expect(rotated).toMatch(/^[a-f0-9]{64}$/); + expect(rotated).not.toBe(TOKEN); + expect(cli.output).toContain( + `Open: http://localhost:8787/?token=${rotated}`, + ); + }); + + test("rotate refuses services that use a fixed password", async () => { + cli = createTestCli(); + cli.writeConfig("ROAMGATE_PASSWORD=correct-horse-battery\n"); + expect(await runCli(["auth", "rotate"], cli.context)).toBe(EXIT_FAILURE); + expect(existsSync(tokenPath(cli))).toBe(false); + }); + + test("revoke-sessions discards the signing secret under the runtime home", async () => { + cli = createTestCli(); + cli.writeConfig(""); + const secretPath = join( + cli.home, + ".config", + "roamgate", + "session-secret.json", + ); + loadOrCreateSessionSecret(["credential"], secretPath); + expect( + await runCli(["auth", "revoke-sessions", "--no-restart"], cli.context), + ).toBe(EXIT_OK); + expect(existsSync(secretPath)).toBe(false); + expect(await runCli(["auth", "revoke-sessions"], cli.context)).toBe( + EXIT_OK, + ); + expect(cli.output.at(-1)).toContain("no sessions to revoke"); + }); + + test("restarts a running managed service after rotation", async () => { + // No legacy herdr-gui unit exists (systemctl status exits 4 for it). + cli = createTestCli({ + runCommand: (argv) => (argv.includes("herdr-gui.service") ? 4 : 0), + }); + cli.writeConfig(""); + const unit = join( + cli.home, + ".config", + "systemd", + "user", + "roamgate.service", + ); + mkdirSync(join(cli.home, ".config", "systemd", "user"), { + recursive: true, + }); + writeFileSync(unit, "# Generated by roamgate service install.\n"); + expect(await runCli(["auth", "rotate"], cli.context)).toBe(EXIT_OK); + expect(cli.commands).toContainEqual([ + "systemctl", + "--user", + "restart", + "roamgate.service", + ]); + }); + + test("rotate repairs a corrupt token and revoke-sessions ignores it", async () => { + cli = createTestCli(); + cli.writeConfig(""); + writeFileSync(tokenPath(cli), "truncated\n"); + expect(await runCli(["auth", "show"], cli.context)).toBe(EXIT_OK); + expect(cli.output.find((line) => line.startsWith("Token:"))).toContain( + "invalid", + ); + expect( + await runCli(["auth", "revoke-sessions", "--no-restart"], cli.context), + ).toBe(EXIT_OK); + expect(await runCli(["auth", "rotate"], cli.context)).toBe(EXIT_OK); + expect(readFileSync(tokenPath(cli), "utf8").trim()).toMatch( + /^[a-f0-9]{64}$/, + ); + }); +}); diff --git a/server/src/cli/auth.ts b/server/src/cli/auth.ts new file mode 100644 index 00000000..366d3711 --- /dev/null +++ b/server/src/cli/auth.ts @@ -0,0 +1,182 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { rotateAuthToken } from "../config/auth-token"; +import { roamgateEnv } from "../config/environment"; +import { + revokeSessionSecret, + sessionSecretPath, +} from "../config/session-secret"; +import { getLanIPs } from "../config/server-config"; +import { + type CliCommand, + type CliContext, + displayPath, + EXIT_FAILURE, + EXIT_OK, + parseCommandArgs, + printJson, + UsageError, + wantsHelp, +} from "./command"; +import { + accessUrls, + readServiceAccess, + readServiceSetup, + restartRunningService, + runtimeDataRoot, +} from "./service-context"; + +function authHelp(): string { + return `Inspect and rotate login credentials of the managed service. + +Usage: + roamgate auth show [--reveal] [--json] + roamgate auth rotate [--no-restart] + roamgate auth revoke-sessions [--no-restart] + +show print the login method, token file and PIN state; the token + is masked unless --reveal is given +rotate replace the generated login token, which also signs out every + browser, then restart the running service and print new URLs +revoke-sessions sign out every browser without changing the token or password + +Both changes take effect when the server restarts. They restart a running +managed service unless --no-restart is given; restart foreground runs yourself. +Use \`roamgate config set ROAMGATE_PASSWORD=- --restart\` to change a fixed password. +`; +} + +function mask(token: string): string { + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + +function finish(context: CliContext, restart: boolean): number { + if (!restart) { + context.log("Restart Roamgate to apply: `roamgate service restart`."); + return EXIT_OK; + } + let code: number | null; + try { + code = restartRunningService(context); + } catch (error) { + context.error(`roamgate auth: ${(error as Error).message}`); + return EXIT_FAILURE; + } + if (code === null) { + context.log( + "The managed service is not running; restart any foreground roamgate process to apply.", + ); + return EXIT_OK; + } + if (code === 0) context.log("Restarted the service."); + return code; +} + +export const authCommand: CliCommand = { + name: "auth", + summary: "show or rotate login credentials and sign out browsers", + actions: ["show", "rotate", "revoke-sessions"], + flags: ["--reveal", "--json", "--no-restart"], + help: authHelp, + async run(args, context) { + const action = args[0]; + if (wantsHelp(args) || action === undefined) { + context.log(authHelp()); + return EXIT_OK; + } + const rest = args.slice(1); + const setup = readServiceSetup(context.runtime); + const secretPath = sessionSecretPath( + roamgateEnv("SETTINGS_PATH", setup.env), + runtimeDataRoot(context.runtime), + ); + + if (action === "show") { + const access = readServiceAccess(setup); + const { values } = parseCommandArgs(rest, { + reveal: { type: "boolean" }, + json: { type: "boolean" }, + }); + const token = + access.token && (values.reveal ? access.token : mask(access.token)); + if (values.json) { + printJson(context, { + method: access.auth, + token_path: access.auth === "token" ? access.tokenPath : undefined, + token: access.auth === "token" ? (token ?? null) : undefined, + token_error: access.tokenError, + pin_enabled: access.pinEnabled, + session_secret_path: secretPath, + session_secret_exists: existsSync(secretPath), + }); + return EXIT_OK; + } + if (access.auth === "password") { + context.log( + "Login: fixed password (ROAMGATE_PASSWORD in the service config)", + ); + } else { + context.log("Login: generated token"); + context.log( + `Token file: ${displayPath(access.tokenPath, context.runtime)}`, + ); + context.log( + `Token: ${ + access.tokenError + ? `invalid (${access.tokenError}); run \`roamgate auth rotate\`` + : (token ?? "(not created yet)") + }`, + ); + } + context.log(`PIN login: ${access.pinEnabled ? "enabled" : "disabled"}`); + return EXIT_OK; + } + + const { values } = parseCommandArgs(rest, { + "no-restart": { type: "boolean" }, + }); + const restart = values["no-restart"] !== true; + + if (action === "rotate") { + // A broken token file is reported, not thrown, so rotate can replace it. + const access = readServiceAccess(setup); + if (access.auth === "password") { + context.error( + "roamgate auth: the service uses a fixed password, not a generated token.", + ); + context.error( + "Change it with `roamgate config set ROAMGATE_PASSWORD=- --restart`.", + ); + return EXIT_FAILURE; + } + // The server reads the current location and migrates only when it is absent. + const tokenPath = join(dirname(setup.paths.config), "auth-token"); + const token = rotateAuthToken(tokenPath); + context.log( + `Rotated the login token in ${displayPath(tokenPath, context.runtime)}.`, + ); + context.log( + "Every signed-in browser must sign in again with the new URL.", + ); + if (access.pinEnabled) + context.log("PIN login stays enabled with the same PIN."); + const code = finish(context, restart); + const urls = accessUrls({ ...access, token, tokenPath }, getLanIPs); + context.log(`Open: ${urls.local}`); + for (const url of urls.lan) context.log(`LAN: ${url}`); + return code; + } + + if (action === "revoke-sessions") { + const removed = revokeSessionSecret(secretPath); + context.log( + removed + ? "Discarded the session signing secret; every browser must sign in again." + : "No session signing secret exists yet; there are no sessions to revoke.", + ); + return removed ? finish(context, restart) : EXIT_OK; + } + + throw new UsageError(`unknown auth action: ${action}`); + }, +}; diff --git a/server/src/cli/command.ts b/server/src/cli/command.ts new file mode 100644 index 00000000..a05430a9 --- /dev/null +++ b/server/src/cli/command.ts @@ -0,0 +1,131 @@ +import { homedir } from "node:os"; +import { parseArgs, type ParseArgsConfig } from "node:util"; +import { + type CaptureCommand, + defaultCaptureCommand, + defaultRunCommand, + type RunCommand, +} from "../config/service-manager"; + +/** Exit codes shared by management commands. */ +export const EXIT_OK = 0; +export const EXIT_FAILURE = 1; +export const EXIT_USAGE = 2; + +/** Returned by a command that hands control to the server listener. */ +export const CONTINUE_TO_SERVER = "continue-to-server"; + +export type CommandResult = number | typeof CONTINUE_TO_SERVER; + +/** Invalid arguments: reported with a usage hint and EXIT_USAGE. */ +export class UsageError extends Error {} + +export interface CliRuntime { + platform: string; + arch: string; + homeDir: string; + appDataDir?: string; + execPath: string; + argv: string[]; + uid?: number; + env: Record; +} + +export interface CliContext { + version: string; + runtime: CliRuntime; + runCommand: RunCommand; + /** Runs a command and returns its standard output, for status queries. */ + captureCommand: CaptureCommand; + log: (message: string) => void; + error: (message: string) => void; +} + +export interface CliCommand { + name: string; + /** One line for `roamgate --help`. */ + summary: string; + /** Subcommand words offered by shell completion. */ + actions?: readonly string[]; + /** Flags offered by shell completion. */ + flags?: readonly string[]; + help: () => string; + run: (args: string[], context: CliContext) => Promise; +} + +export function currentRuntime(): CliRuntime { + return { + platform: process.platform, + arch: process.arch, + homeDir: homedir(), + appDataDir: process.env.APPDATA, + execPath: process.execPath, + argv: process.argv, + uid: process.getuid?.(), + env: process.env, + }; +} + +export function defaultCliContext(version: string): CliContext { + return { + version, + runtime: currentRuntime(), + runCommand: defaultRunCommand, + captureCommand: defaultCaptureCommand, + log: console.log, + error: console.error, + }; +} + +/** True for `--help`, `-h` or a leading `help` word. */ +export function wantsHelp(args: readonly string[]): boolean { + return args[0] === "help" || args.includes("--help") || args.includes("-h"); +} + +type OptionsConfig = NonNullable; + +/** Strict flag parsing whose errors become usage errors. */ +export function parseCommandArgs( + args: string[], + options: Options, + maxPositionals = 0, +) { + let parsed: ReturnType>; + try { + parsed = parseArgs({ + args, + options, + strict: true, + allowPositionals: true, + }) as ReturnType>; + } catch (error) { + throw new UsageError((error as Error).message); + } + const positionals = (parsed as { positionals: string[] }).positionals; + if (positionals.length > maxPositionals) { + throw new UsageError(`unexpected argument: ${positionals[maxPositionals]}`); + } + return { values: parsed.values, positionals }; +} + +export function printJson(context: CliContext, value: unknown): void { + context.log(JSON.stringify(value, null, 2)); +} + +/** Show a home-relative path in human output. */ +export function displayPath(path: string, runtime: CliRuntime): string { + if (runtime.platform === "win32") return path; + return path === runtime.homeDir || path.startsWith(`${runtime.homeDir}/`) + ? `~${path.slice(runtime.homeDir.length)}` + : path; +} + +/** Whether this process is the standalone binary rather than `bun src/index.ts`. */ +export function isStandaloneBinary(runtime: CliRuntime): boolean { + const executable = runtime.execPath.split(/[\\/]/).pop()?.toLowerCase(); + return ( + executable !== "bun" && + executable !== "bun.exe" && + !(runtime.argv[1] ?? "").endsWith("src/index.ts") + ); +} diff --git a/server/src/cli/completion.test.ts b/server/src/cli/completion.test.ts new file mode 100644 index 00000000..906ea543 --- /dev/null +++ b/server/src/cli/completion.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EXIT_OK, EXIT_USAGE } from "./command"; +import { renderCompletion } from "./completion"; +import { cliCommands, runCli } from "./index"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +describe("roamgate completion", () => { + for (const shell of ["bash", "zsh"] as const) { + test.skipIf(!Bun.which(shell))( + `${shell} script parses and names every command`, + () => { + cli = createTestCli(); + const script = renderCompletion(shell, cliCommands()); + const path = join(cli.home, `completion.${shell}`); + writeFileSync(path, script); + const check = Bun.spawnSync([shell, "-n", path], { stderr: "pipe" }); + expect(check.stderr.toString()).toBe(""); + expect(check.exitCode).toBe(0); + for (const command of cliCommands()) + expect(script).toContain(command.name); + }, + ); + } + + test.skipIf(!Bun.which("bash"))("bash completes subcommand actions", () => { + cli = createTestCli(); + const path = join(cli.home, "completion.bash"); + writeFileSync(path, renderCompletion("bash", cliCommands())); + const result = Bun.spawnSync( + [ + "bash", + "-c", + `source "$1"; COMP_WORDS=(roamgate config s); COMP_CWORD=2; _roamgate; echo "\${COMPREPLY[*]}"`, + "bash", + path, + ], + { stdout: "pipe" }, + ); + expect(result.stdout.toString().trim()).toBe("show set"); + }); + + test("fish script registers commands with descriptions", () => { + const script = renderCompletion("fish", cliCommands()); + expect(script).toContain( + "complete -c roamgate -n __fish_use_subcommand -a doctor -d 'diagnose the installation and suggest fixes'", + ); + expect(script).toContain( + "complete -c roamgate -n '__fish_seen_subcommand_from url' -l qr", + ); + }); + + test("rejects unknown shells", async () => { + cli = createTestCli(); + expect(await runCli(["completion", "tcsh"], cli.context)).toBe(EXIT_USAGE); + expect(await runCli(["completion", "bash"], cli.context)).toBe(EXIT_OK); + }); +}); diff --git a/server/src/cli/completion.ts b/server/src/cli/completion.ts new file mode 100644 index 00000000..c28bf0d8 --- /dev/null +++ b/server/src/cli/completion.ts @@ -0,0 +1,149 @@ +import { SERVER_FLAGS } from "../config/server-config"; +import { + type CliCommand, + EXIT_OK, + parseCommandArgs, + UsageError, + wantsHelp, +} from "./command"; + +const SHELLS = ["bash", "zsh", "fish"] as const; +type Shell = (typeof SHELLS)[number]; + +function completionHelp(): string { + return `Print a shell completion script. + +Usage: + roamgate completion + +Install it with one of: + bash: roamgate completion bash > ~/.local/share/bash-completion/completions/roamgate + zsh: roamgate completion zsh > "\${fpath[1]}/_roamgate" + (or add \`source <(roamgate completion zsh)\` to ~/.zshrc) + fish: roamgate completion fish > ~/.config/fish/completions/roamgate.fish +`; +} + +/** Words offered after `roamgate `. */ +function commandWords(command: CliCommand): string[] { + return [...(command.actions ?? []), ...(command.flags ?? []), "--help"]; +} + +function shellWords(words: readonly string[]): string { + // Completion words are fixed identifiers and flags; reject anything a shell + // could interpret rather than quoting it. + for (const word of words) { + if (!/^[A-Za-z0-9-]+$/.test(word)) + throw new Error(`invalid completion word: ${word}`); + } + return words.join(" "); +} + +export function renderCompletion( + shell: Shell, + commands: readonly CliCommand[], +): string { + const topLevel = [ + ...commands.map((command) => command.name), + ...SERVER_FLAGS, + ]; + if (shell === "bash") { + const cases = commands + .map( + (command) => + ` ${command.name}) words="${shellWords(commandWords(command))}" ;;`, + ) + .join("\n"); + return `# roamgate bash completion +_roamgate() { + local cur words + cur="\${COMP_WORDS[COMP_CWORD]}" + if [ "$COMP_CWORD" -eq 1 ]; then + COMPREPLY=($(compgen -W "${shellWords(topLevel)}" -- "$cur")) + return + fi + case "\${COMP_WORDS[1]}" in +${cases} + *) words="" ;; + esac + COMPREPLY=($(compgen -W "$words" -- "$cur")) +} +complete -o default -F _roamgate roamgate +`; + } + if (shell === "zsh") { + const cases = commands + .map( + (command) => + ` ${command.name}) compadd -- ${shellWords(commandWords(command))} ;;`, + ) + .join("\n"); + return `#compdef roamgate +# roamgate zsh completion +_roamgate() { + if (( CURRENT == 2 )); then + compadd -- ${shellWords(topLevel)} + return + fi + case "$words[2]" in +${cases} + *) _files ;; + esac +} +if [ "$funcstack[1]" = "_roamgate" ]; then + _roamgate "$@" +else + compdef _roamgate roamgate +fi +`; + } + const lines = [ + "# roamgate fish completion", + "complete -c roamgate -f", + ...SERVER_FLAGS.map( + (flag) => + `complete -c roamgate -n __fish_use_subcommand -l ${shellWords([flag.slice(2)])}`, + ), + ]; + for (const command of commands) { + const summary = command.summary.replaceAll("'", ""); + lines.push( + `complete -c roamgate -n __fish_use_subcommand -a ${shellWords([command.name])} -d '${summary}'`, + ); + const condition = `'__fish_seen_subcommand_from ${command.name}'`; + if (command.actions?.length) + lines.push( + `complete -c roamgate -n ${condition} -a '${shellWords(command.actions)}'`, + ); + for (const flag of [...(command.flags ?? []), "--help"]) + lines.push( + `complete -c roamgate -n ${condition} -l ${shellWords([flag.slice(2)])}`, + ); + } + return `${lines.join("\n")}\n`; +} + +export function createCompletionCommand( + commands: () => readonly CliCommand[], +): CliCommand { + return { + name: "completion", + summary: "print a bash, zsh or fish completion script", + actions: SHELLS, + help: completionHelp, + async run(args, context) { + if (wantsHelp(args) || args.length === 0) { + context.log(completionHelp()); + return EXIT_OK; + } + const { positionals } = parseCommandArgs(args, {}, 1); + const shell = positionals[0] as Shell; + if (!SHELLS.includes(shell)) + throw new UsageError( + `unsupported shell: ${positionals[0]} (expected bash, zsh or fish)`, + ); + context.log(renderCompletion(shell, commands()).trimEnd()); + return EXIT_OK; + }, + }; +} diff --git a/server/src/cli/config.test.ts b/server/src/cli/config.test.ts new file mode 100644 index 00000000..7a2fbc11 --- /dev/null +++ b/server/src/cli/config.test.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { DEFAULT_SERVICE_ENV_FILE } from "../config/service-definitions"; +import { parseServiceEnvironment } from "../config/service-manager"; +import { EXIT_FAILURE, EXIT_OK, EXIT_USAGE } from "./command"; +import { + configCommand, + encodeEnvironmentValue, + updateEnvironmentFile, +} from "./config"; +import { runCli } from "./index"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +const update = (contents: string, entries: Record) => + updateEnvironmentFile(contents, new Map(Object.entries(entries))); + +describe("updateEnvironmentFile", () => { + test("replaces the last assignment and drops earlier duplicates", () => { + expect(update("A=1\n# note\nexport A=2\nB=3\n", { A: "4" })).toBe( + "# note\nA=4\nB=3\n", + ); + }); + + test("inserts below a commented placeholder, else appends", () => { + expect(update("# A=example\nB=1\n", { A: "x" })).toBe( + "# A=example\nA=x\nB=1\n", + ); + expect(update("B=1", { A: "x" })).toBe("B=1\nA=x\n"); + }); + + test("removes every assignment on unset and keeps comments", () => { + expect(update("# A=example\nA=1\nB=2\nA=3\n", { A: null })).toBe( + "# A=example\nB=2\n", + ); + }); + + test("keeps CRLF line endings", () => { + expect(update("A=1\r\nB=2\r\n", { B: "3" })).toBe("A=1\r\nB=3\r\n"); + }); + + test("leaves similarly named keys alone", () => { + expect(update("PORTAL=1\n", { PORT: "2" })).toBe("PORTAL=1\nPORT=2\n"); + }); +}); + +describe("encodeEnvironmentValue", () => { + test("quotes only when needed", () => { + expect(encodeEnvironmentValue("/a/b.pem")).toBe("/a/b.pem"); + expect(encodeEnvironmentValue("")).toBe("''"); + expect(encodeEnvironmentValue("a b$c")).toBe("'a b$c'"); + expect(encodeEnvironmentValue("C:\\Users\\me")).toBe("'C:\\Users\\me'"); + }); + + test("every service manager reads back exactly the value written", () => { + for (const value of [ + "plain", + "two words", + "it's my long passphrase", + "$HOME and `cmd`", + "C:\\Program Files\\x", + "", + ]) { + const line = `KEY=${encodeEnvironmentValue(value)}\n`; + for (const platform of ["systemd", "launchd", "windows-task"] as const) { + expect( + parseServiceEnvironment(line, "/env", platform, "/home/me").KEY, + ).toBe(value); + } + } + expect(encodeEnvironmentValue("it's")).toBe(`"it's"`); + }); + + test("rejects values no service manager reads identically", () => { + expect(() => encodeEnvironmentValue("a\nb")).toThrow("single line"); + expect(() => encodeEnvironmentValue(`it's $HOME`)).toThrow( + "edit the file directly", + ); + }); +}); + +describe("roamgate config", () => { + test("set creates the file from the template with private permissions", async () => { + cli = createTestCli(); + expect( + await runCli( + ["config", "set", "PORT=9123", "HOST=127.0.0.1"], + cli.context, + ), + ).toBe(EXIT_OK); + const contents = readFileSync(cli.configPath, "utf8"); + expect(contents).toContain("HOST=127.0.0.1\nPORT=9123\n"); + expect(contents).toContain("# ROAMGATE_PASSWORD="); + expect(statSync(cli.configPath).mode & 0o777).toBe(0o600); + }); + + test("rejects invalid values before writing anything", async () => { + cli = createTestCli(); + expect(await runCli(["config", "set", "PORT=70000"], cli.context)).toBe( + EXIT_USAGE, + ); + expect(cli.errors[0]).toContain("PORT: must be a port number"); + expect( + await runCli(["config", "set", "ROAMGATE_PASSWORD=short"], cli.context), + ).toBe(EXIT_USAGE); + expect(await runCli(["config", "set", "NOPE=1"], cli.context)).toBe( + EXIT_USAGE, + ); + expect(existsSync(cli.configPath)).toBe(false); + }); + + test("validates the whole file, so TLS halves must be set together", async () => { + cli = createTestCli(); + expect( + await runCli( + ["config", "set", "ROAMGATE_TLS_CERT=/missing.pem"], + cli.context, + ), + ).toBe(EXIT_FAILURE); + expect(cli.errors[0]).toContain("TLS requires both"); + expect(existsSync(cli.configPath)).toBe(false); + }); + + test("writes Roamgate names and removes legacy aliases", async () => { + cli = createTestCli(); + cli.writeConfig("HERDR_GUI_LOG_LEVEL=warn\n"); + expect( + await runCli(["config", "set", "HERDR_GUI_LOG_LEVEL=debug"], cli.context), + ).toBe(EXIT_OK); + expect(readFileSync(cli.configPath, "utf8")).toBe( + "ROAMGATE_LOG_LEVEL=debug\n", + ); + expect( + await runCli(["config", "unset", "ROAMGATE_LOG_LEVEL"], cli.context), + ).toBe(EXIT_OK); + expect(readFileSync(cli.configPath, "utf8")).toBe(""); + }); + + test("get prints effective values and fallbacks", async () => { + cli = createTestCli(); + cli.writeConfig("PORT=9000\nHERDR_GUI_PIN=123456\n"); + await runCli(["config", "get", "PORT"], cli.context); + await runCli(["config", "get", "ROAMGATE_PIN"], cli.context); + await runCli(["config", "get", "ROAMGATE_LOG_LEVEL"], cli.context); + expect(cli.output).toEqual(["9000", "123456", "info"]); + expect(await runCli(["config", "get", "HERDR_SSH_HOST"], cli.context)).toBe( + EXIT_FAILURE, + ); + }); + + test("show hides secrets unless revealed", async () => { + cli = createTestCli(); + cli.writeConfig("ROAMGATE_PASSWORD=correct-horse-battery\nCUSTOM=1\n"); + await runCli(["config", "show", "--json"], cli.context); + const shown = JSON.parse(cli.output.join("\n")); + expect(shown.settings.ROAMGATE_PASSWORD).toEqual({ + value: "(hidden)", + source: "file", + }); + expect(shown.settings.HOST).toEqual({ + value: "127.0.0.1", + source: "default", + }); + expect(shown.other).toEqual(["CUSTOM"]); + cli.output.length = 0; + await runCli(["config", "show", "--json", "--reveal"], cli.context); + expect( + JSON.parse(cli.output.join("\n")).settings.ROAMGATE_PASSWORD.value, + ).toBe("correct-horse-battery"); + }); + + test("set --restart restarts only a running service", async () => { + cli = createTestCli({ runCommand: () => 0 }); + cli.writeConfig(DEFAULT_SERVICE_ENV_FILE); + // No service definition exists, so nothing is restarted. + expect( + await runCli(["config", "set", "PORT=9001", "--restart"], cli.context), + ).toBe(EXIT_OK); + expect(cli.commands).toEqual([]); + expect(cli.output.at(-1)).toContain("not running"); + }); + + test("existing values the server accepts never block unrelated changes", async () => { + cli = createTestCli(); + cli.writeConfig("ROAMGATE_WEB_PUSH_PATH=web-push.json\nHOST=my host\n"); + expect(await runCli(["config", "set", "PORT=9000"], cli.context)).toBe( + EXIT_OK, + ); + expect(readFileSync(cli.configPath, "utf8")).toContain("PORT=9000"); + // Settings the server refuses to start with are still always checked. + cli.writeConfig("ROAMGATE_LOG_LEVEL=loud\n"); + expect(await runCli(["config", "set", "PORT=9001"], cli.context)).toBe( + EXIT_FAILURE, + ); + expect(cli.errors.at(-1)).toContain("ROAMGATE_LOG_LEVEL"); + }); + + test("path and edit still work when the file cannot be parsed", async () => { + let script = ""; + cli = createTestCli({ + runCommand: (argv) => { + if (argv[0] !== "fake-editor") return 4; + cli.writeConfig(script); + return 0; + }, + }); + cli.context.runtime.env.EDITOR = "fake-editor"; + cli.writeConfig("this is not an assignment\n"); + expect(await runCli(["config", "path"], cli.context)).toBe(EXIT_OK); + expect(cli.output.at(-1)).toBe(cli.configPath); + expect(await runCli(["config", "show"], cli.context)).toBe(EXIT_FAILURE); + + // Saving a still-broken file is reported with guidance, not thrown. + script = "still broken\n"; + expect(await runCli(["config", "edit"], cli.context)).toBe(EXIT_FAILURE); + expect(cli.errors.at(-2)).toContain("the edited file is invalid"); + expect(cli.errors.at(-1)).toContain("roamgate config edit"); + + script = "PORT=9002\n"; + expect(await runCli(["config", "edit"], cli.context)).toBe(EXIT_OK); + expect(cli.commands[0]).toEqual(["fake-editor", cli.configPath]); + }); + + test("edit applies strict checks only to settings that changed", async () => { + cli = createTestCli({ + runCommand: () => { + cli.writeConfig("HOST=my host\nROAMGATE_WEB_PUSH_PATH=relative.json\n"); + return 0; + }, + }); + cli.context.runtime.env.EDITOR = "fake-editor"; + cli.writeConfig("HOST=my host\n"); + expect(await runCli(["config", "edit"], cli.context)).toBe(EXIT_FAILURE); + expect(cli.errors.at(-2)).toContain("ROAMGATE_WEB_PUSH_PATH"); + }); + + test("help and unknown actions", async () => { + cli = createTestCli(); + expect(await configCommand.run([], cli.context)).toBe(EXIT_OK); + expect(await runCli(["config", "frobnicate"], cli.context)).toBe( + EXIT_USAGE, + ); + }); +}); diff --git a/server/src/cli/config.ts b/server/src/cli/config.ts new file mode 100644 index 00000000..9577169c --- /dev/null +++ b/server/src/cli/config.ts @@ -0,0 +1,619 @@ +import { isAbsolute } from "node:path"; +import { validateSshDestination } from "../bridge/ssh-command"; +import { + assertValidAuthPassword, + assertValidAuthPin, +} from "../config/auth-token"; +import { replaceDataFile } from "../config/data-paths"; +import { roamgateEnv } from "../config/environment"; +import { + parseServiceEnvironment, + prepareServiceAccess, +} from "../config/service-manager"; +import { normalizeUpdateBaseUrl } from "../http/update"; +import { parseTaskNotificationSource } from "../notifications/herdr-notification-listener"; +import { assertValidPushSubject } from "../notifications/web-push"; +import { parseLogLevel } from "../utils/logger"; +import { + type CliCommand, + type CliContext, + displayPath, + EXIT_FAILURE, + EXIT_OK, + parseCommandArgs, + printJson, + UsageError, + wantsHelp, +} from "./command"; +import { + locateServiceConfig, + readServiceSetup, + restartRunningService, + type ServiceConfigFile, + serviceStatus, + type ServiceSetup, +} from "./service-context"; + +interface Setting { + key: string; + description: string; + /** The value the server uses when the file does not set one. */ + fallback?: string; + secret?: boolean; + validate?: (value: string) => void; + /** + * The server refuses to start with an invalid value, so it is checked on + * every write. Other settings are checked strictly only when they change, so + * an existing value the server tolerates never blocks unrelated edits. + */ + startup?: boolean; +} + +function assertFlag(value: string) { + if (value !== "0" && value !== "1") throw new Error("must be 0 or 1"); +} + +function assertPositiveInteger(value: string) { + if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(Number(value))) + throw new Error("must be a positive integer"); +} + +function assertAbsolutePath(value: string) { + if (!isAbsolute(value)) throw new Error("must be an absolute path"); +} + +export const SETTINGS: readonly Setting[] = [ + { + key: "HOST", + description: "listen address", + fallback: "127.0.0.1", + validate: (value) => { + if (!/^[A-Za-z0-9.:_-]+$/.test(value)) + throw new Error("must be a host name or IP address"); + }, + }, + { + key: "PORT", + description: "listen port", + fallback: "8787", + validate: (value) => { + if ( + !/^[0-9]+$/.test(value) || + Number(value) < 1 || + Number(value) > 65_535 + ) + throw new Error("must be a port number from 1 to 65535"); + }, + }, + { + key: "ROAMGATE_PASSWORD", + description: "fixed login password instead of a generated token", + secret: true, + validate: assertValidAuthPassword, + }, + { + key: "ROAMGATE_PIN", + description: "6..12 digit convenience login (private networks only)", + secret: true, + validate: assertValidAuthPin, + }, + { + key: "ROAMGATE_TLS_CERT", + description: "PEM certificate chain for native HTTPS", + validate: assertAbsolutePath, + }, + { + key: "ROAMGATE_TLS_KEY", + description: "PEM private key for native HTTPS", + validate: assertAbsolutePath, + }, + { + key: "ROAMGATE_LOG_LEVEL", + description: "error|warn|info|debug", + fallback: "info", + validate: parseLogLevel, + startup: true, + }, + { + key: "ROAMGATE_NOTIFICATION_SOURCE", + description: "herdr|status task alert source", + fallback: "herdr", + validate: (value) => parseTaskNotificationSource(value), + startup: true, + }, + { key: "HERDR_SOCKET_PATH", description: "Herdr control socket" }, + { key: "HERDR_CLIENT_SOCKET_PATH", description: "Herdr render socket" }, + { + key: "HERDR_SSH_HOST", + description: "remote Herdr over SSH (user@host)", + validate: validateSshDestination, + startup: true, + }, + { key: "HERDR_SESSION", description: "named Herdr session" }, + { + key: "ROAMGATE_UPDATE_BASE_URL", + description: "latest-release mirror directory", + validate: normalizeUpdateBaseUrl, + }, + { + key: "ROAMGATE_DISABLE_UPDATE_CHECK", + description: "1 disables update checks", + validate: assertFlag, + }, + { + key: "ROAMGATE_ALLOW_FILE_REVEAL", + description: "1 allows revealing files on the host", + validate: assertFlag, + }, + { + key: "ROAMGATE_UPLOAD_MAX_BYTES", + description: "maximum terminal upload size", + validate: assertPositiveInteger, + }, + { + key: "ROAMGATE_UPLOAD_RETENTION_HOURS", + description: "staged upload retention", + validate: assertPositiveInteger, + }, + { + key: "ROAMGATE_WEB_PUSH_SUBJECT", + description: "VAPID contact; empty disables Web Push", + validate: (value) => { + if (value) assertValidPushSubject(value); + }, + }, + { + key: "ROAMGATE_WEB_PUSH_PATH", + description: "Web Push registry file", + validate: assertAbsolutePath, + }, + { + key: "ROAMGATE_CONNECTIONS_PATH", + description: "connection profile registry file", + validate: assertAbsolutePath, + }, + { + key: "ROAMGATE_SETTINGS_PATH", + description: "GUI settings file", + validate: assertAbsolutePath, + }, +]; + +const SETTINGS_BY_KEY = new Map(SETTINGS.map((entry) => [entry.key, entry])); + +/** Accept legacy HERDR_GUI_* spellings, but always write the Roamgate name. */ +function resolveSetting(key: string): Setting { + const canonical = key.startsWith("HERDR_GUI_") + ? `ROAMGATE_${key.slice("HERDR_GUI_".length)}` + : key; + const setting = SETTINGS_BY_KEY.get(canonical); + if (!setting) { + throw new UsageError( + `unknown setting: ${key} (run \`roamgate config show\` for the list; edit the file directly for other variables)`, + ); + } + return setting; +} + +function legacyAlias(key: string): string | undefined { + return key.startsWith("ROAMGATE_") + ? `HERDR_GUI_${key.slice("ROAMGATE_".length)}` + : undefined; +} + +function effectiveValue(env: Record, key: string) { + return key.startsWith("ROAMGATE_") + ? roamgateEnv(key.slice("ROAMGATE_".length), env) + : env[key]; +} + +const BARE_VALUE = /^[A-Za-z0-9_./:@%+,=-]*$/; + +/** + * Quote so systemd's EnvironmentFile, launchd's sh source and the Windows + * parser all read the same value: single quotes are literal everywhere, and + * double quotes are literal when the value has no $, `, \\ or ". + */ +export function encodeEnvironmentValue(value: string): string { + if (/[\0\r\n]/.test(value)) + throw new Error("values must be a single line without NUL characters"); + if (value !== "" && BARE_VALUE.test(value)) return value; + if (!value.includes("'")) return `'${value}'`; + if (!/[$`\\"]/.test(value)) return `"${value}"`; + throw new Error( + "values containing ' together with $, `, \\ or \" cannot be quoted for every service manager; edit the file directly", + ); +} + +function assignmentPattern(key: string): RegExp { + return new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`); +} + +/** + * Apply updates (null removes) while preserving comments and order. A new + * value replaces the last assignment, lands below its commented placeholder, + * or is appended; earlier duplicate assignments are removed. + */ +export function updateEnvironmentFile( + contents: string, + updates: ReadonlyMap, +): string { + const newline = contents.includes("\r\n") ? "\r\n" : "\n"; + let lines = contents.split(/\r?\n/); + if (lines.at(-1) === "") lines.pop(); + for (const [key, value] of updates) { + const pattern = assignmentPattern(key); + const assigned = lines.flatMap((line, index) => + pattern.test(line) ? [index] : [], + ); + const replacement = + value === null ? null : `${key}=${encodeEnvironmentValue(value)}`; + if (replacement !== null && assigned.length > 0) { + lines[assigned.at(-1)!] = replacement; + assigned.pop(); + } + const removed = new Set(assigned); + lines = lines.filter((_, index) => !removed.has(index)); + if (replacement === null || lines.includes(replacement)) continue; + const placeholder = new RegExp(`^\\s*#\\s*(?:export\\s+)?${key}\\s*=`); + const placeholderIndex = lines.findLastIndex((line) => + placeholder.test(line), + ); + if (placeholderIndex >= 0) + lines.splice(placeholderIndex + 1, 0, replacement); + else lines.push(replacement); + } + return lines.length ? `${lines.join(newline)}${newline}` : ""; +} + +/** + * Reject a candidate file the service would refuse to start with. Settings in + * strictKeys also get the CLI's stricter per-setting checks. + */ +function validateCandidate( + file: ServiceConfigFile, + contents: string, + strictKeys: ReadonlySet, +) { + const env = parseServiceEnvironment( + contents, + file.configPath, + file.platform, + file.homeDir, + ); + for (const setting of SETTINGS) { + if (!setting.validate) continue; + if (!setting.startup && !strictKeys.has(setting.key)) continue; + const value = effectiveValue(env, setting.key); + if (value === undefined || value === "") continue; + try { + setting.validate(value); + } catch (error) { + throw new Error(`${setting.key}: ${(error as Error).message}`, { + cause: error, + }); + } + } + prepareServiceAccess(file.configPath, contents, file.platform, file.homeDir); + return env; +} + +/** Settings whose effective value differs between two parsed files. */ +function changedSettings( + before: Record | null, + after: Record, +): Set { + return new Set( + SETTINGS.filter( + (setting) => + before === null || + effectiveValue(before, setting.key) !== + effectiveValue(after, setting.key), + ).map((setting) => setting.key), + ); +} + +function configHelp(): string { + return `Read and change the managed service's environment file. + +Usage: + roamgate config show [--reveal] [--json] + roamgate config get + roamgate config set ... [--restart] + roamgate config unset ... [--restart] + roamgate config edit [--restart] + roamgate config path + +Changes are validated as a whole before the file is replaced, so the service +never receives a configuration it would refuse to start with. Changed settings +get stricter checks; existing values the server accepts are left alone. Comments and +ordering are preserved. A running service keeps its old settings until it +restarts; --restart restarts it when it is running. + +Set paired values in one command, for example + roamgate config set ROAMGATE_TLS_CERT=/path/cert.pem ROAMGATE_TLS_KEY=/path/key.pem + +Use KEY=- to read a value from standard input instead of the command line, +which keeps secrets such as ROAMGATE_PASSWORD out of shell history: + printf '%s' "$PASSWORD" | roamgate config set ROAMGATE_PASSWORD=- + +Shell flags and exported variables apply only to foreground \`roamgate\` runs; +this command manages the file the service reads. +`; +} + +async function readStdinValue(): Promise { + const text = await new Response(Bun.stdin.stream()).text(); + return text.replace(/\r?\n$/, ""); +} + +function writeAndReport( + context: CliContext, + setup: ServiceSetup, + next: string, + changed: ReadonlySet, + restart: boolean, +): number { + validateCandidate(setup, next, changed); + replaceDataFile(setup.configPath, next); + context.log(`Updated ${displayPath(setup.configPath, context.runtime)}`); + return applyChange(context, restart); +} + +function applyChange(context: CliContext, restart: boolean): number { + if (restart) { + const code = restartRunningService(context); + if (code === null) { + context.log("The service is not running; changes apply when it starts."); + return EXIT_OK; + } + if (code === 0) context.log("Restarted the service."); + return code; + } + let active = false; + try { + active = serviceStatus(context).active; + } catch { + // Unsupported platforms have no managed service to remind about. + } + if (active) + context.log( + "The running service still uses the old values; run `roamgate service restart`.", + ); + return EXIT_OK; +} + +function editConfig(args: string[], context: CliContext): number { + const { values } = parseCommandArgs(args, { + restart: { type: "boolean" }, + }); + const file = locateServiceConfig(context.runtime); + if (!file.configExists) replaceDataFile(file.configPath, file.contents); + let before: Record | null = null; + try { + before = parseServiceEnvironment( + file.contents, + file.configPath, + file.platform, + file.homeDir, + ); + } catch { + // Repairing a broken file is a reason to edit it; check everything after. + } + const editor = + context.runtime.env.VISUAL || + context.runtime.env.EDITOR || + (context.runtime.platform === "win32" ? "notepad" : "vi"); + const code = context.runCommand([ + ...editor.split(/\s+/).filter(Boolean), + file.configPath, + ]); + if (code !== 0) { + context.error(`roamgate config: editor exited ${code}`); + return EXIT_FAILURE; + } + const edited = locateServiceConfig(context.runtime); + try { + const after = parseServiceEnvironment( + edited.contents, + edited.configPath, + edited.platform, + edited.homeDir, + ); + validateCandidate(edited, edited.contents, changedSettings(before, after)); + } catch (error) { + context.error( + `roamgate config: the edited file is invalid: ${(error as Error).message}`, + ); + context.error( + "The service would refuse to start; run `roamgate config edit` again to fix it.", + ); + return EXIT_FAILURE; + } + if (edited.contents === file.contents) { + context.log("No changes."); + return EXIT_OK; + } + return applyChange(context, values.restart === true); +} + +export const configCommand: CliCommand = { + name: "config", + summary: "show or change the service configuration", + actions: ["show", "get", "set", "unset", "edit", "path"], + flags: ["--reveal", "--json", "--restart"], + help: configHelp, + async run(args, context) { + const action = args[0]; + if (wantsHelp(args) || action === undefined) { + context.log(configHelp()); + return EXIT_OK; + } + const rest = args.slice(1); + + // path and edit only locate the file, so they still work when it is broken. + if (action === "path") { + parseCommandArgs(rest, {}); + context.log(locateServiceConfig(context.runtime).configPath); + return EXIT_OK; + } + if (action === "edit") return editConfig(rest, context); + + const setup = readServiceSetup(context.runtime); + + if (action === "show") { + const { values } = parseCommandArgs(rest, { + reveal: { type: "boolean" }, + json: { type: "boolean" }, + }); + const known = new Set(); + const rows = SETTINGS.map((setting) => { + known.add(setting.key); + const alias = legacyAlias(setting.key); + if (alias) known.add(alias); + const value = effectiveValue(setup.env, setting.key); + const source = + value !== undefined + ? setup.env[setting.key] !== undefined + ? "file" + : "file (legacy name)" + : setting.fallback !== undefined + ? "default" + : "unset"; + const shown = + value === undefined + ? setting.fallback + : setting.secret && !values.reveal && value !== "" + ? "(hidden)" + : value; + return { key: setting.key, value: shown, source, setting }; + }); + const other = Object.keys(setup.env).filter((key) => !known.has(key)); + if (values.json) { + printJson(context, { + path: setup.configPath, + exists: setup.configExists, + settings: Object.fromEntries( + rows.map((row) => [ + row.key, + { value: row.value ?? null, source: row.source }, + ]), + ), + other: values.reveal + ? Object.fromEntries(other.map((key) => [key, setup.env[key]])) + : other, + }); + return EXIT_OK; + } + context.log( + `${displayPath(setup.configPath, context.runtime)}${setup.configExists ? "" : " (not created yet; showing defaults)"}`, + ); + context.log(""); + const width = Math.max(...rows.map((row) => row.key.length)); + for (const row of rows) { + if (row.source === "unset") continue; + const label = row.source === "file" ? "" : ` (${row.source})`; + context.log( + ` ${row.key.padEnd(width)} ${row.value === "" ? "''" : row.value}${label}`, + ); + } + const unset = rows.filter((row) => row.source === "unset"); + if (unset.length) { + context.log(""); + context.log("Not set:"); + for (const row of unset) + context.log(` ${row.key.padEnd(width)} ${row.setting.description}`); + } + if (other.length) { + context.log(""); + context.log(`Other variables: ${other.join(", ")}`); + } + return EXIT_OK; + } + + if (action === "get") { + const { positionals } = parseCommandArgs(rest, {}, 1); + if (positionals.length !== 1) + throw new UsageError("config get needs one KEY"); + const setting = resolveSetting(positionals[0]); + const value = effectiveValue(setup.env, setting.key); + if (value === undefined) { + if (setting.fallback === undefined) return EXIT_FAILURE; + context.log(setting.fallback); + return EXIT_OK; + } + context.log(value); + return EXIT_OK; + } + + if (action === "set" || action === "unset") { + const { values, positionals } = parseCommandArgs( + rest, + { restart: { type: "boolean" } }, + Number.POSITIVE_INFINITY, + ); + if (positionals.length === 0) + throw new UsageError( + action === "set" + ? "config set needs KEY=VALUE" + : "config unset needs a KEY", + ); + const updates = new Map(); + let readFromStdin = false; + for (const argument of positionals) { + let key = argument; + let value: string | null = null; + if (action === "set") { + const separator = argument.indexOf("="); + if (separator <= 0) + throw new UsageError(`expected KEY=VALUE, got: ${argument}`); + key = argument.slice(0, separator); + value = argument.slice(separator + 1); + } + const setting = resolveSetting(key); + if (value === "-") { + if (readFromStdin) + throw new UsageError( + "only one value can be read from standard input", + ); + readFromStdin = true; + value = await readStdinValue(); + } else if (setting.secret && value) { + context.error( + `Warning: ${setting.key} on the command line can be saved in shell history; prefer ${setting.key}=-`, + ); + } + if (value !== null && value !== "" && setting.validate) { + try { + setting.validate(value); + } catch (error) { + throw new UsageError( + `${setting.key}: ${(error as Error).message}`, + { cause: error }, + ); + } + } + updates.set(setting.key, value); + // The legacy alias would shadow nothing once the new name exists, but + // after an unset it would take effect again; remove both spellings. + const alias = legacyAlias(setting.key); + if (alias && (value === null || setup.env[alias] !== undefined)) + updates.set(alias, null); + } + const next = updateEnvironmentFile(setup.contents, updates); + if (next === setup.contents && setup.configExists) { + context.log("No changes."); + return EXIT_OK; + } + return writeAndReport( + context, + setup, + next, + new Set(updates.keys()), + values.restart === true, + ); + } + + throw new UsageError(`unknown config action: ${action}`); + }, +}; diff --git a/server/src/cli/connections.test.ts b/server/src/cli/connections.test.ts new file mode 100644 index 00000000..34f578c9 --- /dev/null +++ b/server/src/cli/connections.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EXIT_FAILURE, EXIT_OK, EXIT_USAGE } from "./command"; +import { + type ConnectionsDependencies, + createConnectionsCommand, +} from "./connections"; +import { runCli } from "./index"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +const idle: ConnectionsDependencies = { probe: async () => false }; + +function registry(testCli: TestCli) { + return JSON.parse( + readFileSync( + join(testCli.home, ".config", "roamgate", "connections.json"), + "utf8", + ), + ); +} + +async function connections(args: string[], dependencies = idle) { + return runCli(["connections", ...args], cli.context, [ + createConnectionsCommand(dependencies), + ]); +} + +describe("roamgate connections", () => { + test("lists the implicit default from the service config", async () => { + cli = createTestCli(); + cli.writeConfig(""); + expect(await connections(["list", "--json"])).toBe(EXIT_OK); + const listed = JSON.parse(cli.output.join("\n")); + expect(listed.default_connection_id).toBe("legacy-default"); + expect(listed.profiles[0]).toMatchObject({ + saved: false, + control_socket_path: join(cli.home, ".config", "herdr", "herdr.sock"), + }); + }); + + test("the first saved profile keeps the local server, like the web UI", async () => { + cli = createTestCli(); + cli.writeConfig(""); + expect( + await connections([ + "add", + "box", + "--ssh", + "me@box", + "--label", + "Box", + "--auto-connect", + ]), + ).toBe(EXIT_OK); + const saved = registry(cli); + expect(saved.default_connection_id).toBe("local"); + expect(saved.profiles.map((profile: { id: string }) => profile.id)).toEqual( + ["local", "box"], + ); + expect(saved.profiles[1]).toEqual({ + id: "box", + label: "Box", + type: "ssh", + ssh_destination: "me@box", + remote_control_socket_path: "", + remote_client_socket_path: "", + auto_connect: true, + }); + }); + + test("a new profile named local seeds localhost instead", async () => { + cli = createTestCli(); + cli.writeConfig(""); + await connections(["add", "local", "--ssh", "me@box", "--default"]); + const saved = registry(cli); + expect(saved.profiles[0].id).toBe("localhost"); + expect(saved.default_connection_id).toBe("local"); + }); + + test("enforces the same removal and default rules as the server", async () => { + cli = createTestCli(); + cli.writeConfig(""); + await connections(["add", "box", "--ssh", "me@box"]); + expect(await connections(["remove", "local"])).toBe(EXIT_USAGE); + expect(cli.errors.at(-2)).toContain("cannot remove the default connection"); + expect(await connections(["set-default", "box"])).toBe(EXIT_OK); + expect(await connections(["remove", "local"])).toBe(EXIT_OK); + // The last saved profile is always the default, as on the server. + expect(await connections(["remove", "box"])).toBe(EXIT_USAGE); + expect(cli.errors.at(-2)).toContain("cannot remove the default connection"); + expect(await connections(["add", "box", "--ssh", "me@other"])).toBe( + EXIT_USAGE, + ); + expect(cli.errors.at(-2)).toContain("already exists"); + }); + + test("rejects invalid profiles as usage errors", async () => { + cli = createTestCli(); + cli.writeConfig(""); + expect(await connections(["add", "x"])).toBe(EXIT_USAGE); + expect( + await connections(["add", "x", "--windows", "--control-socket", "/a"]), + ).toBe(EXIT_USAGE); + expect( + await connections(["add", "x", "--ssh", "-oProxyCommand=evil"]), + ).toBe(EXIT_USAGE); + expect( + existsSync(join(cli.home, ".config", "roamgate", "connections.json")), + ).toBe(false); + }); + + test("refuses edits while a Roamgate listener holds its own copy", async () => { + cli = createTestCli(); + cli.writeConfig(""); + const serving = { probe: async () => true }; + expect(await connections(["add", "box", "--ssh", "me@box"], serving)).toBe( + EXIT_FAILURE, + ); + expect(cli.errors[0]).toContain("would overwrite this change"); + expect( + existsSync(join(cli.home, ".config", "roamgate", "connections.json")), + ).toBe(false); + }); + + test("restarts a running managed service with --restart", async () => { + cli = createTestCli({ + runCommand: (argv) => (argv.includes("herdr-gui.service") ? 4 : 0), + }); + cli.writeConfig(""); + mkdirSync(join(cli.home, ".config", "systemd", "user"), { + recursive: true, + }); + writeFileSync( + join(cli.home, ".config", "systemd", "user", "roamgate.service"), + "# Generated by roamgate service install.\n", + ); + const serving = { probe: async () => true }; + expect(await connections(["add", "box", "--ssh", "me@box"], serving)).toBe( + EXIT_FAILURE, + ); + expect(cli.errors[1]).toContain("--restart"); + expect( + await connections( + ["add", "box", "--ssh", "me@box", "--restart"], + serving, + ), + ).toBe(EXIT_OK); + expect(registry(cli).profiles).toHaveLength(2); + expect(cli.commands).toContainEqual([ + "systemctl", + "--user", + "restart", + "roamgate.service", + ]); + }); + + test("test reports each profile and fails when any is unreachable", async () => { + cli = createTestCli(); + cli.writeConfig(""); + await connections(["add", "box", "--ssh", "me@box"]); + cli.output.length = 0; + const code = await connections(["test", "--json"], { + ...idle, + test: async (profile) => { + if (profile.type === "ssh") throw new Error("ssh: connection refused"); + return { ok: true, version: "0.9.4", protocol: 7 }; + }, + }); + expect(code).toBe(EXIT_FAILURE); + expect(JSON.parse(cli.output.join("\n")).results).toEqual([ + { id: "local", ok: true, version: "0.9.4", protocol: 7 }, + { id: "box", ok: false, error: "ssh: connection refused" }, + ]); + expect(await connections(["test", "nope"])).toBe(EXIT_USAGE); + }); + + test("explicit Herdr settings stay out of a saved registry", async () => { + cli = createTestCli(); + cli.writeConfig(""); + await connections(["add", "box", "--ssh", "me@box"]); + cli.writeConfig("HERDR_SESSION=work\n"); + expect(await connections(["add", "lab", "--ssh", "me@lab"])).toBe(EXIT_OK); + expect(await connections(["remove", "box"])).toBe(EXIT_OK); + const saved = registry(cli); + expect(saved.profiles.map((profile: { id: string }) => profile.id)).toEqual( + ["local", "lab"], + ); + cli.output.length = 0; + await connections(["list", "--json"]); + const listed = JSON.parse(cli.output.join("\n")); + expect(listed.default_connection_id).toBe("legacy-default"); + expect(listed.profiles[0]).toMatchObject({ + id: "legacy-default", + saved: false, + control_socket_path: join( + cli.home, + ".config", + "herdr", + "sessions", + "work", + "herdr.sock", + ), + }); + }); +}); diff --git a/server/src/cli/connections.ts b/server/src/cli/connections.ts new file mode 100644 index 00000000..a581cc06 --- /dev/null +++ b/server/src/cli/connections.ts @@ -0,0 +1,471 @@ +import { + type ManagedConnectionProfile, + type SyntheticLocalProfile, + testConnectionProfile, +} from "../connections/profile-service"; +import { + CONNECTION_PROFILE_FILE_VERSION, + type ConnectionProfile, + ConnectionProfileStore, + type PersistedConnectionRegistry, + validateConnectionProfile, +} from "../connections/profiles"; +import { LEGACY_DEFAULT_CONNECTION_ID } from "../connections/types"; +import { + type CliCommand, + type CliContext, + displayPath, + EXIT_FAILURE, + EXIT_OK, + parseCommandArgs, + printJson, + UsageError, + wantsHelp, +} from "./command"; +import { + connectionRegistryPath, + type ListenerProbe, + probeListener, + readServiceAccess, + readServiceSetup, + restartRunningService, + serviceHerdrSockets, + serviceStatus, + type ServiceSetup, +} from "./service-context"; + +export interface ConnectionsDependencies { + probe?: ListenerProbe; + test?: typeof testConnectionProfile; +} + +function connectionsHelp(): string { + return `List, test and edit saved Herdr connection profiles. + +Usage: + roamgate connections list [--json] + roamgate connections test [...] [--json] + roamgate connections add --ssh [ssh options] [common options] + roamgate connections add --control-socket --client-socket + [common options] + roamgate connections remove [--restart] + roamgate connections set-default [--restart] + +Common add options: + --label display name (default: the id) + --auto-connect connect when Roamgate starts + --default make it the default connection + --restart see below + +SSH add options: + --remote-control-socket , --remote-client-socket + remote socket paths (default: inferred from the remote home) + --windows the SSH host runs Windows + --herdr-path , --session + Windows host executable and Herdr session + +The profiles are the same ones the connection selector edits. A running +Roamgate keeps its own copy and would overwrite a change made here, so edits +are refused while it serves; with --restart a running managed service is +restarted to load the change. Only the address in the service config is +checked: stop any other roamgate process that shares this registry, such as +a foreground run on another port, before editing here. SSH tests need a +running remote Herdr and non-interactive SSH authentication. +`; +} + +const ADD_OPTIONS = { + label: { type: "string" }, + ssh: { type: "string" }, + "control-socket": { type: "string" }, + "client-socket": { type: "string" }, + "remote-control-socket": { type: "string" }, + "remote-client-socket": { type: "string" }, + windows: { type: "boolean" }, + "herdr-path": { type: "string" }, + session: { type: "string" }, + "auto-connect": { type: "boolean" }, + default: { type: "boolean" }, + restart: { type: "boolean" }, +} as const; + +/** Mirrors index.ts: explicit Herdr settings pin the process default. */ +function explicitOverride(setup: ServiceSetup): boolean { + return Boolean( + setup.env.HERDR_SOCKET_PATH || + setup.env.HERDR_CLIENT_SOCKET_PATH || + setup.env.HERDR_SSH_HOST || + setup.env.HERDR_SESSION, + ); +} + +function implicitDefault( + context: CliContext, + setup: ServiceSetup, +): SyntheticLocalProfile { + const sockets = serviceHerdrSockets(context.runtime, setup.env); + return { + id: LEGACY_DEFAULT_CONNECTION_ID, + label: "Default", + type: "local", + control_socket_path: sockets.socketPath, + client_socket_path: sockets.clientSocketPath, + auto_connect: true, + }; +} + +function describe(profile: ManagedConnectionProfile): string { + if (profile.type === "local") return `local ${profile.control_socket_path}`; + const remote = + profile.remote_platform === "windows" + ? ` (Windows${profile.remote_session ? `, session ${profile.remote_session}` : ""})` + : ""; + return `ssh ${profile.ssh_destination}${remote}`; +} + +/** Refuse to write while a Roamgate listener holds its own copy of the registry. */ +async function guardRunningServer( + context: CliContext, + setup: ServiceSetup, + restart: boolean, + probe: ListenerProbe, +): Promise<"write" | "write-and-restart" | number> { + if (!(await probe(readServiceAccess(setup)))) return "write"; + let active = false; + try { + active = serviceStatus(context).active; + } catch { + // Unsupported platform: only a foreground process can be serving. + } + if (!active) { + context.error( + "roamgate connections: a roamgate process is serving and would overwrite this change.", + ); + context.error( + "Use the connection selector in the web UI, or stop that process first.", + ); + return EXIT_FAILURE; + } + if (!restart) { + context.error( + "roamgate connections: the running service keeps its own copy of the profiles and would overwrite this change.", + ); + context.error( + "Use the connection selector in the web UI, or rerun with --restart to restart the service.", + ); + return EXIT_FAILURE; + } + return "write-and-restart"; +} + +function profileFromFlags( + id: string, + values: Record, +): ConnectionProfile { + const label = (values.label as string | undefined) ?? id; + const autoConnect = values["auto-connect"] === true; + const ssh = values.ssh as string | undefined; + const sshOnly = [ + "remote-control-socket", + "remote-client-socket", + "windows", + "herdr-path", + "session", + ]; + if (ssh === undefined) { + const used = sshOnly.find((key) => values[key] !== undefined); + if (used) throw new UsageError(`--${used} requires --ssh`); + if (!values["control-socket"] || !values["client-socket"]) + throw new UsageError( + "add needs --ssh , or both --control-socket and --client-socket", + ); + return validateConnectionProfile({ + id, + label, + type: "local", + control_socket_path: values["control-socket"], + client_socket_path: values["client-socket"], + auto_connect: autoConnect, + }); + } + if (values["control-socket"] || values["client-socket"]) + throw new UsageError("local socket options cannot be combined with --ssh"); + const windows = values.windows === true; + return validateConnectionProfile({ + id, + label, + type: "ssh", + ssh_destination: ssh, + remote_control_socket_path: + (values["remote-control-socket"] as string | undefined) ?? "", + remote_client_socket_path: + (values["remote-client-socket"] as string | undefined) ?? "", + auto_connect: autoConnect, + ...(windows ? { remote_platform: "windows" } : {}), + ...(values["herdr-path"] !== undefined + ? { remote_herdr_path: values["herdr-path"] } + : {}), + ...(values.session !== undefined ? { remote_session: values.session } : {}), + }); +} + +export function createConnectionsCommand( + dependencies: ConnectionsDependencies = {}, +): CliCommand { + const probe = dependencies.probe ?? probeListener; + const test = dependencies.test ?? testConnectionProfile; + return { + name: "connections", + summary: "list, test and edit Herdr connection profiles", + actions: ["list", "test", "add", "remove", "set-default"], + flags: [ + "--json", + "--ssh", + "--control-socket", + "--client-socket", + "--label", + "--auto-connect", + "--default", + "--restart", + "--remote-control-socket", + "--remote-client-socket", + "--windows", + "--herdr-path", + "--session", + ], + help: connectionsHelp, + async run(args, context) { + const action = args[0]; + if (wantsHelp(args) || action === undefined) { + context.log(connectionsHelp()); + return EXIT_OK; + } + const rest = args.slice(1); + const setup = readServiceSetup(context.runtime); + const store = new ConnectionProfileStore({ + path: connectionRegistryPath(context.runtime, setup.env), + }); + const registry = store.load(); + // Copy: the explicit default is listed but must never reach a save. + const profiles: ManagedConnectionProfile[] = registry + ? [...registry.profiles] + : [implicitDefault(context, setup)]; + const defaultId = explicitOverride(setup) + ? LEGACY_DEFAULT_CONNECTION_ID + : (registry?.default_connection_id ?? LEGACY_DEFAULT_CONNECTION_ID); + if (registry && explicitOverride(setup)) + profiles.unshift(implicitDefault(context, setup)); + + if (action === "list") { + const { values } = parseCommandArgs(rest, { + json: { type: "boolean" }, + }); + if (values.json) { + printJson(context, { + path: store.path, + default_connection_id: defaultId, + profiles: profiles.map((profile) => ({ + ...profile, + default: profile.id === defaultId, + saved: profile.id !== LEGACY_DEFAULT_CONNECTION_ID, + })), + }); + return EXIT_OK; + } + context.log(displayPath(store.path, context.runtime)); + if (!registry) + context.log( + "No saved profiles; Roamgate uses the default local connection.", + ); + const width = Math.max(...profiles.map((profile) => profile.id.length)); + for (const profile of profiles) { + const flags = [ + profile.id === defaultId ? "default" : "", + profile.auto_connect ? "auto-connect" : "", + profile.id === LEGACY_DEFAULT_CONNECTION_ID + ? "from service config" + : "", + ].filter(Boolean); + context.log( + `${profile.id === defaultId ? "*" : " "} ${profile.id.padEnd(width)} ${describe(profile)} ${profile.label}${flags.length ? ` [${flags.join(", ")}]` : ""}`, + ); + } + return EXIT_OK; + } + + if (action === "test") { + const { values, positionals } = parseCommandArgs( + rest, + { json: { type: "boolean" } }, + Number.POSITIVE_INFINITY, + ); + const selected = positionals.length + ? positionals.map((id) => { + const profile = profiles.find((entry) => entry.id === id); + if (!profile) throw new UsageError(`unknown connection: ${id}`); + return profile; + }) + : profiles; + const results = await Promise.all( + selected.map(async (profile) => { + try { + const result = await test(profile); + return { + id: profile.id, + ok: true, + version: result.version, + protocol: result.protocol, + }; + } catch (error) { + return { + id: profile.id, + ok: false, + error: (error as Error).message, + }; + } + }), + ); + if (values.json) printJson(context, { results }); + else + for (const result of results) { + context.log( + result.ok + ? `ok ${result.id} Herdr ${result.version ?? "(unknown version)"}, protocol ${result.protocol}` + : `fail ${result.id} ${result.error}`, + ); + } + return results.every((result) => result.ok) ? EXIT_OK : EXIT_FAILURE; + } + + if (action === "add" || action === "remove" || action === "set-default") { + const { values, positionals } = + action === "add" + ? parseCommandArgs(rest, ADD_OPTIONS, 1) + : parseCommandArgs(rest, { restart: { type: "boolean" } }, 1); + const id = positionals[0]; + if (!id) + throw new UsageError(`connections ${action} needs a connection id`); + const flagValues = values as Record< + string, + string | boolean | undefined + >; + + let next: PersistedConnectionRegistry | null; + if (action === "add") { + let profile: ConnectionProfile; + try { + profile = profileFromFlags(id, flagValues); + } catch (error) { + if (error instanceof UsageError) throw error; + throw new UsageError((error as Error).message, { cause: error }); + } + if (profiles.some((entry) => entry.id === id)) + throw new UsageError(`connection already exists: ${id}`); + if (registry) { + next = { + ...registry, + version: CONNECTION_PROFILE_FILE_VERSION, + profiles: [...registry.profiles, profile], + }; + } else if (explicitOverride(setup)) { + next = { + version: CONNECTION_PROFILE_FILE_VERSION, + default_connection_id: profile.id, + profiles: [profile], + }; + } else { + // Like the web UI, keep the implicit local server as a saved profile. + const implicit = implicitDefault(context, setup); + const seed: ConnectionProfile = { + id: id === "local" ? "localhost" : "local", + label: "Local", + type: "local", + control_socket_path: implicit.control_socket_path, + client_socket_path: implicit.client_socket_path, + auto_connect: true, + }; + next = { + version: CONNECTION_PROFILE_FILE_VERSION, + default_connection_id: seed.id, + profiles: [seed, profile], + }; + } + if (flagValues.default === true) { + if (explicitOverride(setup)) + throw new UsageError( + "explicit Herdr settings in the service config remain the default connection", + ); + next.default_connection_id = profile.id; + } + } else { + if ( + !registry || + id === LEGACY_DEFAULT_CONNECTION_ID || + !registry.profiles.some((entry) => entry.id === id) + ) + throw new UsageError(`no saved connection: ${id}`); + if (action === "set-default") { + if (explicitOverride(setup)) + throw new UsageError( + "explicit Herdr settings in the service config remain the default connection", + ); + next = { + ...registry, + version: CONNECTION_PROFILE_FILE_VERSION, + default_connection_id: id, + }; + } else { + if ( + registry.default_connection_id === id && + !explicitOverride(setup) + ) + throw new UsageError( + "cannot remove the default connection; choose another default first", + ); + const remaining = registry.profiles.filter( + (entry) => entry.id !== id, + ); + // Without explicit Herdr settings the last profile is always the + // default, so only an explicit override can clear the registry. + next = remaining.length + ? { + ...registry, + version: CONNECTION_PROFILE_FILE_VERSION, + profiles: remaining, + default_connection_id: + registry.default_connection_id === id + ? remaining[0].id + : registry.default_connection_id, + } + : null; + } + } + + const decision = await guardRunningServer( + context, + setup, + flagValues.restart === true, + probe, + ); + if (typeof decision === "number") return decision; + if (next) await store.save(next); + else await store.clear(); + context.log( + action === "add" + ? `Added connection ${id}.` + : action === "remove" + ? `Removed connection ${id}.` + : `Default connection is now ${id}.`, + ); + if (decision === "write-and-restart") { + const code = restartRunningService(context); + if (code === 0) context.log("Restarted the service."); + return code ?? EXIT_OK; + } + return EXIT_OK; + } + + throw new UsageError(`unknown connections action: ${action}`); + }, + }; +} diff --git a/server/src/cli/doctor.test.ts b/server/src/cli/doctor.test.ts new file mode 100644 index 00000000..15b408a7 --- /dev/null +++ b/server/src/cli/doctor.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EXIT_FAILURE, EXIT_OK } from "./command"; +import { createDoctorCommand, type DoctorDependencies } from "./doctor"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +const healthy: DoctorDependencies = { + probe: async () => true, + portInUse: () => true, + testSockets: async () => ({ ok: true, version: "0.9.4", protocol: 7 }), + herdrService: () => ({ installed: false, active: false }), + latestVersion: async () => "1.2.3", + lanIPs: () => ["192.168.1.20"], +}; + +async function doctor(dependencies: DoctorDependencies, args = ["--json"]) { + const code = await createDoctorCommand(dependencies).run(args, cli.context); + const result = JSON.parse(cli.output.join("\n")); + const check = (name: string) => + result.checks.find((entry: { name: string }) => entry.name === name); + return { code, result, check }; +} + +describe("roamgate doctor", () => { + test("passes a healthy foreground installation and redacts the token", async () => { + cli = createTestCli(); + cli.writeConfig("HOST=0.0.0.0\nPORT=8787\n"); + writeFileSync( + join(cli.home, ".config", "roamgate", "auth-token"), + "c".repeat(64), + ); + const { code, result, check } = await doctor(healthy); + expect(code).toBe(EXIT_OK); + expect(result.ok).toBe(true); + expect(check("Herdr").detail).toContain("Herdr 0.9.4, protocol 7"); + expect(check("Network").detail).toContain("http://192.168.1.20:8787"); + expect(check("Service").status).toBe("warn"); + expect(check("Updates").status).toBe("ok"); + expect(JSON.stringify(result)).not.toContain("c".repeat(64)); + }); + + test("fails on an invalid service config with a fix", async () => { + cli = createTestCli(); + cli.writeConfig("PORT=0\n"); + const { code, check } = await doctor(healthy); + expect(code).toBe(EXIT_FAILURE); + expect(check("Service config")).toMatchObject({ status: "fail" }); + expect(check("Service config").hint).toContain("roamgate config edit"); + }); + + test("explains a port held by another process", async () => { + cli = createTestCli(); + cli.writeConfig(""); + const { check } = await doctor({ ...healthy, probe: async () => false }); + expect(check("Listener")).toMatchObject({ status: "fail" }); + expect(check("Listener").detail).toContain("port 8787 is held"); + }); + + test("reports an unreachable Herdr with the setup command", async () => { + cli = createTestCli(); + cli.writeConfig(""); + const { code, check } = await doctor({ + ...healthy, + testSockets: async () => { + throw new Error("ENOENT"); + }, + }); + expect(code).toBe(EXIT_FAILURE); + expect(check("Herdr").detail).toContain( + join(cli.home, ".config", "herdr", "herdr.sock"), + ); + expect(check("Herdr").hint).toContain("roamgate herdr setup"); + }); + + test("skips SSH Herdr, disabled push and offline update checks", async () => { + cli = createTestCli(); + cli.writeConfig("HERDR_SSH_HOST=me@box\nROAMGATE_WEB_PUSH_SUBJECT=\n"); + const { check } = await doctor(healthy, ["--json", "--offline"]); + expect(check("Herdr").status).toBe("skip"); + expect(check("Web Push").status).toBe("skip"); + expect(check("Updates").status).toBe("skip"); + }); + + test("warns about available updates", async () => { + cli = createTestCli(); + cli.writeConfig(""); + const { check } = await doctor({ + ...healthy, + latestVersion: async () => "1.3.0", + }); + expect(check("Updates")).toMatchObject({ + status: "warn", + hint: "Run `roamgate update`.", + }); + }); + + test.skipIf(!Bun.which("openssl"))( + "warns about a certificate that expires soon", + async () => { + cli = createTestCli(); + const cert = join(cli.home, "cert.pem"); + const key = join(cli.home, "key.pem"); + const generated = Bun.spawnSync( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "3", + "-subj", + "/CN=roamgate.test", + "-keyout", + key, + "-out", + cert, + ], + { stdout: "ignore", stderr: "ignore" }, + ); + expect(generated.exitCode).toBe(0); + cli.writeConfig( + `HOST=0.0.0.0\nROAMGATE_TLS_CERT=${cert}\nROAMGATE_TLS_KEY=${key}\n`, + ); + const { check } = await doctor(healthy); + expect(check("TLS certificate").status).toBe("warn"); + expect(check("TLS certificate").detail).toContain("CN=roamgate.test"); + expect(check("Network").detail).toContain("HTTPS"); + }, + ); + + test("flags an invalid connection registry", async () => { + cli = createTestCli(); + cli.writeConfig(""); + mkdirSync(join(cli.home, ".config", "roamgate"), { recursive: true }); + writeFileSync( + join(cli.home, ".config", "roamgate", "connections.json"), + "{", + { + mode: 0o600, + }, + ); + const { check } = await doctor(healthy); + expect(check("Connections")).toMatchObject({ status: "fail" }); + }); + + test("prints a readable summary", async () => { + cli = createTestCli(); + cli.writeConfig(""); + const code = await createDoctorCommand(healthy).run([], cli.context); + expect(code).toBe(EXIT_OK); + // Not installed as a service, and no login token has been created yet. + expect(cli.output.at(-1)).toMatch(/^\d+ ok, 2 warnings, 0 failures$/); + expect(cli.output).toContain( + " Run `roamgate service install` to start Roamgate at login.", + ); + }); + + test("fails on a corrupt token with the rotate command", async () => { + cli = createTestCli(); + cli.writeConfig(""); + writeFileSync(join(cli.home, ".config", "roamgate", "auth-token"), "x\n"); + const { code, check } = await doctor(healthy); + expect(code).toBe(EXIT_FAILURE); + expect(check("Login").hint).toContain("roamgate auth rotate"); + }); + + test("a loaded but crashed LaunchAgent is not reported as running", async () => { + cli = createTestCli({ + runtime: { platform: "darwin" }, + captureCommand: () => ({ + code: 0, + stdout: + "gui/501/dev.roamgate = {\n\tstate = waiting\n\tendpoints = {\n\t\tstate = active\n\t}\n}\n", + }), + }); + cli.writeConfig(""); + const agents = join(cli.home, "Library", "LaunchAgents"); + mkdirSync(agents, { recursive: true }); + writeFileSync(join(agents, "dev.roamgate.plist"), ""); + const { check } = await doctor({ + ...healthy, + probe: async () => false, + portInUse: () => false, + }); + expect(check("Service")).toMatchObject({ + status: "fail", + detail: "installed but not running", + }); + }); +}); diff --git a/server/src/cli/doctor.ts b/server/src/cli/doctor.ts new file mode 100644 index 00000000..e39cd898 --- /dev/null +++ b/server/src/cli/doctor.ts @@ -0,0 +1,615 @@ +import { X509Certificate } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { roamgateEnv } from "../config/environment"; +import { getLanIPs, isAnyHost } from "../config/server-config"; +import { resolveLegacyServicePaths } from "../config/service-definitions"; +import { testConnectionSockets } from "../connections/profile-service"; +import { ConnectionProfileStore } from "../connections/profiles"; +import { herdrServiceStatus } from "../herdr/service"; +import { + compareVersion, + fetchLatestUpdateManifest, + normalizeUpdateBaseUrl, + resolveUpdateTarget, +} from "../http/update"; +import { + assertValidPushSubject, + readPushRegistry, + webPushPath, + webPushSubject, +} from "../notifications/web-push"; +import { runProcessWithCodeTimeout } from "../utils/process-utils"; +import { + type CliCommand, + type CliContext, + displayPath, + EXIT_FAILURE, + EXIT_OK, + isStandaloneBinary, + parseCommandArgs, + printJson, + wantsHelp, +} from "./command"; +import { + accessUrls, + type ListenerProbe, + probeListener, + probeUrl, + readServiceAccess, + readServiceSetup, + type ServiceAccessInfo, + type ServiceSetup, + serviceHerdrSockets, + serviceStatus, + connectionRegistryPath, + runtimeDataRoot, +} from "./service-context"; + +export type CheckStatus = "ok" | "warn" | "fail" | "skip"; + +export interface Check { + name: string; + status: CheckStatus; + detail: string; + hint?: string; +} + +export interface DoctorDependencies { + probe?: ListenerProbe; + portInUse?: (host: string, port: number) => boolean; + testSockets?: typeof testConnectionSockets; + herdrService?: () => { installed: boolean; active: boolean }; + latestVersion?: ( + context: CliContext, + env: Record, + ) => Promise; + lanIPs?: () => string[]; + now?: () => Date; +} + +const CERT_WARNING_DAYS = 14; + +function doctorHelp(): string { + return `Check the Roamgate installation and report problems with fixes. + +Usage: + roamgate doctor [--offline] [--json] + +Options: + --offline skip the release update check + --json print machine-readable results + +Checks the service configuration, credentials, TLS certificate, user service, +listener, network exposure, local Herdr server, saved connections, Web Push +registry and available updates. Exits 1 when any check fails. Include the +output (it contains no secrets) when reporting an issue. +`; +} + +/** Bind and release the address to learn whether another process holds it. */ +export function defaultPortInUse(host: string, port: number): boolean { + try { + const listener = Bun.listen({ + hostname: host, + port, + exclusive: true, + socket: { data() {} }, + }); + listener.stop(true); + return false; + } catch (cause) { + const code = (cause as NodeJS.ErrnoException | null)?.code; + return ( + code === "EADDRINUSE" || + /port .* in use|address already in use/i.test( + String((cause as Error)?.message), + ) + ); + } +} + +async function defaultLatestVersion( + context: CliContext, + env: Record, +): Promise { + const target = resolveUpdateTarget( + context.runtime.platform, + context.runtime.arch, + ); + if (!target) return null; + const manifest = await fetchLatestUpdateManifest({ + baseUrl: normalizeUpdateBaseUrl( + roamgateEnv("UPDATE_BASE_URL", context.runtime.env) ?? + roamgateEnv("UPDATE_BASE_URL", env), + ), + target, + runProcessWithCodeTimeout, + }); + return manifest.version; +} + +function certificateCheck(path: string, now: Date): Check { + try { + const certificate = new X509Certificate(readFileSync(path)); + const expires = new Date(certificate.validTo); + const days = Math.floor((expires.getTime() - now.getTime()) / 86_400_000); + const subject = certificate.subject.replace(/\n/g, ", "); + if (days < 0) + return { + name: "TLS certificate", + status: "fail", + detail: `expired on ${expires.toISOString().slice(0, 10)} (${subject})`, + hint: "Renew the certificate, then run `roamgate service restart`.", + }; + return { + name: "TLS certificate", + status: days < CERT_WARNING_DAYS ? "warn" : "ok", + detail: `valid until ${expires.toISOString().slice(0, 10)}, ${days} days left (${subject})`, + hint: + days < CERT_WARNING_DAYS ? "Renew the certificate soon." : undefined, + }; + } catch (error) { + return { + name: "TLS certificate", + status: "fail", + detail: `cannot read ${path}: ${(error as Error).message}`, + }; + } +} + +function serviceChecks(context: CliContext, setup: ServiceSetup): Check[] { + const checks: Check[] = []; + const status = serviceStatus(context); + checks.push( + !status.installed + ? { + name: "Service", + status: "warn", + detail: "not installed", + hint: "Run `roamgate service install` to start Roamgate at login.", + } + : status.active + ? { + name: "Service", + status: "ok", + detail: `installed and running (${displayPath(status.definition, context.runtime)})`, + } + : { + name: "Service", + status: "fail", + detail: "installed but not running", + hint: "Run `roamgate service logs` to see why, then `roamgate service restart`.", + }, + ); + const legacy = resolveLegacyServicePaths( + setup.platform, + context.runtime.homeDir, + context.runtime.appDataDir, + ); + if (existsSync(legacy.definition)) { + checks.push({ + name: "Legacy service", + status: "warn", + detail: `herdr-gui service definition found: ${displayPath(legacy.definition, context.runtime)}`, + hint: "See docs/DEPLOYMENT.md#transition-from-herdr-studio--herdr-gui.", + }); + } + return checks; +} + +async function listenerCheck( + context: CliContext, + access: ServiceAccessInfo, + serviceActive: boolean, + dependencies: Required>, +): Promise { + const url = probeUrl(access).replace(/\/healthz$/, ""); + if (await dependencies.probe(access)) + return { + name: "Listener", + status: "ok", + detail: `Roamgate answers at ${url}`, + }; + if (dependencies.portInUse(access.host, access.port)) + return { + name: "Listener", + status: "fail", + detail: `port ${access.port} is held by a process that is not answering as Roamgate`, + hint: "Stop the other process, or choose another port with `roamgate config set PORT= --restart`.", + }; + return serviceActive + ? { + name: "Listener", + status: "fail", + detail: `the service runs but nothing answers at ${url}`, + hint: "Run `roamgate service logs` to see why.", + } + : { + name: "Listener", + status: "warn", + detail: `nothing answers at ${url}`, + hint: "Start it with `roamgate service install`, or run `roamgate`.", + }; +} + +function networkCheck( + access: ServiceAccessInfo, + lanIPs: () => string[], +): Check { + const loopback = ["127.0.0.1", "localhost", "::1"].includes(access.host); + if (loopback) + return { + name: "Network", + status: "ok", + detail: `only this machine can connect (HOST=${access.host})`, + }; + const origins = accessUrls( + { ...access, token: undefined, auth: "password" }, + lanIPs, + ); + const reachable = isAnyHost(access.host) ? origins.lan : [origins.local]; + const transport = access.tls + ? "HTTPS" + : "plain HTTP; use a trusted private network or VPN"; + const parts: string[] = [ + `listening on ${access.host}:${access.port}, ${transport}`, + ...(reachable.length ? [`reachable at ${reachable.join(", ")}`] : []), + ]; + if (access.pinEnabled) + return { + name: "Network", + status: "warn", + detail: `${parts.join("; ")}; PIN login is enabled`, + hint: "PIN login is meant for trusted private networks only.", + }; + return { name: "Network", status: "ok", detail: parts.join("; ") }; +} + +async function herdrCheck( + context: CliContext, + setup: ServiceSetup, + dependencies: Required< + Pick + >, +): Promise { + if (setup.env.HERDR_SSH_HOST) + return { + name: "Herdr", + status: "skip", + detail: `remote Herdr over SSH (${setup.env.HERDR_SSH_HOST})`, + hint: "Run `roamgate connections test` to check the SSH connection.", + }; + const sockets = serviceHerdrSockets(context.runtime, setup.env); + let managed = ""; + try { + const service = dependencies.herdrService(); + managed = service.installed + ? `; managed Herdr service ${service.active ? "running" : "not running"}` + : ""; + } catch { + // Service state is supplementary. + } + try { + const result = await dependencies.testSockets( + sockets.socketPath, + sockets.clientSocketPath, + ); + return { + name: "Herdr", + status: "ok", + detail: `Herdr ${result.version ?? "(unknown version)"}, protocol ${result.protocol}${managed}`, + }; + } catch (error) { + const explicit = + setup.env.HERDR_SOCKET_PATH || setup.env.HERDR_CLIENT_SOCKET_PATH; + return { + name: "Herdr", + status: "fail", + detail: `cannot reach ${sockets.socketPath}: ${(error as Error).message}${managed}`, + hint: explicit + ? "Check HERDR_SOCKET_PATH and HERDR_CLIENT_SOCKET_PATH with `roamgate config show`." + : "Run `roamgate herdr setup` to install and start Herdr.", + }; + } +} + +function connectionsCheck(context: CliContext, setup: ServiceSetup): Check { + try { + const store = new ConnectionProfileStore({ + path: connectionRegistryPath(context.runtime, setup.env), + }); + const registry = store.load(); + return { + name: "Connections", + status: "ok", + detail: registry + ? `${registry.profiles.length} saved profile${registry.profiles.length === 1 ? "" : "s"}, default ${registry.default_connection_id}` + : "default local connection only", + }; + } catch (error) { + return { + name: "Connections", + status: "fail", + detail: `invalid registry: ${(error as Error).message}`, + hint: "Roamgate preserves the file and disables profile edits until it is fixed.", + }; + } +} + +function pushCheck(context: CliContext, setup: ServiceSetup): Check { + const subject = webPushSubject(setup.env); + if (!subject) + return { + name: "Web Push", + status: "skip", + detail: "disabled (ROAMGATE_WEB_PUSH_SUBJECT is empty)", + }; + try { + assertValidPushSubject(subject); + } catch { + return { + name: "Web Push", + status: "fail", + detail: `invalid ROAMGATE_WEB_PUSH_SUBJECT: ${subject}`, + hint: "Use a mailto: address or an HTTPS URL.", + }; + } + const path = webPushPath(setup.env, runtimeDataRoot(context.runtime)); + if (!existsSync(path)) + return { + name: "Web Push", + status: "ok", + detail: "no devices enrolled yet", + }; + try { + const devices = readPushRegistry(path).devices.length; + return { + name: "Web Push", + status: "ok", + detail: `${devices} device${devices === 1 ? "" : "s"} enrolled`, + hint: devices ? "Run `roamgate push test` to check delivery." : undefined, + }; + } catch (error) { + return { + name: "Web Push", + status: "fail", + detail: `unusable registry ${path}: ${(error as Error).message}`, + hint: "Roamgate preserves the file and disables push until it is restored.", + }; + } +} + +async function updateCheck( + context: CliContext, + setup: ServiceSetup | null, + offline: boolean, + latestVersion: NonNullable, +): Promise { + if (offline) + return { name: "Updates", status: "skip", detail: "skipped (--offline)" }; + const env = setup?.env ?? {}; + if ( + roamgateEnv("DISABLE_UPDATE_CHECK", context.runtime.env) === "1" || + roamgateEnv("DISABLE_UPDATE_CHECK", env) === "1" + ) + return { + name: "Updates", + status: "skip", + detail: "disabled by ROAMGATE_DISABLE_UPDATE_CHECK", + }; + try { + const latest = await latestVersion(context, env); + if (latest === null) + return { + name: "Updates", + status: "skip", + detail: "no update package for this platform", + }; + return compareVersion(latest, context.version) > 0 + ? { + name: "Updates", + status: "warn", + detail: `roamgate ${latest} is available`, + hint: "Run `roamgate update`.", + } + : { name: "Updates", status: "ok", detail: "up to date" }; + } catch (error) { + return { + name: "Updates", + status: "warn", + detail: `could not check: ${(error as Error).message.split("\n")[0]}`, + }; + } +} + +export async function runDoctorChecks( + context: CliContext, + options: { offline: boolean }, + dependencies: DoctorDependencies = {}, +): Promise { + const probe = dependencies.probe ?? probeListener; + const portInUse = dependencies.portInUse ?? defaultPortInUse; + const testSockets = dependencies.testSockets ?? testConnectionSockets; + const herdrService = + dependencies.herdrService ?? + (() => + herdrServiceStatus({ + homeDir: runtime.homeDir, + appDataDir: runtime.appDataDir, + uid: runtime.uid, + runCommand: context.runCommand, + })); + const latestVersion = dependencies.latestVersion ?? defaultLatestVersion; + const lanIPs = dependencies.lanIPs ?? getLanIPs; + const now = dependencies.now ?? (() => new Date()); + const { runtime } = context; + + const checks: Check[] = [ + { + name: "Roamgate", + status: "ok", + detail: `${context.version} on ${runtime.platform}-${runtime.arch}, ${ + isStandaloneBinary(runtime) + ? displayPath(runtime.execPath, runtime) + : "source checkout" + }`, + }, + ]; + + let setup: ServiceSetup | null = null; + try { + setup = readServiceSetup(runtime); + } catch (error) { + checks.push({ + name: "Service config", + status: "fail", + detail: (error as Error).message, + }); + } + const update = updateCheck(context, setup, options.offline, latestVersion); + if (!setup) { + checks.push(await update); + return checks; + } + + let access: ServiceAccessInfo | null = null; + try { + access = readServiceAccess(setup); + checks.push( + setup.configExists + ? { + name: "Service config", + status: "ok", + detail: displayPath(setup.configPath, runtime), + } + : { + name: "Service config", + status: "warn", + detail: `${displayPath(setup.configPath, runtime)} does not exist; defaults apply`, + hint: "`roamgate service install` creates it.", + }, + ); + } catch (error) { + checks.push({ + name: "Service config", + status: "fail", + detail: `${displayPath(setup.configPath, runtime)}: ${(error as Error).message}`, + hint: "The service refuses to start; fix it with `roamgate config edit`.", + }); + } + + if (access) { + checks.push( + access.auth === "password" + ? { + name: "Login", + status: "ok", + detail: "fixed password from the service config", + } + : access.tokenError + ? { + name: "Login", + status: "fail", + detail: `invalid token file: ${access.tokenError}`, + hint: "The service refuses to start; run `roamgate auth rotate`.", + } + : access.token + ? { + name: "Login", + status: "ok", + detail: `generated token in ${displayPath(access.tokenPath, runtime)}`, + } + : { + name: "Login", + status: "warn", + detail: "no login token yet", + hint: "It is created by `roamgate service install` or the first start.", + }, + ); + const certificate = roamgateEnv("TLS_CERT", setup.env); + if (access.tls && certificate) + checks.push(certificateCheck(certificate, now())); + } + + let serviceActive = false; + try { + const service = serviceChecks(context, setup); + serviceActive = service[0]?.status === "ok"; + checks.push(...service); + } catch (error) { + checks.push({ + name: "Service", + status: "fail", + detail: (error as Error).message, + }); + } + + const [listener, herdr] = await Promise.all([ + access + ? listenerCheck(context, access, serviceActive, { probe, portInUse }) + : null, + herdrCheck(context, setup, { testSockets, herdrService }), + ]); + if (listener) checks.push(listener); + if (access) checks.push(networkCheck(access, lanIPs)); + checks.push( + herdr, + connectionsCheck(context, setup), + pushCheck(context, setup), + await update, + ); + return checks; +} + +const STATUS_LABELS: Record = { + ok: "ok ", + warn: "warn", + fail: "FAIL", + skip: "skip", +}; + +export function createDoctorCommand( + dependencies: DoctorDependencies = {}, +): CliCommand { + return { + name: "doctor", + summary: "diagnose the installation and suggest fixes", + flags: ["--offline", "--json"], + help: doctorHelp, + async run(args, context) { + if (wantsHelp(args)) { + context.log(doctorHelp()); + return EXIT_OK; + } + const { values } = parseCommandArgs(args, { + offline: { type: "boolean" }, + json: { type: "boolean" }, + }); + const checks = await runDoctorChecks( + context, + { offline: values.offline === true }, + dependencies, + ); + const failed = checks.some((check) => check.status === "fail"); + if (values.json) { + printJson(context, { version: context.version, ok: !failed, checks }); + return failed ? EXIT_FAILURE : EXIT_OK; + } + const width = Math.max(...checks.map((check) => check.name.length)); + for (const check of checks) { + context.log( + `${STATUS_LABELS[check.status]} ${check.name.padEnd(width)} ${check.detail}`, + ); + if (check.hint && check.status !== "ok") + context.log(`${" ".repeat(width + 8)}${check.hint}`); + } + const count = (status: CheckStatus) => + checks.filter((check) => check.status === status).length; + context.log(""); + context.log( + `${count("ok")} ok, ${count("warn")} warning${count("warn") === 1 ? "" : "s"}, ${count("fail")} failure${count("fail") === 1 ? "" : "s"}`, + ); + return failed ? EXIT_FAILURE : EXIT_OK; + }, + }; +} diff --git a/server/src/cli/index.test.ts b/server/src/cli/index.test.ts new file mode 100644 index 00000000..515618b9 --- /dev/null +++ b/server/src/cli/index.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + type CliCommand, + CONTINUE_TO_SERVER, + EXIT_FAILURE, + EXIT_OK, + EXIT_USAGE, + UsageError, +} from "./command"; +import { cliCommands, runCli, topLevelHelp } from "./index"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +function fakeCommand(run: CliCommand["run"]): CliCommand { + return { + name: "fake", + summary: "a fake command", + help: () => "fake help", + run, + }; +} + +describe("runCli", () => { + test("leaves server invocations to the listener", async () => { + cli = createTestCli(); + expect(await runCli([], cli.context)).toBeNull(); + expect(await runCli(["--port", "9000"], cli.context)).toBeNull(); + expect(await runCli(["--help"], cli.context)).toBeNull(); + }); + + test("rejects unknown commands with a usage exit code", async () => { + cli = createTestCli(); + expect(await runCli(["sevrice"], cli.context)).toBe(EXIT_USAGE); + expect(cli.errors[0]).toBe("roamgate: unknown command: sevrice"); + }); + + test("top-level help lists every command, including herdr", async () => { + cli = createTestCli(); + expect(await runCli(["help"], cli.context)).toBe(EXIT_OK); + const help = cli.output.join("\n"); + for (const command of cliCommands()) { + expect(help).toContain(` ${command.name} `); + } + expect(help).toContain("--socket-path "); + expect(topLevelHelp(cliCommands())).toBe(help); + }); + + test("help prints that command's help", async () => { + cli = createTestCli(); + expect(await runCli(["help", "doctor"], cli.context)).toBe(EXIT_OK); + expect(cli.output.join("\n")).toContain("roamgate doctor [--offline]"); + expect(await runCli(["help", "nope"], cli.context)).toBe(EXIT_USAGE); + }); + + test("maps usage errors and failures to distinct exit codes", async () => { + cli = createTestCli(); + const usage = fakeCommand(async () => { + throw new UsageError("bad flag"); + }); + expect(await runCli(["fake"], cli.context, [usage])).toBe(EXIT_USAGE); + expect(cli.errors).toEqual([ + "roamgate fake: bad flag", + "Run `roamgate fake --help` for usage.", + ]); + const failing = fakeCommand(async () => { + throw new Error("disk full"); + }); + expect(await runCli(["fake"], cli.context, [failing])).toBe(EXIT_FAILURE); + expect(cli.errors.at(-1)).toBe("roamgate fake: disk full"); + }); + + test("passes the remaining arguments and continuation through", async () => { + cli = createTestCli(); + let received: string[] = []; + const command = fakeCommand(async (args) => { + received = args; + return CONTINUE_TO_SERVER; + }); + expect(await runCli(["fake", "a", "--b"], cli.context, [command])).toBe( + CONTINUE_TO_SERVER, + ); + expect(received).toEqual(["a", "--b"]); + }); + + test("service logs is routed to the log viewer", async () => { + cli = createTestCli({ runCommand: () => 0 }); + expect(await runCli(["service", "logs", "-n", "5"], cli.context)).toBe(0); + expect(cli.commands).toEqual([ + [ + "journalctl", + "--user", + "--unit", + "roamgate.service", + "--no-pager", + "--lines", + "5", + ], + ]); + }); + + test("unknown flags of new commands are usage errors", async () => { + cli = createTestCli(); + expect(await runCli(["url", "--nope"], cli.context)).toBe(EXIT_USAGE); + expect(cli.errors[0]).toContain("roamgate url:"); + }); +}); diff --git a/server/src/cli/index.ts b/server/src/cli/index.ts new file mode 100644 index 00000000..2f36b4a3 --- /dev/null +++ b/server/src/cli/index.ts @@ -0,0 +1,172 @@ +import { runPluginCommand } from "../config/plugin-command"; +import { serverOptionsHelp } from "../config/server-config"; +import { + runServiceCommand, + SERVICE_COMMAND_CONTINUE, +} from "../config/service-manager"; +import { runHerdrCommand } from "../herdr/cli"; +import { authCommand } from "./auth"; +import { + type CliCommand, + type CliContext, + type CommandResult, + CONTINUE_TO_SERVER, + EXIT_FAILURE, + EXIT_OK, + EXIT_USAGE, + UsageError, + wantsHelp, +} from "./command"; +import { createCompletionCommand } from "./completion"; +import { configCommand } from "./config"; +import { createConnectionsCommand } from "./connections"; +import { createDoctorCommand } from "./doctor"; +import { createPushCommand } from "./push"; +import { serviceRuntime } from "./service-context"; +import { runServiceLogs } from "./service-logs"; +import { createUpdateCommand, rollbackCommand } from "./update"; +import { urlCommand } from "./url"; + +// The existing command families keep their own parsers, help and exit codes; +// the router only adds dispatch, discovery and completion metadata. +const serviceCommand: CliCommand = { + name: "service", + summary: "install and manage the Roamgate user service", + actions: ["install", "status", "restart", "reload", "uninstall", "logs"], + flags: ["--force", "--follow", "--lines"], + help: () => "Run `roamgate service --help`.", + async run(args, context) { + if (args[0] === "logs" && !wantsHelp(args.slice(1))) + return runServiceLogs(args.slice(1), context); + const result = runServiceCommand(["service", ...args], { + runtime: serviceRuntime(context.runtime), + runCommand: context.runCommand, + log: context.log, + error: context.error, + }); + if (result === SERVICE_COMMAND_CONTINUE) return CONTINUE_TO_SERVER; + return result ?? EXIT_FAILURE; + }, +}; + +const herdrCommand: CliCommand = { + name: "herdr", + summary: "install, start and check the local Herdr server", + actions: ["setup", "status"], + help: () => "Run `roamgate herdr --help`.", + async run(args, context) { + return ( + (await runHerdrCommand(["herdr", ...args], context.version, { + log: context.log, + error: context.error, + })) ?? EXIT_FAILURE + ); + }, +}; + +const pluginCommand: CliCommand = { + name: "plugin", + summary: "actions and panel used by the Herdr plugin", + actions: [ + "start", + "restart", + "status", + "url", + "version", + "uninstall", + "panel", + ], + help: () => "Run `roamgate plugin --help`.", + async run(args, context) { + return ( + (await runPluginCommand( + ["plugin", ...args], + context.version, + context.runtime.execPath, + )) ?? EXIT_FAILURE + ); + }, +}; + +const DELEGATED_HELP = new Set(["service", "herdr", "plugin"]); + +export function cliCommands(): CliCommand[] { + const commands: CliCommand[] = [ + serviceCommand, + herdrCommand, + createDoctorCommand(), + urlCommand, + configCommand, + authCommand, + createConnectionsCommand(), + createPushCommand(), + createUpdateCommand(), + rollbackCommand, + pluginCommand, + ]; + commands.push(createCompletionCommand(() => commands)); + return commands; +} + +export function topLevelHelp(commands: readonly CliCommand[]): string { + const width = Math.max(...commands.map((command) => command.name.length)); + const lines = commands.map( + (command) => ` ${command.name.padEnd(width)} ${command.summary}`, + ); + return `Roamgate — Web and PWA client for Herdr + +Usage: roamgate [options] run the server + roamgate [arguments] manage an installation + +Commands: +${lines.join("\n")} + +Run \`roamgate --help\` or \`roamgate help \` for details. + +${serverOptionsHelp()}`; +} + +/** + * Dispatch a management command. Returns null when argv belongs to the server + * (no arguments or only flags), so the caller starts the listener. + */ +export async function runCli( + argv: string[], + context: CliContext, + commands: readonly CliCommand[] = cliCommands(), +): Promise { + const first = argv[0]; + if (first === undefined || first.startsWith("-")) return null; + if (first === "help") { + const topic = argv[1]; + if (topic === undefined) { + context.log(topLevelHelp(commands)); + return EXIT_OK; + } + const command = commands.find((entry) => entry.name === topic); + if (!command) { + context.error(`roamgate: unknown command: ${topic}`); + return EXIT_USAGE; + } + if (DELEGATED_HELP.has(command.name)) + return command.run(["--help"], context); + context.log(command.help()); + return EXIT_OK; + } + const command = commands.find((entry) => entry.name === first); + if (!command) { + context.error(`roamgate: unknown command: ${first}`); + context.error("Run `roamgate --help` for the list of commands."); + return EXIT_USAGE; + } + try { + return await command.run(argv.slice(1), context); + } catch (error) { + context.error(`roamgate ${command.name}: ${(error as Error).message}`); + if (error instanceof UsageError) { + context.error(`Run \`roamgate ${command.name} --help\` for usage.`); + return EXIT_USAGE; + } + return EXIT_FAILURE; + } +} diff --git a/server/src/cli/push.test.ts b/server/src/cli/push.test.ts new file mode 100644 index 00000000..c291e88a --- /dev/null +++ b/server/src/cli/push.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createECDH, randomBytes } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import webpush from "web-push"; +import { EXIT_FAILURE, EXIT_OK } from "./command"; +import { createPushCommand } from "./push"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +function device(endpoint: string) { + const ecdh = createECDH("prime256v1"); + ecdh.generateKeys(); + return { + subscription: { + endpoint, + keys: { + p256dh: ecdh.getPublicKey().toString("base64url"), + auth: randomBytes(16).toString("base64url"), + }, + }, + preferences: { completed: true, blocked: false }, + }; +} + +function writeRegistry(testCli: TestCli, devices: unknown[]) { + const path = join(testCli.home, ".config", "roamgate", "web-push.json"); + writeFileSync( + path, + JSON.stringify({ version: 1, ...webpush.generateVAPIDKeys(), devices }), + { mode: 0o600 }, + ); + return path; +} + +describe("roamgate push test", () => { + test("sends one test message per device and reports expired ones", async () => { + cli = createTestCli(); + cli.writeConfig(""); + const path = writeRegistry(cli, [ + device("https://fcm.googleapis.com/fcm/send/one"), + device("https://web.push.apple.com/two"), + ]); + const before = readFileSync(path, "utf8"); + const payloads: unknown[] = []; + const command = createPushCommand({ + send: async (subscription, payload) => { + payloads.push(JSON.parse(String(payload))); + if (subscription.endpoint.includes("apple")) + throw Object.assign(new Error("gone"), { statusCode: 410 }); + return { statusCode: 201, body: "", headers: {} }; + }, + }); + expect(await command.run(["test"], cli.context)).toBe(EXIT_FAILURE); + expect(cli.output).toEqual([ + "ok device 1 (fcm.googleapis.com)", + "fail device 2 (web.push.apple.com): subscription expired; re-enable notifications on that device", + ]); + expect(payloads[0]).toEqual({ + title: "Roamgate test notification", + body: "Web Push delivery to this device works.", + tag: "roamgate-test", + target: null, + }); + // The server owns the registry; the CLI never rewrites it. + expect(readFileSync(path, "utf8")).toBe(before); + }); + + test("explains disabled push and empty registries", async () => { + cli = createTestCli(); + cli.writeConfig("ROAMGATE_WEB_PUSH_SUBJECT=\n"); + const send = async () => ({ statusCode: 201, body: "", headers: {} }); + expect(await createPushCommand({ send }).run(["test"], cli.context)).toBe( + EXIT_FAILURE, + ); + expect(cli.errors[0]).toContain("disabled"); + cli.writeConfig(""); + expect(await createPushCommand({ send }).run(["test"], cli.context)).toBe( + EXIT_FAILURE, + ); + expect(cli.errors[1]).toContain("no push registry"); + writeRegistry(cli, []); + expect(await createPushCommand({ send }).run(["test"], cli.context)).toBe( + EXIT_FAILURE, + ); + expect(cli.errors[2]).toContain("no devices are enrolled"); + }); + + test("succeeds when every provider accepts", async () => { + cli = createTestCli(); + cli.writeConfig(""); + writeRegistry(cli, [device("https://fcm.googleapis.com/fcm/send/one")]); + const command = createPushCommand({ + send: async () => ({ statusCode: 201, body: "", headers: {} }), + }); + expect(await command.run(["test"], cli.context)).toBe(EXIT_OK); + }); +}); diff --git a/server/src/cli/push.ts b/server/src/cli/push.ts new file mode 100644 index 00000000..57d40b5b --- /dev/null +++ b/server/src/cli/push.ts @@ -0,0 +1,132 @@ +import { existsSync } from "node:fs"; +import { + readPushRegistry, + sendPushRequest, + webPushPath, + webPushSubject, +} from "../notifications/web-push"; +import { + type CliCommand, + displayPath, + EXIT_FAILURE, + EXIT_OK, + parseCommandArgs, + printJson, + UsageError, + wantsHelp, +} from "./command"; +import { readServiceSetup, runtimeDataRoot } from "./service-context"; + +export interface PushDependencies { + send?: typeof sendPushRequest; +} + +function pushHelp(): string { + return `Check Web Push delivery to enrolled devices. + +Usage: + roamgate push test [--json] + +Sends one test notification to every device enrolled for task notifications, +using the service's VAPID keys, and reports each push provider's answer. The +registry is only read: expired subscriptions are reported, and the server +removes them on its next delivery. A device must still allow notifications +for the alert to appear. +`; +} + +export function createPushCommand( + dependencies: PushDependencies = {}, +): CliCommand { + const send = dependencies.send ?? sendPushRequest; + return { + name: "push", + summary: "send a test Web Push notification to enrolled devices", + actions: ["test"], + flags: ["--json"], + help: pushHelp, + async run(args, context) { + const action = args[0]; + if (wantsHelp(args) || action === undefined) { + context.log(pushHelp()); + return EXIT_OK; + } + if (action !== "test") + throw new UsageError(`unknown push action: ${action}`); + const { values } = parseCommandArgs(args.slice(1), { + json: { type: "boolean" }, + }); + const setup = readServiceSetup(context.runtime); + const subject = webPushSubject(setup.env); + if (!subject) { + context.error( + "roamgate push: Web Push is disabled because ROAMGATE_WEB_PUSH_SUBJECT is empty.", + ); + return EXIT_FAILURE; + } + const path = webPushPath(setup.env, runtimeDataRoot(context.runtime)); + if (!existsSync(path)) { + context.error( + `roamgate push: no push registry at ${displayPath(path, context.runtime)}; enable Task notifications in a browser first.`, + ); + return EXIT_FAILURE; + } + const registry = readPushRegistry(path); + if (registry.devices.length === 0) { + context.error( + "roamgate push: no devices are enrolled; enable Task notifications in a browser first.", + ); + return EXIT_FAILURE; + } + const payload = JSON.stringify({ + title: "Roamgate test notification", + body: "Web Push delivery to this device works.", + tag: "roamgate-test", + target: null, + }); + const results = await Promise.all( + registry.devices.map(async (device) => { + const provider = new URL(device.subscription.endpoint).hostname; + try { + await send(device.subscription, payload, { + TTL: 300, + urgency: "high", + timeout: 10_000, + vapidDetails: { + subject, + publicKey: registry.publicKey, + privateKey: registry.privateKey, + }, + }); + return { provider, ok: true as const }; + } catch (error) { + const status = (error as { statusCode?: number } | null) + ?.statusCode; + return { + provider, + ok: false as const, + status, + error: + status === 404 || status === 410 + ? "subscription expired; re-enable notifications on that device" + : status + ? `provider answered HTTP ${status}` + : (error as Error).message, + }; + } + }), + ); + if (values.json) printJson(context, { devices: results }); + else { + results.forEach((result, index) => { + context.log( + result.ok + ? `ok device ${index + 1} (${result.provider})` + : `fail device ${index + 1} (${result.provider}): ${result.error}`, + ); + }); + } + return results.every((result) => result.ok) ? EXIT_OK : EXIT_FAILURE; + }, + }; +} diff --git a/server/src/cli/qr.ts b/server/src/cli/qr.ts new file mode 100644 index 00000000..9c5ac617 --- /dev/null +++ b/server/src/cli/qr.ts @@ -0,0 +1,30 @@ +import qrcode from "qrcode-generator"; + +const QUIET_ZONE = 4; +// 256-color black on white keeps the code scannable on dark terminal themes. +const COLORS = "\x1b[38;5;16;48;5;231m"; +const RESET = "\x1b[0m"; + +/** + * Render text as a QR code using half-block characters, two modules per row. + * Colors are explicit because a code with inverted contrast often fails to scan. + */ +export function renderTerminalQr(text: string): string { + const code = qrcode(0, "M"); + code.addData(text, "Byte"); + code.make(); + const size = code.getModuleCount(); + const dark = (row: number, col: number) => + row >= 0 && row < size && col >= 0 && col < size && code.isDark(row, col); + const lines: string[] = []; + for (let row = -QUIET_ZONE; row < size + QUIET_ZONE; row += 2) { + let line = COLORS; + for (let col = -QUIET_ZONE; col < size + QUIET_ZONE; col++) { + const top = dark(row, col); + const bottom = dark(row + 1, col); + line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " "; + } + lines.push(line + RESET); + } + return lines.join("\n"); +} diff --git a/server/src/cli/service-context.ts b/server/src/cli/service-context.ts new file mode 100644 index 00000000..c7fbdc55 --- /dev/null +++ b/server/src/cli/service-context.ts @@ -0,0 +1,283 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { readAuthToken } from "../config/auth-token"; +import { + assertSafeDataPath, + dataRoot, + defaultDataFile, +} from "../config/data-paths"; +import { roamgateEnv } from "../config/environment"; +import { + browserUrlFor, + getLanIPs, + herdrConfigDir, + isAnyHost, + resolveHerdrSocketPaths, + withLoginToken, +} from "../config/server-config"; +import { + DEFAULT_SERVICE_ENV_FILE, + resolveLegacyServicePaths, + resolveServicePaths, + resolveServicePlatform, + type ServicePaths, + type ServicePlatform, +} from "../config/service-definitions"; +import { + parseServiceEnvironment, + prepareServiceAccess, + roamgateServiceStatus, + runServiceCommand, +} from "../config/service-manager"; +import type { CliContext, CliRuntime } from "./command"; + +/** Where the managed service's environment file lives, read but not parsed. */ +export interface ServiceConfigFile { + platform: ServicePlatform; + paths: ServicePaths; + /** The herdr-gui layout that the server still reads when files are missing. */ + legacyPaths: ServicePaths; + /** The environment file in effect: current, else legacy, else current. */ + configPath: string; + configExists: boolean; + /** File contents, or the default template when the file does not exist. */ + contents: string; + homeDir: string; +} + +/** The managed service's configuration as the service process will see it. */ +export interface ServiceSetup extends ServiceConfigFile { + env: Record; +} + +/** The private data directory of the runtime's user, never the test runner's. */ +export function runtimeDataRoot(runtime: CliRuntime): string { + return dataRoot(runtime.homeDir, runtime.platform, runtime.appDataDir); +} + +export function connectionRegistryPath( + runtime: CliRuntime, + env: Record, +): string { + return ( + roamgateEnv("CONNECTIONS_PATH", env) ?? + defaultDataFile( + "connections.json", + runtime.homeDir, + runtime.platform, + runtime.appDataDir, + ) + ); +} + +/** Herdr sockets the service resolves from its environment file. */ +export function serviceHerdrSockets( + runtime: CliRuntime, + env: Record, +) { + return resolveHerdrSocketPaths({ + socketPath: env.HERDR_SOCKET_PATH, + clientSocketPath: env.HERDR_CLIENT_SOCKET_PATH, + sshHost: env.HERDR_SSH_HOST, + session: env.HERDR_SESSION, + herdrDir: herdrConfigDir( + runtime.platform, + runtime.appDataDir, + runtime.homeDir, + ), + }); +} + +export function serviceRuntime(runtime: CliRuntime) { + return { + platform: runtime.platform, + homeDir: runtime.homeDir, + appDataDir: runtime.appDataDir, + execPath: runtime.execPath, + argv: runtime.argv, + uid: runtime.uid, + }; +} + +/** + * Locate and read the environment file without parsing it, so commands that + * repair a broken file still work. Throws only for unsupported platforms and + * unreadable or unsafe paths. + */ +export function locateServiceConfig(runtime: CliRuntime): ServiceConfigFile { + const platform = resolveServicePlatform(runtime.platform); + const paths = resolveServicePaths( + platform, + runtime.homeDir, + runtime.appDataDir, + ); + const legacy = resolveLegacyServicePaths( + platform, + runtime.homeDir, + runtime.appDataDir, + ); + // Inspection never migrates: prefer the new file, then the legacy one. + const configPath = + [paths.config, legacy.config].find((path) => existsSync(path)) ?? + paths.config; + assertSafeDataPath(configPath); + const configExists = existsSync(configPath); + return { + platform, + paths, + legacyPaths: legacy, + configPath, + configExists, + contents: configExists + ? readFileSync(configPath, "utf8") + : DEFAULT_SERVICE_ENV_FILE, + homeDir: runtime.homeDir, + }; +} + +/** Also throws when the env file has entries the service could not load. */ +export function readServiceSetup(runtime: CliRuntime): ServiceSetup { + const file = locateServiceConfig(runtime); + return { + ...file, + env: parseServiceEnvironment( + file.contents, + file.configPath, + file.platform, + file.homeDir, + ), + }; +} + +export interface ServiceAccessInfo { + host: string; + port: number; + tls: boolean; + auth: "password" | "token"; + /** Present when the generated token file exists and is valid. */ + token?: string; + /** Why an existing token file is unusable; the server would refuse to start. */ + tokenError?: string; + tokenPath: string; + pinEnabled: boolean; +} + +/** Like the server: the current token, else a legacy one it would migrate. */ +export function generatedTokenPath(setup: ServiceConfigFile): string { + const current = join(dirname(setup.paths.config), "auth-token"); + const legacy = join(dirname(setup.legacyPaths.config), "auth-token"); + return existsSync(current) || !existsSync(legacy) ? current : legacy; +} + +/** Validated access settings plus the existing token; never creates one. */ +export function readServiceAccess(setup: ServiceSetup): ServiceAccessInfo { + const access = prepareServiceAccess( + setup.configPath, + setup.contents, + setup.platform, + setup.homeDir, + ); + const tokenPath = generatedTokenPath(setup); + let token: string | undefined; + let tokenError: string | undefined; + if (!access.usesFixedPassword && existsSync(tokenPath)) { + // Report rather than throw, so `auth rotate` can replace a broken token. + try { + assertSafeDataPath(tokenPath); + token = readAuthToken(tokenPath); + } catch (error) { + tokenError = (error as Error).message; + } + } + return { + host: access.host, + port: access.port, + tls: access.tls, + auth: access.usesFixedPassword ? "password" : "token", + token, + tokenError, + tokenPath, + pinEnabled: Boolean(roamgateEnv("PIN", setup.env)), + }; +} + +export interface AccessUrls { + local: string; + lan: string[]; +} + +/** Login URLs for this machine and, on wildcard binds, every LAN address. */ +export function accessUrls( + access: ServiceAccessInfo, + lanIPs: () => string[] = getLanIPs, +): AccessUrls { + const withToken = (url: string) => + access.auth === "token" ? withLoginToken(url, access.token) : url; + return { + local: withToken(browserUrlFor(access.host, access.port, access.tls)), + lan: isAnyHost(access.host) + ? lanIPs().map((ip) => + withToken(browserUrlFor(ip, access.port, access.tls)), + ) + : [], + }; +} + +export function serviceStatus(context: CliContext) { + return roamgateServiceStatus( + context.runtime, + context.runCommand, + context.captureCommand, + ); +} + +/** + * Restart the managed service when it is running so a configuration change + * takes effect. Returns the exit code, or null when nothing was running. + */ +export function restartRunningService(context: CliContext): number | null { + const status = serviceStatus(context); + if (!status.installed || !status.active) return null; + const code = runServiceCommand(["service", "restart"], { + runtime: serviceRuntime(context.runtime), + runCommand: context.runCommand, + log: context.log, + error: context.error, + }); + return typeof code === "number" ? code : 1; +} + +/** The address a local client uses to reach the configured listener. */ +export function probeUrl( + access: Pick, +) { + const host = isAnyHost(access.host) + ? access.host === "::" + ? "[::1]" + : "127.0.0.1" + : access.host.includes(":") && !access.host.startsWith("[") + ? `[${access.host}]` + : access.host; + return `${access.tls ? "https" : "http"}://${host}:${access.port}/healthz`; +} + +export type ListenerProbe = ( + access: Pick, +) => Promise; + +/** Whether a Roamgate listener answers its unauthenticated health check. */ +export const probeListener: ListenerProbe = async (access) => { + try { + const response = await fetch(probeUrl(access), { + redirect: "manual", + signal: AbortSignal.timeout(2_000), + // Health checks only identify the listener; self-signed local + // certificates are common, and no credentials are sent. + tls: { rejectUnauthorized: false }, + }); + const body = await response.text(); + return response.status === 200 && body === "Ok"; + } catch { + return false; + } +}; diff --git a/server/src/cli/service-logs.test.ts b/server/src/cli/service-logs.test.ts new file mode 100644 index 00000000..bcbd608a --- /dev/null +++ b/server/src/cli/service-logs.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EXIT_FAILURE } from "./command"; +import { runServiceLogs } from "./service-logs"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +describe("roamgate service logs", () => { + test("follows the systemd journal on Linux", () => { + cli = createTestCli({ runCommand: () => 0 }); + expect(runServiceLogs(["-f", "--lines", "20"], cli.context)).toBe(0); + expect(cli.commands).toEqual([ + [ + "journalctl", + "--user", + "--unit", + "roamgate.service", + "--no-pager", + "--lines", + "20", + "--follow", + ], + ]); + }); + + test("tails the LaunchAgent log files on macOS", () => { + cli = createTestCli({ + runtime: { platform: "darwin" }, + runCommand: () => 0, + }); + expect(runServiceLogs([], cli.context)).toBe(EXIT_FAILURE); + const logs = join(cli.home, "Library", "Logs"); + mkdirSync(logs, { recursive: true }); + writeFileSync(join(logs, "roamgate.stderr.log"), ""); + writeFileSync(join(logs, "roamgate.stdout.log"), ""); + expect(runServiceLogs(["-n", "5", "--follow"], cli.context)).toBe(0); + expect(cli.commands).toEqual([ + [ + "tail", + "-n", + "5", + "-F", + join(logs, "roamgate.stderr.log"), + join(logs, "roamgate.stdout.log"), + ], + ]); + }); + + test("explains that Windows tasks do not capture output", () => { + cli = createTestCli({ runtime: { platform: "win32" } }); + expect(runServiceLogs([], cli.context)).toBe(EXIT_FAILURE); + expect(cli.errors[0]).toContain("does not capture output"); + }); + + test("rejects invalid line counts", () => { + cli = createTestCli(); + expect(() => runServiceLogs(["-n", "lots"], cli.context)).toThrow( + "--lines", + ); + }); +}); diff --git a/server/src/cli/service-logs.ts b/server/src/cli/service-logs.ts new file mode 100644 index 00000000..36b98fa3 --- /dev/null +++ b/server/src/cli/service-logs.ts @@ -0,0 +1,67 @@ +import { existsSync } from "node:fs"; +import { + resolveServicePaths, + resolveServicePlatform, +} from "../config/service-definitions"; +import { + type CliContext, + EXIT_FAILURE, + parseCommandArgs, + UsageError, +} from "./command"; + +/** `roamgate service logs`: show the managed service's output on every platform. */ +export function runServiceLogs(args: string[], context: CliContext): number { + const { values } = parseCommandArgs(args, { + follow: { type: "boolean", short: "f" }, + lines: { type: "string", short: "n" }, + }); + const lines = values.lines ?? "100"; + if (!/^[0-9]{1,6}$/.test(lines)) + throw new UsageError("--lines must be a number from 0 to 999999"); + + const { runtime } = context; + const platform = resolveServicePlatform(runtime.platform); + if (platform === "systemd") { + return context.runCommand([ + "journalctl", + "--user", + "--unit", + "roamgate.service", + "--no-pager", + "--lines", + lines, + ...(values.follow ? ["--follow"] : []), + ]); + } + if (platform === "windows-task") { + context.error( + "roamgate service logs: the Windows scheduled task does not capture output.", + ); + context.error( + "Stop the task and run `roamgate` in a terminal to see its log lines.", + ); + return EXIT_FAILURE; + } + const paths = resolveServicePaths( + platform, + runtime.homeDir, + runtime.appDataDir, + ); + const files = [paths.stderrLog, paths.stdoutLog].filter( + (path): path is string => !!path && existsSync(path), + ); + if (files.length === 0) { + context.error( + `roamgate service logs: no log files yet (${paths.stderrLog}); is the service installed?`, + ); + return EXIT_FAILURE; + } + return context.runCommand([ + "tail", + "-n", + lines, + ...(values.follow ? ["-F"] : []), + ...files, + ]); +} diff --git a/server/src/cli/test-context.ts b/server/src/cli/test-context.ts new file mode 100644 index 00000000..88a43c12 --- /dev/null +++ b/server/src/cli/test-context.ts @@ -0,0 +1,74 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CaptureCommand, RunCommand } from "../config/service-manager"; +import type { CliContext, CliRuntime } from "./command"; + +export interface TestCli { + home: string; + context: CliContext; + output: string[]; + errors: string[]; + commands: string[][]; + /** The service environment file under the temporary home (Linux layout). */ + configPath: string; + writeConfig: (contents: string) => void; + cleanup: () => void; +} + +/** A CLI context whose files, commands and output never leave a temp home. */ +export function createTestCli( + options: { + runtime?: Partial; + runCommand?: RunCommand; + captureCommand?: CaptureCommand; + } = {}, +): TestCli { + const home = mkdtempSync(join(tmpdir(), "roamgate-cli-test-")); + const output: string[] = []; + const errors: string[] = []; + const commands: string[][] = []; + const runtime: CliRuntime = { + platform: "linux", + arch: "x64", + homeDir: home, + execPath: join(home, "bin", "roamgate"), + argv: [join(home, "bin", "roamgate"), "roamgate"], + uid: 1000, + env: {}, + ...options.runtime, + }; + const configPath = join(home, ".config", "roamgate", "roamgate.env"); + return { + home, + output, + errors, + commands, + configPath, + context: { + version: "1.2.3", + runtime, + runCommand: (argv, commandOptions) => { + commands.push(argv); + // Unknown commands fail like a missing service by default. + return options.runCommand?.(argv, commandOptions) ?? 4; + }, + captureCommand: (argv) => { + commands.push(argv); + return options.captureCommand?.(argv) ?? { code: 113, stdout: "" }; + }, + log: (message) => output.push(message), + error: (message) => errors.push(message), + }, + writeConfig(contents) { + mkdirSync(join(home, ".config", "roamgate"), { + recursive: true, + mode: 0o700, + }); + writeFileSync(configPath, contents, { mode: 0o600 }); + }, + cleanup() { + rmSync(home, { recursive: true, force: true }); + }, + }; +} diff --git a/server/src/cli/update.test.ts b/server/src/cli/update.test.ts new file mode 100644 index 00000000..4d9c59c1 --- /dev/null +++ b/server/src/cli/update.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EXIT_FAILURE, EXIT_OK } from "./command"; +import { createUpdateCommand, rollbackCommand } from "./update"; +import { createTestCli, type TestCli } from "./test-context"; + +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +const manifest = (version: string) => + JSON.stringify({ + schema: 1, + name: "roamgate", + version, + platform: "linux-x64", + archive: "roamgate-linux-x64.tar.xz", + sha256: "a".repeat(64), + }); + +function fakeProcess(version: string, installCode = 0) { + const calls: string[][] = []; + return { + calls, + runProcess: async (argv: string[]) => { + calls.push(argv); + return argv[0] === "curl" + ? { code: 0, stdout: manifest(version), stderr: "" } + : { + code: installCode, + stdout: "", + stderr: installCode ? "checksum mismatch" : "", + }; + }, + }; +} + +describe("roamgate update", () => { + test("--check compares against the latest manifest", async () => { + cli = createTestCli(); + const fake = fakeProcess("1.3.0"); + const code = await createUpdateCommand(fake).run( + ["--check", "--json"], + cli.context, + ); + expect(code).toBe(EXIT_OK); + expect(JSON.parse(cli.output.join("\n"))).toEqual({ + current_version: "1.2.3", + latest_version: "1.3.0", + update_available: true, + platform: "linux-x64", + }); + expect(fake.calls[0]).toContain( + "https://github.com/powerfooI/roamgate/releases/latest/download/roamgate-linux-x64.update.json", + ); + }); + + test("uses a mirror from the service config", async () => { + cli = createTestCli(); + cli.writeConfig( + "ROAMGATE_UPDATE_BASE_URL=https://mirror.example/roamgate/\n", + ); + const fake = fakeProcess("1.2.3"); + await createUpdateCommand(fake).run(["--check"], cli.context); + expect(fake.calls[0].at(-1)).toBe( + "https://mirror.example/roamgate/roamgate-linux-x64.update.json", + ); + expect(cli.output.at(-1)).toBe("Up to date."); + }); + + test("installs over the running executable with the shared verifier", async () => { + cli = createTestCli(); + const fake = fakeProcess("1.3.0"); + expect(await createUpdateCommand(fake).run([], cli.context)).toBe(EXIT_OK); + const [shell, flag, script] = fake.calls[1]; + expect([shell, flag]).toEqual(["sh", "-c"]); + expect(script).toContain(`target='${cli.context.runtime.execPath}'`); + expect(script).toContain(`expected_sha256='${"a".repeat(64)}'`); + expect(script).toContain('backup="$target.previous"'); + expect(cli.output).toContain( + `Installed roamgate 1.3.0 at ${cli.context.runtime.execPath}.`, + ); + }); + + test("reports installer failures and refuses source checkouts", async () => { + cli = createTestCli(); + expect( + await createUpdateCommand(fakeProcess("1.3.0", 1)).run([], cli.context), + ).toBe(EXIT_FAILURE); + expect(cli.errors[0]).toBe("roamgate update: checksum mismatch"); + cli.context.runtime.argv = ["bun", "/repo/server/src/index.ts"]; + expect( + await createUpdateCommand(fakeProcess("1.3.0")).run([], cli.context), + ).toBe(EXIT_FAILURE); + }); + + test("explains platforms without update packages", async () => { + cli = createTestCli({ runtime: { platform: "win32" } }); + expect( + await createUpdateCommand(fakeProcess("1.3.0")).run([], cli.context), + ).toBe(EXIT_FAILURE); + expect(cli.errors[0]).toContain("no update package for win32-x64"); + }); +}); + +describe("roamgate rollback", () => { + function executable(path: string, version: string) { + writeFileSync(path, `#!/bin/sh\necho 'roamgate ${version}'\n`); + chmodSync(path, 0o755); + } + + test("swaps the executable with roamgate.previous and back", async () => { + cli = createTestCli(); + const current = cli.context.runtime.execPath; + mkdirSync(join(cli.home, "bin")); + executable(current, "1.2.3"); + executable(`${current}.previous`, "1.1.0"); + expect(await rollbackCommand.run([], cli.context)).toBe(EXIT_OK); + expect(readFileSync(current, "utf8")).toContain("roamgate 1.1.0"); + expect(readFileSync(`${current}.previous`, "utf8")).toContain( + "roamgate 1.2.3", + ); + expect(cli.output[0]).toBe("Rolled back roamgate 1.2.3 -> 1.1.0."); + expect(await rollbackCommand.run([], cli.context)).toBe(EXIT_OK); + expect(readFileSync(current, "utf8")).toContain("roamgate 1.2.3"); + }); + + test("leaves files alone when the previous executable is unusable", async () => { + cli = createTestCli(); + const current = cli.context.runtime.execPath; + mkdirSync(join(cli.home, "bin")); + executable(current, "1.2.3"); + expect(await rollbackCommand.run([], cli.context)).toBe(EXIT_FAILURE); + writeFileSync(`${current}.previous`, "#!/bin/sh\nexit 3\n"); + chmodSync(`${current}.previous`, 0o755); + expect(await rollbackCommand.run([], cli.context)).toBe(EXIT_FAILURE); + expect(readFileSync(current, "utf8")).toContain("roamgate 1.2.3"); + }); +}); diff --git a/server/src/cli/update.ts b/server/src/cli/update.ts new file mode 100644 index 00000000..87d5661b --- /dev/null +++ b/server/src/cli/update.ts @@ -0,0 +1,267 @@ +import { lstatSync, readFileSync, renameSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { roamgateEnv } from "../config/environment"; +import { + escapeSystemdExecPath, + xmlEscape, +} from "../config/service-definitions"; +import { + compareVersion, + curlTransportArgs, + fetchLatestUpdateManifest, + normalizeUpdateBaseUrl, + renderUpdateInstallScript, + resolveUpdateTarget, + type RunProcessWithCodeTimeout, + UPDATE_INSTALL_TIMEOUT_MS, +} from "../http/update"; +import { runProcessWithCodeTimeout, shQuote } from "../utils/process-utils"; +import { + type CliCommand, + type CliContext, + EXIT_FAILURE, + EXIT_OK, + isStandaloneBinary, + parseCommandArgs, + printJson, + wantsHelp, +} from "./command"; +import { + readServiceSetup, + restartRunningService, + serviceStatus, +} from "./service-context"; + +export interface UpdateDependencies { + runProcess?: RunProcessWithCodeTimeout; +} + +function updateHelp(): string { + return `Update the standalone roamgate executable to the latest release. + +Usage: + roamgate update [--check] [--json] + +Options: + --check only report whether a newer release exists + --json print machine-readable output (with --check) + +The release manifest, archive checksum, package VERSION and binary version are +verified before the executable is replaced in place. The previous executable is +kept as roamgate.previous; \`roamgate rollback\` restores it. A running managed +service that uses this executable is restarted afterwards. + +ROAMGATE_UPDATE_BASE_URL selects a mirror (from the shell, else the service +config). To install a specific version, rerun the installer with +ROAMGATE_VERSION=X.Y.Z. Windows builds update by downloading a release. +`; +} + +function rollbackHelp(): string { + return `Swap the roamgate executable with roamgate.previous. + +Usage: + roamgate rollback + +The update command and installer keep the replaced executable as +roamgate.previous. Rollback swaps the two files, so running it again undoes the +rollback. A running managed service that uses this executable is restarted. +`; +} + +function serviceEnv(context: CliContext): Record { + try { + return readServiceSetup(context.runtime).env; + } catch { + return {}; + } +} + +/** Restart the managed service only when its definition runs this executable. */ +function restartServiceUsing(context: CliContext, executable: string): number { + let status: ReturnType; + try { + status = serviceStatus(context); + } catch { + return EXIT_OK; + } + if (!status.installed || !status.active) { + context.log("Restart any running roamgate process to use the new version."); + return EXIT_OK; + } + const definition = readFileSync(status.definition, "utf8"); + const forms = [ + executable, + escapeSystemdExecPath(executable), + xmlEscape(executable), + ]; + if (!forms.some((form) => definition.includes(form))) { + context.log( + `The managed service runs a different executable (${status.definition}); it was not restarted.`, + ); + return EXIT_OK; + } + const code = restartRunningService(context); + if (code === 0) context.log("Restarted the service."); + return code ?? EXIT_OK; +} + +export function createUpdateCommand( + dependencies: UpdateDependencies = {}, +): CliCommand { + const runProcess = dependencies.runProcess ?? runProcessWithCodeTimeout; + return { + name: "update", + summary: "update roamgate to the latest release", + flags: ["--check", "--json"], + help: updateHelp, + async run(args, context) { + if (wantsHelp(args)) { + context.log(updateHelp()); + return EXIT_OK; + } + const { values } = parseCommandArgs(args, { + check: { type: "boolean" }, + json: { type: "boolean" }, + }); + const { runtime } = context; + const target = resolveUpdateTarget(runtime.platform, runtime.arch); + if (!target) { + context.error( + `roamgate update: no update package for ${runtime.platform}-${runtime.arch}; download it from https://github.com/powerfooI/roamgate/releases/latest`, + ); + return EXIT_FAILURE; + } + const baseUrl = normalizeUpdateBaseUrl( + roamgateEnv("UPDATE_BASE_URL", runtime.env) ?? + roamgateEnv("UPDATE_BASE_URL", serviceEnv(context)), + ); + const manifest = await fetchLatestUpdateManifest({ + baseUrl, + target, + runProcessWithCodeTimeout: runProcess, + }); + const available = compareVersion(manifest.version, context.version) > 0; + + if (values.check) { + if (values.json) { + printJson(context, { + current_version: context.version, + latest_version: manifest.version, + update_available: available, + platform: target.platform, + }); + } else { + context.log(`Current: roamgate ${context.version}`); + context.log(`Latest: roamgate ${manifest.version}`); + context.log( + available + ? "An update is available: run `roamgate update`." + : "Up to date.", + ); + } + return EXIT_OK; + } + if (!available) { + context.log(`roamgate ${context.version} is up to date.`); + return EXIT_OK; + } + if (!isStandaloneBinary(runtime)) { + context.error( + "roamgate update: only the standalone binary can update itself; pull and rebuild source checkouts.", + ); + return EXIT_FAILURE; + } + + context.log( + `Updating roamgate ${context.version} -> ${manifest.version}...`, + ); + const script = renderUpdateInstallScript({ + targetPath: runtime.execPath, + target, + manifest, + archiveUrl: `${baseUrl}/${target.archiveName}`, + curlTransport: curlTransportArgs(baseUrl).map(shQuote).join(" "), + shQuote, + }); + const result = await runProcess( + ["sh", "-c", script], + UPDATE_INSTALL_TIMEOUT_MS, + ); + if (result.code !== 0) { + context.error( + `roamgate update: ${(result.stderr || result.stdout || `install exited ${result.code}`).trim().slice(0, 1000)}`, + ); + return EXIT_FAILURE; + } + context.log( + `Installed roamgate ${manifest.version} at ${runtime.execPath}.`, + ); + context.log(`Previous executable: ${runtime.execPath}.previous`); + return restartServiceUsing(context, runtime.execPath); + }, + }; +} + +function regularFile(path: string): boolean { + try { + const stat = lstatSync(path); + return stat.isFile() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +export const rollbackCommand: CliCommand = { + name: "rollback", + summary: "restore the executable replaced by the last update", + help: rollbackHelp, + async run(args, context) { + if (wantsHelp(args)) { + context.log(rollbackHelp()); + return EXIT_OK; + } + parseCommandArgs(args, {}); + const { runtime } = context; + if (!isStandaloneBinary(runtime) || runtime.platform === "win32") { + context.error( + "roamgate rollback: only the standalone Linux and macOS binary can roll back.", + ); + return EXIT_FAILURE; + } + const current = runtime.execPath; + const previous = `${current}.previous`; + if (!regularFile(current) || !regularFile(previous)) { + context.error(`roamgate rollback: no previous executable at ${previous}`); + return EXIT_FAILURE; + } + const probe = Bun.spawnSync([previous, "--version"], { + stdout: "pipe", + stderr: "ignore", + }); + const previousVersion = probe.stdout.toString().trim(); + if (probe.exitCode !== 0 || !/^roamgate \S+$/.test(previousVersion)) { + context.error( + `roamgate rollback: ${previous} is not a working roamgate executable; nothing changed.`, + ); + return EXIT_FAILURE; + } + const parked = join( + dirname(current), + `.${basename(current)}.rollback-${process.pid}`, + ); + renameSync(current, parked); + try { + renameSync(previous, current); + } catch (error) { + renameSync(parked, current); + throw error; + } + renameSync(parked, previous); + context.log( + `Rolled back roamgate ${context.version} -> ${previousVersion.slice("roamgate ".length)}.`, + ); + context.log("Run `roamgate rollback` again to undo."); + return restartServiceUsing(context, current); + }, +}; diff --git a/server/src/cli/url.test.ts b/server/src/cli/url.test.ts new file mode 100644 index 00000000..53a63bd3 --- /dev/null +++ b/server/src/cli/url.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import qrcode from "qrcode-generator"; +import { EXIT_FAILURE, EXIT_OK, EXIT_USAGE } from "./command"; +import { runCli } from "./index"; +import { renderTerminalQr } from "./qr"; +import { createTestCli, type TestCli } from "./test-context"; + +const TOKEN = "a".repeat(64); +let cli: TestCli; +afterEach(() => cli?.cleanup()); + +function writeToken(testCli: TestCli) { + writeFileSync( + join(testCli.home, ".config", "roamgate", "auth-token"), + `${TOKEN}\n`, + { + mode: 0o600, + }, + ); +} + +describe("renderTerminalQr", () => { + test("draws exactly the library's modules with a quiet zone", () => { + const text = `http://192.168.1.20:8787/?token=${TOKEN}`; + const code = qrcode(0, "M"); + code.addData(text, "Byte"); + code.make(); + const size = code.getModuleCount(); + // Strip colors and rebuild the module grid from the half-block glyphs. + const rows = renderTerminalQr(text) + .split("\n") + .map((line) => Array.from(line.replace(/\x1b\[[0-9;]*m/g, ""))); + const grid: boolean[][] = []; + for (const row of rows) { + grid.push(row.map((glyph) => glyph === "█" || glyph === "▀")); + grid.push(row.map((glyph) => glyph === "█" || glyph === "▄")); + } + for (let row = -4; row < size + 4; row++) { + for (let col = -4; col < size + 4; col++) { + const expected = + row >= 0 && + row < size && + col >= 0 && + col < size && + code.isDark(row, col); + expect(grid[row + 4][col + 4]).toBe(expected); + } + } + }); +}); + +describe("roamgate url", () => { + test("prints tokenized local and LAN URLs from the service config", async () => { + cli = createTestCli(); + cli.writeConfig("HOST=0.0.0.0\nPORT=9100\n"); + writeToken(cli); + expect(await runCli(["url", "--json"], cli.context)).toBe(EXIT_OK); + const result = JSON.parse(cli.output.join("\n")); + expect(result.local).toBe(`http://localhost:9100/?token=${TOKEN}`); + expect(result.auth).toBe("token"); + expect(Array.isArray(result.lan)).toBe(true); + }); + + test("omits the token for fixed-password services and applies --address", async () => { + cli = createTestCli(); + cli.writeConfig("ROAMGATE_PASSWORD=correct-horse-battery\n"); + writeToken(cli); + await runCli( + ["url", "--json", "--address", "box.tailnet.ts.net"], + cli.context, + ); + const result = JSON.parse(cli.output.join("\n")); + expect(result.local).toBe("http://localhost:8787"); + expect(result.address).toBe("http://box.tailnet.ts.net:8787"); + }); + + test("refuses a QR code that only this machine could open", async () => { + cli = createTestCli(); + cli.writeConfig("HOST=127.0.0.1\n"); + writeToken(cli); + expect(await runCli(["url", "--qr"], cli.context)).toBe(EXIT_FAILURE); + expect(cli.errors[0]).toContain("only listens on this machine"); + }); + + test("prints a QR code for an explicit address with a token warning", async () => { + cli = createTestCli(); + cli.writeConfig("HOST=127.0.0.1\n"); + writeToken(cli); + expect( + await runCli(["url", "--qr", "--address", "10.0.0.5"], cli.context), + ).toBe(EXIT_OK); + expect(cli.output).toContain("QR code opens: http://10.0.0.5:8787"); + expect(cli.output.at(-1)).toContain("contains your login token"); + }); + + test("validates options", async () => { + cli = createTestCli(); + expect(await runCli(["url", "--qr", "--json"], cli.context)).toBe( + EXIT_USAGE, + ); + expect(await runCli(["url", "--address", "a/b"], cli.context)).toBe( + EXIT_USAGE, + ); + }); + + test("the loopback hint is a command that works", async () => { + cli = createTestCli(); + cli.writeConfig("HOST=127.0.0.1\n"); + await runCli(["url", "--qr"], cli.context); + const hint = cli.errors.find((line) => line.includes("config set")); + const command = /`roamgate (config set [^`]+)`/.exec(hint ?? "")?.[1]; + expect(command).toBe("config set HOST=0.0.0.0 --restart"); + expect(await runCli(command!.split(" "), cli.context)).toBe(EXIT_OK); + expect(readFileSync(cli.configPath, "utf8")).toContain("HOST=0.0.0.0"); + }); +}); diff --git a/server/src/cli/url.ts b/server/src/cli/url.ts new file mode 100644 index 00000000..b149e70c --- /dev/null +++ b/server/src/cli/url.ts @@ -0,0 +1,130 @@ +import { + browserUrlFor, + getLanIPs, + withLoginToken, +} from "../config/server-config"; +import { + type CliCommand, + displayPath, + EXIT_FAILURE, + EXIT_OK, + parseCommandArgs, + printJson, + UsageError, + wantsHelp, +} from "./command"; +import { renderTerminalQr } from "./qr"; +import { + accessUrls, + readServiceAccess, + readServiceSetup, +} from "./service-context"; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +function urlHelp(): string { + return `Print the login URLs of the managed Roamgate service. + +Usage: + roamgate url [--qr] [--address ] [--json] + +Options: + --qr also print a QR code to open the URL on a phone + --address use this host name or IP in the URL, for example a VPN + or Tailscale name, instead of the detected addresses + --json print machine-readable output + +URLs come from the service environment file and include the generated login +token unless the service uses a fixed password. The QR code contains that +token: anyone who scans it can sign in, so do not share screenshots of it. +`; +} + +export const urlCommand: CliCommand = { + name: "url", + summary: "print login URLs, optionally as a QR code", + flags: ["--qr", "--address", "--json"], + help: urlHelp, + async run(args, context) { + if (wantsHelp(args)) { + context.log(urlHelp()); + return EXIT_OK; + } + const { values } = parseCommandArgs(args, { + qr: { type: "boolean" }, + address: { type: "string" }, + json: { type: "boolean" }, + }); + if (values.qr && values.json) + throw new UsageError("--qr and --json cannot be combined"); + const address = values.address?.trim(); + if (values.address !== undefined && !address) + throw new UsageError("--address must not be empty"); + if (address && /[\s/?#@]/.test(address)) + throw new UsageError("--address must be a host name or IP address"); + + const setup = readServiceSetup(context.runtime); + const access = readServiceAccess(setup); + const urls = accessUrls(access, getLanIPs); + const custom = address + ? withLoginToken( + browserUrlFor(address, access.port, access.tls), + access.auth === "token" ? access.token : undefined, + ) + : undefined; + const missingToken = access.auth === "token" && !access.token; + + if (values.json) { + printJson(context, { + config_path: setup.configPath, + config_exists: setup.configExists, + auth: access.auth, + token_available: access.auth === "token" ? !missingToken : undefined, + local: urls.local, + lan: urls.lan, + ...(custom ? { address: custom } : {}), + }); + return EXIT_OK; + } + + if (!setup.configExists) { + context.error( + `No service environment at ${displayPath(setup.configPath, context.runtime)}; showing defaults.`, + ); + } + if (access.tokenError) { + context.error( + `The login token file is invalid (${access.tokenError}); run \`roamgate auth rotate\`.`, + ); + } else if (missingToken) { + context.error( + "No login token yet: it is created by `roamgate service install` or the first server start.", + ); + } + if (custom) context.log(`Address: ${custom}`); + context.log(`Local: ${urls.local}`); + for (const url of urls.lan) context.log(`LAN: ${url}`); + if (!values.qr) return EXIT_OK; + + const target = custom ?? urls.lan[0] ?? urls.local; + const targetHost = new URL(target).hostname; + if (!custom && LOOPBACK_HOSTS.has(targetHost)) { + context.error( + "The service only listens on this machine, so another device cannot open this URL.", + ); + context.error( + "Use --address with a reachable name, or `roamgate config set HOST=0.0.0.0 --restart`.", + ); + return EXIT_FAILURE; + } + context.log(""); + context.log(renderTerminalQr(target)); + context.log(`QR code opens: ${new URL(target).origin}`); + if (access.auth === "token" && access.token) { + context.log( + "This code contains your login token. Anyone who scans it can sign in.", + ); + } + return EXIT_OK; + }, +}; diff --git a/server/src/config/auth-token.test.ts b/server/src/config/auth-token.test.ts index 9c8f97c3..487addd9 100644 --- a/server/src/config/auth-token.test.ts +++ b/server/src/config/auth-token.test.ts @@ -14,6 +14,7 @@ import { assertValidAuthPassword, defaultAuthTokenPath, loadOrCreateAuthToken, + rotateAuthToken, } from "./auth-token"; const tempDirs: string[] = []; @@ -118,3 +119,25 @@ describe("generated authentication token", () => { ); }); }); + +describe("rotateAuthToken", () => { + test("replaces the token atomically with private permissions", () => { + const path = tempAuthTokenPath(); + const original = loadOrCreateAuthToken(path); + const rotated = rotateAuthToken(path); + expect(rotated).toMatch(/^[a-f0-9]{64}$/); + expect(rotated).not.toBe(original); + expect(readFileSync(path, "utf8")).toBe(`${rotated}\n`); + if (process.platform !== "win32") + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + test("refuses symlinked token paths", () => { + const home = tempHome(); + const path = tempAuthTokenPath(home); + loadOrCreateAuthToken(path); + const link = join(home, "link-token"); + symlinkSync(path, link); + expect(() => rotateAuthToken(link)).toThrow("symlink"); + }); +}); diff --git a/server/src/config/auth-token.ts b/server/src/config/auth-token.ts index abbf6a73..35a01391 100644 --- a/server/src/config/auth-token.ts +++ b/server/src/config/auth-token.ts @@ -10,6 +10,7 @@ import { homedir } from "node:os"; import { defaultDataFile, publishDataFile, + replaceDataFile, assertSafeDataPath, } from "./data-paths"; import { randomBytes } from "node:crypto"; @@ -41,7 +42,7 @@ export function defaultAuthTokenPath( return defaultDataFile("auth-token", homeDir, platform, appDataDir); } -function readAuthToken(path: string): string { +export function readAuthToken(path: string): string { const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); try { if (!fstatSync(fd).isFile()) { @@ -72,3 +73,10 @@ export function loadOrCreateAuthToken(path = defaultAuthTokenPath()): string { publishDataFile(path, `${randomBytes(32).toString("hex")}\n`); return readAuthToken(path); } + +/** Replace the generated token; running servers keep the old one until restart. */ +export function rotateAuthToken(path = defaultAuthTokenPath()): string { + assertSafeDataPath(path); + replaceDataFile(path, `${randomBytes(32).toString("hex")}\n`); + return readAuthToken(path); +} diff --git a/server/src/config/data-paths.test.ts b/server/src/config/data-paths.test.ts index f7fd9ea2..dc4cb525 100644 --- a/server/src/config/data-paths.test.ts +++ b/server/src/config/data-paths.test.ts @@ -19,6 +19,7 @@ import { legacyDataRoot, migrateDataFile, publishDataFile, + replaceDataFile, } from "./data-paths"; import { defaultAuthTokenPath, loadOrCreateAuthToken } from "./auth-token"; @@ -132,6 +133,21 @@ test("failed publication leaves no partial target and retries without overwritin expect(readdirSync(dirname(target))).toEqual(["settings.json"]); }); +test("replacement overwrites atomically with private permissions", () => { + const root = home(); + const target = join(root, "roamgate", "roamgate.env"); + publishDataFile(target, "first", 0o644); + replaceDataFile(target, "second"); + expect(readFileSync(target, "utf8")).toBe("second"); + expect(readdirSync(dirname(target))).toEqual(["roamgate.env"]); + if (process.platform !== "win32") + expect(statSync(target).mode & 0o777).toBe(0o600); + const link = join(root, "roamgate", "link.env"); + symlinkSync(target, link); + expect(() => replaceDataFile(link, "third")).toThrow("symlink"); + expect(readFileSync(target, "utf8")).toBe("second"); +}); + test("concurrent first use publishes one complete legacy token", async () => { const root = home(); const legacy = join(root, ".config", "herdr-gui", "auth-token"); @@ -262,7 +278,7 @@ test("plugin URL reads legacy files without migrating and prefers new paths", as const current = dataRoot(root, process.platform, appData); write(join(legacy, "herdr-gui.env"), "HOST=0.0.0.0\nPORT=8890\n"); write(join(legacy, "auth-token"), `${"c".repeat(64)}\n`); - const script = `import { computeUrl } from ${JSON.stringify(join(import.meta.dir, "plugin-command.ts"))}; console.log(computeUrl());`; + const script = `import { serviceLoginUrl } from ${JSON.stringify(join(import.meta.dir, "plugin-command.ts"))}; console.log(serviceLoginUrl());`; const invoke = async () => { const child = Bun.spawn([process.execPath, "-e", script], { env: { ...process.env, HOME: root, USERPROFILE: root, APPDATA: appData }, @@ -277,7 +293,7 @@ test("plugin URL reads legacy files without migrating and prefers new paths", as expect(await invoke()).toBe(`http://localhost:8890/?token=${"c".repeat(64)}`); expect(existsSync(current)).toBeFalse(); write(join(current, "roamgate.env"), "HOST=127.0.0.1\nPORT=8891\n"); - expect(await invoke()).toBe(`http://127.0.0.1:8891/?token=${"c".repeat(64)}`); + expect(await invoke()).toBe(`http://localhost:8891/?token=${"c".repeat(64)}`); expect(readFileSync(join(legacy, "herdr-gui.env"), "utf8")).toBe( "HOST=0.0.0.0\nPORT=8890\n", ); diff --git a/server/src/config/data-paths.ts b/server/src/config/data-paths.ts index 6c43ce88..ca0ce1e7 100644 --- a/server/src/config/data-paths.ts +++ b/server/src/config/data-paths.ts @@ -10,6 +10,7 @@ import { mkdirSync, openSync, readFileSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; @@ -59,15 +60,20 @@ export function assertSafeDataPath(path: string): void { } } -/** Publish a complete private file without replacing a concurrent winner. */ -export function publishDataFile( +/** + * Write a complete private file through a synced temporary file. Publishing + * links it and keeps a concurrent winner; replacing renames over the target. + */ +function writeDataFile( path: string, contents: string | Buffer, - mode = 0o600, + mode: number, + replace: boolean, ): void { assertSafeDataPath(path); - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const temporaryPath = join(dirname(path), `.roamgate-${randomUUID()}.tmp`); + const directory = dirname(path); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const temporaryPath = join(directory, `.roamgate-${randomUUID()}.tmp`); let fd: number | undefined; try { fd = openSync( @@ -81,16 +87,54 @@ export function publishDataFile( closeSync(fd); fd = undefined; assertSafeDataPath(path); - try { - linkSync(temporaryPath, path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - assertSafeDataPath(path); + if (replace) { + renameSync(temporaryPath, path); + } else { + try { + linkSync(temporaryPath, path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + assertSafeDataPath(path); + } } } finally { if (fd !== undefined) closeSync(fd); rmSync(temporaryPath, { force: true }); } + // Persist the new directory entry too. Windows cannot open directories, and + // some filesystems reject directory fsync; the file itself is already synced. + if (process.platform === "win32") return; + try { + const directoryFd = openSync( + directory, + constants.O_RDONLY | (constants.O_DIRECTORY ?? 0), + ); + try { + fsyncSync(directoryFd); + } finally { + closeSync(directoryFd); + } + } catch { + // Best effort, as for the connection registry. + } +} + +/** Publish a complete private file without replacing a concurrent winner. */ +export function publishDataFile( + path: string, + contents: string | Buffer, + mode = 0o600, +): void { + writeDataFile(path, contents, mode, false); +} + +/** Atomically replace a private file, unlike publishDataFile which never overwrites. */ +export function replaceDataFile( + path: string, + contents: string | Buffer, + mode = 0o600, +): void { + writeDataFile(path, contents, mode, true); } /** Copy only missing files. Originals and concurrent/new values always win. */ diff --git a/server/src/config/plugin-command.test.ts b/server/src/config/plugin-command.test.ts index 23923954..0f9c84fe 100644 --- a/server/src/config/plugin-command.test.ts +++ b/server/src/config/plugin-command.test.ts @@ -9,87 +9,74 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { computeUrl, readServiceEnv, runPluginCommand } from "./plugin-command"; +import { runPluginCommand, serviceLoginUrl } from "./plugin-command"; -describe("readServiceEnv", () => { - test("reads plain values", () => { - expect(readServiceEnv("HOST=0.0.0.0\nPORT=8791\n", "HOST")).toBe("0.0.0.0"); - expect(readServiceEnv("HOST=0.0.0.0\nPORT=8791\n", "PORT")).toBe("8791"); - }); - - test("accepts export prefix, whitespace, and quoted values", () => { - const contents = "export HOST=\"0.0.0.0\"\n PORT = '8799'\n"; - expect(readServiceEnv(contents, "HOST")).toBe("0.0.0.0"); - expect(readServiceEnv(contents, "PORT")).toBe("8799"); - }); - - test("last occurrence wins and missing keys are undefined", () => { - expect(readServiceEnv("PORT=1\nPORT=2\n", "PORT")).toBe("2"); - expect(readServiceEnv("HOST=x\n", "PORT")).toBeUndefined(); - }); - - test("ignores comments and unrelated keys", () => { - const contents = - "# HOST=10.0.0.1\nHERDR_GUI_LOG_LEVEL=info\nHOST=127.0.0.1\n"; - expect(readServiceEnv(contents, "HOST")).toBe("127.0.0.1"); - }); -}); - -describe("computeUrl", () => { - const dirs: string[] = []; - function fixture(files: Record): string { - const dir = mkdtempSync(join(tmpdir(), "studio-plugin-")); - dirs.push(dir); +describe("serviceLoginUrl", () => { + const TOKEN = "d".repeat(64); + const homes: string[] = []; + function fixture(files: Record) { + const home = mkdtempSync(join(tmpdir(), "roamgate-plugin-url-")); + homes.push(home); + const dir = join(home, ".config", "roamgate"); + mkdirSync(dir, { recursive: true }); for (const [name, text] of Object.entries(files)) { writeFileSync(join(dir, name), text); } - return dir; + return { + platform: "linux", + arch: "x64", + homeDir: home, + execPath: join(home, "roamgate"), + argv: [join(home, "roamgate")], + env: {}, + }; } afterEach(() => { - while (dirs.length) { - rmSync(dirs.pop()!, { recursive: true, force: true }); + while (homes.length) { + rmSync(homes.pop()!, { recursive: true, force: true }); } }); - test("defaults to loopback when no env file exists", () => { - expect(computeUrl(fixture({}))).toBe("http://127.0.0.1:8787"); - }); - - test("includes the login token for non-loopback binds", () => { - const dir = fixture({ - "roamgate.env": "HOST=0.0.0.0\nPORT=8791\n", - "auth-token": "abc123\n", - }); - expect(computeUrl(dir)).toBe("http://localhost:8791/?token=abc123"); - }); - - test("includes the login token for loopback binds", () => { - const dir = fixture({ - "roamgate.env": "HOST=127.0.0.1\nPORT=8787\n", - "auth-token": "abc123\n", - }); - expect(computeUrl(dir)).toBe("http://127.0.0.1:8787/?token=abc123"); + test("matches `roamgate url`, including defaults and the login token", () => { + expect(serviceLoginUrl(fixture({}))).toBe("http://localhost:8787"); + expect( + serviceLoginUrl( + fixture({ + "roamgate.env": "HOST=0.0.0.0\nPORT=8791\n", + "auth-token": `${TOKEN}\n`, + }), + ), + ).toBe(`http://localhost:8791/?token=${TOKEN}`); }); test("new password values take precedence, including empty values", () => { - const dir = fixture({ + const runtime = fixture({ "roamgate.env": - "HOST=0.0.0.0\nHERDR_GUI_PASSWORD=old\nROAMGATE_PASSWORD=new\n", - "auth-token": "saved-token\n", + "HERDR_GUI_PASSWORD=old-password-value\nROAMGATE_PASSWORD=new-password-value\n", + "auth-token": `${TOKEN}\n`, }); - expect(computeUrl(dir)).toBe("http://localhost:8787"); + expect(serviceLoginUrl(runtime)).toBe("http://localhost:8787"); writeFileSync( - join(dir, "roamgate.env"), - "HOST=0.0.0.0\nHERDR_GUI_PASSWORD=old\nROAMGATE_PASSWORD=\n", + join(runtime.homeDir, ".config", "roamgate", "roamgate.env"), + "HERDR_GUI_PASSWORD=old-password-value\nROAMGATE_PASSWORD=\n", + ); + expect(serviceLoginUrl(runtime)).toBe( + `http://localhost:8787/?token=${TOKEN}`, ); - expect(computeUrl(dir)).toBe("http://localhost:8787/?token=saved-token"); }); - test("honors exported and quoted entries without a token file", () => { - const dir = fixture({ + test("honors exported and quoted entries and native TLS", () => { + if (process.platform === "win32") return; + const runtime = fixture({ "roamgate.env": 'export HOST="0.0.0.0"\nPORT = "8799"\n', }); - expect(computeUrl(dir)).toBe("http://localhost:8799"); + expect(serviceLoginUrl(runtime)).toBe("http://localhost:8799"); + }); + + test("rejects configurations the service could not start with", () => { + expect(() => + serviceLoginUrl(fixture({ "roamgate.env": "PORT=0\n" })), + ).toThrow("invalid PORT"); }); }); diff --git a/server/src/config/plugin-command.ts b/server/src/config/plugin-command.ts index e8cb636f..30eed4e6 100644 --- a/server/src/config/plugin-command.ts +++ b/server/src/config/plugin-command.ts @@ -1,7 +1,11 @@ import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { dataRoot, legacyDataRoot, assertSafeDataPath } from "./data-paths"; +import { type CliRuntime, currentRuntime } from "../cli/command"; +import { + accessUrls, + locateServiceConfig, + readServiceAccess, + readServiceSetup, +} from "../cli/service-context"; function capture(binary: string, args: string[]) { const result = spawnSync(binary, args, { encoding: "utf8" }); @@ -12,86 +16,37 @@ function capture(binary: string, args: string[]) { }; } -function configDir(): string { - return dataRoot(); +/** The service's local login URL, computed exactly as `roamgate url` does. */ +export function serviceLoginUrl( + runtime: CliRuntime = currentRuntime(), +): string { + return accessUrls(readServiceAccess(readServiceSetup(runtime)), () => []) + .local; } -// URL/status are read-only: prefer the new file, but do not migrate on inspection. -function readableConfigFile(dir: string, name: string): string { - const path = join(dir, name); - assertSafeDataPath(path); - if (existsSync(path) || dir !== configDir()) return path; - const legacy = join( - legacyDataRoot(), - name === "roamgate.env" ? "herdr-gui.env" : name, - ); - assertSafeDataPath(legacy); - return legacy; -} - -// Mirrors the server's service env parser: leading whitespace, an optional -// `export` prefix, whitespace around `=`, and optionally quoted values; the -// last occurrence wins. -export function readServiceEnv( - contents: string, - name: string, -): string | undefined { - let value: string | undefined; - for (const line of contents.split(/\r?\n/)) { - const match = line.match( - new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*?)\\s*$`), - ); - if (!match) continue; - const raw = match[1] ?? ""; - value = - raw.length >= 2 && - (raw.startsWith("'") || raw.startsWith('"')) && - raw.at(-1) === raw[0] - ? raw.slice(1, -1) - : raw; +function urlText(): string { + try { + return serviceLoginUrl(); + } catch (error) { + return `unavailable (${(error as Error).message})`; } - return value; -} - -export function computeUrl(dir = configDir()): string { - const envFile = readableConfigFile(dir, "roamgate.env"); - let host = "127.0.0.1"; - let port = "8787"; - let usesFixedPassword = false; - if (existsSync(envFile)) { - const contents = readFileSync(envFile, "utf8"); - host = readServiceEnv(contents, "HOST") ?? host; - port = readServiceEnv(contents, "PORT") ?? port; - usesFixedPassword = - ( - readServiceEnv(contents, "ROAMGATE_PASSWORD") ?? - readServiceEnv(contents, "HERDR_GUI_PASSWORD") ?? - "" - ).length > 0; - } - const anyHost = host === "0.0.0.0" || host === "::"; - const browserHost = anyHost ? "localhost" : host; - const formatted = browserHost.includes(":") - ? `[${browserHost}]` - : browserHost; - let url = `http://${formatted}:${port}`; - const tokenPath = readableConfigFile(dir, "auth-token"); - if (!usesFixedPassword && existsSync(tokenPath)) { - const token = readFileSync(tokenPath, "utf8").trim(); - if (token) url = `${url}/?token=${encodeURIComponent(token)}`; - } - return url; } function printUrl(): number { - const envFile = readableConfigFile(configDir(), "roamgate.env"); - if (!existsSync(envFile)) { - console.error( - `roamgate plugin: no service environment at ${envFile}, showing defaults`, - ); + const runtime = currentRuntime(); + try { + const file = locateServiceConfig(runtime); + if (!file.configExists) { + console.error( + `roamgate plugin: no service environment at ${file.configPath}, showing defaults`, + ); + } + console.log(serviceLoginUrl(runtime)); + return 0; + } catch (error) { + console.error(`roamgate plugin: ${(error as Error).message}`); + return 1; } - console.log(computeUrl()); - return 0; } function statusText(binary: string): string { @@ -103,7 +58,7 @@ function statusText(binary: string): string { function panel(binary: string, version: string): Promise { if (!process.stdin.isTTY) { console.log(`Status: ${statusText(binary)}`); - console.log(`URL: ${computeUrl()}`); + console.log(`URL: ${urlText()}`); console.log(`Version: roamgate ${version}`); return Promise.resolve(0); } @@ -115,7 +70,7 @@ function panel(binary: string, version: string): Promise { "Roamgate", "", `Status: ${statusText(binary)}`, - `URL: ${computeUrl()}`, + `URL: ${urlText()}`, `Version: roamgate ${version}`, "", "[s] start [r] restart [u] uninstall [q] close", diff --git a/server/src/config/server-config.test.ts b/server/src/config/server-config.test.ts index f768571e..843d2f49 100644 --- a/server/src/config/server-config.test.ts +++ b/server/src/config/server-config.test.ts @@ -13,6 +13,7 @@ import { basename, dirname, join, resolve } from "node:path"; import { herdrConfigDir, nativeSocketPath, + resolveHerdrSocketPaths, browserUrlFor, loadServerConfig, loadServerTls, @@ -799,3 +800,31 @@ describe("optional PIN configuration", () => { } }); }); + +describe("resolveHerdrSocketPaths", () => { + test("prefers explicit paths, then SSH tunnels, then the session layout", () => { + expect( + resolveHerdrSocketPaths({ + socketPath: "/run/a.sock", + clientSocketPath: "/run/b.sock", + sshHost: "me@box", + }), + ).toEqual({ socketPath: "/run/a.sock", clientSocketPath: "/run/b.sock" }); + const tunnel = resolveHerdrSocketPaths({ sshHost: "me@box" }); + expect(tunnel.socketPath).toMatch(/roamgate-[a-f0-9]{12}-control\.sock$/); + expect(tunnel.clientSocketPath).toMatch( + /roamgate-[a-f0-9]{12}-client\.sock$/, + ); + if (process.platform !== "win32") { + expect( + resolveHerdrSocketPaths({ + session: "work", + herdrDir: "/h/.config/herdr", + }), + ).toEqual({ + socketPath: "/h/.config/herdr/sessions/work/herdr.sock", + clientSocketPath: "/h/.config/herdr/sessions/work/herdr-client.sock", + }); + } + }); +}); diff --git a/server/src/config/server-config.ts b/server/src/config/server-config.ts index e9fa6c93..2a27e9c5 100644 --- a/server/src/config/server-config.ts +++ b/server/src/config/server-config.ts @@ -83,10 +83,13 @@ const cliOptions = { "profile-duration": { type: "string" }, "profile-dir": { type: "string" }, open: { type: "boolean" }, - help: { type: "boolean" }, + help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "V" }, } as const; +/** Long server flags, for shell completion. */ +export const SERVER_FLAGS = Object.keys(cliOptions).map((name) => `--${name}`); + export function resolveServerLogLevel( cliValue: string | undefined, envValue: string | undefined, @@ -134,37 +137,9 @@ export function loadServerTls( } } -export function loadServerConfig(appVersion: string): ServerConfig { - let args: CliArgs; - try { - args = parseArgs({ - args: process.argv.slice(2), - options: cliOptions, - strict: true, - allowPositionals: false, - }).values as CliArgs; - } catch (e) { - console.error(`[bridge] ${(e as Error).message}`); - console.error("Run `roamgate --help` for usage."); - process.exit(2); - } - - if (args.help) { - console.log(`Roamgate — Web and PWA client for Herdr - -Usage: roamgate [options] - roamgate service - roamgate plugin - -Service actions: - install [--force] install and start the platform user service - status show service status - restart restart the managed service - reload reload its definition and restart the service - uninstall stop and remove the service definition - Run \`roamgate service --help\` for service details. - -Options (flags override env vars; ROAMGATE_* overrides HERDR_GUI_*): +/** The server's own flags, shared by `roamgate --help` and `roamgate help`. */ +export function serverOptionsHelp(): string { + return `Options (flags override env vars; ROAMGATE_* overrides HERDR_GUI_*): --host listen address (env HOST, default 127.0.0.1) --port listen port (env PORT, default 8787) --password fixed login password, ${MIN_PASSWORD_LENGTH}..${MAX_PASSWORD_LENGTH} characters @@ -185,8 +160,38 @@ Options (flags override env vars; ROAMGATE_* overrides HERDR_GUI_*): --profile-dir output parent directory (env ROAMGATE_PROFILE_DIR, default: Roamgate data dir/profiles) --open open browser on start (env OPEN_BROWSER=1) -V, --version show version - --help show this help -`); + -h, --help show this help +`; +} + +function serverHelp(): string { + return `Roamgate — Web and PWA client for Herdr + +Usage: roamgate [options] + +${serverOptionsHelp()}`; +} + +export function loadServerConfig( + appVersion: string, + options: { helpText?: () => string } = {}, +): ServerConfig { + let args: CliArgs; + try { + args = parseArgs({ + args: process.argv.slice(2), + options: cliOptions, + strict: true, + allowPositionals: false, + }).values as CliArgs; + } catch (e) { + console.error(`[bridge] ${(e as Error).message}`); + console.error("Run `roamgate --help` for usage."); + process.exit(2); + } + + if (args.help) { + console.log(options.helpText?.() ?? serverHelp()); process.exit(0); } @@ -302,8 +307,11 @@ Options (flags override env vars; ROAMGATE_* overrides HERDR_GUI_*): typeof args["client-socket-path"] === "string" || !!process.env.HERDR_CLIENT_SOCKET_PATH; - const socketPath = resolveSocketPath(args, sshHost, session); - const clientSocketPath = resolveClientSocketPath(args, sshHost, session); + const { socketPath, clientSocketPath } = resolveSocketPaths( + args, + sshHost, + session, + ); return { appVersion, @@ -349,11 +357,12 @@ function remoteTunnelLocalPath( export function herdrConfigDir( platform: string = process.platform, appData: string | null | undefined = process.env.APPDATA, + homeDir: string = homedir(), ): string { if (platform === "win32") { - return join(appData ?? join(homedir(), "AppData", "Roaming"), "herdr"); + return join(appData ?? join(homeDir, "AppData", "Roaming"), "herdr"); } - return join(homedir(), ".config", "herdr"); + return join(homeDir, ".config", "herdr"); } export function nativeSocketPath( @@ -371,40 +380,67 @@ export function nativeSocketPath( return `\\\\.\\pipe\\${socketPath}`; } -function resolveSocketPath( - args: CliArgs, - sshHost: string | undefined, - session: string | undefined, -): string { - const fromFlag = args["socket-path"]; - if (typeof fromFlag === "string") return nativeSocketPath(fromFlag); - if (process.env.HERDR_SOCKET_PATH) { - return nativeSocketPath(process.env.HERDR_SOCKET_PATH); - } - if (sshHost) return remoteTunnelLocalPath(sshHost, session, "control"); - const base = herdrConfigDir(); - const socketPath = session - ? resolve(base, "sessions", session, "herdr.sock") - : resolve(base, "herdr.sock"); - return nativeSocketPath(socketPath); +export type HerdrSocketSettings = { + socketPath?: string; + clientSocketPath?: string; + sshHost?: string; + session?: string; + /** Herdr's configuration directory; defaults to this process's user. */ + herdrDir?: string; +}; + +/** + * Resolve the control and render sockets from already-merged settings, so + * management commands can apply the service environment instead of the shell's. + */ +export function resolveHerdrSocketPaths(settings: HerdrSocketSettings): { + socketPath: string; + clientSocketPath: string; +} { + const resolveOne = ( + explicit: string | undefined, + kind: "control" | "client", + ) => { + if (explicit) return nativeSocketPath(explicit); + if (settings.sshHost) + return remoteTunnelLocalPath(settings.sshHost, settings.session, kind); + const base = settings.herdrDir ?? herdrConfigDir(); + const name = kind === "control" ? "herdr.sock" : "herdr-client.sock"; + const socketPath = settings.session + ? resolve(base, "sessions", settings.session, name) + : resolve(base, name); + return nativeSocketPath(socketPath); + }; + return { + socketPath: resolveOne(settings.socketPath, "control"), + clientSocketPath: resolveOne(settings.clientSocketPath, "client"), + }; } -function resolveClientSocketPath( +function resolveSocketPaths( args: CliArgs, sshHost: string | undefined, session: string | undefined, -): string { - const fromFlag = args["client-socket-path"]; - if (typeof fromFlag === "string") return nativeSocketPath(fromFlag); - if (process.env.HERDR_CLIENT_SOCKET_PATH) { - return nativeSocketPath(process.env.HERDR_CLIENT_SOCKET_PATH); - } - if (sshHost) return remoteTunnelLocalPath(sshHost, session, "client"); - const base = herdrConfigDir(); - const socketPath = session - ? resolve(base, "sessions", session, "herdr-client.sock") - : resolve(base, "herdr-client.sock"); - return nativeSocketPath(socketPath); +) { + // An explicit empty flag is still explicit and selects nativeSocketPath(""). + const socketPath = args["socket-path"]; + const clientSocketPath = args["client-socket-path"]; + const resolved = resolveHerdrSocketPaths({ + socketPath: socketPath || process.env.HERDR_SOCKET_PATH, + clientSocketPath: clientSocketPath || process.env.HERDR_CLIENT_SOCKET_PATH, + sshHost, + session, + }); + return { + socketPath: + typeof socketPath === "string" && !socketPath + ? nativeSocketPath(socketPath) + : resolved.socketPath, + clientSocketPath: + typeof clientSocketPath === "string" && !clientSocketPath + ? nativeSocketPath(clientSocketPath) + : resolved.clientSocketPath, + }; } function resolvePublicDir(args: CliArgs): string { diff --git a/server/src/config/service-manager.test.ts b/server/src/config/service-manager.test.ts index 20f16142..b57a5d52 100644 --- a/server/src/config/service-manager.test.ts +++ b/server/src/config/service-manager.test.ts @@ -15,7 +15,9 @@ import legacyDefinitions from "./service-legacy-definitions.test.json"; import { tmpdir } from "node:os"; import { runServiceCommand as runServiceCommandWithLegacyCheck, + launchdJobRunning, queryWindowsTask, + roamgateServiceStatus, SERVICE_COMMAND_CONTINUE, } from "./service-manager"; import { @@ -2113,3 +2115,57 @@ test.each(["12345", "1234567890123", "12345a"])( expect(existsSync(paths.definition)).toBe(false); }, ); + +describe("launchdJobRunning", () => { + test("reads only the job's own state, not nested endpoint states", () => { + const printed = (state: string) => + `gui/501/dev.roamgate = {\n\tactive count = 1\n\tstate = ${state}\n\tendpoints = {\n\t\t"x" = {\n\t\t\tstate = active\n\t\t}\n\t}\n}\n`; + expect(launchdJobRunning(printed("running"))).toBe(true); + expect(launchdJobRunning(printed("waiting"))).toBe(false); + expect(launchdJobRunning("")).toBe(false); + }); +}); + +describe("roamgateServiceStatus", () => { + test("reports a published legacy service that service commands manage", () => { + const homeDir = tempHome(); + const execPath = join(homeDir, "roamgate"); + const legacy = resolveLegacyServicePaths("systemd", homeDir); + mkdirSync(dirname(legacy.definition), { recursive: true }); + writeFileSync( + legacy.definition, + publishedLegacyDefinition("systemd", homeDir, execPath), + ); + const commands: string[][] = []; + const status = roamgateServiceStatus( + { platform: "linux", homeDir, execPath }, + (argv) => { + commands.push(argv); + return 0; + }, + ); + expect(status).toEqual({ + installed: true, + active: true, + definition: legacy.definition, + }); + expect(commands).toEqual([ + ["systemctl", "--user", "is-active", "--quiet", "herdr-gui.service"], + ]); + }); + + test("ignores a customized legacy definition", () => { + const homeDir = tempHome(); + const legacy = resolveLegacyServicePaths("systemd", homeDir); + mkdirSync(dirname(legacy.definition), { recursive: true }); + writeFileSync(legacy.definition, "[Service]\nExecStart=/custom\n"); + const status = roamgateServiceStatus( + { platform: "linux", homeDir, execPath: join(homeDir, "roamgate") }, + () => 0, + ); + expect(status.installed).toBe(false); + expect(status.definition).toBe( + resolveServicePaths("systemd", homeDir).definition, + ); + }); +}); diff --git a/server/src/config/service-manager.ts b/server/src/config/service-manager.ts index 723fbf74..723a52e9 100644 --- a/server/src/config/service-manager.ts +++ b/server/src/config/service-manager.ts @@ -47,7 +47,7 @@ type ServiceAction = "install" | "uninstall" | "status" | "restart" | "reload"; export const SERVICE_COMMAND_CONTINUE = Symbol("service-command-continue"); -interface ServiceRuntime { +export interface ServiceRuntime { platform: string; homeDir: string; appDataDir?: string; @@ -61,7 +61,7 @@ export type RunCommand = ( options?: { quiet?: boolean }, ) => number; -interface ServiceCommandDependencies { +export interface ServiceCommandDependencies { runtime?: ServiceRuntime; runCommand?: RunCommand; getLanIPs?: () => string[]; @@ -122,6 +122,7 @@ Usage: roamgate service restart roamgate service reload roamgate service uninstall + roamgate service logs [-f|--follow] [-n|--lines ] Linux uses a systemd user service. macOS uses a launchd LaunchAgent. Windows uses a per-user Task Scheduler task that starts at login. @@ -129,6 +130,7 @@ New services listen on 0.0.0.0 and use a generated login token by default. Install prints tokenized login URLs, preserves the platform-specific roamgate.env file, and starts the service. Reload re-reads the service definition and restarts the process. +Logs shows recent output (journalctl on Linux, ~/Library/Logs on macOS). Use --force only to replace an existing service definition not generated by roamgate. Uninstall preserves the environment file. `; @@ -344,8 +346,11 @@ function readEnvironmentValue( return value; } -function loadServiceEnvironmentFile(path: string) { - const contents = readFileSync(path, "utf8"); +function parseEnvironmentEntries( + contents: string, + path: string, +): Record { + const values: Record = Object.create(null); for (const [index, line] of contents.split(/\r?\n/).entries()) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; @@ -355,11 +360,33 @@ function loadServiceEnvironmentFile(path: string) { if (!match) { throw new Error(`invalid environment entry at ${path}:${index + 1}`); } - process.env[match[1]] = decodeEnvironmentValue(match[2] ?? ""); + values[match[1]] = decodeEnvironmentValue(match[2] ?? ""); } + return values; +} + +/** Read the values a managed service will receive, without executing anything. */ +export function parseServiceEnvironment( + contents: string, + path: string, + platform: ServicePlatform, + homeDir: string, +): Record { + if (platform !== "launchd") return parseEnvironmentEntries(contents, path); + const values = resolveLaunchdEnvironment(contents, path, homeDir); + // HOME is only a predefined expansion source, not a configured setting. + if (!/^\s*(?:export\s+)?HOME\s*=/m.test(contents)) delete values.HOME; + return values; +} + +function loadServiceEnvironmentFile(path: string) { + Object.assign( + process.env, + parseEnvironmentEntries(readFileSync(path, "utf8"), path), + ); } -interface ServiceAccess { +export interface ServiceAccess { host: string; port: number; tls: boolean; @@ -368,7 +395,8 @@ interface ServiceAccess { usesFixedPassword: boolean; } -function prepareServiceAccess( +/** Validate the access settings of a service environment file. */ +export function prepareServiceAccess( configPath: string, contents: string, platform: ServicePlatform, @@ -543,7 +571,7 @@ function assertNoLegacyService( function isPublishedLegacyDefinition( platform: ServicePlatform, - runtime: ServiceRuntime, + runtime: Pick, paths: ServicePaths, ): boolean { const binary = resolve(runtime.execPath); @@ -915,6 +943,93 @@ function runServiceAction( ); } +export type CaptureCommand = (argv: string[]) => { + code: number; + stdout: string; +}; + +export const defaultCaptureCommand: CaptureCommand = (argv) => { + const result = Bun.spawnSync(argv, { stdout: "pipe", stderr: "ignore" }); + return { code: result.exitCode ?? 1, stdout: result.stdout.toString() }; +}; + +/** + * `launchctl print` succeeds for any loaded job, including one that crashed + * or is throttled. The job's own state is its first one-tab `state =` line; + * nested sections are indented further. + */ +export function launchdJobRunning(printed: string): boolean { + return /^\tstate = (\S+)$/m.exec(printed)?.[1] === "running"; +} + +/** + * Whether the service definition that `service` commands manage exists and + * its manager runs it: the Roamgate one, else a published legacy one. + */ +export function roamgateServiceStatus( + runtime: Pick< + ServiceRuntime, + "platform" | "homeDir" | "appDataDir" | "uid" | "execPath" + >, + runCommand: RunCommand = defaultRunCommand, + captureCommand: CaptureCommand = defaultCaptureCommand, +): { installed: boolean; active: boolean; definition: string } { + const platform = resolveServicePlatform(runtime.platform); + let paths = resolveServicePaths( + platform, + runtime.homeDir, + runtime.appDataDir, + ); + if (!existsSync(paths.definition)) { + const legacy = resolveLegacyServicePaths( + platform, + runtime.homeDir, + runtime.appDataDir, + ); + assertSafeDataPath(legacy.definition); + if ( + existsSync(legacy.definition) && + isPublishedLegacyDefinition(platform, runtime, legacy) + ) { + paths = legacy; + } + } + const installed = existsSync(paths.definition); + const status = (active: boolean) => ({ + installed, + active, + definition: paths.definition, + }); + if (!installed) return status(false); + if (platform === "systemd") { + return status( + runCommand( + [ + "systemctl", + "--user", + "is-active", + "--quiet", + basename(paths.definition), + ], + { quiet: true }, + ) === 0, + ); + } + if (platform === "launchd") { + if (runtime.uid === undefined) return status(false); + const printed = captureCommand([ + "launchctl", + "print", + `gui/${runtime.uid}/${basename(paths.definition, ".plist")}`, + ]); + return status(printed.code === 0 && launchdJobRunning(printed.stdout)); + } + const query = queryWindowsTask(windowsTaskName(paths), (argv, options) => + runCommand(argv, { ...options, quiet: true }), + ); + return status(query.status === "exists" && query.active); +} + export function runServiceCommand( args: string[], dependencies: ServiceCommandDependencies = {}, diff --git a/server/src/config/session-secret.test.ts b/server/src/config/session-secret.test.ts index 53d3a2a3..9b4e0ee9 100644 --- a/server/src/config/session-secret.test.ts +++ b/server/src/config/session-secret.test.ts @@ -18,7 +18,11 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { dataRoot } from "./data-paths"; -import { loadOrCreateSessionSecret } from "./session-secret"; +import { + loadOrCreateSessionSecret, + revokeSessionSecret, + sessionSecretPath, +} from "./session-secret"; const tempDirs: string[] = []; const password = "correct horse battery staple"; @@ -463,3 +467,27 @@ describe("session signing secret", () => { } }, 10_000); }); + +describe("revokeSessionSecret", () => { + test("removes the secret so the next load signs with fresh material", () => { + const root = join(tempHome(), ".config", "roamgate"); + const path = sessionSecretPath(undefined, root); + expect(path).toBe(join(root, "session-secret.json")); + expect(revokeSessionSecret(path)).toBe(false); + const original = loadOrCreateSessionSecret([password], path); + expect(revokeSessionSecret(path)).toBe(true); + expect(existsSync(path)).toBe(false); + expect(loadOrCreateSessionSecret([password], path)).not.toBe(original); + }); + + test("keys the path by the settings file like startup does", () => { + const root = join(tempHome(), "data"); + const settings = join(root, "custom-settings.json"); + expect(sessionSecretPath(settings, root)).toBe( + join( + root, + `session-secret-${createHash("sha256").update(settings).digest("hex")}.json`, + ), + ); + }); +}); diff --git a/server/src/config/session-secret.ts b/server/src/config/session-secret.ts index b18ef512..ac336aaf 100644 --- a/server/src/config/session-secret.ts +++ b/server/src/config/session-secret.ts @@ -2,7 +2,6 @@ import { createHash, createHmac, randomBytes, - randomUUID, timingSafeEqual, } from "node:crypto"; import { @@ -11,18 +10,15 @@ import { constants, fchmodSync, fstatSync, - fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, - renameSync, rmSync, - writeFileSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { lockSync } from "proper-lockfile"; -import { assertSafeDataPath, dataRoot } from "./data-paths"; +import { assertSafeDataPath, dataRoot, replaceDataFile } from "./data-paths"; import { roamgateEnv } from "./environment"; const SECRET_PATTERN = /^[a-f0-9]{64}$/; @@ -133,58 +129,49 @@ function acquireSessionLock(path: string): () => void { } function writeSessionSecret(path: string, state: SessionSecretState): void { - const directory = dirname(path); - const temporaryPath = join( - directory, - `.roamgate-session-${randomUUID()}.tmp`, - ); - let fd: number | undefined; + replaceDataFile(path, `${JSON.stringify(state)}\n`); +} + +/** Persist independent signing material, rotating it when login credentials change. */ +export function sessionSecretPath( + settingsPath = roamgateEnv("SETTINGS_PATH"), + root = dataRoot(), +): string { + // Settings identify the instance; signing material stays in private storage. + const filename = settingsPath + ? `session-secret-${createHash("sha256").update(resolve(settingsPath)).digest("hex")}.json` + : "session-secret.json"; + return resolve(join(root, filename)); +} + +/** + * Discard the signing secret so the next startup signs with fresh material and + * every existing browser session becomes invalid. Returns false when absent. + */ +export function revokeSessionSecret(path = sessionSecretPath()): boolean { + path = resolve(path); + assertSafeDataPath(path); try { - fd = openSync( - temporaryPath, - constants.O_CREAT | - constants.O_EXCL | - constants.O_WRONLY | - (constants.O_NOFOLLOW ?? 0), - 0o600, - ); - writeFileSync(fd, `${JSON.stringify(state)}\n`); - fchmodSync(fd, 0o600); - fsyncSync(fd); - closeSync(fd); - fd = undefined; - assertSafeDataPath(path); - renameSync(temporaryPath, path); - // Flush the replacement directory entry as well as the file contents. - // Windows does not support opening directories for fsync this way. - if (process.platform !== "win32") { - const directoryFd = openSync( - directory, - constants.O_RDONLY | (constants.O_DIRECTORY ?? 0), - ); - try { - fsyncSync(directoryFd); - } finally { - closeSync(directoryFd); - } - } + lstatSync(dirname(path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + const release = acquireSessionLock(path); + try { + if (!readSessionSecret(path)) return false; + rmSync(path); + return true; } finally { - if (fd !== undefined) closeSync(fd); - rmSync(temporaryPath, { force: true }); + release(); } } -/** Persist independent signing material, rotating it when login credentials change. */ export function loadOrCreateSessionSecret( credentials: readonly string[], path?: string, ): string { - const settingsPath = roamgateEnv("SETTINGS_PATH"); - // Settings identify the instance; signing material stays in private storage. - const filename = settingsPath - ? `session-secret-${createHash("sha256").update(resolve(settingsPath)).digest("hex")}.json` - : "session-secret.json"; - path = resolve(path ?? join(dataRoot(), filename)); + path = resolve(path ?? sessionSecretPath()); assertSafeDataPath(path); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const release = acquireSessionLock(path); diff --git a/server/src/http/update.ts b/server/src/http/update.ts index f72be411..ae6bd987 100644 --- a/server/src/http/update.ts +++ b/server/src/http/update.ts @@ -1,7 +1,7 @@ import { basename } from "node:path"; import { roamgateEnv } from "../config/environment"; -type RunProcessWithCodeTimeout = ( +export type RunProcessWithCodeTimeout = ( argv: string[], timeoutMs: number, ) => Promise<{ code: number; stdout: string; stderr: string }>; @@ -43,7 +43,7 @@ const DEFAULT_UPDATE_BASE_URL = const UPDATE_METADATA_MAX_BYTES = 4096; const UPDATE_CHECK_CACHE_MS = 5 * 60 * 1000; const UPDATE_CHECK_TIMEOUT_MS = 15000; -const UPDATE_INSTALL_TIMEOUT_MS = 120000; +export const UPDATE_INSTALL_TIMEOUT_MS = 120000; function hasUpdateConfirmation(req: Request): boolean { return ( (req.headers.get("x-roamgate-update") ?? @@ -191,6 +191,163 @@ export function compareVersion(a: string, b: string): number { return 0; } +/** + * Verify and atomically install a release over targetPath, keeping the old + * executable as targetPath.previous. Shared by the in-app and CLI updaters. + */ +export function renderUpdateInstallScript({ + targetPath, + target, + manifest, + archiveUrl, + curlTransport, + shQuote, +}: { + targetPath: string; + target: UpdateTarget; + manifest: UpdateManifest; + archiveUrl: string; + curlTransport: string; + shQuote: (value: string) => string; +}): string { + return ` +set -eu +tmp="$(mktemp -d "\${TMPDIR:-/tmp}/roamgate-update.XXXXXX")" +target=${shQuote(targetPath)} +target_tmp="" +backup_tmp="" +if [ ! -f "$target" ] || [ -L "$target" ]; then + echo "running executable is not a regular file" >&2 + exit 1 +fi +cleanup() { + rm -rf "$tmp" + if [ -n "\${target_tmp:-}" ]; then + rm -f "$target_tmp" + fi + if [ -n "\${backup_tmp:-}" ]; then + rm -f "$backup_tmp" + fi +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +archive="$tmp/${target.archiveName}" +expected_sha256=${shQuote(manifest.sha256)} +curl ${curlTransport} -fsSL ${shQuote(archiveUrl)} -o "$archive" +if command -v shasum >/dev/null 2>&1; then + actual_sha256="$(shasum -a 256 "$archive" | awk 'NR == 1 { print $1 }')" +elif command -v sha256sum >/dev/null 2>&1; then + actual_sha256="$(sha256sum "$archive" | awk 'NR == 1 { print $1 }')" +else + echo "shasum or sha256sum is required to verify updates" >&2 + exit 1 +fi +if [ "$actual_sha256" != "$expected_sha256" ]; then + echo "downloaded archive checksum does not match update manifest" >&2 + exit 1 +fi +tar -xJf "$archive" -C "$tmp" \ + ${shQuote(`${target.packageDir}/VERSION`)} \ + ${shQuote(`${target.packageDir}/roamgate`)} +package_dir="$tmp/${target.packageDir}" +version_file="$package_dir/VERSION" +binary="$package_dir/roamgate" +if [ ! -d "$package_dir" ] || [ -L "$package_dir" ] || \ + [ ! -f "$version_file" ] || [ -L "$version_file" ] || \ + [ ! -f "$binary" ] || [ -L "$binary" ] || [ ! -x "$binary" ]; then + echo "downloaded archive does not contain regular package files" >&2 + exit 1 +fi +expected_version=${shQuote(manifest.version)} +package_name="" +actual_version="" +actual_platform="" +extra_version_field="" +read -r package_name actual_version actual_platform extra_version_field < "$version_file" +if [ "$package_name" != "roamgate" ] || \ + [ "$actual_version" != "$expected_version" ] || \ + [ "$actual_platform" != ${shQuote(target.platform)} ] || \ + [ -n "$extra_version_field" ]; then + echo "downloaded VERSION metadata does not match update manifest" >&2 + exit 1 +fi +binary_version="$("$binary" --version)" +if [ "$binary_version" != "roamgate $expected_version" ]; then + echo "downloaded binary version does not match update manifest" >&2 + exit 1 +fi +if [ ! -f "$target" ] || [ -L "$target" ]; then + echo "running executable changed during update" >&2 + exit 1 +fi +target_dir="$(dirname "$target")" +target_base="$(basename "$target")" +target_tmp="$(mktemp "$target_dir/.$target_base.new.XXXXXX")" +install -m 0755 "$binary" "$target_tmp" +if [ -f "$target" ] && [ ! -L "$target" ]; then + backup="$target.previous" + backup_tmp="$(mktemp "$target_dir/.$target_base.previous.XXXXXX")" + install -m 0755 "$target" "$backup_tmp" + mv -f "$backup_tmp" "$backup" + backup_tmp="" +fi +mv -f "$target_tmp" "$target" +target_tmp="" +`; +} + +export function curlTransportArgs(baseUrl: string): string[] { + const protocol = new URL(baseUrl).protocol === "https:" ? "=https" : "=http"; + return ["--proto", protocol, "--proto-redir", protocol]; +} + +/** Download, parse and check the latest manifest for one platform target. */ +export async function fetchLatestUpdateManifest({ + baseUrl, + target, + runProcessWithCodeTimeout, +}: { + baseUrl: string; + target: UpdateTarget; + runProcessWithCodeTimeout: RunProcessWithCodeTimeout; +}): Promise { + const result = await runProcessWithCodeTimeout( + [ + "curl", + ...curlTransportArgs(baseUrl), + "-fsSL", + "--max-filesize", + String(UPDATE_METADATA_MAX_BYTES), + `${baseUrl}/${target.manifestName}`, + ], + UPDATE_CHECK_TIMEOUT_MS, + ); + // Every Roamgate release has a manifest. Never probe legacy archives. + if (result.code !== 0) { + throw new Error( + ( + result.stderr || + result.stdout || + `update manifest download exited ${result.code}` + ) + .trim() + .slice(0, 500), + ); + } + const manifest = parseUpdateManifest(result.stdout); + if (manifest.platform !== target.platform) { + throw new Error( + `latest update platform is ${manifest.platform}, expected ${target.platform}`, + ); + } + if (manifest.archive !== target.archiveName) { + throw new Error( + `latest update archive is ${manifest.archive}, expected ${target.archiveName}`, + ); + } + return manifest; +} + export function isSupervisorManagedEnvironment( environment: Record, ): boolean { @@ -273,14 +430,8 @@ export function createUpdateHandlers({ }; } - function curlTransportArgs(): string[] { - const protocol = - new URL(updateBaseUrl()).protocol === "https:" ? "=https" : "=http"; - return ["--proto", protocol, "--proto-redir", protocol]; - } - function curlTransportCommand(): string { - return curlTransportArgs().map(shQuote).join(" "); + return curlTransportArgs(updateBaseUrl()).map(shQuote).join(" "); } function autoUpdateCapability(): { @@ -327,59 +478,17 @@ export function createUpdateHandlers({ } } - function processFailure( - result: { code: number; stdout: string; stderr: string }, - operation: string, - ): Error { - return new Error( - (result.stderr || result.stdout || `${operation} exited ${result.code}`) - .trim() - .slice(0, 500), - ); - } - - function validateUpdateManifest(manifest: UpdateManifest): UpdateManifest { - if (!updateTarget) { - throw new Error( - `no update package is available for ${runtime.platform}-${runtime.arch}`, - ); - } - if (manifest.platform !== updateTarget.platform) { - throw new Error( - `latest update platform is ${manifest.platform}, expected ${updateTarget.platform}`, - ); - } - if (manifest.archive !== updateTarget.archiveName) { - throw new Error( - `latest update archive is ${manifest.archive}, expected ${updateTarget.archiveName}`, - ); - } - return manifest; - } - async function loadLatestUpdateManifest(): Promise { if (!updateTarget) { throw new Error( `no update package is available for ${runtime.platform}-${runtime.arch}`, ); } - - const manifestResult = await runProcessWithCodeTimeout( - [ - "curl", - ...curlTransportArgs(), - "-fsSL", - "--max-filesize", - String(UPDATE_METADATA_MAX_BYTES), - updateManifestUrl(), - ], - UPDATE_CHECK_TIMEOUT_MS, - ); - // Every Roamgate release has a manifest. Never probe legacy archives. - if (manifestResult.code !== 0) { - throw processFailure(manifestResult, "update manifest download"); - } - return validateUpdateManifest(parseUpdateManifest(manifestResult.stdout)); + return fetchLatestUpdateManifest({ + baseUrl: updateBaseUrl(), + target: updateTarget, + runProcessWithCodeTimeout, + }); } async function readLatestUpdateManifest( @@ -519,90 +628,14 @@ export function createUpdateHandlers({ }); } - const command = ` -set -eu -tmp="$(mktemp -d "\${TMPDIR:-/tmp}/roamgate-update.XXXXXX")" -target=${shQuote(capability.targetPath)} -target_tmp="" -backup_tmp="" -if [ ! -f "$target" ] || [ -L "$target" ]; then - echo "running executable is not a regular file" >&2 - exit 1 -fi -cleanup() { - rm -rf "$tmp" - if [ -n "\${target_tmp:-}" ]; then - rm -f "$target_tmp" - fi - if [ -n "\${backup_tmp:-}" ]; then - rm -f "$backup_tmp" - fi -} -trap cleanup EXIT -trap 'exit 1' HUP INT TERM -archive="$tmp/${updateTarget.archiveName}" -expected_sha256=${shQuote(latest.sha256)} -curl ${curlTransportCommand()} -fsSL ${shQuote(updateArchiveUrl())} -o "$archive" -if command -v shasum >/dev/null 2>&1; then - actual_sha256="$(shasum -a 256 "$archive" | awk 'NR == 1 { print $1 }')" -elif command -v sha256sum >/dev/null 2>&1; then - actual_sha256="$(sha256sum "$archive" | awk 'NR == 1 { print $1 }')" -else - echo "shasum or sha256sum is required to verify updates" >&2 - exit 1 -fi -if [ "$actual_sha256" != "$expected_sha256" ]; then - echo "downloaded archive checksum does not match update manifest" >&2 - exit 1 -fi -tar -xJf "$archive" -C "$tmp" \ - ${shQuote(`${updateTarget.packageDir}/VERSION`)} \ - ${shQuote(`${updateTarget.packageDir}/roamgate`)} -package_dir="$tmp/${updateTarget.packageDir}" -version_file="$package_dir/VERSION" -binary="$package_dir/roamgate" -if [ ! -d "$package_dir" ] || [ -L "$package_dir" ] || \ - [ ! -f "$version_file" ] || [ -L "$version_file" ] || \ - [ ! -f "$binary" ] || [ -L "$binary" ] || [ ! -x "$binary" ]; then - echo "downloaded archive does not contain regular package files" >&2 - exit 1 -fi -expected_version=${shQuote(latest.version)} -package_name="" -actual_version="" -actual_platform="" -extra_version_field="" -read -r package_name actual_version actual_platform extra_version_field < "$version_file" -if [ "$package_name" != "roamgate" ] || \ - [ "$actual_version" != "$expected_version" ] || \ - [ "$actual_platform" != ${shQuote(updateTarget.platform)} ] || \ - [ -n "$extra_version_field" ]; then - echo "downloaded VERSION metadata does not match update manifest" >&2 - exit 1 -fi -binary_version="$("$binary" --version)" -if [ "$binary_version" != "roamgate $expected_version" ]; then - echo "downloaded binary version does not match update manifest" >&2 - exit 1 -fi -if [ ! -f "$target" ] || [ -L "$target" ]; then - echo "running executable changed during update" >&2 - exit 1 -fi -target_dir="$(dirname "$target")" -target_base="$(basename "$target")" -target_tmp="$(mktemp "$target_dir/.$target_base.new.XXXXXX")" -install -m 0755 "$binary" "$target_tmp" -if [ -f "$target" ] && [ ! -L "$target" ]; then - backup="$target.previous" - backup_tmp="$(mktemp "$target_dir/.$target_base.previous.XXXXXX")" - install -m 0755 "$target" "$backup_tmp" - mv -f "$backup_tmp" "$backup" - backup_tmp="" -fi -mv -f "$target_tmp" "$target" -target_tmp="" -`; + const command = renderUpdateInstallScript({ + targetPath: capability.targetPath, + target: updateTarget, + manifest: latest, + archiveUrl: updateArchiveUrl(), + curlTransport: curlTransportCommand(), + shQuote, + }); const result = await runProcessWithCodeTimeout( ["sh", "-c", command], UPDATE_INSTALL_TIMEOUT_MS, diff --git a/server/src/index.ts b/server/src/index.ts index 032df829..745539f3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,12 +22,8 @@ import { openBrowser, withLoginToken, } from "./config/server-config"; -import { - runServiceCommand, - SERVICE_COMMAND_CONTINUE, -} from "./config/service-manager"; -import { runHerdrCommand } from "./herdr/cli"; -import { runPluginCommand } from "./config/plugin-command"; +import { cliCommands, runCli, topLevelHelp } from "./cli"; +import { CONTINUE_TO_SERVER, defaultCliContext } from "./cli/command"; import { enrichIntegrationVersions } from "./herdr/integration-versions"; import { createHerdrSetupHandlers, @@ -110,25 +106,21 @@ import { } from "./worktree/remove"; const APP_VERSION = packageJson.version; -const pluginCommandResult = await runPluginCommand( +const cliCommandList = cliCommands(); +const cliResult = await runCli( process.argv.slice(2), - APP_VERSION, + defaultCliContext(APP_VERSION), + cliCommandList, ); -if (pluginCommandResult !== null) process.exit(pluginCommandResult); -const serviceCommandResult = runServiceCommand(process.argv.slice(2)); -if (serviceCommandResult === SERVICE_COMMAND_CONTINUE) { +if (cliResult === CONTINUE_TO_SERVER) { + // `service run ` loaded the service environment; start the listener. process.argv.splice(2); -} else if (serviceCommandResult !== null) { - process.exit(serviceCommandResult); +} else if (cliResult !== null) { + process.exit(cliResult); } -const herdrCommandResult = await runHerdrCommand( - process.argv.slice(2), - APP_VERSION, -); -if (herdrCommandResult !== null) { - process.exit(herdrCommandResult); -} -const config = loadServerConfig(APP_VERSION); +const config = loadServerConfig(APP_VERSION, { + helpText: () => topLevelHelp(cliCommandList), +}); // Only a listener startup may rotate sessions. Management commands also read // ServerConfig, sometimes with a different environment from the running service. let sessionSecret = ""; diff --git a/server/src/notifications/web-push.ts b/server/src/notifications/web-push.ts index 8e1490bf..015afbaa 100644 --- a/server/src/notifications/web-push.ts +++ b/server/src/notifications/web-push.ts @@ -23,11 +23,11 @@ export interface PushPreferences { completed: boolean; blocked: boolean; } -interface Device { +export interface Device { subscription: webpush.PushSubscription; preferences: PushPreferences; } -interface Registry { +export interface Registry { version: 1; publicKey: string; privateKey: string; @@ -164,6 +164,81 @@ export function validatePushDevice(value: unknown): Device { }; } +/** An empty subject disables push; unset restores the project contact. */ +export function webPushSubject( + environment: Record = process.env, +): string { + return ( + roamgateEnv("WEB_PUSH_SUBJECT", environment) ?? + "https://github.com/powerfooI/roamgate/issues" + ); +} + +export function webPushPath( + environment: Record = process.env, + root = dataRoot(), +): string { + return ( + roamgateEnv("WEB_PUSH_PATH", environment) ?? join(root, "web-push.json") + ); +} + +export function assertValidPushSubject(subject: string): void { + const contact = new URL(subject); + if ( + !( + contact.protocol === "https:" || + (contact.protocol === "mailto:" && contact.pathname.includes("@")) + ) + ) + throw new Error("Invalid VAPID subject"); +} + +/** Load and fully validate an existing registry without creating or changing it. */ +export function readPushRegistry(path: string): Registry { + assertSafeDataPath(path); + if (statSync(path).size > 1024 * 1024) + throw new Error("Push registry too large"); + const stored = JSON.parse(readFileSync(path, "utf8")) as Registry; + if ( + stored.version !== 1 || + !Array.isArray(stored.devices) || + stored.devices.length > MAX_DEVICES + ) + throw new Error("Invalid push registry"); + const ecdh = createECDH("prime256v1"); + ecdh.setPrivateKey(keyBytes(stored.privateKey, 32)); + if (ecdh.getPublicKey().toString("base64url") !== stored.publicKey) + throw new Error("Invalid VAPID key pair"); + return { ...stored, devices: stored.devices.map(validatePushDevice) }; +} + +/** Deliver one encrypted message without following redirects. */ +export const sendPushRequest: typeof webpush.sendNotification = async ( + subscription, + payload, + settings, +) => { + const details = webpush.generateRequestDetails( + subscription, + payload ?? undefined, + settings, + ); + const response = await fetch(details.endpoint, { + method: details.method, + headers: details.headers, + body: Uint8Array.from(details.body), + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + await response.body?.cancel(); + if (!response.ok) + throw Object.assign(new Error("Push provider rejected delivery"), { + statusCode: response.status, + }); + return { statusCode: response.status, body: "", headers: {} }; +}; + export function createWebPushService( options: { subject?: string; @@ -172,36 +247,9 @@ export function createWebPushService( warn?: (message: string) => void; } = {}, ) { - const subject = - options.subject ?? - roamgateEnv("WEB_PUSH_SUBJECT") ?? - "https://github.com/powerfooI/roamgate/issues"; - const path = - options.path ?? - roamgateEnv("WEB_PUSH_PATH") ?? - join(dataRoot(), "web-push.json"); - const send: typeof webpush.sendNotification = - options.send ?? - (async (subscription, payload, settings) => { - const details = webpush.generateRequestDetails( - subscription, - payload ?? undefined, - settings, - ); - const response = await fetch(details.endpoint, { - method: details.method, - headers: details.headers, - body: Uint8Array.from(details.body), - redirect: "error", - signal: AbortSignal.timeout(10_000), - }); - await response.body?.cancel(); - if (!response.ok) - throw Object.assign(new Error("Push provider rejected delivery"), { - statusCode: response.status, - }); - return { statusCode: response.status, body: "", headers: {} }; - }); + const subject = options.subject ?? webPushSubject(); + const path = options.path ?? webPushPath(); + const send: typeof webpush.sendNotification = options.send ?? sendPushRequest; const warn = options.warn ?? (() => {}); // ponytail: one process owns this file; use a transactional store for shared deployments. let registry: Registry | null = null; @@ -228,14 +276,7 @@ export function createWebPushService( if (subject) { try { - const contact = new URL(subject); - if ( - !( - contact.protocol === "https:" || - (contact.protocol === "mailto:" && contact.pathname.includes("@")) - ) - ) - throw new Error("Invalid VAPID subject"); + assertValidPushSubject(subject); assertSafeDataPath(path); if (!existsSync(path)) publishDataFile( @@ -246,20 +287,7 @@ export function createWebPushService( devices: [], }), ); - if (statSync(path).size > 1024 * 1024) - throw new Error("Push registry too large"); - const stored = JSON.parse(readFileSync(path, "utf8")) as Registry; - if ( - stored.version !== 1 || - !Array.isArray(stored.devices) || - stored.devices.length > MAX_DEVICES - ) - throw new Error("Invalid push registry"); - const ecdh = createECDH("prime256v1"); - ecdh.setPrivateKey(keyBytes(stored.privateKey, 32)); - if (ecdh.getPublicKey().toString("base64url") !== stored.publicKey) - throw new Error("Invalid VAPID key pair"); - registry = { ...stored, devices: stored.devices.map(validatePushDevice) }; + registry = readPushRegistry(path); chmodSync(path, 0o600); } catch { registry = null;