Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
13846b6
docs(ssh): record the port-forwards design, amending the relay spec's…
theogravity Oct 15, 2026
a1e7118
feat(ssh): forward-row grammar in the protocol package
theogravity Oct 15, 2026
e91cba8
feat(ssh): render approved forward rows into the exact-destination co…
theogravity Oct 15, 2026
46e67db
feat(ssh): launch accepts forward rows; per-node wide-bind setting
theogravity Oct 15, 2026
65a13ad
feat(ssh): SSH presets store declared port forwards, validated at save
theogravity Oct 15, 2026
63f49a4
feat(ssh): port-forward rows in the launch form, presets, and the nod…
theogravity Oct 15, 2026
0e22197
fix(ssh): move the backup era strip set with the migration pin
theogravity Oct 15, 2026
a4dd356
docs(ssh): port forwards in the guides, the security accounting, and …
theogravity Oct 15, 2026
bd9bde2
fix(ssh): canonical duplicate-bind check, per-row kind help, and the …
theogravity Oct 16, 2026
ad2c295
refactor(ssh): per-side duplicate binds, one refusal sentence, sharpe…
theogravity Oct 16, 2026
c73aaf5
docs(ssh): unpack the §9 test bullet after review
theogravity Oct 16, 2026
b3ef68b
feat(ssh): radio the forward kind and split its validation tone gold
theogravity Oct 16, 2026
2567b99
feat(ssh): nest the forward fields under the chosen kind, like Authen…
theogravity Oct 16, 2026
2a593d6
feat(ssh): one-line route diagrams under each kind caption
theogravity Oct 16, 2026
6d7143e
feat(ssh): compose one forward at a time, then list what's added
theogravity Oct 16, 2026
146e8d0
feat(ssh): the forward form waits for the press
theogravity Oct 16, 2026
c572d9a
feat(ssh): the declared forwards read like the dialog's other lists
theogravity Oct 16, 2026
23ce0aa
feat(ssh): drop the loopback note from the forward fields
theogravity Oct 16, 2026
6680473
feat(ssh): say what the wide-forwards switch refuses
theogravity Oct 16, 2026
8c2862f
feat(ssh): state the loopback rule in the forward section
theogravity Oct 16, 2026
287d546
feat(ssh): name the switch the loopback rule waits on
theogravity Oct 16, 2026
f275b6a
feat(ssh): say what flipping the wide-forwards switch does
theogravity Oct 16, 2026
03c6602
feat(ssh): wear the gold the owner-only rule earns
theogravity Oct 16, 2026
2ba938f
feat(ssh): the pane header lists where it goes and what it opened
theogravity Oct 16, 2026
7ce1bf7
fix(sidebar): let a machine's plate wrap its cells
theogravity Oct 16, 2026
ea481bd
test(server): give the cross-subshell child its own config home
theogravity Oct 16, 2026
ace72fd
feat(ssh): the pane tooltip names the machine it connects from
theogravity Oct 16, 2026
8efff9a
fix(ssh): answer the review round on the forward surfaces
theogravity Oct 16, 2026
c99109c
fix(ssh): answer the second review round
theogravity Oct 16, 2026
0c86fc2
docs: showcase SSH forwarding and refresh sharing controls
theogravity Oct 16, 2026
d935cb3
test(e2e): port forwards get a real second machine
theogravity Oct 16, 2026
4eb9e12
fix(web): pickers explain short, inside the field's own width
theogravity Oct 16, 2026
9710be2
fix(web): the split button drops its border like its header neighbours
theogravity Oct 16, 2026
1e22166
fix(e2e): answer the CI run and the review round
theogravity Oct 16, 2026
6b5a439
docs(e2e): the container's address is a dial target, not a client end
theogravity Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/document-ssh-port-forwards.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/picker-rows-fit-their-field.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/sharing-permission-selectors.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/ssh-forwarding-guide-screenshot.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/ssh-port-forwards.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/website-ssh-sharing-screenshots.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions .claude/rules/security-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions apps/docs/content/docs/guides/sharing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

<figure className="docs-screenshot">
<ImageZoom src="/screenshots/session-sharing.png" alt="Sharing dialog with Everyone granted View access and separate View + edit controls." width="1072" height="768" loading="lazy" decoding="async" unoptimized />
<ImageZoom src="/screenshots/session-sharing.png" alt="Sharing dialog with Everyone granted View access, permission dropdowns, and a searchable recipient picker." width="1072" height="760" loading="lazy" decoding="async" unoptimized />
<figcaption>Select an access level for each recipient, then save the grants. Select the image to enlarge it.</figcaption>
</figure>

Expand Down
58 changes: 56 additions & 2 deletions apps/docs/content/docs/guides/ssh.mdx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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.

<figure className="docs-screenshot">
<ImageZoom src="/screenshots/ssh-connection.png" alt="SSH connection form showing the connecting node, destination, authentication options, and a local forward from port 8080 to port 3000." width="1200" height="1964" loading="lazy" decoding="async" unoptimized />
<figcaption>Review the listed ports beneath the authentication options before connecting. Select the image to enlarge it.</figcaption>
</figure>

### 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.
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/nodes/manage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions apps/docs/content/docs/reference/ports.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
Binary file modified apps/docs/public/screenshots/session-sharing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/screenshots/ssh-connection.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/node/web/src/routes/__tests__/status-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading