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
8 changes: 7 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ The bundle uses scrypt (N=32768, r=8, p=1) → AES-256-GCM. Salt + nonce are ran
**What's included:**

- Every profile's `baseUrl`, `keyPrefix`, full `mantis_live_…` API key, Cloudflare Access mode + app URL + Service-Auth client-id/secret, linked edge worker URL, and edge AES key
- All locally stored edge worker keys, including workers without a server profile. Edge-only installs can use backup/restore without logging into a server. Existing edge keys are kept on restore unless `--overwrite` is set. Older backup files remain supported.
- The active-profile pointer
- Plugin manifest: each plugin's `name`, `source` (GitHub `owner/repo`), pinned commit SHA, and version. **`restore` re-installs plugins via `mantis plugin add <source>@<ref>`** — the bundle does NOT carry the plugin contents themselves, so the new machine needs network access to GitHub for the re-install.

Expand All @@ -393,12 +394,17 @@ The bundle uses scrypt (N=32768, r=8, p=1) → AES-256-GCM. Salt + nonce are ran
- Local-path plugins (`mantis plugin add ./some/path`) — those aren't reproducible on another machine; `backup` lists them as skipped.
- `~/.cloudflared/` cached JWTs — owned by `cloudflared`, regenerated on next login.

Full backup lists the edge worker URLs it included. Independent keys require
keychain enumeration support; if your platform cannot enumerate entries,
linked profile keys are still included. Check that list before migrating an
edge-only setup and keep a separate vaulted copy of any omitted key.

**Flag reference:**

| Flag | What it does |
|---|---|
| `mantis backup --out <file>` | Where to write the bundle. Default `./mantis-backup.json`. |
| `mantis backup --only <name>` | Back up just one profile. Default is all profiles. |
| `mantis backup --only <name>` | Back up just one profile and its linked edge worker. Independent edge workers are omitted; the command reports this scope. Default includes all profiles and discoverable edge worker keys. |
| `mantis backup --passphrase-stdin` | Read passphrase from stdin (for scripts piping a vault into the CLI). |
| `mantis backup --passphrase-env <VAR>` | Read passphrase from the named env var. |
| `mantis restore <file>` | Decrypt + restore. By default, existing profiles on the target machine are kept; bundle entries with the same name are skipped. |
Expand Down
43 changes: 39 additions & 4 deletions cli/src/commands/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
setProfile,
useProfile,
} from "../lib/config.js";
import { setEdgeKey } from "../lib/edge-key.js";
import { getEdgeKey, setEdgeKey } from "../lib/edge-key.js";
import {
collectBackupPayload,
collectSkippedLocalPlugins,
Expand Down Expand Up @@ -74,6 +74,12 @@ export async function backupCmd(opts: BackupCmdOpts): Promise<void> {
process.stderr.write(
` ${c.dim("plugins: ")} ${payload.plugins.length === 0 ? c.dim("(none)") : payload.plugins.map((p) => p.name).join(", ")}\n`,
);
process.stderr.write(
` ${c.dim("edge workers:")} ${payload.edgeWorkers?.map((worker) => worker.workerUrl).join(", ") || c.dim("(none)")}\n`,
);
if (opts.profile) {
process.stderr.write(` ${c.dim("scope:")} only profile ${opts.profile} and its linked worker; omit --only to include independent edge workers.\n`);
}
if (skippedLocalPlugins.length > 0) {
process.stderr.write(
` ${c.yellow("note:")} skipped ${skippedLocalPlugins.length} local-path plugin(s) (not reproducible on another machine): ${skippedLocalPlugins.join(", ")}\n`,
Expand All @@ -88,6 +94,8 @@ export async function backupCmd(opts: BackupCmdOpts): Promise<void> {
profiles: payload.profiles.map((p) => p.name),
plugins: payload.plugins.length,
skipped_local_plugins: skippedLocalPlugins,
edge_workers: payload.edgeWorkers?.map((worker) => worker.workerUrl) ?? [],
scope: opts.profile ?? "all",
},
);
}
Expand Down Expand Up @@ -159,6 +167,29 @@ export async function restoreCmd(
}
}

const edgeRestored: string[] = [];
const edgeSkipped: string[] = [];
const edgeErrors: Array<{ worker: string; reason: string }> = [];
// Legacy v1 files embedded keys in profiles. Apply the same protection to
// those keys as independent worker entries, even when the profile is new.
const workerKeys = new Map<string, string>();
for (const profile of payload.profiles) {
if (profile.edgeWorkerUrl && profile.edgeKey) workerKeys.set(profile.edgeWorkerUrl, profile.edgeKey);
}
for (const { workerUrl, key } of payload.edgeWorkers ?? []) workerKeys.set(workerUrl, key);
for (const [workerUrl, key] of workerKeys) {
if (getEdgeKey(workerUrl) && !opts.overwrite) {
edgeSkipped.push(workerUrl);
continue;
}
try {
setEdgeKey(workerUrl, key);
edgeRestored.push(workerUrl);
} catch (err) {
edgeErrors.push({ worker: workerUrl, reason: err instanceof Error ? err.message : String(err) });
}
}

// Restore active-profile pointer if the backup specified one AND we
// actually restored it AND the user didn't already have a different
// current profile they care about.
Expand Down Expand Up @@ -202,6 +233,9 @@ export async function restoreCmd(
`${c.red("✗")} ${e.name}: ${e.reason}\n`,
);
}
if (edgeRestored.length > 0) process.stderr.write(`${c.green("✓")} restored edge keys: ${edgeRestored.join(", ")}\n`);
if (edgeSkipped.length > 0) process.stderr.write(`${c.yellow("·")} kept existing edge keys (pass --overwrite to replace): ${edgeSkipped.join(", ")}\n`);
for (const e of edgeErrors) process.stderr.write(`${c.red("✗")} edge ${e.worker}: ${e.reason}\n`);
if (!opts.skipPlugins) {
if (pluginsRestored.length > 0) {
process.stderr.write(
Expand Down Expand Up @@ -234,8 +268,12 @@ export async function restoreCmd(
plugins_restored: pluginsRestored,
plugins_failed: pluginsFailed,
active_profile: payload.currentProfile,
edge_restored: edgeRestored,
edge_skipped: edgeSkipped,
edge_errors: edgeErrors,
},
);
if (errors.length || edgeErrors.length || pluginsFailed.length) process.exitCode = 1;
}

async function applyProfile(bp: BackupProfile): Promise<void> {
Expand All @@ -245,9 +283,6 @@ async function applyProfile(bp: BackupProfile): Promise<void> {
// about this profile" marker).
setKey(entry.baseUrl, secrets.apiKey);
if (secrets.cf) setCloudflareServiceAuth(entry.baseUrl, secrets.cf);
if (secrets.edgeKey && entry.edgeWorkerUrl) {
setEdgeKey(entry.edgeWorkerUrl, secrets.edgeKey);
}
await setProfile(bp.name, entry);
}

Expand Down
6 changes: 3 additions & 3 deletions cli/src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,17 @@ export async function deviceNewCmd(opts: DeviceNewOpts): Promise<void> {
emit(
() => {
process.stderr.write(
`${c.green("✓")} ${c.bold(device)} armed — ${minted.length} alarm(s)\n`,
`${c.green("✓")} ${c.bold(device)} ${installed ? "armed" : "minted"} — ${minted.length} alarm(s)\n`,
);
for (const m of minted) {
process.stderr.write(` ${c.dim(m.memo)}\n ${c.cyan(m.url)}\n`);
}
if (bundlePath) {
process.stderr.write(`\n${c.green("✓")} bundle → ${c.cyan(bundlePath)}\n`);
}
if (!opts.install && !opts.bundle) {
if (!installed) {
process.stderr.write(
`\n${c.dim("Nothing installed. Re-run with --bundle <path> for a zip, or --install to apply here.")}\n`,
`\n${c.dim(bundlePath ? "Nothing installed. Run the install script in the exported bundle to activate these alarms." : opts.install ? "Installation canceled. Nothing installed; use the staged bundle path above to install later." : "Nothing installed. Re-run with --bundle <path> for a zip, or --install to apply here.")}\n`,
);
}
},
Expand Down
6 changes: 3 additions & 3 deletions cli/src/commands/edge-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export async function edgeDeviceCmd(opts: EdgeDeviceOpts): Promise<void> {
emit(
() => {
process.stderr.write(
`${c.green("✓")} ${c.bold(device)} armed (edge) — ${minted.length} alarm(s)\n`,
`${c.green("✓")} ${c.bold(device)} ${installed ? "armed" : "minted"} (edge) — ${minted.length} alarm(s)\n`,
);
for (const m of minted) {
process.stderr.write(` ${c.dim(m.memo)}\n ${c.cyan(m.url)}\n`);
Expand All @@ -213,9 +213,9 @@ export async function edgeDeviceCmd(opts: EdgeDeviceOpts): Promise<void> {
`${c.yellow("not on edge:")} ${v.slug} normally dedupes hits in a ${v.dedupeWindowSeconds}s window server-side; the stateless worker cannot remember the last hit, so expect bursts (e.g. Wi-Fi roams) to notify several times.\n`,
);
}
if (!opts.install && !bundleDir) {
if (!installed) {
process.stderr.write(
`\n${c.dim("Nothing installed. Re-run with --bundle <dir> for an install directory, or --install to apply here.")}\n`,
`\n${c.dim(bundleDir ? "Nothing installed. Run the install script in the exported bundle to activate these alarms." : opts.install ? "Installation canceled. Nothing installed; use the staged bundle path above to install later." : "Nothing installed. Re-run with --bundle <dir> for an install directory, or --install to apply here.")}\n`,
);
}
},
Expand Down
14 changes: 9 additions & 5 deletions cli/src/commands/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,8 @@ export async function installCmd(
async function renderInstaller(
url: string,
opts: InstallerOpts,
): Promise<{ filename: string; written: string | null }> {
emitResult = true,
): Promise<{ filename: string; written: string | null; content?: string; mime: string }> {
const type = opts.type;
if (!isInstallType(type)) {
fail(
Expand Down Expand Up @@ -474,7 +475,7 @@ async function renderInstaller(
writtenTo = target;
}

emit(
if (emitResult || !isJsonMode()) emit(
() => {
if (writtenTo) {
process.stderr.write(
Expand All @@ -497,7 +498,7 @@ async function renderInstaller(
},
);

return { filename: installer.filename, written: writtenTo };
return { filename: installer.filename, written: writtenTo, content: writtenTo ? undefined : content, mime: installer.mime };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -616,15 +617,15 @@ export async function mintCmd(opts: MintOpts): Promise<void> {
// Chained installer (--install <type>) runs after URL is produced. We
// render it inline here so the user gets URL → test → installer in one
// coherent stream, rather than spawning a second command.
let installResult: { filename: string; written: string | null } | null = null;
let installResult: Awaited<ReturnType<typeof renderInstaller>> | null = null;
if (opts.install) {
installResult = await renderInstaller(url, {
type: opts.install,
out: opts.out,
sshOnly: opts.sshOnly,
hostname: opts.hostname,
memo: opts.memo,
});
}, false);
}

emit(
Expand Down Expand Up @@ -667,6 +668,9 @@ export async function mintCmd(opts: MintOpts): Promise<void> {
installer: {
type: opts.install,
written_to: installResult.written,
filename: installResult.filename,
mime: installResult.mime,
content: installResult.content,
},
}
: {}),
Expand Down
28 changes: 27 additions & 1 deletion cli/src/lib/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
type StoredConfig,
type CloudflareAccessMode,
} from "./config.js";
import { getEdgeKey } from "./edge-key.js";
import { getEdgeKey, listEdgeKeyWorkers } from "./edge-key.js";
import { readLockfile } from "./plugins/lockfile.js";

// Manually promisify so we control the options-arg signature. Node's
Expand Down Expand Up @@ -120,6 +120,8 @@ export type BackupPayload = {
currentProfile?: string;
profiles: BackupProfile[];
plugins: BackupPlugin[];
/** Independent worker keys, including edge-only setups. Absent in older v1 bundles. */
edgeWorkers?: Array<{ workerUrl: string; key: string }>;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -200,6 +202,21 @@ export async function collectBackupPayload(
);
}

// A worker does not need a server profile. Keep its key independently so
// edge-only setups and additional workers survive migration too. A scoped
// backup includes only the selected profile's linked worker.
const workerUrls = new Set(
filtered.flatMap(({ entry }) => entry.edgeWorkerUrl ? [entry.edgeWorkerUrl] : []),
);
if (onlyProfile === undefined) {
for (const workerUrl of await listEdgeKeyWorkers()) workerUrls.add(workerUrl);
}
const edgeWorkers: NonNullable<BackupPayload["edgeWorkers"]> = [];
for (const workerUrl of workerUrls) {
const key = getEdgeKey(workerUrl);
if (key) edgeWorkers.push({ workerUrl, key });
}

const lock = await readLockfile();
const plugins: BackupPlugin[] = lock.plugins
// Local-path plugins aren't reproducible on another machine — skip them
Expand All @@ -219,6 +236,7 @@ export async function collectBackupPayload(
onlyProfile === undefined ? current ?? undefined : onlyProfile,
profiles: backupProfiles,
plugins,
edgeWorkers,
};
}

Expand Down Expand Up @@ -436,6 +454,14 @@ function assertPayload(v: unknown): BackupPayload {
if (!Array.isArray(o.plugins)) {
throw new Error("payload.plugins must be an array");
}
if (o.edgeWorkers !== undefined) {
if (!Array.isArray(o.edgeWorkers) || o.edgeWorkers.some((worker) =>
typeof worker !== "object" || worker === null ||
typeof worker.workerUrl !== "string" || typeof worker.key !== "string"
)) {
throw new Error("payload.edgeWorkers must contain workerUrl and key strings");
}
}
return o as unknown as BackupPayload;
}

Expand Down
72 changes: 71 additions & 1 deletion cli/tests/backup-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
Expand Down Expand Up @@ -146,6 +146,76 @@ async function wipeState(): Promise<void> {
// ---------------------------------------------------------------------------

describe("mantis backup → mantis restore round-trip", () => {
it("round-trips independent edge keys without any server profile and preserves existing keys", async () => {
const outPath = join(tmpHome, "edge-only.json");
process.env.MANTIS_BACKUP_TEST_PASS = "edge-only-passphrase";
const { setEdgeKey, getEdgeKey } = await import("../src/lib/edge-key.js");
const worker = "https://standalone-edge.workers.dev";
setEdgeKey(worker, "original-edge-key");
const { backupCmd, restoreCmd } = await import("../src/commands/backup.js");
await backupCmd({ out: outPath, passphraseEnv: "MANTIS_BACKUP_TEST_PASS" });
await wipeState();
await restoreCmd(outPath, { passphraseEnv: "MANTIS_BACKUP_TEST_PASS" });
expect(getEdgeKey(worker)).toBe("original-edge-key");
const config = await import("../src/lib/config.js");
expect(await config.readConfig()).toBeNull();
setEdgeKey(worker, "newer-local-key");
await restoreCmd(outPath, { passphraseEnv: "MANTIS_BACKUP_TEST_PASS" });
expect(getEdgeKey(worker)).toBe("newer-local-key");
await restoreCmd(outPath, { passphraseEnv: "MANTIS_BACKUP_TEST_PASS", overwrite: true });
expect(getEdgeKey(worker)).toBe("original-edge-key");
delete process.env.MANTIS_BACKUP_TEST_PASS;
}, 15_000);

it("scopes --only to the selected profile's linked worker", async () => {
await populateState();
const { setEdgeKey } = await import("../src/lib/edge-key.js");
setEdgeKey("https://independent.workers.dev", "independent-key");
const { backupCmd } = await import("../src/commands/backup.js");
const { openBundle } = await import("../src/lib/backup.js");
const outPath = join(tmpHome, "scoped.json");
process.env.MANTIS_BACKUP_TEST_PASS = "scope-passphrase";
await backupCmd({ out: outPath, profile: "primary", passphraseEnv: "MANTIS_BACKUP_TEST_PASS" });
const payload = await openBundle(JSON.parse(await readFile(outPath, "utf8")), "scope-passphrase");
expect(payload.edgeWorkers?.map((worker) => worker.workerUrl)).toEqual(["https://primary-edge.workers.dev"]);
expect(vi.mocked(process.stderr.write).mock.calls.join(" ")).toContain("omit --only to include independent edge workers");
delete process.env.MANTIS_BACKUP_TEST_PASS;
});

it("restores old v1 bundles with edge keys embedded only in profiles", async () => {
await populateState();
const { collectBackupPayload, sealBundle } = await import("../src/lib/backup.js");
const payload = await collectBackupPayload(undefined);
delete payload.edgeWorkers;
const outPath = join(tmpHome, "legacy.json");
await writeFile(outPath, JSON.stringify(await sealBundle(payload, "legacy-passphrase")));
await wipeState();
process.env.MANTIS_BACKUP_TEST_PASS = "legacy-passphrase";
const { restoreCmd } = await import("../src/commands/backup.js");
await restoreCmd(outPath, { passphraseEnv: "MANTIS_BACKUP_TEST_PASS" });
const { getEdgeKey } = await import("../src/lib/edge-key.js");
expect(getEdgeKey("https://primary-edge.workers.dev")).toBe("MGYWRl0WT3RcVuQrMQuv4Ph9DcZakhfwHcZk0lszKnE");
delete process.env.MANTIS_BACKUP_TEST_PASS;
});

it.each([false, true])("protects an existing independent worker key when restoring legacy profiles (overwrite=%s)", async (overwrite) => {
await populateState();
const { collectBackupPayload, sealBundle } = await import("../src/lib/backup.js");
const payload = await collectBackupPayload(undefined);
delete payload.edgeWorkers;
const outPath = join(tmpHome, "legacy-existing-worker.json");
await writeFile(outPath, JSON.stringify(await sealBundle(payload, "legacy-passphrase")));
await wipeState();
const { setEdgeKey, getEdgeKey } = await import("../src/lib/edge-key.js");
const worker = "https://primary-edge.workers.dev";
setEdgeKey(worker, "newer-independent-key");
process.env.MANTIS_BACKUP_TEST_PASS = "legacy-passphrase";
const { restoreCmd } = await import("../src/commands/backup.js");
await restoreCmd(outPath, { passphraseEnv: "MANTIS_BACKUP_TEST_PASS", overwrite });
expect(getEdgeKey(worker)).toBe(overwrite ? "MGYWRl0WT3RcVuQrMQuv4Ph9DcZakhfwHcZk0lszKnE" : "newer-independent-key");
delete process.env.MANTIS_BACKUP_TEST_PASS;
});

it("restores every profile and its keychain entries on a clean machine", async () => {
const outPath = join(tmpHome, "bundle.json");
process.env.MANTIS_BACKUP_TEST_PASS = "diceware-style-test-passphrase";
Expand Down
Loading
Loading