diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3599478..9bc32bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,13 @@ prompt. Write them for users.
versions of the app keep syncing alongside this one.
- Install and restart waits for an update check that's still running, so it installs the update
that check found.
+- Feeds: hosts and snippets from an address your team publishes (Hosts → Add from… → Feed URL…),
+ read with a token. They stay in step with it on their own, every 15 minutes; your renames, keys,
+ tags and notes stay yours. A feed can list each server's host key, so new servers connect
+ without asking you to trust them, and a server with another key is refused. Its snippets are
+ read-only, and when the feed changes one, Run and Paste show you the new commands first.
+- Two devices refreshing the same cloud source at once no longer make duplicate hosts or
+ "(conflict)" copies.
## 0.2.0
diff --git a/Cargo.lock b/Cargo.lock
index f2ec5f1..e574ae6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3397,6 +3397,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
+ "sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
"tokio",
diff --git a/README.md b/README.md
index a3407be..1dc998b 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,10 @@ encrypted sync you can self-host.
Docs: [developer docs](docs/README.md) · [sync & crypto spec](docs/sync-crypto-spec.md) · [self-host](https://morphgate.com/docs/self-hosting) · [roadmap](ROADMAP.md) · [releasing](RELEASING.md) ·
[changelog](CHANGELOG.md)
+
+
+
+
## Install
Download from the [Releases](../../releases) page:
diff --git a/ROADMAP.md b/ROADMAP.md
index 3c17081..c1a792c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -40,6 +40,9 @@ xterm.js in the webview avoids writing a terminal renderer.
- [x] Terminal appearance: built-in themes plus themes imported from Ghostty, Alacritty, iTerm2 and Windows
Terminal files (vault items, synced), a theme per host, and this device's font, size and cursor
- [x] Jump hosts (ProxyJump chains via direct-tcpip, per-hop host-key pinning)
+- [x] Cloud sources: hosts kept in step with Vallic Cloud (its API), AWS, Google Cloud, Azure, Hetzner Cloud and
+ DigitalOcean (their CLIs), and feeds: hosts and snippets from a JSON document a team publishes at a URL,
+ read with a token, refreshed every 15 minutes, with the host keys it lists trusted for its hosts
- [x] Port forwarding: local / remote / dynamic SOCKS5 tunnels saved in the vault, start/stop from the sidebar
- [x] SFTP tabs (`russh-sftp`): local + remote panes, drag and drop (also from the OS), recursive upload/download
with progress, cancel and replace, mkdir/rename/chmod/delete
diff --git a/apps/desktop/e2e/README.md b/apps/desktop/e2e/README.md
index cfad6aa..c9827f1 100644
--- a/apps/desktop/e2e/README.md
+++ b/apps/desktop/e2e/README.md
@@ -4,10 +4,11 @@ These run the real AppImage, deb and rpm binaries, not a dev build, and drive th
| Scenario | What it checks |
| --- | --- |
-| `appimage.mjs` | Creates a vault; SSHes to a throwaway local sshd and runs a command; imports two hosts and their key from a `~/.ssh/config` (one behind the other as its jump host) and connects through it; imports a PuTTY session with a new key in a `.ppk` file, a Termius-style CSV file with a group and a Royal TS document, and signs in with the PuTTY key; connects Vallic Cloud through a stand-in console (`vallic-console.mjs`: device sign-in, rotating tokens, an environment that is this sshd) and signs in to its host; signs in with a key that only has a certificate from the run's user CA (which sshd trusts), from the vault and from an agent; reaches a host by a command its jump host runs (with a login command instead of the shell), and another by a proxy command on this computer (both the relay in `crates/mg-ssh/tests/relay.py`); selects two hosts, moves them into a group and deletes them; signs in to a host with one key from an `ssh-agent` (its own socket) and forwards it (`ssh-add -l` on the host lists it); splits a tab into three panes (beside and below, one through a jump host), broadcasts a command to all of them and then types into one, drags a border and closes them; opens a `morphgate://connect` link from a second launch (which hands it over and exits) and connects from the filled-in quick connect, and refuses an unknown link; refuses a tampered update twice without touching the AppImage; installs the signed update, restarts, unlocks the same vault as the new version, and finds nothing newer |
+| `appimage.mjs` | Creates a vault; SSHes to a throwaway local sshd and runs a command; imports two hosts and their key from a `~/.ssh/config` (one behind the other as its jump host) and connects through it; imports a PuTTY session with a new key in a `.ppk` file, a Termius-style CSV file with a group and a Royal TS document, and signs in with the PuTTY key; connects Vallic Cloud through a stand-in console (`vallic-console.mjs`: device sign-in, rotating tokens, an environment that is this sshd) and signs in to its host; adds AWS and DigitalOcean through stand-in CLIs (`clouds/`); adds a feed (`host-feed.mjs`) whose host connects with the host key it lists, whose wrongly listed host is refused and whose changed snippet asks before it runs; signs in with a key that only has a certificate from the run's user CA (which sshd trusts), from the vault and from an agent; reaches a host by a command its jump host runs (with a login command instead of the shell), and another by a proxy command on this computer (both the relay in `crates/mg-ssh/tests/relay.py`); selects two hosts, moves them into a group and deletes them; signs in to a host with one key from an `ssh-agent` (its own socket) and forwards it (`ssh-add -l` on the host lists it); splits a tab into three panes (beside and below, one through a jump host), broadcasts a command to all of them and then types into one, drags a border and closes them; opens a `morphgate://connect` link from a second launch (which hands it over and exits) and connects from the filled-in quick connect, and refuses an unknown link; refuses a tampered update twice without touching the AppImage; installs the signed update, restarts, unlocks the same vault as the new version, and finds nothing newer |
| `package.mjs` (deb, rpm) | The binary knows how it was installed, finds the update, and warns that installing asks for an administrator password (installing needs root, so it stops there) |
| `teams.mjs` | Two apps side by side (Alice and Bob) with a local reference sync server: both turn on sync; Alice creates an organization and a vault and invites Bob; Bob joins only after the owner's fingerprint matches, and Alice confirms him only after his matches; a host saved in the shared vault reaches Bob, his edit reaches Alice; Bob publishes his SSH key; Alice revokes him (the vault leaves his device) and rotates the vault key; members may create vaults; the activity log tells the story. Then Alice offboards Bob against the local sshd: removal and key rotation, his key found and removed from `authorized_keys`, and the host's shared key replaced (she still logs in) |
| `layout.mjs` | Turns sync on from the offer a new vault gets, fills every page (hosts in a group, a key, a snippet, a tunnel, a team with an invite to a long address), and at 860, 1024, 1280 and 1600 px wide checks that nothing on a page overlaps or runs out of its column. Closing an editor returns to the page it was opened from, and closing a page to the session under it; with no session open, a page takes the tab bar's place |
+| `demo.mjs` | Only with `E2E_ONLY=demo`: the README's demo. The AppImage's Hosts page, a click on a host and its recorded session (in a demo shell), drawn by `tools/readme-demo/demo-svg.mjs` as `demo/morphgate.svg` (copy it to `assets/demo/morphgate.svg`) |
| `mobile.mjs` | The phone layout in a 390 × 844 window: imports a key and saves a host on the phone screens, connects from the host's sheet, interrupts a command with Ctrl from the extra-key row and repeats one with ↑, keeps the session open behind the host list, browses into a folder over SFTP and downloads a file to Downloads, then locks and unlocks on the phone unlock screen |
```sh
@@ -24,10 +25,10 @@ Screenshots and logs go to `target/e2e/run-/`.
screen, and nothing reaches your session bus or keyring.
- **Data:** `HOME` and the XDG directories point at the run directory, so your vault and settings are
never read or written.
-- **Network:** everything is on loopback (sshd on 127.0.0.1 and 127.0.0.2), on ports free when the
+- **Network:** everything is on loopback (sshd on 127.0.0.1 to 127.0.0.4), on ports free when the
run starts, so runs can share a machine: sshd (`E2E_SSH_PORT`), the update feed
(`E2E_FEED_PORT`), the sync server (`E2E_SYNC_PORT`), the test identity provider
- (`E2E_IDP_PORT`), and two per tauri-driver (`E2E_DRIVER_PORT`: from that port up). Set them to
+ (`E2E_IDP_PORT`), the stand-in feed of hosts (`E2E_HOST_FEED_PORT`), and two per tauri-driver (`E2E_DRIVER_PORT`: from that port up). Set them to
pin a port.
- **Files on the "server":** sshd's SFTP sessions start in the run's `sshd/home`, whose
`.ssh/authorized_keys` is the file sshd reads, so the offboarding steps edit that copy and never
diff --git a/apps/desktop/e2e/appimage.mjs b/apps/desktop/e2e/appimage.mjs
index b4959b6..0f59487 100644
--- a/apps/desktop/e2e/appimage.mjs
+++ b/apps/desktop/e2e/appimage.mjs
@@ -622,6 +622,127 @@ try {
await s.click(".tab.active .close");
ok("added DigitalOcean through its CLI: its droplet became a host and connected");
+ // A feed (host-feed.mjs): Add from… → Feed URL…, read with its token; its host connects with
+ // the host key the feed lists (nothing asks), another listed with the wrong key is refused, an
+ // invalid entry is skipped and said; its snippet is read-only, and once the feed changes it,
+ // running it asks first.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
+ await s.addFrom("Feed URL…");
+ await s.waitText("Add a feed", ".cloud-adding", 30000);
+ await s.typeLabelled("Address", process.env.E2E_HOST_FEED);
+ await s.typeLabelled("Token", "wrong-token");
+ await s.clickButton("Add and read the feed");
+ await s.waitText("refused the token", ".cloud-page > .error", 30000);
+ await s.typeLabelled("Token", process.env.E2E_HOST_FEED_TOKEN);
+ await s.clickButton("Add and read the feed");
+ // Titles are uppercased by CSS (innerText follows it).
+ const feedCard = `[...document.querySelectorAll(".cloud-source")].find((x) => x.innerText.toLowerCase().includes("e2e operations"))`;
+ const feedAdded = await s
+ .wait(
+ () => s.exec(`return ${feedCard}?.innerText.includes("2 hosts added") ? "ok" : document.querySelector(".cloud-page > .error, .cloud-adding .error")?.innerText || null`),
+ "the feed source",
+ 30000,
+ )
+ .catch(async (e) => {
+ await s.screenshot(`${shots}/feed-not-added.png`);
+ throw e;
+ });
+ assert.equal(feedAdded, "ok", `adding the feed: ${feedAdded}`);
+ page = await s.exec(`return ${feedCard}.innerText`);
+ assert.match(page, /1 snippet added/);
+ assert.match(page, /Skipped: broken: the host name/);
+ assert.match(page, /token …cdef/);
+ assert.ok(!page.includes(process.env.E2E_HOST_FEED_TOKEN), "the token isn't shown");
+ await s.screenshot(`${shots}/feed-source.png`);
+ await s.clickButton("Close");
+ await s.exec(
+ `[...document.querySelectorAll(".sidebar .list li")].find((l) => l.querySelector(".row-title")?.innerText === "e2e-feed-web").querySelector(".row-main").click(); return true`,
+ );
+ await s.wait(
+ () =>
+ s.exec(
+ `if (document.querySelector("dialog.hostkey[open]")) throw new Error("asked to trust the feed's host");
+ const r = document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows"); return r && /[$#]\\s*$/m.test(r.innerText) ? true : null`,
+ ),
+ "a shell on the feed's host, nothing asked",
+ 60000,
+ );
+ await s.exec(
+ `[...document.querySelectorAll(".sidebar .list li")].find((l) => l.querySelector(".row-title")?.innerText === "e2e-feed-wrong").querySelector(".row-main").click(); return true`,
+ );
+ // The terminal wraps mid-word: compared without whitespace.
+ const refusedText = await s
+ .wait(
+ () =>
+ s.exec(
+ `const r = (document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows")?.innerText ?? "").replace(/\\s+/g, "");
+ return r.includes("isn'toneE2Eoperationslistsforit:refused") ? r : null`,
+ ),
+ "the wrong key refused",
+ 60000,
+ )
+ .catch(async (e) => {
+ await s.screenshot(`${shots}/feed-wrong-key.png`);
+ throw e;
+ });
+ assert.ok(!refusedText.includes("$"), "no shell on the wrongly listed host");
+ await s.click(".tab.active .close");
+ // Back to the session on e2e-feed-web: where snippets go.
+ await s.exec(`[...document.querySelectorAll(".tab")].find((t) => t.querySelector(".label")?.innerText === "e2e-feed-web").click(); return true`);
+ ok("added a feed (a wrong token said first): its host connected with the key the feed lists, one listed with another key was refused, an invalid entry skipped");
+
+ // Its snippet, from the Snippets page into the session on e2e-feed-web.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the feed's snippet");
+ const feedRow = `[...document.querySelectorAll(".panel.page [role=row]")].find((r) => r.innerText.includes("E2E hello"))`;
+ assert.match(await s.exec(`return ${feedRow}.innerText`), /E2E operations/i);
+ const runFeedSnippet = () =>
+ s.exec(`[...${feedRow}.querySelectorAll("button")].find((b) => b.innerText === "Run").click(); return true`);
+ await runFeedSnippet();
+ await s.wait(
+ () => s.exec(`const r = document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows"); return r && /^e2e-feed-one\s*$/m.test(r.innerText) ? true : null`),
+ "the snippet's output",
+ 30000,
+ );
+ // Read-only: no Save, a copy instead.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the Snippets page again");
+ await s.exec(`${feedRow}.querySelector(".lnamebtn").click(); return true`);
+ await s.waitText("it changes there, and is read-only here", ".panel", 30000);
+ assert.ok(!(await s.exec(`return [...document.querySelectorAll(".panel button")].some((b) => b.innerText.startsWith("Save"))`)), "no Save");
+ await s.clickButton("Close");
+
+ // The feed changes the snippet: a refresh brings it, and running it asks first.
+ await fetch(`${new URL(process.env.E2E_HOST_FEED).origin}/e2e/next`, { method: "POST" });
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
+ await s.addFrom("Clouds…");
+ await s.waitText("E2E operations", ".cloud-source", 30000);
+ await s.exec(`[...${feedCard}.querySelectorAll("button")].find((b) => b.innerText === "Refresh").click(); return true`);
+ await s.wait(() => s.exec(`return ${feedCard}?.innerText.match(/1 gone from the feed, 1 snippet changed/) ? true : null`), "the feed's next version", 30000);
+ await s.clickButton("Close");
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the Snippets page");
+ await runFeedSnippet();
+ await s.waitText("E2E hello changed", "dialog.snippet-check", 30000);
+ assert.match(await s.exec(`return document.querySelector("dialog.snippet-check").innerText`), /Changed by E2E operations[\s\S]*echo e2e-feed-two/);
+ await s.screenshot(`${shots}/feed-snippet-changed.png`);
+ await s.clickButton("Run it");
+ await s.wait(
+ () => s.exec(`const r = document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows"); return r && /^e2e-feed-two\s*$/m.test(r.innerText) ? true : null`),
+ "the changed snippet's output",
+ 30000,
+ );
+ // Sent once as it is now: the next run doesn't ask.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the Snippets page");
+ await runFeedSnippet();
+ await new Promise((r) => setTimeout(r, 1000));
+ assert.ok(!(await s.exec(`return !!document.querySelector("dialog.snippet-check[open]")`)), "not asked again");
+ await s.click(".tab.active .close");
+ ok("the feed's snippet ran, read-only with a copy offered; once the feed changed it, running it asked first (and only once)");
+
// Several hosts at once: the CSV and Royal TS ones (nothing later uses them) into one group,
// then both deleted.
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
diff --git a/apps/desktop/e2e/demo.mjs b/apps/desktop/e2e/demo.mjs
new file mode 100644
index 0000000..f8f1805
--- /dev/null
+++ b/apps/desktop/e2e/demo.mjs
@@ -0,0 +1,132 @@
+// The README's demo: the packaged AppImage with a few hosts in groups. Its Hosts page is captured,
+// web-1 (the test sshd) is clicked, and its session view is captured and recorded with the app's
+// own session recording; tools/readme-demo/demo-svg.mjs draws it all (demo.json) as an animated
+// SVG. The host's login command runs a clean bash with a demo prompt in a demo home (a small git
+// repository); this machine's user and loopback address are swapped for demo ones in the captured
+// text, and nothing is saved that still shows them or this computer's home folder.
+//
+// node demo.mjs
+
+import { execFileSync } from "node:child_process";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { userInfo } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { createVault, session } from "./webdriver.mjs";
+
+const [app, out, key, port, user] = process.argv.slice(2);
+const PASSWORD = "correct horse battery staple";
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const snapshotJs = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../tools/readme-demo/snapshot.js"), "utf8");
+
+// The demo home: an app with a few releases.
+const home = `${out}/home-demo`;
+const repo = `${home}/shop`;
+mkdirSync(`${repo}/config`, { recursive: true });
+const git = (...args) =>
+ execFileSync("git", ["-C", repo, ...args], {
+ env: { ...process.env, GIT_AUTHOR_NAME: "Ana", GIT_AUTHOR_EMAIL: "ana@example.com", GIT_COMMITTER_NAME: "Ana", GIT_COMMITTER_EMAIL: "ana@example.com" },
+ });
+git("init", "-q", "-b", "main");
+for (const [file, text, message] of [
+ ["README.md", "# shop\n", "Start the shop"],
+ ["config/nginx.conf", "server { listen 80; }\n", "Serve it with nginx"],
+ ["Dockerfile", "FROM php:8.4-fpm\n", "Run in a container"],
+ ["config/nginx.conf", "server { listen 443 ssl; }\n", "TLS everywhere"],
+]) {
+ writeFileSync(`${repo}/${file}`, text);
+ git("add", "-A");
+ git("commit", "-q", "-m", message);
+}
+git("tag", "v2.4.1");
+
+// sshd starts it in the real home: it moves to the demo's first. A clean bash: no rc files, a
+// demo prompt (the theme's green and blue), this home.
+const login =
+ `cd '${home}' && exec env -i TERM=xterm-256color LANG=C.UTF-8 HOME='${home}' PATH=/usr/local/bin:/usr/bin:/bin GIT_PAGER=cat ` +
+ `PS1='\\[\\e[32m\\]deploy@web-1\\[\\e[0m\\]:\\[\\e[34m\\]\\w\\[\\e[0m\\]$ ' bash --norc --noprofile -i`;
+// What the captured text shows instead of this machine's.
+const mask = [[`${user}@127.0.0.1:${port}`, "deploy@10.0.12.34:22"]];
+
+let s = await session(app);
+try {
+ await createVault(s, PASSWORD);
+ await s.resize(1024, 600);
+ const invoke = (cmd, args) => s.exec(`return window.__TAURI_INTERNALS__.invoke(arguments[0], arguments[1])`, cmd, args);
+ const capture = () => s.exec(`${snapshotJs}; return snapshot(arguments[0]);`, mask);
+ const k = await invoke("key_import", { input: { label: "deploy", source: { kind: "file", path: key }, passphrase: null } });
+ const group = async (name) => (await invoke("group_save", { input: { name } })).id;
+ const production = await group("Production");
+ const staging = await group("Staging");
+ const host = (label, hostname, groupId, tags, extra = {}) =>
+ invoke("host_save", { input: { label, hostname, port: 22, user: "deploy", auth: { kind: "key", keyId: k.id }, groupId, tags, ...extra } });
+ await host("web-1", "127.0.0.1", production, ["shop"], { port: Number(port), user, recordSessions: true, loginCommand: login });
+ await host("web-2", "10.0.12.35", production, ["shop"]);
+ await host("db-1", "10.0.12.40", production, ["postgres"]);
+ await host("staging", "10.0.20.11", staging, ["shop"]);
+ await host("bastion", "bastion.shop.example", null, []);
+ // Made behind the page's back: it reloads its lists on vault-changed.
+ await invoke("plugin:event|emit", { event: "vault-changed", payload: null });
+ const sidebarHost = (label) =>
+ `[...document.querySelectorAll(".sidebar .list li")].find((l) => l.querySelector(".row-title")?.innerText === ${JSON.stringify(label)})`;
+ await s.wait(() => s.exec(`return ${sidebarHost("web-1")} ? true : null`), "web-1 listed");
+
+ // Its host key, pinned once beforehand: the demo's connection asks nothing.
+ const rows = () => s.exec(`return document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows")?.innerText ?? ""`);
+ const prompts = async () => ((await rows()).match(/deploy@web-1:/g) ?? []).length;
+ await s.exec(`${sidebarHost("web-1")}.querySelector(".row-main").click(); return true`);
+ await s.waitText("Trust and connect", "body", 60000);
+ await s.clickButton("Trust and connect");
+ await s.wait(async () => ((await prompts()) >= 1 ? true : null), "the demo prompt", 60000);
+ await s.click(".tab.active .close");
+
+ // Scene one: the Hosts page.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
+ await sleep(800);
+ const row = `[...document.querySelectorAll(".panel.page .lrow")].find((r) => r.querySelector(".lname")?.innerText === "web-1").querySelector(".lnamebtn")`;
+ const target = await s.exec(`const r = ${row}.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }`);
+ const s1 = await capture();
+ await s.screenshot(`${out}/scene1.png`);
+
+ // Scene two: the click, and the session.
+ await s.exec(`${row}.click(); return true`);
+ await s.wait(async () => ((await prompts()) >= 1 ? true : null), "the demo prompt", 60000);
+ await sleep(600);
+ const s2 = await capture();
+ await s.screenshot(`${out}/scene2.png`);
+ if (!s2.terminal) throw new Error("no terminal in the session view");
+ await sleep(600);
+ // Typed as a person would: a key at a time, a pause before Enter.
+ for (const command of ["uptime", "cd shop && git log --oneline --decorate -4", "ls"]) {
+ const before = await prompts();
+ for (const ch of command) {
+ await s.typeTerminal(ch);
+ await sleep(45 + Math.round(Math.random() * 70));
+ }
+ await sleep(450);
+ await s.typeTerminal("\r");
+ await s.wait(async () => ((await prompts()) > before ? true : null), `the prompt after ${command}`, 30000);
+ await sleep(1400);
+ }
+ await sleep(1500);
+ // Closing the tab ends the session and its recording.
+ await s.click(".tab.active .close");
+ const log = await s.wait(async () => {
+ const logs = (await invoke("session_logs", { hostId: null })).filter((l) => l.endedAt);
+ return logs.length >= 2 ? logs.sort((a, b) => b.startedAt - a.startedAt)[0] : null;
+ }, "the recording, ended", 30000);
+ const cast = await invoke("session_log_cast", { id: log.id });
+ const demo = JSON.stringify({ s1, s2, target, cast });
+ // It goes in a public README: nothing of this computer's.
+ for (const leak of [userInfo().homedir, "127.0.0.1", `${user}@`]) {
+ if (demo.includes(leak)) throw new Error(`the demo shows ${leak}: not saved`);
+ }
+ writeFileSync(`${out}/demo.json`, demo);
+ console.log(` ✓ captured the demo: ${out}/demo.json`);
+} catch (e) {
+ await s.screenshot(`${out}/failed.png`).catch(() => {});
+ throw e;
+} finally {
+ await s.close();
+}
diff --git a/apps/desktop/e2e/host-feed.mjs b/apps/desktop/e2e/host-feed.mjs
new file mode 100644
index 0000000..343a2ac
--- /dev/null
+++ b/apps/desktop/e2e/host-feed.mjs
@@ -0,0 +1,42 @@
+// A stand-in feed (docs/features/cloud-sources.md, "Feeds"): hosts and snippets a team publishes,
+// read with a token. Its host "e2e-feed-web" is the test sshd on 127.0.0.4, with the sshd's host
+// key listed (so nothing asks to trust it); "e2e-feed-wrong" is the same server listed with
+// another key (refused); "broken" is skipped. POST /e2e/next changes the snippet and drops the
+// wrong host, as a publisher's next version would. The token is E2E_HOST_FEED_TOKEN.
+//
+// node host-feed.mjs
+
+import { readFileSync } from "node:fs";
+import { createServer } from "node:http";
+
+const [port, hostKeyFile, sshPort, user] = process.argv.slice(2);
+const TOKEN = process.env.E2E_HOST_FEED_TOKEN;
+const hostKey = readFileSync(hostKeyFile, "utf8").trim().split(/\s+/).slice(0, 2).join(" ");
+const otherKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDRbMaf7RI+oSkPOCPrFjPoMWJEIu96Drte4Q3r9OQYR";
+let version = 1;
+
+const doc = () => ({
+ version: 1,
+ name: "E2E operations",
+ hosts: [
+ { id: "web", label: "e2e-feed-web", hostname: "127.0.0.4", port: Number(sshPort), user, group: "E2E feed", tags: ["feed"], key: "e2e_key", host_keys: [hostKey] },
+ ...(version === 1 ? [{ id: "wrong", label: "e2e-feed-wrong", hostname: "127.0.0.4", port: Number(sshPort), user, group: "E2E feed", key: "e2e_key", host_keys: [otherKey] }] : []),
+ { id: "broken", label: "broken", hostname: "-oProxyCommand=touch /tmp/x", user },
+ ],
+ snippets: [{ id: "hello", name: "E2E hello", body: version === 1 ? "echo e2e-feed-one" : "echo e2e-feed-two", tags: ["feed"] }],
+});
+
+createServer((req, res) => {
+ const send = (status, json) => {
+ res.writeHead(status, { "content-type": "application/json" });
+ res.end(JSON.stringify(json));
+ };
+ console.log(req.method, req.url, req.headers["user-agent"]);
+ if (req.method === "POST" && req.url === "/e2e/next") {
+ version = 2;
+ return send(200, { version });
+ }
+ if (req.url !== "/morphgate.json") return send(404, {});
+ if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(401, { message: "Unauthenticated." });
+ return send(200, doc());
+}).listen(Number(port), "127.0.0.1");
diff --git a/apps/desktop/e2e/run.sh b/apps/desktop/e2e/run.sh
index f30611f..d530b0e 100755
--- a/apps/desktop/e2e/run.sh
+++ b/apps/desktop/e2e/run.sh
@@ -21,8 +21,8 @@ if [ -z "${XDG_RUNTIME_DIR:-}" ]; then
fi
# Free ports unless given: several runs can share a machine.
-free=$(node "$here/free-ports.mjs" 5)
-read -r free_feed free_ssh free_sync free_idp free_vallic <"$run/host-feed.log" 2>&1 &
+pids="$pids $!"
+for _ in $(seq 1 50); do
+ curl -s "http://127.0.0.1:$host_feed_port/" >/dev/null 2>&1 && break
+ sleep 0.2
+done
+
# A stand-in aws CLI (clouds/aws), whose running instance is the sshd above.
export MORPHGATE_CLOUD_CLI_DIR="$here/clouds" E2E_CLOUD_PORT="$ssh_port" E2E_CLOUD_USER="$(id -un)"
@@ -242,4 +254,17 @@ if want mobile; then
"$run/mobile/key" "$ssh_port" "$(id -un)" 2>"$run/mobile/stderr.log"
fi
+# The README's demo (assets/demo/morphgate.svg), only when asked for: E2E_ONLY=demo.
+if [ -n "${E2E_ONLY:-}" ] && want demo; then
+ echo "e2e: demo (the AppImage's Hosts page and a session, drawn for the README)"
+ mkdir -p "$run/demo/tmp"
+ cp "$artifacts/$appimage" "$run/demo/"
+ APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$run/demo/tmp" \
+ dbus-run-session -- "$here/session.sh" "$run/demo" \
+ node "$here/demo.mjs" "$run/demo/$appimage" "$run/demo" "$run/sshd/client_key" "$ssh_port" "$(id -un)" 2>"$run/demo/stderr.log"
+ [ -d "$root/tools/readme-demo/node_modules" ] || npm ci --prefix "$root/tools/readme-demo" --silent
+ node "$root/tools/readme-demo/demo-svg.mjs" "$run/demo/demo.json" "$run/demo/morphgate.svg"
+ echo "e2e: demo drawn: $run/demo/morphgate.svg (copy it to assets/demo/morphgate.svg)"
+fi
+
echo "e2e: all scenarios passed; screenshots and logs in $run"
diff --git a/apps/desktop/src-tauri/src/bulk.rs b/apps/desktop/src-tauri/src/bulk.rs
index 4c08234..076d214 100644
--- a/apps/desktop/src-tauri/src/bulk.rs
+++ b/apps/desktop/src-tauri/src/bulk.rs
@@ -1,7 +1,7 @@
//! Changes to several items at once, from the lists' selections: deleting them, and putting hosts
//! in a group. Each item is changed as on its own; the ones that can't be are said, not hidden.
-use mg_store::{ItemData, LocalVault};
+use mg_store::{Item, ItemData, LocalVault, Snippet};
use serde::Serialize;
use tauri::State;
@@ -36,6 +36,18 @@ pub(crate) fn delete_items(v: &mut LocalVault, ids: &[String]) -> BulkResult {
let mut left: Vec = Vec::new();
for id in ids {
match v.item(id) {
+ // A feed's snippets go when the feed drops them.
+ Ok(Item {
+ data:
+ ItemData::Snippet(Snippet {
+ source: Some(_),
+ name,
+ ..
+ }),
+ ..
+ }) => result.refused.push(format!(
+ "\"{name}\" comes from a feed: it goes when the feed drops it"
+ )),
Ok(item) if plain(&item.data) => left.push(id.clone()),
Ok(_) => result
.refused
@@ -209,6 +221,7 @@ mod tests {
name: "df".into(),
body: "df -h".into(),
tags: vec![],
+ source: None,
}),
)
.unwrap()
diff --git a/apps/desktop/src-tauri/src/cloud.rs b/apps/desktop/src-tauri/src/cloud.rs
index 7a7fa38..05dbc22 100644
--- a/apps/desktop/src-tauri/src/cloud.rs
+++ b/apps/desktop/src-tauri/src/cloud.rs
@@ -1,21 +1,24 @@
//! Cloud sources (docs/features/cloud-sources.md): hosts kept in step with Vallic Cloud, AWS,
-//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean. For Vallic each device signs in itself (the
-//! console's device flow) and keeps its sign-in on the device; the others are listed with the
-//! provider's CLI as this device has it set up. The vault syncs the source and the hosts it made. A
-//! refresh brings the provider's hosts in: what the provider decides (address, port, user, login
-//! and jump commands, the label until it's renamed here) follows it, what's the person's (key,
-//! scheme, tags, notes, group) stays; a host the provider no longer lists is marked gone, not
-//! deleted.
-
-use std::collections::HashMap;
+//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean, or a feed. For Vallic each device signs in
+//! itself (the console's device flow) and keeps its sign-in on the device; the others are listed
+//! with the provider's CLI as this device has it set up; a feed is read from its URL with the
+//! token the source holds. The vault syncs the source and the hosts it made. A refresh brings the
+//! provider's hosts in: what the provider decides (address, port, user, login and jump commands,
+//! the label until it's renamed here) follows it, what's the person's (key, scheme, tags, notes,
+//! group) stays; a host the provider no longer lists is marked gone, not deleted. A feed's
+//! snippets follow it whole.
+
+use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use mg_inventory::cli::Cli;
+use mg_inventory::feed::{self, FeedSnippet};
use mg_inventory::shape::Shape;
use mg_inventory::vallic::{DeviceAuth, Poll, Session, Vallic};
use mg_inventory::{InventoryHost, aws, azure, gcp};
use mg_store::{
- CloudReach, CloudSource, Host, HostAuth, HostGroup, HostSource, ItemData, LocalVault,
+ Access, CloudReach, CloudSource, Host, HostAuth, HostGroup, HostSource, Item, ItemData,
+ LocalVault, Snippet, SnippetSource,
};
use serde::{Deserialize, Serialize};
use tauri::State;
@@ -23,9 +26,32 @@ use tauri::State;
use crate::vault::{VaultState, save};
/// One cloud operation at a time on this device: a refresh token is used once, and two refreshes
-/// at once would replay it, which ends the sign-in.
+/// at once would replay it, which ends the sign-in. And why the last refresh of a source failed
+/// here, for its card (an automatic one has nowhere else to say it).
#[derive(Default)]
-pub struct CloudState(tokio::sync::Mutex<()>);
+pub struct CloudState {
+ one: tokio::sync::Mutex<()>,
+ failed: std::sync::Mutex>,
+}
+
+impl CloudState {
+ fn note(&self, source_id: &str, result: &Result) {
+ if let Ok(mut failed) = self.failed.lock() {
+ match result {
+ Ok(_) => failed.remove(source_id),
+ Err(e) => failed.insert(source_id.to_owned(), e.clone()),
+ };
+ }
+ }
+
+ fn failure(&self, source_id: &str) -> Option {
+ self.failed.lock().ok()?.get(source_id).cloned()
+ }
+}
+
+/// A feed's source refreshes itself on desktops: at unlock, then every 15 minutes while unlocked
+/// (the app's timer), unless it was refreshed (on any device) in the last 10.
+const FEED_FRESH_SECS: i64 = 10 * 60;
/// Where this device keeps its sign-in to a source.
fn secret_name(source_id: &str) -> String {
@@ -99,9 +125,27 @@ pub struct SourceView {
group_tag: Option,
/// Where this device runs the provider's CLI from, when it was given.
cli_path: Option,
+ /// A feed's address.
+ url: Option,
+ /// The end of a feed's token, to tell tokens apart (`…f3a9`); the rest is never shown.
+ token_end: Option,
+ /// Snippets a feed keeps.
+ snippets: usize,
+ /// Why the last refresh on this device failed (an automatic one included).
+ failed: Option,
}
-fn views(v: &LocalVault) -> Result, String> {
+/// The token as the source shows it: its last 4 characters where it's long enough to spare them.
+fn token_end(token: &str) -> String {
+ let chars: Vec = token.trim().chars().collect();
+ if chars.len() >= 16 {
+ format!("…{}", chars[chars.len() - 4..].iter().collect::())
+ } else {
+ "…".into()
+ }
+}
+
+fn views(v: &LocalVault, cloud: &CloudState) -> Result, String> {
let items = v.items().map_err(|e| e.to_string())?;
let mut out = Vec::new();
for item in &items {
@@ -113,6 +157,12 @@ fn views(v: &LocalVault) -> Result, String> {
_ => None,
});
let (hosts, gone) = kept.fold((0, 0), |(n, g), s| (n + 1, g + usize::from(s.gone)));
+ let snippets = items
+ .iter()
+ .filter(|i| {
+ matches!(&i.data, ItemData::Snippet(Snippet { source: Some(s), .. }) if s.source_ref == item.id)
+ })
+ .count();
out.push(SourceView {
id: item.id.clone(),
vault_id: item.vault_id.clone(),
@@ -136,6 +186,10 @@ fn views(v: &LocalVault) -> Result, String> {
user: c.user.clone(),
group_tag: c.group_tag.clone(),
cli_path: v.setting(&cli_setting(&c.provider)).ok().flatten(),
+ url: c.url.clone(),
+ token_end: c.token.as_deref().map(token_end),
+ snippets,
+ failed: cloud.failure(&item.id),
});
}
out.sort_by(|a, b| a.label.cmp(&b.label));
@@ -143,8 +197,11 @@ fn views(v: &LocalVault) -> Result, String> {
}
#[tauri::command]
-pub fn cloud_sources(vault: State<'_, VaultState>) -> Result, String> {
- views(&*vault.guard()?)
+pub fn cloud_sources(
+ vault: State<'_, VaultState>,
+ cloud: State<'_, CloudState>,
+) -> Result, String> {
+ views(&*vault.guard()?, &cloud)
}
/// Starts signing in to Vallic Cloud: the code to show and the page to type it at.
@@ -183,7 +240,7 @@ pub async fn vallic_sign_in_poll(
source_id: Option,
vault_id: Option,
) -> Result {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let v = client(endpoint.as_deref())?;
let tokens = match v.poll(&device_code).await.map_err(|e| e.to_string())? {
Poll::Pending => return Ok(SignInStatus::Pending { slower: false }),
@@ -229,7 +286,7 @@ pub async fn vallic_sign_in_poll(
};
vault.changed();
let refresh = refresh_with(&vault, &v, &id, &mut session).await?;
- let source = views(&*vault.guard()?)?
+ let source = views(&*vault.guard()?, &cloud)?
.into_iter()
.find(|s| s.id == id)
.ok_or("the source went away")?;
@@ -259,8 +316,13 @@ pub struct RefreshResult {
without_shell: Vec,
/// Machines without the address the source uses (running ones, or new stopped ones).
without_address: Vec,
- /// Regions, projects or subscriptions that couldn't be listed, and why.
+ /// Regions, projects or subscriptions that couldn't be listed, and why; a feed's entries
+ /// left out.
skipped: Vec,
+ /// A feed's snippets.
+ snippets_added: usize,
+ snippets_updated: usize,
+ snippets_removed: usize,
}
/// Brings a source's hosts in from the provider: with this device's sign-in (Vallic Cloud), or
@@ -271,33 +333,136 @@ pub async fn cloud_refresh(
cloud: State<'_, CloudState>,
source_id: String,
) -> Result {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
+ let result = refresh(&vault, &source_id).await;
+ cloud.note(&source_id, &result);
+ result
+}
+
+/// [`cloud_refresh`], with the one cloud operation running here held.
+async fn refresh(vault: &VaultState, source_id: &str) -> Result {
let provider = match vault
.guard()?
- .item(&source_id)
+ .item(source_id)
.map_err(|e| e.to_string())?
.data
{
ItemData::CloudSource(c) => c.provider,
_ => return Err("not a cloud source".into()),
};
- if provider != "vallic" {
- return refresh_cli(&vault, &source_id).await;
+ match provider.as_str() {
+ "vallic" => {}
+ "feed" => return refresh_feed(vault, source_id).await,
+ _ => return refresh_cli(vault, source_id).await,
}
let (endpoint, refresh) = {
let v = vault.guard()?;
- let ItemData::CloudSource(c) = v.item(&source_id).map_err(|e| e.to_string())?.data else {
+ let ItemData::CloudSource(c) = v.item(source_id).map_err(|e| e.to_string())?.data else {
return Err("not a cloud source".into());
};
let refresh = v
- .local_secret(&secret_name(&source_id))
+ .local_secret(&secret_name(source_id))
.map_err(|e| e.to_string())?
.ok_or("this device isn't signed in to it: sign in first")?;
(c.endpoint, refresh.to_string())
};
let v = client(endpoint.as_deref())?;
let mut session = Session::new(refresh);
- refresh_with(&vault, &v, &source_id, &mut session).await
+ refresh_with(vault, &v, source_id, &mut session).await
+}
+
+/// A feed: read from its URL with the source's token; its hosts and snippets into the vault.
+async fn refresh_feed(vault: &VaultState, source_id: &str) -> Result {
+ let c = match vault
+ .guard()?
+ .item(source_id)
+ .map_err(|e| e.to_string())?
+ .data
+ {
+ ItemData::CloudSource(c) => c,
+ _ => return Err("not a cloud source".into()),
+ };
+ let (Some(url), Some(token)) = (&c.url, &c.token) else {
+ return Err(format!("{}: a feed needs its address and token", c.label));
+ };
+ let feed = feed::fetch(&c.label, url, token)
+ .await
+ .map_err(|e| match e {
+ mg_inventory::Error::TokenRefused(name) => {
+ format!("{name} refused the token: change it in the source's settings")
+ }
+ e => e.to_string(),
+ })?;
+ apply_feed(vault, source_id, feed)
+}
+
+/// What a feed listed into the vault of source `source_id`: its hosts, then its snippets.
+fn apply_feed(
+ vault: &VaultState,
+ source_id: &str,
+ feed: feed::Feed,
+) -> Result {
+ let result = {
+ let mut store = vault.guard()?;
+ let mut result = apply(&mut store, source_id, &feed.hosts)?;
+ let (added, updated, removed) = apply_snippets(&mut store, source_id, &feed.snippets)?;
+ result.snippets_added = added;
+ result.snippets_updated = updated;
+ result.snippets_removed = removed;
+ result.skipped = feed.skipped;
+ result
+ };
+ vault.changed();
+ Ok(result)
+}
+
+/// Refreshes the feeds that are due: on a vault this device can write to, and not refreshed (on
+/// any device) in the last 10 minutes. The app calls it at unlock and every 15 minutes; a failure
+/// shows on the source's card. Returns how many it refreshed.
+#[tauri::command]
+pub async fn cloud_auto_refresh(
+ vault: State<'_, VaultState>,
+ cloud: State<'_, CloudState>,
+) -> Result {
+ if cfg!(mobile) {
+ return Ok(0);
+ }
+ let due = |v: &LocalVault| -> Result, String> {
+ let writable: HashSet = v
+ .vaults()
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .filter(|x| x.access >= Access::Write)
+ .map(|x| x.id)
+ .collect();
+ Ok(v.items()
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .filter(|i| writable.contains(&i.vault_id))
+ .filter_map(|i| match i.data {
+ ItemData::CloudSource(c)
+ if c.provider == "feed" && now() - c.refreshed_at >= FEED_FRESH_SECS =>
+ {
+ Some(i.id)
+ }
+ _ => None,
+ })
+ .collect())
+ };
+ let mut refreshed = 0;
+ let ids = due(&*vault.guard()?)?;
+ for id in ids {
+ let _one = cloud.one.lock().await;
+ // Refreshed meanwhile (by hand, or a sync brought another device's refresh).
+ let still = due(&*vault.guard()?)?.contains(&id);
+ if !still {
+ continue;
+ }
+ let result = refresh_feed(&vault, &id).await;
+ cloud.note(&id, &result);
+ refreshed += usize::from(result.is_ok());
+ }
+ Ok(refreshed)
}
async fn refresh_with(
@@ -410,6 +575,43 @@ async fn refresh_cli(vault: &VaultState, source_id: &str) -> Result mg_store::Result- {
+ let id = mg_store::derived_id(&[vault_id, source_id, kind, key]);
+ match v.item(&id) {
+ Ok(_) => save(v, None, Some(vault_id), data),
+ Err(mg_store::Error::NotFound(_)) => v.insert_in(vault_id, &id, data),
+ Err(e) => Err(e),
+ }
+}
+
+/// The host's tags: the person's (what the source didn't give it), then what the source gives
+/// it now.
+fn merged_tags(h: &Host, before: &[String], now: &[String]) -> Vec
{
+ let mut tags: Vec = h
+ .tags
+ .iter()
+ .filter(|t| !before.contains(t))
+ .cloned()
+ .collect();
+ for t in now {
+ if !tags.contains(t) {
+ tags.push(t.clone());
+ }
+ }
+ tags
+}
+
/// The platform's hosts into the vault of source `source_id`.
fn apply(
v: &mut LocalVault,
@@ -477,6 +679,8 @@ fn apply(
place: via.place.clone(),
project: via.project.clone(),
}),
+ host_keys: ih.host_keys.clone(),
+ tags: ih.tags.clone(),
};
match kept.remove(&ih.external_id) {
Some((id, mut h)) => {
@@ -496,6 +700,12 @@ fn apply(
if ih.jump.is_none() {
h.jump_ref = source_jump.clone().filter(|_| ih.via.is_none());
}
+ let before = h
+ .source
+ .as_ref()
+ .map(|s| s.tags.clone())
+ .unwrap_or_default();
+ h.tags = merged_tags(&h, &before, &ih.tags);
h.source = Some(Box::new(mark(&ih.label)));
if h != was {
save(v, Some(&id), None, ItemData::Host(h)).map_err(e)?;
@@ -506,18 +716,28 @@ fn apply(
// New and without an address (stopped): nothing to connect to yet.
None if ih.hostname.is_empty() => result.without_address.push(ih.label.clone()),
None => {
+ // In the group of that name, made when missing; none where the source names none.
let group_ref = match groups.get(&ih.group.to_lowercase()) {
- Some(g) => g.clone(),
+ _ if ih.group.trim().is_empty() => None,
+ Some(g) => Some(g.clone()),
None => {
let group = HostGroup {
name: ih.group.clone(),
..HostGroup::default()
};
- let id = save(v, None, Some(&vault_id), ItemData::HostGroup(group))
- .map_err(e)?
- .id;
- groups.insert(ih.group.to_lowercase(), id.clone());
- id
+ let key = ih.group.to_lowercase();
+ let id = create(
+ v,
+ &vault_id,
+ source_id,
+ "group",
+ &key,
+ ItemData::HostGroup(group),
+ )
+ .map_err(e)?
+ .id;
+ groups.insert(key, id.clone());
+ Some(id)
}
};
let host = Host {
@@ -535,9 +755,9 @@ fn apply(
vpn_ref: None,
theme_ref: None,
record_sessions: false,
- tags: Vec::new(),
+ tags: ih.tags.clone(),
notes: String::new(),
- group_ref: Some(group_ref),
+ group_ref,
identity_ref: None,
keep_session: false,
forward_agent: false,
@@ -546,9 +766,16 @@ fn apply(
proxy_command: None,
source: Some(Box::new(mark(&ih.label))),
};
- let id = save(v, None, Some(&vault_id), ItemData::Host(host))
- .map_err(e)?
- .id;
+ let id = create(
+ v,
+ &vault_id,
+ source_id,
+ "host",
+ &ih.external_id,
+ ItemData::Host(host),
+ )
+ .map_err(e)?
+ .id;
ids.insert(ih.external_id.clone(), id);
result.added += 1;
}
@@ -583,6 +810,68 @@ fn apply(
Ok(result)
}
+/// A feed's snippets into its vault: the feed decides them whole (they're read-only in the app),
+/// so one it no longer lists is deleted. Returns (added, updated, removed).
+fn apply_snippets(
+ v: &mut LocalVault,
+ source_id: &str,
+ snippets: &[FeedSnippet],
+) -> Result<(usize, usize, usize), String> {
+ let e = |e: mg_store::Error| e.to_string();
+ let vault_id = v.item(source_id).map_err(e)?.vault_id;
+ let mut kept: HashMap = v
+ .items()
+ .map_err(e)?
+ .into_iter()
+ .filter(|i| i.vault_id == vault_id)
+ .filter_map(|i| match i.data {
+ ItemData::Snippet(s) => match &s.source {
+ Some(src) if src.source_ref == source_id => {
+ Some((src.external_id.clone(), (i.id, s)))
+ }
+ _ => None,
+ },
+ _ => None,
+ })
+ .collect();
+ let (mut added, mut updated) = (0, 0);
+ for fs in snippets {
+ let snippet = Snippet {
+ name: fs.name.clone(),
+ body: fs.body.clone(),
+ tags: fs.tags.clone(),
+ source: Some(SnippetSource {
+ source_ref: source_id.to_owned(),
+ external_id: fs.external_id.clone(),
+ }),
+ };
+ match kept.remove(&fs.external_id) {
+ Some((_, was)) if was == snippet => {}
+ Some((id, _)) => {
+ save(v, Some(&id), None, ItemData::Snippet(snippet)).map_err(e)?;
+ updated += 1;
+ }
+ None => {
+ create(
+ v,
+ &vault_id,
+ source_id,
+ "snippet",
+ &fs.external_id,
+ ItemData::Snippet(snippet),
+ )
+ .map_err(e)?;
+ added += 1;
+ }
+ }
+ }
+ let removed = kept.len();
+ for (id, _) in kept.into_values() {
+ v.delete(&id).map_err(e)?;
+ }
+ Ok((added, updated, removed))
+}
+
/// Ends this device's sign-in (on the console too); the source and its hosts stay.
#[tauri::command]
pub async fn cloud_sign_out(
@@ -590,7 +879,7 @@ pub async fn cloud_sign_out(
cloud: State<'_, CloudState>,
source_id: String,
) -> Result<(), String> {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let (endpoint, refresh) = {
let v = vault.guard()?;
let endpoint = match v.item(&source_id).map_err(|e| e.to_string())?.data {
@@ -610,7 +899,8 @@ pub async fn cloud_sign_out(
.map_err(|e| e.to_string())
}
-/// Removes a source: its hosts too (`delete_hosts`), or they stay as hosts of their own.
+/// Removes a source: its hosts (and a feed's snippets) too (`delete_hosts`), or they stay as
+/// hosts and snippets of their own.
#[tauri::command]
pub async fn cloud_source_delete(
vault: State<'_, VaultState>,
@@ -637,6 +927,19 @@ pub async fn cloud_source_delete(
_ => None,
})
.collect();
+ let snippets: Vec<(String, Snippet)> = v
+ .items()
+ .map_err(e)?
+ .into_iter()
+ .filter_map(|i| match i.data {
+ ItemData::Snippet(s)
+ if s.source.as_ref().is_some_and(|s| s.source_ref == source_id) =>
+ {
+ Some((i.id, s))
+ }
+ _ => None,
+ })
+ .collect();
if delete_hosts {
// Hosts that jump through others first, so none is still in use when it goes.
let (through, rest): (Vec<_>, Vec<_>) =
@@ -644,11 +947,18 @@ pub async fn cloud_source_delete(
for (id, _) in through.into_iter().chain(rest) {
v.delete(&id).map_err(e)?;
}
+ for (id, _) in snippets {
+ v.delete(&id).map_err(e)?;
+ }
} else {
for (id, mut h) in hosts {
h.source = None;
save(&mut v, Some(&id), None, ItemData::Host(h)).map_err(e)?;
}
+ for (id, mut s) in snippets {
+ s.source = None;
+ save(&mut v, Some(&id), None, ItemData::Snippet(s)).map_err(e)?;
+ }
}
v.delete(&source_id).map_err(e)?;
}
@@ -888,7 +1198,7 @@ pub async fn cloud_source_add(
key_id: Option,
settings: SourceSettings,
) -> Result {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let cli = find_cli(&*vault.guard()?, &provider)?;
let profile = trimmed(settings.profile.clone());
let account = whoami(&cli, &provider, profile.as_deref()).await?;
@@ -925,7 +1235,7 @@ pub async fn cloud_source_add(
};
vault.changed();
let refresh = refresh_cli(&vault, &id).await?;
- let source = views(&*vault.guard()?)?
+ let source = views(&*vault.guard()?, &cloud)?
.into_iter()
.find(|s| s.id == id)
.ok_or("the source went away")?;
@@ -948,8 +1258,10 @@ pub fn cloud_source_settings(
let ItemData::CloudSource(mut source) = v.item(&source_id).map_err(e)?.data else {
return Err("not a cloud source".into());
};
- if source.provider == "vallic" {
- return Err("Vallic Cloud lists what your sign-in reaches".into());
+ match source.provider.as_str() {
+ "vallic" => return Err("Vallic Cloud lists what your sign-in reaches".into()),
+ "feed" => return Err("a feed's settings are its address and token".into()),
+ _ => {}
}
settings.onto(&mut source);
save(
@@ -964,6 +1276,118 @@ pub fn cloud_source_settings(
Ok(())
}
+/// A feed source's name, address and token (`None` or empty: the token stays as it is).
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FeedInput {
+ #[serde(default)]
+ label: Option,
+ url: String,
+ #[serde(default)]
+ token: Option,
+}
+
+/// A feed's address, checked: `https://`, or `http://` on this computer.
+fn feed_url(url: &str) -> Result {
+ feed::check_url(url)
+ .map(|u| u.to_string())
+ .map_err(|e| e.to_string())
+}
+
+/// Adds a feed (in `vault_id`, default the personal vault) once it has been read with the token,
+/// named as given, else as the feed calls itself, else by its host; then brings its hosts and
+/// snippets in.
+#[tauri::command]
+pub async fn cloud_feed_add(
+ vault: State<'_, VaultState>,
+ cloud: State<'_, CloudState>,
+ vault_id: Option,
+ key_id: Option,
+ input: FeedInput,
+) -> Result {
+ let _one = cloud.one.lock().await;
+ let parsed = feed::check_url(&input.url).map_err(|e| e.to_string())?;
+ let url = parsed.to_string();
+ let host = parsed.host_str().unwrap_or("the feed").to_owned();
+ let token = trimmed(input.token).ok_or("a feed needs the token it's read with")?;
+ let label = trimmed(input.label);
+ // Read before it's kept: a wrong address or token is said now, and the feed may name itself.
+ let read = feed::fetch(label.as_deref().unwrap_or(&host), &url, &token)
+ .await
+ .map_err(|e| e.to_string())?;
+ let id = {
+ let mut store = vault.guard()?;
+ let target = match vault_id.filter(|v| !v.is_empty()) {
+ Some(id) => id,
+ None => personal(&store)?,
+ };
+ let source = CloudSource {
+ label: label.or(read.name.clone()).unwrap_or(host),
+ provider: "feed".into(),
+ key_ref: trimmed(key_id),
+ account: String::new(),
+ url: Some(url),
+ token: Some(token),
+ ..CloudSource::default()
+ };
+ save(
+ &mut store,
+ None,
+ Some(&target),
+ ItemData::CloudSource(source),
+ )
+ .map_err(|e| e.to_string())?
+ .id
+ };
+ vault.changed();
+ // What was read, brought in: no second request.
+ let refresh = apply_feed(&vault, &id, read);
+ cloud.note(&id, &refresh);
+ let source = views(&*vault.guard()?, &cloud)?
+ .into_iter()
+ .find(|s| s.id == id)
+ .ok_or("the source went away")?;
+ Ok(Added {
+ source: Box::new(source),
+ refresh: refresh?,
+ })
+}
+
+/// Changes a feed's name, address or token (the next refresh reads it).
+#[tauri::command]
+pub fn cloud_feed_settings(
+ vault: State<'_, VaultState>,
+ source_id: String,
+ input: FeedInput,
+) -> Result<(), String> {
+ {
+ let mut v = vault.guard()?;
+ let e = |e: mg_store::Error| e.to_string();
+ let ItemData::CloudSource(mut source) = v.item(&source_id).map_err(e)?.data else {
+ return Err("not a cloud source".into());
+ };
+ if source.provider != "feed" {
+ return Err("not a feed".into());
+ }
+ if let Some(label) = trimmed(input.label) {
+ source.label = label;
+ }
+ source.url = Some(feed_url(&input.url)?);
+ if let Some(token) = trimmed(input.token) {
+ source.token = Some(token);
+ }
+ save(
+ &mut v,
+ Some(&source_id),
+ None,
+ ItemData::CloudSource(source),
+ )
+ .map_err(e)?;
+ }
+ vault.changed();
+ Ok(())
+}
+
/// Puts a key's public half on the signed-in person's Vallic account.
#[tauri::command]
pub async fn cloud_add_key(
@@ -972,7 +1396,7 @@ pub async fn cloud_add_key(
source_id: String,
key_id: String,
) -> Result<(), String> {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let (endpoint, refresh, label, public_key) = {
let v = vault.guard()?;
let e = |e: mg_store::Error| e.to_string();
@@ -1037,6 +1461,8 @@ mod tests {
state: None,
key_name: None,
via: None,
+ host_keys: vec![],
+ tags: vec![],
}
}
@@ -1109,6 +1535,8 @@ mod tests {
state: state.map(str::to_owned),
key_name: Some("deploy".into()),
via: None,
+ host_keys: vec![],
+ tags: vec![],
};
let first = apply(
&mut v,
@@ -1178,6 +1606,8 @@ mod tests {
place: "eu-central-1".into(),
project: None,
}),
+ host_keys: vec![],
+ tags: vec![],
};
let command = reach_command(&v, &source).unwrap().unwrap();
assert_eq!(
@@ -1292,4 +1722,115 @@ mod tests {
};
assert!(gone.source.unwrap().gone);
}
+
+ /// A feed: its hosts under ids worked out from what they are (the same on every device),
+ /// its groups, tags (the person's kept) and host keys; its snippets follow it whole.
+ #[test]
+ fn a_feed_keeps_hosts_and_snippets_in_step() {
+ let mut v = vault();
+ let target = personal(&v).unwrap();
+ let sid = save(
+ &mut v,
+ None,
+ Some(&target),
+ ItemData::CloudSource(CloudSource {
+ label: "Ops".into(),
+ provider: "feed".into(),
+ url: Some("https://ops.example/feed".into()),
+ token: Some("T".into()),
+ ..CloudSource::default()
+ }),
+ )
+ .unwrap()
+ .id;
+ let doc = |port: u16, tags: &[&str], snippet_body: Option<&str>| {
+ let mut snippets =
+ vec![serde_json::json!({ "id": "up", "name": "Uptime", "body": "uptime" })];
+ if let Some(body) = snippet_body {
+ snippets.push(serde_json::json!({ "id": "logs", "name": "Logs", "body": body }));
+ }
+ mg_inventory::feed::parse(
+ "Ops",
+ serde_json::json!({
+ "version": 1,
+ "hosts": [
+ { "id": "bastion", "hostname": "bastion.example", "user": "ops", "group": "Bastions",
+ "host_keys": ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"] },
+ { "id": "web", "hostname": "10.0.0.1", "port": port, "user": "ops", "jump": "bastion",
+ "tags": tags },
+ { "id": "loose", "hostname": "loose.example", "user": "ops" }
+ ],
+ "snippets": snippets
+ })
+ .to_string()
+ .as_bytes(),
+ )
+ .unwrap()
+ };
+ let first = doc(22, &["prod"], Some("journalctl -n 100"));
+ let r = apply(&mut v, &sid, &first.hosts).unwrap();
+ assert_eq!((r.added, r.updated), (3, 0));
+ assert_eq!(
+ apply_snippets(&mut v, &sid, &first.snippets).unwrap(),
+ (2, 0, 0)
+ );
+ let hosts = hosts_of(&v, &sid);
+ let id_of = |label: &str| {
+ hosts
+ .iter()
+ .find(|(_, h)| h.label == label)
+ .unwrap()
+ .0
+ .clone()
+ };
+ let web = id_of("web");
+ // Every device works out the same id.
+ assert_eq!(
+ web,
+ mg_store::derived_id(&[&target, &sid, "host", "feed:web"])
+ );
+ let ItemData::Host(h) = v.item(&web).unwrap().data else {
+ unreachable!()
+ };
+ assert_eq!(h.jump_ref, Some(id_of("bastion")));
+ assert_eq!(h.tags, ["prod"]);
+ assert_eq!(h.group_ref, None, "no group where the feed names none");
+ let ItemData::Host(b) = v.item(&id_of("bastion")).unwrap().data else {
+ unreachable!()
+ };
+ assert_eq!(b.source.unwrap().host_keys.len(), 1);
+ assert!(b.group_ref.is_some());
+
+ // The person tags it; the feed moves its port and swaps its tag.
+ let ItemData::Host(mut mine) = v.item(&web).unwrap().data else {
+ unreachable!()
+ };
+ mine.tags.push("mine".into());
+ save(&mut v, Some(&web), None, ItemData::Host(mine)).unwrap();
+ let second = doc(2222, &["fsn1"], None);
+ let r = apply(&mut v, &sid, &second.hosts).unwrap();
+ assert_eq!((r.added, r.updated, r.gone), (0, 1, 0));
+ let ItemData::Host(h) = v.item(&web).unwrap().data else {
+ unreachable!()
+ };
+ assert_eq!(
+ (h.port, h.tags.as_slice()),
+ (2222, &["mine".to_owned(), "fsn1".to_owned()][..])
+ );
+ // The snippet the feed dropped goes; nobody's edits are lost, it couldn't be edited.
+ assert_eq!(
+ apply_snippets(&mut v, &sid, &second.snippets).unwrap(),
+ (0, 0, 1)
+ );
+ let names: Vec = v
+ .items()
+ .unwrap()
+ .into_iter()
+ .filter_map(|i| match i.data {
+ ItemData::Snippet(s) => Some(s.name),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(names, ["Uptime"]);
+ }
}
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index 51f6b49..bdc4208 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -211,6 +211,9 @@ pub fn run() {
cloud::cloud_source_add,
cloud::cloud_source_settings,
cloud::cloud_add_key,
+ cloud::cloud_feed_add,
+ cloud::cloud_feed_settings,
+ cloud::cloud_auto_refresh,
agents::agents_list,
orgs::orgs_locked,
links::links_take,
@@ -221,6 +224,7 @@ pub fn run() {
vault::snippets_list,
vault::snippet_save,
vault::snippet_delete,
+ vault::snippet_sent,
snippet_import::snippets_read_csv,
snippet_import::snippets_csv_example,
tunnels::forwards_list,
diff --git a/apps/desktop/src-tauri/src/offboard.rs b/apps/desktop/src-tauri/src/offboard.rs
index 216d144..de7f3cb 100644
--- a/apps/desktop/src-tauri/src/offboard.rs
+++ b/apps/desktop/src-tauri/src/offboard.rs
@@ -38,7 +38,8 @@ pub struct ReachView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SecretView {
- /// `password` (a host's saved password), `key` (a shared SSH key) or `vpn`.
+ /// `password` (a host's saved password), `key` (a shared SSH key), `vpn`, or `feed` (a
+ /// feed's token, to rotate where the feed is published).
kind: &'static str,
item_id: String,
label: String,
@@ -158,6 +159,16 @@ pub async fn offboard_summary(
used_by: Vec::new(),
});
}
+ ItemData::CloudSource(c) if c.token.is_some() => {
+ r.other += 1;
+ secrets.push(SecretView {
+ kind: "feed",
+ item_id: item.id.clone(),
+ label: c.label.clone(),
+ vault_name: name.clone(),
+ used_by: Vec::new(),
+ });
+ }
ItemData::MemberKey(_) => {}
_ => r.other += 1,
}
diff --git a/apps/desktop/src-tauri/src/ssh.rs b/apps/desktop/src-tauri/src/ssh.rs
index 0a1924e..f57e813 100644
--- a/apps/desktop/src-tauri/src/ssh.rs
+++ b/apps/desktop/src-tauri/src/ssh.rs
@@ -184,6 +184,7 @@ pub(crate) fn resolve(target: Target, vault: &VaultState) -> Result c.label,
+ _ => "its feed".into(),
+ },
+ });
Ok(Hop {
host: host.hostname,
port: host.port,
user: host.user,
auth,
via_command: host.jump_command,
+ listed_keys,
})
}
@@ -520,6 +534,7 @@ pub async fn ssh_connect(
auth: target.auth,
jumps,
via_command: target.via_command,
+ listed_keys: target.listed_keys,
dialer,
term: "xterm-256color".into(),
size: request.size,
diff --git a/apps/desktop/src-tauri/src/vault.rs b/apps/desktop/src-tauri/src/vault.rs
index 06a1d16..7543878 100644
--- a/apps/desktop/src-tauri/src/vault.rs
+++ b/apps/desktop/src-tauri/src/vault.rs
@@ -330,6 +330,8 @@ pub struct HostSourceView {
state: Option,
/// Reached through its cloud: `aws-ssm`, `aws-eice` or `gcp-iap`.
via: Option,
+ /// The feed that lists its host keys (which decide instead of a pinned key), if one does.
+ listed_by: Option,
}
#[derive(Serialize)]
@@ -373,6 +375,7 @@ impl HostView {
no_forwarding: s.no_forwarding,
state: s.state,
via: s.reach.map(|r| r.kind),
+ listed_by: (!s.host_keys.is_empty()).then(String::new),
}),
group_id: h.group_ref,
identity_id: h.identity_ref,
@@ -409,6 +412,9 @@ impl HostView {
}) = v.item(&s.source_id)
{
s.provider = c.provider;
+ if let Some(by) = s.listed_by.as_mut() {
+ *by = c.label;
+ }
}
Some(view)
}
@@ -1027,6 +1033,18 @@ pub struct SnippetView {
body: String,
tags: Vec,
updated_at: i64,
+ /// The feed that keeps it (it's read-only here), if one does.
+ source: Option,
+ /// Its feed changed the body since this device last pasted or ran it: ask first.
+ changed: bool,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SnippetSourceView {
+ source_id: String,
+ /// The feed's name.
+ label: String,
}
#[derive(Deserialize)]
@@ -1041,22 +1059,79 @@ pub struct SnippetInput {
tags: Vec,
}
+/// Where this device keeps the hash of the body it last sent of a feed's snippet (not synced).
+fn sent_setting(id: &str) -> String {
+ format!("snippet-sent:{id}")
+}
+
+fn body_hash(body: &str) -> String {
+ use sha2::{Digest, Sha256};
+ Sha256::digest(body.as_bytes())
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect()
+}
+
+fn snippet_view(
+ v: &LocalVault,
+ id: String,
+ vault_id: String,
+ updated_at: i64,
+ s: Snippet,
+) -> SnippetView {
+ let source = s.source.as_ref().map(|src| SnippetSourceView {
+ source_id: src.source_ref.clone(),
+ label: match v.item(&src.source_ref).map(|i| i.data) {
+ Ok(ItemData::CloudSource(c)) => c.label,
+ _ => "its feed".into(),
+ },
+ });
+ let changed = source.is_some()
+ && v.setting(&sent_setting(&id))
+ .ok()
+ .flatten()
+ .is_some_and(|sent| sent != body_hash(&s.body));
+ SnippetView {
+ id,
+ vault_id,
+ name: s.name,
+ body: s.body,
+ tags: s.tags,
+ updated_at,
+ source,
+ changed,
+ }
+}
+
+/// A feed's snippet can't be changed here: "make a copy" instead.
+fn from_feed(v: &LocalVault, id: &str) -> Result<(), String> {
+ match v.item(id).map(|i| i.data) {
+ Ok(ItemData::Snippet(Snippet {
+ source: Some(src),
+ name,
+ ..
+ })) => {
+ let feed = match v.item(&src.source_ref).map(|i| i.data) {
+ Ok(ItemData::CloudSource(c)) => c.label,
+ _ => "its feed".into(),
+ };
+ Err(format!(
+ "\"{name}\" comes from {feed}: it changes there, or make a copy to change it here"
+ ))
+ }
+ _ => Ok(()),
+ }
+}
+
#[tauri::command]
pub fn snippets_list(vault: State<'_, VaultState>) -> Result, String> {
- let mut snippets: Vec = vault
- .guard()?
+ let v = vault.guard()?;
+ let mut snippets: Vec = v
.items()
.map_err(err)?
.into_iter()
.filter_map(|i| match i.data {
- ItemData::Snippet(s) => Some(SnippetView {
- id: i.id,
- vault_id: i.vault_id,
- name: s.name,
- body: s.body,
- tags: s.tags,
- updated_at: i.updated_at,
- }),
+ ItemData::Snippet(s) => Some(snippet_view(&v, i.id, i.vault_id, i.updated_at, s)),
_ => None,
})
.collect();
@@ -1078,40 +1153,59 @@ pub fn snippet_save(
.map(|t| t.trim().to_owned())
.filter(|t| !t.is_empty())
.collect(),
+ source: None,
};
+ let mut v = vault.guard()?;
+ if let Some(id) = &input.id {
+ from_feed(&v, id)?;
+ }
let Item {
id,
vault_id,
updated_at,
data,
} = save(
- &mut *vault.guard()?,
+ &mut v,
input.id.as_deref(),
input.vault_id.as_deref(),
ItemData::Snippet(snippet),
)
.map_err(err)?;
- vault.changed();
- match data {
- ItemData::Snippet(s) => Ok(SnippetView {
- id,
- vault_id,
- name: s.name,
- body: s.body,
- tags: s.tags,
- updated_at,
- }),
+ let view = match data {
+ ItemData::Snippet(s) => snippet_view(&v, id, vault_id, updated_at, s),
_ => unreachable!("upsert returns the data it was given"),
- }
+ };
+ drop(v);
+ vault.changed();
+ Ok(view)
}
#[tauri::command]
pub fn snippet_delete(vault: State<'_, VaultState>, id: String) -> Result<(), String> {
- vault.guard()?.delete(&id).map_err(err)?;
+ {
+ let mut v = vault.guard()?;
+ from_feed(&v, &id)?;
+ v.delete(&id).map_err(err)?;
+ }
vault.changed();
Ok(())
}
+/// This device pasted or ran a feed's snippet: what it sent is what it'll compare the next
+/// change with.
+#[tauri::command]
+pub fn snippet_sent(vault: State<'_, VaultState>, id: String) -> Result<(), String> {
+ let v = vault.guard()?;
+ let ItemData::Snippet(s) = v.item(&id).map_err(err)?.data else {
+ return Err("not a snippet".into());
+ };
+ if s.source.is_some() {
+ v.set_setting(&sent_setting(&id), Some(&body_hash(&s.body)))
+ .map_err(err)?;
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 05fca81..a2042fd 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -10,6 +10,7 @@ import {
hostCas as hostCasApi,
keys as keysApi,
snippets as snippetsApi,
+ cloud as cloudApi,
forwards as forwardsApi,
device as deviceApi,
sync as syncApi,
@@ -54,6 +55,7 @@ import { HostEditor } from "./HostEditor";
import { GroupEditor, IdentityEditor } from "./Identities";
import { HostCaPanel } from "./HostCaPanel";
import { HostKeyChangedDialog, HostKeyDialog } from "./HostKeyDialog";
+import { SnippetCheckDialog } from "./SnippetCheck";
import { RouteBar } from "./RouteBar";
import { addPane, dividers, holds, moveDivider, panes, rects, removePane, type Divider, type Rect, type Split } from "./splits";
import { SessionLogsPanel } from "./SessionLogs";
@@ -169,7 +171,7 @@ type View =
/** Hosts from OpenSSH's configuration. */
| { kind: "importHosts" }
| { kind: "importSnippets" }
- | { kind: "cloud" }
+ | { kind: "cloud"; add?: "feed" }
| { kind: "groups" }
/** One group and everything in it. */
| { kind: "groupPage"; id: string }
@@ -390,6 +392,8 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
// A security key waiting for a touch to sign in.
const [touch, setTouch] = useState(null);
const [changedKeys, setChangedKeys] = useState([]);
+ // A feed's snippet that changed since this device last sent it, waiting for a yes.
+ const [checking, setChecking] = useState<{ snippet: SnippetView; run: boolean; go: () => void } | null>(null);
const [vpnPrompts, setVpnPrompts] = useState([]);
const [autoLock, setAutoLock] = useState(0);
useIdleLock(autoLock, onLock);
@@ -433,6 +437,32 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
}
}, []);
+ // Feeds refresh themselves on desktops: once the first sync since unlocking has brought what
+ // other devices refreshed (at once without sync; after 20 s at the latest), then every 15
+ // minutes. The backend skips a feed refreshed on any device in the last 10.
+ const feedsRan = useRef(false);
+ const feedsSyncAt = useRef(undefined);
+ const refreshFeeds = useCallback(() => {
+ feedsRan.current = true;
+ // What it changed shows at once (the lists otherwise follow only a sync).
+ void cloudApi.autoRefresh().then((n) => n > 0 && void refresh(), () => {});
+ }, [refresh]);
+ useEffect(() => {
+ if (mobileOs) return;
+ const first = window.setTimeout(() => feedsRan.current || refreshFeeds(), 20_000);
+ const every = window.setInterval(refreshFeeds, 15 * 60_000);
+ return () => (window.clearTimeout(first), window.clearInterval(every));
+ }, [refreshFeeds]);
+ useEffect(() => {
+ if (mobileOs || feedsRan.current || !syncStatus) return;
+ const syncing = syncStatus.enabled && !syncStatus.signedOut;
+ if (feedsSyncAt.current === undefined) {
+ feedsSyncAt.current = syncStatus.lastSyncAt;
+ if (syncing) return;
+ }
+ if (!syncing || (!syncStatus.syncing && syncStatus.lastSyncAt !== feedsSyncAt.current)) refreshFeeds();
+ }, [syncStatus, refreshFeeds]);
+
// After a move, the editor in front shows the item where it went (a copy has a new id).
const moves = useMemo(
() => ({
@@ -675,7 +705,20 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
if (view.kind !== "tab" || view.key !== key) open({ kind: "tab", key });
};
- const sendSnippet = (body: string, run: boolean) => sendTarget && sendTo(sendTarget.key, body, run);
+ /** What this device sent of a feed's snippet: a later change of it asks first. */
+ const markSent = (id: string) =>
+ void snippetsApi.sent(id).then(
+ () => setSnippets((all) => all.map((x) => (x.id === id ? { ...x, changed: false } : x))),
+ () => {},
+ );
+ /** Sends a snippet (`go`), asking first where its feed changed it since this device last did. */
+ const guardSnippet = (snippet: SnippetView | undefined, run: boolean, go: () => void) => {
+ if (snippet?.source && snippet.changed) return setChecking({ snippet, run, go });
+ go();
+ if (snippet?.source) markSent(snippet.id);
+ };
+ const sendSnippet = (body: string, run: boolean, snippet?: SnippetView) =>
+ sendTarget && guardSnippet(snippet, run, () => sendTo(sendTarget.key, body, run));
const hostOf = (t: Tab) => (t.target.kind === "saved" ? hostList.find((h) => h.id === (t.target as { hostId: string }).hostId) : undefined);
const current = tabs.find((t) => t.key === activeTab);
@@ -931,13 +974,15 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
onEditGroup={(id) => open({ kind: "group", id })}
onOpenGroup={(id) => open({ kind: "groupPage", id })}
onImport={() => open({ kind: "importHosts" })}
- onCloud={() => open({ kind: "cloud" })}
+ onCloud={(add) => open({ kind: "cloud", add })}
onQuickConnect={mobile ? undefined : () => open({ kind: "quick" })}
onClose={close}
/>
)}
{view.kind === "importHosts" && void refresh()} onClose={close} />}
- {view.kind === "cloud" && void refresh()} onClose={close} />}
+ {view.kind === "cloud" && (
+ void refresh()} onClose={close} />
+ )}
{(view.kind === "groups" || (view.kind === "groupPage" && !groupList.some((g) => g.id === view.id))) && (
void refresh()}
groups={groupList}
@@ -1056,6 +1101,19 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
)}
{prompts[0] && answer(prompts[0], a)} />}
+ {checking && (
+ {
+ setChecking(null);
+ if (!send) return;
+ checking.go();
+ markSent(checking.snippet.id);
+ }}
+ />
+ )}
{askPassword && (
open({ kind: "sync", totp: true })}
onSso={setSsoFor}
onApproval={setApprovalFor}
- onSend={sendTo}
+ onSend={(key, body, run, snippet) => guardSnippet(snippet, run, () => sendTo(key, body, run))}
onKey={(key, k) => controls.current.get(key)?.key(k)}
onCtrl={(key, on, used) => controls.current.get(key)?.ctrl(on, used)}
/>
@@ -1232,10 +1290,12 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
vpns={vpnList}
onFiles={currentHost && (() => connectHost(currentHost, undefined, "sftp"))}
snippets={snippetList}
- onSend={(body, run) =>
- currentSplit?.broadcast
- ? panes(currentSplit.layout).forEach((k) => tabs.find((t) => t.key === k)?.status === "connected" && sendTo(k, body, run, false))
- : sendTo(current.key, body, run)
+ onSend={(snippet, run) =>
+ guardSnippet(snippet, run, () =>
+ currentSplit?.broadcast
+ ? panes(currentSplit.layout).forEach((k) => tabs.find((t) => t.key === k)?.status === "connected" && sendTo(k, snippet.body, run, false))
+ : sendTo(current.key, snippet.body, run),
+ )
}
onSplit={splitWith}
broadcast={currentSplit ? { on: currentSplit.broadcast, panes: panes(currentSplit.layout).length } : null}
diff --git a/apps/desktop/src/CloudPage.tsx b/apps/desktop/src/CloudPage.tsx
index 0450f48..33dc269 100644
--- a/apps/desktop/src/CloudPage.tsx
+++ b/apps/desktop/src/CloudPage.tsx
@@ -11,6 +11,7 @@ import {
type CloudRefresh,
type CloudSource,
type DeviceAuth,
+ type FeedInput,
type HostView,
type KeyView,
type SourceSettings,
@@ -31,8 +32,15 @@ function ago(seconds: number, now = Date.now() / 1000) {
return dateText(seconds * 1000);
}
-const refreshed = (r: CloudRefresh) =>
- [r.added && `${plural(r.added, "host")} added`, r.updated && `${r.updated} updated`, r.gone && `${r.gone} gone from the cloud`]
+const refreshed = (r: CloudRefresh, feed: boolean) =>
+ [
+ r.added && `${plural(r.added, "host")} added`,
+ r.updated && `${r.updated} updated`,
+ r.gone && `${r.gone} gone from the ${feed ? "feed" : "cloud"}`,
+ r.snippetsAdded && `${plural(r.snippetsAdded, "snippet")} added`,
+ r.snippetsUpdated && `${plural(r.snippetsUpdated, "snippet")} changed`,
+ r.snippetsRemoved && `${plural(r.snippetsRemoved, "snippet")} removed`,
+ ]
.filter(Boolean)
.join(", ") || "Nothing changed";
@@ -42,14 +50,17 @@ interface Note {
skipped?: string[];
withoutAddress?: string[];
couldNot?: string[];
+ /** A feed's entries left out (what `couldNot` holds for a cloud). */
+ feed?: boolean;
}
-const noteOf = (sourceId: string, r: CloudRefresh): Note => ({
+const noteOf = (sourceId: string, r: CloudRefresh, feed = false): Note => ({
sourceId,
- text: refreshed(r),
+ text: refreshed(r, feed),
skipped: r.withoutShell,
withoutAddress: r.withoutAddress,
couldNot: r.skipped,
+ feed,
});
/** A sign-in in progress: the code, the page to type it at, until it's approved. */
@@ -228,6 +239,85 @@ function SettingsForm({
);
}
+/** A feed's name, address and token, for adding one or changing it. */
+function FeedForm({
+ source,
+ keys,
+ vaultId,
+ busy,
+ submit,
+ onSubmit,
+ onCancel,
+}: {
+ source?: CloudSource;
+ keys: KeyView[];
+ vaultId: string;
+ busy: boolean;
+ submit: string;
+ onSubmit: (input: FeedInput, keyId: string | null) => void;
+ onCancel: () => void;
+}) {
+ const [label, setLabel] = useState(source?.label ?? "");
+ const [url, setUrl] = useState(source?.url ?? "");
+ const [token, setToken] = useState("");
+ const [keyId, setKeyId] = useState("");
+ const vaultKeys = keys.filter((k) => k.vaultId === vaultId);
+ const valid = /^https:\/\/\S+$|^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]\S*)?$/.test(url.trim()) && (!!source || token.trim() !== "");
+ return (
+
+ );
+}
+
/** Clouds: their machines and environments as hosts, kept in step. Vallic Cloud signs in on each
* device (a code typed in the browser); AWS, Google Cloud and Azure list with their CLI on this
* device. The hosts sync like any others. */
@@ -235,12 +325,15 @@ export function CloudPage({
vaults,
keys,
hosts,
+ adding: addingFirst,
onChanged,
onClose,
}: {
vaults: VaultEntry[];
keys: KeyView[];
hosts: HostView[];
+ /** Opened to add this (Add from… → Feed URL…). */
+ adding?: CloudProvider;
onChanged: () => void;
onClose: () => void;
}) {
@@ -251,7 +344,7 @@ export function CloudPage({
const [note, setNote] = useState(null);
const [busy, setBusy] = useState(null);
const [removing, setRemoving] = useState(null);
- const [adding, setAdding] = useState(null);
+ const [adding, setAdding] = useState(addingFirst ?? null);
const [editing, setEditing] = useState(null);
// The poll of the sign-in on screen (a new sign-in or Cancel ends it).
const polling = useRef(0);
@@ -307,6 +400,16 @@ export function CloudPage({
);
};
+ const addFeed = (input: FeedInput, keyId: string | null) => {
+ setBusy("adding");
+ setError("");
+ setNote(null);
+ cloud.addFeed(input, { vaultId, keyId }).then(
+ (r) => (setBusy(null), setAdding(null), setNote(noteOf(r.source.id, r.refresh, true)), load(), onChanged()),
+ (e) => (setBusy(null), setError(errorText(e)), load()),
+ );
+ };
+
const add = (provider: CloudProvider, settings: SourceSettings, keyId: string | null) => {
setBusy("adding");
setError("");
@@ -320,7 +423,7 @@ export function CloudPage({
return (
@@ -348,10 +451,13 @@ export function CloudPage({
const vaultKeys = keys.filter((k) => k.vaultId === s.vaultId);
const key = vaultKeys.find((k) => k.id === s.keyId);
const vallic = s.provider === "vallic";
+ const feed = s.provider === "feed";
const provider = PROVIDERS[s.provider]?.name ?? s.provider;
const how = vallic
? null
- : [
+ : feed
+ ? `${s.url ?? ""} · token ${s.tokenEnd ?? "…"}`
+ : [
s.profile && `${s.provider === "aws" ? "profile" : "context"} ${s.profile}`,
s.scopes.length
? s.scopes.join(", ")
@@ -384,12 +490,13 @@ export function CloudPage({
{s.account && `${s.account} · `}
{plural(s.hosts, "host")}
{s.gone > 0 && ` (${s.gone} gone)`}
+ {s.snippets > 0 && ` · ${plural(s.snippets, "snippet")}`}
{s.refreshedAt > 0 && ` · refreshed ${ago(s.refreshedAt)}`}
- {how &&
{how}
}
+ {how &&
{how}
}
{s.signedIn ? (
- act(s.id, cloud.refresh(s.id), (r) => setNote(noteOf(s.id, r as CloudRefresh)))}>
+ act(s.id, cloud.refresh(s.id), (r) => setNote(noteOf(s.id, r as CloudRefresh, feed)))}>
{busy === s.id ? "Refreshing…" : "Refresh"}
) : (
@@ -407,10 +514,31 @@ export function CloudPage({
Without {s.reach === "private" ? "a private" : "a public"} address: {note.withoutAddress.join(", ")}.
)}
- {note.couldNot && note.couldNot.length > 0 && Couldn't list: {note.couldNot.join("; ")}
}
+ {note.couldNot && note.couldNot.length > 0 && (
+
+ {note.feed ? "Skipped" : "Couldn't list"}: {note.couldNot.join("; ")}
+
+ )}
>
)}
- {editing === s.id && !vallic ? (
+ {s.failed && note?.sourceId !== s.id && busy !== s.id && The last refresh failed: {s.failed}
}
+ {editing === s.id && feed ? (
+ setEditing(null)}
+ onSubmit={(input) =>
+ act(
+ s.id,
+ cloud.feedSettings(s.id, input).then(() => cloud.refresh(s.id)),
+ (r) => (setEditing(null), setNote(noteOf(s.id, r as CloudRefresh, true))),
+ )
+ }
+ />
+ ) : editing === s.id && !vallic ? (
(setRemoving(null), act(s.id, cloud.remove(s.id, true)))}>
- Remove it and its hosts
+ {s.snippets > 0 ? "Remove it, its hosts and snippets" : "Remove it and its hosts"}
(setRemoving(null), act(s.id, cloud.remove(s.id, false)))}>
- Remove it, keep the hosts
+ {s.snippets > 0 ? "Remove it, keep the hosts and snippets" : "Remove it, keep the hosts"}
setRemoving(null)}>
Cancel
@@ -490,7 +618,27 @@ export function CloudPage({
);
})}
- {adding && (
+ {adding === "feed" && (
+
+ Add a feed
+
+ Hosts and snippets from a JSON document at an address, read with a token: from Terraform output, a CMDB or an internal endpoint. Its hosts and
+ snippets stay in step with it; your renames, keys, tags and notes stay yours.
+
+
+ setAdding(null)}
+ onSubmit={addFeed}
+ />
+
+ )}
+
+ {adding && adding !== "feed" && (
Add {PROVIDERS[adding].name}
@@ -522,6 +670,9 @@ export function CloudPage({
Add {PROVIDERS[p].name}
))}
+ (setError(""), setNote(null), setAdding("feed"))}>
+ Add a feed
+
)}
diff --git a/apps/desktop/src/Lists.tsx b/apps/desktop/src/Lists.tsx
index 6789be4..b769ed3 100644
--- a/apps/desktop/src/Lists.tsx
+++ b/apps/desktop/src/Lists.tsx
@@ -197,7 +197,7 @@ function HostRows({ list, things, ...p }: HostRowsProps) {
{h.source?.gone && (
- gone from {PROVIDERS[h.source.provider || "vallic"]?.name ?? "its cloud"}
+ gone from {h.source.provider === "feed" ? "its feed" : (PROVIDERS[h.source.provider || "vallic"]?.name ?? "its cloud")}
)}
{h.source?.state && !h.source.gone && (
@@ -248,7 +248,8 @@ interface HostsProps {
onOpenGroup: (id: string) => void;
/** Hosts from OpenSSH's configuration (desktop). */
onImport: () => void;
- onCloud: () => void;
+ /** Clouds, or straight to adding a feed. */
+ onCloud: (add?: "feed") => void;
/** Quick connect (desktop, where this page is home once there are hosts). */
onQuickConnect?: () => void;
/** After a change made here (several deleted, moved to a group). */
@@ -289,7 +290,8 @@ export function HostsPage(p: HostsProps) {
p.onCloud() },
+ { label: "Feed URL…", sub: "Hosts and snippets your team publishes at an address", onClick: () => p.onCloud("feed") },
]}
/>
)}
@@ -843,7 +845,7 @@ interface SnippetsProps {
onOpen: (id?: string) => void;
/** From a CSV file. */
onImport: () => void;
- onSend: (body: string, run: boolean) => void;
+ onSend: (body: string, run: boolean, snippet?: SnippetView) => void;
onChanged: () => void;
onClose: () => void;
}
@@ -904,21 +906,22 @@ export function SnippetsPage(p: SnippetsProps) {
<>
{p.target && (
<>
- p.onSend(sn.body, false)}>Paste
- p.onSend(sn.body, true)}>Run
+ p.onSend(sn.body, false, sn)}>Paste
+ p.onSend(sn.body, true, sn)}>Run
>
)}
- p.onOpen(sn.id)} />
+ p.onOpen(sn.id)} />
>
}
>
p.onOpen(sn.id)}
- title={`Edit ${sn.name}`}
+ title={`${sn.source ? "Open" : "Edit"} ${sn.name}`}
lead={ }
name={sn.name}
beside={ }
+ sub={sn.source ? `From ${sn.source.label} (read-only)` : undefined}
/>
{lines[0]}
diff --git a/apps/desktop/src/RouteBar.tsx b/apps/desktop/src/RouteBar.tsx
index f385b26..c46b5af 100644
--- a/apps/desktop/src/RouteBar.tsx
+++ b/apps/desktop/src/RouteBar.tsx
@@ -22,7 +22,7 @@ interface Props {
onFiles?: () => void;
snippets: SnippetView[];
/** Paste a snippet into this session, or run it. */
- onSend: (body: string, run: boolean) => void;
+ onSend: (snippet: SnippetView, run: boolean) => void;
onNewSnippet: () => void;
/** The session is recorded into the vault. */
recording?: boolean;
@@ -152,12 +152,17 @@ export function RouteBar({ target, status, hosts, vpns, onFiles, snippets, onSen
Recording
|
)}
- {pinned && (
+ {host?.source?.listedBy && (
+
+ key listed by {host.source.listedBy}
+
+ )}
+ {pinned && !host?.source?.listedBy && (
{keyType(pinned.algorithm)} {shortFingerprint(pinned.fingerprint)}
)}
- {!pinned && certifiedBy && (
+ {!pinned && certifiedBy && !host?.source?.listedBy && (
certified by {certifiedBy}
@@ -265,7 +270,7 @@ function SnippetMenu({
}: {
snippets: SnippetView[];
connected: boolean;
- onSend: (body: string, run: boolean) => void;
+ onSend: (snippet: SnippetView, run: boolean) => void;
onNew: () => void;
/** Broadcasting: snippets go to this many panes (0: just this one). */
all: number;
@@ -273,9 +278,9 @@ function SnippetMenu({
const [open, setOpen] = useState(false);
const ref = useDismiss(open, setOpen);
- const send = (body: string, run: boolean) => {
+ const send = (snippet: SnippetView, run: boolean) => {
setOpen(false);
- onSend(body, run);
+ onSend(snippet, run);
};
return (
@@ -296,12 +301,12 @@ function SnippetMenu({
className="menu-item"
disabled={!connected}
title={`Paste into the session: ${s.body}`}
- onClick={() => send(s.body, false)}
+ onClick={() => send(s, false)}
>
{s.name}
{s.body.split("\n")[0]}
- send(s.body, true)}>
+ send(s, true)}>
Run
diff --git a/apps/desktop/src/SnippetCheck.tsx b/apps/desktop/src/SnippetCheck.tsx
new file mode 100644
index 0000000..715c31b
--- /dev/null
+++ b/apps/desktop/src/SnippetCheck.tsx
@@ -0,0 +1,31 @@
+import { useEffect, useRef } from "react";
+import { dateText, type SnippetView } from "./api";
+
+/** A feed's snippet changed since this device last pasted or ran it: what it is now, before it
+ * goes to the terminal. */
+export function SnippetCheckDialog({ snippet, run, onAnswer }: { snippet: SnippetView; run: boolean; onAnswer: (send: boolean) => void }) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ ref.current?.showModal();
+ }, []);
+
+ return (
+ (e.preventDefault(), onAnswer(false))}>
+ {snippet.name} changed
+
+ Changed by {snippet.source?.label ?? "its feed"} on {dateText(snippet.updatedAt)}, since you last {run ? "ran" : "pasted"} it on this
+ device. It now {run ? "runs" : "pastes"}:
+
+ {snippet.body}
+
+ onAnswer(false)}>
+ Cancel
+
+ onAnswer(true)}>
+ {run ? "Run it" : "Paste it"}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/SnippetPanel.tsx b/apps/desktop/src/SnippetPanel.tsx
index 32c543b..25aa3e6 100644
--- a/apps/desktop/src/SnippetPanel.tsx
+++ b/apps/desktop/src/SnippetPanel.tsx
@@ -10,12 +10,15 @@ interface Props {
onSaved: (s: SnippetView) => void;
onDeleted: () => void;
onCancel: () => void;
- onSend: (body: string, run: boolean) => void;
+ /** `snippet`: the saved one being sent (a feed's may ask first). */
+ onSend: (body: string, run: boolean, snippet?: SnippetView) => void;
}
export function SnippetPanel({ snippet, vaults, targetTitle, onSaved, onDeleted, onCancel, onSend }: Props) {
const [vaultId, setVaultId] = useState(snippet?.vaultId ?? personalVault(vaults));
- const readOnly = isReadOnly(vaults, snippet?.vaultId);
+ // A feed's snippet changes in the feed: here it's read, run or copied.
+ const feed = snippet?.source ?? null;
+ const readOnly = isReadOnly(vaults, snippet?.vaultId) || !!feed;
const [name, setName] = useState(snippet?.name ?? "");
const [body, setBody] = useState(snippet?.body ?? "");
const [tags, setTags] = useState(snippet?.tags.join(", ") ?? "");
@@ -40,22 +43,55 @@ export function SnippetPanel({ snippet, vaults, targetTitle, onSaved, onDeleted,
}
};
+ /** A snippet of one's own with the feed's commands, in the same vault where it can be written. */
+ const copy = async () => {
+ if (!snippet) return;
+ setBusy(true);
+ setError("");
+ try {
+ const into = isReadOnly(vaults, snippet.vaultId) ? personalVault(vaults) : snippet.vaultId;
+ onSaved(await snippets.save({ vaultId: into || undefined, name: `${snippet.name} (copy)`, body: snippet.body, tags: snippet.tags }));
+ } catch (err) {
+ setError(errorText(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
return (
diff --git a/apps/desktop/src/TeamsPanel.tsx b/apps/desktop/src/TeamsPanel.tsx
index 262d4dc..7131ca4 100644
--- a/apps/desktop/src/TeamsPanel.tsx
+++ b/apps/desktop/src/TeamsPanel.tsx
@@ -2029,7 +2029,7 @@ function Offboard({ org, member, onBack, onChanged }: { org: OrgInfo; member: Me
4. Change what they could see
- {summary.secrets.length === 0 &&
No saved passwords, SSH keys or VPN profiles in those vaults.
}
+ {summary.secrets.length === 0 &&
No saved passwords, SSH keys, VPN profiles or feed tokens in those vaults.
}
{summary.secrets.map((s) => {
const r = replaced[s.itemId];
@@ -2038,7 +2038,9 @@ function Offboard({ org, member, onBack, onChanged }: { org: OrgInfo; member: Me
tick(s.itemId)} />
- {s.kind === "password" ? "Password of" : s.kind === "key" ? "SSH key" : "VPN profile"} {s.label}
+ {s.kind === "password" ? "Password of" : s.kind === "key" ? "SSH key" : s.kind === "feed" ? "Rotate the token for" : "VPN profile"}{" "}
+ {s.label}
+ {s.kind === "feed" && " (a feed: change it where the feed is published, then in the source's settings)"}
{" "}
· {s.vaultName}
diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts
index 5eee015..fb98538 100644
--- a/apps/desktop/src/api.ts
+++ b/apps/desktop/src/api.ts
@@ -263,6 +263,8 @@ export interface HostView {
state: string | null;
/** Reached through its cloud. */
via: CloudReachKind | null;
+ /** The feed that lists its host keys (they decide, rather than a pinned key), if one does. */
+ listedBy: string | null;
} | null;
groupId: string | null;
/** Shared credentials it signs in with. */
@@ -425,6 +427,10 @@ export interface SnippetView {
body: string;
tags: string[];
updatedAt: number;
+ /** The feed that keeps it (it's read-only here), if one does. */
+ source: { sourceId: string; label: string } | null;
+ /** Its feed changed the body since this device last pasted or ran it: ask first. */
+ changed: boolean;
}
export interface SnippetInput {
@@ -463,6 +469,8 @@ export const snippets = {
list: () => invoke("snippets_list"),
save: (input: SnippetInput) => invoke("snippet_save", { input }),
remove: (id: string) => invoke("snippet_delete", { id }),
+ /** This device pasted or ran a feed's snippet (what a later change of it is compared with). */
+ sent: (id: string) => invoke("snippet_sent", { id }),
/** The snippets in a CSV file's text (Name, Command, Tags columns); `name` is the file's. */
readCsv: (name: string, text: string) => invoke("snippets_read_csv", { name, text }),
/** Saves an example of that CSV in the downloads folder; says where. */
@@ -1130,7 +1138,7 @@ export interface Reach {
/** A secret the member could read: change it after they leave. */
export interface Secret {
- kind: "password" | "key" | "vpn";
+ kind: "password" | "key" | "vpn" | "feed";
itemId: string;
label: string;
vaultName: string;
@@ -1443,7 +1451,7 @@ export const openLink = (url: string) => invoke("open_link", { url });
// ------------------------------------------------------------------ Cloud sources
-export type CloudProvider = "vallic" | "aws" | "gcp" | "azure" | "hetzner" | "digitalocean";
+export type CloudProvider = "vallic" | "aws" | "gcp" | "azure" | "hetzner" | "digitalocean" | "feed";
/** Reaching a machine through its cloud rather than an address. */
export type CloudReachKind = "aws-ssm" | "aws-eice" | "gcp-iap";
@@ -1462,10 +1470,12 @@ export const PROVIDERS: Record
invoke<{ source: CloudSource; refresh: CloudRefresh }>("cloud_source_add", { provider, settings, vaultId: opts.vaultId ?? null, keyId: opts.keyId ?? null }),
settings: (sourceId: string, settings: SourceSettings) => invoke("cloud_source_settings", { sourceId, settings }),
+ /** A feed, read once with its token before it's kept, then brought in. */
+ addFeed: (input: FeedInput, opts: { vaultId?: string | null; keyId?: string | null } = {}) =>
+ invoke<{ source: CloudSource; refresh: CloudRefresh }>("cloud_feed_add", { input, vaultId: opts.vaultId ?? null, keyId: opts.keyId ?? null }),
+ feedSettings: (sourceId: string, input: FeedInput) => invoke("cloud_feed_settings", { sourceId, input }),
+ /** Refreshes the feeds not refreshed in the last 10 minutes (desktop); how many it did. */
+ autoRefresh: () => invoke("cloud_auto_refresh"),
};
export const errorText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
diff --git a/apps/desktop/src/mobile/MobileWorkspace.tsx b/apps/desktop/src/mobile/MobileWorkspace.tsx
index df3e8ce..5d577b3 100644
--- a/apps/desktop/src/mobile/MobileWorkspace.tsx
+++ b/apps/desktop/src/mobile/MobileWorkspace.tsx
@@ -86,7 +86,8 @@ interface Props {
onSso: (need: SsoNeed) => void;
/** An organization's vaults wait for this device's approval. */
onApproval: (need: ApprovalNeed) => void;
- onSend: (key: number, body: string, run: boolean) => void;
+ /** Text into a session; `snippet` where it's one (a feed's may ask first). */
+ onSend: (key: number, body: string, run: boolean, snippet?: SnippetView) => void;
onKey: (key: number, k: ExtraKey) => void;
/** Latches Ctrl for the next key typed in the session; `used` is called when it applies. */
onCtrl: (key: number, on: boolean, used: () => void) => void;
@@ -753,14 +754,14 @@ function TerminalBar(p: Props & { tab: MobileTab }) {
type="button"
className="m-row-main"
disabled={p.tab.status !== "connected"}
- onClick={() => (close(), p.onSend(p.tab.key, s.body, false))}
+ onClick={() => (close(), p.onSend(p.tab.key, s.body, false, s))}
>
{s.name}
{s.body.split("\n")[0]}
- (close(), p.onSend(p.tab.key, s.body, true))}>
+ (close(), p.onSend(p.tab.key, s.body, true, s))}>
Run
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index dd492e8..ad0699b 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -3155,6 +3155,28 @@ dialog.modal.approval {
width: min(520px, calc(100% - 32px));
}
+/* A feed's snippet that changed since it was last sent from this device. */
+dialog.modal.snippet-check {
+ width: min(560px, calc(100% - 32px));
+}
+
+dialog.modal.snippet-check .snippet-body {
+ max-height: 240px;
+ margin: 0 0 16px;
+ padding: 10px 12px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-s);
+ background: var(--bg);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+/* A feed's address on its source card. */
+.cloud-source .cloud-url {
+ overflow-wrap: anywhere;
+}
+
/* Field headings that aren't labels (a choice, a list). */
.field-label {
color: var(--muted);
diff --git a/assets/demo/morphgate.svg b/assets/demo/morphgate.svg
new file mode 100644
index 0000000..a55bc39
--- /dev/null
+++ b/assets/demo/morphgate.svg
@@ -0,0 +1,12 @@
+
+MorphGate: connecting to web-1
+
+
+
+
+Hosts 5 Groups 2 Keys 1 Snippets Tunnels VPNs Session logs Search hosts OPEN web-1 deploy@10.0.12.34:22 HOSTS bastion deploy@bastion.shop.e… Production 3 db-1 deploy@10.0.12.40 web-1 deploy@10.0.12.34:… web-2 deploy@10.0.12.35 Staging 1 staging deploy@10.0.20.11 Sync off REC web-1 web-1 deploy@10.0.… Recording ED25519 SHA256:803o…W12k Files Split >_ Snippets deploy@web-1 : ~ $ deploy@web-1 : ~ $ u deploy@web-1 : ~ $ up deploy@web-1 : ~ $ upt deploy@web-1 : ~ $ upti deploy@web-1 : ~ $ uptim deploy@web-1 : ~ $ uptime 12:29:31 up 7 days, 22:16, 4 users, load average: 2.52, 4.16, 4.88 deploy@web-1 : ~ $ deploy@web-1 : ~ $ c deploy@web-1 : ~ $ cd deploy@web-1 : ~ $ cd s deploy@web-1 : ~ $ cd sh deploy@web-1 : ~ $ cd sho deploy@web-1 : ~ $ cd shop deploy@web-1 : ~ $ cd shop & deploy@web-1 : ~ $ cd shop && deploy@web-1 : ~ $ cd shop && g deploy@web-1 : ~ $ cd shop && gi deploy@web-1 : ~ $ cd shop && git deploy@web-1 : ~ $ cd shop && git l deploy@web-1 : ~ $ cd shop && git lo deploy@web-1 : ~ $ cd shop && git log deploy@web-1 : ~ $ cd shop && git log - deploy@web-1 : ~ $ cd shop && git log -- deploy@web-1 : ~ $ cd shop && git log --o deploy@web-1 : ~ $ cd shop && git log --on deploy@web-1 : ~ $ cd shop && git log --one deploy@web-1 : ~ $ cd shop && git log --onel deploy@web-1 : ~ $ cd shop && git log --oneli deploy@web-1 : ~ $ cd shop && git log --onelin deploy@web-1 : ~ $ cd shop && git log --oneline deploy@web-1 : ~ $ cd shop && git log --oneline - deploy@web-1 : ~ $ cd shop && git log --oneline -- deploy@web-1 : ~ $ cd shop && git log --oneline --d deploy@web-1 : ~ $ cd shop && git log --oneline --de deploy@web-1 : ~ $ cd shop && git log --oneline --dec deploy@web-1 : ~ $ cd shop && git log --oneline --deco deploy@web-1 : ~ $ cd shop && git log --oneline --decor deploy@web-1 : ~ $ cd shop && git log --oneline --decora deploy@web-1 : ~ $ cd shop && git log --oneline --decorat deploy@web-1 : ~ $ cd shop && git log --oneline --decorate deploy@web-1 : ~ $ cd shop && git log --oneline --decorate - deploy@web-1 : ~ $ cd shop && git log --oneline --decorate -4 fcad5ec ( HEAD -> main , tag: v2.4.1 ) TLS everywhere 4543d8c Run in a container 777e8c2 Serve it with nginx 47a7717 Start the shop deploy@web-1 : ~/shop $ deploy@web-1 : ~/shop $ l deploy@web-1 : ~/shop $ ls Dockerfile README.md config deploy@web-1 : ~/shop $
+Hosts 5 Groups 2 Keys 1 Snippets Tunnels VPNs Session logs Search hosts HOSTS bastion deploy@bastion.shop.e… Production 3 db-1 deploy@10.0.12.40 web-1 deploy@10.0.12.34:… web-2 deploy@10.0.12.35 Staging 1 staging deploy@10.0.20.11 Sync off Hosts Add from… Quick connect New group New host Close 5 hosts in 2 groups. Search by name, address, user or tag shop 3 postgres 1 Select HOST ROUTE SIGNS IN WITH TAGS Actions Not in a group 1 bastion deploy@bastion.shop.… direct key deploy Production 3 Edit group db-1 deploy@10.0.12.40:… direct key deploy postgres web-1 deploy@10.0.12.34… direct key deploy recorded shop web-2 deploy@10.0.12.35:… direct key deploy shop Staging 1 Edit group staging deploy@10.0.20.11:… direct key deploy shop
+
+
+
+
diff --git a/crates/mg-inventory/src/feed.rs b/crates/mg-inventory/src/feed.rs
new file mode 100644
index 0000000..f27e5d4
--- /dev/null
+++ b/crates/mg-inventory/src/feed.rs
@@ -0,0 +1,664 @@
+//! Feeds: hosts and snippets from a URL, as a JSON document a team publishes (from Terraform
+//! output, a CMDB, a cron job, an internal endpoint), read with a token (docs/features/
+//! cloud-sources.md, "Feeds"). A feed carries data only: nothing in it runs on the device (no
+//! proxy or local commands, no reaching through a cloud). Entries that don't hold up are skipped
+//! and said; a document that doesn't is refused whole, so a bad one changes nothing.
+
+use std::collections::{HashMap, HashSet};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use url::Url;
+
+use crate::{Error, InventoryHost, Result};
+
+/// The format this version reads.
+pub const VERSION: u64 = 1;
+/// The largest document read.
+pub const MAX_BYTES: usize = 5 * 1024 * 1024;
+pub const MAX_HOSTS: usize = 5_000;
+pub const MAX_SNIPPETS: usize = 1_000;
+/// The largest snippet body.
+pub const MAX_BODY: usize = 64 * 1024;
+/// How long a feed has to answer.
+pub const TIMEOUT: Duration = Duration::from_secs(60);
+
+/// An entry's `id`, and how long a label, group, tag or state may be.
+const MAX_ID: usize = 200;
+const MAX_TEXT: usize = 200;
+const MAX_TAG: usize = 64;
+const MAX_TAGS: usize = 50;
+const MAX_HOST_KEYS: usize = 16;
+
+/// Host key types a feed may list (OpenSSH's, without certificates).
+const KEY_TYPES: [&str; 7] = [
+ "ssh-ed25519",
+ "ecdsa-sha2-nistp256",
+ "ecdsa-sha2-nistp384",
+ "ecdsa-sha2-nistp521",
+ "ssh-rsa",
+ "sk-ssh-ed25519@openssh.com",
+ "sk-ecdsa-sha2-nistp256@openssh.com",
+];
+
+/// A snippet as a feed gives it.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FeedSnippet {
+ /// `feed:`: what a refresh matches the vault's snippet on.
+ pub external_id: String,
+ pub name: String,
+ pub body: String,
+ pub tags: Vec,
+}
+
+/// What a feed lists.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Feed {
+ /// What the feed calls itself (`"Vallic operations"`).
+ pub name: Option,
+ pub hosts: Vec,
+ pub snippets: Vec,
+ /// Entries left out, and why (`server-1234: the port isn't 1 to 65535`).
+ pub skipped: Vec,
+}
+
+fn local(url: &Url) -> bool {
+ matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
+}
+
+/// A feed's address: `https://`, or `http://` on this computer.
+pub fn check_url(url: &str) -> Result {
+ let url = url.trim();
+ let parsed = Url::parse(url).map_err(|e| Error::Api(format!("{url}: {e}")))?;
+ if parsed.scheme() == "https" || (parsed.scheme() == "http" && local(&parsed)) {
+ Ok(parsed)
+ } else {
+ Err(Error::Api(format!(
+ "{url}: a feed's address starts with https://"
+ )))
+ }
+}
+
+/// Reads the feed at `url` with `token`. `name` is the source's, for messages.
+pub async fn fetch(name: &str, url: &str, token: &str) -> Result {
+ let url = check_url(url)?;
+ // Redirects stay on https (or this computer); the token isn't sent to another host.
+ let redirects = reqwest::redirect::Policy::custom(|attempt| {
+ let ok = attempt.url().scheme() == "https"
+ || (attempt.url().scheme() == "http" && local(attempt.url()));
+ if attempt.previous().len() >= 5 || !ok {
+ attempt.stop()
+ } else {
+ attempt.follow()
+ }
+ });
+ let http = reqwest::Client::builder()
+ .user_agent(concat!("MorphGate/", env!("CARGO_PKG_VERSION")))
+ .timeout(TIMEOUT)
+ .redirect(redirects)
+ .build()
+ .map_err(|e| Error::Api(e.to_string()))?;
+ let failed = |e: reqwest::Error| {
+ if e.is_timeout() {
+ Error::Api(format!("{name} took longer than 60 s to answer"))
+ } else {
+ Error::Unreachable(name.to_owned(), e.to_string())
+ }
+ };
+ let mut response = http
+ .get(url)
+ .bearer_auth(token)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .map_err(failed)?;
+ match response.status().as_u16() {
+ 200..=299 => {}
+ 401 | 403 => return Err(Error::TokenRefused(name.to_owned())),
+ status => {
+ return Err(Error::Api(
+ format!(
+ "{name} answered {status} {}",
+ response.status().canonical_reason().unwrap_or("")
+ )
+ .trim_end()
+ .to_owned(),
+ ));
+ }
+ }
+ let too_large = || {
+ Error::Api(format!(
+ "{name} sent more than 5 MiB: a feed is at most that"
+ ))
+ };
+ if response
+ .content_length()
+ .is_some_and(|n| n > MAX_BYTES as u64)
+ {
+ return Err(too_large());
+ }
+ let mut body = Vec::new();
+ while let Some(chunk) = response.chunk().await.map_err(failed)? {
+ if body.len() + chunk.len() > MAX_BYTES {
+ return Err(too_large());
+ }
+ body.extend_from_slice(&chunk);
+ }
+ parse(name, &body)
+}
+
+/// An optional field: absent and `null` both read as empty.
+fn nullable<'de, D, T>(d: D) -> std::result::Result
+where
+ D: serde::Deserializer<'de>,
+ T: Default + Deserialize<'de>,
+{
+ Ok(Option::::deserialize(d)?.unwrap_or_default())
+}
+
+#[derive(Deserialize)]
+struct RawHost {
+ id: String,
+ #[serde(default, deserialize_with = "nullable")]
+ label: String,
+ hostname: String,
+ #[serde(default)]
+ port: Option,
+ user: String,
+ #[serde(default, deserialize_with = "nullable")]
+ group: String,
+ #[serde(default, deserialize_with = "nullable")]
+ tags: Vec,
+ #[serde(default)]
+ jump: Option,
+ #[serde(default)]
+ key: Option,
+ #[serde(default, deserialize_with = "nullable")]
+ host_keys: Vec,
+ #[serde(default)]
+ state: Option,
+}
+
+#[derive(Deserialize)]
+struct RawSnippet {
+ id: String,
+ name: String,
+ body: String,
+ #[serde(default, deserialize_with = "nullable")]
+ tags: Vec,
+}
+
+/// `[A-Za-z0-9._:/@-]`, 1 to 200 characters.
+fn valid_id(id: &str) -> bool {
+ !id.is_empty()
+ && id.len() <= MAX_ID
+ && id
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b"._:/@-".contains(&b))
+}
+
+/// A host name or user: no whitespace or control characters, and no leading `-` (it would read
+/// as an option).
+fn word(s: &str, what: &str) -> std::result::Result {
+ if s.is_empty() {
+ return Err(format!("no {what}"));
+ }
+ if s.len() > 255 || s.starts_with('-') || s.chars().any(|c| c.is_whitespace() || c.is_control())
+ {
+ return Err(format!("the {what} {s:?} isn't one SSH can use"));
+ }
+ Ok(s.to_owned())
+}
+
+/// Text to show: control characters out, trimmed, at most `max` characters.
+fn text(s: &str, max: usize) -> String {
+ let clean: String = s
+ .chars()
+ .map(|c| if c.is_control() { ' ' } else { c })
+ .collect();
+ clean
+ .trim()
+ .chars()
+ .take(max)
+ .collect::()
+ .trim()
+ .to_owned()
+}
+
+fn tags(raw: &[String]) -> Vec {
+ let mut out: Vec = Vec::new();
+ for t in raw {
+ let t = text(t, MAX_TAG);
+ if !t.is_empty() && !out.iter().any(|x| x.eq_ignore_ascii_case(&t)) {
+ out.push(t);
+ }
+ }
+ out.truncate(MAX_TAGS);
+ out
+}
+
+/// Standard base64, padded.
+fn base64(s: &str) -> bool {
+ !s.is_empty()
+ && s.len().is_multiple_of(4)
+ && s.trim_end_matches('=').len() + 2 >= s.len()
+ && s.trim_end_matches('=')
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
+}
+
+/// A host key line as `type base64` (a comment after it is dropped).
+fn host_key(line: &str) -> std::result::Result {
+ let mut words = line.split_whitespace();
+ match (words.next(), words.next()) {
+ (Some(kind), Some(data)) if KEY_TYPES.contains(&kind) && base64(data) => {
+ Ok(format!("{kind} {data}"))
+ }
+ _ => Err(format!(
+ "the host key {:?} isn't an OpenSSH public key (type and base64)",
+ text(line, 40)
+ )),
+ }
+}
+
+fn host(raw: RawHost) -> std::result::Result<(String, InventoryHost), String> {
+ let hostname = word(raw.hostname.trim(), "host name")?;
+ let user = word(raw.user.trim(), "user")?;
+ let port = match raw.port {
+ None => 22,
+ Some(p @ 1..=65535) => p as u16,
+ Some(_) => return Err("the port isn't 1 to 65535".into()),
+ };
+ let jump = match raw.jump.as_deref().map(str::trim).filter(|j| !j.is_empty()) {
+ Some(j) if j == raw.id => return Err("it can't be its own jump host".into()),
+ Some(j) if !valid_id(j) => {
+ return Err(format!("the jump host {:?} isn't an id", text(j, 40)));
+ }
+ other => other.map(str::to_owned),
+ };
+ if raw.host_keys.len() > MAX_HOST_KEYS {
+ return Err(format!("more than {MAX_HOST_KEYS} host keys"));
+ }
+ let host_keys = raw
+ .host_keys
+ .iter()
+ .map(|k| host_key(k))
+ .collect::, _>>()?;
+ let label = match text(&raw.label, MAX_TEXT) {
+ l if l.is_empty() => raw.id.clone(),
+ l => l,
+ };
+ let state = raw
+ .state
+ .map(|s| text(&s, MAX_TAG))
+ .filter(|s| !s.is_empty() && !s.eq_ignore_ascii_case("running"));
+ Ok((
+ jump.unwrap_or_default(),
+ InventoryHost {
+ external_id: format!("feed:{}", raw.id),
+ group: text(&raw.group, MAX_TEXT),
+ label,
+ hostname,
+ port,
+ user,
+ login_command: None,
+ jump: None,
+ jump_command: None,
+ no_sftp: false,
+ no_forwarding: false,
+ state,
+ key_name: raw
+ .key
+ .map(|k| text(&k, MAX_TEXT))
+ .filter(|k| !k.is_empty()),
+ via: None,
+ host_keys,
+ tags: tags(&raw.tags),
+ },
+ ))
+}
+
+fn snippet(raw: RawSnippet) -> std::result::Result {
+ let name = text(&raw.name, MAX_TEXT);
+ if name.is_empty() {
+ return Err("no name".into());
+ }
+ if raw.body.trim().is_empty() {
+ return Err("no body".into());
+ }
+ if raw.body.len() > MAX_BODY {
+ return Err("the body is over 64 KiB".into());
+ }
+ // A terminal reads escape sequences as more than text.
+ if raw
+ .body
+ .chars()
+ .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
+ {
+ return Err("the body has control characters".into());
+ }
+ Ok(FeedSnippet {
+ external_id: format!("feed:{}", raw.id),
+ name,
+ body: raw.body,
+ tags: tags(&raw.tags),
+ })
+}
+
+/// What an entry is called in messages: its id, else its place in the list.
+fn entry_name(v: &Value, kind: &str, n: usize) -> String {
+ match v.get("id").and_then(Value::as_str) {
+ Some(id) if valid_id(id) => id.to_owned(),
+ _ => format!("{kind} {}", n + 1),
+ }
+}
+
+/// Reads a feed document. `name` is the source's, for messages.
+pub fn parse(name: &str, bytes: &[u8]) -> Result {
+ let unreadable = |why: String| {
+ Error::Api(format!(
+ "{name} sent something this version can't read: {why}"
+ ))
+ };
+ let doc: Value = serde_json::from_slice(bytes).map_err(|e| unreadable(e.to_string()))?;
+ let obj = doc
+ .as_object()
+ .ok_or_else(|| unreadable("not a JSON object".into()))?;
+ match obj.get("version").and_then(Value::as_u64) {
+ Some(VERSION) => {}
+ Some(n) if n > VERSION => {
+ return Err(Error::Api(format!(
+ "{name} is a version {n} feed: update MorphGate to read it"
+ )));
+ }
+ _ => return Err(unreadable("no \"version\": 1".into())),
+ }
+ let list = |key: &str| match obj.get(key) {
+ None | Some(Value::Null) => Ok(&[][..]),
+ Some(Value::Array(a)) => Ok(a.as_slice()),
+ Some(_) => Err(unreadable(format!("\"{key}\" isn't a list"))),
+ };
+ let (raw_hosts, raw_snippets) = (list("hosts")?, list("snippets")?);
+ if raw_hosts.len() > MAX_HOSTS {
+ return Err(Error::Api(format!(
+ "{name} lists {} hosts: a feed holds at most 5,000",
+ raw_hosts.len()
+ )));
+ }
+ if raw_snippets.len() > MAX_SNIPPETS {
+ return Err(Error::Api(format!(
+ "{name} lists {} snippets: a feed holds at most 1,000",
+ raw_snippets.len()
+ )));
+ }
+ let mut feed = Feed {
+ name: obj
+ .get("name")
+ .and_then(Value::as_str)
+ .map(|n| text(n, MAX_TEXT))
+ .filter(|n| !n.is_empty()),
+ ..Feed::default()
+ };
+
+ // Hosts, with the id of the one each jumps through.
+ let mut hosts: Vec<(String, String, InventoryHost)> = Vec::new();
+ let mut seen = HashSet::new();
+ for (n, v) in raw_hosts.iter().enumerate() {
+ let entry = entry_name(v, "host", n);
+ let parsed = serde_json::from_value::(v.clone())
+ .map_err(|e| e.to_string())
+ .and_then(|raw| {
+ if !valid_id(&raw.id) {
+ return Err("its id isn't 1 to 200 of A-Z, a-z, 0-9 and . _ : / @ -".into());
+ }
+ if !seen.insert(raw.id.clone()) {
+ return Err("another host has this id".into());
+ }
+ let id = raw.id.clone();
+ host(raw).map(|(jump, h)| (id, jump, h))
+ });
+ match parsed {
+ Ok(h) => hosts.push(h),
+ Err(why) => feed.skipped.push(format!("{entry}: {why}")),
+ }
+ }
+ // A jump host must be one of the feed's own, kept, and not lead round in a circle.
+ let mut circular = Vec::new();
+ {
+ let jumps: HashMap<&str, &str> = hosts
+ .iter()
+ .filter(|(_, j, _)| !j.is_empty())
+ .map(|(id, j, _)| (id.as_str(), j.as_str()))
+ .collect();
+ // In a circle: following its jump hosts comes back to it. (One that only leads into a
+ // circle goes with the hosts it jumps through, below.)
+ for (id, _, _) in &hosts {
+ let mut at = id.as_str();
+ for _ in 0..hosts.len() {
+ match jumps.get(at) {
+ Some(next) if *next == id.as_str() => {
+ circular.push(id.clone());
+ break;
+ }
+ Some(next) => at = next,
+ None => break,
+ }
+ }
+ }
+ }
+ hosts.retain(|(id, _, _)| {
+ let keep = !circular.contains(id);
+ if !keep {
+ feed.skipped
+ .push(format!("{id}: its jump hosts go round in a circle"));
+ }
+ keep
+ });
+ loop {
+ let kept: HashSet = hosts.iter().map(|(id, _, _)| id.clone()).collect();
+ let before = hosts.len();
+ hosts.retain(|(id, jump, _)| {
+ let keep = jump.is_empty() || kept.contains(jump);
+ if !keep {
+ feed.skipped.push(format!(
+ "{id}: its jump host {jump:?} isn't one of the feed's hosts (or was skipped)"
+ ));
+ }
+ keep
+ });
+ if hosts.len() == before {
+ break;
+ }
+ }
+ feed.hosts = hosts
+ .into_iter()
+ .map(|(_, jump, mut h)| {
+ h.jump = (!jump.is_empty()).then(|| format!("feed:{jump}"));
+ h
+ })
+ .collect();
+
+ let mut seen = HashSet::new();
+ for (n, v) in raw_snippets.iter().enumerate() {
+ let entry = entry_name(v, "snippet", n);
+ let parsed = serde_json::from_value::(v.clone())
+ .map_err(|e| e.to_string())
+ .and_then(|raw| {
+ if !valid_id(&raw.id) {
+ return Err("its id isn't 1 to 200 of A-Z, a-z, 0-9 and . _ : / @ -".into());
+ }
+ if !seen.insert(raw.id.clone()) {
+ return Err("another snippet has this id".into());
+ }
+ snippet(raw)
+ });
+ match parsed {
+ Ok(s) => feed.snippets.push(s),
+ Err(why) => feed.skipped.push(format!("{entry}: {why}")),
+ }
+ }
+ Ok(feed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ const KEY: &str =
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl";
+
+ fn parsed(doc: Value) -> Feed {
+ parse("Ops", doc.to_string().as_bytes()).unwrap()
+ }
+
+ #[test]
+ fn the_roadmap_example_reads() {
+ let feed = parsed(json!({
+ "version": 1,
+ "name": "Vallic operations",
+ "hosts": [
+ { "id": "bastion-fsn1", "label": "Bastion FSN1", "hostname": "bastion-fsn1.example.net",
+ "port": 22, "user": "vallic", "group": "Bastions", "host_keys": [KEY] },
+ { "id": "server-1234", "label": "acme-prod-1", "hostname": "10.0.12.34", "port": 2222,
+ "user": "vallic", "group": "Acme", "tags": ["production", "fsn1"], "jump": "bastion-fsn1",
+ "key": "vallic-customers", "host_keys": [format!("{KEY} root@bastion")], "state": "running" }
+ ],
+ "snippets": [
+ { "id": "php-errors", "name": "PHP error log", "body": "podman logs --tail 200 php", "tags": ["diagnostics"] }
+ ]
+ }));
+ assert_eq!(feed.name.as_deref(), Some("Vallic operations"));
+ assert!(feed.skipped.is_empty(), "{:?}", feed.skipped);
+ let [bastion, server] = &feed.hosts[..] else {
+ panic!("two hosts: {:?}", feed.hosts)
+ };
+ assert_eq!(bastion.external_id, "feed:bastion-fsn1");
+ assert_eq!(bastion.jump, None);
+ assert_eq!(server.jump.as_deref(), Some("feed:bastion-fsn1"));
+ assert_eq!(
+ (
+ server.hostname.as_str(),
+ server.port,
+ server.user.as_str(),
+ server.group.as_str()
+ ),
+ ("10.0.12.34", 2222, "vallic", "Acme")
+ );
+ assert_eq!(server.key_name.as_deref(), Some("vallic-customers"));
+ // The comment is dropped; "running" is no state to show.
+ assert_eq!(server.host_keys, [KEY]);
+ assert_eq!(server.state, None);
+ assert_eq!(server.tags, ["production", "fsn1"]);
+ assert!(server.login_command.is_none() && server.via.is_none());
+ assert_eq!(
+ feed.snippets,
+ [FeedSnippet {
+ external_id: "feed:php-errors".into(),
+ name: "PHP error log".into(),
+ body: "podman logs --tail 200 php".into(),
+ tags: vec!["diagnostics".into()],
+ }]
+ );
+ }
+
+ #[test]
+ fn defaults_and_optional_fields() {
+ let feed = parsed(json!({
+ "version": 1,
+ "hosts": [{ "id": "web", "hostname": "web.example", "user": "deploy", "group": null,
+ "tags": null, "state": "stopped" }]
+ }));
+ let h = &feed.hosts[0];
+ assert_eq!(
+ (h.label.as_str(), h.port, h.group.as_str()),
+ ("web", 22, "")
+ );
+ assert_eq!(h.state.as_deref(), Some("stopped"));
+ assert!(h.host_keys.is_empty() && h.key_name.is_none());
+ assert!(feed.name.is_none() && feed.snippets.is_empty());
+ }
+
+ #[test]
+ fn invalid_entries_are_skipped_and_said() {
+ let feed = parsed(json!({
+ "version": 1,
+ "hosts": [
+ { "id": "ok", "hostname": "ok.example", "user": "u" },
+ { "id": "ok", "hostname": "again.example", "user": "u" },
+ { "id": "bad id!", "hostname": "x", "user": "u" },
+ { "id": "dash", "hostname": "-oProxyCommand=x", "user": "u" },
+ { "id": "space", "hostname": "a b", "user": "u" },
+ { "id": "nouser", "hostname": "x.example" },
+ { "id": "port", "hostname": "x.example", "user": "u", "port": 70000 },
+ { "id": "key", "hostname": "x.example", "user": "u", "host_keys": ["ssh-dss AAAA"] },
+ { "id": "notbase64", "hostname": "x.example", "user": "u", "host_keys": ["ssh-ed25519 $(id)"] },
+ { "id": "orphan", "hostname": "x.example", "user": "u", "jump": "nowhere" },
+ { "id": "a", "hostname": "a.example", "user": "u", "jump": "b" },
+ { "id": "b", "hostname": "b.example", "user": "u", "jump": "a" },
+ { "id": "behind-a", "hostname": "c.example", "user": "u", "jump": "a" },
+ { "id": "self", "hostname": "s.example", "user": "u", "jump": "self" },
+ "not an object"
+ ],
+ "snippets": [
+ { "id": "ok", "name": "Uptime", "body": "uptime" },
+ { "id": "esc", "name": "Sneaky", "body": "echo \u{1b}[201~" },
+ { "id": "empty", "name": "Nothing", "body": " " },
+ { "id": "big", "name": "Big", "body": "x".repeat(MAX_BODY + 1) }
+ ]
+ }));
+ assert_eq!(feed.hosts.len(), 1, "{:?}", feed.hosts);
+ assert_eq!(feed.snippets.len(), 1);
+ let said = feed.skipped.join("\n");
+ for expected in [
+ "ok: another host has this id",
+ "host 3: its id isn't",
+ "dash: the host name \"-oProxyCommand=x\" isn't one SSH can use",
+ "space: the host name",
+ "nouser: missing field `user`",
+ "port: the port isn't 1 to 65535",
+ "key: the host key \"ssh-dss AAAA\" isn't an OpenSSH public key",
+ "notbase64: the host key",
+ "orphan: its jump host \"nowhere\" isn't one of the feed's hosts",
+ "a: its jump hosts go round in a circle",
+ "b: its jump hosts go round in a circle",
+ "behind-a: its jump host \"a\" isn't one of the feed's hosts (or was skipped)",
+ "self: it can't be its own jump host",
+ "host 15: invalid type",
+ "esc: the body has control characters",
+ "empty: no body",
+ "big: the body is over 64 KiB",
+ ] {
+ assert!(said.contains(expected), "{expected:?} in:\n{said}");
+ }
+ }
+
+ #[test]
+ fn a_bad_document_is_refused_whole() {
+ let err = |doc: &str| parse("Ops", doc.as_bytes()).unwrap_err().to_string();
+ assert!(err("").starts_with("Ops sent something this version can't read: "));
+ assert!(err("[]").ends_with("not a JSON object"));
+ assert!(err(r#"{"hosts": []}"#).ends_with("no \"version\": 1"));
+ assert_eq!(
+ err(r#"{"version": 2}"#),
+ "Ops is a version 2 feed: update MorphGate to read it"
+ );
+ assert!(err(r#"{"version": 1, "hosts": {}}"#).ends_with("\"hosts\" isn't a list"));
+ let many = json!({ "version": 1, "hosts": vec![json!({}); MAX_HOSTS + 1] });
+ assert_eq!(
+ err(&many.to_string()),
+ "Ops lists 5001 hosts: a feed holds at most 5,000"
+ );
+ }
+
+ #[test]
+ fn only_https_or_this_computer() {
+ assert!(check_url("https://ops.example/feed").is_ok());
+ assert!(check_url("http://127.0.0.1:8080/feed").is_ok());
+ assert!(check_url("http://localhost/feed").is_ok());
+ assert!(check_url("http://ops.example/feed").is_err());
+ assert!(check_url("file:///etc/passwd").is_err());
+ }
+}
diff --git a/crates/mg-inventory/src/lib.rs b/crates/mg-inventory/src/lib.rs
index c0e65dc..9a37ea2 100644
--- a/crates/mg-inventory/src/lib.rs
+++ b/crates/mg-inventory/src/lib.rs
@@ -3,12 +3,14 @@
//! never reach MorphGate's server.
//!
//! One module per provider (docs/features/cloud-sources.md): Vallic Cloud through its API; AWS,
-//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean through their CLIs, as [`Machine`]s.
+//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean through their CLIs, as [`Machine`]s; and
+//! feeds, documents a team publishes at a URL.
pub mod aws;
pub mod azure;
pub mod cli;
pub mod digitalocean;
+pub mod feed;
pub mod gcp;
pub mod hetzner;
pub mod shape;
@@ -49,6 +51,11 @@ pub struct InventoryHost {
/// Reached through its cloud (Session Manager, an Instance Connect Endpoint, IAP) rather
/// than an address; `hostname` is then the instance's id or name.
pub via: Option,
+ /// The server's host keys (`ssh-ed25519 AAAA…`), as its publisher knows them (feeds):
+ /// trusted for it without asking.
+ pub host_keys: Vec,
+ /// Tags it comes with (feeds), added to the person's.
+ pub tags: Vec,
}
/// How a machine is reached through its cloud: what the command needs (the app builds it).
@@ -131,6 +138,9 @@ pub enum Error {
SignedOut(String),
#[error("{0}")]
Api(String),
+ /// A feed answered 401 or 403 to its token.
+ #[error("{0} refused the token")]
+ TokenRefused(String),
/// A provider's CLI isn't on this device.
#[error(
"{0} isn't installed on this computer, or MorphGate can't find it: install it, or give its path in the source"
diff --git a/crates/mg-inventory/src/shape.rs b/crates/mg-inventory/src/shape.rs
index 6e426ca..f9e57ae 100644
--- a/crates/mg-inventory/src/shape.rs
+++ b/crates/mg-inventory/src/shape.rs
@@ -117,6 +117,8 @@ pub fn hosts(provider: &str, machines: &[Machine], shape: &Shape) -> Shaped {
state: (!m.running).then(|| m.state.clone()),
key_name: m.key_name.clone(),
via: through,
+ host_keys: Vec::new(),
+ tags: Vec::new(),
});
}
out
diff --git a/crates/mg-inventory/src/vallic.rs b/crates/mg-inventory/src/vallic.rs
index 98ef567..8b2e89e 100644
--- a/crates/mg-inventory/src/vallic.rs
+++ b/crates/mg-inventory/src/vallic.rs
@@ -527,6 +527,8 @@ pub fn hosts_of(project: &str, env: &Environment, t: &SshTarget) -> Vec (String, Arc>>) {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let heads = Arc::new(Mutex::new(Vec::new()));
+ let seen = heads.clone();
+ tokio::spawn(async move {
+ loop {
+ let (mut sock, _) = listener.accept().await.unwrap();
+ let seen = seen.clone();
+ tokio::spawn(async move {
+ let mut buf = Vec::new();
+ let mut chunk = [0u8; 4096];
+ let head = loop {
+ let n = sock.read(&mut chunk).await.unwrap();
+ if n == 0 {
+ return;
+ }
+ buf.extend_from_slice(&chunk[..n]);
+ if let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
+ break String::from_utf8_lossy(&buf[..end]).to_string();
+ }
+ };
+ seen.lock().unwrap().push(head.clone());
+ let path = head.split_whitespace().nth(1).unwrap_or("").to_owned();
+ let authorized = head
+ .lines()
+ .any(|l| l.eq_ignore_ascii_case("authorization: Bearer T1"));
+ let (status, extra, body) = match path.as_str() {
+ _ if !authorized => ("401 Unauthorized", String::new(), "{}".to_owned()),
+ "/feed" => (
+ "200 OK",
+ String::new(),
+ serde_json::json!({
+ "version": 1,
+ "name": "Ops",
+ "hosts": [{ "id": "web-1", "hostname": "10.0.0.1", "user": "ops" }],
+ "snippets": [{ "id": "up", "name": "Uptime", "body": "uptime" }]
+ })
+ .to_string(),
+ ),
+ "/big" => (
+ "200 OK",
+ String::new(),
+ format!(
+ "{{\"version\": 1, \"name\": \"{}\"}}",
+ "x".repeat(feed::MAX_BYTES)
+ ),
+ ),
+ "/moved" => (
+ "302 Found",
+ "location: http://example.com/feed\r\n".to_owned(),
+ String::new(),
+ ),
+ _ => ("404 Not Found", String::new(), String::new()),
+ };
+ let reply = format!(
+ "HTTP/1.1 {status}\r\ncontent-type: application/json\r\n{extra}content-length: {}\r\nconnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ let _ = sock.write_all(reply.as_bytes()).await;
+ });
+ }
+ });
+ (format!("http://{addr}"), heads)
+}
+
+#[tokio::test]
+async fn reads_a_feed_with_its_token() {
+ let (base, heads) = publisher().await;
+ let feed = feed::fetch("Ops", &format!("{base}/feed"), "T1")
+ .await
+ .unwrap();
+ assert_eq!(feed.name.as_deref(), Some("Ops"));
+ assert_eq!(feed.hosts[0].external_id, "feed:web-1");
+ assert_eq!(feed.snippets[0].body, "uptime");
+ let head = heads.lock().unwrap()[0].to_lowercase();
+ assert!(head.contains("accept: application/json"), "{head}");
+ assert!(
+ head.contains(&format!(
+ "user-agent: morphgate/{}",
+ env!("CARGO_PKG_VERSION")
+ )),
+ "{head}"
+ );
+}
+
+#[tokio::test]
+async fn a_refused_token_too_large_and_elsewhere_are_said() {
+ let (base, _) = publisher().await;
+ let err = |r: mg_inventory::Result| r.unwrap_err().to_string();
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/feed"), "wrong").await),
+ "Ops refused the token"
+ );
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/big"), "T1").await),
+ "Ops sent more than 5 MiB: a feed is at most that"
+ );
+ // Not followed to plain HTTP on another host: the redirect itself is the answer.
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/moved"), "T1").await),
+ "Ops answered 302 Found"
+ );
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/nothing"), "T1").await),
+ "Ops answered 404 Not Found"
+ );
+}
diff --git a/crates/mg-ssh/src/connection.rs b/crates/mg-ssh/src/connection.rs
index 686986e..6f9405e 100644
--- a/crates/mg-ssh/src/connection.rs
+++ b/crates/mg-ssh/src/connection.rs
@@ -53,6 +53,30 @@ pub struct Hop {
/// TCP connection. `%h`, `%p` and `%r` are this hop's host, port and user. For bastions that
/// refuse forwarding but hand a session on (`nc %h %p`, Vallic's ` jump `).
pub via_command: Option,
+ /// The host keys whoever described this host lists for it (a feed): the server must
+ /// present one of them, and is trusted without asking.
+ pub listed_keys: Option,
+}
+
+/// Host keys a server is known to have, from where its host came from.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct ListedKeys {
+ /// OpenSSH public keys, `ssh-ed25519 AAAA…`.
+ pub keys: Vec,
+ /// Who lists them, for messages (the feed's name).
+ pub from: String,
+}
+
+/// [`ListedKeys`], read: `None` when none of them is a key (then the host is asked about as
+/// any other).
+fn listed(keys: Option) -> Option<(Vec, String)> {
+ let l = keys?;
+ let parsed: Vec = l
+ .keys
+ .iter()
+ .filter_map(|k| PublicKey::from_openssh(k.trim()).ok())
+ .collect();
+ (!parsed.is_empty()).then_some((parsed, l.from))
}
/// Asks the user whether to trust a host key seen for the first time.
@@ -273,6 +297,7 @@ async fn connect_hop(
let verifier = Verifier {
host: hop.host.clone(),
port: hop.port,
+ listed: listed(hop.listed_keys.clone()),
known_hosts: known_hosts.clone(),
prompt: prompt.clone(),
rejected: rejected.clone(),
@@ -550,6 +575,8 @@ async fn agent(
pub(crate) struct Verifier {
host: String,
port: u16,
+ /// The keys listed for this host, and by whom: they decide instead of known_hosts.
+ listed: Option<(Vec, String)>,
known_hosts: Arc,
prompt: Arc,
/// Why the key was refused, surfaced after `connect` fails.
@@ -610,6 +637,29 @@ impl client::Handler for Verifier {
}
};
let mut info = HostKeyInfo::new(&self.host, self.port, &key);
+ // Listed for the host (its feed): one of those, or refused. A revoked key still isn't.
+ if let Some((keys, from)) = &self.listed {
+ if let Some(revoked) = self
+ .known_hosts
+ .revoked(&self.host, self.port, key.key_data())
+ {
+ return Ok(self.reject(Error::HostKeyRevoked {
+ host: self.host.clone(),
+ port: self.port,
+ fingerprint: info.fingerprint.clone(),
+ from: revoked,
+ }));
+ }
+ if keys.iter().any(|k| k.key_data() == key.key_data()) {
+ return Ok(true);
+ }
+ return Ok(self.reject(Error::HostKeyNotListed {
+ host: self.host.clone(),
+ port: self.port,
+ fingerprint: info.fingerprint.clone(),
+ from: from.clone(),
+ }));
+ }
match self.known_hosts.check(&self.host, self.port, &key)? {
HostKeyCheck::Trusted => Ok(true),
HostKeyCheck::Revoked { from } => Ok(self.reject(Error::HostKeyRevoked {
diff --git a/crates/mg-ssh/src/error.rs b/crates/mg-ssh/src/error.rs
index 8561859..381e6d8 100644
--- a/crates/mg-ssh/src/error.rs
+++ b/crates/mg-ssh/src/error.rs
@@ -26,6 +26,16 @@ pub enum Error {
fingerprint: String,
from: String,
},
+ /// A key other than the ones listed for the host (by its feed).
+ #[error(
+ "the host key of {host}:{port} ({fingerprint}) isn't one {from} lists for it: refused. Someone may be intercepting the connection, or {from} doesn't know the server's new key yet"
+ )]
+ HostKeyNotListed {
+ host: String,
+ port: u16,
+ fingerprint: String,
+ from: String,
+ },
/// A host certificate where a CA is trusted for the host, that the CA didn't sign for it.
#[error("the host certificate of {host}:{port} isn't accepted: {why}")]
HostCertificate {
diff --git a/crates/mg-ssh/src/lib.rs b/crates/mg-ssh/src/lib.rs
index 1dbd8a2..d234f5f 100644
--- a/crates/mg-ssh/src/lib.rs
+++ b/crates/mg-ssh/src/lib.rs
@@ -17,7 +17,9 @@ pub mod sftp;
pub mod ssh_config;
pub use agent::{AgentKey, AgentSource};
-pub use connection::{AsyncStream, Auth, BoxedStream, Connection, Dialer, Hop, HostKeyPrompt};
+pub use connection::{
+ AsyncStream, Auth, BoxedStream, Connection, Dialer, Hop, HostKeyPrompt, ListedKeys,
+};
pub use error::Error;
pub use forward::{Forward, Tunnel};
pub use keys::KeyInfo;
diff --git a/crates/mg-ssh/src/session.rs b/crates/mg-ssh/src/session.rs
index bd02246..9c69d18 100644
--- a/crates/mg-ssh/src/session.rs
+++ b/crates/mg-ssh/src/session.rs
@@ -29,6 +29,8 @@ pub struct ConnectOptions {
pub jumps: Vec,
/// How the last jump host carries the target: a command it runs (see [`Hop::via_command`]).
pub via_command: Option,
+ /// The target's host keys, where its feed lists them (see [`Hop::listed_keys`]).
+ pub listed_keys: Option,
/// Opens the first hop's TCP connection (e.g. through a per-host VPN); `None` = the OS.
pub dialer: Option>,
/// Forward this agent to the server (OpenSSH `ForwardAgent`): programs there can sign with
@@ -81,6 +83,7 @@ impl Session {
user: opts.user,
auth: opts.auth,
via_command: opts.via_command,
+ listed_keys: opts.listed_keys,
};
let conn = Connection::open(
target,
diff --git a/crates/mg-ssh/tests/live.rs b/crates/mg-ssh/tests/live.rs
index b9664d3..371c417 100644
--- a/crates/mg-ssh/tests/live.rs
+++ b/crates/mg-ssh/tests/live.rs
@@ -79,6 +79,7 @@ fn opts(env: &Env, auth: Auth) -> ConnectOptions {
connect_timeout: Duration::from_secs(10),
jumps: vec![],
via_command: None,
+ listed_keys: None,
dialer: None,
forward_agent: None,
}
@@ -224,6 +225,87 @@ async fn changed_host_key_fails_closed() {
);
}
+/// Host keys a feed lists for a host decide instead of known_hosts: the server's key connects
+/// without asking (over a stale pin, too) and isn't written down; another one is refused.
+#[tokio::test]
+#[ignore = "needs MG_TEST_SSH"]
+async fn listed_host_keys_decide() {
+ let Some(env) = env() else { return };
+ // The server's key, learned the usual way.
+ let dir = tempfile::tempdir().unwrap();
+ let learned = dir.path().join("learned");
+ let prompt = Arc::new(CountingPrompt {
+ accept: true,
+ ..Default::default()
+ });
+ let (s, ev) = Session::connect(
+ opts(&env, auth(&env)),
+ Arc::new(KnownHosts::new(&learned)),
+ prompt,
+ )
+ .await
+ .unwrap();
+ run(s, ev, "true").await;
+ let line = std::fs::read_to_string(&learned).unwrap();
+ let key: String = line
+ .split_whitespace()
+ .skip(1)
+ .take(2)
+ .collect::>()
+ .join(" ");
+ let listed = |key: &str| {
+ let mut o = opts(&env, auth(&env));
+ o.listed_keys = Some(mg_ssh::ListedKeys {
+ keys: vec![key.to_owned()],
+ from: "Ops".into(),
+ });
+ o
+ };
+
+ let fresh = dir.path().join("fresh");
+ let never = Arc::new(CountingPrompt::default());
+ let (s, ev) = Session::connect(
+ listed(&key),
+ Arc::new(KnownHosts::new(&fresh)),
+ never.clone(),
+ )
+ .await
+ .unwrap();
+ run(s, ev, "true").await;
+ assert!(!fresh.exists(), "a listed key isn't written down");
+
+ let stale = dir.path().join("stale");
+ let entry = if env.port == 22 {
+ env.host.clone()
+ } else {
+ format!("[{}]:{}", env.host, env.port)
+ };
+ let other = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDRbMaf7RI+oSkPOCPrFjPoMWJEIu96Drte4Q3r9OQYR";
+ std::fs::write(&stale, format!("{entry} {other}\n")).unwrap();
+ let (s, ev) = Session::connect(
+ listed(&key),
+ Arc::new(KnownHosts::new(&stale)),
+ never.clone(),
+ )
+ .await
+ .unwrap();
+ run(s, ev, "true").await;
+
+ let err = Session::connect(
+ listed(other),
+ Arc::new(KnownHosts::new(&fresh)),
+ never.clone(),
+ )
+ .await
+ .err()
+ .unwrap();
+ assert!(
+ matches!(&err, Error::HostKeyNotListed { from, .. } if from == "Ops"),
+ "{err}"
+ );
+ assert_eq!(never.calls.load(Ordering::SeqCst), 0, "nothing asked");
+}
+
#[tokio::test]
#[ignore = "needs MG_TEST_SSH"]
async fn wrong_password_fails() {
@@ -314,6 +396,7 @@ fn hop(env: &Env) -> Hop {
user: env.user.clone(),
auth: auth(env),
via_command: None,
+ listed_keys: None,
}
}
diff --git a/crates/mg-store/Cargo.toml b/crates/mg-store/Cargo.toml
index de75455..7a13682 100644
--- a/crates/mg-store/Cargo.toml
+++ b/crates/mg-store/Cargo.toml
@@ -18,6 +18,7 @@ mg-vault.workspace = true
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde.workspace = true
serde_json.workspace = true
+sha2 = "0.11.0"
thiserror.workspace = true
ulid = "3.0.0"
zeroize = "1.9.1"
diff --git a/crates/mg-store/src/history.rs b/crates/mg-store/src/history.rs
index 7f710f4..7cac598 100644
--- a/crates/mg-store/src/history.rs
+++ b/crates/mg-store/src/history.rs
@@ -97,6 +97,7 @@ mod tests {
name: "uptime".into(),
body: "uptime".into(),
tags: vec![],
+ source: None,
})
}
diff --git a/crates/mg-store/src/lib.rs b/crates/mg-store/src/lib.rs
index ff99a3f..2176f7c 100644
--- a/crates/mg-store/src/lib.rs
+++ b/crates/mg-store/src/lib.rs
@@ -27,9 +27,12 @@ pub use mg_vault::BlobKind;
pub use model::{
Attachment, CertRenewal, CloudReach, CloudSource, ForwardRule, Host, HostAuth, HostCa,
HostGroup, HostSource, Identity, Item, ItemData, LogChunk, MAX_ATTACHMENT_BYTES, MemberKey,
- PortForward, SessionLog, Snippet, SshKey, TerminalTheme, VpnKind, VpnProfile, is_hex_color,
+ PortForward, SessionLog, Snippet, SnippetSource, SshKey, TerminalTheme, VpnKind, VpnProfile,
+ is_hex_color,
};
pub use moving::{Carry, MovePlan, Moved, Planned, Why};
-pub use vault::{Access, LocalVault, MIN_PASSWORD_CHARS, NewCredentials, Status, VaultInfo};
+pub use vault::{
+ Access, LocalVault, MIN_PASSWORD_CHARS, NewCredentials, Status, VaultInfo, derived_id,
+};
pub type Result = core::result::Result;
diff --git a/crates/mg-store/src/model.rs b/crates/mg-store/src/model.rs
index aefe6ee..d42838a 100644
--- a/crates/mg-store/src/model.rs
+++ b/crates/mg-store/src/model.rs
@@ -202,6 +202,23 @@ pub struct HostSource {
/// How it's reached when its source goes through the cloud rather than an address.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reach: Option,
+ /// The server's host keys as its feed gives them (`ssh-ed25519 AAAA…`): trusted when
+ /// connecting to this host, without asking; a server with another key is refused.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub host_keys: Vec,
+ /// The tags its feed gave it, among the host's: a refresh replaces these and keeps the
+ /// person's.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub tags: Vec,
+}
+
+/// Where a snippet a feed keeps came from: the feed decides it (it's read-only in the app).
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SnippetSource {
+ /// The `cloud_source` item, in the same vault.
+ pub source_ref: String,
+ /// `feed:`: what a refresh matches on.
+ pub external_id: String,
}
/// A machine reached through its cloud: what the command needs, never the command itself (a vault
@@ -260,12 +277,14 @@ impl CloudReach {
}
/// Hosts kept in step with a cloud provider (docs/features/cloud-sources.md): Vallic Cloud, AWS, Google
-/// Cloud, Azure, Hetzner Cloud or DigitalOcean. No credentials: each device signs in to Vallic itself
-/// and keeps that on the device; the others are listed with their CLI as the device has it set up.
+/// Cloud, Azure, Hetzner Cloud or DigitalOcean, or a feed. No cloud credentials: each device signs in
+/// to Vallic itself and keeps that on the device; the others are listed with their CLI as the device
+/// has it set up. A feed's token is the exception: it only reads a host list, and every device that
+/// refreshes needs it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CloudSource {
pub label: String,
- /// `vallic`, `aws`, `gcp`, `azure`, `hetzner` or `digitalocean`.
+ /// `vallic`, `aws`, `gcp`, `azure`, `hetzner`, `digitalocean` or `feed`.
pub provider: String,
/// The provider's address, when not its usual one.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -301,6 +320,23 @@ pub struct CloudSource {
/// resource group.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_tag: Option,
+ /// A feed's address (`https://`, or `http://` on the device itself).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub url: Option,
+ /// The token a feed is read with (`Authorization: Bearer`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub token: Option,
+}
+
+impl CloudSource {
+ /// The same source, but for when it was last refreshed: what two devices refreshing at once
+ /// differ in.
+ pub fn same_but_refreshed(&self, other: &CloudSource) -> bool {
+ CloudSource {
+ refreshed_at: other.refreshed_at,
+ ..self.clone()
+ } == *other
+ }
}
impl Host {
@@ -440,6 +476,9 @@ pub struct Snippet {
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec,
+ /// The feed that keeps it, if one does.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option,
}
/// A saved tunnel through `host_ref`, started on demand.
diff --git a/crates/mg-store/src/moving.rs b/crates/mg-store/src/moving.rs
index 6c065e7..4cf7af5 100644
--- a/crates/mg-store/src/moving.rs
+++ b/crates/mg-store/src/moving.rs
@@ -198,6 +198,16 @@ fn relink(
auth: auth(i.auth)?,
..i
}),
+ // Kept by its feed where that goes too; else an ordinary snippet there.
+ ItemData::Snippet(sn) => ItemData::Snippet(crate::Snippet {
+ source: sn.source.and_then(|src| {
+ map.get(&src.source_ref).map(|to| crate::SnippetSource {
+ source_ref: to.clone(),
+ ..src
+ })
+ }),
+ ..sn
+ }),
ItemData::HostGroup(g) => ItemData::HostGroup(crate::HostGroup {
identity_ref: optional(g.identity_ref)?,
jump_ref: optional(g.jump_ref)?,
diff --git a/crates/mg-store/src/sync.rs b/crates/mg-store/src/sync.rs
index 327391f..1b499d6 100644
--- a/crates/mg-store/src/sync.rs
+++ b/crates/mg-store/src/sync.rs
@@ -759,8 +759,10 @@ impl LocalVault {
}
_ => None,
};
- // The same content on both sides (say, both re-sealed it): nothing lost.
- let same = local_data == copy.data;
+ // The same content on both sides (say, both re-sealed it), or what a cloud
+ // source decides on each (two refreshes): nothing of the person's lost.
+ let same = local_data == copy.data
+ || source_decides(local_data.as_ref(), copy.data.as_ref());
if !same {
conflicts += 1;
}
@@ -956,6 +958,59 @@ type LocalRow = (
Option,
);
+/// Whether the local copy differs from the server's only in what a cloud source decides (each
+/// device refreshed it): no copy is kept, and the next refresh sets it again. A person's edit
+/// (a rename, a note, a tag, a key) still makes one.
+fn source_decides(local: Option<&ItemData>, remote: Option<&ItemData>) -> bool {
+ match (local, remote) {
+ (Some(ItemData::CloudSource(a)), Some(ItemData::CloudSource(b))) => a.same_but_refreshed(b),
+ // A feed's snippets are the feed's: nobody edits them here.
+ (
+ Some(ItemData::Snippet(crate::Snippet {
+ source: Some(_), ..
+ })),
+ _,
+ ) => true,
+ (Some(ItemData::Host(a)), Some(ItemData::Host(b))) => match (&a.source, &b.source) {
+ (Some(x), Some(y))
+ if x.source_ref == y.source_ref && x.external_id == y.external_id =>
+ {
+ persons(a) == persons(b)
+ }
+ _ => false,
+ },
+ _ => false,
+ }
+}
+
+/// What of a source's host is the person's; the rest follows the source.
+fn persons(h: &crate::Host) -> crate::Host {
+ let source = h.source.as_deref();
+ let following = source.is_some_and(|s| s.label == h.label);
+ let from_source = source.map(|s| s.tags.as_slice()).unwrap_or_default();
+ crate::Host {
+ label: if following {
+ String::new()
+ } else {
+ h.label.clone()
+ },
+ hostname: String::new(),
+ port: 0,
+ user: String::new(),
+ jump_ref: None,
+ login_command: None,
+ jump_command: None,
+ tags: h
+ .tags
+ .iter()
+ .filter(|t| !from_source.contains(t))
+ .cloned()
+ .collect(),
+ source: None,
+ ..h.clone()
+ }
+}
+
fn mark_conflict(data: &mut ItemData) {
const SUFFIX: &str = " (conflict)";
match data {
@@ -1879,6 +1934,114 @@ mod tests {
assert_eq!(labels(&a), labels(&b), "both devices converge");
}
+ /// Two devices refreshing the same cloud source: the hosts and snippets it made have the
+ /// same ids on both, and copies that differ only in what the source decides make no
+ /// "(conflict)" copy. A person's edit still does.
+ #[tokio::test]
+ async fn refreshes_on_two_devices_make_no_copies() {
+ use crate::{CloudSource, HostSource, SnippetSource};
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ let source = CloudSource {
+ label: "Ops".into(),
+ provider: "feed".into(),
+ url: Some("https://ops.example/feed".into()),
+ token: Some("T".into()),
+ ..CloudSource::default()
+ };
+ let sid = a
+ .lock()
+ .unwrap()
+ .upsert(None, ItemData::CloudSource(source.clone()))
+ .unwrap()
+ .id;
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+ let vault = a.lock().unwrap().items().unwrap()[0].vault_id.clone();
+ let kept = |hostname: &str, state: Option<&str>| {
+ let ItemData::Host(mut h) = host("web") else {
+ unreachable!()
+ };
+ h.hostname = hostname.into();
+ h.tags = vec!["prod".into()];
+ h.source = Some(Box::new(HostSource {
+ source_ref: sid.clone(),
+ external_id: "feed:web".into(),
+ label: "web".into(),
+ gone: false,
+ no_sftp: false,
+ no_forwarding: false,
+ state: state.map(str::to_owned),
+ reach: None,
+ host_keys: vec![],
+ tags: vec!["prod".into()],
+ }));
+ ItemData::Host(h)
+ };
+ let snippet = |body: &str| {
+ ItemData::Snippet(Snippet {
+ name: "Uptime".into(),
+ body: body.into(),
+ tags: vec![],
+ source: Some(SnippetSource {
+ source_ref: sid.clone(),
+ external_id: "feed:up".into(),
+ }),
+ })
+ };
+ let refreshed = |at: i64| {
+ ItemData::CloudSource(CloudSource {
+ refreshed_at: at,
+ ..source.clone()
+ })
+ };
+ let host_id = crate::derived_id(&[&vault, &sid, "host", "feed:web"]);
+ let snippet_id = crate::derived_id(&[&vault, &sid, "snippet", "feed:up"]);
+ // Both make them, a moment apart: the feed moved the host and changed the snippet.
+ for (v, hostname, body, at) in [
+ (&a, "10.0.0.1", "uptime", 100),
+ (&b, "10.0.0.2", "uptime -p", 101),
+ ] {
+ let mut v = v.lock().unwrap();
+ v.insert_in(&vault, &host_id, kept(hostname, None)).unwrap();
+ v.insert_in(&vault, &snippet_id, snippet(body)).unwrap();
+ v.upsert(Some(&sid), refreshed(at)).unwrap();
+ }
+ sync(&a, &server).await;
+ let r = sync(&b, &server).await;
+ assert_eq!(r.conflicts, 0);
+ sync(&a, &server).await;
+ let count = |v: &Mutex| v.lock().unwrap().items().unwrap().len();
+ assert_eq!(
+ (count(&a), count(&b)),
+ (3, 3),
+ "no copies: {:?}",
+ labels(&b)
+ );
+
+ // A's person adds a note while B's refresh marks the host stopped: the note is kept.
+ let ItemData::Host(mut noted) = kept("10.0.0.1", None) else {
+ unreachable!()
+ };
+ noted.notes = "ask Ana first".into();
+ a.lock()
+ .unwrap()
+ .upsert(Some(&host_id), ItemData::Host(noted))
+ .unwrap();
+ b.lock()
+ .unwrap()
+ .upsert(Some(&host_id), kept("10.0.0.1", Some("stopped")))
+ .unwrap();
+ sync(&b, &server).await;
+ let r = sync(&a, &server).await;
+ assert_eq!(r.conflicts, 1);
+ assert!(
+ labels(&a).contains(&"web (conflict)".to_owned()),
+ "{:?}",
+ labels(&a)
+ );
+ }
+
#[tokio::test]
async fn edit_beats_delete_as_a_copy_and_delete_loses_to_edit() {
let (a, b) = two_devices();
@@ -1904,6 +2067,7 @@ mod tests {
name: "s".into(),
body: "uptime".into(),
tags: vec![],
+ source: None,
});
let sid = a.lock().unwrap().upsert(None, snip).unwrap().id;
sync(&a, &server).await;
@@ -1916,6 +2080,7 @@ mod tests {
name: "s2".into(),
body: "w".into(),
tags: vec![],
+ source: None,
}),
)
.unwrap();
diff --git a/crates/mg-store/src/vault.rs b/crates/mg-store/src/vault.rs
index 7693f9c..8c3bbad 100644
--- a/crates/mg-store/src/vault.rs
+++ b/crates/mg-store/src/vault.rs
@@ -642,6 +642,35 @@ impl LocalVault {
})
}
+ /// Creates item `id` in `vault_id`: an id worked out rather than drawn ([`derived_id`]), so
+ /// two devices making the same item write the same one, and the second gets a conflict
+ /// instead of a duplicate. Refused when a live item has the id.
+ pub fn insert_in(&mut self, vault_id: &str, id: &str, data: ItemData) -> Result- {
+ if self.unlocked()?.access(vault_id)? < Access::Write {
+ return Err(Error::ReadOnly);
+ }
+ if data == ItemData::Unknown {
+ return Err(Error::Invalid(
+ "this app can't edit that kind of item".into(),
+ ));
+ }
+ let live: bool = self.conn.query_row(
+ "SELECT EXISTS (SELECT 1 FROM items WHERE id = ?1 AND deleted = 0)",
+ [id],
+ |r| r.get(0),
+ )?;
+ if live {
+ return Err(Error::Invalid(format!("item {id} already exists")));
+ }
+ let now = self.put(vault_id, id, &data)?;
+ Ok(Item {
+ id: id.to_owned(),
+ vault_id: vault_id.to_owned(),
+ updated_at: now,
+ data,
+ })
+ }
+
/// Checks `data` as an item `id` of `vault`, seals it with the vault's current key and stores
/// it for the next sync (over a tombstone of the same id, too). Returns the time stamped.
pub(crate) fn put(&mut self, vault_id: &str, id: &str, data: &ItemData) -> Result
{
@@ -742,6 +771,9 @@ impl LocalVault {
ItemData::Host(h) if h.source.as_ref().is_some_and(|s| s.source_ref == id) => {
Some(h.label)
}
+ ItemData::Snippet(s) if s.source.as_ref().is_some_and(|s| s.source_ref == id) => {
+ Some(format!("snippet {}", s.name))
+ }
ItemData::CloudSource(c)
if c.key_ref.as_deref() == Some(id) || c.jump_ref.as_deref() == Some(id) =>
{
@@ -1118,6 +1150,14 @@ impl LocalVault {
if s.name.trim().is_empty() {
return invalid("snippet name is required");
}
+ if let Some(source) = &s.source {
+ self.expect(
+ vault,
+ &source.source_ref,
+ "the snippet's feed does not exist",
+ |d| matches!(d, ItemData::CloudSource(_)),
+ )?;
+ }
}
ItemData::MemberKey(k) => {
if k.account_id.is_empty() || k.public_key.split_ascii_whitespace().count() != 2 {
@@ -1224,13 +1264,36 @@ impl LocalVault {
}
}
ItemData::CloudSource(c) => {
- if !["vallic", "aws", "gcp", "azure", "hetzner", "digitalocean"]
- .contains(&c.provider.as_str())
+ if ![
+ "vallic",
+ "aws",
+ "gcp",
+ "azure",
+ "hetzner",
+ "digitalocean",
+ "feed",
+ ]
+ .contains(&c.provider.as_str())
{
return invalid(
- "a cloud source's provider is vallic, aws, gcp, azure, hetzner or digitalocean",
+ "a cloud source's provider is vallic, aws, gcp, azure, hetzner, digitalocean or feed",
);
}
+ if c.provider == "feed" {
+ let url = c.url.as_deref().unwrap_or("");
+ let local = ["localhost", "127.0.0.1", "[::1]"].iter().any(|h| {
+ url.strip_prefix("http://").is_some_and(|rest| {
+ rest.strip_prefix(h)
+ .is_some_and(|r| r.is_empty() || r.starts_with([':', '/']))
+ })
+ });
+ if !(url.starts_with("https://") && url.len() > 8) && !local {
+ return invalid("a feed's address starts with https://");
+ }
+ if c.token.as_deref().is_none_or(|t| t.trim().is_empty()) {
+ return invalid("a feed needs the token it's read with");
+ }
+ }
if let Some(key) = &c.key_ref {
self.expect(vault, key, "the selected key does not exist", |d| {
matches!(d, ItemData::SshKey(_))
@@ -1549,6 +1612,24 @@ pub(crate) fn decrypt(
serde_json::from_slice(&plain).map_err(|e| Error::Corrupt(format!("item {id}: {e}")))
}
+/// An item id worked out from `parts` rather than drawn: SHA-256 of them, as a ULID. Every
+/// device that makes the item from the same parts (a source's vault, the source, what it lists)
+/// gives it the same id.
+pub fn derived_id(parts: &[&str]) -> String {
+ use sha2::{Digest, Sha256};
+ let mut hash = Sha256::new();
+ hash.update(b"morphgate derived item id v1");
+ for p in parts {
+ // Each part with its length: no two lists of parts run together the same.
+ hash.update((p.len() as u64).to_be_bytes());
+ hash.update(p.as_bytes());
+ }
+ let digest = hash.finalize();
+ let mut bytes = [0u8; 16];
+ bytes.copy_from_slice(&digest[..16]);
+ Ulid::from_bytes(bytes).to_string()
+}
+
pub(crate) fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1804,6 +1885,7 @@ mod tests {
name: "disk".into(),
body: "df -h".into(),
tags: vec![],
+ source: None,
}),
)
.unwrap();
diff --git a/crates/mg-vpn/src/lib.rs b/crates/mg-vpn/src/lib.rs
index ecb7ff0..b8c4fa2 100644
--- a/crates/mg-vpn/src/lib.rs
+++ b/crates/mg-vpn/src/lib.rs
@@ -312,6 +312,7 @@ pub(crate) mod testutil {
connect_timeout: Duration::from_secs(10),
jumps: vec![],
via_command: None,
+ listed_keys: None,
dialer: Some(Arc::new(Via(tunnel))),
forward_agent: None,
};
diff --git a/docs/README.md b/docs/README.md
index 8f648b9..0f5637a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -26,7 +26,7 @@ Start with [Architecture](architecture.md), then the feature you're working on.
| [SFTP](features/sftp.md) | The file browser, transfers, conflicts |
| [Session logs](features/session-logs.md) | Recording sessions, sealed chunks, uploading and playback |
| [Importing](features/importing.md) | Hosts from OpenSSH, PuTTY, CSV and Royal TS; snippets from CSV |
-| [Cloud sources](features/cloud-sources.md) | Hosts from Vallic Cloud, AWS, Google Cloud, Azure, Hetzner Cloud and DigitalOcean |
+| [Cloud sources](features/cloud-sources.md) | Hosts from Vallic Cloud, AWS, Google Cloud, Azure, Hetzner Cloud and DigitalOcean, and hosts and snippets from a feed |
| **Sync and teams** | |
| [Sync](features/sync.md) | Choosing a server, signing up and in, two-step verification, the push and pull cycle, conflicts, plans and limits |
| [Account and devices](features/account-and-devices.md) | The device list, signing devices out, erasing a revoked device, disconnecting, deleting the account, the website account page, device approval, single sign-on |
diff --git a/docs/architecture.md b/docs/architecture.md
index c9bce4e..cbfaacf 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,7 +39,7 @@ How the MorphGate app is put together: the webview, the Tauri commands behind it
| `mg-ssh` | `crates/mg-ssh` | One `Session` per connection on `russh`: PTY shell, authentication, host-key pinning, agents, port forwards, SFTP, `ssh_config` parsing. See [SSH connections](features/ssh-connections.md) |
| `mg-vpn` | `crates/mg-vpn` | VPN tunnels that run inside the app with their own smoltcp stack; `Tunnel::connect` returns a byte stream to a host behind the VPN. See [VPN](features/vpn.md) |
| `mg-presence` | `crates/mg-presence` | Asks the OS to confirm the user is there before the keychain unlock. See [Keychain and biometric unlock](features/device-unlock.md) |
-| `mg-inventory` | `crates/mg-inventory` | Lists machines from Vallic Cloud (API) and AWS, Google Cloud, Azure, Hetzner Cloud, DigitalOcean (their CLIs). See [Cloud sources](features/cloud-sources.md) |
+| `mg-inventory` | `crates/mg-inventory` | Lists machines from Vallic Cloud (API) and AWS, Google Cloud, Azure, Hetzner Cloud, DigitalOcean (their CLIs), and reads feeds (hosts and snippets at a URL). See [Cloud sources](features/cloud-sources.md) |
| Reference server | `server/` (`vault-sync-server`) | A self-hostable `vault-sync/v1` server. See [Reference server](features/reference-server.md) |
| Conformance suite | `tools/vault-conform` | Checks any `vault-sync/v1` server against the protocol, endpoint by endpoint. See [Development](development.md#conformance-suite) |
@@ -172,7 +172,7 @@ See [Sync](features/sync.md), [Account and devices](features/account-and-devices
| The sync server | Items are signed and checked before decrypting; a revision older than one already seen is an error; KDF parameters below the minimum are refused; shared vault keys count only when signed by a member this device verified, and a changed member key is a hard error |
| Other members of a shared vault | A command that arrives in a vault (a proxy command, a certificate renewal command) runs only after this device allowed it, by its exact text |
| The webview | Private keys and passwords go into Rust and never come back out to the webview |
-| Its own server, with cloud credentials | Cloud provider calls go from the device to the provider; the credentials never reach the sync server |
+| Its own server, with cloud credentials | Cloud provider calls go from the device to the provider; the credentials never reach the sync server (a feed's read-only token travels in the vault, end-to-end encrypted) |
| Links | A `morphgate://` link only opens a screen, filled in; it never joins, connects or changes anything, and passwords or keys in it are ignored |
What it relies on: the OS user account (the keychain unlock and the presence check keep someone at an unlocked computer out, not malware running as the user) and the host keys the user accepts.
diff --git a/docs/development.md b/docs/development.md
index cee9e29..53ec816 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -98,24 +98,26 @@ They cover tls-auth, tls-crypt, TCP, CBC with the PRF, LZO and renegotiation ([V
apps/desktop/e2e/run.sh # builds test packages (about 10 minutes), then every scenario
E2E_SKIP_BUILD=1 apps/desktop/e2e/run.sh # reuses the last build
E2E_SKIP_BUILD=1 E2E_ONLY="teams mobile" apps/desktop/e2e/run.sh # appimage, rpm, deb, teams, layout, mobile
+E2E_SKIP_BUILD=1 E2E_ONLY=demo apps/desktop/e2e/run.sh # only when asked: the README's demo
```
| Scenario | Checks |
| --- | --- |
-| `appimage.mjs` | A vault; SSH to the throwaway sshd; imports from `~/.ssh/config`, PuTTY (`.ppk`), a Termius-style CSV and Royal TS; Vallic Cloud through a stand-in console; user and host certificates; proxy and login commands; groups, bulk move and delete; an agent and agent forwarding; split panes and broadcast; a `morphgate://connect` link from a second launch; appearance; About and the license list; a tampered update refused, the signed update installed and the same vault unlocked in the new version |
+| `appimage.mjs` | A vault; SSH to the throwaway sshd; imports from `~/.ssh/config`, PuTTY (`.ppk`), a Termius-style CSV and Royal TS; Vallic Cloud, AWS, DigitalOcean and a feed through stand-ins; user and host certificates; proxy and login commands; groups, bulk move and delete; an agent and agent forwarding; split panes and broadcast; a `morphgate://connect` link from a second launch; appearance; About and the license list; a tampered update refused, the signed update installed and the same vault unlocked in the new version |
| `package.mjs` (deb, rpm) | The desktop entry handles `morphgate://`; the binary knows its package, finds the update and warns about the administrator password |
| `teams.mjs` | Three apps (`E2E_PEOPLE="alice bob carol"`; Carol's app is Bob's second device) and the reference server: sync, an organization, invites with fingerprint checks, shared edits, moving items, published member keys, revocation and rotation, policies, the activity log and its export, device approval, attachments, offboarding against the sshd, shared credentials and tmux sessions, single sign-on against `idp.mjs`, the data export, signing a device out, deleting accounts |
| `layout.mjs` | Every page filled, at 860, 1024, 1280 and 1600 px: nothing overlaps or leaves its column; closing editors and pages returns where they came from |
| `mobile.mjs` | The phone layout in a 390 × 844 window ([Platforms and mobile](features/platforms-and-mobile.md#tests)) |
+| `demo.mjs` (`E2E_ONLY=demo` only) | The README's demo: the AppImage with a few hosts in groups. Its Hosts page is captured as shapes (`tools/readme-demo/snapshot.js`: boxes, text and icons where the layout put them), web-1 is clicked, and its session view is captured and recorded by the app's session recording (the login command runs a clean bash with a demo prompt in a demo home). `tools/readme-demo/demo-svg.mjs` draws both scenes, a pointer's click and the replayed session (xterm.js headless; each row's states are a strip a CSS animation steps through) as one animated SVG in the app's fonts. This machine's user and loopback address are swapped for demo ones, and nothing that still shows them or your home folder is saved. Copy `target/e2e/run-/demo/morphgate.svg` to `assets/demo/morphgate.svg` |
What `run.sh` sets up, all on loopback and on free ports (`E2E_SSH_PORT`, `E2E_FEED_PORT`, `E2E_SYNC_PORT`, `E2E_IDP_PORT`, `E2E_VALLIC_PORT`, `E2E_DRIVER_PORT` pin them):
| Piece | What it is |
| --- | --- |
| Test packages | The current version (AppImage, deb, rpm) and the next patch version (AppImage), signed with a throwaway update key whose public half replaces the real one, and allowed to fetch updates over plain HTTP. Moved out of `target/release/bundle` to `target/e2e/packages/`; never publish them |
-| sshd | On 127.0.0.1, 127.0.0.2 and 127.0.0.3, key login for the current user, a user CA it trusts and a host certificate for 127.0.0.3; SFTP starts in the run's `sshd/home`, whose `authorized_keys` is the one sshd reads, so offboarding never edits your real `~/.ssh` |
+| sshd | On 127.0.0.1, 127.0.0.2, 127.0.0.3 and 127.0.0.4, key login for the current user, a user CA it trusts and a host certificate for 127.0.0.3; SFTP starts in the run's `sshd/home`, whose `authorized_keys` is the one sshd reads, so offboarding never edits your real `~/.ssh` |
| `feed.mjs` | The update feed (`MORPHGATE_UPDATE_ENDPOINT`), serving a good and a tampered `latest.json` |
-| `vallic-console.mjs`, `clouds/aws` | Stand-ins for the Vallic console and the `aws` CLI, whose machine is the sshd |
+| `vallic-console.mjs`, `clouds/aws`, `clouds/doctl`, `host-feed.mjs` | Stand-ins for the Vallic console, the `aws` and `doctl` CLIs and a feed (`E2E_HOST_FEED`, with its token in `E2E_HOST_FEED_TOKEN`), whose machines are the sshd (the feed's on 127.0.0.4, with its host key listed) |
| `vault-sync-server`, `idp.mjs` | The reference server (open registration, invite codes instead of mail) and an OpenID provider, for `teams` and `layout` |
| `session.sh` | One `tauri-driver` per person, each with its own `HOME` and XDG folders in the run directory, so your vault, session bus and keyring are never touched |
diff --git a/docs/features/cloud-sources.md b/docs/features/cloud-sources.md
index 0eb0451..e117895 100644
--- a/docs/features/cloud-sources.md
+++ b/docs/features/cloud-sources.md
@@ -1,6 +1,6 @@
# Cloud sources
-A cloud source keeps a set of hosts in step with a provider: Vallic Cloud's environments, or the virtual machines of an AWS, Google Cloud, Azure, Hetzner Cloud or DigitalOcean account. A refresh brings what the cloud decides (which machines exist, their addresses, whether they run); what the person sets stays (renames, keys, schemes, tags, notes, groups); a machine the cloud no longer lists is marked gone, never deleted. The vault holds no cloud credentials: each device signs in to Vallic Cloud itself, and lists the other clouds with their own CLI as that device has it set up, so the calls go from the device to the provider and nothing reaches MorphGate's server but the encrypted items. Code: `crates/mg-inventory` (`lib.rs`: `InventoryHost`, `Machine`; `vallic.rs`, `cli.rs`, `aws.rs`, `gcp.rs`, `azure.rs`, `hetzner.rs`, `digitalocean.rs`, `shape.rs`), `crates/mg-store/src/model.rs` (`CloudSource`, `HostSource`, `CloudReach`), `apps/desktop/src-tauri/src/cloud.rs` (Tauri commands, `apply()`, `reach_command()`), `apps/desktop/src/CloudPage.tsx`.
+A cloud source keeps a set of hosts in step with a provider: Vallic Cloud's environments, the virtual machines of an AWS, Google Cloud, Azure, Hetzner Cloud or DigitalOcean account, or a feed (a JSON document of hosts and snippets that a team publishes at an address). A refresh brings what the provider decides (which machines exist, their addresses, whether they run); what the person sets stays (renames, keys, schemes, tags, notes, groups); a machine the provider no longer lists is marked gone, never deleted. The vault holds no cloud credentials: each device signs in to Vallic Cloud itself, and lists the other clouds with their own CLI as that device has it set up, so the calls go from the device to the provider and nothing reaches MorphGate's server but the encrypted items. A feed's token is the one exception ([Feeds](#feeds)). Code: `crates/mg-inventory` (`lib.rs`: `InventoryHost`, `Machine`; `vallic.rs`, `cli.rs`, `aws.rs`, `gcp.rs`, `azure.rs`, `hetzner.rs`, `digitalocean.rs`, `shape.rs`, `feed.rs`), `crates/mg-store/src/model.rs` (`CloudSource`, `HostSource`, `CloudReach`, `SnippetSource`), `apps/desktop/src-tauri/src/cloud.rs` (Tauri commands, `apply()`, `apply_snippets()`, `reach_command()`), `apps/desktop/src/CloudPage.tsx`.
## Providers
@@ -12,6 +12,7 @@ A cloud source keeps a set of hosts in step with a provider: Vallic Cloud's envi
| `azure` | `az` | `az login` | Resource group | The VM's admin user, else `azureuser` | Public, private | `azure:` |
| `hetzner` | `hcloud` | The CLI's context | Location | `root` | Public, private | `hetzner:/` |
| `digitalocean` | `doctl` | The CLI's context | Region | `root` | Public, private | `digitalocean:` |
+| `feed` | `GET` of its URL | The source's token, in the vault | The feed's `group`, else none | The feed's | Address, through the feed's own jump hosts | `feed:` |
## The items
@@ -19,7 +20,7 @@ A `cloud_source` item (in a personal or team vault):
| Field | Meaning |
| --- | --- |
-| `label`, `provider` | "Vallic Cloud", "AWS (prod)", "Hetzner Cloud (shop)"…; one of the six above (else "a cloud source's provider is vallic, aws, gcp, azure, hetzner or digitalocean") |
+| `label`, `provider` | "Vallic Cloud", "AWS (prod)", "Hetzner Cloud (shop)", a feed's name…; one of the seven above (else "a cloud source's provider is vallic, aws, gcp, azure, hetzner, digitalocean or feed") |
| `endpoint` | Vallic's console when not `https://console.vallic.com` |
| `account` | Who signed in when it was made (an email, an ARN, `user (subscription)`, the context) |
| `key_ref` | The `ssh_key` its new hosts sign in with; none: the SSH agent |
@@ -28,8 +29,9 @@ A `cloud_source` item (in a personal or team vault):
| `scopes` | AWS regions, Google Cloud projects or Azure subscription ids; empty: every enabled region, active project or enabled subscription the sign-in sees |
| `reach` | None (public addresses), `private` (with `jump_ref`: "private addresses need a jump host"), `aws-ssm`, `aws-eice` (AWS only), `gcp-iap` (Google Cloud only) |
| `jump_ref`, `user`, `group_tag` | The jump host for private addresses; who to log in as where a machine doesn't say; group by this tag's or label's value instead |
+| `url`, `token` | A feed's address (`https://`, or `http://` on `localhost`, `127.0.0.1` or `[::1]`: else "a feed's address starts with https://") and the token it's read with (required: "a feed needs the token it's read with") |
-A host it keeps carries `source` (`HostSource`): `source_ref`, `external_id` (what a refresh matches on), `label` (the provider's name for it: a host still called that follows renames, one renamed in MorphGate keeps its name), `gone`, `no_sftp`, `no_forwarding`, `state` (`stopped`, `VM deallocated`…) and `reach`. Lists show "gone from AWS" or the state as a badge, and hide **Files** for `no_sftp` ([Hosts, groups and identities](hosts-groups-identities.md)). For `no_forwarding` the tunnel editor leaves the host out, saving or starting a tunnel through it is refused ("\"{host}\" can't carry tunnels: its cloud's SSH doesn't forward ports"), and the agent isn't forwarded to it ([Port forwarding](port-forwarding.md)).
+A host it keeps carries `source` (`HostSource`): `source_ref`, `external_id` (what a refresh matches on), `label` (the provider's name for it: a host still called that follows renames, one renamed in MorphGate keeps its name), `gone`, `no_sftp`, `no_forwarding`, `state` (`stopped`, `VM deallocated`…), `reach`, and from a feed `host_keys` (its server's keys) and `tags` (the ones the feed gave it, among the host's). A feed's snippet carries `source` (`SnippetSource`: `source_ref`, `external_id`). Lists show "gone from AWS" ("gone from its feed") or the state as a badge, and hide **Files** for `no_sftp` ([Hosts, groups and identities](hosts-groups-identities.md)). For `no_forwarding` the tunnel editor leaves the host out, saving or starting a tunnel through it is refused ("\"{host}\" can't carry tunnels: its cloud's SSH doesn't forward ports"), and the agent isn't forwarded to it ([Port forwarding](port-forwarding.md)).
## Where credentials live
@@ -38,12 +40,13 @@ A host it keeps carries `source` (`HostSource`): `source_ref`, `external_id` (wh
| Vallic Cloud sign-in | The rotating refresh token, as a device secret: setting `secret:cloud:` in `settings`, sealed with the personal vault's key (readable only while the vault is unlocked), never synced. The access token stays in memory (15 minutes, renewed a minute early) |
| AWS, Google Cloud, Azure, Hetzner, DigitalOcean | The CLI's own configuration, untouched. MorphGate asks for no keys: keys kept in the vault would put long-lived cloud credentials in every synced copy, and each provider's single sign-on would have to be done again in MorphGate |
| The CLI's path, when it isn't found | Setting `cloud-cli:`, on this device |
+| A feed's token | The `cloud_source` item's `token`, synced with the vault ([Feeds](#feeds)) |
Refresh tokens rotate and a replayed one ends the sign-in, so `CloudState` lets one cloud operation run at a time per device, and a rotated token is stored before anything else can fail. MorphGate signs in to Vallic as the public client `vallic-cli` (`CLIENT_ID`), naming the session "MorphGate on ".
## Adding a source
-The Hosts page's **Add from…** → **Clouds…** (desktop) opens "Clouds": each source, then a **Vault** choice and **Connect Vallic Cloud** (**Connect another Vallic account** once there is one), **Add AWS**, **Add Google Cloud**, **Add Azure**, **Add Hetzner Cloud**, **Add DigitalOcean**.
+The Hosts page's **Add from…** → **Clouds…** (desktop) opens "Clouds": each source, then a **Vault** choice and **Connect Vallic Cloud** (**Connect another Vallic account** once there is one), **Add AWS**, **Add Google Cloud**, **Add Azure**, **Add Hetzner Cloud**, **Add DigitalOcean**, **Add a feed**. **Add from…** → **Feed URL…** opens it at **Add a feed**.
### Vallic Cloud
@@ -95,18 +98,104 @@ How a machine becomes a host (`shape::hosts()`):
| Sign-in (new hosts) | AWS: the vault key labelled like the instance's key pair; else the source's key; else the system's SSH agent |
| Left out | Windows machines, terminated EC2 instances; running machines without the address the source uses, and new stopped ones without any ("Without a public address: …") |
+### A feed
+
+**Add a feed**: "Hosts and snippets from a JSON document at an address, read with a token: …", the **Vault**, then:
+
+| Control | Field |
+| --- | --- |
+| **Address** (placeholder `https://ops.example.com/morphgate.json`) | `url` |
+| **Token** (a password field; "Kept in the vault, so every device that can open it refreshes the feed. Use a token that only reads the feed.") | `token` |
+| **Name (optional)** | `label`: else what the feed calls itself (`name`), else the address's host |
+| **Its hosts sign in with** | `key_ref` |
+| **Add and read the feed** ("Reading…"), **Cancel** | `cloud_feed_add`: the feed is read before the source is kept, so a wrong address or token is said and nothing is made ("{host} refused the token") |
+
+The source's card shows the address and the token's last 4 characters ("token …f3a9"; "…" for a token under 16 characters), never the rest. **Settings…** opens the same form (an empty **Token**, "Leave empty to keep it (…f3a9)", keeps it), then **Save and refresh** (`cloud_feed_settings`, then `cloud_refresh`).
+
## Refresh
-`cloud_refresh(source_id)` runs on demand: **Refresh** on the source, or after its settings change. `apply()` matches the source's hosts in its vault by `external_id`:
+`cloud_refresh(source_id)` runs on demand: **Refresh** on the source, or after its settings change; a feed also refreshes itself ([Feeds](#automatic-refresh)). `apply()` matches the source's hosts in its vault by `external_id`:
| What | On refresh |
| --- | --- |
-| Follows the provider | Label (until renamed here), host name (a stopped machine keeps its last one), port, user, login and jump commands, the jump host (the source's for private addresses, else none; Vallic's front), the `source` marker |
-| Stays the person's | Sign-in (key, identity), terminal scheme, VPN, tags, notes, group, agent forwarding, recording, session keeping |
-| A new machine | A new host, in the group of that name (made when missing) |
+| Follows the provider | Label (until renamed here), host name (a stopped machine keeps its last one), port, user, login and jump commands, the jump host (the source's for private addresses, else none; Vallic's front; a feed's `jump`), the `source` marker (a feed's host keys and state with it) |
+| Stays the person's | Sign-in (key, identity), terminal scheme, VPN, tags, notes, group, agent forwarding, recording, session keeping. A feed's tags are added: the ones it gave before are replaced by the ones it gives now, the person's stay |
+| A new machine | A new host, in the group of that name (made when missing; none where a feed names none) |
| No longer listed | `gone` set; listed again, it clears |
-The page says "2 hosts added, 1 updated, 1 gone from the cloud" (or "Nothing changed"), then what it skipped: "No shell for you (your role), or not running yet: …" (Vallic), "Without a public address: …", "Couldn't list: …" (regions, projects or subscriptions that refused, and machines whose ids aren't shaped like the cloud's).
+New items get ids worked out rather than drawn (`mg_store::derived_id`: SHA-256 of the vault, the source, the kind and the `external_id`, or the group's name in lowercase, as a ULID; `LocalVault::insert_in`): two devices refreshing a source at once make the same host, group or snippet under the same id, and the second gets a conflict instead of a duplicate. Copies that differ only in what the source decides (the source's `refreshed_at` too) make no "(conflict)" copy ([Sync](sync.md#conflicts)). A live item already holding the id (one that lost its `source`) leaves the new one a random id.
+
+The page says "2 hosts added, 1 updated, 1 gone from the cloud" ("…gone from the feed"; a feed adds "1 snippet added", "1 snippet changed", "1 snippet removed"), or "Nothing changed", then what it skipped: "No shell for you (your role), or not running yet: …" (Vallic), "Without a public address: …", "Couldn't list: …" (regions, projects or subscriptions that refused, and machines whose ids aren't shaped like the cloud's), "Skipped: …" (a feed's entries). A refresh that failed on this device (an automatic one included) shows on the card until one succeeds: "The last refresh failed: …" (`CloudState`, in memory).
+
+## Feeds
+
+A feed is a JSON document of hosts and snippets at an address, read with a token: generated from Terraform output, a CMDB or a cron job, or served by an internal endpoint. It's how a team brings in what no cloud's API shows (an operator port, a staff user, bastions). The device reads it straight from the publisher over HTTPS, and the result becomes ordinary vault items, so MorphGate's server sees nothing new. `mg_inventory::feed` reads it (`fetch`, `parse`): data only, never something that runs on the device (no proxy or local command, no `CloudReach`).
+
+### The request
+
+`GET ` with `Authorization: Bearer `, `Accept: application/json` and `User-Agent: MorphGate/`. Redirects are followed (5 at most) only to `https://` or this computer. The answer is read up to 5 MiB, within 60 s.
+
+| Answer | Message |
+| --- | --- |
+| 401 or 403 | "{source} refused the token: change it in the source's settings" |
+| Another status | "{source} answered {status}" |
+| Over 5 MiB | "{source} sent more than 5 MiB: a feed is at most that" |
+| Over 60 s | "{source} took longer than 60 s to answer" |
+| Not JSON, not an object, no `"version": 1`, `hosts` or `snippets` not a list | "{source} sent something this version can't read: …" |
+| `version` above 1 | "{source} is a version {n} feed: update MorphGate to read it" |
+| More than 5,000 hosts or 1,000 snippets | "{source} lists {n} hosts: a feed holds at most 5,000" |
+| Unreachable | "can't reach {source}: …" |
+
+A document refused whole changes nothing.
+
+### The format (version 1)
+
+```json
+{
+ "version": 1,
+ "name": "Ops",
+ "hosts": [
+ { "id": "bastion-fsn1", "label": "Bastion FSN1", "hostname": "bastion-fsn1.example.net",
+ "port": 22, "user": "ops", "group": "Bastions", "host_keys": ["ssh-ed25519 AAAAC3Nza..."] },
+ { "id": "server-1234", "label": "acme-prod-1", "hostname": "10.0.12.34", "port": 2222,
+ "user": "ops", "group": "Acme", "tags": ["production", "fsn1"], "jump": "bastion-fsn1",
+ "key": "customers", "host_keys": ["ssh-ed25519 AAAAC3Nza..."], "state": "running" }
+ ],
+ "snippets": [
+ { "id": "php-errors", "name": "PHP error log", "body": "podman logs --tail 200 php", "tags": ["diagnostics"] }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `version` | `1` |
+| `name` | Optional: what the source is called when it's added without a name |
+| `id` | Required, unique among the hosts (and among the snippets): 1 to 200 of `A-Z a-z 0-9 . _ : / @ -`. What a refresh matches on, as `external_id` `feed:` |
+| `hostname`, `user` | Required: no whitespace or control characters, no leading `-`, at most 255 bytes |
+| `label` | Optional (control characters out, at most 200 characters); else the `id` |
+| `port` | 1 to 65535; 22 when left out |
+| `group` | Optional: the group a new host goes in (made when missing); none when left out |
+| `tags` | Optional: added to the host's (at most 50, 64 characters each) |
+| `jump` | Optional: another host's `id` in the same feed, kept, not itself, and not leading round in a circle |
+| `key` | Optional: the label of an SSH key in the vault that a new host signs in with (as an EC2 key pair's name). No match: the source's key (**Its hosts sign in with**), else the SSH agent. Never a key itself |
+| `host_keys` | Optional, at most 16: the server's OpenSSH public keys (`ssh-ed25519`, `ecdsa-sha2-nistp256/384/521`, `ssh-rsa`, `sk-…`; type and base64, a comment after them dropped). They decide the host's key at connect time instead of trust on first use; another key is refused ([SSH connections](ssh-connections.md#keys-a-feed-lists)). Left out, the first connection asks as usual |
+| `state` | Optional: shown as a badge (`stopped`); `running` shows none |
+| `snippets[].id`, `name`, `body`, `tags` | `id` as for hosts; `name` and `body` required; the body at most 64 KiB, with no control characters but newlines and tabs (a terminal reads escape sequences as more than text) |
+
+Unknown fields are ignored and `null` reads as left out. An entry that breaks a rule is skipped and said ("Skipped: server-9: the port isn't 1 to 65535; …"); a host that jumps through a skipped one is skipped too.
+
+### Snippets
+
+A feed's snippets follow it whole: each refresh sets their name, body and tags, makes new ones and deletes the ones it no longer lists. They're read-only in the app (**Make a copy** to change one), and **Run** and **Paste** ask first when the feed changed one since this device last sent it ([Terminal and snippets](terminal-and-snippets.md#a-feeds-snippets)).
+
+### The token
+
+Every device of the team refreshes, so the token is in the `cloud_source` item, synced to everyone who can open the vault: an exception to the vault holding no cloud credentials, taken because a feed's token only reads a host list. Publishers should issue one that does only that. The card shows its last 4 characters at most. Offboarding lists each feed in the member's vaults under what to change: "Rotate the token for {source}" ([Offboarding](offboarding.md)).
+
+### Automatic refresh
+
+On desktops the app calls `cloud_auto_refresh` once the first sync since unlocking has finished (at once without sync, after 20 s at the latest), then every 15 minutes while unlocked. It refreshes, one at a time, each feed in a vault this device can write to whose `refreshed_at` (synced) is 10 minutes old or more, checking again just before (a sync may have brought another device's refresh). Members who can only view never refresh. The CLI providers and Vallic Cloud still refresh on demand only.
## Reaching machines through the cloud
@@ -125,9 +214,10 @@ The CLI's path is quoted for the shell with `%` doubled; a profile must be lette
| Control | Command | Does |
| --- | --- | --- |
| **Its hosts sign in with** | `cloud_source_key` | Sets the source's key; its hosts that used the previous key (or the system agent, when there was none) follow, others keep theirs |
-| **Settings…** (CLI sources) | `cloud_source_settings` | Changes what is listed and how; Vallic: "Vallic Cloud lists what your sign-in reaches" |
+| **Settings…** (CLI sources) | `cloud_source_settings` | Changes what is listed and how; Vallic: "Vallic Cloud lists what your sign-in reaches"; a feed: "a feed's settings are its address and token" |
+| **Settings…** (feeds) | `cloud_feed_settings` | Its name, address and token |
| **Sign out on this device** (Vallic) | `cloud_sign_out` | Revokes the token on the console and forgets it here; the source and hosts stay |
-| **Remove…**, then **Remove it and its hosts** or **Remove it, keep the hosts** | `cloud_source_delete(source_id, delete_hosts)` | Signs out, deletes the hosts (those behind a jump host first) or makes them ordinary hosts, then the source. Groups it made stay |
+| **Remove…**, then **Remove it and its hosts** or **Remove it, keep the hosts** (a feed with snippets: **Remove it, its hosts and snippets**, **Remove it, keep the hosts and snippets**) | `cloud_source_delete(source_id, delete_hosts)` | Signs out, deletes the hosts (those behind a jump host first) and a feed's snippets, or makes them ordinary hosts and snippets, then the source. Groups it made stay. Deleting the item while a host or snippet still names it is refused, as for anything in use |
## Running the CLIs
@@ -146,31 +236,35 @@ The CLI's path is quoted for the shell with `%` doubled; a profile must be lette
| Command | Does |
| --- | --- |
-| `cloud_sources` | Sources as `SourceView`: host and gone counts, `signed_in` (Vallic: this device has a sign-in; CLI sources: always), the CLI path set here |
+| `cloud_sources` | Sources as `SourceView`: host, gone and snippet counts, `signed_in` (Vallic: this device has a sign-in; the others: always), the CLI path set here, a feed's `url` and `token_end`, and `failed` (the last refresh here, when it failed) |
| `vallic_sign_in(endpoint)`, `vallic_sign_in_poll(endpoint, device_code, source_id, vault_id)` | The device sign-in; `Pending {slower}`, `Denied`, `Expired`, or `Done {source, refresh}` |
| `cloud_source_add(provider, vault_id, key_id, settings)`, `cloud_source_settings(source_id, settings)` | A CLI source; its settings |
| `cloud_check(provider, profile)`, `cloud_cli_path(provider, path)` | Who the CLI is signed in as; where this device runs it from |
-| `cloud_refresh(source_id)` | `RefreshResult {added, updated, gone, without_shell, without_address, skipped}` |
+| `cloud_feed_add(vault_id, key_id, {label, url, token})`, `cloud_feed_settings(source_id, {label, url, token})` | A feed (read first, then kept and brought in); its name, address and token (an empty token keeps it) |
+| `cloud_refresh(source_id)` | `RefreshResult {added, updated, gone, without_shell, without_address, skipped, snippets_added, snippets_updated, snippets_removed}` |
+| `cloud_auto_refresh` | The feeds that are due ([Automatic refresh](#automatic-refresh)); how many it refreshed. Nothing on phones |
| `cloud_source_key`, `cloud_sign_out`, `cloud_source_delete`, `cloud_add_key(source_id, key_id)` | As above |
## Platform differences
| Where | Difference |
| --- | --- |
-| Phones and tablets | No **Clouds…** page and no CLIs. The hosts sync and connect, except those reached through the cloud: "\"web-1\" connects through its cloud's CLI on the computer: connect to it from the desktop app" |
+| Phones and tablets | No **Clouds…** page and no CLIs, and feeds don't refresh themselves. The hosts sync and connect (a feed's host keys apply there too), except those reached through the cloud: "\"web-1\" connects through its cloud's CLI on the computer: connect to it from the desktop app" |
| Windows | CLIs found as `.exe` or `.cmd` |
## Tests
- `crates/mg-inventory/src/*.rs`: each provider's JSON as its CLI documents it, `shape.rs` (addresses, users, groups by tag, Session Manager), `vallic.rs` (environments as hosts), `cli.rs` (JSON read, stderr passed on, a missing CLI named). `crates/mg-store/src/model.rs`: `only_what_the_clouds_ids_look_like`. `apps/desktop/src-tauri/src/cloud.rs`: private machines and stopped ones, the reach command built here, a refresh following the platform and keeping what is the person's.
- `crates/mg-inventory/tests/console.rs`: the Vallic client against a fake console on loopback (device flow, rotation, a replayed token refused, the inventory).
+- `crates/mg-inventory/src/feed.rs`: the format's example, defaults, every rule's skip and its message, documents refused whole, `https://` only. `crates/mg-inventory/tests/feed.rs`: the feed client against a fake publisher on loopback (the token and headers sent, a refused token, over 5 MiB, a redirect to plain HTTP not followed). `apps/desktop/src-tauri/src/cloud.rs`: `a_feed_keeps_hosts_and_snippets_in_step` (derived ids, jump hosts, groups, tags kept, host keys, snippets dropped). `crates/mg-store/src/sync.rs`: `refreshes_on_two_devices_make_no_copies`. `crates/mg-ssh/tests/live.rs`: `listed_host_keys_decide`.
- `crates/mg-inventory/tests/clis.rs`: the real CLIs, read only, printing counts only; ignored unless asked, e.g. `MG_TEST_AWS=1 [MG_TEST_AWS_PROFILE=…] cargo test -p mg-inventory --test clis -- --include-ignored --nocapture` (also `MG_TEST_AZ`, `MG_TEST_GCLOUD`, `MG_TEST_HCLOUD`, `MG_TEST_DOCTL`).
-- e2e `apps/desktop/e2e/appimage.mjs`: Vallic Cloud through `vallic-console.mjs` (sign-in approved on the second poll, the key added to the account, no Files); AWS through the stand-in `e2e/clouds/aws` (an instance that is the test sshd, signing in with the vault key named like its key pair, a stopped one waiting, then the same source through Session Manager, which the stand-in relays with `crates/mg-ssh/tests/relay.py`); DigitalOcean through `e2e/clouds/doctl`. `run.sh` sets `MORPHGATE_VALLIC_CONSOLE`, `MORPHGATE_CLOUD_CLI_DIR`, `E2E_CLOUD_PORT` and `E2E_CLOUD_USER`.
+- e2e `apps/desktop/e2e/appimage.mjs`: Vallic Cloud through `vallic-console.mjs` (sign-in approved on the second poll, the key added to the account, no Files); AWS through the stand-in `e2e/clouds/aws` (an instance that is the test sshd, signing in with the vault key named like its key pair, a stopped one waiting, then the same source through Session Manager, which the stand-in relays with `crates/mg-ssh/tests/relay.py`); DigitalOcean through `e2e/clouds/doctl`; a feed through `e2e/host-feed.mjs` (a wrong token said, its host on 127.0.0.4 connecting with the listed key and no prompt, one listed with another key refused, an invalid entry skipped, its snippet read-only and asking before it runs once the feed changed it). `run.sh` sets `MORPHGATE_VALLIC_CONSOLE`, `MORPHGATE_CLOUD_CLI_DIR`, `E2E_CLOUD_PORT`, `E2E_CLOUD_USER`, `E2E_HOST_FEED` and `E2E_HOST_FEED_TOKEN`.
## Limits
-- Refreshing is on demand only, per source, on a desktop.
+- Refreshing is on demand only, per source, on a desktop; feeds also refresh themselves there.
- Vallic Cloud: a source brings every project the sign-in reaches, grouped by project; no SFTP; its database and cache tunnels (`vallic-tunnel`) aren't offered, and neither are ordinary tunnels or agent forwarding (`no_forwarding`).
- Not supported: Azure Bastion (`az network bastion tunnel` listens on a port and can't carry a connection on stdin), Azure's Entra ID sign-in to VMs (`az ssh cert`). Windows machines are left out.
- A jump host set by hand on a kept host is replaced at the next refresh.
-- Moving a source to another vault doesn't take its hosts (they don't ride with it); its next refresh there makes new ones.
+- Moving a source to another vault doesn't take its hosts or snippets (they don't ride with it); its next refresh there makes new ones.
+- A feed can't carry a login command or reach a host through a command (a remote login command may come later); ETags aren't sent yet, so each refresh reads the whole document.
diff --git a/docs/features/hosts-groups-identities.md b/docs/features/hosts-groups-identities.md
index ebfa976..339dc6d 100644
--- a/docs/features/hosts-groups-identities.md
+++ b/docs/features/hosts-groups-identities.md
@@ -18,7 +18,7 @@ An item is an `ItemData` serialized as JSON with a `type` tag (`{"type": "host",
| `vpn` | A VPN profile a host can go through | [VPN](vpn.md) |
| `terminal_theme` | Terminal colors | [Appearance](appearance.md) |
| `session_log` | A recorded session (blobs) | [Session logs](session-logs.md) |
-| `cloud_source` | Hosts kept in step with a cloud | [Cloud sources](cloud-sources.md) |
+| `cloud_source` | Hosts kept in step with a cloud or a feed | [Cloud sources](cloud-sources.md) |
| `member_key` | A member's published SSH public key; only in an organization's directory vault | [Teams](teams.md) |
| unknown | A kind a newer app wrote: kept and synced, left out of lists, never edited or moved | |
diff --git a/docs/features/offboarding.md b/docs/features/offboarding.md
index 2d16367..ac083c6 100644
--- a/docs/features/offboarding.md
+++ b/docs/features/offboarding.md
@@ -25,6 +25,7 @@ In a team's member list, **Remove…** next to a confirmed member opens the page
| Identity | other item | "Password of {label}" when it has a saved password |
| SSH key | SSH key | "SSH key {label}", with "used by {hosts}": hosts that log in with it, directly or through an identity or group (`effective_host()`) |
| VPN profile | VPN | "VPN profile {label}" |
+| Feed ([Cloud sources](cloud-sources.md#feeds)) | other item | "Rotate the token for {label} (a feed: change it where the feed is published, then in the source's settings)": its token is in the vault |
| Member key | not counted | |
| Anything else | other item | |
@@ -59,7 +60,7 @@ The file is edited with these safety checks (`authorized_keys.rs`, `edit_keys()`
## 4. Change what they could see
-A checklist of the secrets from step 1; the ticks belong to the page and are gone when it closes. Passwords and VPN profiles are changed by hand (on the server or the VPN provider, then in the item). SSH keys have **Replace key** ("Replacing…"); a replaced key shows "replaced ({fingerprint})", and "; the old key is still on {hosts}" when removing it failed somewhere. A failure shows its message in the row.
+A checklist of the secrets from step 1; the ticks belong to the page and are gone when it closes. Passwords, VPN profiles and feed tokens are changed by hand (on the server, the VPN provider or where the feed is published, then in the item or the feed's **Settings…**). SSH keys have **Replace key** ("Replacing…"); a replaced key shows "replaced ({fingerprint})", and "; the old key is still on {hosts}" when removing it failed somewhere. A failure shows its message in the row.
### Replacing a shared SSH key
diff --git a/docs/features/ssh-connections.md b/docs/features/ssh-connections.md
index ac765f6..9004d78 100644
--- a/docs/features/ssh-connections.md
+++ b/docs/features/ssh-connections.md
@@ -1,6 +1,6 @@
# SSH connections
-A connection is one SSH transport to a host, possibly through jump hosts, a VPN or a proxy command, with a PTY shell on top. Every hop's host key is checked before anything is sent to it: pinned on first use in `known_hosts`, or vouched for by a host CA. Each hop signs in with the one method its host is set to. A lost connection reconnects by itself. Code: `crates/mg-ssh/src/connection.rs` (`Connection`, `Hop`, `Auth`, `Verifier`), `crates/mg-ssh/src/session.rs` (`Session`, `ConnectOptions`), `crates/mg-ssh/src/known_hosts.rs` (`KnownHosts`, `HostAuthority`), `crates/mg-ssh/src/proxy_command.rs`, `crates/mg-ssh/src/command_stream.rs`, `crates/mg-ssh/src/error.rs`, `apps/desktop/src-tauri/src/ssh.rs` (Tauri commands), `apps/desktop/src-tauri/src/proxy.rs`, `apps/desktop/src-tauri/src/host_cas.rs`, `apps/desktop/src/ConnectForm.tsx`, `apps/desktop/src/HostKeyDialog.tsx`, `apps/desktop/src/HostCaPanel.tsx`, `apps/desktop/src/TerminalView.tsx`.
+A connection is one SSH transport to a host, possibly through jump hosts, a VPN or a proxy command, with a PTY shell on top. Every hop's host key is checked before anything is sent to it: pinned on first use in `known_hosts`, vouched for by a host CA, or listed by the host's feed. Each hop signs in with the one method its host is set to. A lost connection reconnects by itself. Code: `crates/mg-ssh/src/connection.rs` (`Connection`, `Hop`, `Auth`, `Verifier`), `crates/mg-ssh/src/session.rs` (`Session`, `ConnectOptions`), `crates/mg-ssh/src/known_hosts.rs` (`KnownHosts`, `HostAuthority`), `crates/mg-ssh/src/proxy_command.rs`, `crates/mg-ssh/src/command_stream.rs`, `crates/mg-ssh/src/error.rs`, `apps/desktop/src-tauri/src/ssh.rs` (Tauri commands), `apps/desktop/src-tauri/src/proxy.rs`, `apps/desktop/src-tauri/src/host_cas.rs`, `apps/desktop/src/ConnectForm.tsx`, `apps/desktop/src/HostKeyDialog.tsx`, `apps/desktop/src/HostCaPanel.tsx`, `apps/desktop/src/TerminalView.tsx`.
Keys, certificates and agents are in [SSH keys and agents](ssh-keys-and-agents.md); the host item and its editor in [Hosts, groups and identities](hosts-groups-identities.md); the overview in [App architecture](../architecture.md#how-a-connection-runs).
@@ -111,6 +111,10 @@ A `host_ca` item (`HostCa { label, public_key, hosts }`) trusts host certificate
Where a CA is trusted for a hop, `client_config(true)` asks the server for host certificates (Ed25519, ECDSA P-256/384/521, RSA SHA-512/256). A certificate is accepted, and nothing pinned, only if `known_hosts::certified` agrees: it is a host certificate, signed by one of the CAs trusted for this host, valid now with a good signature, its principals are empty or include the hostname, and it has no critical options. Otherwise the connection fails with the reason, as OpenSSH does; it does not fall back to pinning. A server that answers with a plain key is pinned as usual. Without a CA for the host, certificates are never asked for.
+### Keys a feed lists
+
+A host a feed keeps can come with its server's host keys (`HostSource.host_keys`, from the feed's `host_keys`; [Cloud sources](cloud-sources.md#feeds)). `saved_hop` hands them to the hop as `Hop::listed_keys` (`ListedKeys {keys, from}`, `from` the feed's name), for that host item only: another host item with the same address is checked as usual. For such a hop (`Verifier::listed`), after an `@revoked` line (which still refuses), the server's plain key must be one of the listed ones: then it is trusted, without a prompt and without being written to `known_hosts`, whatever a file pins for the address; any other key fails with "the host key of {host}:{port} ({fingerprint}) isn't one {feed} lists for it: refused. Someone may be intercepting the connection, or {feed} doesn't know the server's new key yet" (`Error::HostKeyNotListed`). Listed lines that don't parse are dropped; when none parses, the host is checked as any other. A host CA trusted for the host still decides first, where the server presents a certificate. The route bar shows "key listed by {feed}" instead of the pinned key.
+
## Jump hosts
A host's `jump_ref` (or its group's) names another host to go through; that one can have its own. `LocalVault::route` follows the chain, at most 8 jump hosts (`MAX_JUMPS`), refusing loops ("jump hosts form a loop or a chain that is too long"); saving refuses them too. A jump host must be in the same vault.
@@ -178,6 +182,7 @@ Connection errors are `mg_ssh::Error`'s text, written in red in the tab.
| "via jump host {host}:{port}: …" | Wraps any error from a later hop |
| "REMOTE HOST KEY CHANGED for {host}:{port} ({type} {fingerprint})…" | Changed key; the dialog shows first |
| "host key for {host}:{port} was not accepted" | Prompt cancelled or timed out |
+| "the host key of {host}:{port} ({fingerprint}) isn't one {feed} lists for it: refused…" | A host whose feed lists its keys answered with another |
| "the host certificate of {host}:{port} isn't accepted: {why}" | Why: "it's a user certificate, not a host's", "it's signed by {fp}, which isn't a CA trusted for this host", "it isn't valid now (expired, not yet valid), or its signature doesn't check out", "it's for {principals}, not {host}", "it has critical options, which host certificates don't" |
| "authentication failed" | The server refused the method |
| "password required" / "jump host \"{label}\" has no saved password; save one or switch it to a key" | No password to send |
@@ -215,8 +220,8 @@ Connection errors are `mg_ssh::Error`'s text, written in red in the tab.
| `crates/mg-ssh/src/proxy_command.rs` | Token expansion, refused host and user names, bytes both ways, stderr as the error |
| `crates/mg-ssh/src/connection.rs` | The macOS local-network hint |
| `apps/desktop/src-tauri/src/ssh.rs`, `proxy.rs`, `host_cas.rs` | A synced proxy command waits for allowing; tmux names; request shapes match the webview; a changed command asks again; parsing a CA key or `@cert-authority` line |
-| `crates/mg-ssh/tests/live.rs` | Against a real sshd: shell, resize, exit and TOFU; a rejected key isn't learned; a changed key fails closed; wrong password; key file; unreachable port; jump host tunnel; jump command; proxy command, and failing ones that say why; a host certificate from a trusted CA (`MG_TEST_HOST_CA`); the first hop through a dialer |
-| `apps/desktop/e2e/appimage.mjs` | Quick connect with a key file chosen in the form, and with a pasted key after a `morphgate://` link; **Trust and connect**, the pinned key in the route bar, **Host key changed** for an edited `known_hosts`, a jump host from `~/.ssh/config`, a jump command and a proxy command (via `crates/mg-ssh/tests/relay.py`), a host CA with "certified by" and no prompt |
+| `crates/mg-ssh/tests/live.rs` | Against a real sshd: shell, resize, exit and TOFU; a rejected key isn't learned; a changed key fails closed; wrong password; key file; unreachable port; jump host tunnel; jump command; proxy command, and failing ones that say why; a host certificate from a trusted CA (`MG_TEST_HOST_CA`); the first hop through a dialer; listed host keys connect without asking (over a stale pin) and aren't written down, another key is refused |
+| `apps/desktop/e2e/appimage.mjs` | Quick connect with a key file chosen in the form, and with a pasted key after a `morphgate://` link; **Trust and connect**, the pinned key in the route bar, **Host key changed** for an edited `known_hosts`, a jump host from `~/.ssh/config`, a jump command and a proxy command (via `crates/mg-ssh/tests/relay.py`), a host CA with "certified by" and no prompt; a feed's host with its listed key (no prompt), and one listed with another key refused |
The live tests are ignored unless `MG_TEST_SSH=host:port` is set (and `MG_TEST_USER`, `MG_TEST_KEY`, optionally `MG_TEST_PASSWORD`); the header of `live.rs` starts a throwaway user-level sshd. Run them with `cargo test -p mg-ssh --test live -- --include-ignored --test-threads=1`. See [Development](../development.md) for the e2e runner.
diff --git a/docs/features/sync.md b/docs/features/sync.md
index f411c90..a0b458e 100644
--- a/docs/features/sync.md
+++ b/docs/features/sync.md
@@ -205,6 +205,7 @@ From the code's own statement: the server copy always wins for an item id, and a
| Local | Server | Result |
| --- | --- | --- |
| An edit | An edit with the same content (both re-sealed it, say) | No copy, no conflict |
+| A cloud source's change | The same source's change from another device (both refreshed it) | No copy, no conflict, when the copies differ only in what the source decides (`source_decides`): a source item only in `refreshed_at`; a host it keeps (same `source_ref` and `external_id`) only in label while it follows the source, host name, port, user, jump host, login and jump commands, the source's tags and the `source` marker; a feed's snippet, whatever it differs in. The next refresh sets it again. A person's edit (a rename, a note, a tag, a key) still makes a copy |
| An edit | A different edit | The server's version under the id; the local one as a new item (new ULID, `rev` 0, dirty), pushed like any new item |
| An edit | A deletion that got there first | The item is deleted; the edit survives as a copy |
| A deletion | An edit that got there first | No copy; the item comes back with the server's edit |
diff --git a/docs/features/terminal-and-snippets.md b/docs/features/terminal-and-snippets.md
index 2d30c03..6a5ae95 100644
--- a/docs/features/terminal-and-snippets.md
+++ b/docs/features/terminal-and-snippets.md
@@ -112,7 +112,21 @@ Shown above terminal tabs (`RouteBar.tsx`). Its menus close on a click elsewhere
## Snippets
-A `snippet` item: `name` (trimmed), `body` (exactly as typed), `tags` (trimmed, empty ones dropped, left out of the JSON when there are none). Lists sort by name, ignoring case. A snippet lives in any vault; in a view-only vault it can be sent but not edited. Importing from CSV is in [Importing](importing.md).
+A `snippet` item: `name` (trimmed), `body` (exactly as typed), `tags` (trimmed, empty ones dropped, left out of the JSON when there are none), and `source` (`SnippetSource {source_ref, external_id}`) when a feed keeps it. Lists sort by name, ignoring case. A snippet lives in any vault; in a view-only vault it can be sent but not edited. Importing from CSV is in [Importing](importing.md).
+
+### A feed's snippets
+
+A feed ([Cloud sources](cloud-sources.md#feeds)) brings snippets as well as hosts. The feed decides them whole: each refresh sets name, body and tags, and deletes one the feed no longer lists (nobody's edits are lost: they can't be edited). In the app they are read-only:
+
+| Where | Difference |
+| --- | --- |
+| Snippets page | "From {feed} (read-only)" under the snippet's name; the row's icon is **Open** rather than **Edit** |
+| Editor | "From the feed **{feed}**: it changes there, and is read-only here." with **Make a copy** (a snippet of one's own, "{name} (copy)", in the same vault, or the personal one when that's view-only, which then opens); the fields are read-only, and there's no **Save** or **Delete** |
+| `snippet_save`, `snippet_delete` | Refused: "\"{name}\" comes from {feed}: it changes there, or make a copy to change it here" |
+| Bulk delete | Left out: "\"{name}\" comes from a feed: it goes when the feed drops it" |
+| Removing the feed | **Remove it, its hosts and snippets** deletes them; **Remove it, keep the hosts and snippets** makes them ordinary snippets |
+
+**Run** and **Paste** ask first when the feed changed the body since this device last sent it: "{name} changed", "Changed by **{feed}** on {date}, since you last ran it on this device. It now runs:" with the body, **Cancel** and **Run it** (or **Paste it**) (`SnippetCheck.tsx`). Every place a snippet is sent goes through `guardSnippet` in `App.tsx`: the route bar's menu, the Snippets page, the editor and the phone's **Snippets** sheet. After a feed's snippet is sent, `snippet_sent` keeps the SHA-256 of its body on this device (setting `snippet-sent:`, not synced); `snippets_list` sets `changed` when that hash is there and differs from the body's. A snippet never sent from this device doesn't ask.
| Control (`SnippetPanel.tsx`) | Does |
| --- | --- |
@@ -136,6 +150,7 @@ The Snippets page (sidebar **Snippets**) lists them as a table (name, the comman
| `snippets_list` | All snippets in every vault |
| `snippet_save` | `{id?, vaultId?, name, body, tags}`; a new snippet goes to `vaultId` or the personal vault |
| `snippet_delete` | `id` |
+| `snippet_sent` | `id`: this device sent a feed's snippet (keeps its body's hash) |
## Keyboard
@@ -162,6 +177,7 @@ The phone layout (`mobile/MobileWorkspace.tsx`) is used on Android and iPhone, a
| `apps/desktop/e2e/teams.mjs` | A group's identity and tmux session; **Reconnect** picks the tmux session back up |
| `apps/desktop/e2e/mobile.mjs` | Ctrl and ↑ from the extra-key row; Esc then a snippet runs as written; a snippet imported from CSV |
| `apps/desktop/src-tauri/src/bulk.rs`: `deletes_snippets_and_groups_and_says_what_it_leaves` | Bulk delete |
+| `apps/desktop/e2e/appimage.mjs` (the feed) | A feed's snippet runs; it's read-only with **Make a copy**; changed by the feed, running it asks first, once |
The e2e scenarios run with `apps/desktop/e2e/run.sh` (`E2E_ONLY=appimage`, `teams` or `mobile` for one).
diff --git a/docs/features/vault-and-unlock.md b/docs/features/vault-and-unlock.md
index 228774f..99c6fc1 100644
--- a/docs/features/vault-and-unlock.md
+++ b/docs/features/vault-and-unlock.md
@@ -49,7 +49,7 @@ Each item's plaintext is an `ItemData`, JSON with a `type` tag (`model.rs`, `SCH
| `vpn` | VPN profiles | [VPN](vpn.md) |
| `session_log` | A recorded session, its chunks in blobs | [Session logs](session-logs.md) |
| `terminal_theme` | Imported color schemes | [Appearance](appearance.md) |
-| `cloud_source` | A cloud account whose machines become hosts | [Cloud sources](cloud-sources.md) |
+| `cloud_source` | A cloud account or feed whose machines become hosts | [Cloud sources](cloud-sources.md) |
| `member_key` | A member's SSH public key, in an organization's directory vault | [Teams](teams.md) |
Store-level rules that apply to every type:
diff --git a/docs/sync-crypto-spec.md b/docs/sync-crypto-spec.md
index e5e3113..f96a7f1 100644
--- a/docs/sync-crypto-spec.md
+++ b/docs/sync-crypto-spec.md
@@ -138,17 +138,17 @@ Serialized as JSON with a `type` tag and the write's `item_version` (Item histor
| Type | Fields | Status |
| --- | --- | --- |
-| `host` | label, hostname, port, user, auth (`password {password?}` \| `key {key_ref}` \| `agent`), jump_ref? (host to tunnel through, chains allowed, no loops, ≤ 8 deep), vpn_ref? (VPN for the first hop), theme_ref? (a `terminal_theme` in the same vault, or `builtin:` for one built into the app), record_sessions?, keep_session? (attach to a tmux session on the server), group_ref? (a `host_group`), identity_ref? (an `identity`, instead of user and auth; user may then be empty), tags, notes | Implemented |
+| `host` | label, hostname, port, user, auth (`password {password?}` \| `key {key_ref}` \| `agent`), jump_ref? (host to tunnel through, chains allowed, no loops, ≤ 8 deep), vpn_ref? (VPN for the first hop), theme_ref? (a `terminal_theme` in the same vault, or `builtin:` for one built into the app), record_sessions?, keep_session? (attach to a tmux session on the server), group_ref? (a `host_group`), identity_ref? (an `identity`, instead of user and auth; user may then be empty), tags, notes, source? (the `cloud_source` that keeps it: source_ref, external_id, label, gone?, state?, host_keys? (its server's keys, listed by a feed), tags? (the ones the feed gave it)) | Implemented |
| `vpn` | label, vpn (`wireguard {config}`: wg-quick text incl. private key) | Implemented |
| `ssh_key` | label, private_key (OpenSSH/PEM, as imported), passphrase?, public_key, algorithm, fingerprint | Implemented |
-| `snippet` | name, body, tags | Implemented (storage only) |
+| `snippet` | name, body, tags, source? (the feed that keeps it: source_ref, external_id) | Implemented (storage only) |
| `identity` | label, user, auth (as a host's) — shared credentials hosts refer to | Implemented |
| `host_group` | name, identity_ref?, jump_ref?, vpn_ref?, theme_ref?, keep_session? — a host takes what it leaves empty from its group (credentials only when its user is empty); deleting a group makes those values the hosts' own | Implemented |
| `attachment` | name, host_ref?, blob, size (see Blobs) | Implemented |
| `session_log` | see Blobs | Implemented |
| `port_forward` | label, host_ref, rule (`local` \| `remote` {bind_host, bind_port, target_host, target_port} \| `dynamic` {bind_host, bind_port}) | Implemented |
| `known_host` | host pattern, key type, public key | Planned (today: local known_hosts file) |
-| `cloud_source` | label, provider, endpoint?, key_ref?, account — a cloud account whose machines become hosts, kept in step with it ([cloud sources](features/cloud-sources.md)) | Implemented |
+| `cloud_source` | label, provider, endpoint?, key_ref?, account, url?, token? — a cloud account or feed whose machines become hosts, kept in step with it ([cloud sources](features/cloud-sources.md)) | Implemented |
| `host_ca` | label, public_key, hosts (patterns) — a CA whose signed host keys are trusted for those hosts (`@cert-authority`) | Implemented |
| `terminal_theme` | name, background, foreground, cursor, cursor_accent, selection_background, selection_foreground?, ansi (16 colors), all `#rrggbb`; imported from Ghostty, Alacritty, iTerm2 or Windows Terminal theme files | Implemented |
| `member_key` | account_id, public_key (OpenSSH `type base64`), label, sig (member key signature, hex) — only in an organization's directory vault | Implemented |
diff --git a/tools/readme-demo/demo-svg.mjs b/tools/readme-demo/demo-svg.mjs
new file mode 100644
index 0000000..4badda0
--- /dev/null
+++ b/tools/readme-demo/demo-svg.mjs
@@ -0,0 +1,156 @@
+// The README's demo: MorphGate as the AppImage showed it, drawn as an animated SVG. Scene one is
+// the Hosts page (the sidebar and the list of hosts); a pointer clicks a host; scene two is its
+// session in a tab, the recording replayed in the app's own terminal area. The scenes are what
+// demo.mjs captured from the app (snapshot.js: boxes, text and icons where the layout put them),
+// drawn in the app's fonts, embedded (SIL Open Font License, apps/desktop/src/assets/fonts).
+//
+// node demo-svg.mjs
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { draw, replay, THEME } from "./terminal.mjs";
+
+const [input, output] = process.argv.slice(2);
+if (!input || !output) {
+ console.error("usage: node demo-svg.mjs ");
+ process.exit(2);
+}
+const demo = JSON.parse(readFileSync(input, "utf8"));
+const fonts = join(dirname(fileURLToPath(import.meta.url)), "../../apps/desktop/src/assets/fonts");
+
+// The story, in seconds: the Hosts page, the pointer moving to the host, the click; then the
+// session from its start; the last screen held before it all starts over.
+const MOVE_FROM = 0.7;
+const MOVE_TO = 1.8;
+const CLICK = 2.0;
+const CONNECT = 2.4;
+const HOLD = 3.5;
+
+const session = await replay(demo.cast);
+const total = CONNECT + session.duration + HOLD;
+const pct = (t) => `${((t / total) * 100).toFixed(3)}%`;
+const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+const n = (v) => +v.toFixed(1);
+const W = demo.s1.width;
+const H = demo.s1.height;
+
+// Font families as classes (the computed ones, each a long list).
+const families = new Map();
+const familyClass = (f) => {
+ if (!families.has(f)) families.set(f, `f${families.size}`);
+ return families.get(f);
+};
+// Clips as clip paths, the whole window needing none.
+const clips = new Map();
+const clipAttr = (c) => {
+ if (!c || (c.x <= 0 && c.y <= 0 && c.x + c.w >= W && c.y + c.h >= H)) return "";
+ const key = [c.x, c.y, c.w, c.h].map((v) => Math.round(v)).join(",");
+ if (!clips.has(key)) clips.set(key, `c${clips.size}`);
+ return ` clip-path="url(#${clips.get(key)})"`;
+};
+
+/** A captured screen: its boxes, then icons and text. */
+function scene(s) {
+ let out = ` `;
+ for (const b of s.boxes) {
+ const shape = `x="${n(b.x)}" y="${n(b.y)}" width="${n(b.w)}" height="${n(b.h)}"${b.radius ? ` rx="${n(Math.min(b.radius, b.w / 2, b.h / 2))}"` : ""}`;
+ const paint = b.stroke
+ ? `fill="none" stroke="${b.stroke}" stroke-width="${b.strokeWidth}" transform="translate(${b.strokeWidth / 2} ${b.strokeWidth / 2})"`
+ : `fill="${b.fill}"`;
+ const inset = b.stroke ? shape.replace(/width="([\d.]+)" height="([\d.]+)"/, (_, w, h) => `width="${n(w - b.strokeWidth)}" height="${n(h - b.strokeWidth)}"`) : shape;
+ out += ` `;
+ }
+ for (const i of s.icons) {
+ // The page's own SVG, placed where it was, its currentColor the element's color.
+ const svg = i.svg.replace(/^]*)>/, (_, attrs) => {
+ const kept = attrs.replace(/\s(width|height|class|style|x|y|aria-hidden)="[^"]*"/g, "");
+ return ``;
+ });
+ const cx = n(i.x + i.w / 2);
+ const cy = n(i.y + i.h / 2);
+ const turned = (i.turns ?? []).reduceRight(
+ (inner, m) => `${inner} `,
+ svg,
+ );
+ const clip = clipAttr(i.clip);
+ out += clip ? `${turned} ` : turned;
+ }
+ for (const t of s.texts) {
+ const attrs = [
+ `x="${n(t.x)}"`,
+ `y="${n(t.y + t.h / 2)}"`,
+ `class="${familyClass(t.family)}"`,
+ `font-size="${t.size}"`,
+ t.weight !== "400" && `font-weight="${t.weight}"`,
+ t.style === "italic" && `font-style="italic"`,
+ t.spacing && `letter-spacing="${t.spacing}"`,
+ `fill="${t.color}"`,
+ t.text.length > 3 && `textLength="${n(t.w)}"`,
+ ].filter(Boolean);
+ out += `${esc(t.text)} `;
+ }
+ return out;
+}
+
+const one = scene(demo.s1);
+const two = scene(demo.s2);
+
+// The session in the terminal area of scene two, on the grid the app laid it out on.
+const area = demo.s2.terminal;
+const cw = area.w / session.cols;
+const lh = area.h / session.rows;
+const term = draw(session, { cw, lh, start: CONNECT, total });
+
+// The pointer: from below right of the host to it, a click, then gone with scene one.
+const { target } = demo;
+const tx = target.x + Math.min(40, target.w / 2);
+const ty = target.y + target.h / 2;
+const fx = tx + 260;
+const fy = ty + 170;
+const pointer = ` `;
+const ripple = ` `;
+
+const font = (family, file, weight, style = "normal") =>
+ `@font-face{font-family:"${family}";src:url(data:font/woff2;base64,${readFileSync(join(fonts, file)).toString("base64")}) format("woff2");font-weight:${weight};font-style:${style}}`;
+const t = () => `${total.toFixed(2)}s`;
+const css = [
+ font("Atkinson Hyperlegible Next", "AtkinsonHyperlegibleNext-Variable.woff2", "200 800"),
+ font("Atkinson Hyperlegible Mono", "AtkinsonHyperlegibleMono-Variable.woff2", "200 800"),
+ font("Martian Mono", "MartianMono-Variable.woff2", "100 800"),
+ [...families].map(([f, c]) => `.${c}{font-family:${f}}`).join(""),
+ `text{dominant-baseline:central;white-space:pre}`,
+ `.term text{font-family:${area.family}}`,
+ `.s1{animation:s1 ${t()} steps(1,end) infinite}@keyframes s1{0%{opacity:1}${pct(CONNECT)}{opacity:0}}`,
+ `.s2{animation:s2 ${t()} steps(1,end) infinite}@keyframes s2{0%{opacity:0}${pct(CONNECT)}{opacity:1}}`,
+ `.ptr{animation:ptr ${t()} infinite}@keyframes ptr{0%{transform:translate(${n(fx)}px,${n(fy)}px);opacity:1}` +
+ `${pct(MOVE_FROM)}{transform:translate(${n(fx)}px,${n(fy)}px);animation-timing-function:ease-in-out}` +
+ `${pct(MOVE_TO)}{transform:translate(${n(tx)}px,${n(ty)}px)}${pct(CONNECT + 0.4)}{transform:translate(${n(tx)}px,${n(ty)}px);opacity:1}` +
+ `${pct(CONNECT + 0.7)}{transform:translate(${n(tx)}px,${n(ty)}px);opacity:0}100%{transform:translate(${n(tx)}px,${n(ty)}px);opacity:0}}`,
+ `.rip{transform-box:fill-box;transform-origin:center;animation:rip ${t()} infinite}@keyframes rip{0%{opacity:0;transform:scale(.3)}` +
+ `${pct(CLICK)}{opacity:0;transform:scale(.3)}${pct(CLICK + 0.05)}{opacity:.9;transform:scale(.4)}${pct(CLICK + 0.5)}{opacity:0;transform:scale(1.4)}100%{opacity:0}}`,
+ term.css,
+].join("");
+
+const defs = [...clips]
+ .map(([key, id]) => {
+ const [x, y, w, h] = key.split(",");
+ return ` `;
+ })
+ .join("");
+
+const svg = `
+MorphGate: connecting to ${esc(session.title)}
+
+
+${defs}
+
+${two}${term.svg}
+${one}${ripple}
+${pointer}
+
+
+
+`;
+writeFileSync(output, svg);
+console.log(`${output}: ${W}×${H}, ${session.frames.length} frames, ${total.toFixed(1)} s, ${(svg.length / 1024).toFixed(0)} KiB`);
diff --git a/tools/readme-demo/package-lock.json b/tools/readme-demo/package-lock.json
new file mode 100644
index 0000000..7dabe1d
--- /dev/null
+++ b/tools/readme-demo/package-lock.json
@@ -0,0 +1,23 @@
+{
+ "name": "morphgate-readme-demo",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "morphgate-readme-demo",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@xterm/headless": "6.0.0"
+ }
+ },
+ "node_modules/@xterm/headless": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
+ "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
+ }
+ }
+}
diff --git a/tools/readme-demo/package.json b/tools/readme-demo/package.json
new file mode 100644
index 0000000..9a5ed2e
--- /dev/null
+++ b/tools/readme-demo/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "morphgate-readme-demo",
+ "private": true,
+ "description": "Draws the README's demo: MorphGate's Hosts page and a recorded session, as an animated SVG",
+ "type": "module",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@xterm/headless": "6.0.0"
+ }
+}
diff --git a/tools/readme-demo/snapshot.js b/tools/readme-demo/snapshot.js
new file mode 100644
index 0000000..5984842
--- /dev/null
+++ b/tools/readme-demo/snapshot.js
@@ -0,0 +1,164 @@
+// Runs in the app's page (demo.mjs passes it over WebDriver): what the window shows, as shapes to
+// draw: boxes (backgrounds, borders, inset underlines), text where the layout put it, and the
+// page's own SVG icons. `mask` swaps text (the test machine's user and address for demo ones).
+// The terminal's text isn't taken: the recording replays there (its geometry is returned).
+// eslint-disable-next-line no-unused-vars
+function snapshot(mask) {
+ const boxes = [];
+ const texts = [];
+ const icons = [];
+ let terminal = null;
+ const view = { x: 0, y: 0, w: innerWidth, h: innerHeight };
+ const clear = (c) => !c || c === "transparent" || /rgba\([^)]*,\s*0\)$/.test(c);
+ const swap = (t) => mask.reduce((s, [from, to]) => s.split(from).join(to), t);
+ const meet = (a, b) => {
+ const x = Math.max(a.x, b.x);
+ const y = Math.max(a.y, b.y);
+ return { x, y, w: Math.max(0, Math.min(a.x + a.w, b.x + b.w) - x), h: Math.max(0, Math.min(a.y + a.h, b.y + b.h) - y) };
+ };
+ const rectOf = (r) => ({ x: r.left, y: r.top, w: r.width, h: r.height });
+ const font = (cs) => ({
+ family: cs.fontFamily,
+ size: parseFloat(cs.fontSize),
+ weight: cs.fontWeight,
+ style: cs.fontStyle,
+ spacing: cs.letterSpacing === "normal" ? 0 : parseFloat(cs.letterSpacing),
+ color: cs.color,
+ opacity: 1,
+ });
+ const cased = (t, cs) => (cs.textTransform === "uppercase" ? t.toUpperCase() : cs.textTransform === "lowercase" ? t.toLowerCase() : t);
+
+ /** A line of text at `r`, clipped to `clip`; cut with an ellipsis where the element does.
+ * `color` overrides the element's (a placeholder's). */
+ function text(str, r, cs, clip, ellipsis, color) {
+ if (!str.trim() || r.width === 0) return;
+ let t = cased(swap(str), cs);
+ let w = r.width;
+ const right = clip.x + clip.w;
+ if (ellipsis && r.right > right + 1) {
+ const per = r.width / t.length;
+ const fit = Math.max(0, Math.floor((right - r.left) / per) - 1);
+ t = `${t.slice(0, fit).trimEnd()}…`;
+ w = per * t.length;
+ }
+ const f = font(cs);
+ texts.push({ text: t, x: r.left, y: r.top, w, h: r.height, clip, ...f, color: color ?? f.color });
+ }
+
+ /** The page's SVG with each shape's computed paint written on it (the app styles its marks by
+ * class), `currentColor` resolved. */
+ function paint(svg) {
+ const copy = svg.cloneNode(true);
+ const from = [svg, ...svg.querySelectorAll("*")];
+ const to = [copy, ...copy.querySelectorAll("*")];
+ from.forEach((el, i) => {
+ const cs = getComputedStyle(el);
+ for (const p of ["fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "fill-opacity", "stroke-opacity"]) {
+ const v = cs.getPropertyValue(p);
+ if (v) to[i].setAttribute(p, v);
+ }
+ to[i].removeAttribute("class");
+ to[i].removeAttribute("style");
+ });
+ return copy.outerHTML;
+ }
+
+ function walk(el, clip, opacity) {
+ const cs = getComputedStyle(el);
+ if (cs.display === "none" || cs.visibility === "hidden") return;
+ const op = opacity * parseFloat(cs.opacity);
+ if (op === 0) return;
+ const r = el.getBoundingClientRect();
+ const box = rectOf(r);
+ const inner = /(hidden|auto|scroll|clip)/.test(`${cs.overflowX} ${cs.overflowY}`) ? meet(clip, box) : clip;
+
+ if (el.classList?.contains("xterm-screen")) {
+ const rows = el.querySelector(".xterm-rows");
+ const rcs = getComputedStyle(rows ?? el);
+ terminal = { ...box, family: rcs.fontFamily, size: parseFloat(rcs.fontSize) };
+ return;
+ }
+ if (el instanceof SVGSVGElement) {
+ // Turned by CSS (a chevron pointing down), on it or the span around it: about its middle.
+ const turns = [el, el.parentElement].map((e) => getComputedStyle(e).transform).filter((t) => t && t !== "none");
+ if (box.w && box.h) icons.push({ svg: paint(el), ...box, color: cs.color, clip, opacity: op, turns });
+ return;
+ }
+ if (box.w > 0 && box.h > 0) {
+ const radius = parseFloat(cs.borderTopLeftRadius) || 0;
+ if (!clear(cs.backgroundColor)) boxes.push({ ...box, fill: cs.backgroundColor, radius, clip, opacity: op });
+ const sides = ["Top", "Right", "Bottom", "Left"].map((s) => ({
+ s,
+ w: cs[`border${s}Style`] === "none" ? 0 : parseFloat(cs[`border${s}Width`]),
+ c: cs[`border${s}Color`],
+ }));
+ const all = sides.every((x) => x.w === sides[0].w && x.c === sides[0].c);
+ if (all && sides[0].w > 0 && !clear(sides[0].c)) {
+ boxes.push({ ...box, stroke: sides[0].c, strokeWidth: sides[0].w, radius, clip, opacity: op });
+ } else {
+ for (const { s, w, c } of sides) {
+ if (!w || clear(c)) continue;
+ const at = { Top: [0, 0, box.w, w], Bottom: [0, box.h - w, box.w, w], Left: [0, 0, w, box.h], Right: [box.w - w, 0, w, box.h] }[s];
+ boxes.push({ x: box.x + at[0], y: box.y + at[1], w: at[2], h: at[3], fill: c, radius: 0, clip, opacity: op });
+ }
+ }
+ // An inset shadow without blur: a bar along one side (the active tab's underline).
+ for (const m of cs.boxShadow.matchAll(/(rgba?\([^)]*\))\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+0px(?:\s+0px)?\s+inset/g)) {
+ const [, c, dx, dy] = m;
+ const x = +dx;
+ const y = +dy;
+ if (y < 0) boxes.push({ x: box.x, y: box.y + box.h + y, w: box.w, h: -y, fill: c, radius: 0, clip, opacity: op });
+ if (y > 0) boxes.push({ x: box.x, y: box.y, w: box.w, h: y, fill: c, radius: 0, clip, opacity: op });
+ if (x > 0) boxes.push({ x: box.x, y: box.y, w: x, h: box.h, fill: c, radius: 0, clip, opacity: op });
+ if (x < 0) boxes.push({ x: box.x + box.w + x, y: box.y, w: -x, h: box.h, fill: c, radius: 0, clip, opacity: op });
+ }
+ }
+ // A field's value, or its placeholder.
+ if (el instanceof HTMLInputElement && el.type !== "checkbox" && el.type !== "radio") {
+ const value = el.value || el.placeholder;
+ if (value) {
+ const pad = parseFloat(cs.paddingLeft) + parseFloat(cs.borderLeftWidth);
+ const color = el.value ? cs.color : getComputedStyle(el, "::placeholder").color || cs.color;
+ // Its width as the page's font has it.
+ const ctx = (snapshot.canvas ??= document.createElement("canvas")).getContext("2d");
+ ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
+ const width = ctx.measureText(value).width;
+ text(value, { left: box.x + pad, top: box.y, width, height: box.h, right: box.x + pad + width }, cs, meet(inner, box), false, color);
+ }
+ return;
+ }
+ const ellipsis = cs.textOverflow === "ellipsis";
+ for (const n of el.childNodes) {
+ if (n.nodeType === Node.TEXT_NODE) {
+ if (!n.textContent.trim()) continue;
+ const range = document.createRange();
+ range.selectNodeContents(n);
+ const lines = [...range.getClientRects()];
+ if (lines.length <= 1) {
+ if (lines[0]) text(n.textContent.replace(/\s+/g, " ").trim(), lines[0], cs, inner, ellipsis);
+ continue;
+ }
+ // Wrapped: a word at a time, gathered into its lines.
+ const words = [...n.textContent.matchAll(/\S+/g)].map((w) => {
+ range.setStart(n, w.index);
+ range.setEnd(n, w.index + w[0].length);
+ return { t: w[0], r: range.getBoundingClientRect() };
+ });
+ const rows = [];
+ for (const w of words) {
+ const row = rows.find((x) => Math.abs(x.top - w.r.top) < 2);
+ if (row) {
+ row.t += ` ${w.t}`;
+ row.right = w.r.right;
+ } else rows.push({ t: w.t, top: w.r.top, left: w.r.left, right: w.r.right, height: w.r.height });
+ }
+ for (const row of rows) text(row.t, { left: row.left, top: row.top, width: row.right - row.left, height: row.height, right: row.right }, cs, inner, ellipsis);
+ } else if (n.nodeType === Node.ELEMENT_NODE) {
+ walk(n, inner, op);
+ }
+ }
+ }
+ walk(document.body, view, 1);
+ const bg = getComputedStyle(document.body).backgroundColor;
+ return { width: innerWidth, height: innerHeight, background: bg, boxes, texts, icons, terminal };
+}
diff --git a/tools/readme-demo/terminal.mjs b/tools/readme-demo/terminal.mjs
new file mode 100644
index 0000000..d215fe3
--- /dev/null
+++ b/tools/readme-demo/terminal.mjs
@@ -0,0 +1,164 @@
+// A session MorphGate recorded (asciicast v2), replayed by xterm.js (headless) and drawn as SVG:
+// each terminal row is a strip of the states it went through, and a CSS animation steps through
+// them, so the file stays small and plays wherever SVG does (GitHub's README too).
+
+import headless from "@xterm/headless";
+
+const { Terminal } = headless;
+
+// The Console theme (apps/desktop/src/themes.ts).
+export const THEME = {
+ background: "#0a1113",
+ foreground: "#dce6e3",
+ cursor: "#ffb547",
+ ansi: ["#0a1113", "#ff6b6b", "#3ddbb6", "#ffb547", "#6cb6ff", "#d59cff", "#4fd1e8", "#dce6e3", "#5e7072", "#ff9494", "#7bebd0", "#ffcc80", "#9dcfff", "#e6c2ff", "#8ae4f2", "#ffffff"],
+};
+
+// Pauses longer than this are cut short.
+const MAX_IDLE = 1.4;
+
+const rgb = (n) => `#${n.toString(16).padStart(6, "0")}`;
+
+/** xterm's 256 colors: the theme's 16, the 6×6×6 cube, 24 grays. */
+function color256(n) {
+ if (n < 16) return THEME.ansi[n];
+ if (n < 232) {
+ const c = n - 16;
+ const v = (x) => (x === 0 ? 0 : 55 + x * 40);
+ return rgb((v(Math.floor(c / 36)) << 16) | (v(Math.floor(c / 6) % 6) << 8) | v(c % 6));
+ }
+ const g = 8 + (n - 232) * 10;
+ return rgb((g << 16) | (g << 8) | g);
+}
+
+/** A cell's look, as a key: foreground, background, bold, italic, underline. */
+function styleOf(cell) {
+ let fg = null;
+ let bg = null;
+ if (cell.isFgPalette()) {
+ let n = cell.getFgColor();
+ // Bold in bright colors, as xterm.js does by default.
+ if (cell.isBold() && n < 8) n += 8;
+ fg = color256(n);
+ } else if (cell.isFgRGB()) fg = rgb(cell.getFgColor());
+ if (cell.isBgPalette()) bg = color256(cell.getBgColor());
+ else if (cell.isBgRGB()) bg = rgb(cell.getBgColor());
+ if (cell.isInverse()) [fg, bg] = [bg ?? THEME.background, fg ?? THEME.foreground];
+ if (cell.isDim() && fg) fg = `${fg}aa`;
+ return [fg ?? "", bg ?? "", cell.isBold() ? 1 : 0, cell.isItalic() ? 1 : 0, cell.isUnderline() ? 1 : 0].join("|");
+}
+
+/** Replays `castText`: the screens it went through (a frame wherever the output pauses), with
+ * long pauses cut short. `duration` is the last frame's time. */
+export async function replay(castText) {
+ const lines = castText.split("\n").filter((l) => l.trim());
+ const header = JSON.parse(lines[0]);
+ const events = lines.slice(1).map((l) => JSON.parse(l));
+ const term = new Terminal({ cols: header.width, rows: header.height, allowProposedApi: true, scrollback: 0 });
+ const write = (data) => new Promise((resolve) => term.write(data, resolve));
+ const snapshot = () => {
+ const b = term.buffer.active;
+ const rows = [];
+ for (let y = 0; y < term.rows; y++) {
+ const line = b.getLine(b.viewportY + y);
+ const runs = [];
+ let run = null;
+ for (let x = 0; line && x < term.cols; x++) {
+ const cell = line.getCell(x);
+ if (!cell || cell.getWidth() === 0) continue;
+ const style = styleOf(cell);
+ const ch = cell.getChars() || " ";
+ if (run && run.style === style) run.text += ch;
+ else runs.push((run = { x, style, text: ch }));
+ }
+ // Trailing blanks in the default look draw nothing.
+ while (runs.length) {
+ const last = runs[runs.length - 1];
+ if (last.style.split("|")[1] !== "") break;
+ last.text = last.text.replace(/ +$/, "");
+ if (last.text) break;
+ runs.pop();
+ }
+ rows.push(runs);
+ }
+ return { rows, cursor: [b.cursorX, b.cursorY] };
+ };
+ const frames = [];
+ let clock = 0;
+ let last = 0;
+ for (let i = 0; i < events.length; i++) {
+ const [t, kind, data] = events[i];
+ if (kind === "o") await write(data);
+ else if (kind === "r") term.resize(...data.split("x").map(Number));
+ const next = events[i + 1]?.[0];
+ if (next !== undefined && next - t < 0.02) continue;
+ clock += Math.min(t - last, MAX_IDLE);
+ last = t;
+ frames.push({ at: clock, ...snapshot() });
+ }
+ return { title: header.title || "session", cols: header.width, rows: header.height, frames, duration: clock };
+}
+
+const esc = (s) => s.replace(/&/g, "&").replace(//g, ">");
+
+/** A row's runs: backgrounds, then a word at a time on its cells (renderers collapse runs of
+ * spaces; `textLength` keeps any monospace font on the grid). */
+function drawRow(runs, cw, lh) {
+ let out = "";
+ for (const r of runs) {
+ const [fg, bg, bold, italic, underline] = r.style.split("|");
+ if (bg) out += ` `;
+ for (const word of r.text.matchAll(/\S+/g)) {
+ const attrs = [
+ `x="${((r.x + word.index) * cw).toFixed(1)}"`,
+ `y="${(lh / 2).toFixed(1)}"`,
+ fg && `fill="${fg}"`,
+ word[0].length > 1 && `textLength="${(word[0].length * cw).toFixed(1)}"`,
+ +bold && `font-weight="700"`,
+ +italic && `font-style="italic"`,
+ +underline && `text-decoration="underline"`,
+ ].filter(Boolean);
+ out += `${esc(word[0])} `;
+ }
+ }
+ return out;
+}
+
+/** The replay drawn on a grid of `cw` × `lh` cells: its SVG (rows from the top left, text
+ * vertically centered in its row) and the CSS that animates it. The session starts `start`
+ * seconds into an animation of `total` seconds; `id` keeps its class names apart. */
+export function draw(r, { cw, lh, start, total, id = "t" }) {
+ const pct = (t) => `${(((start + t) / total) * 100).toFixed(3)}%`;
+ const strip = r.cols * cw;
+ let css = "";
+ let svg = "";
+ for (let y = 0; y < r.rows; y++) {
+ const states = [];
+ const changes = [];
+ for (const f of r.frames) {
+ const key = JSON.stringify(f.rows[y]);
+ let i = states.findIndex((s) => s.key === key);
+ if (i < 0) i = states.push({ key, runs: f.rows[y] }) - 1;
+ if (changes.length === 0 || changes[changes.length - 1].i !== i) changes.push({ at: f.at, i });
+ }
+ if (states.every((s) => s.runs.length === 0)) continue;
+ const cells = states.map((s, i) => `${drawRow(s.runs, cw, lh)} `).join("");
+ // Until the session starts (and once it's over) the row shows its first state.
+ css += `.${id}${y}{animation:${id}${y} ${total.toFixed(2)}s steps(1,end) infinite}@keyframes ${id}${y}{0%{transform:translateX(0)}`;
+ for (const c of changes) css += `${pct(c.at)}{transform:translateX(${(-c.i * strip).toFixed(1)}px)}`;
+ css += "}";
+ svg += `${cells} `;
+ }
+ // The cursor: the theme's amber block, blinking, where each frame left it; hidden until the
+ // session starts.
+ css += `.${id}c{animation:${id}c ${total.toFixed(2)}s steps(1,end) infinite}@keyframes ${id}c{0%{opacity:0}`;
+ let before = "";
+ for (const f of r.frames) {
+ const at = `translate(${(f.cursor[0] * cw).toFixed(1)}px,${(f.cursor[1] * lh).toFixed(1)}px)`;
+ if (at !== before) css += `${pct(f.at)}{opacity:1;transform:${at}}`;
+ before = at;
+ }
+ css += `}.${id}b{animation:${id}b 1.1s steps(1,end) infinite}@keyframes ${id}b{50%{opacity:0}}`;
+ svg += ` `;
+ return { css, svg };
+}