Let a host marked for the relay actually be reached through it
ci / build and test (pull_request) Successful in 6m27s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 15m47s
ci / api image (pull_request) Failing after 28s

The relay checkbox said it was not wired up, and the plan said the server
half was built and shipped. It was not: there was ADR 0004, the contract
records, RelayOptions and a CHECK constraint, and RelayOptions said in its
own remarks that there was no endpoint and no dialler. So both halves are
built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md.

Server, in Features/Relay:

- POST /api/v1/relay/tickets makes every decision: the host, Connect on
  its vault, the relay switched on, and the stored name resolved once and
  judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8,
  multicast and the metadata endpoints are refused with no configuration
  escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the
  operator's denials, allow-list and private-network switch. What passes
  goes into a Data Protection ticket, and nothing resolves the name again.
  Every target refusal is the same 403, so the endpoint cannot be used to
  map the operator's DNS; the real reason is logged.
- GET /api/v1/relay/connect takes the ticket as a second subprotocol,
  checks its expiry against TimeProvider and spends it with a unique
  insert into relay_ticket_use before accepting the upgrade. After the
  upgrade a refusal is a close code: 4502 for a host the server could not
  reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain.
- relay_session is the audit row, written from short-lived scopes.

Client:

- RelayBridge is a loopback SOCKS5 listener that accepts once and pipes
  to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so
  the connection disposes it on every way a session ends and a failed
  connect disposes it before throwing.
- ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for
  the ticket, opens the socket, starts the bridge and turns every refusal
  into a sentence.
- VaultViewModel and TransfersViewModel reach a relayed host through it,
  never fall back to dialling it directly, and replace SSH.NET's transport
  error with the relay's own reason when it gave one.

Neither side ever cancels a WebSocket operation. Cancelling one aborts
the socket, and an aborted socket sends no close frame, which is the only
way the client learns the server could not reach the host.
AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives.

Three departures from ADR 0004, recorded in it: awaited copy loops rather
than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket=
fallback, and no device id in the ticket.

Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a
real login through the bridge, API client 30, contracts 25. Not run: the
Android build (its change is copy only) and the Alpine layout oracle.
This commit is contained in:
jaap-jan committed 2026-09-28 14:37:20 +02:00
1 parent 54bc142dce
commit b20f7cae04
51 files changed
+6455 -88

No files matched your search

+71 -10
View File
@@ -1,26 +1,30 @@
# 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.
one 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.
- The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
pipes a WebSocket to the host, and the client reaches it through a loopback SOCKS5 bridge. Step 3 below.
- 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.
◆ **An earlier version of this page said the relay's server half was "built and shipped". It was not.** What
existed was ADR 0004, the contract records, the options and the CHECK constraint — and `RelayOptions` said,
in its own remarks, that there was no endpoint and no dialler. Both halves were built together at step 3.
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.**
> **Status: steps 0, 1 and 3 done, and step 4 for the relay. Jump hosts are next.**
>
> | 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 |
> | 0. Stop promising the relay | **Done, then retired at step 3** | The "not wired up yet" sentence came out of both heads when the relay started working |
> | 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 |
> | 3. The relay: the bridge, then the ticket | **Done** | Server: `Features/Relay`. Client: `RelayBridge`, `ServerRelay`, and `VaultViewModel.OpenThroughAsync`. See "What step 3 built" |
> | 4. File transfer parity | **Done for the relay** | `TransfersViewModel` opens a relay for a relayed host exactly as the shell does. A jump chain will need the same line when step 2 lands |
## ◆ The relay's checkbox is a false promise, and that is a defect
@@ -171,7 +175,64 @@ 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.
work exists for. ✅ for the relay.
## What step 3 built
**The server**, in `DodoSSH.Api/Features/Relay`:
- `POST /api/v1/relay/tickets` makes every decision. It finds the host, requires Connect on its vault and
the relay switched on, resolves the stored name, and runs every answer through `RelayAddressPolicy` —
the non-overridable list first, then the operator's denials, allow-list and private-network switch. What
passes goes into a Data Protection ticket; the name is not resolved again. **Every target refusal is one
answer**, 403 `relay-target-rejected` with one sentence, because anybody who can write a host can point
it anywhere, and a distinct "resolves, but refused" would be a scanner for the operator's DNS. The real
reason is logged (event 2301).
- `GET /api/v1/relay/connect` is anonymous at the HTTP layer and takes the ticket as the second
subprotocol. It verifies, checks expiry against `TimeProvider`, and spends the ticket with a unique
insert into `relay_ticket_use` before accepting the upgrade — so a refused ticket is a 401 and no socket.
After the upgrade, a refusal the client could not have avoided is a close code: 4429 for a limit, 4502
for a host the server could not reach, 4408 idle, 4409 lifetime, 1001 at the end of a shutdown's drain.
- `relay_session` is the audit row: opened before the dial, closed after, each from a scope of its own.
**The client:**
- `RelayBridge` (Client.Ssh) is the loopback SOCKS5 listener. It binds `127.0.0.1`, accepts once, answers
one CONNECT without acting on its target, and pipes to the WebSocket. It is handed to the connection as
the new `SshLoopbackProxy.Lifetime`, so the connection disposes it on every way a session ends, and a
failed connect disposes it before throwing.
- `ServerRelay` (Client.Session) asks for the ticket, opens the socket and starts the bridge, and turns
every refusal into a sentence. It is `IVaultServer.Relay`, decided from `/api/v1/meta` the way `Events`
is; a server without the feature supplies `UnavailableServerRelay`.
- `VaultViewModel` opens a relay for a host with the box ticked and **never falls back to dialling it
directly** — the user ticked it because that path does not work, or goes somewhere they do not mean. A
relay that closed with a reason replaces SSH.NET's transport error with that reason, and the
reachability probe is skipped for a relayed host, because it would test the direct path.
### ◆ Two things that are not what ADR 0004 wrote
- **No `System.IO.Pipelines`.** Each direction reads one buffer and does not read again until the write
has completed. That is the same end-to-end backpressure — a slow peer stops the read, which fills the
kernel's window, which throttles the far sender — with a tighter bound: two 32 KiB buffers a session
rather than ~2 MiB.
- **No `?ticket=` fallback, and no device id in the ticket.** The fallback exists for clients that cannot
set a subprotocol, which neither head is, and it would put a bearer credential in the one place — the
URL — that access logs keep. A device is never visible to the server on an ordinary request.
### ◆ A WebSocket operation is never cancelled, on either side
Cancelling a pending receive or send on a .NET WebSocket aborts it, and an aborted socket sends no close
frame. The close frame is the only way the client learns that the server could not reach the host, so both
the server's pump and the client's bridge stop a direction by closing and waiting a bounded time instead.
`AnIdleSession_IsClosedWithTheIdleCode` holds the server to it: it asserts the code arrives, not merely that
the socket ended.
### What a developer meets
The relay refuses loopback with no configuration escape, so the development stack's sshd on
`localhost:2222` cannot be reached through it. Point a relayed host at an address the API process reaches
on a real interface — the machine's own LAN address works — or at the container's address on the Docker
network.
## Traps already known
@@ -226,8 +287,8 @@ is right, sharing a lifetime policy is not.
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.
- `README.md` and `docs/android-port.md` wherever the relay is described as available. ✅ at step 3.
- ADR 0004 gains a note that its last consequence was built, and how. ✅ at step 3.
## What the first draft of this document got wrong