diff --git a/.changeset/document-ssh-port-forwards.md b/.changeset/document-ssh-port-forwards.md new file mode 100644 index 000000000..4fdee620f --- /dev/null +++ b/.changeset/document-ssh-port-forwards.md @@ -0,0 +1,5 @@ +--- +"@internal/docs": minor +--- + +Document SSH port forwards: the kinds and the wide-forwards node switch in the SSH guide, the toggle on the node management page, and forwarded ports in the ports reference. diff --git a/.changeset/picker-rows-fit-their-field.md b/.changeset/picker-rows-fit-their-field.md new file mode 100644 index 000000000..dd96411ae --- /dev/null +++ b/.changeset/picker-rows-fit-their-field.md @@ -0,0 +1,5 @@ +--- +"@internal/server": patch +--- + +Launch pickers hold their field's width now: a long reason could stretch a dropdown past the dialog around it. A machine whose SSH is off reads "SSH not enabled" in gold on its row instead of a full sentence cut short, the split button drops the border that made it look different from its header neighbours, and unlaunchable machines keep their greyed rows with the gold or muted reason beside them. diff --git a/.changeset/sharing-permission-selectors.md b/.changeset/sharing-permission-selectors.md new file mode 100644 index 000000000..bb43a0eb0 --- /dev/null +++ b/.changeset/sharing-permission-selectors.md @@ -0,0 +1,5 @@ +--- +"@internal/server": patch +--- + +Use aligned permission dropdowns and a styled, searchable recipient picker in subshell and node sharing dialogs. Clear added recipients from the picker and require a valid selection before adding a grant. diff --git a/.changeset/ssh-forwarding-guide-screenshot.md b/.changeset/ssh-forwarding-guide-screenshot.md new file mode 100644 index 000000000..ddb2e9c86 --- /dev/null +++ b/.changeset/ssh-forwarding-guide-screenshot.md @@ -0,0 +1,7 @@ +--- +"@internal/docs": patch +--- + +Expand the SSH port-forwarding guide with connection steps, local and remote examples, access rules, troubleshooting, and a screenshot of the SSH panel. + +Refresh the sharing screenshot and instructions for permission dropdowns and the searchable recipient picker. diff --git a/.changeset/ssh-port-forwards.md b/.changeset/ssh-port-forwards.md new file mode 100644 index 000000000..2d574d6b0 --- /dev/null +++ b/.changeset/ssh-port-forwards.md @@ -0,0 +1,5 @@ +--- +"@internal/server": minor +--- + +SSH connections can carry port forwards you declare: local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) rows in the connection form and in SSH presets. Forwards bind the connecting node's loopback address unless its owner turns on **Allow wide port forwards** on the node's SSH card, and a remote forward's bind answers to the destination's own SSH server policy. A forward opens with the subshell and closes when it ends; the pane header's SSH tooltip lists the destination and the declared rows for the pane's whole life. Forwards that came from the connecting account's SSH configuration files are still refused by name; only rows declared at launch open a port. diff --git a/.changeset/website-ssh-sharing-screenshots.md b/.changeset/website-ssh-sharing-screenshots.md new file mode 100644 index 000000000..ef3545394 --- /dev/null +++ b/.changeset/website-ssh-sharing-screenshots.md @@ -0,0 +1,5 @@ +--- +"@internal/website": patch +--- + +Show SSH port forwarding on the homepage with a new connection-panel screenshot, and refresh the sharing screenshot with permission dropdowns and recipient search. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 95db9a510..881ed82b8 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -124,7 +124,7 @@ for the route inventory and trust limits. `/api/ssh/*` requires a browser sessio SSH terminal input additionally requires the pane owner and refuses all bearer actors. The node SSH switch is owner-only (admin for the built-in node), including implicit enablement on launch. It controls outgoing SSH and agent-key supply, -not incoming access. Relays require access to both nodes; the UI requires key selection, while the +not incoming access. Port forwards are declared per connection, never config-derived (that refusal stands): loopback binds by default, and a non-loopback bind needs the node's owner-only wide-forwards switch (`node.ssh_wide_forwards.update`); remote-forward binds answer to the destination's sshd policy. The subshell view carries a bounded summary (canonical destination, declared rows) for the pane header: disclosure-only, same class as the audit's. Relays require access to both nodes; the UI requires key selection, while the cookie API can select the current roster when selection is omitted. Private keys stay at the source. Bounded configuration scanning does not prevent all local execution during `ssh -G`; trust the connecting account’s SSH configuration. First-use destination confirmation cannot replace @@ -186,7 +186,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path, Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A ids and relay ref, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, relayRef on a relay launch}` (success only, written after the pane spawned; the canonical `host:port`, never config or key material; SSH preset CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_wide_forwards.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A ids and relay ref, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, relayRef on a relay launch, bounded forwards summary}` (success only, written after the pane spawned; the canonical `host:port` and the declared rows' ports and hosts, never config or key material; SSH preset CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Deliberate non-validation & accepted risks diff --git a/apps/docs/content/docs/guides/sharing.mdx b/apps/docs/content/docs/guides/sharing.mdx index 2b12f3c28..72ab741a6 100644 --- a/apps/docs/content/docs/guides/sharing.mdx +++ b/apps/docs/content/docs/guides/sharing.mdx @@ -12,12 +12,12 @@ Only the owner can change sharing. The recipient needs an account on the same in ## Grant access 1. In the server dashboard's sidebar, choose **Subshells**. Open the **⋯** menu on your session's card or row and choose **Share…**. -2. Choose named users or **Everyone**. -3. Select view or edit access. -4. Save the sharing settings. +2. In **Search people…**, search by email or name and choose a recipient, or choose **Everyone**. +3. Select **View** or **View + edit** from the access dropdown, then select **Add**. Repeat for other recipients. +4. Review the listed recipients and their access levels, then select **Save**.
- +
Select an access level for each recipient, then save the grants. Select the image to enlarge it.
diff --git a/apps/docs/content/docs/guides/ssh.mdx b/apps/docs/content/docs/guides/ssh.mdx index f26be3349..69654fe2b 100644 --- a/apps/docs/content/docs/guides/ssh.mdx +++ b/apps/docs/content/docs/guides/ssh.mdx @@ -1,6 +1,6 @@ --- title: "Connect over SSH" -description: "Open an SSH terminal through a node, choose keys or a password, verify unfamiliar destinations, and reuse connection choices as SSH presets." +description: "Open an SSH terminal through a node, choose keys or a password, verify unfamiliar destinations, forward ports through the connection, and reuse connection choices as SSH presets." --- Open an SSH terminal on another machine through one of your Subshell nodes. @@ -93,7 +93,7 @@ For these connections, Subshell stores the public host key and destination in th Connections using the connecting node's own keys or a password use that node's OpenSSH trust records instead. They automatically remember a previously unknown host key and reject a changed one. To verify a destination before that first connection, have the node's owner establish its trusted host key on the node first. -Once connected, the terminal shows the destination's shell. Its top bar has an **SSH connection** icon with a tooltip identifying it as an SSH connection. If you selected password authentication, wait for its password prompt before typing. Only the owner signed in to the dashboard can type in an SSH subshell; MCP and other bearer credentials cannot send input. Session [sharing](/guides/sharing) lets other users view its output. +Once connected, the terminal shows the destination's shell. Its top bar has an **SSH connection** icon whose tooltip shows the connecting node, destination, and any port forwards declared for that subshell. If you selected password authentication, wait for its password prompt before typing. Only the owner signed in to the dashboard can type in an SSH subshell; MCP and other bearer credentials cannot send input. Session [sharing](/guides/sharing) lets other users view its output. ## SSH presets and recent destinations @@ -116,6 +116,60 @@ Presets never store your private key, key file, passphrase, native approval, or If you select **Save as preset** while launching and the connection fails, the saved preset remains available. Retrying the same choices doesn't create another copy. Previously remembered destinations aren't converted into named presets; create presets for the connections you want to reuse. +## Port forwards + +Forward ports through an SSH connection to reach a web app, database, or other TCP service. Each listening port lasts for the SSH connection and closes when the subshell ends. + +### Add a port forward + +1. In the dashboard, open **Subshells** → **New subshell** → **SSH terminal**. Choose **Connect from**, enter the **SSH destination**, and choose how to authenticate. +2. Under **Port forwards**, select **Add port forward**. +3. Choose a kind from the table below. Enter **Local port** or **Remote port** and, for local or remote forwarding, **Target host** and **Target port**. +4. Select **Add port forward** again to add the completed row to the connection. Repeat for more ports, up to 16 forwards. Use **Remove** to discard a row; to change one, remove it and add its replacement. +5. Review the listed forwards, then select **Start SSH subshell**. Follow the authentication and destination-verification steps above. + +| Kind | Where the port opens | Where the target is reached from | +| --- | --- | --- | +| **Local (-L)** | The connecting node | The SSH destination. Use it for a web app or database reachable from that destination. | +| **Remote (-R)** | The SSH destination | The connecting node. Use it to make a service reachable from the destination. Its SSH server decides whether and how the listening port binds. | +| **Dynamic SOCKS (-D)** | The connecting node | The SSH destination. Configure a tool to use this SOCKS proxy; the tool chooses the target for each request. | + +“Local” means the node selected under **Connect from**, which may be a different machine from your browser. Choosing a key from your laptop doesn't move the listening port to your laptop. + +
+ +
Review the listed ports beneath the authentication options before connecting. Select the image to enlarge it.
+
+ +### Reach a web app on the destination + +Suppose a web app listens on `127.0.0.1:3000` on the SSH destination. Add a **Local (-L)** forward with **Local port** `8080`, **Target host** `127.0.0.1`, and **Target port** `3000`. After connecting, open `http://127.0.0.1:8080` in a browser running on the connecting node. Here, the target's `127.0.0.1` means the SSH destination. + +For the reverse direction, choose **Remote (-R)** with **Remote port** `8080` and the same target values. Programs on the SSH destination can then use its port `8080` to reach port `3000` on the connecting node. The target's `127.0.0.1` now means the connecting node. + +For **Dynamic SOCKS (-D)**, enter only a **Local port**, such as `1080`, then configure a SOCKS-capable tool on the connecting node to use `127.0.0.1:1080`. + +### Bind addresses and access + +Local and dynamic forwards bind `127.0.0.1` on the connecting node by default. Programs on that node, including those run by other OS users, can reach the port. Opening the dashboard elsewhere doesn't make that port available there. + +> [!warning] Allowing access from other machines +> A non-loopback bind can let other machines route traffic through your SSH connection to services it can reach. Only enable it when you intend to give those machines that access. + +The node's owner can open **Nodes**, select the connecting node, and turn on **Allow wide port forwards** in its **SSH** card while **Allow SSH** is enabled. An administrator manages this for the built-in node on the server host. See [Manage a node](/nodes/manage#outgoing-ssh). + +With wide forwards allowed, the form shows **Bind address**. Leaving it blank keeps the default; a value such as `0.0.0.0` requests all IPv4 interfaces. A non-loopback address requires this permission even for a remote forward, and the destination's SSH server still decides how a remote port binds. Turning the setting off blocks future wide-bind launches; existing forwards keep running until their connection ends. + +### Reuse and troubleshoot forwards + +SSH presets save the listed forwards with the other connection choices. You can add them in **Presets** → **New preset** → **New SSH preset**, or select **Save as preset** in the connection form. Applying a preset fills the rows for review before you connect. Editing the preset doesn't change an existing subshell. + +If a requested port is already taken, the destination's shell can still open while SSH prints a warning that the forward couldn't bind. Check the terminal and try the service through the listening port; a working shell alone doesn't prove the forward is available. Choose an unused port and start a new SSH subshell if needed. + +Forwards declared in the connecting node's SSH configuration through `LocalForward`, `RemoteForward`, or `DynamicForward` remain unsupported and cause connection resolution to be refused. Remove those settings from the configuration used by the connection and add the forwards explicitly in Subshell's form or preset instead. Agent forwarding, X11 forwarding, and tunnel devices remain unsupported. + +The **SSH connection** tooltip shows the declared forwards, not a live check of their availability. Users who can view the subshell can see this summary, and each launch records a forwarding summary in the audit trail. If a node is too old to support forwards, update the machine named in the error before trying again. + ## Make the destination a node As the SSH subshell's owner, open its **⋯** menu and choose **Set up Subshell here…** when available. Review the confirmation before running the installer: it installs Subshell on the destination and enrolls it as a node you own. Your SSH subshell keeps running. diff --git a/apps/docs/content/docs/nodes/manage.mdx b/apps/docs/content/docs/nodes/manage.mdx index cc9459a27..7f62a83ab 100644 --- a/apps/docs/content/docs/nodes/manage.mdx +++ b/apps/docs/content/docs/nodes/manage.mdx @@ -27,6 +27,8 @@ Only the node’s owner can change this setting. An administrator manages it for You can also enable it when starting an SSH subshell: the connection form explains the change before you submit. See [Connect over SSH](/guides/ssh). +With SSH allowed, the card gains **Allow wide port forwards**. While it's off, local and dynamic port forwards bind the node's own loopback address, so only programs on that node can use them. Remote forwards listen on the SSH destination under its SSH server's policy. See [Port forwards](/guides/ssh#port-forwards) for examples and bind-address rules. Turning it on lets a launch bind a port other machines can reach, which routes their traffic through that connection. It's off by default and sits behind the same owner-only gate as **Allow SSH**; turning it off affects new launches, while forwards already running keep going until their subshell closes. + ## Service and logs Where you have management access, review the service and log sections. A node may be online from a terminal process even when no service is installed. diff --git a/apps/docs/content/docs/reference/ports.mdx b/apps/docs/content/docs/reference/ports.mdx index f8b4662ff..23c2ada83 100644 --- a/apps/docs/content/docs/reference/ports.mdx +++ b/apps/docs/content/docs/reference/ports.mdx @@ -14,6 +14,7 @@ Identify the network listeners and connection paths used by Subshell. | Browser terminal stream | WebSocket through the control plane. | | HTTPS proxy or network publication | Port selected by that frontend, forwarding to the server. | | Node management dashboard | Loopback-only local listener; no login. Other local users can reach it. | +| SSH port forward | A port and address chosen at launch, bound on the connecting node (local and dynamic kinds) or by the destination's SSH server (remote kind). Subshell fixes none of them. | The server serves its API and production web interface on one port. Browsers don't need a route to every execution node. @@ -23,6 +24,8 @@ Allow the server port only on the networks intended to reach it. A wildcard bind Enrolled nodes connect outward. Avoid exposing their local management dashboard as a public endpoint. +SSH port forwards follow the same shape: a forward that binds loopback adds no listener other machines can reach. A wide bind (allowed only when the node's owner turns on **Allow wide port forwards**) does, so firewall choices on that node's network apply to it. See [Connect over SSH](/guides/ssh#port-forwards). + ## Development The server API defaults to 3080 and the web development server to 5174. The documentation development server uses 3400; the marketing site uses 3401. The isolated end-to-end stack uses 3199. diff --git a/apps/docs/public/screenshots/session-sharing.png b/apps/docs/public/screenshots/session-sharing.png index 219702a5f..6a0ce3907 100644 Binary files a/apps/docs/public/screenshots/session-sharing.png and b/apps/docs/public/screenshots/session-sharing.png differ diff --git a/apps/docs/public/screenshots/ssh-connection.png b/apps/docs/public/screenshots/ssh-connection.png new file mode 100644 index 000000000..5fede8b53 Binary files /dev/null and b/apps/docs/public/screenshots/ssh-connection.png differ diff --git a/apps/node/web/src/routes/__tests__/status-page.test.tsx b/apps/node/web/src/routes/__tests__/status-page.test.tsx index c820da5af..f99f07cc6 100644 --- a/apps/node/web/src/routes/__tests__/status-page.test.tsx +++ b/apps/node/web/src/routes/__tests__/status-page.test.tsx @@ -50,6 +50,7 @@ function localView(): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: true, + sshWideForwards: false, held: null, serverUrl: "http://127.0.0.1:3080", // No `runtime`: the page then skips the Runtime/Service cards, and this diff --git a/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts b/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts index 39d556af1..58d83f2d3 100644 --- a/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts +++ b/apps/server/api/src/__tests__/e2e-cross-subshell.test.ts @@ -38,13 +38,18 @@ const dbPath = join(dir, "subshell.db"); const backendLog = join(dir, "backend.log"); /** Env every child process needs: dev NODE_ENV (a test NODE_ENV would force - * the in-memory DB and ignore DATABASE_PATH — see constants.ts) + the file DB. */ + * the in-memory DB and ignore DATABASE_PATH — see constants.ts) + the file DB + * + a temp config home. The config home matters as much as the DB: boot takes + * `instance-state.lock` in it, and left on the real HOME the child dies + * ("Instance state is already in use") against any server the developer has + * running — this file would only pass on an idle machine. */ function childEnv(extra: Record = {}): Record { return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? tmpdir(), NODE_ENV: "development", DATABASE_PATH: dbPath, + SUBSHELL_SERVER_CONFIG_DIR: join(dir, "config"), ...extra, }; } diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts index 693032d8b..eee4fbd94 100644 --- a/apps/server/api/src/api/models.ts +++ b/apps/server/api/src/api/models.ts @@ -1,5 +1,32 @@ import { t } from "elysia"; +/** + * One declared port forward as the pane header shows it (spec 2026-10-15): + * the row exactly as launch approved it, with the nulls the wire carries. + * The renderer for words lives in the web app, not here. A separate + * declaration from `ssh-views.ts`'s input envelope on purpose: every schema + * here joins one exported `App` type, and the expressive input kind-census + * has already pushed the agent-or-ssh unions in that type past TypeScript's + * inference-depth edge once (measured the day `forwards` arrived; it is why + * `SshForwardRowInputSchema` is flat). The view copy stays constraint-light + * for the same reason. The protocol parser owns the grammar for all of them. + */ +export const SshForwardViewSchema = t.Object({ + kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], { + description: "-L local, -R remote, or -D dynamic SOCKS", + }), + listenPort: t.Number({ description: "Port the forward listens on (1-65535)" }), + bindHost: t.Nullable( + t.String({ + description: "Bind address; null = loopback on a local/dynamic row, the destination's policy on a remote one", + }), + ), + targetHost: t.Nullable( + t.String({ description: "Host reached through the connection; null on a dynamic row, which has no fixed target" }), + ), + targetPort: t.Nullable(t.Number({ description: "Target port; null on a dynamic row" })), +}); + /** * Shared TypeBox schemas for API responses (documented in OpenAPI). */ @@ -70,6 +97,21 @@ export const SubshellSchema = t.Object({ ssh: t.Boolean({ description: "True when this pane is an SSH-terminal pane: only its owner can type into it.", }), + sshSummary: t.Optional( + t.Object( + { + destination: t.String({ description: "Canonical user@host:port the pane connected to" }), + forwards: t.Nullable(t.Array(SshForwardViewSchema, { description: "A declared forward row" }), { + description: + "The port forwards declared at launch, in the order added; empty when none; null when the pane's stored rows can't be read, which the header must not render as 'none' (spec 2026-10-15)", + }), + }, + { + description: + "Bounded view of the SSH connection (canonical destination, declared forward rows) for the pane header. Same disclosure class as the ssh.launch audit summary; absent on a pane whose column can't be read", + }, + ), + ), nodeOffline: t.Boolean({ description: "True when the subshell's node is disconnected. The subshell may still be running there. Always false for subshells on the server host.", diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-ssh-wide-forwards.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-wide-forwards.route.test.ts new file mode 100644 index 000000000..3cdd54793 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/set-node-ssh-wide-forwards.route.test.ts @@ -0,0 +1,144 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * `PUT /api/nodes/:id/ssh-wide-forwards` (spec 2026-10-15 §6) — the per-node + * wide-bind allowance for SSH port forwards, opt-in and off by default, + * cloned from the SSH switch down to the gate: owner (`canManage`, admin on + * `local`), deliberately not the `edit` grantee's door. + * + * The route performs no write and records no audit itself — + * `setNodeSshWideForwards` is the one change-site — so this file asserts the + * ROUTE wired the acting human into that row. + */ +describe("node ssh-wide-forwards route", () => { + const pw = "ssh-wide-1"; + const ownerEmail = `wf-owner-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `wf-editor-${crypto.randomUUID()}@subshell.local`; + const otherEmail = `wf-other-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `wf-admin-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let editorId: string; + let ownerCookie: string; + let editorCookie: string; + let otherCookie: string; + let adminCookie: string; + const NODE = `n-wf-${crypto.randomUUID()}`; + + async function mkUser(email: string, role: "user" | "admin" = "user"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + + async function wfAudit(): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "metadataJson"]) + .where("targetId", "=", NODE) + .where("action", "=", "node.ssh_wide_forwards.update") + .execute(); + return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); + } + + function put(nodeId: string, cookie: string, on: boolean) { + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/ssh-wide-forwards`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + body: JSON.stringify({ on }), + }), + ); + } + + beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(ownerEmail); + editorId = await mkUser(editorEmail); + await mkUser(otherEmail); + await mkUser(adminEmail, "admin"); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ + id: NODE, + ownerUserId: ownerId, + name: NODE, + kind: "agent", + status: "offline", + createdAt: now, + updatedAt: now, + } as never) + .execute(); + await new NodeSharesRepository(db).replaceForNode(NODE, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + }); + + afterAll(async () => { + await db.deleteFrom("nodes").where("id", "=", NODE).execute(); + await db + .updateTable("nodes") + .set({ sshWideForwards: 0 } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + it("defaults off: a fresh node's view carries false", async () => { + const res = await put(NODE, ownerCookie, false); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(false); + }); + + it("the owner turns it on, and the answer is the node view carrying the flag", async () => { + const res = await put(NODE, ownerCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(true); + }); + + it("names the acting human and carries ONLY {on} in the audit row", async () => { + await put(NODE, ownerCookie, false); + const before = await wfAudit(); + + expect((await put(NODE, ownerCookie, true)).status).toBe(200); + const added = [...(await wfAudit())].filter(([id]) => !before.has(id)); + expect(added).toHaveLength(1); + expect(added[0][1].actorUserId).toBe(ownerId); + expect(added[0][1].metadataJson).toBe('{"on":true}'); + }); + + it("an edit grantee is refused — it is not their machine to widen", async () => { + expect((await put(NODE, editorCookie, true)).status).toBe(403); + }); + + it("a stranger gets 404, never 403 — an invisible node stays invisible", async () => { + expect((await put(NODE, otherCookie, true)).status).toBe(404); + }); + + it("answers 404 for an unknown node", async () => { + expect((await put(`n-missing-${crypto.randomUUID()}`, ownerCookie, true)).status).toBe(404); + }); + + it("an admin is refused on a node they do not own, and allowed on `local`", async () => { + expect((await put(NODE, adminCookie, true)).status).toBe(403); + expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403); + const res = await put(LOCAL_NODE_ID, adminCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(true); + }); +}); diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index a57663fbf..8d2d6dede 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -160,6 +160,10 @@ export const NodeViewSchema = t.Object({ description: "Whether this machine may be used for Subshell SSH (outbound ssh and serving its keys). Opt-in per node, off by default on every row including the control-plane host; the launch gate refuses SSH here while false, whoever asks", }), + sshWideForwards: t.Boolean({ + description: + "Whether SSH port forwards on this machine may bind non-loopback addresses. Off by default: forwards bind loopback only, and widening turns the machine into a router for whatever network reaches the bound port. The owner's decision, refused at launch while false", + }), held: t.Nullable( t.Object({ reason: t.Union([t.Literal("below-floor"), t.Literal("protocol-mismatch"), t.Literal("encryption-required")], { @@ -450,6 +454,7 @@ function nodeViewBase( maintenanceAt: row.maintenanceAt, maintenanceSource: row.maintenanceSource, sshEnabled: row.sshEnabled === 1, + sshWideForwards: row.sshWideForwards === 1, // Read from the LIVE registry rather than the row, for the same reason // `runtime` is: being held is a fact about a socket that exists right now, // and a column would go stale the moment the process ends. It is NOT diff --git a/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts index 3e23d5435..2422eb414 100644 --- a/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts +++ b/apps/server/api/src/api/nodes/set-node-ssh-enabled.route.ts @@ -7,7 +7,14 @@ import { db } from "@/db/index.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { apiErrorBody } from "@/lib/api-error.js"; import { apiModels } from "@/schema/index.js"; -import { setNodeSshEnabled } from "@/services/nodes/ssh-enabled.js"; +import { setNodeSshEnabled, setNodeSshWideForwards } from "@/services/nodes/ssh-enabled.js"; + +const SshWideForwardsBodySchema = t.Object({ + on: t.Boolean({ + description: + "Allow SSH port forwards on this machine to bind non-loopback addresses. Off by default: forwards bind loopback only. Owner only; admin on the control-plane host.", + }), +}); const SshEnabledBodySchema = t.Object({ on: t.Boolean({ @@ -79,4 +86,43 @@ export const setNodeSshEnabledRoute = new Elysia() "Allow or refuse Subshell SSH on a node (owner only; admin on the control-plane host). Off by default; enabling lets the plane dial out of and serve keys from this machine.", }, }, + ) + // The wide-bind allowance rides THIS instance rather than a module of its + // own for a measured reason: a second mounted route module of this shape + // tips the Elysia `App` inference into TS2589 (the depth ceiling already + // recorded for new route files; `auth-rate-limit.route.ts` is the fold + // precedent). The acts are one family anyway, owner-only SSH flags on the + // machine's row, each written at exactly one change-site. + .put( + "/:id/ssh-wide-forwards", + async ({ params, body, user, actor, status }) => { + requireCookieActor(actor, "Node SSH wide-forwards settings are restricted to browser sessions"); + const gate = await loadNodeGate(user.id, params.id); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) throw new ForbiddenError(); + + await setNodeSshWideForwards({ nodeId: gate.row.id, on: body.on, actorUserId: user.id }); + // The same re-read rule as the switch above: the view renders the row. + const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; + return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); + }, + { + params: t.Object({ id: t.String({ description: "Node id" }) }), + body: SshWideForwardsBodySchema, + response: { + 200: NodeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + detail: { + operationId: "setNodeSshWideForwards", + tags: ["nodes"], + description: + "Allow or refuse non-loopback binds for SSH port forwards on a node (owner only; admin on the control-plane host). Off by default: forwards bind loopback only, and enabling lets launches on this machine bind addresses other machines can reach. Turning it off refuses future wide binds; already-running forwards ride the connections approved when they started.", + }, + }, ); diff --git a/apps/server/api/src/api/ssh-presets.ts b/apps/server/api/src/api/ssh-presets.ts index 946f4e78c..f693f35ad 100644 --- a/apps/server/api/src/api/ssh-presets.ts +++ b/apps/server/api/src/api/ssh-presets.ts @@ -1,6 +1,11 @@ -import { SSH_FINGERPRINT_PATTERN, SSH_MAX_SELECTED_FINGERPRINTS } from "@internal/subshell-protocol"; +import type { SshForwardWire } from "@internal/subshell-protocol"; +import { + SSH_FINGERPRINT_PATTERN, + SSH_MAX_FORWARD_ROWS, + SSH_MAX_SELECTED_FINGERPRINTS, +} from "@internal/subshell-protocol"; import { t } from "elysia"; -import { SshExpectedDestinationField } from "@/api/ssh/ssh-views.js"; +import { SshExpectedDestinationField, SshForwardRowInputSchema } from "@/api/ssh/ssh-views.js"; import type { SshPresetTable } from "@/db/types/ssh-presets.db-types.js"; const SshPresetFields = { @@ -49,6 +54,15 @@ const SshPresetFields = { }), ), ), + forwards: t.Optional( + t.Nullable( + t.Array(SshForwardRowInputSchema, { + maxItems: SSH_MAX_FORWARD_ROWS, + description: + "Port forwards this preset carries to every launch; validated by the protocol grammar at save, and a non-loopback bind is still refused at launch until the connecting machine allows it. Omitted on update keeps the stored list; null or an empty list clears it", + }), + ), + ), }; export const CreateSshPresetSchema = t.Object( { @@ -77,6 +91,9 @@ export const SshPresetSchema = t.Object({ fingerprints: t.Array(t.String({ description: "Selected public key fingerprint" }), { description: "Remote key selection, empty for other authentication modes", }), + forwards: t.Array(SshForwardRowInputSchema, { + description: "Declared port forwards, empty when the preset stores none", + }), createdAt: t.String({ description: "Creation timestamp" }), updatedAt: t.String({ description: "Last update timestamp" }), }); @@ -86,5 +103,6 @@ export function sshPresetView(row: SshPresetTable) { ...row, kind: "ssh" as const, fingerprints: row.fingerprints === null ? [] : (JSON.parse(row.fingerprints) as string[]), + forwards: row.forwards === null ? [] : (JSON.parse(row.forwards) as SshForwardWire[]), }; } diff --git a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts index 7bfad2fd7..2cfe208f9 100644 --- a/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts +++ b/apps/server/api/src/api/ssh/__tests__/ssh-routes.test.ts @@ -543,6 +543,281 @@ describe("POST /api/ssh/launch", () => { }); }); +describe("POST /api/ssh/launch with port forwards (spec 2026-10-15)", () => { + const localRow = { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }; + const wideRow = { kind: "local", listenPort: 8080, bindHost: "10.0.0.5", targetHost: "api.internal", targetPort: 80 }; + + /** A node whose owner allowed wide binds; the flag is restored by the caller. */ + async function wideOn(nodeId: string): Promise { + await nodes.setSshWideForwards(nodeId, { on: true }); + } + async function wideOff(nodeId: string): Promise { + await nodes.setSshWideForwards(nodeId, { on: false }); + } + + function forwardingScript() { + return attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: ACCEPTED }), + launch: ok, + stat_dir: statDirEcho, + }); + } + + it("a malformed row list is a 400 before the machine is asked anything", async () => { + const scripted = silentScript(NODE_ON); + try { + // dynamic-with-target: a contradiction the grammar refuses whole. + const res = await launch( + { node: NODE_ON, destination: "work", forwards: [{ ...localRow, kind: "dynamic" }] }, + ownerCookie, + ); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.CONFIG_INVALID); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("a duplicate listen end is refused (two rows, one bind: a bug, not a configuration)", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await launch( + { node: NODE_ON, destination: "work", forwards: [localRow, { ...localRow, targetHost: "other.internal" }] }, + ownerCookie, + ); + expect(res.status).toBe(400); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("a wide bind is refused before any resolve while the machine's setting is off", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await launch({ node: NODE_ON, destination: "work", forwards: [wideRow] }, ownerCookie); + expect(res.status).toBe(403); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_FORWARD_BIND_DENIED); + expect(scripted.cmdTypes()).toEqual([]); + } finally { + scripted.detach(); + } + }); + + it("a loopback forward launches, renders into the exact-destination block, and reads in the audit", async () => { + const scripted = forwardingScript(); + const beforeIds = new Set((await auditRows()).map((r) => r.id)); + try { + const res = await launch({ node: NODE_ON, destination: "work", forwards: [localRow] }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + + const cmd = scripted.cmdsOf("launch")[0] as { + ssh?: { fileContent: string }; + argv?: string[]; + preset?: { flags?: string[] }; + }; + expect(cmd.ssh?.fileContent).toBe(renderSshConfigContents(ACCEPTED as never, undefined, [localRow] as never)); + expect(cmd.ssh?.fileContent).toContain(" LocalForward 127.0.0.1:5432 db.internal:5432"); + expect(cmd.ssh?.fileContent).not.toContain("ClearAllForwardings"); + // The forward rows must NOT ride the option argv (the policy file would + // clear them there; the config block is the one honest channel). + const flags = cmd.preset?.flags ?? []; + expect(flags.join(" ")).not.toMatch(/LocalForward|-L /); + + const added = (await auditRows()).filter((r) => !beforeIds.has(r.id)); + expect(added).toHaveLength(1); + expect(added[0].metadata).toEqual({ + nodeId: NODE_ON, + destination: CANON, + subshellId: subshell.id, + forwards: [{ kind: "local", listen: "127.0.0.1:5432", target: "db.internal:5432" }], + }); + } finally { + scripted.detach(); + } + }); + + it("a wide bind launches once the owner allowed it, and the row carries the written address", async () => { + const scripted = forwardingScript(); + await wideOn(NODE_ON); + try { + const res = await launch({ node: NODE_ON, destination: "work", forwards: [localRow, wideRow] }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + const cmd = scripted.cmdsOf("launch")[0] as { ssh?: { fileContent: string } }; + expect(cmd.ssh?.fileContent).toContain(" LocalForward 10.0.0.5:8080 api.internal:80"); + } finally { + await wideOff(NODE_ON); + scripted.detach(); + } + }); + + it("forwards on a connection whose jump names the destination itself are refused after resolve", async () => { + const snap = snapshot({ proxyJumps: [{ host: "EXAMPLE.test", user: null, port: 22 }] }); + const scripted = attachScriptedNode(NODE_ON, { + ssh_resolve_config: () => ({ accepted: true, snapshot: snap }), + launch: ok, + stat_dir: statDirEcho, + }); + try { + const res = await launch({ node: NODE_ON, destination: "work", forwards: [localRow] }, ownerCookie); + expect(res.status).toBe(400); + expect(((await res.json()) as { message: string }).message).toContain("jump hop is the destination itself"); + expect(scripted.countOf("launch")).toBe(0); + } finally { + scripted.detach(); + } + }); + + it("a dynamic row launches with no target, and the audit says so", async () => { + const scripted = forwardingScript(); + const beforeIds = new Set((await auditRows()).map((r) => r.id)); + const dynamic = { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null }; + try { + const res = await launch({ node: NODE_ON, destination: "work", forwards: [dynamic] }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + const cmd = scripted.cmdsOf("launch")[0] as { ssh?: { fileContent: string } }; + expect(cmd.ssh?.fileContent).toContain(" DynamicForward 127.0.0.1:1080"); + const added = (await auditRows()).filter((r) => !beforeIds.has(r.id)); + expect(added[0].metadata).toMatchObject({ + forwards: [{ kind: "dynamic", listen: "127.0.0.1:1080" }], + }); + } finally { + scripted.detach(); + } + }); + + it("a remote row with no bind host renders bare (D's policy spells it)", async () => { + const scripted = forwardingScript(); + const remote = { kind: "remote", listenPort: 9090, bindHost: null, targetHost: "dev.local", targetPort: 3000 }; + try { + const res = await launch({ node: NODE_ON, destination: "work", forwards: [remote] }, ownerCookie); + expect(res.status).toBe(201); + const { subshell } = (await res.json()) as { subshell: { id: string } }; + createdSubshellIds.push(subshell.id); + const cmd = scripted.cmdsOf("launch")[0] as { ssh?: { fileContent: string } }; + expect(cmd.ssh?.fileContent).toContain(" RemoteForward 9090 dev.local:3000"); + } finally { + scripted.detach(); + } + }); +}); + +describe("SSH preset forwards (spec 2026-10-15 §7)", () => { + const fwd = { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }; + const dyn = { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null }; + const presetBody = (name: string, extra: Record = {}) => ({ + kind: "ssh", + name, + nodeId: NODE_ON, + destination: "work", + authentication: "connecting", + fingerprints: [], + ...extra, + }); + + it("a saved list round-trips through the view and the row", async () => { + const scripted = silentScript(NODE_ON); + try { + const created = await sshFetch("/api/presets", { + method: "POST", + cookie: ownerCookie, + body: presetBody("Forward preset", { forwards: [fwd, dyn] }), + }); + expect(created.status).toBe(200); + const view = (await created.json()) as { id: string; forwards: unknown[] }; + expect(view.forwards).toEqual([fwd, dyn]); + const row = await db.selectFrom("sshPresets").select("forwards").where("id", "=", view.id).executeTakeFirst(); + expect(JSON.parse(String(row?.forwards))).toEqual([fwd, dyn]); + await sshFetch(`/api/presets/${view.id}`, { method: "DELETE", cookie: ownerCookie }); + } finally { + scripted.detach(); + } + }); + + it("omission keeps the stored list, null clears it, an empty list clears it", async () => { + const scripted = silentScript(NODE_ON); + try { + const created = await sshFetch("/api/presets", { + method: "POST", + cookie: ownerCookie, + body: presetBody("Forward keeps", { forwards: [fwd] }), + }); + const { id } = (await created.json()) as { id: string }; + const put = (extra: Record) => + sshFetch(`/api/presets/${id}`, { + method: "PUT", + cookie: ownerCookie, + body: { + name: "Forward keeps", + nodeId: NODE_ON, + destination: CANON, + authentication: "connecting", + fingerprints: [], + ...extra, + }, + }); + const kept = await put({}); + expect(kept.status).toBe(200); + expect(((await kept.json()) as { forwards: unknown[] }).forwards).toEqual([fwd]); + // The editor's real clear path: deleting every row sends []. An + // "empty keeps" refactor here would silently break that button. + const emptied = await put({ forwards: [] }); + expect(((await emptied.json()) as { forwards: unknown[] }).forwards).toEqual([]); + const cleared = await put({ forwards: null }); + expect(((await cleared.json()) as { forwards: unknown[] }).forwards).toEqual([]); + const row = await db.selectFrom("sshPresets").select("forwards").where("id", "=", id).executeTakeFirst(); + expect(row?.forwards).toBeNull(); + await sshFetch(`/api/presets/${id}`, { method: "DELETE", cookie: ownerCookie }); + } finally { + scripted.detach(); + } + }); + + it("a malformed list is refused at save, before anything writes", async () => { + const scripted = silentScript(NODE_ON); + try { + const res = await sshFetch("/api/presets", { + method: "POST", + cookie: ownerCookie, + body: presetBody("Bad forwards", { forwards: [{ kind: "tunnel", listenPort: 1, bindHost: null }] }), + }); + expect(res.status).toBe(400); + const rows = await db.selectFrom("sshPresets").select("id").where("name", "=", "Bad forwards").execute(); + expect(rows).toHaveLength(0); + } finally { + scripted.detach(); + } + }); + + it("a wide-bind row SAVES fine and refuses at launch until the machine allows it", async () => { + const scripted = silentScript(NODE_ON); + try { + const wide = { kind: "remote", listenPort: 9090, bindHost: "0.0.0.0", targetHost: "dev.local", targetPort: 3000 }; + const created = await sshFetch("/api/presets", { + method: "POST", + cookie: ownerCookie, + body: presetBody("Wide preset", { forwards: [wide] }), + }); + expect(created.status).toBe(200); + const { id } = (await created.json()) as { id: string }; + // The save stores the wish; the launch enforces the machine's answer. + const res = await launch({ node: NODE_ON, destination: "work", forwards: [wide] }, ownerCookie); + expect(res.status).toBe(403); + expect(((await res.json()) as { code: string }).code).toBe(BackendErrorCodes.SSH_FORWARD_BIND_DENIED); + await sshFetch(`/api/presets/${id}`, { method: "DELETE", cookie: ownerCookie }); + } finally { + scripted.detach(); + } + }); +}); + describe("/api/ssh/recent-destinations", () => { it("GET lists only the caller's recent destinations", async () => { // Owner A saves; owner B (a second human with their own node) must see diff --git a/apps/server/api/src/api/ssh/launch.route.ts b/apps/server/api/src/api/ssh/launch.route.ts index becb58df8..05bf8b348 100644 --- a/apps/server/api/src/api/ssh/launch.route.ts +++ b/apps/server/api/src/api/ssh/launch.route.ts @@ -1,10 +1,11 @@ -import { normalizeLabel, SSH_ROSTER_MAX_IDENTITIES } from "@internal/subshell-protocol"; +import { normalizeLabel, SSH_MAX_FORWARD_ROWS, SSH_ROSTER_MAX_IDENTITIES } from "@internal/subshell-protocol"; import { Elysia, t } from "elysia"; import { authGuard, requireCookieActor } from "@/api/auth-guard.js"; import { SubshellSchema } from "@/api/models.js"; import { SshDestinationField, SshExpectedDestinationField, + SshForwardRowSchema, SshResolveRefusalViewSchema, throwCodedRefusal, } from "@/api/ssh/ssh-views.js"; @@ -47,6 +48,9 @@ export const sshLaunchRoute = new Elysia() fingerprints: body.fingerprints, authentication: body.authentication, localKeyId: body.localKeyId, + // The protocol parser owns the detail (shape, kind rules, dupes); + // this handler only carries the envelope through. + forwards: body.forwards, sessionId: session?.id, subshells: ctx.services.subshells, }); @@ -81,6 +85,13 @@ export const sshLaunchRoute = new Elysia() "RELAY MODE: the key home machine whose agent signs for this connection; absent uses the connecting machine's own keys", }), ), + forwards: t.Optional( + t.Array(SshForwardRowSchema, { + maxItems: SSH_MAX_FORWARD_ROWS, + description: + "User-declared port forwards this connection carries (spec 2026-10-15). They live and die with the pane; a non-loopback bind host needs the machine's wide-forwards setting on", + }), + ), }), response: { 201: t.Object({ subshell: SubshellSchema }), diff --git a/apps/server/api/src/api/ssh/ssh-views.ts b/apps/server/api/src/api/ssh/ssh-views.ts index d02f7db2c..bb0b504ce 100644 --- a/apps/server/api/src/api/ssh/ssh-views.ts +++ b/apps/server/api/src/api/ssh/ssh-views.ts @@ -10,6 +10,75 @@ import type { SshRefusal } from "@/services/ssh-launch.service.js"; * drift. */ +/** + * One user-declared port-forward row's ENVELOPE (spec 2026-10-15), shared by + * the launch body and the preset save because they carry the same thing. + * Deliberately NOT the detailed grammar: `parseSshForwardRows` in the protocol + * package is the single authority (host forms, kind rules, duplicate listens), + * and the renderer re-runs that parser at the last station before bytes - a + * second grammar in a schema here would be two that drift. + */ +export const SshForwardRowSchema = t.Object({ + kind: t.Union([t.Literal("local"), t.Literal("remote"), t.Literal("dynamic")], { + description: + "local: bind a port on the connecting machine that reaches a host the destination can see; remote: ask the destination to bind a port that reaches a host the connecting machine can see; dynamic: a SOCKS proxy on the connecting machine", + }), + listenPort: t.Number({ + minimum: 1, + maximum: 65535, + description: "Port to bind (always explicit; no auto ports). Integer-ness belongs to the protocol parser", + }), + bindHost: t.Nullable( + t.String({ + minLength: 1, + maxLength: 253, + description: + "Address to bind (hostname, IPv4, or bracketed IPv6). Null means loopback for local/dynamic, and the destination's own policy for remote", + }), + ), + targetHost: t.Nullable( + t.String({ + minLength: 1, + maxLength: 253, + description: "Host to reach through the connection; required for local/remote, null for dynamic", + }), + ), + targetPort: t.Nullable( + t.Number({ minimum: 1, maximum: 65535, description: "Port on the target host; same kind rule" }), + ), +}); + +/** + * The same forward row as the preset routes carry it, deliberately FLATTER + * than {@link SshForwardRowSchema}: nested literal unions inside the + * agent-or-ssh body/response union push the `presets.route.ts` handlers past + * Elysia's inference depth (measured the day `forwards` arrived; the launch + * route, which never unions with agent bodies, keeps the expressive kind + * census). The protocol parser enforces the kind set either way, so the + * pattern here is documentation, not the enforcement. + */ +export const SshForwardRowInputSchema = t.Object({ + kind: t.String({ pattern: "^(local|remote|dynamic)$", description: "local (-L), remote (-R), or dynamic (-D)" }), + listenPort: t.Number({ minimum: 1, maximum: 65535, description: "Port to bind (always explicit; no auto ports)" }), + bindHost: t.Nullable( + t.String({ + minLength: 1, + maxLength: 253, + description: "Address to bind; null means loopback for local/dynamic, the destination's policy for remote", + }), + ), + targetHost: t.Nullable( + t.String({ + minLength: 1, + maxLength: 253, + description: "Host to reach through the connection; required for local/remote, null for dynamic", + }), + ), + targetPort: t.Nullable( + t.Number({ minimum: 1, maximum: 65535, description: "Port on the target host; same kind rule" }), + ), +}); + /** One ProxyJump hop as the frozen snapshot spells it. */ export const SshHopViewSchema = t.Object({ host: t.String({ description: "Hop hostname (bracketed IPv6 literal kept as given)" }), diff --git a/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts index edbd37d11..e79a130ef 100644 --- a/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/subshell-view-ssh.test.ts @@ -66,9 +66,25 @@ describe("ssh panes in subshell views: the non-owner access downgrade", () => { carolCookie = await signIn(carolEmail, pw); // The snapshot content is irrelevant to the rule (presence is the kind - // fact); this one is valid JSON so the fixture also reads plausibly. + // fact); this one is valid JSON so the fixture also reads plausibly, and + // it carries no destination facts, so it must read as NO summary either. await ownSubshell("s_ssh", JSON.stringify({ destination: "dev.example.com", node: "local" })); await ownSubshell("s_plain"); // ordinary pane: ssh NULL + // A pane launched with declared forwards: the header summary comes from + // exactly these stored bytes (spec 2026-10-15 §6). + await ownSubshell( + "s_fwd", + JSON.stringify({ + alias: "build", + host: "build.example", + user: "theo", + port: 22, + forwards: null, + declaredForwards: [ + { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }, + ], + }), + ); const shares = new SubshellSharesRepository(db); await shares.replaceForSubshell( "s_ssh", @@ -122,6 +138,21 @@ describe("ssh panes in subshell views: the non-owner access downgrade", () => { expect(got.ssh).toBe(false); }); + it("carries the bounded summary: destination and declared rows (owner read)", async () => { + const got = await get("/s_fwd", aliceCookie); + expect(got.ssh).toBe(true); + expect((got as Record).sshSummary).toEqual({ + destination: "theo@build.example:22", + forwards: [{ kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }], + }); + }); + + it("an ssh pane whose column can't be read has ssh true and no summary", async () => { + const got = await get("/s_ssh", aliceCookie); + expect(got.ssh).toBe(true); + expect((got as Record).sshSummary).toBeUndefined(); + }); + it("admins need a grant to see foreign SSH and ordinary panes", async () => { const cookie = await signIn(adminEmail, pw); expect((await req("GET", "/s_ssh", cookie)).status).toBe(404); diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 0192304eb..ac33cc75c 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047+0048+0049+0050+0051+0052 together: this era's six migrations are - // the tail of the registered ledger, so the prefix runs contiguously through - // 0046 and boot re-applies all six. Advancing LATEST_BACKUP_MIGRATION (0052) - // means the strip set moves with it in the same commit. + // WHY 0047..0056 together: this era's ten migrations are the tail of the + // registered ledger, so the prefix runs contiguously through 0046 and boot + // re-applies all ten. Advancing LATEST_BACKUP_MIGRATION (0056) means the + // strip set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards') ORDER BY name", ) .all(), ).toEqual([ @@ -442,6 +442,8 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0052-subshell-key-home" }, { name: "0053-drop-ssh-grants" }, { name: "0054-ssh-presets" }, + { name: "0055-node-ssh-wide-forwards" }, + { name: "0056-ssh-preset-forwards" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( @@ -464,6 +466,12 @@ test("real entry: available older upgrade snapshot restores and migrates before expect(migrated.query("SELECT name FROM sqlite_schema WHERE name='ssh_host_pins'").get()).toEqual({ name: "ssh_host_pins", }); + expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_wide_forwards'").get()).toEqual({ + name: "ssh_wide_forwards", + }); + expect(migrated.query("SELECT name FROM pragma_table_info('ssh_presets') WHERE name='forwards'").get()).toEqual({ + name: "forwards", + }); for (const removedTable of ["ssh_key_grants", "ssh_grant_requests"]) { expect(migrated.query("SELECT name FROM sqlite_schema WHERE name=?").get(removedTable)).toBeNull(); } diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 772fb930f..a5d4ad7e0 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -53,6 +53,8 @@ import * as sshGrantsMigration from "@/db/migrations/0050-ssh-grants.js"; import * as sshRequestDestinationMigration from "@/db/migrations/0051-ssh-request-destination.js"; import * as subshellKeyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import * as sshPresetsMigration from "@/db/migrations/0054-ssh-presets.js"; +import * as sshWideForwardsMigration from "@/db/migrations/0055-node-ssh-wide-forwards.js"; +import * as sshPresetForwardsMigration from "@/db/migrations/0056-ssh-preset-forwards.js"; import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; /** @@ -163,6 +165,8 @@ export async function runMigrations(): Promise { "0052-subshell-key-home": subshellKeyHomeMigration, "0053-drop-ssh-grants": dropSshGrantsMigration, "0054-ssh-presets": sshPresetsMigration, + "0055-node-ssh-wide-forwards": sshWideForwardsMigration, + "0056-ssh-preset-forwards": sshPresetForwardsMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0055-node-ssh-wide-forwards.ts b/apps/server/api/src/db/migrations/0055-node-ssh-wide-forwards.ts new file mode 100644 index 000000000..145c5bf4c --- /dev/null +++ b/apps/server/api/src/db/migrations/0055-node-ssh-wide-forwards.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +// Adds the per-node wide-bind allowance for SSH port forwards +// (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md §6). Default +// 0 keeps the whole fleet loopback-only after an upgrade: a forward that binds +// a non-loopback address turns the machine into a router for whatever network +// reaches that port, and only the machine's owner decides that is acceptable +// there. Same default-0 doctrine as the SSH capability gate (0047). +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("ssh_wide_forwards", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("ssh_wide_forwards").execute(); +} diff --git a/apps/server/api/src/db/migrations/0056-ssh-preset-forwards.ts b/apps/server/api/src/db/migrations/0056-ssh-preset-forwards.ts new file mode 100644 index 000000000..c80d36ab5 --- /dev/null +++ b/apps/server/api/src/db/migrations/0056-ssh-preset-forwards.ts @@ -0,0 +1,16 @@ +import type { Kysely } from "kysely"; + +// SSH presets carry their owner's declared port forwards (spec 2026-10-15 §7): +// a JSON array of rows the protocol grammar validated AT SAVE, or NULL for +// "no forwards" (the pre-column spelling and the clear-the-list spelling +// agree). The rows are connection choices, not credentials, so they follow +// the same storage rules as destination and authentication - and a wide-bind +// row remains a WIDE refusal at launch until the connecting machine allows +// it, exactly as it would for a typed list. +export async function up(db: Kysely): Promise { + await db.schema.alterTable("ssh_presets").addColumn("forwards", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("ssh_presets").dropColumn("forwards").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0055-node-ssh-wide-forwards.test.ts b/apps/server/api/src/db/migrations/__tests__/0055-node-ssh-wide-forwards.test.ts new file mode 100644 index 000000000..d4e41bf7c --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0055-node-ssh-wide-forwards.test.ts @@ -0,0 +1,72 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as sshEnabledMigration from "@/db/migrations/0047-node-ssh-enabled.js"; +import * as wideForwardsMigration from "@/db/migrations/0055-node-ssh-wide-forwards.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; +} + +/** + * The wide-bind allowance column (spec 2026-10-15 §6). Default 0 is the + * upgrade story: every machine, the control-plane host included, stays + * loopback-only until its owner widens it. + */ +describe("migration 0055-node-ssh-wide-forwards", () => { + let dbFile: string; + let db: Kysely; + + beforeAll(async () => { + dbFile = `/tmp/subshell-0055-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + await sshEnabledMigration.up(db); + // Written BEFORE the column exists - the upgrade case this test is about. + await db + .insertInto("nodes") + .values({ + id: "pre-existing", + owner_user_id: "u1", + name: "pre-existing", + kind: "agent", + created_at: "2026-10-15T00:00:00.000Z", + updated_at: "2026-10-15T00:00:00.000Z", + }) + .execute(); + await wideForwardsMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the column", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(cols.rows.map((c) => c.name)).toContain("ssh_wide_forwards"); + }); + + it("rows written before the migration read 0 (loopback stays the floor after an upgrade)", async () => { + const row = await db + .selectFrom("nodes") + .select("ssh_wide_forwards") + .where("id", "=", "pre-existing") + .executeTakeFirst(); + expect(row?.ssh_wide_forwards).toBe(0); + }); + + it("down removes the column", async () => { + await wideForwardsMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + expect(cols.rows.map((c) => c.name)).not.toContain("ssh_wide_forwards"); + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0056-ssh-preset-forwards.test.ts b/apps/server/api/src/db/migrations/__tests__/0056-ssh-preset-forwards.test.ts new file mode 100644 index 000000000..2b22eb6ed --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0056-ssh-preset-forwards.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as presetsMigration from "@/db/migrations/0054-ssh-presets.js"; +import * as presetForwardsMigration from "@/db/migrations/0056-ssh-preset-forwards.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +/** + * The preset forwards column (spec 2026-10-15 §7): nullable JSON, so every + * preset written before the migration simply has none, and NULL is the same + * reading as the explicit clear. + */ +test("SSH preset forwards column arrives nullable and leaves cleanly", async () => { + const db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(":memory:") }) }); + try { + await sql`CREATE TABLE user (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE nodes (id TEXT PRIMARY KEY)`.execute(db); + await sql`CREATE TABLE presets (id TEXT PRIMARY KEY, name TEXT)`.execute(db); + await sql`CREATE TABLE ssh_saved_hosts (id TEXT PRIMARY KEY, owner_user_id TEXT, destination TEXT, alias TEXT, node_id TEXT, saved_at TEXT, last_connect_at TEXT)`.execute( + db, + ); + await sql`INSERT INTO user VALUES ('u')`.execute(db); + await sql`INSERT INTO nodes VALUES ('n')`.execute(db); + await presetsMigration.up(db); + // A preset written BEFORE 0056, the upgrade case. + await sql`INSERT INTO ssh_presets VALUES ('p', 'u', 'Work', NULL, 'n', 'host:22', NULL, NULL, 'connecting', NULL, NULL, 'now', 'now')`.execute( + db, + ); + + await presetForwardsMigration.up(db); + const cols = await sql<{ + name: string; + notnull: number; + }>`SELECT name, "notnull" FROM pragma_table_info('ssh_presets')`.execute(db); + const fwd = cols.rows.find((c) => c.name === "forwards"); + expect(fwd).toBeDefined(); + expect(fwd?.notnull).toBe(0); + const row = await sql<{ forwards: string | null }>`SELECT forwards FROM ssh_presets WHERE id='p'`.execute(db); + expect(row.rows[0]?.forwards).toBeNull(); + + await presetForwardsMigration.down(db); + const after = await sql<{ name: string }>`SELECT name FROM pragma_table_info('ssh_presets')`.execute(db); + expect(after.rows.map((c) => c.name)).not.toContain("forwards"); + } finally { + await db.destroy(); + } +}); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index 3fd95ca53..566df61a6 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -60,6 +60,7 @@ export class NodesRepository extends BaseRepository { // and the gate is opt-in per machine (spec 4.3); mirror it the same way. sshEnabled: input.sshEnabled ?? 0, sshEnabledAt: input.sshEnabledAt ?? null, + sshWideForwards: 0, createdAt: input.createdAt ?? now, updatedAt: now, }) @@ -275,6 +276,30 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Write the SSH wide-forwards allowance (spec 2026-10-15 §6). No stamp + * column: unlike {@link setSshEnabled} there is no node-side mirror to + * reconcile - the flag is read plane-side at launch, when the rendered + * config is composed, and nothing travels to the machine. + */ + async setSshWideForwards( + id: string, + state: { + /** True = forwards on this machine may bind non-loopback addresses. */ + on: boolean; + }, + ): Promise { + return await this.db + .updateTable("nodes") + .set({ + sshWideForwards: state.on ? 1 : 0, + updatedAt: new Date().toISOString(), + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + /** * The unfinished subshells on one node, as FULL rows. * diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 74400a0a8..c45aa5f03 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -101,6 +101,14 @@ export interface NodeTable { * maintenance. */ sshEnabledAt: string | null; + /** + * 1 = this machine's owner allows SSH port forwards to bind NON-loopback + * addresses (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md + * §6). Default 0: forwards bind loopback only. A wide bind turns the machine + * into a router for whatever network reaches that port, so the decision is + * per machine and belongs to the owner, never to a connection's author. + */ + sshWideForwards: number; /** * JSON mirror of the ssh-fingerprint block from this node's last runtime * report (spec 2026-10-08 §4.5), so the trust card survives an offline node diff --git a/apps/server/api/src/db/types/ssh-presets.db-types.ts b/apps/server/api/src/db/types/ssh-presets.db-types.ts index bbaf43cae..41bd45bd7 100644 --- a/apps/server/api/src/db/types/ssh-presets.db-types.ts +++ b/apps/server/api/src/db/types/ssh-presets.db-types.ts @@ -33,6 +33,14 @@ export interface SshPresetTable { keyHome: string | null; /** JSON-encoded array of selected `SHA256:` public-key fingerprints (remote only); null otherwise */ fingerprints: string | null; + /** + * JSON-encoded array of user-declared port-forward rows (spec 2026-10-15 + * §7), validated by the protocol grammar at save; null means no forwards. + * Connection choices, not credentials - the same class as destination and + * authentication, and a wide-bind row stored here is still refused at + * launch until the connecting machine allows it. + */ + forwards: string | null; /** ISO 8601 creation timestamp (DB default) */ createdAt: string; /** ISO 8601 last-update timestamp */ diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index 57085432e..37767ee00 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -81,9 +81,15 @@ export interface SubshellTable { /** * an SSH-terminal pane carries its approved snapshot here; the value's * presence is the owner-only-input rule's trigger (spec §5.4). JSON-encoded - * `SshConnectionSnapshotWire` plus optional `relayFingerprints: string[]` - * recording the exact selected keys for relay panes. Never serialized to - * clients, the launch answer included. See migration 0048. + * `SshConnectionSnapshotWire` plus optional members the launch records + * beside it: `localKey`, `relayFingerprints: string[]` (the exact keys a + * relay used, never widened later), and `declaredForwards` (the rows the + * person declared, spec 2026-10-15 - its own key, because the snapshot's + * `forwards: null` is the config-derived refusal marker). The snapshot + * itself is never serialized to clients; what the view layer derives FROM + * this column is the bounded `sshSummary` (canonical destination + declared + * rows, `sshPaneSummary`), the same disclosure class as the ssh.launch + * audit row. See migration 0048. */ ssh: string | null; /** diff --git a/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts b/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts new file mode 100644 index 000000000..308912dc8 --- /dev/null +++ b/apps/server/api/src/services/__tests__/ssh-pane-summary.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { sshPaneSummary } from "@/services/subshell-manager.service.js"; + +/** + * The bounded ssh view the pane header reads (spec 2026-10-15 §6): what the + * column holds, nothing more, and never a throw over unreadable bytes. + */ +const column = { + alias: "build", + host: "build.example", + user: "theo", + port: 22, + identityFiles: [], + certificateFiles: [], + authAgentSocket: null, + knownHostsFiles: [], + hostKeyAlias: null, + proxyJumps: [], + proxyCommand: null, + // The config-derived refusal marker stays null; declared rows are the + // sibling key, and the summary reads only that one. + forwards: null, + tunnels: null, +}; + +describe("sshPaneSummary", () => { + it("names the canonical destination and lists the declared rows", () => { + const summary = sshPaneSummary( + JSON.stringify({ + ...column, + declaredForwards: [ + { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }, + { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null }, + ], + }), + ); + expect(summary?.destination).toBe("theo@build.example:22"); + expect(summary?.forwards).toEqual([ + { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }, + { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null }, + ]); + }); + + it("omits the user when the connection used the account default", () => { + expect(sshPaneSummary(JSON.stringify({ ...column, user: null }))?.destination).toBe("build.example:22"); + }); + + it("an ssh pane that declared nothing has an empty list, not a missing summary", () => { + const summary = sshPaneSummary(JSON.stringify(column)); + expect(summary?.destination).toBe("theo@build.example:22"); + expect(summary?.forwards).toEqual([]); + }); + + it("yields nothing for bytes a list must not trip over", () => { + expect(sshPaneSummary(null)).toBeNull(); + expect(sshPaneSummary("{oops")).toBeNull(); + expect(sshPaneSummary("[1,2]")).toBeNull(); + // A column without the destination facts (a fixture, a future shape) shows + // no half-built line. + expect(sshPaneSummary(JSON.stringify({ destination: "dev.example.com" }))).toBeNull(); + }); + + it("keeps the destination when a stored row list fails its grammar", () => { + // A header is not where a stored-row bug gets surfaced: the destination + // still earned its place, and the broken list is WITHHELD as null - never + // folded into [], because [] is the claim that no ports were opened. + const summary = sshPaneSummary(JSON.stringify({ ...column, declaredForwards: [{ kind: "sideways" }] })); + expect(summary?.destination).toBe("theo@build.example:22"); + expect(summary?.forwards).toBeNull(); + }); +}); diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index dc9229979..b96f2e69a 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0054-ssh-presets"; +export const LATEST_BACKUP_MIGRATION = "0056-ssh-preset-forwards"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index a8cb3413a..e650f61e8 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -174,6 +174,7 @@ function nodeRow(over: Partial = {}): NodeTable { maintenanceAt: null, maintenanceSource: null, sshEnabled: 0, + sshWideForwards: 0, sshEnabledAt: null, // The 0049 mirror slot: NULL until the runtime-report task fills it. sshFingerprint: null, diff --git a/apps/server/api/src/services/nodes/ssh-enabled.ts b/apps/server/api/src/services/nodes/ssh-enabled.ts index d461513a3..69afbca9e 100644 --- a/apps/server/api/src/services/nodes/ssh-enabled.ts +++ b/apps/server/api/src/services/nodes/ssh-enabled.ts @@ -48,6 +48,32 @@ export async function setNodeSshEnabled(write: SshEnabledWrite): Promise { pushSetSshEnabledBestEffort(write.nodeId, { on: write.on, changedAt: write.changedAt }); } +/** + * Flip one node's wide-bind allowance for SSH port forwards (spec 2026-10-15 + * §6): row and audit in one function, same one-change-site posture as + * {@link setNodeSshEnabled}. The differences are all in what does NOT happen: + * no stamp, no push, no mirror, and no relay cut - the flag is read + * plane-side when a launch composes its config, so turning it off refuses + * every FUTURE wide bind while already-running forwards ride the connection + * that was approved when they started (the same honesty the SSH switch + * carries about already-authenticated sessions). + */ +export async function setNodeSshWideForwards(write: { + nodeId: string; + on: boolean; + actorUserId: string; +}): Promise { + const { repos } = getRequestlessContext(); + await repos.nodes.setSshWideForwards(write.nodeId, { on: write.on }); + await audit({ + actorUserId: write.actorUserId, + action: "node.ssh_wide_forwards.update", + targetType: "node", + targetId: write.nodeId, + metadataJson: JSON.stringify({ on: write.on }), + }); +} + /** The node-row fields {@link decideSshEnabled} reads — nothing else is relevant. */ type SshRow = Pick; diff --git a/apps/server/api/src/services/ssh-launch.service.ts b/apps/server/api/src/services/ssh-launch.service.ts index 731435195..d06914835 100644 --- a/apps/server/api/src/services/ssh-launch.service.ts +++ b/apps/server/api/src/services/ssh-launch.service.ts @@ -14,13 +14,17 @@ import { sshOptionTokens, } from "@internal/pane-runtime"; import { + isLoopbackForwardBind, type NodeSshAgentIdentity, type NodeSshAliasListResult, type NodeSshResolveOutcomeWire, parseNodeSshAliasList, parseSshConnectionSnapshot, + parseSshForwardRows, + SSH_FORWARD_LIST_REFUSAL, SSH_NAME_MAX_CHARS, type SshConnectionSnapshotWire, + type SshForwardWire, } from "@internal/subshell-protocol"; import { loadNodeGate } from "@/api/nodes/node-gate.js"; import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; @@ -142,6 +146,15 @@ export function composeSshLaunch(args: { */ hostPinPath?: string; authentication?: "password"; + /** + * User-declared port-forward rows (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md). + * The launch route has already gate-checked them (wide binds against the + * machine's setting); the renderer re-validates the SHAPE here as the last + * station before the bytes, same impossible-state posture as the snapshot + * line below - a refusal that dropped rows silently would tell the user a + * forward exists when nothing binds it. + */ + forwards?: SshForwardWire[]; }): { configPath: string; fileContent: string; @@ -154,6 +167,7 @@ export function composeSshLaunch(args: { let fileContent = renderSshConfigContents( approved, args.hostPinPath === undefined ? undefined : { hostPinPath: args.hostPinPath }, + args.forwards, ); if (args.authentication === "password") { if (args.hostPinPath) throw new Error("Password authentication cannot use a key relay."); @@ -399,6 +413,13 @@ export async function sshLaunch(args: { authentication?: "password"; /** One-use preparation owned by this exact cookie session. */ localKeyId?: string; + /** + * User-declared port forwards (spec 2026-10-15). `unknown` ON PURPOSE: the + * route's t-schema is an envelope, and `parseSshForwardRows` here is the + * authority - the same parser the renderer re-runs at the last station, so + * what composes can never be wider than what the grammar can express. + */ + forwards?: unknown; sessionId?: string; /** The REQUEST-scoped subshells service (the create path is its own). */ subshells: Pick; @@ -420,6 +441,22 @@ export async function sshLaunch(args: { if (!isWireSafeSshDestination(args.destination)) { return codedRefusal(400, BackendErrorCodes.ALIAS_UNSAFE, UNSAFE_DESTINATION_COPY); } + // Port forwards (spec 2026-10-15): parse first (the grammar owns shape), + // then the machine's wide-bind gate (the owner's setting owns policy). Both + // BEFORE any resolve, so a refused list never dials a machine. + let forwards: SshForwardWire[] = []; + if (args.forwards !== undefined) { + const parsed = parseSshForwardRows(args.forwards); + if (parsed === null) return codedRefusal(400, BackendErrorCodes.CONFIG_INVALID, SSH_FORWARD_LIST_REFUSAL); + forwards = parsed; + } + if (forwards.length > 0 && forwards.some((f) => !isLoopbackForwardBind(f)) && row.sshWideForwards !== 1) { + return codedRefusal( + 403, + BackendErrorCodes.SSH_FORWARD_BIND_DENIED, + "This machine only allows port forwards that bind its own loopback address. Change the bind address, or its owner can allow wide forwards in the machine's SSH settings.", + ); + } if (args.fingerprints !== undefined && args.keyHomeNodeId === undefined) { return codedRefusal( 400, @@ -443,6 +480,16 @@ export async function sshLaunch(args: { const snapshot = resolved.value.snapshot; // parseNodeSshResolveOutcome already narrowed + rebuilt it const destinationProblem = sshExpectedDestinationProblem(snapshot, args.expectedDestination); if (destinationProblem) return codedRefusal(400, BackendErrorCodes.CONFIG_INVALID, destinationProblem); + if (forwards.length > 0 && snapshot.proxyJumps.some((h) => h.host.toLowerCase() === snapshot.host.toLowerCase())) { + // The forward block renders under the exact destination host; a hop named + // the same would match the child too and hand it a forward (the renderer + // throws on this as the guard beneath; the human hears it here first). + return codedRefusal( + 400, + BackendErrorCodes.CONFIG_INVALID, + "Port forwards can't ride a connection whose jump hop is the destination itself. Remove the jump or the forwards.", + ); + } if (args.keyHomeNodeId !== undefined) { const refusal = relaySnapshotRefusal(snapshot); if (refusal) return refusal; @@ -499,6 +546,10 @@ export async function sshLaunch(args: { // signed relay-open just wrote beside it; a direct launch renders the M1 // accept-new bytes untouched. ...(relay || localKey ? { hostPinPath: buildSshKnownHostsPath(dataDir, subshellId) } : {}), + // Approved rows (spec 2026-10-15): the compose re-validates and renders + // them into the exact-destination block; rowless launches keep today's + // bytes, ClearAllForwardings included. + ...(forwards.length > 0 ? { forwards } : {}), }); // Relay mode RE-POINTS the scoped exception (spec §5.2): the socket the // pane's ssh authenticates through is B's proxy the broker just verified, @@ -533,6 +584,11 @@ export async function sshLaunch(args: { configPath: composed.configPath, fileContent: composed.fileContent, snapshot, + // The declared rows ride the pane's column beside the snapshot (spec + // 2026-10-15 §6): the launch bytes already carry them, and the pane + // header states them for the pane's whole life. They were parsed once + // above, so what is recorded is exactly what was approved. + ...(forwards.length > 0 ? { forwards } : {}), ...(localKey ? { localKey: true as const } : {}), // Preserve the actual selected keys, including the complete roster // resolved for callers that omitted selection. Later acts cannot widen it. @@ -570,11 +626,32 @@ export async function sshLaunch(args: { targetType: "node", targetId: row.id, // Only session ids and destination metadata enter the success audit. - metadataJson: JSON.stringify( - relay - ? { nodeId: row.id, destination, subshellId: created.id, relayRef: relay.ref } - : { nodeId: row.id, destination, subshellId: created.id }, - ), + // The forward summary (spec 2026-10-15 §8) is the same class of fact: + // canonical names and ports the user approved, no config text and no key + // material, bounded by the row cap. + metadataJson: JSON.stringify({ + nodeId: row.id, + destination, + subshellId: created.id, + ...(relay ? { relayRef: relay.ref } : {}), + ...(forwards.length > 0 + ? { + forwards: forwards.map((f) => ({ + kind: f.kind, + // What the user wrote; "127.0.0.1:port" when they wrote loopback + // implicitly, and a bare port for a remote row, whose bind is + // the destination's policy to spell. + listen: + f.bindHost === null + ? f.kind === "remote" + ? String(f.listenPort) + : `127.0.0.1:${f.listenPort}` + : `${f.bindHost}:${f.listenPort}`, + ...(f.kind === "dynamic" ? {} : { target: `${String(f.targetHost)}:${String(f.targetPort)}` }), + })), + } + : {}), + }), }); return ok({ subshellId: created.id }); } diff --git a/apps/server/api/src/services/ssh-presets.service.ts b/apps/server/api/src/services/ssh-presets.service.ts index 2de0f2db9..babee6248 100644 --- a/apps/server/api/src/services/ssh-presets.service.ts +++ b/apps/server/api/src/services/ssh-presets.service.ts @@ -1,3 +1,4 @@ +import { parseSshForwardRows, SSH_FORWARD_LIST_REFUSAL } from "@internal/subshell-protocol"; import type { Static } from "elysia"; import { loadNodeGate } from "@/api/nodes/node-gate.js"; import { throwCodedRefusal } from "@/api/ssh/ssh-views.js"; @@ -49,6 +50,17 @@ export async function saveSshPreset( throw new SshPresetError("bad_request", "Choose a different node to provide the SSH key"); await validateNodeAccess(userId, body.keyHome); } + // Declared port forwards (spec 2026-10-15 §7): saved through the protocol's + // own parser, so no row the launch renderer would refuse can ride a stored + // preset. Whether a WIDE bind is allowed is a connect-time decision, not a + // save-time one: a preset stores a wish, and the launch route checks the + // connecting machine's flag every time it is honored. + let forwardsJson: string | null = null; + if (body.forwards !== undefined && body.forwards !== null) { + const parsed = parseSshForwardRows(body.forwards); + if (parsed === null) throw new SshPresetError("bad_request", SSH_FORWARD_LIST_REFUSAL); + if (parsed.length > 0) forwardsJson = JSON.stringify(parsed); + } let destination = body.destination; // PUT omits a field it is not deciding about, so an absent alias keeps the // stored one (null clears it), the same rule the agent PUT body follows. @@ -105,6 +117,9 @@ export async function saveSshPreset( keyHome: body.authentication === "remote" ? (body.keyHome ?? null) : null, fingerprints: body.authentication === "remote" && body.fingerprints != null ? JSON.stringify(body.fingerprints) : null, + // PUT convention, same as `description`: omitted keeps what is stored, + // null (or an emptied list) clears it. + forwards: body.forwards === undefined ? (existing?.forwards ?? null) : forwardsJson, createdAt: existing?.createdAt ?? now, updatedAt: now, }; diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index b5080b9e3..d6daff0d8 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -17,7 +17,9 @@ import { type NodeProbeEntry, normalizeLabel, parseNodeProbeEntries, + parseSshForwardRows, type SshConnectionSnapshotWire, + type SshForwardWire, } from "@internal/subshell-protocol"; import { harnessUsable } from "@/api/harness-utils.js"; import type { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -59,6 +61,7 @@ import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService, type NotifyKind } from "@/services/notify.service.js"; import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; +import { sshCanonicalDestination } from "@/services/ssh-destination.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; import { issueSubshellToken, revokeSubshellToken } from "@/services/subshell-tokens.js"; @@ -146,6 +149,12 @@ export interface SshLaunchPlumbing { fileContent: string; /** The snapshot the resolver approved (grammar-validated at each boundary already). */ snapshot: SshConnectionSnapshotWire; + /** + * User-declared port forwards (spec 2026-10-15), already parsed. Stored on + * the pane so its header can list what the connection carries; absent when + * the launch declared none, so the column of a plain pane is unchanged. + */ + forwards?: readonly SshForwardWire[]; /** * RELAY MODE (spec 2026-10-08 §7): the key home whose agent signed the * connection, stored on the row (migration 0052) so "Set up Subshell here" @@ -506,6 +515,11 @@ export class SubshellManagerService { ssh: ssh ? JSON.stringify({ ...ssh.snapshot, + // Its own key, never `forwards`: the spread snapshot carries + // `forwards: null` as the config-derived refusal marker, and + // every grammar that re-reads this column expects that null. + // Declared rows are a different fact and say so in their name. + ...(ssh.forwards !== undefined && ssh.forwards.length > 0 ? { declaredForwards: ssh.forwards } : {}), ...(ssh.localKey ? { localKey: true } : {}), ...(ssh.relayFingerprints !== undefined ? { relayFingerprints: ssh.relayFingerprints } : {}), }) @@ -2235,7 +2249,7 @@ export function toSubshellView( waitingSince: string | null; lastPushUrgency: number | null; crossAgent: number; - /** The ssh snapshot column (migration 0048); read for presence only, never parsed here. */ + /** The ssh snapshot column (migration 0048); parsed for the bounded summary only (see {@link sshPaneSummary}). */ ssh: string | null; }, status: string, @@ -2256,6 +2270,7 @@ export function toSubshellView( */ nodeOffline = false, ) { + const sshSummary = row.ssh !== null ? sshPaneSummary(row.ssh) : null; return { id: row.id, presetId: row.presetId, @@ -2296,10 +2311,44 @@ export function toSubshellView( unseenPush: row.lastPushUrgency !== null, access, // The pane's KIND fact: the ssh snapshot column's PRESENCE (migration - // 0048). The snapshot itself stays never-serialized (its db-types doc). + // 0048). The snapshot itself stays never-serialized (its db-types doc); + // what rides along below is the bounded summary, not the snapshot. ssh: row.ssh !== null, + ...(sshSummary !== null ? { sshSummary } : {}), // Agent node unreachable right now (see the param doc) — the UI's // "node offline" chip; false for every local subshell. nodeOffline, }; } + +/** + * The bounded view of an ssh pane's column: the canonical destination and the + * forward rows declared at launch (spec 2026-10-15 §6), which is the same + * disclosure the `ssh.launch` audit row already carries. Best-effort by + * construction: a column written before a field existed yields what it has, + * and a column that cannot be read yields nothing. A list must never throw + * over one row's bytes. Exported for direct test (`ssh-pane-summary.test.ts`). + */ +export function sshPaneSummary(raw: string | null): { destination: string; forwards: SshForwardWire[] | null } | null { + if (raw === null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const column = parsed as Record; + if (typeof column.host !== "string" || column.host === "" || typeof column.port !== "number") return null; + if (!(column.user === null || typeof column.user === "string")) return null; + // The same composer the audit row and the recent-destinations key use, so + // the header's "canonical" is one spelling, not a second one that drifts. + const destination = sshCanonicalDestination({ host: column.host, port: column.port, user: column.user }); + // A row list that fails its grammar is WITHHELD (null), not folded into the + // empty list: an empty list is the claim "this pane opened no ports", and a + // stored row a tightened rule would now refuse may well have opened them. + // Failed reads stay distinct from empty ones; the destination still earned + // its place, and a header is not where a stored-row bug gets surfaced. + const forwards = column.declaredForwards === undefined ? [] : parseSshForwardRows(column.declaredForwards); + return { destination, forwards }; +} diff --git a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx index 65c4b1c9a..8c6a6004b 100644 --- a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx @@ -56,6 +56,7 @@ function node(overrides: Partial): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/__tests__/node-row.test.tsx b/apps/server/web/src/components/__tests__/node-row.test.tsx index f30afc40d..f173c5928 100644 --- a/apps/server/web/src/components/__tests__/node-row.test.tsx +++ b/apps/server/web/src/components/__tests__/node-row.test.tsx @@ -30,6 +30,7 @@ const BASE: Node = { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }; diff --git a/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx b/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx index e7adc59b0..9977bf7cf 100644 --- a/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx @@ -71,7 +71,14 @@ describe("SharingDialog", () => { renderDialog(); await screen.findByText("Everyone"); // Choose bob from the roster, then Add. - fireEvent.change(screen.getByLabelText("Share with"), { target: { value: "u2" } }); + const picker = screen.getByRole("combobox", { name: "Share with" }); + fireEvent.mouseDown(picker); + fireEvent.click(picker); + await screen.findByRole("option", { name: "bob@subshell.local" }); + fireEvent.change(picker, { target: { value: "nobody-matches" } }); + await screen.findByText("No matching people to add."); + fireEvent.change(picker, { target: { value: "bob" } }); + fireEvent.click(await screen.findByRole("option", { name: "bob@subshell.local" })); fireEvent.click(screen.getByRole("button", { name: "Add" })); expect(await screen.findByText("bob@subshell.local")).toBeDefined(); diff --git a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx index 10c9cadfd..d1fb85ff3 100644 --- a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx +++ b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx @@ -59,3 +59,56 @@ it("explains the SSH icon on keyboard focus", async () => { await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus()); await waitFor(() => expect(screen.getByText("This subshell connects to another machine over SSH.")).toBeTruthy()); }); + +it("lists the destination and the declared forwards in the tooltip", async () => { + render( + , + ); + await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus()); + await waitFor(() => expect(screen.getByText("theo@build.example:22")).toBeTruthy()); + // The machine it connects FROM rides the top line, in its display name. + expect(screen.getByText("mac mini")).toBeTruthy(); + expect(screen.getByText("Local · loopback:5432 → db.internal:5432")).toBeTruthy(); + expect(screen.getByText("Dynamic · loopback:1080 → any host the tool picks")).toBeTruthy(); + // The concrete facts replace the vague sentence, they do not stack on it. + expect(screen.queryByText(/connects to another machine/) === null).toBe(true); +}); + +it("names the machine even when the pane carries no summary", async () => { + render(); + await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus()); + await waitFor(() => expect(screen.getByText("Server")).toBeTruthy()); + expect(screen.getByText("This subshell connects to another machine over SSH.")).toBeTruthy(); +}); + +it("says nothing about forwards when the stored rows could not be read", async () => { + // A null list is a failed read, not an empty one: the destination stands + // and no "No port forwards" claim is made beside it. + render( + , + ); + await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus()); + await waitFor(() => expect(screen.getByText("deploy@host:2222")).toBeTruthy()); + expect(screen.queryByText("No port forwards") === null).toBe(true); +}); + +it("says so when the connection carries no forwards", async () => { + render( + , + ); + await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus()); + await waitFor(() => expect(screen.getByText("deploy@host:2222")).toBeTruthy()); + expect(screen.getByText("No port forwards")).toBeTruthy(); +}); diff --git a/apps/server/web/src/components/__tests__/subshell-card.test.tsx b/apps/server/web/src/components/__tests__/subshell-card.test.tsx index 15e7da1fc..2db9036cd 100644 --- a/apps/server/web/src/components/__tests__/subshell-card.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-card.test.tsx @@ -47,6 +47,7 @@ function agent(overrides: Partial = {}): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx index b595db3b1..84c47629d 100644 --- a/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/connect-panel.test.tsx @@ -50,6 +50,7 @@ function node(overrides: Partial): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; @@ -242,18 +243,20 @@ describe("machine disclosure (contract 1)", () => { await settle(); const studio = await screen.findByRole("option", { name: /studio/ }); expect(studio.getAttribute("data-disabled")).toBe(""); - // The row carries the remedy, not just the cause (the changeset's - // promise; the server's gateCause sentences are the pattern). - expect(studio.textContent).toContain( - "SSH is off on this machine. Its owner can switch it on from this machine's settings.", - ); + // The row is short and gold, not a paragraph: "SSH not enabled" reads in + // one line of a capped popup (ruling 2026-10-16). The full remedy + // sentence arrives beside the field and in the launch refusal. + expect(studio.textContent).toContain("SSH not enabled"); + expect(studio.textContent).not.toContain("Its owner can switch it on"); + // Gold: the switch-not-on tone, not a mistake in what the reader typed. + expect(studio.querySelector('[class*="text-warning"]')).not.toBeNull(); expect(pathname()).toBe("/"); } finally { restore(); } }); - it("names the admin remedy on a gated-off local row, kind-aware like the server's gate", async () => { + it("shortens a gated-off local row's reason the same way, one label for either switch", async () => { const localOff = node({ id: "loc", name: "this host", kind: "local", sshEnabled: false }); const { restore } = await renderPanel({ nodes: [HOST_A, localOff] }); try { @@ -261,7 +264,9 @@ describe("machine disclosure (contract 1)", () => { await settle(); const row = await screen.findByRole("option", { name: /this host/ }); expect(row.getAttribute("data-disabled")).toBe(""); - expect(row.textContent).toContain("SSH is off here. An admin can switch it on from this machine's settings."); + // Short in the row regardless of whose switch it is: the admin-vs-owner + // pair lives in the field guidance and the refusal, not the one-liner. + expect(row.textContent).toContain("SSH not enabled"); } finally { restore(); } @@ -763,3 +768,163 @@ it("a delayed URL preset cannot overwrite an edited draft during or after a pend restore(); } }); + +it("carries added port forwards in the launch body, and holds Add until the editor row is valid (spec 2026-10-15)", async () => { + const { calls, restore } = await renderPanel({ + nodes: [HOST_A], + launch: () => json({ subshell: { id: "pane-fwd" } }, 201), + }); + try { + fireEvent.change(destinationInput(), { target: { value: "build.example" } }); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" })); + await settle(); + // Nobody declared a forward: the section is one button, not a form. + expect(screen.queryByLabelText(/Local port/) === null).toBe(true); + // The machine's bind rule is stated up front, not discovered at launch. + expect(screen.getByText(/127\.0\.0\.1/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + // Loopback-only machine: no bind field offered. + expect(screen.queryByLabelText(/Bind address/)).toBeNull(); + // Disabled state rides aria-disabled, not the attribute: the node-admin + // Button stays focusable while it explains why Add is held (Base UI). + const addDisabled = () => { + const b = screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement; + return b.disabled || b.getAttribute("aria-disabled") === "true"; + }; + // A pristine editor adds nothing: Add is disabled and no complaint shows. + expect(addDisabled()).toBe(true); + expect(screen.queryByText(/listen port/i)).toBeNull(); + fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } }); + await settle(); + // Half a row: the listen port alone is not a forward. + expect(addDisabled()).toBe(true); + expect(screen.getByText(/host to reach/i)).toBeTruthy(); + fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } }); + fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } }); + await settle(); + expect(addDisabled()).toBe(false); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + // The forward lands in the table and the editor folds away. + expect(screen.getByText("loopback:5432")).toBeTruthy(); + expect(screen.queryByLabelText(/Local port/) === null).toBe(true); + // Reopening starts clean: the kind sticks, the values do not. + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + expect((screen.getByLabelText(/Local port/) as HTMLInputElement).value).toBe(""); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await clickConnect(); + await waitFor(() => expect(calls.some((call) => call.url === "/api/ssh/launch")).toBe(true)); + expect(calls.find((call) => call.url === "/api/ssh/launch")?.body).toMatchObject({ + node: "a1", + destination: "build.example", + forwards: [{ kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }], + }); + } finally { + restore(); + } +}); + +it("Cancel means the half-typed row is gone, not hidden", async () => { + const { restore } = await renderPanel({ nodes: [HOST_A] }); + try { + fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" })); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "8080" } }); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await settle(); + expect(screen.queryByLabelText(/Local port/) === null).toBe(true); + // Reopening shows nothing stale: what Cancel discarded stays discarded. + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + expect((screen.getByLabelText(/Local port/) as HTMLInputElement).value).toBe(""); + expect(screen.queryByRole("alert") === null).toBe(true); + } finally { + restore(); + } +}); + +it("removes an added forward from the table before the launch", async () => { + const { calls, restore } = await renderPanel({ + nodes: [HOST_A], + launch: () => json({ subshell: { id: "pane-fwd2" } }, 201), + }); + try { + fireEvent.change(destinationInput(), { target: { value: "build.example" } }); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" })); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "8080" } }); + fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "app.internal" } }); + fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "3000" } }); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + expect(screen.getByText("loopback:8080")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Remove this forward" })); + await settle(); + expect(screen.queryByText("loopback:8080")).toBeNull(); + await clickConnect(); + await waitFor(() => expect(calls.some((call) => call.url === "/api/ssh/launch")).toBe(true)); + const launchBody = calls.find((call) => call.url === "/api/ssh/launch")?.body as Record; + expect(launchBody.forwards).toBeUndefined(); + } finally { + restore(); + } +}); + +it("refuses to add a second forward on a port the table already holds", async () => { + const { restore } = await renderPanel({ nodes: [HOST_A] }); + try { + fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" })); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } }); + fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } }); + fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } }); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + // The next row on the same port is refused at the Add button, not at launch. + // Adding folded the editor, so it opens again clean and the whole row is retyped. + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } }); + fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "cache.internal" } }); + fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } }); + await settle(); + const addButton = screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement; + expect(addButton.disabled || addButton.getAttribute("aria-disabled") === "true").toBe(true); + expect(screen.getByText(/already binds that port/i)).toBeTruthy(); + // A press on the held button changes nothing: the table keeps its one row. + fireEvent.click(addButton); + await settle(); + expect(screen.getAllByRole("button", { name: "Remove this forward" })).toHaveLength(1); + } finally { + restore(); + } +}); + +it("offers the bind address on a machine whose owner allows wide binds", async () => { + const HOST_WIDE = node({ id: "w1", name: "router box", sshEnabled: true, sshWideForwards: true }); + const { restore } = await renderPanel({ nodes: [HOST_WIDE] }); + try { + fireEvent.click(screen.getByRole("button", { name: "Advanced SSH options" })); + await settle(); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await settle(); + expect(screen.getByLabelText(/Bind address/)).toBeTruthy(); + // The owner already allowed wide binds: no loopback-only sentence. + expect(screen.queryByText(/127\.0\.0\.1/) === null).toBe(true); + } finally { + restore(); + } +}); diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx index 8c1d725bd..5df4187d0 100644 --- a/apps/server/web/src/components/connect/connect-panel.tsx +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -7,6 +7,7 @@ import { useBrowserKeySelection } from "@/components/connect/browser-key-fields" import { DestinationField } from "@/components/connect/destination-field"; import { buildDestinationOptions } from "@/components/connect/destination-options"; import { NoSshTargets } from "@/components/connect/no-ssh-targets"; +import { SshForwardFields } from "@/components/connect/ssh-forward-fields"; import { SshKeyStep } from "@/components/connect/ssh-key-step"; import { SaveSshPresetFields, SshPresetPicker } from "@/components/connect/ssh-preset-controls"; import { type SshRefusalCopy, sshLaunchRefusal } from "@/components/connect/ssh-refusal"; @@ -40,7 +41,8 @@ import { isDesktop } from "@/lib/desktop"; import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form"; import { nodeOptionLabel } from "@/lib/node-label"; import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; -import { sshCanEnableOnStart } from "@/lib/ssh"; +import { sshCanEnableOnStart, sshMachineRowReason } from "@/lib/ssh"; +import { sshForwardRowsToWire } from "@/lib/ssh-forwards"; import { sshDestinationProblem } from "@/lib/ssh-input-validation"; import { launchWithLocalKey } from "@/lib/ssh-local-key"; import { sshDraftFromPreset, sshPresetPayload } from "@/lib/ssh-preset"; @@ -172,17 +174,24 @@ export function ConnectPanel({ // several doors with no preference, or no door at all (spec §7, decision 2). const machineRequired = nodes !== null && !ledgerPending && nodeId === ""; - // The machine disclosure keeps gated-off rows visible (decision 3): grey, - // with the reason carrying the remedy, not just the cause - the sentence - // pair mirrors the server's own gate (ssh-launch.service.ts's `gateCause`): - // the control-plane host's switch is an admin's, every other machine's is - // its owner's. - const machineOptions: ComboboxOption[] = targets.map((n) => ({ - value: n.id, - label: nodeOptionLabel(n), - disabled: !machineCanConnect(n.id), - reason: machineBlocker(n.id) ?? undefined, - })); + // The machine disclosure keeps gated-off rows visible (decision 3), grey + // with their reason: the row stays short (`sshMachineRowReason` - a full + // remedy sentence is unreadable in a picker line), and the sentence pair + // that names WHO can fix it - the server's own gate words, + // ssh-launch.service.ts's `gateCause` - reads beside the field once the row + // is chosen or the launch refuses. + const machineRow = (id: string) => machines?.find((machine) => machine.node.id === id); + const machineOptions: ComboboxOption[] = targets.map((n) => { + const machine = machineRow(n.id); + const row = machine === undefined ? undefined : sshMachineRowReason(machine); + return { + value: n.id, + label: nodeOptionLabel(n), + disabled: !machineCanConnect(n.id), + reason: row?.reason, + reasonWarning: row?.warning, + }; + }); // Aliases are the MACHINE's config, asked only while a gate-ON machine is // picked (spec §7: discovery runs where it can answer). @@ -213,15 +222,17 @@ export function ConnectPanel({ { value: "connecting", label: "Keys on the connecting machine" }, ...(nodes ?? []) .filter((n) => n.id !== nodeId || n.id === keyHome) - .map((n) => ({ - value: n.id, - label: n.name, - disabled: n.id === nodeId || !machineCanConnect(n.id), - reason: - n.id === nodeId - ? "Use keys on this machine, or choose a different machine." - : (machineBlocker(n.id) ?? undefined), - })), + .map((n) => { + const machine = machineRow(n.id); + const row = machine === undefined ? undefined : sshMachineRowReason(machine); + return { + value: n.id, + label: n.name, + disabled: n.id === nodeId || !machineCanConnect(n.id), + reason: n.id === nodeId ? "Use keys on this machine, or choose a different machine." : row?.reason, + reasonWarning: n.id !== nodeId && row?.warning === true, + }; + }), ]; const keySourceProblem = sshKeySourceProblem(draft); const enableKeysOnStart = @@ -232,6 +243,7 @@ export function ConnectPanel({ const selectionError = sshKeySelectionProblem(fingerprints, roster.data?.identities); const rosterReady = !keyHome || (!roster.isPending && !roster.isError && selectionError === null); const destination = sshDestination(draft); + const forwardRows = sshForwardRowsToWire(draft.forwards); // A committed row's label is the field's text; while that echo stands the // list is UNFILTERED (the whole ledger again), because the echo is display, // not a search. Any keystroke releases the pick, and text becomes query. @@ -344,6 +356,7 @@ export function ConnectPanel({ destination, ...(expectedDestination ? { expectedDestination } : {}), ...(keyHome ? { keyHome, fingerprints } : {}), + ...(forwardRows.length > 0 ? { forwards: forwardRows } : {}), }; const created = draft.authentication === "local-key" @@ -381,6 +394,7 @@ export function ConnectPanel({ destination, ...(expectedDestination ? { expectedDestination } : {}), ...(keyHome ? { keyHome, fingerprints } : {}), + ...(forwardRows.length > 0 ? { forwards: forwardRows } : {}), }, launch.mutateAsync, ); @@ -566,14 +580,17 @@ export function ConnectPanel({
- diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx new file mode 100644 index 000000000..e20189aee --- /dev/null +++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx @@ -0,0 +1,298 @@ +import { Button, cn, Input, Label } from "@internal/node-admin"; +import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { RequiredMark } from "@/components/ui/required-mark"; +import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; +import { + EMPTY_SSH_FORWARD_ROW, + FORWARD_EXAMPLE, + type SshForwardDraftRow, + sshForwardListenText, + sshForwardRowConflict, + sshForwardRowDiagram, + sshForwardRowProblem, + sshForwardTargetText, +} from "@/lib/ssh-forwards"; + +/** + * The port-forwards group (spec 2026-10-15 §7), shaped like the app's other + * add-a-thing lists: the section starts as one "Add port forward" button and + * nothing else. Pressing it reveals the editor for ONE forward; commit puts + * that row in the list below (enabled only when the row is valid and doesn't + * re-bind a listed port) and folds the editor away again, so a person who + * declared nothing never sees a form. The editor is state that never leaves + * the component until it is added; the list holds what `onChange` commits + * and what the launch carries. Forwards die with the pane, and the intro says + * so. + */ + +const KINDS = [ + { value: "local", label: "Local (-L)", short: "Local" }, + { value: "remote", label: "Remote (-R)", short: "Remote" }, + { value: "dynamic", label: "Dynamic SOCKS (-D)", short: "Dynamic" }, +] as const; + +/** + * What the chosen kind does and where its port lives, kept beside the option + * it explains (spec §7). The ends are named ("the machine you connect from", + * "the destination") because on a remote-node launch a bare "this machine" + * would read as the browser. The diagram (sshForwardRowDiagram) traces one + * request with the editor's own typed values, so the prose names the ends + * and the arrows show the direction. + */ +const KIND_HELP: Record = { + local: + "Opens a port on the machine you connect from. Anything you send to that port reaches the target through the destination.", + remote: + "Opens a port on the destination. Anything sent to that port reaches the target on the machine you connect from. The destination's own SSH settings decide whether the port may bind.", + dynamic: + "Opens a SOCKS proxy port on the machine you connect from. Tools pointed at it choose their own targets, each reached through the destination.", +}; + +/** + * The duplicate-listen refusal in one sentence, shared by the Add button's + * tooltip and the alert under the editor so the two voices can't drift. + */ +const CONFLICT_SENTENCE = "Another listed forward already binds that port there."; + +/** A line's Listen part: what ssh will really bind, spelled plainly. */ +function listenText(row: SshForwardDraftRow): string { + return sshForwardListenText(row.kind, row.listenPort.trim(), row.bindHost.trim()); +} + +/** A line's Target part; a SOCKS forward has no fixed target to name. */ +function targetText(row: SshForwardDraftRow): string { + return sshForwardTargetText(row.kind, row.targetHost.trim(), row.targetPort.trim()); +} + +export function SshForwardFields({ + rows, + allowWideBind, + busy, + onChange, +}: { + rows: SshForwardDraftRow[]; + /** The selected machine's wide-bind allowance; false hides the bind field. */ + allowWideBind: boolean; + busy: boolean; + onChange: (rows: SshForwardDraftRow[]) => void; +}) { + const [composing, setComposing] = useState(false); + const [editor, setEditor] = useState({ ...EMPTY_SSH_FORWARD_ROW }); + const patchEditor = (value: Partial) => setEditor((current) => ({ ...current, ...value })); + const problem = sshForwardRowProblem(editor); + const touched = Object.entries(editor).some(([key, value]) => key !== "kind" && value !== ""); + const conflict = problem === null && touched && sshForwardRowConflict(editor, rows); + const atCap = rows.length >= SSH_MAX_FORWARD_ROWS; + const capReason = `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`; + const addReason = problem !== null ? problem.text : conflict ? CONFLICT_SENTENCE : undefined; + const foldEditor = () => { + // Folding is always a clean slate: the values clear and only the chosen + // kind stays. Commit and Cancel share this, so whatever closes the editor + // - a row landing or a mind changing - means the next opening shows + // nothing stale, and "Cancel" means what it says. + setEditor({ ...EMPTY_SSH_FORWARD_ROW, kind: editor.kind }); + setComposing(false); + }; + const addForward = () => { + if (problem !== null || conflict || atCap) return; + onChange([...rows, { ...editor }]); + foldEditor(); + }; + const helpId = "ssh-fwd-kind-help"; + const example = FORWARD_EXAMPLE[editor.kind]; + return ( +
+ Port forwards +

+ Each forward opens a listening port for as long as this connection lives, and the port closes when the subshell + ends. +

+ {!allowWideBind && ( +

+ Until the owner turns on “Allow wide port forwards” in this node's SSH + settings, a forward can only bind 127.0.0.1 on the machine you connect from, so just programs there can reach + the port. A remote forward binds on the destination instead, under the destination's own policy. +

+ )} + {!composing ? ( + + ) : ( +
+ patchEditor({ kind: (value ?? "local") as SshForwardDraftRow["kind"] })} + > + {/* The Authentication field's shape: the chosen option carries its + caption, route diagram and fields in the column beside the radio, + and the other options stay one line each. */} + {KINDS.map((option) => { + const selected = editor.kind === option.value; + return ( + + +
+ + {option.label} + + {selected && ( + <> + + {KIND_HELP[option.value]} + + + {sshForwardRowDiagram(editor)} + +
+ + + patchEditor({ listenPort: event.target.value })} + /> + + {option.value !== "dynamic" && ( + <> + + + patchEditor({ targetHost: event.target.value })} + /> + + + + patchEditor({ targetPort: event.target.value })} + /> + + + )} + {(allowWideBind || editor.bindHost !== "") && ( + + + patchEditor({ bindHost: event.target.value })} + /> + + )} +
+ + )} +
+
+ ); + })} +
+ {/* Sentences show once a field is touched, not while it sits pristine. */} + {touched && problem && ( +

+ {problem.text} +

+ )} + {/* `conflict` is only computed when there's no other problem, so one + guard here cannot double-say the row's own sentence. */} + {touched && conflict && ( +

+ {CONFLICT_SENTENCE} Change the port or the bind, or remove that one. +

+ )} +
+ + +
+
+ )} + {rows.length > 0 && ( + // The declared forwards as the dialog's other lists read (the sharing + // dialog's grants are the worked example): one line each, the control + // right, no card and no header row. What ssh will really listen on + // stays monospace; a SOCKS forward names no target, so its ends are + // prose. +
+ {rows.map((row, index) => ( + // A row's listen identity is unique in this list: the Add button + // and the server both refuse a port the list already binds, so + // kind + bind spelling + port cannot repeat. +
+ + + {KINDS.find((option) => option.value === row.kind)?.short ?? row.kind} + + + {listenText(row)} + + + {targetText(row)} + + + +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/server/web/src/components/connect/ssh-machine-step.tsx b/apps/server/web/src/components/connect/ssh-machine-step.tsx index ee3489259..8f2e6fc1a 100644 --- a/apps/server/web/src/components/connect/ssh-machine-step.tsx +++ b/apps/server/web/src/components/connect/ssh-machine-step.tsx @@ -4,7 +4,7 @@ import { LoaderCircle } from "lucide-react"; import type { ReactNode } from "react"; import { SearchableSelect } from "@/components/ui/combobox"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; -import type { SshMachineReadiness } from "@/lib/ssh"; +import { type SshMachineReadiness, sshMachineRowReason } from "@/lib/ssh"; /** Lists visible machines even when they need preparation; launch and configuration are distinct rights. */ export function SshMachineStep({ @@ -42,15 +42,16 @@ export function SshMachineStep({ placeholder="Choose a node" options={machines .filter((m) => m.node.id !== exclude || m.node.id === value) - .map((m) => ({ - value: m.node.id, - label: m.node.name, - disabled: m.node.id === exclude, - reason: - m.node.id === exclude - ? "Choose a different machine to supply your keys." - : m.blockers.map((b) => b.message).join(" "), - }))} + .map((m) => { + const row = sshMachineRowReason(m); + return { + value: m.node.id, + label: m.node.name, + disabled: m.node.id === exclude, + reason: m.node.id === exclude ? "Choose a different machine to supply your keys." : row?.reason, + reasonWarning: m.node.id !== exclude && row?.warning === true, + }; + })} onValueChange={(id) => id && onChange(id)} /> {description &&

{description}

} diff --git a/apps/server/web/src/components/connect/ssh-refusal.ts b/apps/server/web/src/components/connect/ssh-refusal.ts index dd5fbd377..c85a96de2 100644 --- a/apps/server/web/src/components/connect/ssh-refusal.ts +++ b/apps/server/web/src/components/connect/ssh-refusal.ts @@ -85,6 +85,16 @@ export function sshLaunchRefusal( // ALIAS_UNSAFE: the token was refused before the machine was asked. return { text: "Enter a valid SSH destination, such as deploy@example.com:22.", field: "destination" }; case 403: { + if (err.code === "SSH_FORWARD_BIND_DENIED") { + // A wide bind needs the machine's owner to allow it (spec 2026-10-15 + // §6): the server's own sentence names both remedies, so pass it + // through like the CONFIG_INVALID arm does. + const message = + err.body && typeof err.body === "object" && "message" in err.body && typeof err.body.message === "string" + ? err.body.message + : err.message; + return { text: message, field: "machine" }; + } if (err.code === "SSH_GATE_OFF" && machine !== null && !machine.sshEnabled) { // The gate's off-door. Naming WHO flips it follows the row's kind: // `local` is an admin's setting, every other machine its owner's diff --git a/apps/server/web/src/components/connect/ssh-session-draft.ts b/apps/server/web/src/components/connect/ssh-session-draft.ts index 6a06b181a..ccfd553f2 100644 --- a/apps/server/web/src/components/connect/ssh-session-draft.ts +++ b/apps/server/web/src/components/connect/ssh-session-draft.ts @@ -1,5 +1,6 @@ import type { DestinationCandidate } from "@/components/connect/destination-options"; import { type SshAgentIdentity, sshSelectionError } from "@/lib/ssh"; +import { type SshForwardDraftRow, sshForwardListProblems } from "@/lib/ssh-forwards"; import { sshDestinationProblem, sshPresetNameProblem } from "@/lib/ssh-input-validation"; export type SshInitialChoices = { @@ -30,6 +31,8 @@ export interface SshSessionDraft { presetApplied?: boolean; saveAsPreset?: boolean; presetName?: string; + /** Declared port forwards as FORM rows (strings; untyped rows are ignored). Spec 2026-10-15. */ + forwards: SshForwardDraftRow[]; } export function sshSessionDraft(initial?: SshInitialChoices): SshSessionDraft { @@ -43,6 +46,7 @@ export function sshSessionDraft(initial?: SshInitialChoices): SshSessionDraft { typed: initial?.destination ?? "", }, initialPresetId: initial?.preset, + forwards: [], }; } @@ -72,6 +76,8 @@ export function sshDraftProblems(draft: SshSessionDraft): Record if (!draft.nodeId) return { node: "Choose a node to connect from first." }; const destinationProblem = sshDestinationProblem(sshDestination(draft)); if (destinationProblem) return { destination: destinationProblem }; + const forwardProblem = sshForwardListProblems(draft.forwards); + if (forwardProblem) return { forwards: forwardProblem }; const error = sshKeySourceProblem(draft) || (draft.keyHome ? sshKeySelectionProblem(draft.selections[draft.keyHome] ?? []) : null); diff --git a/apps/server/web/src/components/connect/ssh-wizard.tsx b/apps/server/web/src/components/connect/ssh-wizard.tsx index c49d89073..0049b0ee4 100644 --- a/apps/server/web/src/components/connect/ssh-wizard.tsx +++ b/apps/server/web/src/components/connect/ssh-wizard.tsx @@ -3,6 +3,7 @@ import { CircleAlert } from "lucide-react"; import { useEffect, useRef } from "react"; import { BrowserKeyFields, type BrowserKeySelection } from "@/components/connect/browser-key-fields"; import { SshDestinationStep } from "@/components/connect/ssh-destination-step"; +import { SshForwardFields } from "@/components/connect/ssh-forward-fields"; import { SshKeyStep } from "@/components/connect/ssh-key-step"; import { SshMachineSetup, SshMachineStep } from "@/components/connect/ssh-machine-step"; import { SaveSshPresetFields } from "@/components/connect/ssh-preset-controls"; @@ -19,6 +20,7 @@ import { useSshNodeRoster } from "@/hooks/use-ssh"; import { isDesktop } from "@/lib/desktop"; import { makeForm, useSubmitDisabled } from "@/lib/form"; import { type SshMachineReadiness, sshCanEnableOnStart } from "@/lib/ssh"; +import { sshForwardListProblems } from "@/lib/ssh-forwards"; import { sshDestinationProblem, sshPresetNameProblem } from "@/lib/ssh-input-validation"; export interface SshWizardProps { @@ -92,6 +94,8 @@ export function SshWizard({ if (!value.nodeId) return { draft: "Choose a node." }; const destinationProblem = sshDestinationProblem(sshDestination(value)); if (destinationProblem) return { draft: destinationProblem }; + const forwardProblem = sshForwardListProblems(value.forwards); + if (forwardProblem) return { draft: forwardProblem }; if (remote) { const error = !value.keyHome ? "Choose a node to supply keys." : sshKeySourceProblem(value); if (error) return { draft: error }; @@ -331,6 +335,12 @@ export function SshWizard({ + patch({ forwards })} + /> {!presetEditor && } {refusal && ( diff --git a/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx index 83a0ccf64..312720f05 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-harness-card.test.tsx @@ -105,6 +105,7 @@ function view(over: Partial): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...over, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx index 465af89b7..26927df0c 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-list-row.test.tsx @@ -36,6 +36,7 @@ function node(overrides: Partial = {}): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx index b6101c03d..1ddac617a 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-section-nav.test.tsx @@ -35,6 +35,7 @@ function navNode(overrides: Partial = {}): NodeDetail { canManage: true, canRetire: true, sshEnabled: false, + sshWideForwards: false, canLaunch: true, allowedDirs: [], capabilities: [], diff --git a/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx index 5f69e2727..3b7fe0cc0 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-ssh-card.test.tsx @@ -28,6 +28,7 @@ function node(over: Partial = {}): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...over, }; @@ -54,7 +55,7 @@ for (const enabled of [false, true]) { return new Response("{}"); }) as unknown as typeof fetch; mount(node({ sshEnabled: enabled })); - expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe(String(enabled)); + expect(screen.getByRole("switch", { name: "Allow SSH" }).getAttribute("aria-checked")).toBe(String(enabled)); fireEvent.click(screen.getByRole("switch", { name: "Allow SSH" })); await waitFor(() => expect(calls).toHaveLength(1)); expect(calls[0].url).toContain("/api/nodes/node1/ssh-enabled"); @@ -62,6 +63,29 @@ for (const enabled of [false, true]) { expect(JSON.parse(String(calls[0].init?.body))).toEqual({ on: !enabled }); }); } +it("offers the wide-forwards switch beside the SSH switch, and writes its own endpoint", async () => { + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (url, init) => { + calls.push({ url: String(url), init }); + return new Response("{}"); + }) as unknown as typeof fetch; + mount(node({ sshEnabled: true })); + const wide = screen.getByRole("switch", { name: "Allow wide port forwards" }); + expect(wide.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(wide); + await waitFor(() => expect(calls.some((c) => c.url.includes("/api/nodes/node1/ssh-wide-forwards"))).toBe(true)); + const call = calls.find((c) => c.url.includes("/ssh-wide-forwards")); + expect(JSON.parse(String(call?.init?.body))).toEqual({ on: true }); +}); + +it("hides the wide-forwards switch while SSH itself is off, and says who owns it to others", () => { + mount(node({ sshEnabled: false })); + expect(screen.queryByRole("switch", { name: "Allow wide port forwards" })).toBeNull(); + mount(node({ sshEnabled: true, access: "edit", canManage: false })); + const wide = screen.getByRole("switch", { name: "Allow wide port forwards" }); + expect(wide.hasAttribute("data-disabled")).toBe(true); +}); + it("shows configuration to edit viewers without offering changes", () => { mount(node({ access: "edit", canManage: false })); expect(screen.getByRole("switch").hasAttribute("data-disabled")).toBe(true); diff --git a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx index 7b61365d6..41feda532 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx @@ -30,6 +30,7 @@ function node(overrides: Partial = {}): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx index bc64345fa..b820699bf 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx @@ -36,6 +36,7 @@ function node(over: Partial = {}): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...over, }; diff --git a/apps/server/web/src/components/nodes/node-ssh-card.tsx b/apps/server/web/src/components/nodes/node-ssh-card.tsx index 1c122507a..3afcba648 100644 --- a/apps/server/web/src/components/nodes/node-ssh-card.tsx +++ b/apps/server/web/src/components/nodes/node-ssh-card.tsx @@ -9,11 +9,12 @@ import { Switch, } from "@internal/node-admin"; import type { ReactNode } from "react"; -import { useSetSshEnabled } from "@/hooks/use-ssh"; +import { useSetSshEnabled, useSetSshWideForwards } from "@/hooks/use-ssh"; /** The node's configuration permission is supplied by the same server gate as the update route. */ export function NodeSshCard({ node, children }: { node: NodeDetail; children?: ReactNode }) { const update = useSetSshEnabled(); + const wide = useSetSshWideForwards(); return ( @@ -47,6 +48,32 @@ export function NodeSshCard({ node, children }: { node: NodeDetail; children?: R {errMessage(update.error, "Couldn't update the SSH setting.")}

)} + {node.sshEnabled && ( +
+
+ wide.mutate({ id: node.id, on })} + /> + +
+

+ {node.sshWideForwards + ? "Forwards here are no longer confined to 127.0.0.1. One can bind a LAN address or 0.0.0.0, and any machine that reaches it gets through to the target via the SSH connection. Turning this off affects new subshells only." + : "Port forwards on this machine listen on 127.0.0.1 only. Turn this on to let a forward bind another address, such as 0.0.0.0 for every interface. Remote forwards bind on the destination under its own policy either way."} +

+ {!node.canManage && ( +

Only this node’s owner can change this setting.

+ )} + {wide.isError && ( +

+ {errMessage(wide.error, "Couldn't update the port-forward setting.")} +

+ )} +
+ )} {children}
diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx index be3a6ce26..c35b51307 100644 --- a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx +++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx @@ -32,6 +32,7 @@ const preset: SshPreset = { authentication: "local-key", keyHome: null, fingerprints: [], + forwards: [], createdAt: "t", updatedAt: "t", }; @@ -112,10 +113,50 @@ it("saves the local-key radio choice without requiring a file or approval", asyn authentication: "local-key", keyHome: null, fingerprints: [], + forwards: [], }, }, ]); }); + +it("saves a declared port forward alongside the connection choices", async () => { + const { writes, closed } = await setup(); + await waitFor(() => expect(disabled()).toBe(false)); + // The form only appears once the person asks to add a forward. + expect(screen.queryByLabelText(/Local port/) === null).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } }); + fireEvent.change(screen.getByLabelText(/Target host/), { target: { value: "db.internal" } }); + fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } }); + await waitFor(() => + expect((screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement).disabled).toBe(false), + ); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + await waitFor(() => expect(screen.getByText("loopback:5432")).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + await waitFor(() => expect(closed()).toBe(true)); + expect(writes[0]?.body.forwards).toEqual([ + { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }, + ]); +}); + +it("a half-typed forward never reaches the preset: Add stays off and Save is not held", async () => { + const { writes, closed } = await setup(); + await waitFor(() => expect(disabled()).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Add port forward" })); + fireEvent.change(screen.getByLabelText(/Local port/), { target: { value: "5432" } }); + fireEvent.change(screen.getByLabelText(/Target port/), { target: { value: "5432" } }); + // The incomplete editor row gates its own Add button, not the Save button. + // Disabled state rides aria-disabled (the node-admin Button explains itself + // while focusable), so check both spellings. + await waitFor(() => { + const b = screen.getByRole("button", { name: "Add port forward" }) as HTMLButtonElement; + expect(b.disabled || b.getAttribute("aria-disabled") === "true").toBe(true); + }); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + await waitFor(() => expect(closed()).toBe(true)); + expect(writes[0]?.body.forwards).toEqual([]); +}); it("allows unrelated edits while the saved connecting node is offline", async () => { const { writes } = await setup(false); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Renamed" } }); diff --git a/apps/server/web/src/components/sharing-dialog-core.tsx b/apps/server/web/src/components/sharing-dialog-core.tsx index 5975ba7a9..32c5f7196 100644 --- a/apps/server/web/src/components/sharing-dialog-core.tsx +++ b/apps/server/web/src/components/sharing-dialog-core.tsx @@ -1,5 +1,6 @@ import { Button, errMessage } from "@internal/node-admin"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useId, useState } from "react"; +import { SearchableSelect } from "@/components/ui/combobox"; import { Dialog, DialogContent, @@ -8,7 +9,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Segmented } from "@/components/ui/segmented"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import type { RosterUser, ShareDraft } from "@/hooks/use-subshell-shares"; import type { SubshellAccess } from "@/types/subshell"; @@ -20,7 +21,34 @@ const LEVEL_OPTIONS: { value: ShareLevel; label: string }[] = [ { value: "edit", label: "View + edit" }, ]; -/** The Everyone grant's sentinel in the native picker (resolved to null on add). */ +function ShareLevelSelect({ + label, + value, + onChange, +}: { + label: string; + value: ShareLevel; + onChange: (value: ShareLevel) => void; +}) { + return ( + + ); +} + +/** The Everyone grant's sentinel in the picker (resolved to null on add). */ const EVERYONE = "everyone"; /** @@ -86,9 +114,16 @@ export function SharingDialogCore({ const [pickLevel, setPickLevel] = useState("view"); const [error, setError] = useState(null); + const pickerId = useId(); const grants = draft ?? serverGrants; const alreadyShared = new Set(grants.map((g) => g.granteeUserId ?? EVERYONE)); + const recipientOptions = [ + { value: EVERYONE, label: "Everyone" }, + ...roster.map((user) => ({ value: user.id, label: user.email, searchText: user.name })), + ].filter((option) => !alreadyShared.has(option.value)); + const selectedRecipient = recipientOptions.some((option) => option.value === pickId) ? pickId : ""; + function handleClose(next: boolean) { if (!next) { setDraft(null); @@ -106,9 +141,11 @@ export function SharingDialogCore({ } function addGrant() { - const granteeUserId = pickId === EVERYONE ? null : pickId; + if (!selectedRecipient) return; + const granteeUserId = selectedRecipient === EVERYONE ? null : selectedRecipient; // Replace any existing grant to the same target rather than duplicating. setDraft([...grants.filter((g) => g.granteeUserId !== granteeUserId), { granteeUserId, permission: pickLevel }]); + setPickId(""); } async function save() { @@ -137,27 +174,29 @@ export function SharingDialogCore({

)} -
+
{isLoading &&

Loading…

} {!isLoading && grants.length === 0 && (

Not shared with anyone yet.

)} {grants.map((g, i) => ( -
- +
+ {g.granteeUserId === null ? "Everyone" : (roster.find((u) => u.id === g.granteeUserId)?.email ?? g.granteeUserId)} {canManage ? ( <> - changeLevel(i, level)} /> - @@ -170,29 +209,29 @@ export function SharingDialogCore({ ))} {canManage && ( -
- - -
diff --git a/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx b/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx index 08874682b..9bbfecdff 100644 --- a/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx +++ b/apps/server/web/src/components/sidebar/subshell-cell-grid.tsx @@ -471,9 +471,14 @@ export function SubshellCellGrid({ return (
{runs.map((run) => ( + // The plate wraps too, and `flex` (not `inline-flex`) is what lets it: + // one machine with more panes than the rail fits on a line would + // otherwise form a plate wider than the container and run its last + // cells off the edge, since a single unwrappable item is the one thing + // the outer `flex-wrap` cannot rescue.
{run.cells.map((c) => ( {renderCell(c.sub)} diff --git a/apps/server/web/src/components/split-subshell-button.tsx b/apps/server/web/src/components/split-subshell-button.tsx index 86f98e585..d9afb7c36 100644 --- a/apps/server/web/src/components/split-subshell-button.tsx +++ b/apps/server/web/src/components/split-subshell-button.tsx @@ -77,10 +77,13 @@ export function SplitSubshellButton({ subshell }: { subshell: SubshellView }): J cannot afford the word). The tooltip carries it, and the dialog it opens is titled "Split" so the word survives where the choice is actually made. */} + {/* `ghost` like its header neighbours (operator ruling 2026-10-16): the + outline border made this one icon-only button read as a different + kind of control than the workspace link and menu beside it. */} setOpen(true)} > diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx index db6158933..739e05836 100644 --- a/apps/server/web/src/components/ssh-pane-labels.tsx +++ b/apps/server/web/src/components/ssh-pane-labels.tsx @@ -1,5 +1,6 @@ import { SquareTerminal } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { sshForwardWireLine } from "@/lib/ssh-forwards"; import type { SubshellView } from "@/types/subshell"; /** The header sentence a non-owner of an ssh pane sees (spec 2026-10-07 §5.4). */ @@ -18,8 +19,22 @@ export const SSH_VIEW_ONLY_COPY = "Only the owner can type in this SSH subshell. * `=== true` on the flag is deliberate: a payload cached before the field * existed has no `ssh` key and must read as an ordinary pane. */ -export function SshPaneLabels({ subshell }: { subshell?: SubshellView }) { +export function SshPaneLabels({ + subshell, + nodeLabel, +}: { + subshell?: SubshellView; + /** + * The pane's machine through the page's shared `nodeLabelFor` ladder: the + * display name once the nodes read answers, a short id fragment while it has + * never answered, and "node you can't see" for a node off the viewer's + * list. The tooltip shows whatever the ladder resolved, top line; null or + * empty (a caller with nothing to say) simply omits the line. + */ + nodeLabel?: string | null; +}) { if (subshell?.ssh !== true) return null; + const summary = subshell.sshSummary; return ( <> @@ -27,7 +42,41 @@ export function SshPaneLabels({ subshell }: { subshell?: SubshellView }) { - This subshell connects to another machine over SSH. + {/* Where it goes and what it opened, from the row the launch + approved. A pane whose column predates the summary (or cannot be + read) falls back to the plain sentence, which is still true. */} + +
+ {/* The machine it connects FROM, in its display name (labels + never derive from the id). The destination line below names + the machine it connects TO. */} + {nodeLabel !== undefined && nodeLabel !== null && nodeLabel !== "" && ( +

{nodeLabel}

+ )} + {summary === undefined ? ( +

This subshell connects to another machine over SSH.

+ ) : ( + <> +

{summary.destination}

+ {/* An empty list is the claim "this connection opened no + ports" - made only when the pane can make it. A null + list (the stored rows can't be read) stays silent: the + destination line stands, and a failed read is never + rendered as absence. */} + {summary.forwards !== null && + (summary.forwards.length === 0 ? ( +

No port forwards

+ ) : ( + summary.forwards.map((row) => ( +

+ {sshForwardWireLine(row)} +

+ )) + ))} + + )} +
+
{subshell.access === "view" && {SSH_VIEW_ONLY_COPY}} diff --git a/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx b/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx index 3169523d2..48aafe65e 100644 --- a/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx +++ b/apps/server/web/src/components/ssh/__tests__/trust-section.test.tsx @@ -48,6 +48,7 @@ function node(over: Partial = {}): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: true, + sshWideForwards: false, held: null, sshTrust: { own: { signing: FP_OWN_SIGN, encryption: FP_OWN_ENC }, diff --git a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx index e2bef6da1..b64ce062d 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx @@ -194,6 +194,7 @@ const BOX = { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }; diff --git a/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx index 60f367fac..b3e354cbe 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/no-launch-targets.test.tsx @@ -57,6 +57,7 @@ function node(overrides: Partial): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx index 0716166bc..ec693a2e4 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx @@ -39,6 +39,7 @@ function api() { canManage: true, access: "owner", sshEnabled: true, + sshWideForwards: false, maintenance: false, harnesses: [], allowedDirs: [], @@ -70,6 +71,7 @@ function api() { name: "Server", kind: "local", sshEnabled: true, + sshWideForwards: false, canLaunch: true, canManage: true, status: "online", diff --git a/apps/server/web/src/components/ui/combobox.tsx b/apps/server/web/src/components/ui/combobox.tsx index 25eeba458..c3537c9b6 100644 --- a/apps/server/web/src/components/ui/combobox.tsx +++ b/apps/server/web/src/components/ui/combobox.tsx @@ -11,6 +11,12 @@ export interface ComboboxOption { disabled?: boolean; /** Muted trailing copy on the row (a disabled row's refusal, or a detail line) */ reason?: string; + /** + * Render `reason` in gold instead of muted: a switch-not-on restriction at + * this decision point (an SSH-gated machine's row says "SSH not enabled", + * short and gold) rather than something wrong the reader did. + */ + reasonWarning?: boolean; /** Node shown before the label (aria-hidden — the accessible name stays the label) */ icon?: ReactNode; /** @@ -192,7 +198,14 @@ export function SearchableSelect({ {(option: ComboboxOption, index: number) => { @@ -225,7 +238,13 @@ export function SearchableSelect({ )} {option.label} {option.reason ? ( - + {option.reason} ) : null} diff --git a/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx b/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx index 181047db7..82d5c30df 100644 --- a/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx +++ b/apps/server/web/src/hooks/__tests__/use-node-harnesses.test.tsx @@ -39,6 +39,7 @@ const NODE: NodeDetail = { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }; diff --git a/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx b/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx index eac2e457e..9afc42fad 100644 --- a/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx +++ b/apps/server/web/src/hooks/__tests__/use-nodes.test.tsx @@ -41,6 +41,7 @@ const NODE: Node = { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }; diff --git a/apps/server/web/src/hooks/use-ssh.ts b/apps/server/web/src/hooks/use-ssh.ts index c3dd59ca4..ae24a5ad2 100644 --- a/apps/server/web/src/hooks/use-ssh.ts +++ b/apps/server/web/src/hooks/use-ssh.ts @@ -182,3 +182,22 @@ export function useSetSshEnabled() { }, }); } + +/** Flip one machine's wide-bind allowance for SSH port forwards (spec 2026-10-15 §6); owner-only, like the SSH switch. */ +export function useSetSshWideForwards() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, on }: { id: string; on: boolean }) => + apiFetch(`/api/nodes/${encodeURIComponent(id)}/ssh-wide-forwards`, { + method: "PUT", + body: JSON.stringify({ on }), + }), + onSuccess: async (_data, { id }) => { + await Promise.all([ + client.invalidateQueries({ queryKey: ["ssh-readiness"] }), + client.invalidateQueries({ queryKey: [...NODE_QUERY_KEY, id] }), + client.invalidateQueries({ queryKey: NODES_QUERY_KEY }), + ]); + }, + }); +} diff --git a/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts new file mode 100644 index 000000000..a51fe06e7 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-forwards.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "bun:test"; +import { + EMPTY_SSH_FORWARD_ROW, + type SshForwardDraftRow, + sshForwardListProblems, + sshForwardRowDiagram, + sshForwardRowProblem, + sshForwardRowProblems, + sshForwardRowsFromWire, + sshForwardRowsToWire, + sshForwardWireLine, +} from "@/lib/ssh-forwards"; + +/** The form-side mirror of the launch-time refusals (spec 2026-10-15). */ +function row(over: Partial = {}): SshForwardDraftRow { + return { kind: "local", listenPort: "5432", bindHost: "", targetHost: "db.internal", targetPort: "5432", ...over }; +} + +describe("sshForwardRowProblems", () => { + it("accepts a complete local row and a dynamic row with no target", () => { + expect(sshForwardRowProblems(row())).toBeNull(); + expect(sshForwardRowProblems(row({ kind: "dynamic", targetHost: "", targetPort: "" }))).toBeNull(); + }); + + it("refuses ports outside the range and non-numbers", () => { + for (const listenPort of ["", "0", "65536", "abc", "1.5", "-1"]) { + expect(sshForwardRowProblems(row({ listenPort }))).toMatch(/listen port/i); + } + expect(sshForwardRowProblems(row({ targetPort: "0" }))).toMatch(/target port/i); + }); + + it("refuses a local or remote row with no target host", () => { + expect(sshForwardRowProblems(row({ targetHost: "" }))).toMatch(/host to reach/i); + expect(sshForwardRowProblems(row({ kind: "remote", targetHost: "" }))).toMatch(/host to reach/i); + // dynamic carries no target at all, so blank is correct there. + expect(sshForwardRowProblems(row({ kind: "dynamic", targetHost: "", targetPort: "" }))).toBeNull(); + }); + + it("refuses option-like or space-bearing bind hosts, and accepts blanks", () => { + expect(sshForwardRowProblems(row({ bindHost: "-oProxyCommand=x" }))).toMatch(/bind host/i); + expect(sshForwardRowProblems(row({ bindHost: "two words" }))).toMatch(/bind host/i); + expect(sshForwardRowProblems(row({ bindHost: "" }))).toBeNull(); + expect(sshForwardRowProblems(row({ bindHost: "10.0.0.5" }))).toBeNull(); + expect(sshForwardRowProblems(row({ bindHost: "[::1]" }))).toBeNull(); + }); +}); + +describe("sshForwardRowProblem tone", () => { + it("calls an empty required field a gap and a typed wrong value a hard error", () => { + expect(sshForwardRowProblem(row({ listenPort: "" }))).toEqual({ + text: "Enter a listen port from 1 to 65535.", + gap: true, + }); + expect(sshForwardRowProblem(row({ listenPort: "abc" }))).toEqual({ + text: "Enter a listen port from 1 to 65535.", + gap: false, + }); + expect(sshForwardRowProblem(row({ listenPort: "1.5" }))).toEqual({ + text: "Enter a listen port from 1 to 65535.", + gap: false, + }); + // An empty target host is the same fact as an empty listen port: nothing typed yet. + expect(sshForwardRowProblem(row({ targetHost: "" }))).toEqual({ + text: "Enter the host to reach through the connection.", + gap: true, + }); + // A malformed bind host is always a typed value that is wrong. + expect(sshForwardRowProblem(row({ bindHost: "-oProxyCommand=x" }))).toEqual({ + text: "Enter the bind host as a plain name or address, or leave it blank for loopback.", + gap: false, + }); + expect(sshForwardRowProblem(row())).toBeNull(); + }); + + it("keeps the text-only voice in step with the toned one", () => { + const r = row({ listenPort: "" }); + expect(sshForwardRowProblems(r)).toBe(sshForwardRowProblem(r)?.text ?? null); + expect(sshForwardRowProblems(row())).toBeNull(); + }); +}); + +describe("sshForwardListProblems", () => { + it("an all-empty list (including untouched blank rows) is ready", () => { + expect(sshForwardListProblems([])).toBeNull(); + expect(sshForwardListProblems([EMPTY_SSH_FORWARD_ROW, { ...EMPTY_SSH_FORWARD_ROW }])).toBeNull(); + }); + + it("refuses two rows binding the same port, case-insensitively on the name", () => { + expect(sshForwardListProblems([row(), row({ targetHost: "other.internal" })])).toMatch(/same port/i); + expect(sshForwardListProblems([row({ bindHost: "LOCALHOST" }), row({ bindHost: "localhost" })])).toMatch( + /same port/i, + ); + // Different literal names on one port is a bind race ssh reports itself. + expect(sshForwardListProblems([row({ bindHost: "localhost" }), row({ bindHost: "127.0.0.1" })])).toBeNull(); + }); + + it("refuses a blank bind beside an explicit 127.0.0.1, and keeps the remote pair distinct", () => { + // A blank local bind renders as 127.0.0.1, so the rows are one listener twice. + expect(sshForwardListProblems([row(), row({ bindHost: "127.0.0.1", targetHost: "other.internal" })])).toMatch( + /same port/i, + ); + // A blank remote bind rides the destination's own policy spelling. + expect( + sshForwardListProblems([ + row({ kind: "remote", bindHost: "" }), + row({ kind: "remote", bindHost: "127.0.0.1", targetHost: "other.internal" }), + ]), + ).toBeNull(); + // The same port on the two sides is two listeners, not a dupe. + expect( + sshForwardListProblems([ + row({ listenPort: "8080", targetPort: "8080" }), + row({ kind: "remote", listenPort: "8080", targetPort: "3000" }), + ]), + ).toBeNull(); + // The port keys numerically, so "05432" beside "5432" is one listener + // here rather than a form-only near-miss the server then 400s. + expect( + sshForwardListProblems([row({ listenPort: "05432" }), row({ listenPort: "5432", targetHost: "other.internal" })]), + ).toMatch(/same port/i); + }); + + it("refuses over the cap", () => { + const many = Array.from({ length: 17 }, (_, i) => row({ listenPort: String(i + 1) })); + expect(sshForwardListProblems(many)).toMatch(/at most 16/i); + }); + + it("reports the first row's problem before list rules", () => { + expect(sshForwardListProblems([row({ listenPort: "" }), row()])).toMatch(/listen port/i); + }); +}); + +describe("sshForwardRowsToWire", () => { + it("drops empty rows, parses numbers, and nulls the blanks", () => { + expect( + sshForwardRowsToWire([ + row({ listenPort: " 5432 ", targetHost: " db.internal " }), + EMPTY_SSH_FORWARD_ROW, + row({ kind: "dynamic", listenPort: "1080", targetHost: "", targetPort: "" }), + ]), + ).toEqual([ + { kind: "local", listenPort: 5432, bindHost: null, targetHost: "db.internal", targetPort: 5432 }, + { kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null }, + ]); + }); +}); + +describe("sshForwardRowsFromWire", () => { + it("round-trips wire rows into form strings and back", () => { + const wire = [ + { kind: "remote" as const, listenPort: 9090, bindHost: "0.0.0.0", targetHost: "dev.local", targetPort: 3000 }, + ]; + expect(sshForwardRowsToWire(sshForwardRowsFromWire(wire))).toEqual(wire); + expect(sshForwardRowsFromWire(undefined)).toEqual([]); + }); +}); + +describe("sshForwardWireLine", () => { + it("spells a stored row like the editor's line does", () => { + expect( + sshForwardWireLine({ + kind: "local", + listenPort: 5432, + bindHost: null, + targetHost: "db.internal", + targetPort: 5432, + }), + ).toBe("Local · loopback:5432 → db.internal:5432"); + expect( + sshForwardWireLine({ + kind: "remote", + listenPort: 9090, + bindHost: "0.0.0.0", + targetHost: "app.local", + targetPort: 3000, + }), + ).toBe("Remote · 0.0.0.0:9090 → app.local:3000"); + // A remote blank bind rides the destination's policy; dynamic has no target. + expect( + sshForwardWireLine({ kind: "remote", listenPort: 9090, bindHost: null, targetHost: "a", targetPort: 1 }), + ).toBe("Remote · 9090 (by policy) → a:1"); + expect( + sshForwardWireLine({ kind: "dynamic", listenPort: 1080, bindHost: null, targetHost: null, targetPort: null }), + ).toBe("Dynamic · loopback:1080 → any host the tool picks"); + }); +}); + +describe("sshForwardRowDiagram", () => { + it("traces the route with the ports and host the row actually holds", () => { + expect(sshForwardRowDiagram(row({ listenPort: "8080", targetHost: "cache.internal", targetPort: "11211" }))).toBe( + "program → 8080 on the connecting machine → destination → cache.internal:11211", + ); + expect( + sshForwardRowDiagram(row({ kind: "remote", listenPort: "6000", targetHost: "dev.lan", targetPort: "5173" })), + ).toBe("program on the destination → 6000 there → connecting machine → dev.lan:5173"); + }); + + it("trims typed whitespace out of the diagram", () => { + expect(sshForwardRowDiagram(row({ listenPort: " 8080 ", targetHost: " db.internal ", targetPort: " 6379 " }))).toBe( + "program → 8080 on the connecting machine → destination → db.internal:6379", + ); + }); + + it("fills a still-empty slot from the kind's example, matching the field placeholder", () => { + // The row helper starts from the local example; blank it to see the fallback. + expect(sshForwardRowDiagram(EMPTY_SSH_FORWARD_ROW)).toBe( + "program → 5432 on the connecting machine → destination → db.internal:5432", + ); + expect(sshForwardRowDiagram({ ...EMPTY_SSH_FORWARD_ROW, kind: "remote" })).toBe( + "program on the destination → 9090 there → connecting machine → app.local:3000", + ); + expect(sshForwardRowDiagram({ ...EMPTY_SSH_FORWARD_ROW, kind: "dynamic" })).toBe( + "SOCKS client → 1080 on the connecting machine → destination → any host it asks for", + ); + }); + + it("drops the target from a dynamic row's diagram even if a stale target lingers", () => { + expect(sshForwardRowDiagram(row({ kind: "dynamic", listenPort: "1080" }))).toBe( + "SOCKS client → 1080 on the connecting machine → destination → any host it asks for", + ); + }); +}); diff --git a/apps/server/web/src/lib/__tests__/ssh-preset.test.ts b/apps/server/web/src/lib/__tests__/ssh-preset.test.ts index 1fcc354e6..fb7101222 100644 --- a/apps/server/web/src/lib/__tests__/ssh-preset.test.ts +++ b/apps/server/web/src/lib/__tests__/ssh-preset.test.ts @@ -13,6 +13,7 @@ const preset: SshPreset = { authentication: "remote", keyHome: "keys", fingerprints: ["SHA256:one"], + forwards: [], createdAt: "t", updatedAt: "t", }; @@ -47,6 +48,7 @@ it("projects the local-key preference without key, credential, or stale remote s authentication: "local-key", keyHome: null, fingerprints: [], + forwards: [], }); }); it.each(["connecting", "remote", "local-key", "password"] as const)( diff --git a/apps/server/web/src/lib/__tests__/ssh.test.ts b/apps/server/web/src/lib/__tests__/ssh.test.ts index a00d31f16..02676a075 100644 --- a/apps/server/web/src/lib/__tests__/ssh.test.ts +++ b/apps/server/web/src/lib/__tests__/ssh.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { SSH_MAX_SELECTED_FINGERPRINTS } from "@internal/subshell-protocol"; -import { sshSelectionError } from "@/lib/ssh"; +import { type SshMachineReadiness, sshMachineRowReason, sshSelectionError } from "@/lib/ssh"; /** * The client-side half of the grant selection rule (spec 2026-10-08 §5.4, @@ -26,3 +26,46 @@ describe("sshSelectionError (the over-cap hard error)", () => { expect(error).toContain(String(SSH_MAX_SELECTED_FINGERPRINTS)); }); }); + +/** + * The one-line explanation an unlaunchable machine wears in the launch + * pickers (ruling 2026-10-16): gold and short when the machine's own SSH + * switch is the sole refusal, the server's full remedy sentences otherwise + * (naming only the switch when something ELSE also blocks would mislead). + */ +describe("sshMachineRowReason", () => { + const machine = (overrides: { + sshEnabled: boolean; + blockers: { code: string; message: string }[]; + }): SshMachineReadiness => + ({ + node: { id: "n1", name: "box", sshEnabled: overrides.sshEnabled }, + canConnect: overrides.blockers.length === 0, + canConfigure: true, + blockers: overrides.blockers, + }) as unknown as SshMachineReadiness; + + const gateOff = { code: "SSH_GATE_OFF", message: "Outgoing SSH is disabled on this node. Ask its owner." }; + + it("says nothing for a machine with no blockers", () => { + expect(sshMachineRowReason(machine({ sshEnabled: true, blockers: [] }))).toBeUndefined(); + }); + + it("shortens to gold when the switch is the sole refusal", () => { + expect(sshMachineRowReason(machine({ sshEnabled: false, blockers: [gateOff] }))).toEqual({ + reason: "SSH not enabled", + warning: true, + }); + }); + + it("keeps the server's sentences when something else blocks too", () => { + const also = { code: "NODE_OFFLINE", message: "Start Subshell on this machine to bring it online." }; + const row = sshMachineRowReason(machine({ sshEnabled: false, blockers: [gateOff, also] })); + expect(row).toEqual({ reason: `${gateOff.message} ${also.message}`, warning: false }); + }); + + it("keeps the sentence when SSH_GATE_OFF is not the machine's switch (the server gate, lockdown)", () => { + const row = sshMachineRowReason(machine({ sshEnabled: true, blockers: [gateOff] })); + expect(row).toEqual({ reason: gateOff.message, warning: false }); + }); +}); diff --git a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts index a6baaaaae..b9d565370 100644 --- a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts +++ b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts @@ -33,6 +33,7 @@ function node(overrides: Partial): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/lib/ssh-forwards.ts b/apps/server/web/src/lib/ssh-forwards.ts new file mode 100644 index 000000000..d06398678 --- /dev/null +++ b/apps/server/web/src/lib/ssh-forwards.ts @@ -0,0 +1,222 @@ +import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol"; +import type { SshForwardRow } from "@/lib/ssh"; + +/** + * Port-forward rows as the FORM holds them: every value a string, because a + * half-typed port is normal keystrokes and NaN is not. The pure checks here + * mirror the server's refusals (kind rules, port bounds, host hygiene, + * duplicate binds) so a person sees the problem beside the field instead of + * after pressing Start; the protocol grammar on the server stays the + * authority, and nothing here needs to be a perfect copy of it. + */ +export type SshForwardDraftRow = { + kind: "local" | "remote" | "dynamic"; + listenPort: string; + bindHost: string; + targetHost: string; + targetPort: string; +}; + +export const EMPTY_SSH_FORWARD_ROW: SshForwardDraftRow = { + kind: "local", + listenPort: "", + bindHost: "", + targetHost: "", + targetPort: "", +}; + +/** True when a row holds nothing but its default kind: an empty row is not a forward. */ +export function sshForwardRowEmpty(row: SshForwardDraftRow): boolean { + return ( + row.listenPort.trim() === "" && + row.bindHost.trim() === "" && + row.targetHost.trim() === "" && + row.targetPort.trim() === "" + ); +} + +const PORT_RE = /^\d{1,5}$/; + +function isPortText(value: string): boolean { + if (!PORT_RE.test(value.trim())) return false; + const n = Number(value.trim()); + return n >= 1 && n <= 65535; +} + +/** The host hygiene the wire grammar also refuses: no option-like start, no whitespace or control characters, bounded length. */ +function isHostText(value: string): boolean { + const host = value.trim(); + return host.length > 0 && host.length <= 253 && !host.startsWith("-") && !/[\s\p{Cc}]/u.test(host); +} + +/** A row problem and its tone: `gap` means "nothing typed yet" (gold), else the typed value is wrong (red). */ +export type SshForwardRowProblem = { text: string; gap: boolean }; + +/** + * The first problem with one row, in the voice of the field it belongs to, + * with the gold/red split the form substrate uses (ruling 2026-09-30): an + * empty required field is a requirement not yet filled, a typed value that + * fails the same rule is a hard error. + */ +export function sshForwardRowProblem(row: SshForwardDraftRow): SshForwardRowProblem | null { + if (!isPortText(row.listenPort)) + return { text: "Enter a listen port from 1 to 65535.", gap: row.listenPort.trim() === "" }; + if (row.bindHost.trim() !== "" && !isHostText(row.bindHost)) + return { + text: "Enter the bind host as a plain name or address, or leave it blank for loopback.", + gap: false, + }; + if (row.kind !== "dynamic") { + if (!isHostText(row.targetHost)) + return { text: "Enter the host to reach through the connection.", gap: row.targetHost.trim() === "" }; + if (!isPortText(row.targetPort)) + return { text: "Enter a target port from 1 to 65535.", gap: row.targetPort.trim() === "" }; + } + return null; +} + +/** The first problem with one row, as plain text (the gating voice; the component reads {@link sshForwardRowProblem} for the tone). */ +export function sshForwardRowProblems(row: SshForwardDraftRow): string | null { + return sshForwardRowProblem(row)?.text ?? null; +} + +/** + * The protocol's duplicate key for one row: per SIDE (a local/dynamic row + * binds the connecting machine, a remote row the destination, so one port on + * each side is two listeners), with the canonical bind (a blank on a local or + * dynamic row renders as `127.0.0.1`, so it IS that explicit bind twice; a + * remote blank rides the destination's policy spelling) and the port as a + * number, so "05432" beside "5432" is one listener. Meaningful only on a row + * that passes {@link sshForwardRowProblems}. + */ +function forwardListenKey(row: SshForwardDraftRow): string { + const side = row.kind === "remote" ? "D" : "B"; + const bind = row.bindHost.trim() === "" && row.kind !== "remote" ? "127.0.0.1" : row.bindHost.trim(); + return `${side}|${bind.toLowerCase()}|${String(Number(row.listenPort.trim()))}`; +} + +/** + * True when a candidate row would bind a port a listed forward already binds. + * The editor's Add button checks this so the list can never hold a pair the + * server would refuse at launch, and a fresh forward cannot quietly overwrite + * a listed one. + */ +export function sshForwardRowConflict(row: SshForwardDraftRow, rows: readonly SshForwardDraftRow[]): boolean { + if (rows.length === 0) return false; + const key = forwardListenKey(row); + return rows.some((existing) => forwardListenKey(existing) === key); +} + +/** + * The whole-list answer: per-row problems first, then the LIST rules + * (duplicate binds, the cap). Returns null when the list is ready to launch; + * empty rows do not count against anything. + */ +export function sshForwardListProblems(rows: readonly SshForwardDraftRow[]): string | null { + const filled = rows.filter((row) => !sshForwardRowEmpty(row)); + if (filled.length === 0) return null; + if (filled.length > SSH_MAX_FORWARD_ROWS) + return `A connection carries at most ${String(SSH_MAX_FORWARD_ROWS)} port forwards.`; + for (const row of filled) { + const problem = sshForwardRowProblems(row); + if (problem) return problem; + } + const listens = new Set(); + for (const row of filled) { + const key = forwardListenKey(row); + if (listens.has(key)) + return "Two forwards can't bind the same port on the same machine. Remove one or change a port."; + listens.add(key); + } + return null; +} + +/** The list as the wire wants it (empty rows dropped, numbers parsed, blanks null). Call only when {@link sshForwardListProblems} says the list is ready. */ +export function sshForwardRowsToWire(rows: readonly SshForwardDraftRow[]): SshForwardRow[] { + return rows + .filter((row) => !sshForwardRowEmpty(row)) + .map((row) => ({ + kind: row.kind, + listenPort: Number(row.listenPort.trim()), + bindHost: row.bindHost.trim() === "" ? null : row.bindHost.trim(), + targetHost: row.kind === "dynamic" || row.targetHost.trim() === "" ? null : row.targetHost.trim(), + targetPort: row.kind === "dynamic" || row.targetPort.trim() === "" ? null : Number(row.targetPort.trim()), + })); +} + +/** + * The example each kind uses for a still-empty slot. It is the single source + * of truth for BOTH the field placeholders and the route diagram, so a + * half-typed row never shows one example in its input and a different one in + * its diagram. `dynamic` has no target, so its host/port are empty. + */ +export const FORWARD_EXAMPLE: Record = { + local: { listen: "5432", host: "db.internal", port: "5432" }, + remote: { listen: "9090", host: "app.local", port: "3000" }, + // No target fields are rendered for a SOCKS forward and its diagram names + // no target, so the host and port here are deliberately empty: nothing may + // start treating them as a real example. + dynamic: { listen: "1080", host: "", port: "" }, +}; + +/** + * The one-line route diagram for THIS row: the ports and host the person + * actually typed, with {@link FORWARD_EXAMPLE} standing in for a still-empty + * slot. A diagram of generic numbers stops helping the moment real ones are + * filled in; this one tracks the form as it is typed. + */ +export function sshForwardRowDiagram(row: SshForwardDraftRow): string { + const example = FORWARD_EXAMPLE[row.kind]; + const listen = row.listenPort.trim() === "" ? example.listen : row.listenPort.trim(); + if (row.kind === "dynamic") { + return `SOCKS client → ${listen} on the connecting machine → destination → any host it asks for`; + } + const host = row.targetHost.trim() === "" ? example.host : row.targetHost.trim(); + const targetPort = row.targetPort.trim() === "" ? example.port : row.targetPort.trim(); + return row.kind === "local" + ? `program → ${listen} on the connecting machine → destination → ${host}:${targetPort}` + : `program on the destination → ${listen} there → connecting machine → ${host}:${targetPort}`; +} + +/** + * The Listen half of a row in words: a named bind spelled as `bind:port`, a + * blank on a local or dynamic row is ssh's own loopback, and a blank on a + * remote row rides the destination's policy. Inputs are the trimmed strings + * the form holds, or wire values stringified by {@link sshForwardWireLine}. + */ +export function sshForwardListenText(kind: SshForwardDraftRow["kind"], listenPort: string, bindHost: string): string { + if (bindHost !== "") return `${bindHost}:${listenPort}`; + return kind === "remote" ? `${listenPort} (by policy)` : `loopback:${listenPort}`; +} + +/** The Target half; a SOCKS forward has no fixed target to name. */ +export function sshForwardTargetText(kind: SshForwardDraftRow["kind"], targetHost: string, targetPort: string): string { + return kind === "dynamic" ? "any host the tool picks" : `${targetHost}:${targetPort}`; +} + +/** + * One stored (wire) row as the pane header lists it: `Local · loopback:5432 → + * db.internal:5432`. The same spellings the editor's table uses, so a person + * who declared a row recognizes it in the header. + */ +export function sshForwardWireLine(row: SshForwardRow): string { + const kind = row.kind === "local" ? "Local" : row.kind === "remote" ? "Remote" : "Dynamic"; + const listen = sshForwardListenText(row.kind, String(row.listenPort), row.bindHost ?? ""); + const target = sshForwardTargetText( + row.kind, + row.targetHost ?? "", + row.targetPort === null ? "" : String(row.targetPort), + ); + return `${kind} · ${listen} ${"→"} ${target}`; +} + +/** A stored preset's rows back into form spelling. */ +export function sshForwardRowsFromWire(rows: readonly SshForwardRow[] | undefined): SshForwardDraftRow[] { + return (rows ?? []).map((row) => ({ + kind: row.kind, + listenPort: String(row.listenPort), + bindHost: row.bindHost ?? "", + targetHost: row.targetHost ?? "", + targetPort: row.targetPort === null ? "" : String(row.targetPort), + })); +} diff --git a/apps/server/web/src/lib/ssh-preset.ts b/apps/server/web/src/lib/ssh-preset.ts index 7f3e8abd6..7fd90a6e9 100644 --- a/apps/server/web/src/lib/ssh-preset.ts +++ b/apps/server/web/src/lib/ssh-preset.ts @@ -1,4 +1,5 @@ import { type SshSessionDraft, sshDestination, sshSessionDraft } from "@/components/connect/ssh-session-draft"; +import { sshForwardRowsFromWire, sshForwardRowsToWire } from "@/lib/ssh-forwards"; import type { SshPreset, SshPresetPayload } from "@/types/preset"; export const SSH_AUTHENTICATION_LABELS = { @@ -19,6 +20,7 @@ export function sshDraftFromPreset(preset: SshPreset): SshSessionDraft { keyHome: preset.authentication === "remote" ? (preset.keyHome ?? "") : "", selections: preset.authentication === "remote" && preset.keyHome ? { [preset.keyHome]: [...preset.fingerprints] } : {}, + forwards: sshForwardRowsFromWire(preset.forwards), }; } @@ -37,5 +39,6 @@ export function sshPresetPayload(draft: SshSessionDraft, name: string, descripti authentication, keyHome: authentication === "remote" ? draft.keyHome : null, fingerprints: authentication === "remote" ? (draft.selections[draft.keyHome] ?? []) : [], + forwards: sshForwardRowsToWire(draft.forwards), }; } diff --git a/apps/server/web/src/lib/ssh.ts b/apps/server/web/src/lib/ssh.ts index 4845c22f5..42339488a 100644 --- a/apps/server/web/src/lib/ssh.ts +++ b/apps/server/web/src/lib/ssh.ts @@ -126,6 +126,26 @@ export interface SshAliasesView { truncated: boolean; } +/** + * One declared port-forward row, mirroring the server's `SshForwardWire` + * (docs/security.md's SSH section; spec 2026-10-15). Hosts are canonical + * names; nulls mean the omitted spelling (loopback for local/dynamic, the + * destination's policy for remote). Checked against the route schema in + * `apps/server/api/src/api/ssh/launch.route.ts`. + */ +export interface SshForwardRow { + /** local (-L, binds on the connecting machine), remote (-R, binds on the destination), dynamic (-D SOCKS) */ + kind: "local" | "remote" | "dynamic"; + /** Port to bind, 1..65535, always explicit */ + listenPort: number; + /** Listen bind host; null is the omitted default */ + bindHost: string | null; + /** Target host through the connection; null only for dynamic */ + targetHost: string | null; + /** Target port; null only for dynamic */ + targetPort: number | null; +} + /** `POST /api/ssh/launch` body: name is the pane's display name, optional. */ export interface SshLaunchRequest { /** One-use native signing preparation, never key material. */ @@ -142,6 +162,8 @@ export interface SshLaunchRequest { expectedDestination?: string; /** Pane display name */ name?: string; + /** User-declared port forwards this connection carries (spec 2026-10-15); omitted launches none */ + forwards?: SshForwardRow[]; } /** @@ -195,6 +217,24 @@ export interface SshMachineReadiness { blockers: { code: string; message: string }[]; } +/** + * The explanation a machine row carries in the launch pickers. When the + * machine's OWN SSH switch is the SOLE refusal, the row says it in three gold + * words: the server's full sentence is a paragraph in a one-line row and + * truncates mid-word (report 2026-10-16). The sole-cause discipline mirrors + * `sshCanEnableOnStart`: with anything else also blocking, naming only the + * switch would mislead, so the row keeps the server's joined sentences, which + * name the remedies. The whole story reads below the field once a row is + * chosen. + */ +export function sshMachineRowReason(machine: SshMachineReadiness): { reason: string; warning: boolean } | undefined { + const only = machine.blockers.length === 1 ? machine.blockers[0] : undefined; + if (only !== undefined && only.code === "SSH_GATE_OFF" && !machine.node.sshEnabled) + return { reason: "SSH not enabled", warning: true }; + if (machine.blockers.length === 0) return undefined; + return { reason: machine.blockers.map((b) => b.message).join(" "), warning: false }; +} + /** Only a disabled SSH flag may be changed as part of starting a connection. */ export function sshCanEnableOnStart(machine: SshMachineReadiness | undefined): boolean { return ( diff --git a/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx b/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx index 59adefa8b..185991655 100644 --- a/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx @@ -40,6 +40,7 @@ function enrolledNode(overrides: Partial = {}): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx b/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx index 6997faf9e..327afab75 100644 --- a/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-page-filters.test.tsx @@ -33,6 +33,7 @@ const NODES: Node[] = [ canRetire: true, canLaunch: true, sshEnabled: false, + sshWideForwards: false, allowedDirs: [], capabilities: [], harnesses: [], @@ -58,6 +59,7 @@ const NODES: Node[] = [ canRetire: true, canLaunch: true, sshEnabled: false, + sshWideForwards: false, allowedDirs: [], capabilities: [], harnesses: [], diff --git a/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx b/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx index 84df1c763..c28ad7742 100644 --- a/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-page-gate.test.tsx @@ -142,6 +142,7 @@ describe("/nodes add-node gating", () => { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }, ], diff --git a/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx b/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx index ddb6938c4..d51078527 100644 --- a/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-tabs.test.tsx @@ -29,6 +29,7 @@ const NODE = { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }; diff --git a/apps/server/web/src/routes/subshells_.$id.tsx b/apps/server/web/src/routes/subshells_.$id.tsx index e71f6c9f8..8310d4d13 100644 --- a/apps/server/web/src/routes/subshells_.$id.tsx +++ b/apps/server/web/src/routes/subshells_.$id.tsx @@ -315,7 +315,7 @@ function SubshellPage() { off (spec 2026-10-07 §5.4). The badge is the label; the enforcement is the server's access downgrade the terminal already honours. */} - + {/* Permanent disclosure: whose machine this runs on, and who else can read it. Never suppressible — see components/trust-indicators.tsx. */} diff --git a/apps/server/web/src/types/preset.ts b/apps/server/web/src/types/preset.ts index c2a856ac6..52e1f6da4 100644 --- a/apps/server/web/src/types/preset.ts +++ b/apps/server/web/src/types/preset.ts @@ -4,6 +4,8 @@ * (apps/server/api/src/api/models.ts): it carries the fields the frontend uses * and omits `userId`. */ +import type { SshForwardRow } from "@/lib/ssh"; + export interface AgentPreset { kind: "agent"; id: string; @@ -58,6 +60,8 @@ export interface SshPreset { keyHome: string | null; /** Selected public-key fingerprints (remote mode only); private keys stay on their machine */ fingerprints: string[]; + /** Declared port forwards this preset carries to every launch; empty when it stores none (spec 2026-10-15) */ + forwards: SshForwardRow[]; /** ISO 8601 creation timestamp */ createdAt: string; /** ISO 8601 update timestamp */ @@ -66,5 +70,13 @@ export interface SshPreset { export type SshPresetPayload = Pick< SshPreset, - "name" | "description" | "destination" | "alias" | "resolutionAlias" | "authentication" | "keyHome" | "fingerprints" + | "name" + | "description" + | "destination" + | "alias" + | "resolutionAlias" + | "authentication" + | "keyHome" + | "fingerprints" + | "forwards" > & { kind: "ssh"; nodeId: string; expectedDestination?: string }; diff --git a/apps/server/web/src/types/subshell.ts b/apps/server/web/src/types/subshell.ts index c391d1394..2f7fb874b 100644 --- a/apps/server/web/src/types/subshell.ts +++ b/apps/server/web/src/types/subshell.ts @@ -1,6 +1,7 @@ /** * Shared subshell view model (mirrors the backend toSubshellView shape). */ +import type { SshForwardRow } from "@/lib/ssh"; /** Coarse activity heuristic: "active" | "idle" | "terminated". */ export type SubshellActivity = "active" | "idle" | "terminated"; @@ -87,6 +88,23 @@ export interface SubshellView { access: SubshellAccess; /** True when this pane is an SSH-terminal pane: only its owner can type into it. */ ssh: boolean; + /** + * Bounded view of the pane's SSH connection for the header: the canonical + * destination and the port-forward rows declared at launch (spec + * 2026-10-15). Optional because a payload cached before the field exists + * must keep typechecking, and a pane with an unreadable ssh column carries + * no summary; the header then says only what it always said. + */ + sshSummary?: { + /** Canonical `user@host:port` the pane connected to */ + destination: string; + /** + * The declared forwards in the order added; empty when the launch carried + * none. Null means the stored rows could not be read: absence of data, + * not absence of forwards, and the header says nothing rather than "none". + */ + forwards: SshForwardRow[] | null; + }; /** * How many sharing grants this subshell carries; 0 = private to its owner. * Optional for the same reason `nodeId` is — a payload cached by a client diff --git a/apps/website/AGENTS.md b/apps/website/AGENTS.md index 13b5f4f6b..edc456820 100644 --- a/apps/website/AGENTS.md +++ b/apps/website/AGENTS.md @@ -84,7 +84,7 @@ and descriptive captions that do not require knowing abbreviations such as PWA. ## Feature screenshots -The five feature screenshots come from `e2e/capture-docs.ts`, shared with the +The six feature screenshots come from `e2e/capture-docs.ts`, shared with the documentation site. Capture at 2× with `e2e/screenshot.ts`, retain lossless WebP encoding, and use the product-colored screenshot frame tokens. The PNGs already include a consistent 12px margin; don't add another inset or a modal backdrop. diff --git a/apps/website/components/feature-showcase.tsx b/apps/website/components/feature-showcase.tsx index 237d8215b..3c244e989 100644 --- a/apps/website/components/feature-showcase.tsx +++ b/apps/website/components/feature-showcase.tsx @@ -27,10 +27,17 @@ const SHOTS = { sharing: { name: "session-sharing", width: 1072, - height: 768, - alt: "Subshell sharing dialog with separate View and View + edit permissions", + height: 760, + alt: "Subshell sharing dialog with permission dropdowns and a searchable recipient picker", caption: "Choose who can watch and who can type into the subshell.", }, + ssh: { + name: "ssh-connection", + width: 1200, + height: 1964, + alt: "SSH connection form with a connecting node, destination, authentication choices, and a local port forward", + caption: "Choose how to connect and forward ports through the same SSH connection.", + }, nodes: { name: "node-enrollment", width: 1072, @@ -188,17 +195,18 @@ export function FeatureShowcase() { > Open a remote shell from your dashboard. -
-
-

+

Connect over SSH and work in the terminal alongside your agents. Use keys from a connected node, choose a key from your own machine, or enter a password when connecting. Save frequently used connections as SSH - presets. + presets, including port forwards to reach a web app, database, or other service through the connection.

Explore SSH connections
+
+ +
diff --git a/apps/website/public/shots/session-sharing.webp b/apps/website/public/shots/session-sharing.webp index c115f0fc3..fa913fe71 100644 Binary files a/apps/website/public/shots/session-sharing.webp and b/apps/website/public/shots/session-sharing.webp differ diff --git a/apps/website/public/shots/ssh-connection.webp b/apps/website/public/shots/ssh-connection.webp new file mode 100644 index 000000000..74097ac85 Binary files /dev/null and b/apps/website/public/shots/ssh-connection.webp differ diff --git a/docs/security.md b/docs/security.md index e124688d7..e8cc3eb2b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1220,7 +1220,7 @@ connecting node's credentials. Restart still requires a new SSH connection. The connection form is **New subshell → SSH terminal**. **Settings → Presets → SSH** manages saved connection choices; it has no host-key editor. SSH presets retain -the resolved destination, original SSH resolution token, and authentication choice. +the resolved destination, original SSH resolution token, authentication choice, and declared port forwards. The token preserves alias-scoped routing and key settings; an optional `expectedDestination` constraint checks the resolved username, host, and port before save/clone, local-key preparation, trust discovery/acceptance, or launch @@ -1238,6 +1238,7 @@ keys. It does not configure `sshd` or grant incoming SSH access. | `/api/ssh/readiness`, `/aliases`, `/resolve`, `/identities` | Browser session required. Node discovery and operations follow the caller's node access and the applicable readiness gates; alias/configuration data and public agent identities are not anonymous information. | | `POST /api/ssh/launch` | Browser session and launch access to the connecting node; relays also require launch access to the key source. Both participants must pass SSH and readiness checks. | | `PUT /api/nodes/:id/ssh-enabled` | Browser session and node owner, or administrator for the built-in node. An edit share alone cannot enable SSH. The setting can be changed while a node is offline and is reconciled on reconnect. | +| `PUT /api/nodes/:id/ssh-wide-forwards` | Browser session and node owner, or administrator for the built-in node. Governs whether new SSH launches from this node may bind port forwards beyond loopback; an edit share cannot change it. | | `/api/ssh/recent-destinations` and `/preferences` | Browser session, scoped to the caller. Recent suggestions and the default connecting node are separate from destination trust. | | `/api/presets` with SSH kind | Browser session, scoped to the caller. Saving resolves the destination through the existing node gate and checks access to any key node; it does not enable SSH or approve trust. Launch rechecks access, readiness, fingerprints, and trust. Deleting a preset neither removes its host pin nor closes a subshell. | | `/api/ssh/verify-destination` and `/verify-destination/accept` | Browser session, current access checks, and the one-use confirmation binding described above. These are destination network probes and trust writes, not login attempts. | @@ -1283,6 +1284,34 @@ account’s configuration as executable trusted input, not passive data. Remote- one canonical destination. These restrictions do not sandbox that OS account or make its OpenSSH binary and configuration untrusted-code-safe. +**Port forwards are declared, never ambient.** Every forward that came from the +connecting account's own SSH configuration stays refused by name: the resolver +rejects `LocalForward`, `RemoteForward`, and `DynamicForward` found by `ssh -G` +before the snapshot freezes, and the snapshot keeps them as forbidden members. +Forwards that exist come from rows the user declared at launch (or from the +preset storing them): an explicit kind, port, and target, visible in the form +and summarized in the launch audit. The subshell view carries that same bounded +summary (canonical destination, declared rows) to anyone who can see the pane, +which is disclosure-only like the pane output that already shows the login. A local or dynamic forward binds the +connecting node's loopback address by default, which that node's other OS users +can reach: §0's accepted posture verbatim, the same class the node's loopback +dashboard already carries. Binding anything wider requires the node's +**Allow wide port forwards** switch (owner-only, admin on the built-in node, +default off, audited `node.ssh_wide_forwards.update`), and a launch asking for +a wide bind on a node without it is refused; enforcement sits plane-side +because the node already gates the whole SSH subsystem, and a plane lying +about the flag is the control-plane-compromise case §0 accepts. A remote +forward binds on the destination under its sshd's own `GatewayPorts` and +`PermitRemoteOpen` policy, like any SSH client's request; Subshell relays it +and owns the asking. When approved rows exist the rendered config omits its +`ClearAllForwardings yes` line (measured on OpenSSH: the keyword applies at the +end of the whole config load, so rows cannot survive it from anywhere), and +that omission is honest because the pane's `-F` file replaces the connecting +account's configuration outright, leaving only our rendered lines and the +root-owned system file as forward sources. `Tunnel`, `ForwardAgent`, and +`ForwardX11` toward the destination stay refused: this opens TCP port +forwarding and nothing else. + **Setup progress is not a durable job.** Completed results remain for one hour in the running server process. Closing the dialog does not cancel the installer; a server restart loses progress tracking. Check the destination and node roster @@ -2572,6 +2601,7 @@ Audit events are written, grouped by family: - **Nodes**: `node.enroll`, `node.delete`, `node.rename`, `node.key_rotate`, `node.reregister_key`, `node.reregister`, `node.allowed_dirs.update`, `node.config.update`, `node.maintenance.update`, `node.ssh_enabled.update`, + `node.ssh_wide_forwards.update` (the boolean itself; which node, who flipped it), `node.ssh_identity.register` (spec 2026-10-08 §4.3: the signing-key bootstrap lands when the link's answer fills the EMPTY slot, `via: "link"` and nothing else in the metadata - never the key value; the idempotent @@ -2607,7 +2637,8 @@ Audit events are written, grouped by family: verb shares its action with the rest of the service surface). The SSH launcher's act lands in this family too as `ssh.launch` (spec 2026-10-07 §12): metadata `{ nodeId, destination, subshellId }` plus `relayRef` for a - relay launch, naming which machine + relay launch and a bounded `forwards` summary (per approved row: kind, canonical + listen end, and target for local/remote) when the launch carries rows, naming which machine dialed out to which canonical `host:port` and the pane that came of it, and never a config value, path list, or key material (§13). It is written AFTER the pane spawned, success only, the same posture as `subshell.create`, and diff --git a/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md new file mode 100644 index 000000000..7da8b0119 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md @@ -0,0 +1,202 @@ +# SSH Port Forwards + +Status: design, for review. Dated 2026-10-15. +Branch: `feat/ssh-port-forwards`, cut from `origin/main`. This deliberately amends a +written non-goal: the relay spec (`2026-10-08-ssh-agent-relay-design.md` §1) listed +port forwarding out of scope, and the launcher refused it at three layers. The prior +specs stay as the record of why; this one records what changed and why it is sound. + +## 1. Problem and goal + +An SSH pane gives the user a shell on the destination D, dialed from the connecting +machine B. Plenty of services sit behind D: a database on D's private network, an +admin API, a staging box only D can see. Today reaching them means hand-running +tunnel commands inside the pane, and nothing in Subshell knows they exist. + +This feature makes port forwarding a first-class part of an SSH connection. At +launch the user may declare forward rows, and the connection carries them for its +lifetime. All three OpenSSH kinds ship: local (`-L`), dynamic/SOCKS (`-D`), and +remote (`-R`). SSH presets can store rows, so "this host always forwards the DB" +is a preset, not a ritual. Forwards bind to loopback by default; letting them bind +wider is a per-machine owner decision. + +## 2. What this changes, stated precisely + +The refusal this feature inverts is real and was reasoned, so the inversion has to +name what exactly stays and what goes. + +What goes: a config file the connecting account holds can no longer be the reason +nothing forwards. What stays: **a config file can never be the reason something +does.** The resolver keeps refusing `LocalForward`/`RemoteForward`/`DynamicForward` +found by `ssh -G`, by name, unchanged and pinned by test; the frozen snapshot keeps +`forwards` and `tunnels` as literal-null forbidden members; `Tunnel` (the tun +device) stays `no`, and `ForwardAgent`/`ForwardX11` toward D stay `no`. The M1 +argument, "missing a forward would be the silent-widening defect," was always about +*ambient* directives the user never consciously approved. What ships here is the +opposite kind of thing: explicit user-declared rows, visible in the launch form, +saved in presets, audited at launch. + +The widening is bounded by what the connection already grants. An approved `-L` or +`-D` gives B less than the shell on D that this very connection hands the user (from +inside the pane you can `socat` the same path by hand). An approved `-R` asks D to +bind a port, and D's sshd decides under `GatewayPorts`/`PermitRemoteOpen` exactly as +it would for any client; we relay the request and own the asking, nothing more. The +accounting goes into `docs/security.md` §SSH surfaces as a paragraph, not a patch-on- +a-refusal: config-derived forwards are still refused by name, and the new rows enter +through a different door. + +## 3. Terminology + +B, D, J keep the ssh-anywhere spec's meanings (connecting machine, destination, jump +hop). A **forward row** is one declared tunnel: a `kind`, a **listen** end (`bindHost` ++ `listenPort`, bound by B for local/dynamic, by D for remote), and for local/remote +a **target** (`targetHost` + `targetPort`, reached through D for local, through B for +remote). Dynamic rows have no target: the SSH channel decides destinations at runtime, +which is what makes a SOCKS proxy useful and is exactly the reach a shell on D already +has. + +## 4. Row grammar + +New protocol module `packages/subshell-protocol/src/ssh-forwards.ts`, pure and +Metro-safe like its neighbors: + +- `SshForwardWire`: `kind` (`local` | `remote` | `dynamic`), `listenPort` (integer + 1..65535, always explicit; no ephemeral/auto ports), optional `bindHost` + (canonical host/IPv4/IPv6, bracketed IPv6 kept, option-like leading `-` refused + the way the snapshot grammar refuses it), and `targetHost`/`targetPort` required + for local/remote and forbidden for dynamic. +- `parseSshForwardRows()`: the closed-shape rebuild discipline of + `parseSshConnectionSnapshot`. Any bad row refuses the whole set (there is no + "drop the bad one" in an approval list). Cap at 16 rows. Duplicate listen ends + refused: same `bindHost` (canonically compared) and `listenPort` twice is a bug, + not a configuration. + +Rows ride the pane's ssh launch payload **beside** the snapshot, never inside it. +The snapshot is the resolver's output and its grammar says config-derived forwards +do not survive it; approved rows are user input from a different door, and keeping +the channels distinct is what lets the old refusal stay honest. + +## 5. Rendering + +`renderSshConfigContents` grows a third argument, the approved row list (the +relay option stays where it is). Rendering is the plane's job end to end - the +launch frame still carries only `{ configPath, fileContent }` - so no wire +grammar changes and no agent update is required: the rows shape the bytes the +node already writes verbatim. When rows are present the policy block **drops +its `ClearAllForwardings yes` line** and a second block appears: `Host `, carrying `LocalForward` / `RemoteForward` / `DynamicForward` +lines composed only from re-parsed rows. Omitted bind hosts render explicitly as +`127.0.0.1:port` for local/dynamic; a remote row with no bind host rides D's own +policy spelling. Without rows, the file is byte-identical to what renders today, +clear included. + +That omission is measured, not assumed (OpenSSH 10.2p1, real sshd, observable +listener): `ClearAllForwardings` applies at the END of the whole config load, not +where it is written, so forwardings survive it neither from a later block nor from +the command line. Placement "after the clear" was the first design and the probe +buried it. What makes omitting the clear honest is where forwardings can otherwise +come from on this connection: `-F` replaces the user file outright, so the only +channels are our rendered lines and the root-owned system file - the same +"inside the OS trust boundary, root's to decide" class the renderer's header +already names. The connecting account's own config never reaches a Subshell +launch, which is what the clear was really guarding. + +The exact-Host block still matters for the other reason: ProxyJump children re-read +the `-F` file matching their own host, so they inherit the `Host *` policy (without +the now-absent clear, which they never needed for confinement: `ForwardAgent`/ +`ForwardX11 no` and friends are first-obtained keywords) and never match the final +host's pattern. A forward cannot leak onto a hop's connection. One grammar corner: +a launch whose ProxyJump names the destination host itself is refused with forwards +(the pattern would match both processes); the route refuses it by name. + +Byte stability is kept (same approval renders the same file), so the run's recorded +config byte-check and spec-22's sweep keep working. `ExitOnForwardFailure` stays at +ssh's default: a busy port prints ssh's own warning into the pane and the session +carries on. The forward is auxiliary to the terminal, not its precondition, and the +form says so. + +## 6. Trust and gates + +- **Loopback is the default and the floor.** An omitted `bindHost` binds ssh's own + loopback, same as typing `127.0.0.1`. Loopback binds on B are reachable by B's + other OS users; that is §0's accepted posture verbatim, the same one the node's + loopback dashboard already carries, and `security.md` says so in one sentence. +- **Wide binds are a machine decision.** A row naming a non-loopback bind host is + refused at launch unless the connecting node's new `sshWideForwards` setting is on: + owner-only (admin on `local`), cookie-only, default off, audited + `node.ssh_wide_forwards.update`, presented on the node's SSH card beside the + allow-SSH switch. Enforcement is plane-side only, deliberately: the node already + gates the whole SSH subsystem, the render trusts the approved rows the launch + carries, and a plane lying about the flag is the control-plane-compromise case §0 + accepts. A wide bind turns B into a router for whatever can reach that port; the + setting is the owner saying their network is trusted enough for that, per machine. +- **-R binds are the destination's policy**, not ours; the copy names it. +- **Auth modes are untouched.** A forward is an option on the one already-approved + connection. Password mode and the one-shot local-key path gain nothing new: rows + never multiply connections, and the local-key signer still signs exactly once. +- **No agent update is required, and none is silently assumed.** The rows shape + the rendered config, and the launch frame already carries the rendered bytes; + every agent that writes a Subshell ssh config writes a forwarding one. What + must never happen is a plane rendering a launch WITHOUT a row the user + approved: compose re-validates the row list at the same boundary it + re-validates the snapshot, and a grammar refusal throws rather than dropping + a row (silently stripping a forward is the silent-widening failure the + resolver's refusal was written against, seen from the other side). + +## 7. Surfaces + +- **Launch form**: a "Port forwards" group inside the existing `ssh-advanced-options` + disclosure. Per row: kind, listen port, optional bind host (only offered when the + selected machine reports wide binds on), target host and port (hidden for dynamic), + plus one detail-role line explaining what the kind does and where the port lives. + The forward dies with the pane, and the copy says so. +- **Presets**: rows persist beside destination/auth/key choices (JSON column on + `ssh_presets`), validated at save through the same parser, applied to the form like + every other preset choice. A preset with rows is still unaudited CRUD, per the + presets precedent; the launch itself audits. +- **Node overview**: the SSH card grows the wide-forwards toggle, owner-only. +- The pane itself gains no new live surface in this round; see §10. + +## 8. Audit + +`ssh.launch` metadata grows a bounded summary of the approved rows (kind, canonical +listen end, canonical target for local/remote). These are the same class of fact the +row already records (it names the destination), and they answer "which machine is +forwarding what" for a security review without a new event family. The wide-forwards +flip writes its own row in the nodes family. + +## 9. Tests + +- Protocol: accept/refuse census on `parseSshForwardRows` (both IPv6 spellings, port + bounds, dynamic target rules, kind closedness, cap, dupes). +- Resolver: the config-forward refusal pins stay green (this change must not touch + them; a test guards that a config `LocalForward` still fails resolution). +- Render: byte snapshots (policy-first ordering, the forward block, relay and + forwards coexisting, the exact-Host pattern not matching the hop's host). +- Plane: launch route gate matrix (loopback vs wide bind x node setting off/on), + duplicate refusal, jump-names-destination refusal, audit summary shape; + preset save validation and round-trip. +- Node: unchanged launch path (the frame still carries configPath + content + bytes; the path byte-check and the config dir sweep must still pass for a + launch that forwards). +- Web: draft round-trip, bind-host gating on the node flag, preset editor rows. +- E2E (shipped: `e2e/tests/26-ssh-forwarding.spec.ts`): extend spec 22's real-sshd + pattern: launch with a `-L` row and curl the bound loopback port for the fixture's + bytes; a `-D` leg through `curl --socks5-hostname`; a `-R` loopback-bind leg; the + wide-bind refusal before the toggle and acceptance after. Assert the recorded config + carries the forward block under the exact-`Host` section and that the `Host *` + policy's `ClearAllForwardings` line - present in a rowless render - is GONE + (§5: rows approved means the clear dropped, so asserting its presence would + assert the opposite of what ships). + +## 10. Non-goals and follow-ups + +- Not managed-tunnel objects: no start/stop apart from the pane, no live tunnel + list, no health. If forwards outgrow the pane, that's a new spec. +- No ephemeral/auto ports (a row without a port has no honest audit story). +- No way to use a forwarded port from the browser or the desktop app's machine; + forwards terminate on B, where the ssh process lives. +- No pane-header "active forwards" display yet; the launch audit and the pane's ssh + config are the record. +- `ForwardAgent`, `ForwardX11`, and `Tunnel` toward D stay refused: this feature is + TCP port forwarding, and nothing here opens those doors. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 5f85f282f..1e80835cd 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -54,7 +54,7 @@ runs discovery against this fake), and no spec ever dials a real IdP. Every fixed port in `ports.ts` takes an env override (`E2E_PORT_BACKEND`, `E2E_PORT_FAKE_REGISTRY`, `E2E_PORT_FAKE_IDP`, `E2E_PORT_ONBOARDING`, -`E2E_PORT_SSH`, `E2E_PORT_CONNECT`, `E2E_PORT_RELAY`) with the committed +`E2E_PORT_SSH`, `E2E_PORT_CONNECT`, `E2E_PORT_RELAY`, `E2E_PORT_FORWARDS`) with the committed defaults unchanged, the escape hatch for two suites running side by side on one machine, which the defaults collide on. @@ -133,13 +133,21 @@ asserts recipient-filtered reads. ## SSH container coverage -`23-connect-flow.spec.ts` requires a running Docker-compatible daemon. -Testcontainers builds `fixtures/ssh-container/Dockerfile`, publishes sshd on a -random port, and owns container cleanup. Missing Docker fails setup rather -than silently skipping coverage. The container creates disposable developer -and operator accounts for real password login; no host accounts or SSH files -are changed. Host keys are generated under the test's temporary directory and -trusted explicitly by the connecting process. +`23-connect-flow.spec.ts` and `26-ssh-forwarding.spec.ts` require a running +Docker-compatible daemon. Testcontainers builds +`fixtures/ssh-container/Dockerfile`, publishes sshd on a random port, and owns +container cleanup. Missing Docker fails setup rather than silently skipping +coverage. The container creates disposable developer and operator accounts for +real password login; no host accounts or SSH files are changed. Host keys are +generated under the test's temporary directory and trusted explicitly by the +connecting process. The image also carries `curl`, `iproute2` and `darkhttpd`, +and its sshd runs `GatewayPorts clientspecified`: spec 26 reads each port +forward from the side that owns it, and proves each wide bind as an address +pair - a loopback-bound port does not answer a dial to its machine's +interface address, the wide one does, destination-side `-R` read from inside +the container. The dial never crosses from the container to the job's own +network: on CI the job is itself a container, and docker does not route +sibling containers across user-defined networks (measured 2026-10-16). The browser exercises key login, password prompts and retry, terminal I/O, view-only sharing, and saving named SSH presets for distinct usernames on one host. Password checks @@ -176,7 +184,7 @@ layout changes. Never point it at the live server. For the other documentation screenshots, run `bun e2e/capture-docs.ts` after building the SPA. It uses the same isolated stack with sample presets, prompts, a workspace, and real terminal sessions running a small temporary project. -No coding-agent responses are simulated. The script also refreshes the five +No coding-agent responses are simulated. The script also refreshes the six marketing-site WebP copies using `ffmpeg`, which must be installed. Review every capture, update intrinsic dimensions in the referring MDX and diff --git a/e2e/capture-docs.ts b/e2e/capture-docs.ts index 62fd77ef5..71e3dfc89 100644 --- a/e2e/capture-docs.ts +++ b/e2e/capture-docs.ts @@ -97,6 +97,24 @@ try { await dialog.getByRole("button", { name: "Close", exact: true }).click(); } + const enableSsh = await api.put("/api/nodes/local/ssh-enabled", { data: { on: true } }); + expect(enableSsh.ok(), await enableSsh.text()).toBe(true); + await page.goto("/new?kind=ssh"); + await expect(dialog.getByRole("button", { name: "SSH terminal", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await page.locator("#ssh-wizard-destination").fill("developer@build.example.com:22"); + await dialog.getByRole("button", { name: "Add port forward", exact: true }).click(); + await page.locator("#ssh-fwd-port").fill("8080"); + await page.locator("#ssh-fwd-target-host").fill("127.0.0.1"); + await page.locator("#ssh-fwd-target-port").fill("3000"); + await dialog.getByRole("button", { name: "Add port forward", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Remove this forward" })).toBeVisible(); + // Capture connection choices without opening a connection to the sample address. + await capture(page, "ssh-connection", dialog); + await close(); + await page.goto("/presets"); await expect(page.getByRole("button", { name: "Repository review", exact: true })).toBeVisible(); await capture(page, "preset-library", page.locator("main h1"), card.last()); @@ -188,6 +206,7 @@ try { "preset-library", "session-sharing", "node-enrollment", + "ssh-connection", ]) { const encoded = spawnSync("ffmpeg", [ "-v", diff --git a/e2e/fixtures/ssh-container/Dockerfile b/e2e/fixtures/ssh-container/Dockerfile index 45085d1d0..7e1957da2 100644 --- a/e2e/fixtures/ssh-container/Dockerfile +++ b/e2e/fixtures/ssh-container/Dockerfile @@ -1,4 +1,4 @@ FROM alpine:3.22.2 -RUN apk add --no-cache openssh-server bash && mkdir -p /run/sshd && adduser -D -s /bin/bash developer && adduser -D -s /bin/bash operator +RUN apk add --no-cache openssh-server bash curl iproute2 darkhttpd && mkdir -p /run/sshd && adduser -D -s /bin/bash developer && adduser -D -s /bin/bash operator EXPOSE 22 CMD ["/usr/sbin/sshd", "-D", "-e", "-f", "/etc/ssh/sshd_config"] diff --git a/e2e/fixtures/sshd-container.ts b/e2e/fixtures/sshd-container.ts index 1eccccde6..ce390591b 100644 --- a/e2e/fixtures/sshd-container.ts +++ b/e2e/fixtures/sshd-container.ts @@ -32,6 +32,11 @@ export async function startContainerSshFixture(root: string) { "PasswordAuthentication yes", "KbdInteractiveAuthentication no", "PubkeyAuthentication yes", + // The destination's own bind policy (spec 2026-10-15 §6): a remote + // forward that names an address may bind it; a blank bind still + // lands on loopback. `no` would flatten both to loopback and hide + // the distinction spec 26 exists to prove. + "GatewayPorts clientspecified", "UsePAM no", "StrictModes yes", "LogLevel VERBOSE", @@ -82,6 +87,8 @@ export async function startContainerSshFixture(root: string) { user, host, port, + /** Run a command INSIDE the destination (spec 26 reads its own forwarded ports from here). */ + exec: (cmd: string[]) => container.exec(cmd), password, hostKeyLine, trustedKeyPath, diff --git a/e2e/ports.ts b/e2e/ports.ts index eb0d20e4e..ea97e0905 100644 --- a/e2e/ports.ts +++ b/e2e/ports.ts @@ -67,6 +67,16 @@ export const PORTS = { * artifact dir the "Set up Subshell here" install fetches from. */ relay: envPort("E2E_PORT_RELAY", 3203), + /* + * `forwards` belongs to spec 26 (the port-forwards legs of spec 2026-10-15 + * §9): a FOURTH standalone ssh-tier backend for the same HOME-override + * reason as `ssh` and `connect`, and a fresh slot rather than a reuse - + * the ssh specs keep their instances alive across their own retries, so + * parallel ssh backends must never collide on one port. Its forwards are + * the spec's own scratch listeners, not fixed ports: the spec binds what + * `freePort()` names. + */ + forwards: envPort("E2E_PORT_FORWARDS", 3204), } as const; export const BASE_URL = `http://127.0.0.1:${PORTS.backend}`; diff --git a/e2e/tests/23-connect-flow.spec.ts b/e2e/tests/23-connect-flow.spec.ts index 9b17ef662..b14c40297 100644 --- a/e2e/tests/23-connect-flow.spec.ts +++ b/e2e/tests/23-connect-flow.spec.ts @@ -31,7 +31,13 @@ test.skip( /** The exact strings the page ships (components/connect/* + components/ssh-pane-labels.tsx). */ const SSH_VIEW_ONLY_COPY = "Only the owner can type in this SSH subshell."; -const MACHINE_OFF_REASON = "Outgoing SSH is disabled on this node."; +/** + * A gated-off machine's dropdown row (ruling 2026-10-16): short and gold. + * The server's full sentence ("Outgoing SSH is disabled on this node.") no + * longer fits a one-line row; the decision-point explanation is the notice + * this test asserts beside the field below. + */ +const MACHINE_OFF_ROW = "SSH not enabled"; /** The seeded control-plane host's display name (seed-local.ts): the picker reads names, not ids. */ const LOCAL_NODE_NAME = "Server"; /** `configGroupLabel(selectedNode.name)` for that row. */ @@ -301,7 +307,7 @@ test("gate off: the form explains enablement on Start", async ({ page }) => { await page.getByLabel("SSH destination", { exact: true }).fill(SSH_FIXTURE_ALIAS); await page.getByRole("combobox", { name: "Connect from", exact: true }).click(); const serverRow = page.getByRole("option", { name: /Server/ }); - await expect(serverRow).toContainText(MACHINE_OFF_REASON); + await expect(serverRow).toContainText(MACHINE_OFF_ROW); await serverRow.click(); await expect(page.getByRole("button", { name: "Enable SSH", exact: true })).toHaveCount(0); await expect(page.getByText("Starting this subshell will enable outgoing SSH on this node.")).toBeVisible(); diff --git a/e2e/tests/26-ssh-forwarding.spec.ts b/e2e/tests/26-ssh-forwarding.spec.ts new file mode 100644 index 000000000..f6bc7e801 --- /dev/null +++ b/e2e/tests/26-ssh-forwarding.spec.ts @@ -0,0 +1,636 @@ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { networkInterfaces, tmpdir } from "node:os"; +import path from "node:path"; +import { type APIRequestContext, expect, type PlaywrightWorkerArgs, test } from "@playwright/test"; +import { missingSshBins, SSH_FIXTURE_ALIAS } from "../fixtures/sshd"; +import { startContainerSshFixture } from "../fixtures/sshd-container"; +import { PORTS } from "../ports"; +import { shortTmuxBase } from "../stack"; + +/** + * The port-forwards legs of spec 2026-10-15 §9: a REAL sshd, a REAL launch, + * and listeners this spec curls. Everything unit-level already answers the + * question "does the rendered file contain the forward lines?" - including a + * real `ssh -G` parse. What only a bound port can answer is the fact the + * whole render design rests on: that with `ClearAllForwardings` dropped from + * the policy block, ssh really binds the approved listens and traffic really + * flows, and that a row nobody approved binds nothing. + * + * The destination is the TESTCONTAINERS sshd: B is this worker's own machine + * (or job container), D is a sibling container. Where a row's TARGET lives + * follows ssh's own semantics, and with two machines those ends finally + * differ: a `-L`/`-D` connection's far end opens at the DESTINATION, so the + * marker those two read comes from a tiny `darkhttpd` the spec starts inside + * the container; a `-R` connection's far end opens at the CLIENT, so that + * row's target is this spec's own in-process marker server. Each listener is + * read from the side that owns it: + * - `-L`: curl the loopback port on the connecting side and read the marker + * the destination's httpd answers with (if the clear survived, or the + * block never matched, the port stays unbound and curl fails), + * - `-D`: `curl --socks5-hostname` THROUGH the SOCKS listener fetches the + * same marker from the same httpd (a proxy that never bound refuses the + * connect), + * - `-R`: sshd opened this listener ON THE CONTAINER, so the read runs + * inside it (a `container.exec` curl) and the bytes come back through the + * tunnel from this spec's marker server. The fixture sshd runs + * `GatewayPorts clientspecified`, and a row that names no bind address + * still lands on loopback (spec §6) - a dial to the container's own + * interface address must draw a refusal on that port. + * + * Then the wide-bind gate as a door, not a copy: wide rows are refused + * (403 `SSH_FORWARD_BIND_DENIED`) before the node's setting exists, and after + * the owner flips it the launch is accepted, the rendered file spells both + * wide binds, and each escape is proved where a loopback bind cannot follow: + * the wide `-L` answers a dial to this machine's INTERFACE address (a socket + * bound to 127.0.0.1 does not exist at that address, whoever dials it), and + * the wide `-R` answers the same kind of dial INSIDE the destination container + * at the container's interface address. That dial from the worker, not from + * the container, is measured CI doctrine: the e2e job itself runs in a + * container, and sibling testcontainers do not route to the job's network. + * The audit row's bounded forwards summary is checked beside the bytes it + * describes. + * + * Like spec 23, the container legs need a Docker daemon: with none, beforeAll + * fails rather than quietly skipping. The binary gate is spec 22's: no ssh + * binaries, skip LOUDLY. + */ + +const ROOT = path.join(import.meta.dirname, "..", ".."); +const BACKEND_DIR = path.join(ROOT, "apps", "server", "api"); +const ORIGIN = `http://127.0.0.1:${PORTS.forwards}`; + +const READY_TIMEOUT = 60_000; +const SPAWN_TIMEOUT = 60_000; + +/** The marker the target server answers with; every leg expects exactly this. */ +const MARKER = "FORWARD-MARKER-OK\n"; + +/** + * Ports sshd binds INSIDE the destination container for the `-R` legs. They + * are not probed for freedom like the B-side ports are: the container's + * network namespace is freshly started for this spec and holds nothing but + * sshd and these forwards, so a clash would mean the fixture itself broke. + */ +const DEST_BLANK_R_PORT = 18801; +const DEST_WIDE_R_PORT = 18802; + +/** + * The container's own marker server, started below with `darkhttpd`. `-L` + * and `-D` rows open their far end ON THE DESTINATION, so this is the target + * they point at (loopback inside the container). + */ +const DEST_MARKER_PORT = 8080; + +// The client-side binaries only: the destination now runs in the container, +// so this host's own sshd is no longer something this spec needs to find. +const missing = missingSshBins().filter((bin) => bin !== "sshd"); +test.skip( + missing.length > 0 || spawnSync("curl", ["--version"], { encoding: "utf8" }).status !== 0, + `spec 26 needs real ssh binaries${missing.length > 0 ? ` (missing: ${missing.join(", ")})` : ""} and curl for the SOCKS leg`, +); + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** Polls `check` until true or the deadline; on timeout throws the label's current text. */ +async function pollUntil( + label: string | (() => string), + check: () => Promise | boolean, + timeoutMs = SPAWN_TIMEOUT, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await check()) return; + if (Date.now() > deadline) throw new Error(typeof label === "string" ? label : label()); + await sleep(500); + } +} + +/** A loopback ephemeral port, freed the instant it is named (the spec's own copy of the fixture's trick). */ +async function freePort(): Promise { + const srv = createServer(); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const { port } = srv.address() as { port: number }; + await new Promise((resolve) => srv.close(() => resolve())); + return port; +} + +/** GET as a yes/no bytes answer; connection refused (an unbound forward) is just "not yet". */ +async function fetchText(url: string): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + return res.ok ? await res.text() : null; + } catch { + return null; + } +} + +/** + * Fetch through a SOCKS5 listener (remote-resolving, the way `-D` is meant to + * be used). ASYNC on purpose: the marker server lives in this worker process, + * and a synchronous curl would park the event loop the server needs to + * answer - the forward would stall looking like a refusal to bind. + */ +async function socksFetch(proxyPort: number, url: string): Promise { + return await new Promise((resolve) => { + const child = spawn("curl", ["-s", "--max-time", "10", "--socks5-hostname", `127.0.0.1:${String(proxyPort)}`, url]); + let out = ""; + child.stdout.on("data", (chunk: Buffer) => { + out += chunk.toString(); + }); + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code === 0 ? out : null)); + }); +} + +/** + * This machine's first routable interface address - the address a wide `-L` + * bind is dialed AT. A socket bound to 127.0.0.1 simply does not exist there, + * so "answers at this address" is the bind-scope proof no matter who dials. + * The dialer is deliberately NOT the destination container: the CI e2e job + * runs in its own docker network, and sibling testcontainers cannot route to + * a container on a different user-defined network (measured on CI, 2026-10-16 + * - the container-to-job dial gets connection refused). The worker dialing + * its own interface address tests exactly the same socket fact from the same + * namespace, in both topologies. + */ +function connectingMachineAddress(): string { + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (!entry.internal && entry.address.split(".").length === 4) return entry.address; + } + } + throw new Error("no routable IPv4 interface to dial a wide-bound port at"); +} + +/** What ssh printed into the pane, plus what actually LISTENs, for a leg that timed out. */ +function forwardDiagnostics(inst: ForwardInstance, id: string, port: number): string { + const parts: string[] = []; + try { + parts.push(`pane tail: ${readFileSync(path.join(inst.dataDir, "subshells", `${id}.log`), "utf8").slice(-1500)}`); + } catch { + parts.push("pane: (no log)"); + } + // A host without iproute2 answers with no stdout at all; the diagnostics + // must not throw over the real failure's message it exists to carry. + const ss = spawnSync("ss", ["-ltn"], { encoding: "utf8" }); + parts.push( + `listeners on ${String(port)}: ${ + (ss.stdout ?? "") + .split("\n") + .filter((l) => l.includes(`:${String(port)}`)) + .join(" | ") || "none (or ss unavailable)" + }`, + ); + return parts.join("\n"); +} + +interface ForwardInstance { + child: ReturnType; + dir: string; + dataDir: string; + tmuxBase: string; +} + +/** Boots the spec's OWN backend on `PORTS.forwards`, HOME + agent socket in its env (spec 22's reason). */ +async function startForwardBackend(fixtureHome: string, sshAuthSock: string): Promise { + stopForwardBackend(); + const dir = mkdtempSync(path.join(tmpdir(), "subshell-e2e-fwd-")); + const dataDir = path.join(dir, "data"); + const tmuxBase = shortTmuxBase(); + mkdirSync(tmuxBase, { recursive: true }); + + const env: Record = { + ...process.env, + NODE_ENV: "development", + SUBSHELL_TEST_MODE: "false", + SUBSHELL_SERVER_CONFIG_DIR: dir, + SERVER_PORT: String(PORTS.forwards), + HOST: "127.0.0.1", + DATABASE_PATH: path.join(dir, "subshell.db"), + SUBSHELL_SERVER_DATA_DIR: dataDir, + APP_BASE_URL: ORIGIN, + BETTER_AUTH_SECRET: "e2e-secret-not-used-outside-tests-0000000000", + SUBSHELL_PLUGIN_REGISTRY_URL: `http://127.0.0.1:${PORTS.fakeRegistry}`, + SUBSHELL_RELEASE_URL: "", + TMUX_TMPDIR: tmuxBase, + HOME: fixtureHome, + SSH_AUTH_SOCK: sshAuthSock, + }; + delete env.TMUX; + delete env.TMUX_PANE; + + const child = spawn("bun", ["run", "src/index.ts"], { + cwd: BACKEND_DIR, + detached: true, + stdio: process.env.E2E_VERBOSE ? "inherit" : "ignore", + env, + }); + child.unref(); + const inst: ForwardInstance = { child, dir, dataDir, tmuxBase }; + instance = inst; + + const deadline = Date.now() + READY_TIMEOUT; + for (;;) { + try { + const res = await fetch(`${ORIGIN}/api/setup/status`); + if (res.ok) { + const body = (await res.json()) as { needsSetup: boolean }; + if (!body.needsSetup) throw new Error("[e2e] forwarding instance: port already set up (leaked run)"); + return inst; + } + } catch (err) { + if ((err as Error).message.includes("port already set up")) throw err as Error; + // Not listening yet. + } + if (Date.now() > deadline) throw new Error(`[e2e] forwarding instance did not become ready at ${ORIGIN}`); + await sleep(300); + } +} + +let instance: ForwardInstance | undefined; + +function stopForwardBackend(): void { + const inst = instance; + if (!inst) return; + instance = undefined; + try { + if (inst.child.pid) process.kill(-inst.child.pid, "SIGTERM"); + } catch { + // Already gone. + } + for (const uidDir of readdirSafe(inst.tmuxBase)) { + for (const socket of readdirSafe(path.join(inst.tmuxBase, uidDir))) { + try { + spawnSync("tmux", ["-S", path.join(inst.tmuxBase, uidDir, socket), "kill-server"]); + } catch { + // Best-effort per socket. + } + } + } + rmSync(inst.dir, { recursive: true, force: true }); + rmSync(path.dirname(inst.tmuxBase), { recursive: true, force: true }); +} + +function readdirSafe(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +const ADMIN = { name: "Fwd Admin", email: "fwd-admin@subshell.test", password: "e2e-fwd-admin-pass-1" } as const; + +function freshCtx(playwright: PlaywrightWorkerArgs["playwright"]): Promise { + return playwright.request.newContext({ baseURL: ORIGIN, storageState: { cookies: [], origins: [] } }); +} + +test.describe("ssh port forwards (spec 2026-10-15)", () => { + test.describe.configure({ mode: "serial" }); + + const leaks: string[] = []; + const specTeardown: (() => Promise | void)[] = []; + let fixture: Awaited> | undefined; + let fixtureRoot: string | undefined; + let agentPid: number | undefined; + let adminRef: APIRequestContext | undefined; + let targetPort = 0; + /** The container's own non-loopback address, read from inside it once at boot. */ + let destAddress = ""; + const livePaneIds: string[] = []; + + /** Curl run INSIDE the destination container: the reader for every port sshd bound there. */ + async function destFetch(url: string): Promise { + const fx = fixture; + if (!fx) throw new Error("beforeAll did not run"); + const res = await fx.exec(["sh", "-c", `curl -s --max-time 10 "${url}"`]); + return res.exitCode === 0 ? res.output : null; + } + + test.beforeAll(async ({ playwright }) => { + fixtureRoot = mkdtempSync(path.join(tmpdir(), "subshell-e2e-fwdtier-")); + fixture = await startContainerSshFixture(fixtureRoot); + + const agentOut = spawnSync("ssh-agent", ["-s"], { encoding: "utf8" }); + if (agentOut.status !== 0) throw new Error(`ssh-agent failed: ${agentOut.stderr}`); + const agentEnv = Object.fromEntries( + ["SSH_AUTH_SOCK", "SSH_AGENT_PID"].map((k) => [k, agentOut.stdout.match(new RegExp(`${k}=([^;]+)`))?.[1] ?? ""]), + ); + agentPid = Number(agentEnv.SSH_AGENT_PID); + if (!agentEnv.SSH_AUTH_SOCK || !Number.isInteger(agentPid) || agentPid <= 0) { + throw new Error(`ssh-agent output unparseable: ${agentOut.stdout.slice(0, 200)}`); + } + const add = spawnSync("ssh-add", [fixture.trustedKeyPath], { + env: { ...process.env, SSH_AUTH_SOCK: agentEnv.SSH_AUTH_SOCK }, + encoding: "utf8", + }); + if (add.status !== 0) throw new Error(`ssh-add failed: ${add.stderr}`); + + // The address the container itself answers on: destination-side dials + // aim there, and a loopback bind cannot be reached at it. + const addrProbe = await fixture.exec(["ip", "-4", "-o", "addr", "show", "dev", "eth0"]); + destAddress = addrProbe.output.match(/inet (\d+\.\d+\.\d+\.\d+)\//)?.[1] ?? ""; + if (destAddress === "") throw new Error(`container eth0 address unparseable: ${addrProbe.output}`); + + // The far end `-L`/`-D` open at: darkhttpd inside the container serving + // the same marker this spec's own server answers with. It runs because + // the forwarded connection's target host is resolved THERE, not on the + // connecting side - with B and D finally apart, that distinction is part + // of what the spec proves. The daemonizing `&` sits INSIDE a brace group: + // a trailing `&` would background the whole `&&` chain and make sh exit 0 + // whatever mkdir or printf does, so the exit-code guard below would + // check nothing. With the group, only the server is backgrounded and the + // guard sees a failed setup; the poll behind it still proves it answers. + const httpd = await fixture.exec([ + "sh", + "-c", + `mkdir -p /var/www && printf '${MARKER.trim()}\\n' > /var/www/index.html && { nohup darkhttpd /var/www --port ${String(DEST_MARKER_PORT)} >/dev/null 2>&1 & }`, + ]); + if (httpd.exitCode !== 0) throw new Error(`container marker httpd failed to start: ${httpd.output}`); + // Prove it answers BEFORE any leg depends on it: a silent background + // daemon failure would otherwise surface as a forward that "never + // answered" and send the next reader hunting for a bug in ssh. + await pollUntil( + "the container's own marker httpd never answered", + async () => (await destFetch(`http://127.0.0.1:${String(DEST_MARKER_PORT)}/`)) === MARKER, + 15_000, + ); + + await startForwardBackend(fixture.sshConfigHome, agentEnv.SSH_AUTH_SOCK); + adminRef = await freshCtx(playwright); + const signUp = await adminRef.post("/api/auth/sign-up/email", { data: ADMIN }); + expect(signUp.ok(), await signUp.text()).toBe(true); + const enable = await adminRef.put("/api/nodes/local/ssh-enabled", { data: { on: true } }); + expect(enable.ok(), await enable.text()).toBe(true); + // The marker server the `-R` rows point at: a `-R` connection's far end + // opens on the CLIENT side, so sshd's forwarded connection lands here. + // (The `-L`/`-D` rows point at the container's own httpd instead.) + targetPort = await freePort(); + const target = createServer((_req, res) => { + res.setHeader("content-type", "text/plain"); + res.end(MARKER); + }); + target.listen(targetPort, "127.0.0.1"); + specTeardown.push(() => new Promise((resolve) => target.close(() => resolve()))); + }); + + test.afterAll(async () => { + for (const [label, id] of [...livePaneIds].reverse().entries()) { + try { + if (adminRef) { + await adminRef.post(`/api/subshells/${id}/terminate`); + await adminRef.delete(`/api/subshells/${id}`); + } + } catch (err) { + leaks.push(`cleanup row ${label}: ${String(err)}`); + } + } + for (const close of specTeardown.reverse()) { + try { + await close(); + } catch (err) { + leaks.push(`teardown: ${String(err)}`); + } + } + try { + await adminRef?.dispose(); + } catch (err) { + leaks.push(`admin ctx: ${String(err)}`); + } + if (agentPid !== undefined) { + try { + process.kill(agentPid, "SIGTERM"); + } catch { + // Already gone. + } + } + try { + await fixture?.stop(); + } catch (err) { + leaks.push(`sshd fixture stop: ${String(err)}`); + } + try { + stopForwardBackend(); + } catch (err) { + leaks.push(`backend stop: ${String(err)}`); + } + if (fixtureRoot !== undefined) rmSync(fixtureRoot, { recursive: true, force: true }); + if (leaks.length > 0) console.error(`[26-ssh-forwarding] cleanup problems: ${leaks.join("; ")}`); + }); + + test("an approved -L, -D, and -R bind real listeners; the rendered config carries the block without the clear; the audit names the rows", async () => { + test.setTimeout(240_000); + const admin = adminRef; + if (!admin || !fixture) throw new Error("beforeAll did not run"); + const fx = fixture; // Local const: the poll closures below cannot narrow the outer `let`. + const inst = instance; + if (!inst) throw new Error("no backend instance"); + + const localListen = await freePort(); + const socksListen = await freePort(); + const launch = await admin.post("/api/ssh/launch", { + data: { + node: "local", + destination: SSH_FIXTURE_ALIAS, + name: "e2e-fwd-three-kinds", + forwards: [ + { + kind: "local", + listenPort: localListen, + bindHost: null, + targetHost: "127.0.0.1", + targetPort: DEST_MARKER_PORT, + }, + { kind: "dynamic", listenPort: socksListen, bindHost: null, targetHost: null, targetPort: null }, + { + kind: "remote", + listenPort: DEST_BLANK_R_PORT, + bindHost: null, + targetHost: "127.0.0.1", + targetPort, + }, + ], + }, + }); + expect(launch.status(), await launch.text()).toBe(201); + const subshellId = ((await launch.json()) as { subshell: { id: string } }).subshell.id; + livePaneIds.push(subshellId); + + await pollUntil( + `ssh pane ${subshellId} never reached running+alive`, + async () => { + const res = await admin.get(`/api/subshells/${subshellId}`); + if (!res.ok()) return false; + const view = (await res.json()) as { status: string; alive: boolean }; + return view.status === "running" && view.alive === true; + }, + READY_TIMEOUT, + ); + + // The rendered config: the exact-destination block spells all three + // lines, the `Host *` policy stands (Tunnel no etc.), and the clear line + // the rows made obsolete is GONE - the whole §5 argument in one file. + const configPath = path.join(inst.dataDir, "ssh", subshellId, "config"); + await pollUntil(`the rendered ssh config never appeared at ${configPath}`, () => existsSync(configPath)); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("Tunnel no"); + expect(config).not.toContain("ClearAllForwardings"); + expect(config).toContain(`LocalForward 127.0.0.1:${String(localListen)} 127.0.0.1:${String(DEST_MARKER_PORT)}`); + expect(config).toContain(`DynamicForward 127.0.0.1:${String(socksListen)}`); + expect(config).toContain(`RemoteForward ${String(DEST_BLANK_R_PORT)} 127.0.0.1:${String(targetPort)}`); + expect(config.indexOf("Host *")).toBeLessThan(config.indexOf("# User-declared port forwards")); + expect(config).toContain("# User-declared port forwards, approved at launch (spec 2026-10-15)."); + + // The sshd shell must be up before the legs read bytes: forwards bind + // only after authentication completes ("Starting session" as spec 22's + // truth source). + await pollUntil( + () => `sshd never reported a shell for ${subshellId}`, + () => /Starting session: shell/.test(readFileSync(fx.logPath, "utf8")), + READY_TIMEOUT, + ); + + // -L: the connecting side's listener answers with the target's bytes. + try { + await pollUntil( + () => `-L forward on 127.0.0.1:${String(localListen)} never answered`, + async () => (await fetchText(`http://127.0.0.1:${String(localListen)}/`)) === MARKER, + ); + } catch (err) { + throw new Error(`${String(err)}\n${forwardDiagnostics(inst, subshellId, localListen)}`); + } + // ...and the loopback-bound port does NOT exist at the machine's interface + // address: the same dial the wide bind answers in test 2 is refused here. + // Bind scope, shown as a pair on one machine, no routing assumptions. + expect(await fetchText(`http://${connectingMachineAddress()}:${String(localListen)}/`)).toBeNull(); + // -D: through the SOCKS listener; the chosen target resolves where a + // SOCKS-over-ssh proxy resolves it - on the destination - so it names + // the container's httpd. + await pollUntil( + () => `-D SOCKS listener on 127.0.0.1:${String(socksListen)} never proxied`, + async () => (await socksFetch(socksListen, `http://127.0.0.1:${String(DEST_MARKER_PORT)}/`)) === MARKER, + ); + // -R: sshd's listener ON THE CONTAINER; the bytes come back through the + // tunnel to this spec's marker server. Read from inside D, because D owns + // the port and this spec is no longer on D's loopback. + await pollUntil( + () => `-R forward on the destination never answered on ${String(DEST_BLANK_R_PORT)}`, + async () => (await destFetch(`http://127.0.0.1:${String(DEST_BLANK_R_PORT)}/`)) === MARKER, + ); + // The blank bind rides the destination's default, and under + // `GatewayPorts clientspecified` that default is loopback: the SAME port + // refuses a dial to the container's interface address. One machine + // answers, the other address does not - the bind really is narrow. + expect(await destFetch(`http://${destAddress}:${String(DEST_BLANK_R_PORT)}/`)).toBeNull(); + + // The audit row names what the pane opened, bounded and secret-free: + // kind + canonical listen per row, target for the two kinds that have one. + const audit = await admin.get("/api/audit?limit=50"); + expect(audit.ok(), await audit.text()).toBe(true); + const rows = (await audit.json()) as { action: string; metadata: unknown }[]; + const launchRow = rows.find((r) => r.action === "ssh.launch"); + expect(launchRow).toBeDefined(); + expect(launchRow?.metadata).toMatchObject({ + nodeId: "local", + forwards: [ + { kind: "local", listen: `127.0.0.1:${String(localListen)}`, target: `127.0.0.1:${String(DEST_MARKER_PORT)}` }, + { kind: "dynamic", listen: `127.0.0.1:${String(socksListen)}` }, + { kind: "remote", listen: String(DEST_BLANK_R_PORT), target: `127.0.0.1:${String(targetPort)}` }, + ], + }); + }); + + test("a wide bind is refused before the switch and bound after it", async () => { + test.setTimeout(240_000); + const admin = adminRef; + if (!admin || !fixture) throw new Error("beforeAll did not run"); + const fx = fixture; + const inst = instance; + if (!inst) throw new Error("no backend instance"); + + const wideListen = await freePort(); + const deniedRows = [ + { + kind: "local", + listenPort: wideListen, + bindHost: "0.0.0.0", + targetHost: "127.0.0.1", + targetPort: DEST_MARKER_PORT, + }, + ]; + + // Before: the refusal is pre-launch and names itself; nothing was dialed. + const denied = await admin.post("/api/ssh/launch", { + data: { node: "local", destination: SSH_FIXTURE_ALIAS, name: "e2e-fwd-wide-denied", forwards: deniedRows }, + }); + expect(denied.status()).toBe(403); + expect(((await denied.json()) as { code: string }).code).toBe("SSH_FORWARD_BIND_DENIED"); + + // The owner flips the machine's own switch. + const flipped = await admin.put("/api/nodes/local/ssh-wide-forwards", { data: { on: true } }); + expect(flipped.ok(), await flipped.text()).toBe(true); + expect(((await flipped.json()) as { sshWideForwards: boolean }).sshWideForwards).toBe(true); + + // After: the same row launches, plus the remote twin, so the wide escape + // gets proved on BOTH machines the feature touches. + const launch = await admin.post("/api/ssh/launch", { + data: { + node: "local", + destination: SSH_FIXTURE_ALIAS, + name: "e2e-fwd-wide-accepted", + forwards: [ + ...deniedRows, + { + kind: "remote", + listenPort: DEST_WIDE_R_PORT, + bindHost: "0.0.0.0", + targetHost: "127.0.0.1", + targetPort, + }, + ], + }, + }); + expect(launch.status(), await launch.text()).toBe(201); + const subshellId = ((await launch.json()) as { subshell: { id: string } }).subshell.id; + livePaneIds.push(subshellId); + + const configPath = path.join(inst.dataDir, "ssh", subshellId, "config"); + await pollUntil(`the wide-bind config never appeared at ${configPath}`, () => existsSync(configPath)); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain(`LocalForward 0.0.0.0:${String(wideListen)} 127.0.0.1:${String(DEST_MARKER_PORT)}`); + expect(config).toContain(`RemoteForward 0.0.0.0:${String(DEST_WIDE_R_PORT)} 127.0.0.1:${String(targetPort)}`); + + // This is the second ssh session against the container; the forwards bind + // only once its shell is up. + await pollUntil( + () => `the second sshd session never reached its shell for ${subshellId}`, + () => (readFileSync(fx.logPath, "utf8").match(/Starting session: shell/g) ?? []).length >= 2, + READY_TIMEOUT, + ); + + // The B side still answers its own loopback curl (unchanged from before + // the switch - the wide bind is an ADDITION, not a replacement). + await pollUntil( + () => `wide forward on ${String(wideListen)} never answered locally`, + async () => (await fetchText(`http://127.0.0.1:${String(wideListen)}/`)) === MARKER, + ); + // THE connecting-side escape: the wide-bound listener answers a dial to + // this machine's interface address, where a 127.0.0.1 bind does not exist + // at all. Pre-switch this port bound loopback only, and test 1 dials the + // loopback-bound -L port at this same address to show it refused. + await pollUntil( + () => + `the wide -L port never answered at the interface address ${connectingMachineAddress()}:${String(wideListen)}`, + async () => (await fetchText(`http://${connectingMachineAddress()}:${String(wideListen)}/`)) === MARKER, + ); + // And the wide -R escape on the destination's own side: the same kind of + // dial that refused the blank bind now answers, at the container's + // interface address. + await pollUntil( + () => `the wide -R port never answered at the container's interface address`, + async () => (await destFetch(`http://${destAddress}:${String(DEST_WIDE_R_PORT)}/`)) === MARKER, + ); + }); +}); diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 795defe0c..956582153 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -289,6 +289,14 @@ export enum BackendErrorCodes { * nowhere in it, and the interactive pane is untouched. */ SSH_UPGRADE_FAILED = "SSH_UPGRADE_FAILED", + /** + * A launch asked for a port forward that binds a non-loopback address on a + * machine whose owner has not allowed wide binds (spec 2026-10-15 §6): the + * forward would turn the machine into a router for whatever network reaches + * that port. Refused before anything composes; the remedy the message names + * is the machine's SSH setting, or a loopback bind. + */ + SSH_FORWARD_BIND_DENIED = "SSH_FORWARD_BIND_DENIED", } /** @@ -591,4 +599,8 @@ export const BackendErrorCodeDefs = { message: "Subshell setup didn't finish", statusCode: 409, }, + [BackendErrorCodes.SSH_FORWARD_BIND_DENIED]: { + message: "This machine only allows port forwards that bind its own loopback address", + statusCode: 403, + }, }; diff --git a/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx b/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx index eaea6ebfd..c1f4f021b 100644 --- a/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx +++ b/packages/node-admin/src/__tests__/node-allowed-dirs.test.tsx @@ -34,6 +34,7 @@ function node(over: Partial = {}): Node { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...over, }; diff --git a/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx b/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx index 9c91ec548..ac297b26b 100644 --- a/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx +++ b/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx @@ -41,6 +41,7 @@ function node(overrides: Partial = {}): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...overrides, }; diff --git a/packages/node-admin/src/__tests__/node-runtime-card.test.tsx b/packages/node-admin/src/__tests__/node-runtime-card.test.tsx index 03310153a..5db4ec467 100644 --- a/packages/node-admin/src/__tests__/node-runtime-card.test.tsx +++ b/packages/node-admin/src/__tests__/node-runtime-card.test.tsx @@ -34,6 +34,7 @@ const base: NodeDetail = { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }; diff --git a/packages/node-admin/src/__tests__/node-service-card.test.tsx b/packages/node-admin/src/__tests__/node-service-card.test.tsx index 1c983a6c5..7f70e0b35 100644 --- a/packages/node-admin/src/__tests__/node-service-card.test.tsx +++ b/packages/node-admin/src/__tests__/node-service-card.test.tsx @@ -49,6 +49,7 @@ const base: NodeDetail = { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, }; diff --git a/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx index 8bc8f846a..eaec6357b 100644 --- a/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx +++ b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx @@ -44,6 +44,7 @@ function node(over: Partial = {}): NodeDetail { maintenanceAt: null, maintenanceSource: null, sshEnabled: false, + sshWideForwards: false, held: null, ...over, }; diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts index 01aa16e32..d71a38dff 100644 --- a/packages/node-admin/src/types/node.ts +++ b/packages/node-admin/src/types/node.ts @@ -177,6 +177,14 @@ export interface Node { * machine visible but disabled, never hidden. */ sshEnabled: boolean; + /** + * Whether SSH port forwards on this machine may bind non-loopback + * addresses (spec 2026-10-15 §6). Off by default: forwards bind loopback + * only, and the launch refuses a wide bind while this is false. The + * launch form reads it to decide whether to offer the bind-host field at + * all. + */ + sshWideForwards: boolean; /** * This node is connected but REFUSED — held open for exactly one * command and offline for every other purpose (spec 2026-09-15 §5.3). diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts index 906f5d0a5..1da86f815 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-render.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import type { SshForwardWire } from "@internal/subshell-protocol"; import { buildAgentSocketPath, buildSshConfigPath, @@ -367,6 +368,136 @@ describe("renderSshConfigContents relay mode (spec 2026-10-08 §9)", () => { }); }); +describe("renderSshConfigContents port forwards (spec 2026-10-15)", () => { + const localRow: SshForwardWire = { + kind: "local", + listenPort: 5432, + bindHost: null, + targetHost: "db.internal", + targetPort: 5432, + }; + const wideRow: SshForwardWire = { + kind: "local", + listenPort: 8080, + bindHost: "10.0.0.5", + targetHost: "api.internal", + targetPort: 80, + }; + const remoteRow: SshForwardWire = { + kind: "remote", + listenPort: 9090, + bindHost: null, + targetHost: "node.internal", + targetPort: 3000, + }; + const dynamicRow: SshForwardWire = { + kind: "dynamic", + listenPort: 1080, + bindHost: null, + targetHost: null, + targetPort: null, + }; + + it("absent or empty rows render byte-identical to the rowless posture, clear included", () => { + const snap = baseSnapshot(); + expect(renderSshConfigContents(snap, undefined, [])).toBe(renderSshConfigContents(snap)); + expect(renderSshConfigContents(snap, undefined, [localRow])).not.toBe(renderSshConfigContents(snap)); + }); + + it("renders each kind as a line under the exact destination Host, loopback binds explicit", () => { + const out = renderSshConfigContents(baseSnapshot(), undefined, [localRow, wideRow, remoteRow, dynamicRow]); + expect(out).toContain("Host app-02.example.com"); + expect(out).toContain(" LocalForward 127.0.0.1:5432 db.internal:5432"); + expect(out).toContain(" LocalForward 10.0.0.5:8080 api.internal:80"); + // A remote row with no bind host stays bare: D's own GatewayPorts policy + // is the authority there, and inventing a name we did not approve would + // be the opposite lie of the one the renderer refuses elsewhere. + expect(out).toContain(" RemoteForward 9090 node.internal:3000"); + expect(out).toContain(" DynamicForward 127.0.0.1:1080"); + }); + + it("the row block comes AFTER the Host * policy section, under its own header", () => { + const out = renderSshConfigContents(baseSnapshot(), undefined, [localRow]); + const policy = out.indexOf("Host *"); + const block = out.indexOf("Host app-02.example.com"); + expect(policy).toBeGreaterThanOrEqual(0); + expect(block).toBeGreaterThan(policy); + // No LocalForward byte exists BEFORE the exact-Host header: the rows are + // under their own section, not smuggled into the policy one (the whole + // jump-child isolation argument rests on this shape). + expect(out.indexOf("LocalForward")).toBeGreaterThan(block); + }); + + it("drops ONLY the ClearAllForwardings line; every other confinement stands", () => { + const out = renderSshConfigContents(baseSnapshot(), undefined, [localRow]); + expect(out).not.toContain("ClearAllForwardings"); + for (const [key, value] of [ + ["ForwardAgent", "no"], + ["ForwardX11", "no"], + ["Tunnel", "no"], + ["PermitRemoteOpen", "none"], + ["PermitLocalCommand", "no"], + ["EscapeChar", "none"], + ["ControlMaster", "no"], + ] as [string, string][]) { + expect(out).toContain(` ${key} ${value}`); + } + }); + + it("coexists with relay mode: pinned trust bytes AND the row block", () => { + const out = renderSshConfigContents(baseSnapshot(), { hostPinPath: "/data/ssh/s1/known_hosts" }, [dynamicRow]); + expect(out).toContain(" StrictHostKeyChecking yes"); + expect(out).toContain(" UserKnownHostsFile /data/ssh/s1/known_hosts"); + expect(out).toContain(" DynamicForward 127.0.0.1:1080"); + expect(out).not.toContain("ClearAllForwardings"); + }); + + it("refuses the whole render on a malformed row (no partial forward list, ever)", () => { + expect(() => renderSshConfigContents(baseSnapshot(), undefined, [localRow, { kind: "tunnel" } as never])).toThrow( + /grammar re-validation/, + ); + }); + + it("refuses rows when a ProxyJump hop names the destination host (case-insensitively)", () => { + const snap = baseSnapshot({ + host: "dup.example.com", + proxyJumps: [{ host: "DUP.example.com", user: null, port: 22 }], + }); + expect(() => renderSshConfigContents(snap, undefined, [localRow])).toThrow(/ProxyJump names the destination/); + // Rowless, the same snapshot still renders: the guard belongs to forwards only. + expect(renderSshConfigContents(snap)).toContain("Host *"); + }); + + it.skipIf(!Bun.which("ssh"))("real OpenSSH parses a rendered forward config without complaint", async () => { + const root = tempRoot("ssh-render-forwards-"); + try { + const path = join(root, "config"); + writeFileSync( + path, + renderSshConfigContents( + baseSnapshot({ identityFiles: [], certificateFiles: [], knownHostsFiles: [] }), + undefined, + [localRow, wideRow, remoteRow, dynamicRow], + ), + ); + // -G evaluates the config for a host without connecting. The destination + // host's pass must exit clean; ssh would warn on a bad keyword or value. + const child = Bun.spawn([Bun.which("ssh") as string, "-G", "-F", path, "app-02.example.com"], { + stdout: "pipe", + stderr: "pipe", + }); + const stderr = await new Response(child.stderr).text(); + expect(await child.exited).toBe(0); + // OpenSSH 10.2 answers -G on a non-tty stdin with a pseudo-terminal + // NOTICE regardless of the config; the honest check is that no CONFIG + // complaint rides along (a bad keyword or value names itself). + expect(stderr).not.toMatch(/bad configuration|unknown|invalid|overlong/i); + } finally { + cleanup(root); + } + }); +}); + describe("buildSshConfigPath", () => { it("composes the derived path from the two facts each side already holds", () => { expect(buildSshConfigPath("/data", "s1")).toBe("/data/ssh/s1/config"); diff --git a/packages/pane-runtime/src/ssh/ssh-render.ts b/packages/pane-runtime/src/ssh/ssh-render.ts index 5a4939b25..f95c9f7ab 100644 --- a/packages/pane-runtime/src/ssh/ssh-render.ts +++ b/packages/pane-runtime/src/ssh/ssh-render.ts @@ -1,7 +1,9 @@ import { parseSshConnectionSnapshot, + parseSshForwardRows, SSH_PATH_MAX_CHARS, type SshConnectionSnapshotWire, + type SshForwardWire, type SshHopWire, } from "@internal/subshell-protocol"; @@ -75,6 +77,9 @@ const MANDATORY_POLICY: readonly [string, string][] = [ ["ForwardX11", "no"], ["ForwardX11Trusted", "no"], // no local/remote/dynamic forwards or tunnels, whatever any config said + // (the ONE conditional policy line: omitted when the launch carries + // approved forward rows, which this clear would wipe anyway - see the + // render function's doc for the measured OpenSSH fact) ["ClearAllForwardings", "yes"], ["Tunnel", "no"], ["PermitRemoteOpen", "none"], @@ -146,6 +151,28 @@ export interface SshRelayRenderOption { /** The two spellings of the ONE host-key-checking line: the M1 posture and the relay block. */ const HOST_KEY_CHECK: Record<"direct" | "relay", string> = { direct: "accept-new", relay: "yes" }; +/** The ssh_config keyword each approved forward kind renders to (the row's kind is grammar-closed, so this lookup is total). */ +const FORWARD_KEYWORD: Record = { + local: "LocalForward", + remote: "RemoteForward", + dynamic: "DynamicForward", +}; + +/** + * One approved forward as one config line, under the exact-destination `Host` + * block. The listen spelling carries no whitespace risk (the row grammar + * validates every host member), so no quoting seam is needed here; a null + * `bindHost` renders ssh's own loopback default EXPLICITLY for local/dynamic + * (127.0.0.1 is the name the audit and the reviewer both read), and stays + * spelled bare for remote, where D's `GatewayPorts` policy is the authority + * and adding a name we did not approve would be a lie of the opposite kind. + */ +function forwardLine(row: SshForwardWire): string { + const listen = row.bindHost === null ? (row.kind === "remote" ? "" : "127.0.0.1:") : `${row.bindHost}:`; + const head = `${FORWARD_KEYWORD[row.kind]} ${listen}${row.listenPort}`; + return ` ${row.kind === "dynamic" ? head : `${head} ${row.targetHost}:${row.targetPort}`}`; +} + /** * Render the runtime ssh_config FILE for one snapshot: the policy above, * every hop included, plus the snapshot's trust refs and identity refs under @@ -167,9 +194,46 @@ const HOST_KEY_CHECK: Record<"direct" | "relay", string> = { direct: "accept-new * B's `/etc/ssh/ssh_known_hosts` must not authenticate D either. Absent the * option (every M1 launch) the bytes are the accept-new posture * byte-for-byte, untouched: the direct branch never names the global file. + * + * User-declared port forwards (the third argument, spec 2026-10-15) change two + * bytes-worth of policy and nothing else: the `ClearAllForwardings yes` line + * is dropped, and an exact-destination `Host` block carrying the approved + * rows is appended. The drop is measured, not assumed (OpenSSH 10.2p1, real + * sshd, observable listener): `ClearAllForwardings` applies at the END of the + * whole config load, not where it is written, so forwardings survive it + * neither from a later block nor from the command line; keeping the line + * would silently undo the rows, and silently dropping a forward is precisely + * the lie the resolver's refusal-by-name exists to prevent. Omitting it is + * honest because the forward channels left are our own lines and the + * root-owned system file - `-F` replaces the user config outright, and the + * system file is the "root's to decide" class this file's header already + * names. The rows enter under the EXACT destination host because ProxyJump + * children re-read this file matching their own host: they get the `Host *` + * policy and never the row block, so a forward cannot leak onto a hop. + * Absent or empty rows, every byte is what renders today, clear included. */ -export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, relay?: SshRelayRenderOption): string { +export function renderSshConfigContents( + snapshot: SshConnectionSnapshotWire, + relay?: SshRelayRenderOption, + forwards?: SshForwardWire[], +): string { assertRenderable(snapshot); + // Rows are re-validated HERE, at the last station before bytes a machine + // writes: a refused row THROWS rather than dropping, because half an + // approved forward list is a lie about what the user approved (the same + // no-partial-approval discipline as the snapshot parse). + let rows: SshForwardWire[] = []; + if (forwards !== undefined) { + const parsed = parseSshForwardRows(forwards); + if (parsed === null) throw new Error("forward rows failed grammar re-validation"); + rows = parsed; + } + if (rows.length > 0 && snapshot.proxyJumps.some((h) => h.host.toLowerCase() === snapshot.host.toLowerCase())) { + // The exact-`Host` pattern would match the hop child too, handing a + // forward to the wrong connection. The route refuses this launch for the + // user first; this is the impossible-state guard beneath it. + throw new Error("port forwards cannot ride a launch whose ProxyJump names the destination host"); + } if (relay !== undefined) { // The same path discipline `sshOptionTokens` applies to the config path: // this value is interpolated into a file ssh reads verbatim, and a @@ -192,7 +256,12 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, rel ] : []), "Host *", - ...MANDATORY_POLICY.map(([key, value]) => ` ${key} ${key === "StrictHostKeyChecking" ? checking : value}`), + ...MANDATORY_POLICY.filter( + // The clear wipes approved rows too, measured (see the function doc): + // a launch with approved forwards drops the line, every other key of + // the policy stays, and so does the line for every rowless launch. + ([key]) => !(rows.length > 0 && key === "ClearAllForwardings"), + ).map(([key, value]) => ` ${key} ${key === "StrictHostKeyChecking" ? checking : value}`), // Relay: the pinned file is the ONE trust source - the snapshot's own // refs must not ride beside it, and neither may the system-wide DB // (`yes` consults GlobalKnownHostsFile too, so it is /dev/null'd; one @@ -209,6 +278,16 @@ export function renderSshConfigContents(snapshot: SshConnectionSnapshotWire, rel ...snapshot.identityFiles.map((f) => ` IdentityFile ${configPathValue(f)}`), ...snapshot.certificateFiles.map((f) => ` CertificateFile ${configPathValue(f)}`), ]), + // User-declared forwards (spec 2026-10-15): a separate exact-Host section, + // never inside the `Host *` policy - the pattern is the whole point, it + // marks the final connection and only the final one. + ...(rows.length > 0 + ? [ + "# User-declared port forwards, approved at launch (spec 2026-10-15).", + `Host ${snapshot.host}`, + ...rows.map(forwardLine), + ] + : []), "", ]; return lines.join("\n"); diff --git a/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts new file mode 100644 index 000000000..00303a51b --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-forwards.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "bun:test"; +import { + isLoopbackForwardBind, + isSshForwardKind, + parseSshForwardRows, + SSH_FORWARD_KINDS, + SSH_MAX_FORWARD_ROWS, + type SshForwardWire, +} from "../ssh-forwards.js"; + +/** + * The forward-row grammar (spec 2026-10-15 §4). These refusals are the + * shape half of the security story - the node re-runs this parser on every + * launch that names forwards, so what renders can never be wider than what + * this grammar can express. Policy (the wide-bind gate) is deliberately NOT + * tested here: this module answers "is this a row", never "is this row + * allowed on this machine". + */ + +function makeRow(overrides: Partial = {}): SshForwardWire { + return { + kind: "local", + listenPort: 5432, + bindHost: null, + targetHost: "db.internal", + targetPort: 5432, + ...overrides, + }; +} + +describe("parseSshForwardRows", () => { + it("round-trips one row of each kind", () => { + const rows: SshForwardWire[] = [ + makeRow(), + makeRow({ kind: "remote", listenPort: 9090, bindHost: null, targetHost: "node.internal", targetPort: 3000 }), + makeRow({ kind: "dynamic", listenPort: 1080, targetHost: null, targetPort: null }), + ]; + expect(parseSshForwardRows(structuredClone(rows))).toEqual(rows); + }); + + it("treats null and absent as no forwards, and the empty list round-trips", () => { + expect(parseSshForwardRows(null)).toEqual([]); + expect(parseSshForwardRows(undefined)).toEqual([]); + expect(parseSshForwardRows([])).toEqual([]); + }); + + it("accepts a bound row: loopback v4, bracketed v6, and a named host", () => { + for (const bindHost of ["127.0.0.1", "[::1]", "localhost", "10.0.0.5", "fwd.internal"]) { + expect(parseSshForwardRows([makeRow({ bindHost })])).toEqual([makeRow({ bindHost })]); + } + }); + + it("refuses a bare (unbracketed) IPv6 bind: the host grammar wants brackets, same as every other host field", () => { + expect(parseSshForwardRows([makeRow({ bindHost: "::1" })])).toBeNull(); + }); + + it("refuses an option-like leading dash on any host member", () => { + expect(parseSshForwardRows([makeRow({ bindHost: "-oProxyCommand=evil" })])).toBeNull(); + expect(parseSshForwardRows([makeRow({ targetHost: "-lroot" })])).toBeNull(); + }); + + it("refuses a target baked with a port (the grammar keeps port its own field)", () => { + expect(parseSshForwardRows([makeRow({ targetHost: "db.internal:5432" })])).toBeNull(); + }); + + it("refuses an unknown kind and a wrong-case kind (enums are enums)", () => { + expect(parseSshForwardRows([makeRow({ kind: "tunnel" as SshForwardWire["kind"] })])).toBeNull(); + expect(parseSshForwardRows([{ ...makeRow(), kind: "Local" }])).toBeNull(); + }); + + it("refuses ports outside 1..65535 and non-integers", () => { + for (const listenPort of [0, 65536, -1, 80.5, Number.NaN, "80"]) { + expect(parseSshForwardRows([makeRow({ listenPort: listenPort as unknown as number })])).toBeNull(); + } + for (const targetPort of [0, 65536, 1.5]) { + expect(parseSshForwardRows([makeRow({ targetPort: targetPort as unknown as number })])).toBeNull(); + } + }); + + it("refuses a dynamic row that carries a target (a contradiction, not a hint)", () => { + expect(parseSshForwardRows([makeRow({ kind: "dynamic", targetHost: "x.example" })])).toBeNull(); + expect(parseSshForwardRows([makeRow({ kind: "dynamic", targetPort: 80 })])).toBeNull(); + }); + + it("refuses local/remote rows missing a target member", () => { + expect(parseSshForwardRows([{ kind: "local", listenPort: 5432, bindHost: null }])).toBeNull(); + expect(parseSshForwardRows([{ kind: "remote", listenPort: 9090, bindHost: null, targetHost: "h" }])).toBeNull(); + }); + + it("refuses an absent or non-null bindHost member: presence is required, null is the default spelling", () => { + expect(parseSshForwardRows([{ kind: "local", listenPort: 5432, targetHost: "h", targetPort: 1 }])).toBeNull(); + expect(parseSshForwardRows([makeRow({ bindHost: "" })])).toBeNull(); + }); + + it("refuses a row with an unknown key rather than dropping it", () => { + expect(parseSshForwardRows([{ ...makeRow(), extra: true }])).toBeNull(); + }); + + it("refuses a duplicate listen end, case-insensitively on the name", () => { + expect(parseSshForwardRows([makeRow(), makeRow({ targetHost: "other.example" })])).toBeNull(); + expect(parseSshForwardRows([makeRow({ bindHost: "LOCALHOST" }), makeRow({ bindHost: "localhost" })])).toBeNull(); + }); + + it("accepts two rows on one port under different literal names (the bind race is ssh's warning, not this grammar's)", () => { + const rows = [makeRow({ bindHost: "localhost" }), makeRow({ bindHost: "127.0.0.1" })]; + expect(parseSshForwardRows(rows)).toEqual(rows); + }); + + it("refuses a null bind beside an explicit 127.0.0.1 on the same port: the two render one listener twice", () => { + expect( + parseSshForwardRows([makeRow(), makeRow({ bindHost: "127.0.0.1", targetHost: "other.example" })]), + ).toBeNull(); + expect( + parseSshForwardRows([ + makeRow({ kind: "dynamic", listenPort: 1080, targetHost: null, targetPort: null }), + makeRow({ kind: "dynamic", listenPort: 1080, bindHost: "127.0.0.1", targetHost: null, targetPort: null }), + ]), + ).toBeNull(); + }); + + it("accepts one port spelled on both sides: a B bind and a D bind are two listeners", () => { + const rows = [ + makeRow({ listenPort: 8080 }), + makeRow({ kind: "remote", listenPort: 8080, bindHost: "127.0.0.1", targetHost: "dev.local", targetPort: 3000 }), + ]; + expect(parseSshForwardRows(rows)).toEqual(rows); + }); + + it("keeps a remote null bind distinct from a named one (D's GatewayPorts spelling is its own listen spec)", () => { + const rows = [ + makeRow({ kind: "remote", listenPort: 9090, targetHost: "a.internal", targetPort: 1 }), + makeRow({ kind: "remote", listenPort: 9090, bindHost: "127.0.0.1", targetHost: "b.internal", targetPort: 2 }), + ]; + expect(parseSshForwardRows(rows)).toEqual(rows); + }); + + it("refuses a non-array list", () => { + expect(parseSshForwardRows({ 0: makeRow() })).toBeNull(); + expect(parseSshForwardRows("local")).toBeNull(); + expect(parseSshForwardRows([null, undefined, 42, []])).toBeNull(); + }); + + it("refuses over the cap rather than truncating", () => { + const over = Array.from({ length: SSH_MAX_FORWARD_ROWS + 1 }, (_, i) => makeRow({ listenPort: i + 1 })); + expect(parseSshForwardRows(over)).toBeNull(); + const atCap = over.slice(0, SSH_MAX_FORWARD_ROWS); + expect(parseSshForwardRows(atCap)).toEqual(atCap); + }); + + it("returns rebuilt rows, never the input objects (stray-array mutation cannot ride along)", () => { + const input = [makeRow()]; + const parsed = parseSshForwardRows(input) ?? []; + expect(parsed).toEqual(input); + expect(parsed[0]).not.toBe(input[0]); + }); +}); + +describe("isLoopbackForwardBind", () => { + const loopback = [null, "localhost", "LOCALHOST", "Localhost", "[::1]", "127.0.0.1", "127.9.9.9", "127.0.0.0"]; + for (const bindHost of loopback) { + it(`confines ${bindHost ?? "(omitted)"}`, () => { + expect(isLoopbackForwardBind(makeRow({ bindHost }))).toBe(true); + }); + } + const wide = [ + "0.0.0.0", + "::", + "[::]", + "10.0.0.5", + "example.com", + "[::ffff:127.0.0.1]", + "mybox.internal", + // Not a bindable quad: classifying it loopback would skip the wide gate + // on a name ssh simply refuses to bind. + "127.999.999.999", + "127", + ]; + for (const bindHost of wide) { + it(`names ${bindHost} wide (fails closed: the test is the written name, not a DNS answer)`, () => { + expect(isLoopbackForwardBind(makeRow({ bindHost }))).toBe(false); + }); + } +}); + +describe("SSH_FORWARD_KINDS / isSshForwardKind", () => { + it("the runtime list matches the type census", () => { + expect([...SSH_FORWARD_KINDS].sort()).toEqual(["dynamic", "local", "remote"]); + }); + it("accepts exactly the named kinds, case-sensitively", () => { + for (const kind of SSH_FORWARD_KINDS) expect(isSshForwardKind(kind)).toBe(true); + expect(isSshForwardKind("LOCAL")).toBe(false); + expect(isSshForwardKind(undefined)).toBe(false); + }); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index a8f058ab8..bbbe27d74 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -249,6 +249,8 @@ export { type ViewerCapacity, } from "./shared-geometry.js"; export { + isSshHostName, + isSshPort, parseSshConnectionSnapshot, SSH_FORBIDDEN_SNAPSHOT_FIELDS, type SshConnectionSnapshotWire, @@ -260,6 +262,16 @@ export { SSH_ERROR_DESCRIPTIONS, type SshErrorCode, } from "./ssh-errors.js"; +export { + isLoopbackForwardBind, + isSshForwardKind, + parseSshForwardRows, + SSH_FORWARD_KINDS, + SSH_FORWARD_LIST_REFUSAL, + SSH_MAX_FORWARD_ROWS, + type SshForwardKind, + type SshForwardWire, +} from "./ssh-forwards.js"; export { isSshFingerprint, isSshFingerprints, diff --git a/packages/subshell-protocol/src/ssh-config.ts b/packages/subshell-protocol/src/ssh-config.ts index 4ce8a511b..9710af426 100644 --- a/packages/subshell-protocol/src/ssh-config.ts +++ b/packages/subshell-protocol/src/ssh-config.ts @@ -163,8 +163,12 @@ function isWireName(value: unknown): value is string { ); } -/** A destination hostname (or bracketed IPv6), by grammar not by hygiene: see {@link HOST_RE}. */ -function isHostName(value: unknown): value is string { +/** + * A destination hostname (or bracketed IPv6), by grammar not by hygiene: see {@link HOST_RE}. + * Exported because the forward-row grammar validates host-valued fields under + * exactly the same rule; one definition, not two that can drift. + */ +export function isSshHostName(value: unknown): value is string { return isStr(value) && value.length <= SSH_NAME_MAX_CHARS && HOST_RE.test(value); } @@ -178,8 +182,11 @@ function isAbsPosixPath(value: unknown): value is string { return isStr(value) && value.startsWith("/") && value.length <= SSH_PATH_MAX_CHARS && !/\p{Cc}/u.test(value); } -/** A resolved port: a positive integer inside the legal range. */ -function isPort(value: unknown): value is number { +/** + * A resolved port: a positive integer inside the legal range. Exported for the + * forward-row grammar, which bounds listen and target ports by the same rule. + */ +export function isSshPort(value: unknown): value is number { return isInt(value) && (value as number) >= 1 && (value as number) <= 65535; } @@ -189,7 +196,7 @@ function isPathListWithin(value: unknown, cap: number): value is string[] { } function isHop(value: unknown): value is SshHopWire { - if (!isRecord(value) || !isHostName(value.host) || !isPort(value.port)) return false; + if (!isRecord(value) || !isSshHostName(value.host) || !isSshPort(value.port)) return false; if (!("user" in value)) return false; return value.user === null || isUserName(value.user); } @@ -213,7 +220,7 @@ export function parseSshConnectionSnapshot(value: unknown): SshConnectionSnapsho for (const field of SSH_FORBIDDEN_SNAPSHOT_FIELDS) { if (value[field] != null) return null; } - if (!isWireName(value.alias) || !isHostName(value.host) || !isPort(value.port)) return null; + if (!isWireName(value.alias) || !isSshHostName(value.host) || !isSshPort(value.port)) return null; if (!("user" in value) || !(value.user === null || isUserName(value.user))) return null; if (!isPathListWithin(value.identityFiles, SSH_MAX_IDENTITY_REFS)) return null; if (!isPathListWithin(value.certificateFiles, SSH_MAX_CERT_REFS)) return null; diff --git a/packages/subshell-protocol/src/ssh-forwards.ts b/packages/subshell-protocol/src/ssh-forwards.ts new file mode 100644 index 000000000..7fad3c6fe --- /dev/null +++ b/packages/subshell-protocol/src/ssh-forwards.ts @@ -0,0 +1,183 @@ +/** + * The user-declared port-forward row (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md). + * + * Read the sibling `ssh-config.ts` first, because the two grammars draw the + * line the security posture depends on: the SNAPSHOT's forbidden `forwards` + * member stays literal null, so a forward found in the connecting account's + * own ssh config still fails resolution by name. What lives HERE is the other + * door - a row the human typed into the launch form (or a preset saved from + * it), approved on its face, rendered into the per-connection config's exact + * destination block. Same rebuild discipline as `parseSshConnectionSnapshot`: + * any bad row refuses the whole list, because half a list of approved + * forwards is a lie about what the user approved. + * + * Shape is the grammar's job; policy is the plane's. `bindHost` may name ANY + * canonical host here, including `0.0.0.0`: whether a wide bind is ALLOWED is + * the connecting node's `sshWideForwards` decision, checked at the launch + * route against the machine's setting, never here. This module only knows + * what a row IS, so the node can re-validate the shape it was handed without + * holding the plane's policy. + * + * Imports no `node:` builtin; Metro-safe barrel like its neighbors. + */ + +import { isRecord, isStr } from "./guards.js"; +import { isSshHostName, isSshPort } from "./ssh-config.js"; + +/** The three OpenSSH forward kinds: `-L`, `-R`, `-D`. */ +export const SSH_FORWARD_KINDS = ["local", "remote", "dynamic"] as const; + +/** One member of {@link SSH_FORWARD_KINDS}. */ +export type SshForwardKind = (typeof SSH_FORWARD_KINDS)[number]; + +/** True when `value` is one of the named kinds (case-sensitive: the wire spells enums, never prose). */ +export function isSshForwardKind(value: unknown): value is SshForwardKind { + return isStr(value) && (SSH_FORWARD_KINDS as readonly string[]).includes(value); +} + +/** + * One approved forward. The listen end is where the port binds: on the + * connecting machine B for `local`/`dynamic`, on the destination D for + * `remote`. The target is what traffic reaches: a host D can see for + * `local`, a host B can see for `remote`, nothing at all for `dynamic` + * (the SOCKS channel picks its own destinations at runtime, which is the + * whole feature and exactly the reach a shell on D already grants). + * + * A `type`, not an interface: rows ride inside JSON command members, and a + * type has an implicit index signature the command builders rely on. + */ +export type SshForwardWire = { + /** Which way traffic flows; see {@link SshForwardKind}. */ + kind: SshForwardKind; + /** Listen port, 1..65535. Always explicit: an ephemeral/auto port has no honest audit story (spec §10). */ + listenPort: number; + /** + * Listen bind host; null means "the stack's loopback default" (rendered + * explicitly for local/dynamic; for remote, D's own `GatewayPorts` policy + * decides, which is D's to make - spec §6). Grammar-valid host otherwise; + * the WIDE question is the plane's gate, not this parser's. + */ + bindHost: string | null; + /** Target host; required for local/remote, null only for dynamic. */ + targetHost: string | null; + /** Target port; follows the same kind rule. */ + targetPort: number | null; +}; + +/** + * Rows one connection may carry. Generous for a real dev host (a database, a + * cache, an admin API, and room to spare) and small enough that the audit + * summary and the rendered block stay reviewable. Over the cap is a refusal, + * never a truncation. + */ +export const SSH_MAX_FORWARD_ROWS = 16; + +/** + * The sentence every station says when {@link parseSshForwardRows} refuses a + * list. Shared so the launch's 400 and the preset save's 400 cannot drift; + * the web form mirrors the same rules in its own per-field copy. + */ +export const SSH_FORWARD_LIST_REFUSAL = + "That port-forward list isn't valid. Each forward needs a kind (local, remote, or dynamic), a listen port from 1 to 65535, and a target host and port except for dynamic. Two forwards can't bind the same port on the same machine."; + +/** The keys one row may spell; anything else means the caller hand-built a shape this grammar does not have. */ +const FORWARD_ROW_KEYS: readonly (keyof SshForwardWire)[] = [ + "kind", + "listenPort", + "bindHost", + "targetHost", + "targetPort", +]; + +/** + * Validate and rebuild one row. Returns the narrowed row or null; the caller + * never sees the input object (rebuilt output, stray keys die with the input, + * same discipline as the snapshot parser). + */ +function parseSshForwardRow(value: unknown): SshForwardWire | null { + if (!isRecord(value)) return null; + for (const key of Object.keys(value)) { + if (!(FORWARD_ROW_KEYS as readonly string[]).includes(key)) return null; + } + if (!isSshForwardKind(value.kind) || !isSshPort(value.listenPort)) return null; + if (!("bindHost" in value)) return null; + if (value.bindHost !== null && !isSshHostName(value.bindHost)) return null; + const needsTarget = value.kind === "local" || value.kind === "remote"; + if (needsTarget) { + if (!("targetHost" in value) || !isSshHostName(value.targetHost)) return null; + if (!("targetPort" in value) || !isSshPort(value.targetPort)) return null; + } else { + // A dynamic row carrying a target is a contradiction, not a hint: refuse. + if ((value.targetHost ?? null) !== null || (value.targetPort ?? null) !== null) return null; + } + return { + kind: value.kind, + listenPort: value.listenPort, + bindHost: value.bindHost === undefined ? null : (value.bindHost as string | null), + targetHost: needsTarget ? (value.targetHost as string) : null, + targetPort: needsTarget ? (value.targetPort as number) : null, + }; +} + +/** + * Validate a whole forward list: each row through {@link parseSshForwardRow}, + * then the LIST rules. A duplicate listen end refuses the list: two rows + * competing for one bind on one machine is a bug the user should hear about, + * not a race ssh silently half-wins. The listen end is keyed per SIDE: local + * and dynamic rows bind on the connecting machine, remote rows on the + * destination, so the same port spelled once on each side is two listeners, + * not a dupe. The cap is a refusal too, never a trim. + * + * @param value - candidate rows (parsed JSON; unknown provenance), or null/undefined for "no forwards" + * @returns the narrowed rows (possibly empty), or null when malformed or over cap + */ +export function parseSshForwardRows(value: unknown): SshForwardWire[] | null { + if (value === null || value === undefined) return []; + if (!Array.isArray(value) || value.length > SSH_MAX_FORWARD_ROWS) return null; + const rows: SshForwardWire[] = []; + const seen = new Set(); + for (const item of value) { + const row = parseSshForwardRow(item); + if (row === null) return null; + // The listen end is per SIDE: a local/dynamic row binds B, a remote row + // binds D, so one port on each side is two listeners. Casefold only the + // name: hostnames match case-insensitively, and "LOCALHOST" beside + // "localhost" is one bind, not two. A null bind on a local/dynamic row is + // canonically ssh's loopback, which is what the renderer spells as + // `127.0.0.1`, so it DUPLICATES a written `127.0.0.1` rather than riding + // beside it; two different literal names on one port ("localhost" vs + // "127.0.0.1") stay a configuration ssh reports itself. A remote null + // renders bare (D's GatewayPorts policy picks the address), which is a + // different listen spec from any named bind, so it keys empty. + const side = row.kind === "remote" ? "D" : "B"; + const bindKey = (row.kind === "remote" ? (row.bindHost ?? "") : (row.bindHost ?? "127.0.0.1")).toLowerCase(); + const key = `${side}|${bindKey}|${row.listenPort}`; + if (seen.has(key)) return null; + seen.add(key); + rows.push(row); + } + return rows; +} + +/** + * True when this row's listen end is confined to loopback: an omitted + * `bindHost` (which renders as loopback) or a named loopback address + * (`127.0.0.0/8`, `::1`, `localhost`). Exported for the plane's wide-bind + * gate: anything answering false requires the connecting machine's + * `sshWideForwards` setting. A hostname that merely HAPPENS to resolve to + * loopback ("mybox.internal") is not loopback here - the test is the name + * the user wrote, because that name is what the rendered config carries and + * what DNS can later point elsewhere. + */ +export function isLoopbackForwardBind(row: SshForwardWire): boolean { + if (row.bindHost === null) return true; + const host = row.bindHost.toLowerCase(); + // The grammar admits IPv6 only bracketed (HOST_RE), so `[::1]` is the + // spelling a row can actually carry. + if (host === "localhost" || host === "[::1]") return true; + // IPv4 127.0.0.0/8: dotted-quad with each octet 0-255 (the grammar's host + // shape admits other spellings, and a 127.999.999.999 is not a bindable + // address - classifying it "wide" fails closed, which is the safe side). + const OCTET = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"; + return new RegExp(`^127(\\.${OCTET}){3}$`).test(host); +}