Public Access
254 lines
18 KiB
Markdown
254 lines
18 KiB
Markdown
# 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:<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, 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.
|