# 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: planned, nothing started.** Step 0 is a one-line honesty fix and should not wait for the rest. > > | Step | State | Notes | > | --- | --- | --- | > | 0. Stop promising the relay | Not started | The checkbox is wired to storage and to nothing else | > | 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 exists because it should not survive another release in that state, and it is one line: the checkbox says the relay is not wired up yet, the way this codebase already handles port forwarding on the phone's More screen. ## 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.