18 KiB
Reaching a host you cannot dial
Some machines do not answer from where the user is sitting. This product has two answers to that, and neither of them works.
- The relay pipes raw TCP through the deployment. The server half is built and shipped; the client half does not exist, and both heads offer a checkbox that promises it.
- A jump host reaches the target through a machine already in the keychain.
HostSecret.JumpHostIdsstores the chain; nothing writes it and nothing reads it.
This document is the comparison between them, which is the thing that has to be settled before either is
built, and then the plan. It replaces an earlier draft of docs/jump-hosts.md that recommended deleting the
jump chain — see the last section for why that was wrong.
Status: step 0 done, the rest planned.
Step State Notes 0. Stop promising the relay Done Both heads say the box is not wired up and that ticking it stores the address and changes nothing. Left tickable, so a host already carrying the flag can lose it 1. Dial through a loopback proxy Done SshLoopbackProxyon the request, honoured inBuildConnectionInfo. Not the bridge — see below2. Jump hosts over it Not started No server change, and no bridge either: SSH.NET's own forward is the proxy 3. The relay: the bridge, then the ticket Not started The bridge's only consumer, so it lands with the feature that uses it 4. File transfer parity Not started The transfers screen opens its own connection. Already half-done — OpenSftpAsyncsharesBuildConnectionInfo, so it honours a proxy today; what is missing is anything passing it one
◆ The relay's checkbox is a false promise, and that is a defect
HostSecret.RelayEnabled is stored, validated — a relay host may not inherit its port — encoded, merged,
and drawn as a checkbox in the host editor on both heads. The desktop's says "Connect through the
server relay" and warns underneath that the address will be stored on the server in plain text. The
phone's says the same at more length.
Nothing on the client reads it. VaultViewModel builds SshConnectionRequest(hostname, port, username, credential) and SshNetConnectionFactory dials that address directly, whether the box is ticked or not.
So a user who ticks it pays the privacy and gets nothing: the host's address and port leave the encrypted payload and land in plaintext columns on the server — the one deliberate concession in the whole design, per ADR 0004 — and the connection is still made from their laptop to the machine they already could not reach. It then fails exactly as it did before, with no hint that the box did nothing.
This is worse than the jump chain, which is invisible and harmless. It is a control that spends something real.
Step 0 is done. Both heads now name the absence in the label and lead the paragraph under it with what ticking the box actually does today — the way this codebase already handles port forwarding on the phone's More screen. It is left tickable rather than disabled, deliberately: a host that already carries the flag has to be able to lose it, and a disabled control would trap the concession on. The sentence comes out again at step 3.
The comparison
Both answers put something between the user and a machine they cannot dial. What differs is what is in between, what it costs, and who has to own it.
| Relay | Jump host | |
|---|---|---|
| Reaches | Anything the deployment can reach | Anything a machine already in the keychain can reach |
| Asks of the deployment | It must sit where it can dial the target, and have the relay enabled | Nothing. The server is not involved at all |
| Tells the operator | The host's address and port, in plaintext columns, for every opted-in host — plus an audit row per session: target, duration, bytes, close reason, client IP | Nothing beyond the sync metadata every item already produces |
| The intermediate's credentials | None to manage. The deployment is the intermediate | The bastion is an ordinary host: its own key or password, its own host key to pin, its own group defaults |
| Where SSH terminates | On the laptop. The relay sees ciphertext, and ADR 0004 is emphatic that no recording is possible | On the laptop. The bastion forwards a TCP stream inside a session the user opened to it |
| When it is unavailable | Deployment down, no connection — including to hosts that were reachable directly | Bastion down, no connection to what is behind it |
| Fits an estate where | The DodoSSH server is inside the network the targets are on | A bastion is the policy and the server is outside — which is the ordinary enterprise shape |
| Auditable by the operator | Yes, coarsely, and that is a feature for a team deployment | No, and that is a feature for a private one |
The two are not substitutes, and the deciding question is where the deployment sits. The relay only answers "unreachable" when the server has line of sight the laptop lacks — a deployment inside the VPC, on the office network, on the same Tailnet. Point it at a self-hosted box outside the target's network, which is what most people running this on a VPS will have, and the relay reaches nothing the laptop could not already reach.
And you do not get to choose other people's topology. Shipping this to strangers means shipping into
estates whose shape is already decided, and bastion-fronted is the common one. Their ssh_config says so:
the importer reads ProxyJump, records it as an option and writes a note on the host saying "DodoSSH does
not route through a jump host yet" — a first-run experience that names the limitation on the hosts it
matters for.
A relay is not a bastion with better manners. ADR 0004 rejected "server terminates SSH" and kept zero-knowledge, which is right and is not what a jump host asks for either: forwarding a TCP stream through a machine the user has authenticated to reveals nothing to the operator, because the operator is not in it. The privacy ordering is the opposite of what the ADR's framing suggests — the relay is the mechanism that costs a plaintext address, and the jump host is the one that costs nothing.
They are one piece of work, and ADR 0004 says so
The last consequence in ADR 0004, written before either half was built:
On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a loopback TCP bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — one mechanism, two features.
That is the plan, and it holds up against the pinned package. SSH.NET 2025.1.0 offers
ForwardedPortDynamic, which is a SOCKS5 proxy served over an established SshClient, and
ConnectionInfo(host, port, username, ProxyTypes, proxyHost, proxyPort, proxyUsername, proxyPassword, AuthenticationMethod[]) with ProxyTypes.Socks5 — checked in Renci.SshNet.xml rather than remembered. So:
- Jump host: connect to the bastion as an ordinary host,
AddForwardedPort(new ForwardedPortDynamic(0))on it, then dial the target with aConnectionInfopointed at that loopback SOCKS5 port. A chain of two is the same trick twice. - Relay: the same shape with a different thing on the loopback socket — a listener that pipes bytes into
the
dodossh.relay.v1WebSocket instead of into a bastion's forward.
◆ But the bridge is the relay's half, not the shared one
Written into step 1 of this plan and wrong. Building it turned the sentence around: the jump-host path
needs no bridge from this repository at all. ForwardedPortDynamic is the loopback listener — SSH.NET
accepts on it, speaks SOCKS5 on it, and tunnels what it accepts through the bastion. Nothing is left for a
bridge of ours to do. The relay is the case with no SshClient to hang a forward off, so it is the one that
needs a listener written here, and it is the bridge's only consumer.
What the two genuinely share is one step lower: the connection being told to reach its target through a
loopback SOCKS5 proxy, while staying about the target. That is the piece both features stand on, it is
fifteen lines in BuildConnectionInfo, and it is what step 1 turned out to be.
So the bridge moves to step 3 and lands with the feature that uses it. Building it now would have been a component whose only caller was two steps away — which is the shape of the two defects this document is about.
The work, in order
0. Stop promising the relay. ✅ The checkbox states that the relay is not wired up yet. One line on each head, and the only step that should ship on its own.
1. Dial through a loopback proxy. ✅ SshConnectionRequest carries an optional SshLoopbackProxy, and
BuildConnectionInfo builds SSH.NET's proxy ConnectionInfo when it is there. Three properties are worth
knowing, and each is held by a test in LoopbackProxyTests:
- The type is a port and nothing else. A proxy on any interface but loopback cannot be expressed, which matters because the failure mode is an open SOCKS proxy on the user's network for the life of a shell, and nothing would report it.
- SOCKS5 rather than a dumb pipe, so the target stays the target. The host and port in the request are
the ones SSH.NET dials through the proxy and the ones the host key gate pins — so the same machine
reached through a bastion is pinned under its own name, not under
127.0.0.1:<ephemeral>, which is not an identity at all. - A proxy that is not listening fails as a connection error, not as an unknown host key. The gate translates "no host key seen" into a fingerprint prompt, and a connection that never reached a server has seen none either — so the prompt would offer to fix the wrong thing, with no fingerprint to show.
Nothing calls it with a proxy yet. That is deliberate and it is one step wide: step 2 is the caller.
2. Jump hosts. No server change, and no bridge. In order:
- The hop's own connection, and a
ForwardedPortDynamic("127.0.0.1", 0)started on it, whoseBoundPortbecomes theSshLoopbackProxyfor the connection after it. A chain of two is that twice. VaultViewModelresolvesJumpHostIdsto hosts in the same vault, applying group inheritance per hop the way the target already gets it, and refuses a chain that crosses a vault — the same refusal the group picker already makes, for the same reason.- ◆ The target's address is resolved at the last hop, not here. A SOCKS CONNECT names the target and the
bastion resolves it, so what has to be stored on the host is the address the bastion can reach — which
is what an
ssh_configmeans byHostNamebeside aProxyJump, and what the importer is already carrying across verbatim. Nothing needs to change for that to be true; it needs to be said, because a host that resolves here and not there fails as a SOCKS "general failure" naming neither end. - Per-hop host keys. Each hop is a separate handshake against a separate endpoint, so the pin, the unknown
key prompt and the changed-key refusal run per hop. The prompt has to name which hop it is about, or
somebody approves a bastion's fingerprint believing it is the target's — see
HostKeyCard, which is built around one connection and one question. - Per-hop credentials, including a hop that wants a typed password.
IsAskingForConnectPasswordasks about one host today. - Teardown: the hops belong to the outer session and go with it, including when the outer connect fails half way. A leaked bastion connection is an open session on a machine the user believes they left.
- The schema version. A chain becomes a real field, so it joins the ladder in
HostSecretCodec— a host carrying one must not be editable by a client that would drop it. That is the whole point of the rule. - The editor: a picker over other hosts in the same vault, and the host detail's subtitle finally getting
the
⤷ bastion-euthe design asked for.
3. The relay, and the bridge with it. A loopback TcpListener on an ephemeral port that accepts one
connection, answers a SOCKS5 CONNECT on it, and pipes the rest into the dodossh.relay.v1 WebSocket —
SOCKS5 rather than a raw pipe so that this path presents the same interface step 1 already speaks, and the
target's identity stays the target's. Then POST /relay/tickets with the host id and the WebSocket with the
ticket in Sec-WebSocket-Protocol; the ticket is single-use and expires in 30 seconds, so it is fetched per
connect and never cached. The bridge binds 127.0.0.1 explicitly, accepts once and stops listening. Then
the sentence step 0 added comes out of both heads.
4. File transfer. ISftpSessionFactory.OpenSftpAsync opens its own second connection, so a host that
needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this
work exists for.
Traps already known
A relay host may not inherit its port, and a jump host has no such rule. TryValidate enforces the
first because the server stores the port and a group edit would silently change what the relay dials. The
chain has no plaintext counterpart, so it inherits normally — do not copy the restriction across out of
symmetry.
Two hosts can name each other. A chain is ids, and nothing stops A jumping through B while B jumps through A. Resolve iteratively with a visited set and refuse a cycle before dialling anything, rather than discovering it as a stack overflow inside a connect.
The bastion's own group defaults matter. A hop is a host, so it resolves its port, username and binding
through HostInheritance exactly as the target does. Skipping that dials 22 as nobody on a bastion that is
on 2222 as deploy.
ForwardedPortDynamic(0) reports its port, and the one-argument constructor's bind address is
undocumented. The first is now pinned by a test rather than trusted. The second is why the two-argument
constructor is always used: if the default were 0.0.0.0 the failure would not be a test failure, it would
be a SOCKS proxy into the developer's network that nothing reports, so the bound host is asserted too.
◆ The test server refuses forwarding, and says so nowhere useful. linuxserver/openssh-server ships
AllowTcpForwarding no. A dynamic forward starts anyway — opening one asks the server nothing — and every
connection through it is then refused at channel-open, which SSH.NET surfaces as SOCKS5: General failure,
naming neither the server nor the setting. SshServerFixture patches it after start and HUPs sshd. There
are two sshd_config files in that image and the running server uses /config/sshd/sshd_config; patching
/etc/ssh/sshd_config, which is the one a search finds first, changes the text and nothing else.
The relay bridge and the jump bridge are the same class and not the same lifetime. A ticket is single-use with a 30-second expiry; a bastion's forward lives as long as the session. Sharing the listener is right, sharing a lifetime policy is not.
Tests
- ✅ A connect through a real SOCKS5 forward to a real
sshd, inLoopbackProxyTests. One container, used as both ends — the forward is opened on a connection to the fixture's server and the connection under test goes back to the same server through it. A second container would look more like the topology and establish nothing extra: what is under test is that the proxy is honoured, that the target is what gets pinned, and that a failure on the way through is reported as itself. - A cycle in a chain is refused before any socket is opened.
- Each hop's host key is asked about separately, and the question names the hop.
- A chain crossing a vault is refused with a reason, as the group picker's is.
- The bridge binds loopback only — assert the bound address, because the failure is silent and the consequence is an open proxy.
- SFTP to a host behind a chain, once step 4 lands.
- Mutations that must fail something: bind the bridge on
IPAddress.Any; drop the visited set; skip group inheritance for a hop; and tear down the outer session without the hops.
Prose that becomes false
docs/design-import-gaps.md— the host subtitle's⤷ bastion-eurow, the SFTPsftp over bastion-eurow, and the status bar'svia bastion-eurow, all of which say jump hosts are data-only.Client.Import/ImportedHost.cs— the note written onto every imported host with aProxyJump, and the remark above it.README.mdanddocs/android-port.mdwherever the relay is described as available.- ADR 0004 gains a note that its last consequence was built, and how.
What the first draft of this document got wrong
It recommended deleting JumpHostIds, on the evidence that nothing writes it, nothing reads it, and it is
missing from the schema-version ladder. The first two facts are true and the conclusion did not follow.
Two things were missed. ADR 0004 had already designed the implementation — the loopback bridge, the SOCKS5 dynamic forward, "one mechanism, two features" — so the transport was a solved problem sitting in an accepted ADR, and the fortnight that draft estimated was priced without it. And the stored shape is right: an ordered list of host ids is exactly what a chain is, the merge arm is already correct, and the missing schema version is a line to add rather than evidence of a bad model.
The lesson is narrower than "read the ADRs": it is that nothing reads this field was taken as evidence the field was a mistake, when it was evidence of an unfinished feature — and the same reasoning applied one paragraph further would have found the relay checkbox, which is the same shape and is actively lying to users.
And then step 1 was wrong too, in the same direction: it named the bridge as the shared foundation on the strength of ADR 0004's "one mechanism, two features", without checking which half of the mechanism SSH.NET already provides. It provides the jump host's half entirely. The shared piece was one level down and a tenth of the size, and the bridge belongs with the relay. Both corrections came from writing the code rather than from reading more — which is an argument for the step-at-a-time ordering rather than against planning, but only if the plan is edited when a step answers back.