Skip to content

feat(ssh): port forwards declared per connection - #388

Merged
theogravity merged 35 commits into
mainfrom
feat/ssh-port-forwards
Oct 16, 2026
Merged

theogravity merged 35 commits into
mainfrom
feat/ssh-port-forwards

Conversation

@theogravity

@theogravity theogravity commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What

SSH connections can carry user-declared port forwards: local (-L), remote (-R) and dynamic SOCKS (-D) rows, composed at launch or saved on an SSH preset. The form starts as one "Add port forward" button, composes one row at a time, and lists declared rows as plain dialog line items; the pane header's SSH tooltip names the machine it connects from, the destination, and the forwards for the pane's whole life.

The public SSH guide includes forwarding setup, examples, bind-address permissions, presets, and troubleshooting. The homepage shows a real SSH connection-panel screenshot. Sharing dialogs now use aligned permission dropdowns and a styled recipient picker that searches the existing roster by email or name; the website and guide use a refreshed sharing screenshot.

Why this inverts the old refusal

The subsystem refused forwarding at three layers because ambient config-derived forwards were never consciously approved. That refusal stands and is load-bearing: the resolver still refuses LocalForward/RemoteForward/DynamicForward found by ssh -G, and the snapshot keeps them as forbidden-null members. What lands here is the opposite class: rows the person declared at launch, visible everywhere the pane speaks, loopback-bound by default.

Decisions

  • Loopback binds by default; a non-loopback bind needs the new owner-only per-node Allow wide port forwards switch (migration 0055, audited node.ssh_wide_forwards.update, enforced plane-side before any dial).
  • Remote forwards bind on the destination under its own GatewayPorts/PermitRemoteOpen policy.
  • Approved rows enter ssh's config through an exact-Host block rendered after the policy block (a ClearAllForwardings yes line is dropped only when rows exist), so they survive jump-child re-reads with the every-hop policy intact.
  • Rows ride the pane's ssh column beside the frozen snapshot under declaredForwards (the snapshot's forwards: null marker keeps its meaning), are re-validated by the node before rendering, and the ssh.launch audit row carries a bounded summary.
  • Presets carry rows (migration 0056); a min-agent-protocol refusal names the machine to update rather than stripping rows silently.

Validation

Route envelope -> parseSshForwardRows grammar (ports, host hygiene, kind rules, per-side duplicate listens, cap 16) -> wide-bind gate -> renderer re-parse before bytes. The web form mirrors the same rules for inline sentences only; the server is the authority.

Security docs

docs/security.md gains the "Port forwards are declared, never ambient" paragraph and the audit/gate inventory; mirrored into the rules summary.

Tests

  • Protocol accept/refuse census, render byte-snapshots, launch route gate matrix, preset round-trips, node re-validation.

  • Web suites for the form, presets and the header tooltip.

  • E2E spec 26: real sshd, real launches; curl legs through bound -L/-D/-R ports, the wide-bind door refused pre-toggle and accepted after, recorded config bytes asserted.

  • Full: verify-types, lint (code/prose/design), bun run test, turbo build, full Playwright e2e (81 passed / 5 skipped).

  • Code review loop: three rounds, final pass zero findings at every level.

  • Documentation and website production builds, 167 documentation tests, export verification, and focused sharing/combobox tests pass. Browser checks cover recipient search and aligned permission controls at desktop and mobile widths.

Spec: docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md. Changesets: @internal/server minor, @internal/docs minor.

theogravity and others added 30 commits October 15, 2026 14:29
The backup engine refuses a snapshot carrying migrations it does not know, and
the era fixture strips the ledger tail that the pin names: advancing the pin
without moving the strip set leaves an intermediate state where the restored
snapshot keeps the newer DDL under a re-applied older name. One commit is the
fixture's own rule.

Co-Authored-By: Claude <noreply@anthropic.com>
…changesets

Co-Authored-By: Claude <noreply@anthropic.com>
…e2e forward legs

Round-two review of spec 2026-10-15: a null bind on a local or dynamic row
renders exactly what a written 127.0.0.1 renders, so the grammar's duplicate
rule now sees them as one bind (mirrored in the form); the loopback test now
bounds each 127/8 octet; each forward row carries its own kind explanation
where the port lives (spec §7); the preset schema's clear sentence names the
editor's real empty-list path, with a test for the branch; and the spec §9
E2E legs ship - real -L, -D, and -R listeners, the wide-bind door before and
after the switch, and the rendered file byte-checked without the clear.

Co-Authored-By: Claude <noreply@anthropic.com>
…r row copy

The three review nits worth shipping: the duplicate-listen rule keys the
listen end per SIDE (a port a local/dynamic row binds on the connecting
machine beside a remote row's port on the destination is two listeners), the
form keys ports numerically so a written-twice port is caught inline, the
launch and preset-save 400s share one sentence exported beside the grammar,
and the row help names the two ends instead of trusting a bare "this machine"
to read right on a remote-node launch. The spec's E2E test line now matches
what §5 ships: with approved rows, the Host * clear is gone.

Co-Authored-By: Claude <noreply@anthropic.com>
"the rowless Host * clear" named the render the clear belongs to in one
compressed word; the sentence now says which line disappears and when.

Co-Authored-By: Claude <noreply@anthropic.com>
The kind becomes a three-way radio with the chosen kind's explanation under
the row (the checked radio points AT it for screen readers), replacing the
one-paragraph description of all three. A row's validation sentence now
follows the ruling 2026-09-30 split: an empty required field shows gold
("nothing typed yet"), a typed value that fails the rule stays red.

Co-Authored-By: Claude <noreply@anthropic.com>
…tication

Drop the "Kind" heading: the radio group is labelled "Forward kind" for
assistive tech and stands on its own. The chosen option now carries its
caption and its port and target fields in the column beside the radio -
the same shape as the Authentication choice, where picking "Use another
node's keys" reveals the key controls beneath it.
Co-Authored-By: Claude <noreply@anthropic.com>
The prose names the ends; a mono line traces one request's path with the
example values from the field placeholders (5432, db.internal), so the
direction is legible at a glance without reading the sentence first.
Co-Authored-By: Claude <noreply@anthropic.com>
The forward fields were a growable list where an incomplete row sat in
the same column as complete ones and gated the whole launch. They're
now an editor plus a table: the person composes one forward under the
chosen kind, Add port forward lights up only when that row is valid,
doesn't re-bind a listed port, and the list has room, and what lands
shows in a table with its plain-spelled bind and target plus a Remove
button. Half-typed text never reaches the draft, so Save and Start are
no longer held by an abandoned row.

The diagram under each kind now traces the ports and host actually
typed, with the kind's example (FORWARD_EXAMPLE, one source for the
placeholders and the diagram) standing in for still-empty slots.
sshForwardRowConflict is the Add-side mirror of the server's duplicate
bind refusal, and the table keys rows on their listen identity because
the list can't hold a pair the server would refuse.
The port-forwards group opened with a full editor a person who wants
zero forwards still has to scroll past. It now follows the app's
add-a-thing lists: the section is one "Add port forward" button, the
press reveals the editor, committing adds the row and folds it away
(the kind sticks, the values clear), and Cancel backs out. Half-typed
text discarded by Cancel is still form state that never reached the
draft, so nothing else changes below the component.
The bordered Kind/Listen/Target table was the dashboard idiom dropped
into a form; inside a dialog the app shows declared data as line items
(the sharing dialog's grants are the worked example). Each forward is
now one line: kind in muted words, what ssh will listen on in mono, an
arrow to the target, Remove at the right. No card, no header row.
The standing sentence about loopback binds and the owner's wide-forwards
setting sat under every open editor and every list. The bind field's
absence already says loopback-only to anyone who looks for the field,
and the node's SSH settings card carries the full explanation where the
switch actually lives.
The off-state sentence described loopback in the abstract and left the
reader guessing whether turning it on moved every forward to 0.0.0.0.
It states the rule and the permission instead: forwards listen on
127.0.0.1 while off; on lets a forward name another address.
When the selected machine has not allowed wide binds, the section says
up front that its forwards listen on 127.0.0.1 only and where the owner
can change that, in the same words the node's SSH card uses. The bind
field's absence was the only signal, and it reads as a missing feature
rather than a rule.
The forward section's loopback sentence said the owner "can allow"
something without saying where. It now reads as one condition: until
"Allow wide port forwards" is on in the node's SSH settings, forwards
listen on 127.0.0.1 only. The setting's name carries the weight.
The on-state sentence leaned on 'wide binds' and a semicolon clause to
explain what a flip reaches. It now names the addresses a forward may
bind, who can use them, and closes with the one fact the switch always
raises: turning it off affects new subshells only.
The loopback sentence names a restriction only the node's owner can
lift, which is exactly what the design system keeps gold: routine
information stays muted, held-back capability does not.
The SSH tooltip on a pane said only that the connection was SSH. The
pane's ssh column now also stores the rows declared at launch (their own
key; the snapshot's forbidden-null forwards member keeps its meaning),
and the subshell view carries a bounded summary of the column: the
canonical user@host:port and the declared forwards, the same disclosure
the ssh.launch audit row already makes. The header tooltip reads them,
and a pane whose column predates the field or can't be parsed falls
back to the plain sentence rather than showing a half line.
The flat grid wrapped between plates, but each plate was an unwrappable
inline-flex row: one machine holding more panes than the rail fits on a
line formed a plate wider than the container, and its last cells ran
off the clipped edge. A plate is now a wrapping flex box, so an
over-long machine cluster continues on its own tinted lines.
Boot takes instance-state.lock from the server's config dir. The suite
spawned its backend on the real HOME, so with any dev server already
running the child died on the lock and the file failed red on a busy
machine while passing on an idle one. The child now gets the same temp
sandbox it already had for its database, which is how restore's helper
process and the status tests isolate the config dir too.
The header states where the connection goes and what it opened but not
where it runs from. The route's existing nodeLabel (the shared
nodeLabelFor ladder, so the display name never derived from the id)
rides the tooltip's top line; a page still loading the node list, or
answered without the row, shows no line rather than a bare id.
Six MINORs and three NITs from reviewing the redesign range:

- The loopback sentences (launch note and node-card off state) overclaimed
  for remote forwards: a blank-bind remote row is allowed with the switch
  off and binds under the destination's policy. Both now say so.
- Cancel folds the editor clean instead of hiding a stale half-typed row,
  shared with the commit path, and the state transition gets a test.
- sshPaneSummary composes its destination through sshCanonicalDestination,
  the function the audit row and recent-destinations key already use,
  instead of hand-rolling a second canonical spelling.
- The ssh column's db-types doc states what it actually holds now
  (localKey, relayFingerprints, declaredForwards) and that the bounded
  summary is derived from it for views.
- SshForwardViewSchema documents why it is its own declaration (the same
  inference-depth reason ssh-views states for its flattened copy).
- The nodeLabel prop doc matches what nodeLabelFor actually resolves;
  the conflict sentence is one constant shared by tooltip and alert;
  the quoted setting name wears the UI's curly quotes; FORWARD_EXAMPLE's
  unused dynamic target is emptied rather than left to be picked up.
MINOR: a stored forward list that fails its grammar is now withheld as
null, not folded into []. An empty list is the claim that the connection
opened no ports, and the failed-read-vs-empty distinction the app holds
everywhere else was being rendered as an affirmative absence beside
running ports. The destination still shows; the header stays silent
where the read failed.

NITs: the view-schema comment names the real inference-depth site (the
App-type-wide agent-or-ssh unions that flattened the input copy) rather
than misdescribing SubshellSchema's bare consumers; the gold note says
"the machine you connect from", the file's own vocabulary; and a
half-typed JSDoc line stopped wrapping as "half -typed".
Move spec 26 onto the Testcontainers sshd so B and D are finally apart.
That let each leg prove what one-host fixtures could only gesture at: a
-L/-D row's far end opens at the DESTINATION (darkhttpd in the container
serves the marker), a -R row's at the client, a blank -R bind answers a
loopback dial and refuses the container's interface address under
GatewayPorts clientspecified, and a wide -L bind is reached BY the
destination container dialing this machine's interface address.

Fixture gains GatewayPorts clientspecified, exec passthrough, and
curl/iproute2/darkhttpd in the image; the spec self-checks the marker
server before any leg depends on it.
Two ends of the same unreadability, from the SSH launch report:

- The combobox popup had a min-w anchor rule and no max, so one long
  muted reason stretched the dropdown past the field and the dialog.
  Both bounds now sit on the anchor width; rows truncate inside it.
- A machine's row says "SSH not enabled" in gold when its own SSH
  switch is the sole refusal, instead of a server paragraph cut to
  "An adm...". Multi-cause refusals keep the full sentences (naming
  only the switch when something else also blocks would mislead), and
  the whole story still reads under the field once a row is chosen.
The only outline-variant icon in a row of ghost ones (workspace link,
actions menu), which made it read as a different kind of control than
it is.
CI's e2e caught what dev could not: the job is itself a container, and
a sibling testcontainer cannot route to it across user-defined docker
networks. The wide -L escape now reads as an address pair on the
connecting side too - the loopback-bound -L port refuses a dial to this
machine's interface address (test 1), the wide one answers the same dial
(test 2) - which proves the same bind-scope fact in both topologies.
Spec 23's row expectation follows the new picker copy: "SSH not
enabled" in gold.

From the review round: the marker httpd's daemonizing & moved inside a
brace group so the exit-code guard sees a failed setup (a trailing &
backgrounds the whole chain and exits 0 no matter what), the ss
diagnostic no longer throws over its own message on a host without
iproute2, and the picker/split-button work gets its missing
@internal/server changeset. e2e's AGENTS picks up the port override the
forwards slot earned and the honest account of why no dial crosses the
container boundary.
Leftover phrasing from the superseded design where the destination
container dialed the connecting machine; the address it holds is what
the destination-side dials are aimed at.
@theogravity
theogravity merged commit e465fc1 into main Oct 16, 2026
21 checks passed
@theogravity
theogravity deleted the feat/ssh-port-forwards branch October 16, 2026 07:18
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant