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.
Running the tests raised a Windows Firewall prompt, and raised it again from
every worktree. WireMockServer.Start() with no settings listens on 0.0.0.0
and [::], and the prompt is keyed to the binary that opened the socket — so
each test executable asks once per bin path, which a new worktree or a switch
between Debug and Release makes new again. The three suites that hold a
firewall rule on this machine are exactly the three that use WireMock; every
other listener in the repository already binds 127.0.0.1.
The stubs now say so explicitly. Port 0 is still WireMock's own free-port
search and still comes back on server.Url, which is what each stub builds its
base URL from, so the authority the API validates against and the issuer its
tokens claim follow the binding rather than being pinned to a host name.
Sampling the listening sockets of a full DodoSSH.Api.Tests run afterwards
finds one, 127.0.0.1, where there were previously three.
A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.
Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.
The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.
The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.
ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.
Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.
448 tests pass, zero warnings on a clean rebuild, format clean.