# 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. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below | > | 2. 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 — `OpenSftpAsync` shares `BuildConnectionInfo`, 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 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. ### ◆ 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:`, 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, whose `BoundPort` becomes the `SshLoopbackProxy` for the connection after it. A chain of two is that twice. - `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 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_config` means by `HostName` beside a `ProxyJump`, 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. `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, 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`, in `LoopbackProxyTests`. **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-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. **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.