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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 34 additions & 4 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<arch>.tar.xz` and `.sha256`
files from the [latest release](https://github.com/powerfooI/roamgate/releases/latest),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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 <command> --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 <host>]` | 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 <count>]` | 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
Expand Down Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
115 changes: 115 additions & 0 deletions server/src/cli/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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}$/,
);
});
});
Loading
Loading