Files
DodoSSH/docs/reaching-a-host-you-cannot-dial.md
T
jaap-jan 575a9a9f5e
ci / build and test (push) Successful in 2m7s
ci / android head (push) Successful in 3m15s
ci / desktop nightly (push) Successful in 47s
ci / api image (push) Successful in 25s
Stop the relay checkbox promising a connection this client cannot make
Ticking "Connect through the server relay" moved the host's address and port out of the encrypted payload
into plaintext columns on the server — the single deliberate privacy concession in the design, per ADR 0004
— and then the client dialled the address directly, exactly as it does with the box clear. VaultViewModel
builds SshConnectionRequest(hostname, port, username, credential) and nothing on this side reads
RelayEnabled at all. The connection failed the way it always had, for a machine the laptop could not reach,
with nothing saying the box had done nothing.

The server half is built and shipped: tickets, the WebSocket, the deny list, the CHECK constraint that
enforces a non-null address for a relay-enabled host. What does not exist is the client's path to it, so
this is an unfinished feature rather than a broken one — but the control in front of it was collecting the
cost of the finished version.

Both heads now say so, in the label and in the first sentence of the paragraph under it. Not disabled, and
that is the one decision here worth stating: a host somebody has already ticked has to be able to lose the
flag, and a control greyed out with the concession switched on would trap it there. Tickable and honest
beats untickable and stuck.

This is step 0 of docs/reaching-a-host-you-cannot-dial.md, and the only step of it that should ship alone —
the sentence is written to be deleted when the bridge lands.

VERIFIED. Build clean, 112 layout tests. The drawer's paragraph is longer than it was and the host editor is
measured with the drawer open at the window's minimum, so the wrap is held inside the column rather than
assumed to fit.
2026-08-07 08:47:02 +02:00

14 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.JumpHostIds stores 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. The loopback bridge Not started ADR 0004's "one mechanism, two features"
2. Jump hosts over it Not started No server change at all
3. The relay over it Not started Ticket call, WebSocket, then the same bridge
4. File transfer parity Not started The transfers screen opens its own connection

◆ 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 a ConnectionInfo pointed 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.v1 WebSocket instead of into a bastion's forward.

Which means the transport work is shared and the ordering is: bridge, then the cheap feature, then the one that needs the server.

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 it is the only step that should ship on its own.

1. The bridge. A loopback TcpListener on an ephemeral port that accepts exactly one connection, hands it to a Stream supplied by whoever opened the bridge, and disposes with the session. It belongs in Client.Ssh beside SshNetConnectionFactory, and it needs to bind 127.0.0.1 explicitly — a bridge on 0.0.0.0 is an open SOCKS proxy on the user's network for the life of a shell.

2. Jump hosts. No server change. In order:

  • SshConnectionRequest grows a route: the resolved chain, each hop carrying what a connect needs, so the SSH layer is handed hops rather than ids and never looks anything up.
  • VaultViewModel resolves JumpHostIds to 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 RefusesTheDrop and the group picker already make, for the same reason.
  • 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. IsAskingForConnectPassword asks 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-eu the design asked for.

3. The relay. POST /relay/tickets with the host id, then the WebSocket with the ticket in Sec-WebSocket-Protocol, piped into the bridge from step 1. The ticket is single-use and expires in 30 seconds, so it is fetched per connect and never cached. Then the checkbox from step 0 becomes true.

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) and reading the port back. Binding an ephemeral port and then asking for the one that was assigned is the part that varies between SSH.NET versions; pin it with a test that opens one against the test sshd rather than trusting the number.

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 two-hop connect against the Testcontainers sshd, which Client.Ssh.Tests already stands up — one container as bastion, one as target, with the target refusing connections from anywhere else.
  • 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-eu row, the SFTP sftp over bastion-eu row, and the status bar's via bastion-eu row, all of which say jump hosts are data-only.
  • Client.Import/ImportedHost.cs — the note written onto every imported host with a ProxyJump, and the remark above it.
  • README.md and docs/android-port.md wherever 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.