diff --git a/README.md b/README.md
index 1fb7d3d..458c99b 100644
--- a/README.md
+++ b/README.md
@@ -873,6 +873,13 @@ keychain plus a terminal — and the spike that gates all of it.
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
are consequences rather than choices.
+ *Relay done:* a host with "Connect through the server relay" ticked is reached through the deployment,
+ for a machine this one cannot dial. The server issues a single-use, thirty-second ticket for that host
+ alone, resolves its address and checks it against a deny list with no configuration escape — loopback,
+ link-local, metadata endpoints — and then pipes raw TCP; the SSH session still ends on your machine, so
+ the relay sees ciphertext. It is off by default (`Relay:Enabled`), and it only helps when the server can
+ reach what you cannot. See [ADR 0004](docs/adr/0004-relay-authorization.md).
+
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
diff --git a/docs/adr/0004-relay-authorization.md b/docs/adr/0004-relay-authorization.md
index 653b131..dee67f1 100644
--- a/docs/adr/0004-relay-authorization.md
+++ b/docs/adr/0004-relay-authorization.md
@@ -99,3 +99,22 @@ real ones.
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
proxy into the operator's network.
- **Allow-listing by hostname.** Defeated by DNS rebinding.
+
+## Implementation notes
+
+Built in `DodoSSH.Api/Features/Relay` and, on the client, `RelayBridge` and `ServerRelay`; see
+`docs/reaching-a-host-you-cannot-dial.md`, "What step 3 built". Three places differ from the text above:
+
+- **Backpressure is an awaited copy loop, not `System.IO.Pipelines`.** Each direction reads one buffer and
+ writes it before reading again. The property is the same — a slow peer stops the read, and TCP throttles
+ the origin — and the bound is tighter: two 32 KiB buffers per session.
+- **There is no `?ticket=` fallback.** Both clients can set a subprotocol, and the fallback's cost — a bearer
+ credential in the URL — was the reason it needed scrubbing at all. It can be added if a constrained
+ client ever needs it; the scrubbing requirement stands.
+- **The ticket carries no device id.** The server does not see a device on an ordinary request, so there was
+ nothing to put there. `PortForwardId` on the request is refused: a forward's port is ciphertext, and
+ honouring one would mean taking the port from the client.
+
+The loopback bridge serves the relay only. SSH.NET's `ForwardedPortDynamic` already is the jump-host path's
+listener, so "one mechanism, two features" is true one level down: both features reach their target through
+a loopback SOCKS5 proxy named on the connection request.
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index 01e420e..11c3bb0 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -254,7 +254,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | v5 element | What ships instead |
> | --- | --- |
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
-> | The relay checkbox's mock copy | The pre-existing sentence, unchanged, "(not wired up yet)" included — restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it) but not reworded. The mock's own copy implies a relay this client can dial; it cannot, for the reason `HostDrawer.axaml`'s own remarks give. |
+> | The relay checkbox's mock copy | Restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it). The "(not wired up yet)" it carried while this client could not dial the relay came out when it could; the sentence under it now says what ticking it does — the server dials the host, so the server has to be able to reach it, and the address is stored in plain text. |
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
diff --git a/docs/reaching-a-host-you-cannot-dial.md b/docs/reaching-a-host-you-cannot-dial.md
index 93ad2e8..0031c3d 100644
--- a/docs/reaching-a-host-you-cannot-dial.md
+++ b/docs/reaching-a-host-you-cannot-dial.md
@@ -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
diff --git a/src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs b/src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs
index e9a3b7a..1550fd7 100644
--- a/src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs
+++ b/src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
/// Sync semantics version. Bumped when push or pull behaviour changes.
internal const int SyncProtocolVersion = 1;
- /// Feature flag for the relay.
- internal const string RelayFeature = "relay";
-
///
public override void Configure()
{
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
List features = ["teams"];
if (relay.Value.Enabled)
{
- features.Add(RelayFeature);
+ features.Add(RelayProtocol.Feature);
}
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
diff --git a/src/DodoSSH.Api/Features/Relay/RelayAddressPolicy.cs b/src/DodoSSH.Api/Features/Relay/RelayAddressPolicy.cs
new file mode 100644
index 0000000..60a5315
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelayAddressPolicy.cs
@@ -0,0 +1,192 @@
+using System.Net;
+using System.Net.Sockets;
+using DodoSSH.Api.Setup;
+
+namespace DodoSSH.Api.Features.Relay;
+
+///
+/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
+///
+///
+///
+/// This runs on addresses, never on names. The name is resolved once, at ticket issue, and what
+/// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the
+/// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback
+/// one for the dial — which is the attack ADR 0004 is built around.
+///
+///
+/// Three layers, and the order is the security property. The list comes first and
+/// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a
+/// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials,
+/// then their allow-list, then the private-network switch.
+///
+///
+/// An address that embeds another is judged as the one it embeds. ::ffff:127.0.0.1 is
+/// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass
+/// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because
+/// on a network with a NAT64 gateway 64:ff9b::a9fe:a9fe is the metadata endpoint.
+///
+///
+internal sealed class RelayAddressPolicy
+{
+ ///
+ /// Addresses the relay never dials, whatever it is configured to do.
+ ///
+ ///
+ /// Broader than the ADR's list in two places, both deliberately. 240.0.0.0/4 — reserved, and
+ /// containing the limited broadcast address — has no host worth reaching. And IPv6 ::/96, the
+ /// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing
+ /// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there.
+ ///
+ private static readonly IPNetwork[] Refused =
+ [
+ // IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved.
+ IPNetwork.Parse("0.0.0.0/8"),
+ IPNetwork.Parse("127.0.0.0/8"),
+ IPNetwork.Parse("169.254.0.0/16"),
+ IPNetwork.Parse("224.0.0.0/4"),
+ IPNetwork.Parse("240.0.0.0/4"),
+
+ // Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would
+ // otherwise be permitted as private — the range a Tailnet also uses, which is why the whole
+ // block cannot simply be refused.
+ IPNetwork.Parse("100.100.100.200/32"),
+
+ // IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS
+ // metadata endpoint's IPv6 address.
+ IPNetwork.Parse("::/96"),
+ IPNetwork.Parse("fe80::/10"),
+ IPNetwork.Parse("ff00::/8"),
+ IPNetwork.Parse("fd00:ec2::254/128"),
+ ];
+
+ ///
+ /// Ranges governs.
+ ///
+ ///
+ /// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and
+ /// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local
+ /// fec0::/10 is deprecated and was private while it lived.
+ ///
+ private static readonly IPNetwork[] Private =
+ [
+ IPNetwork.Parse("10.0.0.0/8"),
+ IPNetwork.Parse("172.16.0.0/12"),
+ IPNetwork.Parse("192.168.0.0/16"),
+ IPNetwork.Parse("100.64.0.0/10"),
+ IPNetwork.Parse("fc00::/7"),
+ IPNetwork.Parse("fec0::/10"),
+ ];
+
+ /// The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.
+ private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
+
+ ///
+ /// Ports the relay never dials.
+ ///
+ ///
+ /// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the
+ /// operator's address on it. Database ports stay open, because forwarding to one is a headline use,
+ /// and the control that matters there is who may dial the host at all.
+ ///
+ private static readonly int[] RefusedPorts = [25, 465, 587];
+
+ private readonly bool allowPrivate;
+ private readonly IPNetwork[] allowed;
+ private readonly IPNetwork[] denied;
+
+ public RelayAddressPolicy(RelayOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ allowPrivate = options.AllowPrivateNetworks;
+ allowed = ParseAll(options.AllowedNetworks);
+ denied = ParseAll(options.DeniedNetworks);
+ }
+
+ /// Whether a port may be dialled at all.
+ internal static bool PermitsPort(int port) =>
+ port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
+
+ ///
+ /// Whether every entry is a network this policy can read.
+ ///
+ ///
+ /// For startup validation. A typo in a deny list that was silently skipped would be a hole the
+ /// operator believes is closed, so an unreadable entry stops the server instead.
+ ///
+ /// Host bits are refused as well, although
+ /// would mask them off quietly. 10.20.5.7/16 could be a /16 written from memory or a /32 with
+ /// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses.
+ ///
+ ///
+ internal static bool AreNetworks(IEnumerable entries) =>
+ entries.All(entry =>
+ IPNetwork.TryParse(entry, out var network)
+ && IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written)
+ && written.Equals(network.BaseAddress));
+
+ ///
+ /// The address the relay would actually be talking to, with any embedding unwrapped.
+ ///
+ internal static IPAddress Normalise(IPAddress address)
+ {
+ ArgumentNullException.ThrowIfNull(address);
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ return address.MapToIPv4();
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address))
+ {
+ var bytes = address.GetAddressBytes();
+ return new IPAddress(bytes.AsSpan(12, 4));
+ }
+
+ // A scope id is how an address names an interface on this machine, which is not something a
+ // host row gets to choose. Dropped, so what is judged is what is dialled.
+ return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0
+ ? new IPAddress(address.GetAddressBytes())
+ : address;
+ }
+
+ /// Whether the relay may dial this address.
+ ///
+ /// Judges the normalised address. Callers should dial 's answer too, so that
+ /// the address checked and the address connected to are the same object rather than two spellings
+ /// the operating system is trusted to agree about.
+ ///
+ internal bool Permits(IPAddress address)
+ {
+ var target = Normalise(address);
+
+ if (Refused.Any(network => Within(network, target)))
+ {
+ return false;
+ }
+
+ if (denied.Any(network => Within(network, target)))
+ {
+ return false;
+ }
+
+ if (allowed.Length > 0)
+ {
+ return allowed.Any(network => Within(network, target));
+ }
+
+ return allowPrivate || !Private.Any(network => Within(network, target));
+ }
+
+ ///
+ /// answers false across address families rather than throwing, so
+ /// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line
+ /// alternative reads as though it compared across families.
+ ///
+ private static bool Within(IPNetwork network, IPAddress address) =>
+ network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
+
+ private static IPNetwork[] ParseAll(IEnumerable entries) =>
+ [.. entries.Select(entry => IPNetwork.Parse(entry))];
+}
diff --git a/src/DodoSSH.Api/Features/Relay/RelayConnectEndpoint.cs b/src/DodoSSH.Api/Features/Relay/RelayConnectEndpoint.cs
new file mode 100644
index 0000000..d440f87
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelayConnectEndpoint.cs
@@ -0,0 +1,722 @@
+using System.Buffers;
+using System.Net;
+using System.Net.WebSockets;
+using DodoSSH.Api.Setup;
+using DodoSSH.Contracts;
+using DodoSSH.Domain;
+using DodoSSH.Infrastructure;
+using FastEndpoints;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Npgsql;
+
+namespace DodoSSH.Api.Features.Relay;
+
+///
+/// The relay socket: a ticket in, raw bytes to one host and back.
+///
+///
+///
+/// Anonymous at the HTTP layer, because the ticket is the authorization and it is the whole of it. A
+/// ticket carries no API authority — it names one host, one set of addresses and one session — so this
+/// endpoint can accept it from a client that cannot set an Authorization header on an upgrade,
+/// which is the constraint ADR 0004 was written around. It is verified, checked for freshness and
+/// redeemed before the upgrade is accepted, so a refused ticket costs a problem document and no socket.
+///
+///
+/// Nothing here is scoped to the request. A session lasts as long as a shell does, which is hours,
+/// and anything scoped held across it is a pooled database connection held across it. The database is
+/// touched three times — redeem, open the audit row, close it — each from a scope of its own that is
+/// disposed before the next byte moves. That is ADR 0004's consequence about DbContext lifetime.
+///
+///
+/// Backpressure is the await. Each direction reads one buffer, writes it, and does not read again until
+/// the write has completed, so a slow reader on either side stops this side reading, which fills the
+/// kernel's receive window, which throttles the sender at the far end. Nothing is queued in between, and
+/// a session costs its two buffers whatever the peers do.
+///
+///
+internal sealed class RelayConnectEndpoint(
+ IServiceScopeFactory scopes,
+ IRelayNetwork network,
+ RelayTicketCodec codec,
+ RelaySessionRegistry sessions,
+ IOptions options,
+ TimeProvider clock,
+ IHostApplicationLifetime lifetime,
+ ILogger logger)
+ : EndpointWithoutRequest
+{
+ ///
+ /// One direction's buffer.
+ ///
+ ///
+ /// Room for an SSH packet at the size OpenSSH sends them, so a bulk transfer moves in one read and
+ /// one write per packet rather than several.
+ ///
+ private const int BufferBytes = 32 * 1024;
+
+ /// How long a close handshake, or a peer's last write, may take before the socket is dropped.
+ private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ public override void Configure()
+ {
+ Get(RelayProtocol.ConnectPath);
+
+ // The ticket is the authorization. See the remarks on this class, and ADR 0004.
+ AllowAnonymous();
+
+ Description(b => b
+ .WithName("RelayConnect")
+ .WithSummary("Pipes raw bytes between a WebSocket and the host a relay ticket names.")
+ .WithTags("Relay"));
+ }
+
+ ///
+ public override async Task HandleAsync(CancellationToken ct)
+ {
+ var ticket = await AdmitTicketAsync(ct).ConfigureAwait(false);
+
+ if (ticket is null)
+ {
+ return;
+ }
+
+ var lease = sessions.TryAdmit(ticket.UserId);
+
+ try
+ {
+ using var socket = await HttpContext.WebSockets
+ .AcceptWebSocketAsync(new WebSocketAcceptContext { SubProtocol = RelayProtocol.SubProtocol })
+ .ConfigureAwait(false);
+
+ if (lease is null)
+ {
+ RelayLog.LimitReached(logger, ticket.UserId, sessions.Count);
+
+ await CloseAsync(
+ socket,
+ new Ending(
+ RelayCloseReason.Unspecified,
+ RelayProtocol.TooManySessionsCloseCode,
+ "Too many relay sessions are open."))
+ .ConfigureAwait(false);
+
+ return;
+ }
+
+ if (lifetime.ApplicationStopping.IsCancellationRequested)
+ {
+ await CloseAsync(socket, ShuttingDown).ConfigureAwait(false);
+
+ return;
+ }
+
+ await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Inside a try that starts before the upgrade, for the reason VaultEventsEndpoint gives: an
+ // accept that throws would otherwise hold a place against this user's cap forever.
+ lease?.Dispose();
+ }
+ }
+
+ private static Ending ShuttingDown { get; } = new(
+ RelayCloseReason.ServerShutdown,
+ (int)WebSocketCloseStatus.EndpointUnavailable,
+ "The server is shutting down.");
+
+ ///
+ /// Answers everything that is not a redeemable ticket, as ordinary HTTP, before any socket exists.
+ ///
+ /// The redeemed ticket, or null when a response has been sent.
+ ///
+ /// A ticket that is forged, expired, spent, or simply absent all answer 401
+ /// relay-ticket-invalid, with one remedy — ask for another — and no hint of which it was.
+ ///
+ private async Task AdmitTicketAsync(CancellationToken cancellationToken)
+ {
+ if (!options.Value.Enabled)
+ {
+ await Send.ResultAsync(Problems.Coded(
+ StatusCodes.Status404NotFound,
+ ProblemCodes.RelayUnavailable,
+ "This server does not relay. GET /api/v1/meta lists the features it does offer."))
+ .ConfigureAwait(false);
+
+ return null;
+ }
+
+ var offered = HttpContext.WebSockets.WebSocketRequestedProtocols;
+
+ if (!HttpContext.WebSockets.IsWebSocketRequest
+ || !offered.Contains(RelayProtocol.SubProtocol, StringComparer.Ordinal))
+ {
+ await Send.ResultAsync(Problems.Coded(
+ StatusCodes.Status400BadRequest,
+ ProblemCodes.MalformedRequest,
+ "This endpoint is a WebSocket. Send an upgrade request offering the "
+ + $"'{RelayProtocol.SubProtocol}' subprotocol and the ticket as a second one, "
+ + $"'{RelayProtocol.TicketProtocolPrefix}'."))
+ .ConfigureAwait(false);
+
+ return null;
+ }
+
+ var sealedTicket = offered
+ .FirstOrDefault(protocol => protocol.StartsWith(RelayProtocol.TicketProtocolPrefix, StringComparison.Ordinal))
+ ?[RelayProtocol.TicketProtocolPrefix.Length..];
+
+ var ticket = sealedTicket is { Length: > 0 } ? codec.TryOpen(sealedTicket) : null;
+
+ if (ticket is null || !await RedeemAsync(ticket, cancellationToken).ConfigureAwait(false))
+ {
+ RelayLog.TicketRefused(logger, ticket?.SessionId);
+
+ await Send.ResultAsync(Problems.Coded(
+ StatusCodes.Status401Unauthorized,
+ ProblemCodes.RelayTicketInvalid,
+ "This relay ticket is not valid: it is expired, already used, or was not issued here. "
+ + $"Ask for another from POST {RelayProtocol.TicketsPath}."))
+ .ConfigureAwait(false);
+
+ return null;
+ }
+
+ return ticket;
+ }
+
+ ///
+ /// Spends the ticket, or answers false when it has been spent already.
+ ///
+ ///
+ ///
+ /// A unique insert, which is the only form of "once" that holds across nodes: two redemptions racing
+ /// on two nodes both attempt the insert and the database lets exactly one of them win.
+ ///
+ ///
+ /// Rows for tickets that can no longer verify are swept on the way. They guard nothing, and sweeping
+ /// here — on a path that runs once per session rather than once per request — keeps the table the
+ /// size of the last thirty seconds without a background job to forget about.
+ ///
+ ///
+ private async Task RedeemAsync(RelayTicket ticket, CancellationToken cancellationToken)
+ {
+ var scope = scopes.CreateAsyncScope();
+ await using var _ = scope.ConfigureAwait(false);
+
+ var database = scope.ServiceProvider.GetRequiredService();
+ var now = clock.GetUtcNow();
+
+ await database.RelayTicketUses
+ .Where(use => use.ExpiresAtUtc < now)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ database.RelayTicketUses.Add(new RelayTicketUse
+ {
+ TicketId = ticket.TicketId,
+ ExpiresAtUtc = ticket.ExpiresAt,
+ UsedAtUtc = now,
+ });
+
+ try
+ {
+ await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ return true;
+ }
+ catch (DbUpdateException exception)
+ when (string.Equals(
+ (exception.InnerException as PostgresException)?.SqlState,
+ PostgresErrorCodes.UniqueViolation,
+ StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ /// Dials the target, pipes until something ends it, and writes down what happened.
+ private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
+ {
+ var started = clock.GetUtcNow();
+
+ await OpenAuditAsync(ticket, started).ConfigureAwait(false);
+
+ var dialled = await DialAsync(ticket, requestAborted).ConfigureAwait(false);
+
+ if (dialled is not var (target, address))
+ {
+ RelayLog.TargetUnreachable(logger, ticket.SessionId, ticket.HostId);
+
+ var unreachable = new Ending(
+ RelayCloseReason.TargetUnreachable,
+ RelayProtocol.TargetUnreachableCloseCode,
+ "The relay could not reach the host.");
+
+ await CloseAsync(socket, unreachable).ConfigureAwait(false);
+ await CloseAuditAsync(ticket.SessionId, null, new Counters(clock, started), unreachable.Reason).ConfigureAwait(false);
+
+ return;
+ }
+
+ var counters = new Counters(clock, started);
+ Ending ending;
+
+ await using (target.ConfigureAwait(false))
+ {
+ RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
+
+ ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
+ }
+
+ RelayLog.SessionClosed(
+ logger,
+ ticket.SessionId,
+ ending.Reason,
+ counters.ClientToTarget,
+ counters.TargetToClient);
+
+ await CloseAuditAsync(ticket.SessionId, address, counters, ending.Reason).ConfigureAwait(false);
+ }
+
+ ///
+ /// Connects to the first of the ticket's addresses that answers, within the connect timeout overall.
+ ///
+ ///
+ /// Overall rather than per address, so a name with sixteen dead addresses costs five seconds rather
+ /// than eighty. In the order resolved, which is the order the operating system's resolver prefers.
+ ///
+ private async Task<(Stream Target, IPAddress Address)?> DialAsync(
+ RelayTicket ticket,
+ CancellationToken cancellationToken)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(options.Value.ConnectTimeout);
+
+ foreach (var address in ticket.TargetAddresses)
+ {
+ try
+ {
+ var target = await network
+ .ConnectAsync(address, ticket.TargetPort, timeout.Token)
+ .ConfigureAwait(false);
+
+ return (target, address);
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception exception) when (exception is IOException or System.Net.Sockets.SocketException)
+ {
+ // The next address, if there is one.
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Runs both directions and the clock until one of them ends the session, then ends it politely.
+ ///
+ ///
+ ///
+ /// No operation on the WebSocket is ever cancelled. Cancelling a pending receive or send on a
+ /// .NET WebSocket aborts it, and an aborted socket cannot send the close frame that is the only way
+ /// a client learns why its session ended — which, for an idle timeout or a drain, is the
+ /// difference between "reconnect" and "something is broken". So the token stops the target side and
+ /// the clock; the sender is let finish its last write; the close goes out; and the receiver ends on
+ /// the client's answering close. Each wait is bounded, and the socket is aborted only when a peer
+ /// has stopped answering altogether.
+ ///
+ ///
+ private async Task PumpAsync(
+ WebSocket socket,
+ Stream target,
+ Counters counters,
+ DateTimeOffset started,
+ CancellationToken requestAborted)
+ {
+ using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
+
+ var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
+ var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
+ var minding = MindAsync(counters, started, pump.Token);
+
+ var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
+ var ending = await first.ConfigureAwait(false) ?? new Ending(
+ RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
+
+ await pump.CancelAsync().ConfigureAwait(false);
+
+ // The only sender must be done before the close frame is written: a WebSocket permits one send
+ // at a time and faults permanently on a second.
+ if (!await SettlesAsync(downstream).ConfigureAwait(false))
+ {
+ socket.Abort();
+ }
+
+ await CloseAsync(socket, ending).ConfigureAwait(false);
+
+ if (!await SettlesAsync(upstream).ConfigureAwait(false))
+ {
+ socket.Abort();
+ await SettlesAsync(upstream).ConfigureAwait(false);
+ }
+
+ await SettlesAsync(minding).ConfigureAwait(false);
+
+ return ending;
+ }
+
+ /// Client to target: every binary message is the next bytes of the stream.
+ private static async Task ClientToTargetAsync(
+ WebSocket socket,
+ Stream target,
+ Counters counters,
+ CancellationToken cancellationToken)
+ {
+ var buffer = ArrayPool.Shared.Rent(BufferBytes);
+
+ try
+ {
+ while (true)
+ {
+ ValueWebSocketReceiveResult received;
+
+ try
+ {
+ // Not cancellable, deliberately. See PumpAsync.
+ received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is WebSocketException or OperationCanceledException)
+ {
+ return ClientGone;
+ }
+
+ if (received.MessageType == WebSocketMessageType.Close)
+ {
+ return ClientGone;
+ }
+
+ // A byte pipe has no text. A client sending some has the wrong idea about this endpoint,
+ // and forwarding it into an SSH stream would corrupt that stream at best.
+ if (received.MessageType != WebSocketMessageType.Binary)
+ {
+ return new Ending(
+ RelayCloseReason.Error,
+ (int)WebSocketCloseStatus.InvalidMessageType,
+ "The relay carries binary messages only.");
+ }
+
+ try
+ {
+ await target.WriteAsync(buffer.AsMemory(0, received.Count), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (IOException)
+ {
+ return TargetGone;
+ }
+
+ counters.AddClientToTarget(received.Count);
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ /// Target to client: every read becomes one binary message.
+ private static async Task TargetToClientAsync(
+ Stream target,
+ WebSocket socket,
+ Counters counters,
+ CancellationToken cancellationToken)
+ {
+ var buffer = ArrayPool.Shared.Rent(BufferBytes);
+
+ try
+ {
+ while (true)
+ {
+ int read;
+
+ try
+ {
+ read = await target.ReadAsync(buffer.AsMemory(0, BufferBytes), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (IOException)
+ {
+ return TargetGone;
+ }
+
+ if (read == 0)
+ {
+ return TargetGone;
+ }
+
+ try
+ {
+ // Not cancellable, deliberately. See PumpAsync.
+ await socket
+ .SendAsync(
+ buffer.AsMemory(0, read),
+ WebSocketMessageType.Binary,
+ endOfMessage: true,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException)
+ {
+ return ClientGone;
+ }
+
+ counters.AddTargetToClient(read);
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ ///
+ /// Ends the session at the idle timeout, the lifetime cap, or the end of a shutdown's drain.
+ ///
+ ///
+ ///
+ /// A shutdown does not end a session; it starts a countdown. ADR 0004 asks that a
+ /// docker compose up -d not guillotine live shells, so a session in progress when the server is
+ /// told to stop is given the drain timeout to finish — most will, because the user was already
+ /// closing it or the deployment waits — and is closed with 1001 only when that runs out, which tells
+ /// the client to reconnect rather than to report a fault. The host's own shutdown timeout is set
+ /// longer than the drain for this to be possible at all.
+ ///
+ ///
+ /// Idle means no bytes in either direction. WebSocket pings are not bytes of the stream and do not
+ /// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
+ ///
+ ///
+ private async Task MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
+ {
+ var settings = options.Value;
+ var hardDeadline = started + settings.MaxSessionDuration;
+ DateTimeOffset? drainDeadline = null;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var now = clock.GetUtcNow();
+
+ if (drainDeadline is null && lifetime.ApplicationStopping.IsCancellationRequested)
+ {
+ drainDeadline = now + settings.DrainTimeout;
+ }
+
+ if (now >= hardDeadline)
+ {
+ return new Ending(
+ RelayCloseReason.DurationLimit,
+ RelayProtocol.DurationLimitCloseCode,
+ "This relay session reached its maximum lifetime.");
+ }
+
+ if (drainDeadline is { } drain && now >= drain)
+ {
+ return ShuttingDown;
+ }
+
+ var idleDeadline = counters.LastActivity + settings.IdleTimeout;
+
+ if (now >= idleDeadline)
+ {
+ return new Ending(
+ RelayCloseReason.IdleTimeout,
+ RelayProtocol.IdleTimeoutCloseCode,
+ "This relay session carried nothing for the idle timeout.");
+ }
+
+ var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
+
+ // Woken early by the start of a shutdown, so the drain is measured from when it began rather
+ // than from whenever the idle clock next came round.
+ using var wake = drainDeadline is null
+ ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
+ : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ try
+ {
+ await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // The shutdown began. Round again to start the drain.
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ private static Ending ClientGone { get; } =
+ new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
+
+ private static Ending TargetGone { get; } =
+ new(RelayCloseReason.TargetClosed, (int)WebSocketCloseStatus.NormalClosure, "The host closed the connection.");
+
+ ///
+ /// Opens the audit row, before the target is dialled, so a session that never reached its host is
+ /// still one the operator can see.
+ ///
+ private async Task OpenAuditAsync(RelayTicket ticket, DateTimeOffset started)
+ {
+ var scope = scopes.CreateAsyncScope();
+ await using var _ = scope.ConfigureAwait(false);
+
+ var database = scope.ServiceProvider.GetRequiredService();
+
+ database.RelaySessions.Add(new RelaySession
+ {
+ Id = ticket.SessionId,
+ UserId = ticket.UserId,
+ HostId = ticket.HostId,
+ VaultId = ticket.VaultId,
+ TargetHost = ticket.TargetHost,
+ TargetPort = ticket.TargetPort,
+ ClientAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
+ StartedAtUtc = started,
+ });
+
+ // Not the request's token: the row has to be written even for a client that has already gone,
+ // or a session that was cut off would be the one with no record.
+ await database.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+
+ ///
+ /// Closes the audit row. A failure is logged and swallowed: the bytes have already moved, and
+ /// throwing here would only turn a finished session into an unhandled exception.
+ ///
+ private async Task CloseAuditAsync(
+ Guid sessionId,
+ IPAddress? address,
+ Counters counters,
+ RelayCloseReason reason)
+ {
+ try
+ {
+ var scope = scopes.CreateAsyncScope();
+ await using var _ = scope.ConfigureAwait(false);
+
+ var database = scope.ServiceProvider.GetRequiredService();
+ var ended = clock.GetUtcNow();
+ var dialled = address?.ToString();
+
+ await database.RelaySessions
+ .Where(session => session.Id == sessionId)
+ .ExecuteUpdateAsync(
+ set => set
+ .SetProperty(session => session.EndedAtUtc, ended)
+ .SetProperty(session => session.TargetAddress, dialled)
+ .SetProperty(session => session.BytesClientToTarget, counters.ClientToTarget)
+ .SetProperty(session => session.BytesTargetToClient, counters.TargetToClient)
+ .SetProperty(session => session.CloseReason, reason),
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is DbUpdateException or NpgsqlException or InvalidOperationException)
+ {
+ RelayLog.AuditCloseFailed(logger, sessionId, exception);
+ }
+ }
+
+ private static async Task CloseAsync(WebSocket socket, Ending ending)
+ {
+ if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
+ {
+ return;
+ }
+
+ using var timeout = new CancellationTokenSource(CloseTimeout);
+
+ try
+ {
+ await socket
+ .CloseOutputAsync((WebSocketCloseStatus)ending.Code, ending.Description, timeout.Token)
+ .ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
+ {
+ // The peer is already gone. There is nothing to tell it.
+ }
+ }
+
+ /// Waits for a loop to finish, for at most the close timeout.
+ /// Whether it finished.
+ private static async Task SettlesAsync(Task loop)
+ {
+ try
+ {
+ await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
+
+ return true;
+ }
+ catch (TimeoutException)
+ {
+ return false;
+ }
+ catch (Exception exception)
+ when (exception is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
+ {
+ return true;
+ }
+ }
+
+ /// How a session ended, and what the client is told.
+ private sealed record Ending(RelayCloseReason Reason, int Code, string Description);
+
+ ///
+ /// Byte counts and the time of the last byte, written by the two directions and read by the clock.
+ ///
+ private sealed class Counters(TimeProvider clock, DateTimeOffset started)
+ {
+ private long clientToTarget;
+ private long targetToClient;
+ private long lastActivityTicks = started.UtcTicks;
+
+ internal long ClientToTarget => Interlocked.Read(ref clientToTarget);
+
+ internal long TargetToClient => Interlocked.Read(ref targetToClient);
+
+ internal DateTimeOffset LastActivity => new(Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
+
+ internal void AddClientToTarget(int count)
+ {
+ Interlocked.Add(ref clientToTarget, count);
+ Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
+ }
+
+ internal void AddTargetToClient(int count)
+ {
+ Interlocked.Add(ref targetToClient, count);
+ Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
+ }
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Relay/RelayLog.cs b/src/DodoSSH.Api/Features/Relay/RelayLog.cs
new file mode 100644
index 0000000..3ea1b46
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelayLog.cs
@@ -0,0 +1,94 @@
+using DodoSSH.Domain;
+
+namespace DodoSSH.Api.Features.Relay;
+
+/// Source-generated log messages for the relay.
+///
+///
+/// Ids, counts and reasons. Never a ticket, which is a bearer credential for thirty seconds, and never
+/// the target's address: that is in the audit table, where retention is the operator's decision, and
+/// copying it into a log would make it the log pipeline's decision too.
+///
+///
+/// is the one place the reason for a refused target is written down. The
+/// client is told nothing specific, deliberately — see RelayTicketEndpoint — so this line is how
+/// an operator answers "why will the relay not reach this host".
+///
+///
+internal static partial class RelayLog
+{
+ [LoggerMessage(
+ EventId = 2301,
+ Level = LogLevel.Information,
+ Message = "Relay ticket refused for user {UserId} and host {HostId}: {Reason}.")]
+ internal static partial void TargetRejected(ILogger logger, Guid userId, Guid hostId, string reason);
+
+ [LoggerMessage(
+ EventId = 2302,
+ Level = LogLevel.Debug,
+ Message = "Relay ticket issued to user {UserId} for host {HostId}, session {SessionId}, with "
+ + "{AddressCount} permitted address(es).")]
+ internal static partial void TicketIssued(
+ ILogger logger,
+ Guid userId,
+ Guid hostId,
+ Guid sessionId,
+ int addressCount);
+
+ ///
+ /// The session id is known only for a ticket that verified and had already been spent, which is the
+ /// case worth an operator's attention: a replay, or a client retrying a redemption it thinks failed.
+ ///
+ [LoggerMessage(
+ EventId = 2303,
+ Level = LogLevel.Information,
+ Message = "Relay ticket refused at the socket (session {SessionId}): forged, expired or already used.")]
+ internal static partial void TicketRefused(ILogger logger, Guid? sessionId);
+
+ [LoggerMessage(
+ EventId = 2304,
+ Level = LogLevel.Information,
+ Message = "Relay session refused for user {UserId}: a session limit is met, with {SessionCount} "
+ + "open on this node.")]
+ internal static partial void LimitReached(ILogger logger, Guid userId, int sessionCount);
+
+ [LoggerMessage(
+ EventId = 2305,
+ Level = LogLevel.Information,
+ Message = "Relay session {SessionId} could not reach host {HostId} on any permitted address.")]
+ internal static partial void TargetUnreachable(ILogger logger, Guid sessionId, Guid hostId);
+
+ [LoggerMessage(
+ EventId = 2306,
+ Level = LogLevel.Debug,
+ Message = "Relay session {SessionId} opened for user {UserId} to host {HostId}; {SessionCount} "
+ + "open on this node.")]
+ internal static partial void SessionOpened(
+ ILogger logger,
+ Guid sessionId,
+ Guid userId,
+ Guid hostId,
+ int sessionCount);
+
+ [LoggerMessage(
+ EventId = 2307,
+ Level = LogLevel.Debug,
+ Message = "Relay session {SessionId} closed ({Reason}) after {ClientToTarget} byte(s) up and "
+ + "{TargetToClient} down.")]
+ internal static partial void SessionClosed(
+ ILogger logger,
+ Guid sessionId,
+ RelayCloseReason reason,
+ long clientToTarget,
+ long targetToClient);
+
+ ///
+ /// A warning rather than an error: the session itself is over and was fine. What is lost is the
+ /// audit row's second half, which leaves it looking like a session still open.
+ ///
+ [LoggerMessage(
+ EventId = 2308,
+ Level = LogLevel.Warning,
+ Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
+ internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
+}
diff --git a/src/DodoSSH.Api/Features/Relay/RelayNetwork.cs b/src/DodoSSH.Api/Features/Relay/RelayNetwork.cs
new file mode 100644
index 0000000..d0e6455
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelayNetwork.cs
@@ -0,0 +1,61 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace DodoSSH.Api.Features.Relay;
+
+///
+/// The two things the relay asks of the network: what a name resolves to, and a connection to an address.
+///
+///
+///
+/// A seam for tests, and a narrow one on purpose. is not behind it:
+/// whatever this answers, the policy judges, and a test that replaces this still runs every address
+/// through the real rules. What a test cannot do without the seam is dial anything, because every
+/// address a test machine can reach on its own — loopback — is one the policy refuses outright.
+///
+///
+internal interface IRelayNetwork
+{
+ /// Resolves a stored hostname, which may be an address literal.
+ Task ResolveAsync(string host, CancellationToken cancellationToken);
+
+ /// Opens a TCP connection to one address.
+ /// A stream that owns the connection.
+ Task ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken);
+}
+
+/// The operating system's resolver and sockets.
+internal sealed class SystemRelayNetwork : IRelayNetwork
+{
+ ///
+ public Task ResolveAsync(string host, CancellationToken cancellationToken) =>
+
+ // A literal parses to itself without a lookup, which is what an address typed into a host
+ // editor should do — and what makes "10.0.0.5" mean the same thing on every node.
+ IPAddress.TryParse(host, out var literal)
+ ? Task.FromResult([literal])
+ : Dns.GetHostAddressesAsync(host, cancellationToken);
+
+ ///
+ public async Task ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
+ {
+ var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
+ {
+ // An interactive shell is a stream of keystrokes; Nagle would hold each one back for the
+ // acknowledgement of the last, which is the latency the user feels as a sticky terminal.
+ NoDelay = true,
+ };
+
+ try
+ {
+ await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
+
+ return new NetworkStream(socket, ownsSocket: true);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Relay/RelaySessionRegistry.cs b/src/DodoSSH.Api/Features/Relay/RelaySessionRegistry.cs
new file mode 100644
index 0000000..a2b3514
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelaySessionRegistry.cs
@@ -0,0 +1,111 @@
+using DodoSSH.Api.Setup;
+using Microsoft.Extensions.Options;
+
+namespace DodoSSH.Api.Features.Relay;
+
+///
+/// Counts the relay sessions open on this node, and refuses the ones over ADR 0004's limits.
+///
+///
+///
+/// Per node, and so per user per node: a user behind a round-robin balancer with three nodes
+/// could hold three times the per-user cap. That is accepted rather than fixed, because fixing it means
+/// shared state on the hot path of every connect, and the cap exists to stop one account exhausting a
+/// node — which a per-node count does exactly.
+///
+///
+/// A singleton, because a session outlives the request that opened it. It holds counts and nothing else;
+/// the sockets belong to the handlers that opened them.
+///
+///
+internal sealed class RelaySessionRegistry(IOptions options)
+{
+ private readonly Lock gate = new();
+ private readonly Dictionary perUser = [];
+ private int total;
+
+ /// How many sessions are open on this node.
+ internal int Count
+ {
+ get
+ {
+ lock (gate)
+ {
+ return total;
+ }
+ }
+ }
+
+ /// Whether a session for this user would be admitted now.
+ ///
+ /// Advisory, for the ticket endpoint: it lets a caller already at the cap hear so as an HTTP status
+ /// before it has spent a ticket, but only decides, since the answer here can
+ /// be stale by the time the socket arrives — possibly at another node.
+ ///
+ internal bool HasRoomFor(Guid userId)
+ {
+ var settings = options.Value;
+
+ lock (gate)
+ {
+ return total < settings.MaxConcurrentSessionsTotal
+ && perUser.GetValueOrDefault(userId) < settings.MaxConcurrentSessionsPerUser;
+ }
+ }
+
+ /// Admits a session, or answers null when a limit is met.
+ /// A lease that gives the place back when disposed.
+ internal Lease? TryAdmit(Guid userId)
+ {
+ var settings = options.Value;
+
+ lock (gate)
+ {
+ var held = perUser.GetValueOrDefault(userId);
+
+ if (total >= settings.MaxConcurrentSessionsTotal || held >= settings.MaxConcurrentSessionsPerUser)
+ {
+ return null;
+ }
+
+ perUser[userId] = held + 1;
+ total++;
+
+ return new Lease(this, userId);
+ }
+ }
+
+ private void Release(Guid userId)
+ {
+ lock (gate)
+ {
+ total--;
+
+ // Removed at zero rather than left behind, so the dictionary is the size of the users with a
+ // session open and not of every user who ever had one.
+ if (perUser[userId] <= 1)
+ {
+ perUser.Remove(userId);
+ }
+ else
+ {
+ perUser[userId]--;
+ }
+ }
+ }
+
+ /// One admitted session's place. Idempotent to dispose.
+ internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
+ {
+ private int released;
+
+ ///
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref released, 1) == 0)
+ {
+ registry.Release(userId);
+ }
+ }
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Relay/RelayTicketCodec.cs b/src/DodoSSH.Api/Features/Relay/RelayTicketCodec.cs
new file mode 100644
index 0000000..d1ae4af
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelayTicketCodec.cs
@@ -0,0 +1,171 @@
+using System.Net;
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace DodoSSH.Api.Features.Relay;
+
+///
+/// Everything the relay socket needs to know, decided by the ticket endpoint.
+///
+///
+/// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it
+/// to resolve again — is carried for the audit row only — which is what keeps a
+/// DNS answer that changes between issue and connect from changing where the bytes go.
+///
+/// The replay guard's key.
+/// The audit row's id, already given to the client.
+/// Who asked.
+/// The host it is for.
+/// The host's vault at issue.
+/// The hostname as stored, for the audit row.
+/// The permitted addresses the name resolved to, in the order to try.
+/// The port.
+/// When the ticket stops verifying.
+internal sealed record RelayTicket(
+ Guid TicketId,
+ Guid SessionId,
+ Guid UserId,
+ Guid HostId,
+ Guid VaultId,
+ string TargetHost,
+ IReadOnlyList TargetAddresses,
+ int TargetPort,
+ DateTimeOffset ExpiresAt);
+
+///
+/// Seals and opens relay tickets.
+///
+///
+///
+/// A Data Protection payload, per ADR 0004: authenticated and encrypted, with key rotation handled by
+/// the framework, and verifiable on any node that shares the key ring. Encrypted rather than merely
+/// signed because the ticket travels in a header every proxy on the way can read, and what it holds is
+/// the resolved address of a machine on the operator's network.
+///
+///
+/// The expiry is inside the payload and checked against , rather than left to
+/// the framework's time-limited protector, which reads the system clock directly — the thing this
+/// repository bans so that time can be faked in tests.
+///
+///
+/// The layout is a version byte and fixed fields, written by hand. Small enough that a serialiser would
+/// be the larger half of the code, and a hand layout has no reflection for trimming to break.
+///
+///
+internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock)
+{
+ /// The purpose string, which isolates these payloads from every other protector's.
+ private const string Purpose = "DodoSSH.Relay.Ticket";
+
+ private const byte LayoutVersion = 1;
+
+ /// More addresses than any real name resolves to, and a bound on what a ticket can carry.
+ internal const int MaxAddresses = 16;
+
+ private readonly IDataProtector protector = protection.CreateProtector(Purpose);
+
+ internal string Seal(RelayTicket ticket)
+ {
+ ArgumentNullException.ThrowIfNull(ticket);
+
+ using var buffer = new MemoryStream();
+ using (var writer = new BinaryWriter(buffer))
+ {
+ writer.Write(LayoutVersion);
+ writer.Write(ticket.TicketId.ToByteArray());
+ writer.Write(ticket.SessionId.ToByteArray());
+ writer.Write(ticket.UserId.ToByteArray());
+ writer.Write(ticket.HostId.ToByteArray());
+ writer.Write(ticket.VaultId.ToByteArray());
+ writer.Write(ticket.ExpiresAt.ToUnixTimeMilliseconds());
+ writer.Write(ticket.TargetPort);
+ writer.Write(ticket.TargetHost);
+ writer.Write((byte)ticket.TargetAddresses.Count);
+
+ foreach (var address in ticket.TargetAddresses)
+ {
+ var bytes = address.GetAddressBytes();
+ writer.Write((byte)bytes.Length);
+ writer.Write(bytes);
+ }
+ }
+
+ // Base64url, whose alphabet is all token characters, so it can stand in a subprotocol name.
+ return WebEncoders.Base64UrlEncode(protector.Protect(buffer.ToArray()));
+ }
+
+ /// Opens a ticket, or answers null for one that is forged, damaged or expired.
+ ///
+ /// One answer for all three, because the caller's remedy is the same — ask for another — and a
+ /// distinct "expired" would tell a forger which of their attempts had verified.
+ ///
+ internal RelayTicket? TryOpen(string sealedTicket)
+ {
+ byte[] payload;
+
+ try
+ {
+ payload = protector.Unprotect(WebEncoders.Base64UrlDecode(sealedTicket));
+ }
+ catch (Exception exception) when (exception is CryptographicException or FormatException)
+ {
+ return null;
+ }
+
+ RelayTicket ticket;
+
+ try
+ {
+ ticket = Read(payload);
+ }
+ catch (Exception exception)
+ when (exception is EndOfStreamException or ArgumentException or FormatException)
+ {
+ return null;
+ }
+
+ return ticket.ExpiresAt > clock.GetUtcNow() ? ticket : null;
+ }
+
+ private static RelayTicket Read(byte[] payload)
+ {
+ using var reader = new BinaryReader(new MemoryStream(payload));
+
+ if (reader.ReadByte() != LayoutVersion)
+ {
+ throw new FormatException("Unknown ticket layout.");
+ }
+
+ var ticketId = new Guid(reader.ReadBytes(16));
+ var sessionId = new Guid(reader.ReadBytes(16));
+ var userId = new Guid(reader.ReadBytes(16));
+ var hostId = new Guid(reader.ReadBytes(16));
+ var vaultId = new Guid(reader.ReadBytes(16));
+ var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.ReadInt64());
+ var port = reader.ReadInt32();
+ var host = reader.ReadString();
+ var count = reader.ReadByte();
+
+ if (count is 0 or > MaxAddresses)
+ {
+ throw new FormatException("A ticket names between one and sixteen addresses.");
+ }
+
+ var addresses = new IPAddress[count];
+
+ for (var index = 0; index < count; index++)
+ {
+ var length = reader.ReadByte();
+
+ if (length is not (4 or 16))
+ {
+ throw new FormatException("An address is four or sixteen bytes.");
+ }
+
+ addresses[index] = new IPAddress(reader.ReadBytes(length));
+ }
+
+ return new RelayTicket(ticketId, sessionId, userId, hostId, vaultId, host, addresses, port, expiresAt);
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Relay/RelayTicketEndpoint.cs b/src/DodoSSH.Api/Features/Relay/RelayTicketEndpoint.cs
new file mode 100644
index 0000000..eee5679
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelayTicketEndpoint.cs
@@ -0,0 +1,231 @@
+using System.Net;
+using System.Net.Sockets;
+using DodoSSH.Api.Authorization;
+using DodoSSH.Api.Setup;
+using DodoSSH.Contracts;
+using DodoSSH.Domain.Authorization;
+using DodoSSH.Infrastructure;
+using FastEndpoints;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace DodoSSH.Api.Features.Relay;
+
+///
+/// Issues the ticket that lets exactly one relay socket reach exactly one host.
+///
+///
+///
+/// Every decision the relay makes is made here, with the full bearer token and the full access model in
+/// hand: which host, whether the caller may connect to it, which addresses its name resolves to and
+/// which of those the policy permits. The socket that follows decides nothing. It checks that the ticket
+/// is genuine, fresh and unused, and dials what the ticket says. See ADR 0004.
+///
+///
+/// Every refusal about the target is the same refusal. A host that does not exist, one in a vault
+/// the caller cannot reach, one without the relay switched on, one whose name does not resolve, and one
+/// whose every address the policy refuses all answer 403 relay-target-rejected with the same
+/// sentence. Anyone who can write a host can set its address to anything, so a distinct answer for
+/// "resolves, but refused" would turn this endpoint into a scanner for the operator's internal DNS. The
+/// real reason goes to the server log, where the operator can read it.
+///
+///
+internal sealed class RelayTicketEndpoint(
+ ICurrentUserContext currentUser,
+ IVaultAccessService vaultAccess,
+ DodoDbContext database,
+ IRelayNetwork network,
+ RelayTicketCodec codec,
+ RelaySessionRegistry sessions,
+ IOptions options,
+ TimeProvider clock,
+ ILogger logger)
+ : Endpoint, ProblemHttpResult>>
+{
+ /// What every target refusal says, whatever the reason was.
+ private const string RejectedDetail =
+ "The relay will not connect to this host. Check that the relay is switched on for it and that its "
+ + "address is one this server may reach; the server's log records which.";
+
+ ///
+ public override void Configure()
+ {
+ Post(RelayProtocol.TicketsPath);
+
+ // Enrolled, matching sync. A relayed host is still a host whose name, username and key are
+ // ciphertext, so a caller with no identity key has no way to have learned which host to ask for.
+ Policies(Auth.EnrolledPolicy);
+
+ Description(b => b
+ .WithName("IssueRelayTicket")
+ .WithSummary("Issues a single-use ticket for one relay connection to one host.")
+ .WithTags("Relay"));
+ }
+
+ ///
+ public override async Task, ProblemHttpResult>> ExecuteAsync(
+ RelayTicketRequest req,
+ CancellationToken ct)
+ {
+ var settings = options.Value;
+
+ if (!settings.Enabled)
+ {
+ return Problems.Coded(
+ StatusCodes.Status404NotFound,
+ ProblemCodes.RelayUnavailable,
+ "This server does not relay. Connect to the host directly; GET /api/v1/meta lists the "
+ + "features this server does offer.");
+ }
+
+ var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
+ var (target, refusal) = await FindTargetAsync(user.Id, req, ct).ConfigureAwait(false);
+
+ if (target is not var (hostname, port, vaultId))
+ {
+ return Rejected(user.Id, req.HostId, refusal!);
+ }
+
+ // Before resolving rather than after: a caller already at the cap is told so without this server
+ // spending a DNS query on a ticket that could not be redeemed.
+ if (!sessions.HasRoomFor(user.Id))
+ {
+ RelayLog.LimitReached(logger, user.Id, sessions.Count);
+
+ return Problems.Coded(
+ StatusCodes.Status429TooManyRequests,
+ ProblemCodes.RelayLimitReached,
+ "You have as many relay sessions open as this server allows. Close one and try again.");
+ }
+
+ var addresses = await ResolvePermittedAsync(hostname, ct).ConfigureAwait(false);
+
+ if (addresses is not { Length: > 0 })
+ {
+ return Rejected(user.Id, req.HostId, addresses is null
+ ? "its name did not resolve"
+ : "every address its name resolved to is refused by policy");
+ }
+
+ var ticket = new RelayTicket(
+ TicketId: Guid.CreateVersion7(),
+ SessionId: Guid.CreateVersion7(),
+ UserId: user.Id,
+ HostId: req.HostId,
+ VaultId: vaultId,
+ TargetHost: hostname,
+ TargetAddresses: addresses,
+ TargetPort: port,
+ ExpiresAt: clock.GetUtcNow() + settings.TicketLifetime);
+
+ RelayLog.TicketIssued(logger, user.Id, req.HostId, ticket.SessionId, addresses.Length);
+
+ return TypedResults.Ok(new RelayTicketResponse(
+ Ticket: codec.Seal(ticket),
+ WebSocketUrl: new Uri(settings.WebSocketUrl!, UriKind.Absolute),
+ SubProtocol: RelayProtocol.SubProtocol,
+ SessionId: ticket.SessionId,
+ ExpiresAt: ticket.ExpiresAt));
+ }
+
+ ///
+ /// Finds the host, and decides whether this caller may be relayed to it at all.
+ ///
+ /// The stored target, or the reason there is none — for the log, never for the caller.
+ private async Task<((string Hostname, int Port, Guid VaultId)? Target, string? Refusal)> FindTargetAsync(
+ Guid userId,
+ RelayTicketRequest req,
+ CancellationToken cancellationToken)
+ {
+ if (req.PortForwardId is not null)
+ {
+ return (null, "a port forward was requested, which v1 does not relay");
+ }
+
+ var host = await database.Hosts
+ .AsNoTracking()
+ .SingleOrDefaultAsync(h => h.Id == req.HostId && h.DeletedAtUtc == null, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (host is null)
+ {
+ return (null, "no such host");
+ }
+
+ var access = await vaultAccess.ResolveAsync(userId, host.VaultId, cancellationToken).ConfigureAwait(false);
+
+ // Connect rather than Read, because Connect is the permission that says what this is. Every role
+ // that reads today also connects — see VaultAccessService.ForRole — but a role that ever split
+ // them should be refused a relay here without anybody remembering to look.
+ if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Connect))
+ {
+ return (null, "the caller may not connect to this host's vault");
+ }
+
+ // The CHECK constraint makes the second half unreachable while the first is true. Tested anyway,
+ // because the cost of trusting it is dialling a null.
+ if (!host.RelayEnabled || host.Hostname is not { } hostname || host.Port is not { } port)
+ {
+ return (null, "the relay is not switched on for this host");
+ }
+
+ return RelayAddressPolicy.PermitsPort(port)
+ ? ((hostname, port, host.VaultId), null)
+ : (null, $"port {port} is refused by policy");
+ }
+
+ ///
+ /// Resolves the stored name, and keeps the addresses the policy permits.
+ ///
+ /// The permitted addresses, possibly none; or null when the name did not resolve.
+ ///
+ ///
+ /// Filtered rather than refused as a whole when one answer is bad. A name that resolves to a public
+ /// address and a link-local one is dialled on the public address, and never on the other — the
+ /// ticket carries only what passed, so there is nothing else for the socket to try.
+ ///
+ ///
+ /// The addresses kept are the normalised ones, so what is dialled is exactly what was judged rather
+ /// than a second spelling of it.
+ ///
+ ///
+ private async Task ResolvePermittedAsync(string hostname, CancellationToken cancellationToken)
+ {
+ var policy = new RelayAddressPolicy(options.Value);
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(options.Value.ConnectTimeout);
+
+ IPAddress[] resolved;
+
+ try
+ {
+ resolved = await network.ResolveAsync(hostname, timeout.Token).ConfigureAwait(false);
+ }
+ catch (SocketException)
+ {
+ return null;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return null;
+ }
+
+ return
+ [
+ .. resolved
+ .Where(policy.Permits)
+ .Select(RelayAddressPolicy.Normalise)
+ .Distinct()
+ .Take(RelayTicketCodec.MaxAddresses),
+ ];
+ }
+
+ private ProblemHttpResult Rejected(Guid userId, Guid hostId, string reason)
+ {
+ RelayLog.TargetRejected(logger, userId, hostId, reason);
+
+ return Problems.Coded(StatusCodes.Status403Forbidden, ProblemCodes.RelayTargetRejected, RejectedDetail);
+ }
+}
diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs
index 650f060..7458dd9 100644
--- a/src/DodoSSH.Api/Program.cs
+++ b/src/DodoSSH.Api/Program.cs
@@ -49,6 +49,9 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton(
provider => provider.GetRequiredService());
+// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
+builder.Services.AddDodoRelay();
+
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
// DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped();
diff --git a/src/DodoSSH.Api/Setup/Configuration.cs b/src/DodoSSH.Api/Setup/Configuration.cs
index 1deb6ef..10f3cb3 100644
--- a/src/DodoSSH.Api/Setup/Configuration.cs
+++ b/src/DodoSSH.Api/Setup/Configuration.cs
@@ -54,6 +54,13 @@ internal static class Configuration
+ "setting exists to provide.")
.ValidateOnStart();
+ services.AddRelayOptions();
+
+ return services;
+ }
+
+ /// The relay's options, which carry more rules than any other section. See ADR 0004.
+ private static void AddRelayOptions(this IServiceCollection services) =>
services.AddOptions()
.BindConfiguration(RelayOptions.SectionName)
.ValidateDataAnnotations()
@@ -63,11 +70,28 @@ internal static class Configuration
.Validate(
options => options.IdleTimeout < options.MaxSessionDuration,
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
+ .Validate(
+ options => options.TicketLifetime > TimeSpan.Zero
+ && options.TicketLifetime <= TimeSpan.FromSeconds(30),
+ "Relay:TicketLifetime must be positive and at most 30 seconds. A ticket is a bearer "
+ + "credential in a header; ADR 0004 bounds how long one may be worth stealing.")
+ .Validate(
+ options => options.ConnectTimeout > TimeSpan.Zero && options.DrainTimeout >= TimeSpan.Zero,
+ "Relay:ConnectTimeout must be positive and Relay:DrainTimeout must not be negative.")
+ .Validate(
+ options => Features.Relay.RelayAddressPolicy.AreNetworks(options.AllowedNetworks)
+ && Features.Relay.RelayAddressPolicy.AreNetworks(options.DeniedNetworks),
+ "Relay:AllowedNetworks and Relay:DeniedNetworks must hold CIDR networks such as "
+ + "10.20.0.0/16, with no host bits set. An entry this server cannot read would be a gap "
+ + "the operator believes is closed.")
+ .Validate(
+ options => !options.Enabled
+ || (Uri.TryCreate(options.WebSocketUrl, UriKind.Absolute, out var url)
+ && (string.Equals(url.Scheme, Uri.UriSchemeWs, StringComparison.Ordinal)
+ || string.Equals(url.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))),
+ "Relay:WebSocketUrl must be an absolute ws:// or wss:// URL.")
.ValidateOnStart();
- return services;
- }
-
///
/// Warns about configuration that is individually valid but dangerous in combination.
///
diff --git a/src/DodoSSH.Api/Setup/DodoOptions.cs b/src/DodoSSH.Api/Setup/DodoOptions.cs
index 100326a..077d612 100644
--- a/src/DodoSSH.Api/Setup/DodoOptions.cs
+++ b/src/DodoSSH.Api/Setup/DodoOptions.cs
@@ -121,17 +121,44 @@ public sealed class RelayOptions
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
///
///
- /// Nothing reads this setting yet, and that is the point of saying so here. The relay is
- /// an ADR and a contract; there is no endpoint and no dialler. This option exists so the shape
- /// is settled before the code is, which makes it exactly the kind of comment that gets read as a
- /// description of an implemented control. It is not one. Whoever builds the dialler owes ADR 0004
- /// §3 in full — loopback, link-local, 0.0.0.0/8, multicast and the cloud metadata addresses
- /// denied with no configuration escape, and IPv4-mapped IPv6 normalised and re-checked — and this
- /// flag governs RFC1918 only, never that list.
+ /// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
+ /// IPv6 unique-local — and never the list RelayAddressPolicy refuses with no configuration
+ /// escape: loopback, link-local, 0.0.0.0/8, multicast and the cloud metadata addresses.
+ /// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
///
///
public bool AllowPrivateNetworks { get; set; } = true;
+ ///
+ /// Networks, in CIDR form, that a relay target must fall inside. Empty means any network the other
+ /// rules permit.
+ ///
+ ///
+ /// When set, this is the whole answer for the ranges it names: a target inside one is permitted even
+ /// with off, because "only 10.20.0.0/16" is the precise thing an
+ /// operator means by setting both. It still cannot reach the non-overridable list, and
+ /// still wins over it.
+ ///
+ public IList AllowedNetworks { get; } = [];
+
+ /// Networks, in CIDR form, that a relay target may never fall inside.
+ ///
+ /// Checked before , so a deny inside an allow is a hole in it — the way
+ /// round that lets an operator carve the database subnet out of an otherwise permitted range.
+ ///
+ public IList DeniedNetworks { get; } = [];
+
+ ///
+ /// A directory to keep the ticket key ring in. Unset means the platform default.
+ ///
+ ///
+ /// Losing the key ring is harmless in the way losing Sync:CursorSigningKey is: a ticket lives
+ /// thirty seconds, so a restart invalidates at most the connects in flight and the client asks
+ /// again. What does need it is more than one node — every node must be able to read every other's
+ /// tickets for round-robin to work, so a multi-node deployment points this at shared storage.
+ ///
+ public string? KeyRingDirectory { get; set; }
+
/// Maximum concurrent sessions per user.
[Range(1, 1000)]
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
index 3fde3ba..afc19b9 100644
--- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs
+++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
@@ -1,6 +1,7 @@
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
+using DodoSSH.Api.Features.Relay;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts;
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint),
+ typeof(RelayTicketEndpoint),
+ typeof(RelayConnectEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint),
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Vaults — per-item ACLs
- // Relay — tickets and the WebSocket
// Audit, Admin
});
diff --git a/src/DodoSSH.Api/Setup/RelaySetup.cs b/src/DodoSSH.Api/Setup/RelaySetup.cs
new file mode 100644
index 0000000..cee1ca0
--- /dev/null
+++ b/src/DodoSSH.Api/Setup/RelaySetup.cs
@@ -0,0 +1,59 @@
+using DodoSSH.Api.Features.Relay;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.DataProtection.KeyManagement;
+using Microsoft.AspNetCore.DataProtection.Repositories;
+using Microsoft.Extensions.Options;
+
+namespace DodoSSH.Api.Setup;
+
+/// The relay's services. See ADR 0004.
+internal static class RelaySetup
+{
+ ///
+ /// How much longer than the drain the host waits before it stops regardless.
+ ///
+ ///
+ /// Room for the close handshakes and the audit rows' final writes once the drain has run out, which
+ /// is the part of a shutdown an operator would otherwise lose without noticing.
+ ///
+ private static readonly TimeSpan ShutdownMargin = TimeSpan.FromSeconds(10);
+
+ internal static IServiceCollection AddDodoRelay(this IServiceCollection services)
+ {
+ // Singletons: a session outlives the request that opened it, a protector is safe to share, and
+ // the network seam holds no state.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // The application name isolates this deployment's payloads from any other ASP.NET application
+ // sharing a key directory, and — unlike the default, which is derived from the content root —
+ // is the same on every node, which is what lets any node verify any node's ticket.
+ services.AddDataProtection().SetApplicationName("DodoSSH");
+
+ services.AddOptions()
+ .Configure, ILoggerFactory>((keys, relay, loggers) =>
+ {
+ if (relay.Value.KeyRingDirectory is { Length: > 0 } directory)
+ {
+ keys.XmlRepository = new FileSystemXmlRepository(new DirectoryInfo(directory), loggers);
+ }
+ });
+
+ // Longer than the drain, or the host would stop waiting for live sessions before the drain ADR 0004
+ // promises them had run. Harmless with the relay off: nothing else holds a request open that long,
+ // and the host stops as soon as the last request has.
+ services.AddOptions()
+ .Configure>((host, relay) =>
+ {
+ var wanted = relay.Value.DrainTimeout + ShutdownMargin;
+
+ if (wanted > host.ShutdownTimeout)
+ {
+ host.ShutdownTimeout = wanted;
+ }
+ });
+
+ return services;
+ }
+}
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
index a2b4624..1b094c3 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
@@ -1199,19 +1199,18 @@
port into plaintext columns the server can read, which is the single deliberate concession in
the design — see ADR 0004 — so it says so rather than being a switch labelled "relay".
- ◆ AND IT SAYS, FIRST, THAT IT BUYS NOTHING TODAY. The relay's server half is built and this
- client has no path to it — the connect path dials the address directly whether this is ticked or
- not — so the box as it stood spent that concession and delivered nothing for it. Left tickable
- rather than disabled, so a host already carrying the flag can lose it. See
+ It also says the two things a user would otherwise find out by failing: the server, not this
+ phone, has to be able to reach the address, and a relayed host does not fall back to dialling
+ directly, so it connects only while the server is reachable. See
docs/reaching-a-host-you-cannot-dial.md.
-->
+ Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
-
@@ -655,19 +654,13 @@
to resolve the target, or it becomes an authenticated open proxy into the operator's
network.
- ◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
- is built and this client has no path to it: VaultViewModel dials the address directly
- whether this is ticked or not. So the box as it stood spent the one deliberate privacy
- concession in the design — the address in plaintext columns — and delivered a
- connection that failed exactly as it had before, with nothing saying why.
-
- Left tickable rather than disabled, because a host that already carries the flag has to
- be able to lose it, and a disabled control would trap the concession on. See
- docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
- removable.
+ It also says the two things a user would otherwise find out by failing: the server,
+ not this machine, has to be able to reach the address — the relay is no help against a
+ deployment outside the target's network — and a relayed host does not fall back to
+ dialling directly, so it connects only while the server is reachable.
-->
+ Text="The server dials this host on your behalf, for a machine this computer cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in plain text; everything else about the host stays encrypted. A relayed host connects only while you are online." />
diff --git a/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj b/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
index c6a43a0..d010b0e 100644
--- a/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
+++ b/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
@@ -36,6 +36,8 @@
+
+
diff --git a/src/DodoSSH.Client.Session/ServerConnection.cs b/src/DodoSSH.Client.Session/ServerConnection.cs
index d89890a..c222b06 100644
--- a/src/DodoSSH.Client.Session/ServerConnection.cs
+++ b/src/DodoSSH.Client.Session/ServerConnection.cs
@@ -161,6 +161,15 @@ public interface IVaultServer : IDisposable
///
IVaultEventStream Events { get; }
+ ///
+ /// Reaches a host through the server, for a host set to connect that way.
+ ///
+ ///
+ /// Never null, for the reason is not: a server without a relay supplies
+ /// , which refuses in words. See ADR 0004.
+ ///
+ IServerRelay Relay { get; }
+
/// Obtains the identity provider's signature over a key statement.
IKeyBindingAuthorizer KeyBinding { get; }
@@ -226,6 +235,12 @@ public sealed class ServerConnection : IVaultServer
Events = meta.Features.Contains(VaultEvents.Feature, StringComparer.Ordinal)
? new VaultEventStream(serverUrl, tokens, clock)
: IdleVaultEventStream.Instance;
+
+ // Decided the same way and for the same reason. Configuration.Relay says the same thing twice;
+ // meta is what the server says it does, and is what Events is decided from.
+ Relay = meta.Features.Contains(RelayProtocol.Feature, StringComparer.Ordinal)
+ ? new ServerRelay(api)
+ : UnavailableServerRelay.Instance;
}
/// The server this is connected to.
@@ -261,6 +276,9 @@ public sealed class ServerConnection : IVaultServer
///
public IVaultEventStream Events { get; }
+ ///
+ public IServerRelay Relay { get; }
+
///
public IKeyBindingAuthorizer KeyBinding => Oidc;
diff --git a/src/DodoSSH.Client.Session/ServerRelay.cs b/src/DodoSSH.Client.Session/ServerRelay.cs
new file mode 100644
index 0000000..f03fcbd
--- /dev/null
+++ b/src/DodoSSH.Client.Session/ServerRelay.cs
@@ -0,0 +1,262 @@
+using System.Net;
+using System.Net.WebSockets;
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Session;
+
+///
+/// Reaches a host through the server, for a host this machine cannot dial itself.
+///
+///
+/// Always present on , like : a server that does
+/// not relay supplies , whose answer is a sentence saying so. That keeps
+/// the connect path on one shape, and it means the refusal a user reads is the same whether the server said
+/// no before being asked or when it was.
+///
+public interface IServerRelay
+{
+ /// Whether this server advertises a relay at all.
+ bool IsOffered { get; }
+
+ ///
+ /// Opens a relay to one host, ready for a connection to be made through it.
+ ///
+ ///
+ /// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
+ /// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will
+ /// use it and never ahead of one.
+ ///
+ /// The relay could not be opened; the message says why.
+ Task OpenAsync(Guid hostId, CancellationToken cancellationToken);
+}
+
+///
+/// An open relay, one connection long.
+///
+///
+/// Handing to a connection hands the bridge over with it, so the connection's own
+/// disposal is what closes the relay. is for a caller that ends up not
+/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
+///
+public sealed class RelayRoute : IAsyncDisposable
+{
+ private readonly RelayBridge bridge;
+
+ internal RelayRoute(RelayBridge bridge) => this.bridge = bridge;
+
+ /// The loopback proxy to connect through, which owns the relay from then on.
+ public SshLoopbackProxy Proxy => bridge.Proxy;
+
+ ///
+ /// What the relay said when it closed, in words a user can act on; null when it has not closed, or
+ /// closed with nothing to say.
+ ///
+ ///
+ /// For a connect that failed. SSH.NET reports a relay that hung up as a stream that ended — which reads
+ /// exactly like the direct connection that failed and sent the user to the relay — so the relay's own
+ /// reason, when it gave one, replaces it.
+ ///
+ public string? Failure => bridge.CloseStatus is { } status ? Describe((int)status) : null;
+
+ ///
+ public ValueTask DisposeAsync() => bridge.DisposeAsync();
+
+ private static string? Describe(int code) => code switch
+ {
+ RelayProtocol.TargetUnreachableCloseCode =>
+ "The server relay could not reach this host. The server dials it on your behalf, so the "
+ + "address has to be one the server can reach — check it from the server's side.",
+
+ RelayProtocol.TooManySessionsCloseCode =>
+ "The server relay has as many sessions open as it allows. Close one and try again.",
+
+ (int)WebSocketCloseStatus.EndpointUnavailable =>
+ "The server relay is restarting. Try again in a moment.",
+
+ RelayProtocol.IdleTimeoutCloseCode => "The server relay closed the connection for being idle.",
+
+ RelayProtocol.DurationLimitCloseCode =>
+ "The server relay closed the connection: it reached the longest session the server allows.",
+
+ _ => null,
+ };
+}
+
+/// A relay that could not be opened, with the reason in its message.
+///
+/// Its own type so the connect path can tell a relay refusal from a connection failure. The first has an
+/// exact reason already, and running the reachability probe over it would dial the direct address — the one
+/// the user chose the relay because they cannot reach — and append a finding about the wrong path.
+///
+public sealed class ServerRelayException : Exception
+{
+ /// A refusal with nothing underneath it.
+ public ServerRelayException(string message)
+ : base(message)
+ {
+ }
+
+ /// A refusal and what caused it.
+ public ServerRelayException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
+
+/// The relay of a server that does not offer one.
+public sealed class UnavailableServerRelay : IServerRelay
+{
+ private UnavailableServerRelay()
+ {
+ }
+
+ /// The one instance.
+ public static UnavailableServerRelay Instance { get; } = new();
+
+ ///
+ public bool IsOffered => false;
+
+ ///
+ public Task OpenAsync(Guid hostId, CancellationToken cancellationToken) =>
+ Task.FromException(new ServerRelayException(ServerRelay.NotOffered));
+}
+
+///
+/// The relay of a server that offers one: a ticket from the API, a socket, and a bridge.
+///
+///
+/// See ADR 0004 for the protocol and docs/reaching-a-host-you-cannot-dial.md for why the bridge
+/// speaks SOCKS5. Every way this can fail is turned into a sentence here, because the codes are the
+/// server's and the one place that should know what each means is the one that asked.
+///
+public sealed class ServerRelay : IServerRelay
+{
+ /// What a user is told when the server they are signed in to does not relay.
+ internal const string NotOffered =
+ "This host is set to connect through the server relay, and this server does not offer one. "
+ + "Untick “Connect through the server relay” in the host's settings to dial it directly.";
+
+ private readonly IRelayApi api;
+ private readonly Func, CancellationToken, Task> connect;
+
+ /// A relay reached through this API client.
+ public ServerRelay(IRelayApi api)
+ : this(api, DialAsync)
+ {
+ }
+
+ /// A relay whose socket is opened by , for tests.
+ internal ServerRelay(
+ IRelayApi api,
+ Func, CancellationToken, Task> connect)
+ {
+ this.api = api;
+ this.connect = connect;
+ }
+
+ ///
+ public bool IsOffered => true;
+
+ ///
+ public async Task OpenAsync(Guid hostId, CancellationToken cancellationToken)
+ {
+ RelayTicketResponse ticket;
+
+ try
+ {
+ ticket = await api.IssueRelayTicketAsync(hostId, cancellationToken).ConfigureAwait(false);
+ }
+ catch (DodoSshApiException exception)
+ {
+ throw new ServerRelayException(Describe(exception), exception);
+ }
+ catch (HttpRequestException exception)
+ {
+ throw new ServerRelayException(
+ $"The server could not be reached to open the relay: {exception.Message}",
+ exception);
+ }
+
+ // The ticket as a second subprotocol, which is how ADR 0004 carries it: a header every WebSocket
+ // client can set. The server echoes the first alone.
+ var socket = await connect(
+ ticket.WebSocketUrl,
+ [RelayProtocol.SubProtocol, RelayProtocol.TicketProtocolPrefix + ticket.Ticket],
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ return new RelayRoute(RelayBridge.Start(socket));
+ }
+
+ private static string Describe(DodoSshApiException exception) => exception.Code switch
+ {
+ ProblemCodes.RelayUnavailable => NotOffered,
+
+ // Deliberately unspecific on the server's side, so that it cannot be used to map the operator's
+ // network. The likeliest causes from here are the two this machine can check.
+ ProblemCodes.RelayTargetRejected =>
+ "The server refused to relay to this host. Check that the change turning the relay on has "
+ + "synchronised, and that the address is one the server is allowed to reach; the server's log "
+ + "records which.",
+
+ ProblemCodes.RelayLimitReached =>
+ "You have as many relay sessions open as the server allows. Close one and try again.",
+
+ _ => $"The server would not open the relay: {exception.Message}",
+ };
+
+ /// Opens the relay socket, and translates a refused upgrade into its reason.
+ ///
+ /// The status of a refused upgrade is collected, which a constrained client could not do and this one
+ /// can: a 401 is a ticket the relay would not take — spent, expired, or issued by a node whose key ring
+ /// this one does not share — and asking again is the remedy.
+ ///
+ private static async Task DialAsync(
+ Uri url,
+ IReadOnlyList protocols,
+ CancellationToken cancellationToken)
+ {
+ var socket = new ClientWebSocket();
+
+ try
+ {
+ foreach (var protocol in protocols)
+ {
+ socket.Options.AddSubProtocol(protocol);
+ }
+
+ socket.Options.CollectHttpResponseDetails = true;
+
+ await socket.ConnectAsync(url, cancellationToken).ConfigureAwait(false);
+ }
+ catch (WebSocketException exception)
+ {
+ var status = socket.HttpStatusCode;
+ socket.Dispose();
+
+ throw new ServerRelayException(
+ status == HttpStatusCode.Unauthorized
+ ? "The server relay refused its ticket. Try connecting again."
+ : $"The server relay could not be reached: {exception.Message}",
+ exception);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+
+ if (!string.Equals(socket.SubProtocol, RelayProtocol.SubProtocol, StringComparison.Ordinal))
+ {
+ socket.Abort();
+ socket.Dispose();
+
+ throw new ServerRelayException(
+ "The server relay answered in a protocol this app does not speak. Update the app or the server.");
+ }
+
+ return socket;
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
index 3f2b7bf..e3bcb49 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
@@ -1795,25 +1795,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
{
await CloseSessionAsync().ConfigureAwait(true);
- ISftpSession opened;
-
- try
+ if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
+ is not { } opened)
{
- opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
- }
- catch (SshHostKeyUnknownException exception)
- {
- // First contact, decided here rather than inherited from a terminal. File transfer is
- // its own connection, so it makes its own trust decision — and the pin it writes is the
- // same pin a shell would then find.
- PendingHostKey = exception.Presentation;
- Status = "This host has not been seen before.";
- return;
- }
- catch (SshHostKeyMismatchException exception)
- {
- HostKeyMismatch = exception.Message;
- Status = "The host key has changed. Nothing was connected.";
return;
}
@@ -1831,6 +1815,55 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}).ConfigureAwait(true);
}
+ ///
+ /// Opens the session on a host, through the relay when the host uses it, or says why not.
+ ///
+ /// The session, or null with saying why there is none.
+ private async Task OpenHostSessionAsync(
+ VaultViewModel open,
+ HostRowViewModel row,
+ SshConnectionRequest request,
+ CancellationToken cancellationToken)
+ {
+ RelayRoute? relay = null;
+
+ try
+ {
+ // Through the relay for a host set to use it, exactly as a shell to it would be. The
+ // session owns the relay from here, on success and on failure alike.
+ relay = row.Host.RelayEnabled
+ ? await open.OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
+ : null;
+
+ return await sftp
+ .OpenSftpAsync(relay is null ? request : request with { Proxy = relay.Proxy }, cancellationToken)
+ .ConfigureAwait(true);
+ }
+ catch (Exception exception)
+ when (relay?.Failure is { } failure
+ && exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
+ {
+ // See VaultViewModel.OpenThroughAsync: the relay's reason is the true one.
+ Status = failure;
+ return null;
+ }
+ catch (SshHostKeyUnknownException exception)
+ {
+ // First contact, decided here rather than inherited from a terminal. File transfer is
+ // its own connection, so it makes its own trust decision — and the pin it writes is the
+ // same pin a shell would then find.
+ PendingHostKey = exception.Presentation;
+ Status = "This host has not been seen before.";
+ return null;
+ }
+ catch (SshHostKeyMismatchException exception)
+ {
+ HostKeyMismatch = exception.Message;
+ Status = "The host key has changed. Nothing was connected.";
+ return null;
+ }
+ }
+
///
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
/// disconnect log closes out later.
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index aa38bdc..4184841 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -11671,7 +11671,10 @@ internal sealed partial class VaultViewModel(
PhaseReporter reporter,
CancellationToken cancellationToken)
{
- if (reporter.Furthest != SshConnectionPhase.Reaching)
+ // A relay refusal already says exactly what happened, and a relayed host is reached by a path the
+ // probe does not take: it would dial the host directly — the route the user chose the relay because
+ // they cannot use — and report on that instead.
+ if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
{
return exception.Message;
}
@@ -11776,9 +11779,7 @@ internal sealed partial class VaultViewModel(
authentication.Username,
authentication.Credential);
- var sessionId = await workspace
- .OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
- .ConfigureAwait(true);
+ var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
// The workspace has already opened a ticket for this session, with the address and the moment it
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
@@ -11823,6 +11824,90 @@ internal sealed partial class VaultViewModel(
}
}
+ ///
+ /// Opens a relay to a host set to connect through the server, or says why there is none.
+ ///
+ ///
+ ///
+ /// There is no falling back to a direct connection. A user who ticked the relay did so because
+ /// the direct path does not work, or goes somewhere they do not mean — and dialling it anyway on a
+ /// failure would be the old defect in reverse: the box would say one thing and the connection do
+ /// another. So a relay that cannot be opened is the failure, stated as such.
+ ///
+ ///
+ /// The server is resolved the way a synchronisation resolves it, which comes back online quietly when a
+ /// sign-in is remembered. Offline is its own sentence, because it is the one case the user can fix from
+ /// this machine without touching the host.
+ ///
+ ///
+ /// Internal because file transfer opens a connection of its own and a relayed host needs the relay
+ /// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
+ ///
+ ///
+ internal async Task OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
+ {
+ if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
+ {
+ throw new ServerRelayException(
+ $"{label} connects through the server relay, and this machine is not connected to the "
+ + "server right now. Try again once it is back online.");
+ }
+
+ return await server.Relay.OpenAsync(hostId, cancellationToken).ConfigureAwait(true);
+ }
+
+ ///
+ /// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
+ ///
+ ///
+ ///
+ /// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
+ /// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
+ /// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
+ /// host key refusals are left alone: a key was presented, so the relay did its job.
+ ///
+ ///
+ /// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
+ /// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
+ /// bridge listening until its own timeout; disposing twice is safe.
+ ///
+ ///
+ private async Task OpenThroughAsync(
+ ConnectionTarget target,
+ SshConnectionRequest request,
+ PhaseReporter reporter,
+ CancellationToken cancellationToken)
+ {
+ // After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
+ // renderer's wait is the one step of a connect with no bound a user would recognise.
+ var relay = target.Relayed
+ ? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
+ : null;
+
+ try
+ {
+ return await workspace
+ .OpenSessionAsync(
+ relay is null ? request : request with { Proxy = relay.Proxy },
+ TerminalSize.Default,
+ reporter,
+ cancellationToken)
+ .ConfigureAwait(true);
+ }
+ catch (Exception exception) when (relay is not null)
+ {
+ await relay.DisposeAsync().ConfigureAwait(true);
+
+ if (relay.Failure is { } failure
+ && exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
+ {
+ throw new ServerRelayException(failure, exception);
+ }
+
+ throw;
+ }
+ }
+
///
/// Turns the password that just worked into a keychain credential bound to this host.
///
@@ -12041,6 +12126,15 @@ internal sealed partial class VaultViewModel(
{
/// The keychain item, or null for a machine that is not in it.
internal Guid? HostId => Row?.EntityId;
+
+ ///
+ /// Whether this is reached through the server relay rather than dialled.
+ ///
+ ///
+ /// Only a keychain host can be: the relay dials the address the server holds for an item, and a
+ /// machine typed into the manual box has no item and therefore nothing on the server to dial.
+ ///
+ internal bool Relayed => Row?.Host.RelayEnabled is true;
}
///
diff --git a/src/DodoSSH.Client.Ssh/RelayBridge.cs b/src/DodoSSH.Client.Ssh/RelayBridge.cs
new file mode 100644
index 0000000..4efa4b6
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/RelayBridge.cs
@@ -0,0 +1,369 @@
+using System.Buffers;
+using System.Net;
+using System.Net.Sockets;
+using System.Net.WebSockets;
+
+namespace DodoSSH.Client.Ssh;
+
+///
+/// A loopback SOCKS5 proxy that carries exactly one connection over one relay WebSocket.
+///
+///
+///
+/// SSH.NET cannot be handed a stream, only an address — so the relay, which is a WebSocket, is reached by
+/// giving SSH.NET an address that leads into one. This listens on 127.0.0.1 at a port the system
+/// chooses, answers one SOCKS5 CONNECT, and from then on moves bytes: what SSH.NET writes goes out as binary
+/// messages, and what the relay sends comes back as the stream SSH.NET reads. That is ADR 0004's loopback
+/// bridge, and step 3 of docs/reaching-a-host-you-cannot-dial.md.
+///
+///
+/// SOCKS5 rather than a plain pipe, so the host stays the host. The connection made through this is
+/// told the target's real name and port, and pins its host key under them; the CONNECT carrying that name
+/// arrives here and is answered without being acted on, because where the bytes go was decided by the
+/// server when it issued the ticket, and nothing this side says can change it.
+///
+///
+/// Three properties are the security of this, and each is held by a test. It binds loopback and nothing
+/// else — asserted on the bound address, because a bridge on every interface would be a proxy into the
+/// relay for anyone on the network and nothing would report it. It accepts once and stops listening, so a
+/// second process on this machine cannot take a turn. And it gives up if nobody arrives within
+/// , so a connect that failed before dialling cannot leave it waiting.
+///
+///
+/// No operation on the WebSocket is cancelled. Cancelling one aborts a .NET WebSocket, and an aborted
+/// one never reads the close frame that says why the relay ended — which is the one thing the user needs to
+/// be told when the server could not reach the host. The bridge stops a direction by closing, and waits a
+/// bounded time for the other side to agree, as the relay's own endpoint does.
+///
+///
+public sealed class RelayBridge : IAsyncDisposable
+{
+ /// How long the bridge waits for its one connection before closing the relay unused.
+ ///
+ /// The connection is made the moment the bridge exists, so this bounds a caller that failed between the
+ /// two. Longer than any SSH connect timeout, so it never ends a connect that is merely slow.
+ ///
+ public static readonly TimeSpan AcceptTimeout = TimeSpan.FromSeconds(60);
+
+ private const int BufferBytes = 32 * 1024;
+
+ private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
+
+ private readonly TcpListener listener;
+ private readonly WebSocket socket;
+ private readonly CancellationTokenSource stopping = new();
+ private readonly Task running;
+ private int disposed;
+
+ private RelayBridge(WebSocket socket, TimeSpan acceptTimeout)
+ {
+ this.socket = socket;
+
+ // The address rather than "localhost", so there is no resolver and no hosts file between this and
+ // the interface it means. See the remarks on SshLoopbackProxy.
+ listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start(backlog: 1);
+
+ Endpoint = (IPEndPoint)listener.LocalEndpoint;
+ running = RunAsync(acceptTimeout);
+ }
+
+ /// Where the bridge is listening. Loopback, always.
+ public IPEndPoint Endpoint { get; }
+
+ /// The proxy to hand the connection, which then owns this bridge.
+ public SshLoopbackProxy Proxy => new(Endpoint.Port, this);
+
+ ///
+ /// How the relay closed the socket, once it has; null while it is open or when it vanished without
+ /// saying.
+ ///
+ ///
+ /// Read after a connect through the bridge fails. The relay says in a close code what went wrong on its
+ /// side — the server could not reach the host, or has too many sessions open — and that is the sentence
+ /// the user needs in place of the transport error SSH.NET reports for a stream that ended.
+ ///
+ public WebSocketCloseStatus? CloseStatus { get; private set; }
+
+ /// The relay's own words about why it closed, alongside .
+ public string? CloseDescription { get; private set; }
+
+ /// Completes when the bridge has finished: its one connection is over, or nobody came.
+ public Task Completion => running;
+
+ /// Starts a bridge over an open relay socket, which it then owns.
+ public static RelayBridge Start(WebSocket socket) => Start(socket, AcceptTimeout);
+
+ /// Starts a bridge with a shorter wait for its connection, for tests.
+ internal static RelayBridge Start(WebSocket socket, TimeSpan acceptTimeout)
+ {
+ ArgumentNullException.ThrowIfNull(socket);
+
+ return new RelayBridge(socket, acceptTimeout);
+ }
+
+ ///
+ ///
+ /// Idempotent, because two owners may reach it: the connection that was handed the proxy, and a caller
+ /// tidying up after a connect that never got that far.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) != 0)
+ {
+ return;
+ }
+
+ await stopping.CancelAsync().ConfigureAwait(false);
+ listener.Stop();
+
+ if (!await SettlesAsync(running, CloseTimeout * 2).ConfigureAwait(false))
+ {
+ socket.Abort();
+ await SettlesAsync(running, CloseTimeout).ConfigureAwait(false);
+ }
+
+ socket.Dispose();
+ stopping.Dispose();
+ }
+
+ private async Task RunAsync(TimeSpan acceptTimeout)
+ {
+ TcpClient? client = null;
+
+ try
+ {
+ using (var accepting = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token))
+ {
+ accepting.CancelAfter(acceptTimeout);
+ client = await listener.AcceptTcpClientAsync(accepting.Token).ConfigureAwait(false);
+ }
+
+ // Once. A second connection to this port is somebody else's, and there is nothing for it here.
+ listener.Stop();
+ client.NoDelay = true;
+
+ var stream = client.GetStream();
+
+ if (await Socks5.AcceptConnectAsync(stream, stopping.Token).ConfigureAwait(false))
+ {
+ await PipeAsync(stream).ConfigureAwait(false);
+ }
+ }
+ catch (Exception exception)
+ when (exception is OperationCanceledException or SocketException or IOException or ObjectDisposedException)
+ {
+ // Nobody came, the bridge was disposed, or the connection went away mid-handshake. The relay is
+ // closed below either way.
+ }
+ finally
+ {
+ listener.Stop();
+ client?.Dispose();
+ await CloseSocketAsync(WebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
+ }
+ }
+
+ /// Moves bytes both ways until either side ends, then lets the other finish.
+ private async Task PipeAsync(NetworkStream stream)
+ {
+ var upstream = TcpToRelayAsync(stream);
+ var downstream = RelayToTcpAsync(stream);
+
+ var first = await Task.WhenAny(upstream, downstream).ConfigureAwait(false);
+
+ if (first == upstream)
+ {
+ // SSH.NET closed its end. Say so to the relay, then give it a moment to answer, which is what
+ // ends the receive in progress without aborting it.
+ await CloseSocketAsync(WebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
+
+ if (!await SettlesAsync(downstream, CloseTimeout).ConfigureAwait(false))
+ {
+ socket.Abort();
+ }
+ }
+ else
+ {
+ // The relay ended. Closing the TCP side is how SSH.NET hears it, as the end of its stream.
+ stream.Socket.Shutdown(SocketShutdown.Both);
+ await SettlesAsync(upstream, CloseTimeout).ConfigureAwait(false);
+ }
+ }
+
+ private async Task TcpToRelayAsync(NetworkStream stream)
+ {
+ var buffer = ArrayPool.Shared.Rent(BufferBytes);
+
+ try
+ {
+ int read;
+
+ while ((read = await stream.ReadAsync(buffer.AsMemory(0, BufferBytes), stopping.Token).ConfigureAwait(false)) > 0)
+ {
+ // Not cancellable, deliberately. See the remarks on this class.
+ await socket
+ .SendAsync(buffer.AsMemory(0, read), WebSocketMessageType.Binary, endOfMessage: true, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (Exception exception)
+ when (exception is OperationCanceledException or IOException or WebSocketException or ObjectDisposedException)
+ {
+ // Either end went away. PipeAsync decides what that means.
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private async Task RelayToTcpAsync(NetworkStream stream)
+ {
+ var buffer = ArrayPool.Shared.Rent(BufferBytes);
+
+ try
+ {
+ while (true)
+ {
+ // Not cancellable, deliberately. See the remarks on this class.
+ var received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
+ .ConfigureAwait(false);
+
+ if (received.MessageType == WebSocketMessageType.Close)
+ {
+ CloseStatus = socket.CloseStatus;
+ CloseDescription = socket.CloseStatusDescription;
+ return;
+ }
+
+ await stream.WriteAsync(buffer.AsMemory(0, received.Count), stopping.Token).ConfigureAwait(false);
+ }
+ }
+ catch (Exception exception)
+ when (exception is OperationCanceledException or IOException or WebSocketException or ObjectDisposedException)
+ {
+ // Either end went away. PipeAsync decides what that means.
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ /// Sends a close, if one can still be sent, bounded so a vanished peer cannot hold it.
+ private async Task CloseSocketAsync(WebSocketCloseStatus status)
+ {
+ if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
+ {
+ return;
+ }
+
+ using var timeout = new CancellationTokenSource(CloseTimeout);
+
+ try
+ {
+ await socket.CloseOutputAsync(status, string.Empty, timeout.Token).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
+ {
+ // Gone already.
+ }
+ }
+
+ private static async Task SettlesAsync(Task task, TimeSpan within)
+ {
+ try
+ {
+ await task.WaitAsync(within).ConfigureAwait(false);
+ return true;
+ }
+ catch (TimeoutException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// The server half of SOCKS5 (RFC 1928), as much of it as SSH.NET speaks: no authentication, CONNECT.
+ ///
+ private static class Socks5
+ {
+ private const byte Version = 5;
+ private const byte NoAuthentication = 0;
+ private const byte NoAcceptableMethod = 0xFF;
+ private const byte Connect = 1;
+ private const byte Succeeded = 0;
+ private const byte CommandNotSupported = 7;
+ private const byte AddressTypeNotSupported = 8;
+
+ ///
+ /// Answers the greeting and one CONNECT, whatever it names.
+ ///
+ /// Whether the stream is now a tunnel; false when the client asked for something else.
+ ///
+ /// The target in the request is read and discarded. It is the host the connection is about,
+ /// which is why SSH.NET sends it, and it is not where the bytes go, which the relay decided. The bound
+ /// address in the reply is all zeroes: nothing on the far side of a relay has an address the client
+ /// could use, and SSH.NET does not read it.
+ ///
+ internal static async Task AcceptConnectAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ var header = await ReadAsync(stream, 2, cancellationToken).ConfigureAwait(false);
+
+ if (header[0] != Version)
+ {
+ return false;
+ }
+
+ var methods = await ReadAsync(stream, header[1], cancellationToken).ConfigureAwait(false);
+
+ if (!methods.Contains(NoAuthentication))
+ {
+ await stream.WriteAsync(new byte[] { Version, NoAcceptableMethod }, cancellationToken).ConfigureAwait(false);
+ return false;
+ }
+
+ await stream.WriteAsync(new byte[] { Version, NoAuthentication }, cancellationToken).ConfigureAwait(false);
+
+ var request = await ReadAsync(stream, 4, cancellationToken).ConfigureAwait(false);
+
+ if (request[0] != Version || request[1] != Connect)
+ {
+ await ReplyAsync(stream, CommandNotSupported, cancellationToken).ConfigureAwait(false);
+ return false;
+ }
+
+ var addressLength = request[3] switch
+ {
+ 1 => 4,
+ 4 => 16,
+ 3 => (await ReadAsync(stream, 1, cancellationToken).ConfigureAwait(false))[0],
+ _ => -1,
+ };
+
+ if (addressLength < 0)
+ {
+ await ReplyAsync(stream, AddressTypeNotSupported, cancellationToken).ConfigureAwait(false);
+ return false;
+ }
+
+ // The address, then the port. Neither is used; see the remarks above.
+ await ReadAsync(stream, addressLength + 2, cancellationToken).ConfigureAwait(false);
+
+ await ReplyAsync(stream, Succeeded, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ private static Task ReplyAsync(Stream stream, byte status, CancellationToken cancellationToken) =>
+ stream.WriteAsync(new byte[] { Version, status, 0, 1, 0, 0, 0, 0, 0, 0 }, cancellationToken).AsTask();
+
+ private static async Task ReadAsync(Stream stream, int count, CancellationToken cancellationToken)
+ {
+ var buffer = new byte[count];
+ await stream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false);
+ return buffer;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Ssh/SshConnection.cs b/src/DodoSSH.Client.Ssh/SshConnection.cs
index a7c405b..e136316 100644
--- a/src/DodoSSH.Client.Ssh/SshConnection.cs
+++ b/src/DodoSSH.Client.Ssh/SshConnection.cs
@@ -38,12 +38,19 @@ public sealed record SshPrivateKeyCredential(byte[] PrivateKeyPem, string? Passp
/// stand-in. See the gate in SshNetConnectionFactory, which pins what this request names.
///
///
-/// Nothing in this assembly opens one. The proxy is somebody else's — a forward on a bastion connection,
-/// or the relay bridge — and its lifetime belongs to whoever opened it, which must outlast the connection
-/// made through it. See docs/reaching-a-host-you-cannot-dial.md.
+/// Whoever opens a proxy may hand it to the connection made through it. A proxy with no
+/// belongs to its opener, which must keep it listening for as long as the connection
+/// lives. One with a lifetime is given away: the connection disposes it after itself, and a connect that
+/// fails disposes it before throwing — so a proxy opened for one connection, which is what the relay bridge
+/// is, cannot outlive it on any of the paths a session ends by. See
+/// docs/reaching-a-host-you-cannot-dial.md.
///
///
-public sealed record SshLoopbackProxy(int Port);
+/// The loopback port the proxy listens on.
+///
+/// What keeps the proxy listening, when the connection is to own it; null when its opener does.
+///
+public sealed record SshLoopbackProxy(int Port, IAsyncDisposable? Lifetime = null);
/// Everything needed to reach one host.
/// Hostname or address.
diff --git a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
index 0e0b3b8..2df9c7d 100644
--- a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
+++ b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
@@ -75,12 +75,22 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
ArgumentNullException.ThrowIfNull(request);
- var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
+ var lifetime = request.Proxy?.Lifetime;
- var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
- .ConfigureAwait(false);
+ try
+ {
+ var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
- return new SshNetConnection(client, gate.Presented!);
+ var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
+ .ConfigureAwait(false);
+
+ return new SshNetConnection(client, gate.Presented!, lifetime);
+ }
+ catch
+ {
+ await DisposeAsync(lifetime).ConfigureAwait(false);
+ throw;
+ }
}
///
@@ -95,6 +105,40 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
ArgumentNullException.ThrowIfNull(request);
+ var lifetime = request.Proxy?.Lifetime;
+
+ try
+ {
+ return await OpenSftpCoreAsync(request, lifetime, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ await DisposeAsync(lifetime).ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ ///
+ /// Disposes a proxy the connection was given, when the connection it was given for did not happen.
+ ///
+ ///
+ /// Every failure path, including the ones before a socket exists — an unreadable key throws from
+ /// — because a relay bridge left listening after a failed connect is a
+ /// loopback SOCKS proxy with an open relay session behind it, and nothing would ever close it.
+ ///
+ private static async ValueTask DisposeAsync(IAsyncDisposable? lifetime)
+ {
+ if (lifetime is not null)
+ {
+ await lifetime.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private async Task OpenSftpCoreAsync(
+ SshConnectionRequest request,
+ IAsyncDisposable? lifetime,
+ CancellationToken cancellationToken)
+ {
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
// since rebooted would otherwise wait on a socket nothing will ever answer.
@@ -125,7 +169,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
throw;
}
- return new SshNetSftpSession(client, gate.Presented!, home);
+ return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
}
///
@@ -326,7 +370,15 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
}
/// An SSH.NET-backed connection.
-internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hostKey) : ISshConnection
+///
+/// Owns the proxy it was reached through when it was given one, and disposes it after the client — the
+/// order that lets SSH.NET close its end of the loopback socket before the thing on the other end goes.
+///
+internal sealed class SshNetConnection(
+ SshClient client,
+ HostKeyPresentation hostKey,
+ IAsyncDisposable? proxyLifetime = null)
+ : ISshConnection
{
///
public bool IsConnected => client.IsConnected;
@@ -366,10 +418,14 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
}
///
- public ValueTask DisposeAsync()
+ public async ValueTask DisposeAsync()
{
client.Dispose();
- return ValueTask.CompletedTask;
+
+ if (proxyLifetime is not null)
+ {
+ await proxyLifetime.DisposeAsync().ConfigureAwait(false);
+ }
}
}
diff --git a/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs b/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs
index abdad3c..8f8c6a9 100644
--- a/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs
+++ b/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs
@@ -14,13 +14,23 @@ namespace DodoSSH.Client.Ssh;
internal sealed class SshNetSftpSession : ISftpSession
{
private readonly SftpClient client;
+ private readonly IAsyncDisposable? proxyLifetime;
private string? transportFailure;
private int announced;
- internal SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
+ /// The connected client.
+ /// What the server presented.
+ /// Where the session starts.
+ /// The proxy this session was reached through, when it owns it.
+ internal SshNetSftpSession(
+ SftpClient client,
+ HostKeyPresentation hostKey,
+ string homeDirectory,
+ IAsyncDisposable? proxyLifetime = null)
{
this.client = client;
+ this.proxyLifetime = proxyLifetime;
HostKey = hostKey;
HomeDirectory = homeDirectory;
@@ -243,7 +253,7 @@ internal sealed class SshNetSftpSession : ISftpSession
client.Dispose();
- return ValueTask.CompletedTask;
+ return proxyLifetime?.DisposeAsync() ?? ValueTask.CompletedTask;
}
///
diff --git a/src/DodoSSH.Contracts/ProblemCodes.cs b/src/DodoSSH.Contracts/ProblemCodes.cs
index 1c6ab15..77f91a9 100644
--- a/src/DodoSSH.Contracts/ProblemCodes.cs
+++ b/src/DodoSSH.Contracts/ProblemCodes.cs
@@ -76,6 +76,17 @@ public static class ProblemCodes
///
public const string EventsUnavailable = "events-unavailable";
+ ///
+ /// This deployment does not relay.
+ ///
+ ///
+ /// Its own code rather than , because it is the one relay refusal
+ /// whose remedy is not about the host: nothing the user changes on this machine will make it work
+ /// here, and saying so is not an oracle for anything. GET /api/v1/meta says the same thing
+ /// without asking.
+ ///
+ public const string RelayUnavailable = "relay-unavailable";
+
/// The relay refused the requested target. Never states why, to avoid a probe oracle.
public const string RelayTargetRejected = "relay-target-rejected";
diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
index a999c99..3ce892e 100644
--- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
+++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
@@ -966,3 +966,14 @@ static DodoSSH.Contracts.VaultKeyWrap.operator !=(DodoSSH.Contracts.VaultKeyWrap
static DodoSSH.Contracts.VaultKeyWrap.operator ==(DodoSSH.Contracts.VaultKeyWrap? left, DodoSSH.Contracts.VaultKeyWrap? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
+const DodoSSH.Contracts.ProblemCodes.RelayUnavailable = "relay-unavailable" -> string!
+const DodoSSH.Contracts.RelayProtocol.ConnectPath = "/api/v1/relay/connect" -> string!
+const DodoSSH.Contracts.RelayProtocol.DurationLimitCloseCode = 4409 -> int
+const DodoSSH.Contracts.RelayProtocol.Feature = "relay" -> string!
+const DodoSSH.Contracts.RelayProtocol.IdleTimeoutCloseCode = 4408 -> int
+const DodoSSH.Contracts.RelayProtocol.SubProtocol = "dodossh.relay.v1" -> string!
+const DodoSSH.Contracts.RelayProtocol.TargetUnreachableCloseCode = 4502 -> int
+const DodoSSH.Contracts.RelayProtocol.TicketProtocolPrefix = "ticket." -> string!
+const DodoSSH.Contracts.RelayProtocol.TicketsPath = "/api/v1/relay/tickets" -> string!
+const DodoSSH.Contracts.RelayProtocol.TooManySessionsCloseCode = 4429 -> int
+DodoSSH.Contracts.RelayProtocol
diff --git a/src/DodoSSH.Contracts/Relay.cs b/src/DodoSSH.Contracts/Relay.cs
index 58da72e..41e361b 100644
--- a/src/DodoSSH.Contracts/Relay.cs
+++ b/src/DodoSSH.Contracts/Relay.cs
@@ -1,5 +1,78 @@
namespace DodoSSH.Contracts;
+///
+/// The relay's routes, subprotocol and close codes.
+///
+///
+///
+/// Two steps, per ADR 0004. is an ordinary authenticated request that decides
+/// everything — which host, which addresses, whether the caller may — and answers with a ticket.
+/// is a WebSocket that carries no API authority at all: it accepts the ticket
+/// and nothing else, and pipes raw bytes to the addresses the ticket names. There is no framing of the
+/// relay's own inside the socket. SSH is already a byte stream, so every binary message is simply the
+/// next bytes of it, in order, and message boundaries mean nothing.
+///
+///
+/// A refusal the client could not have avoided by asking differently — a limit, a target that did not
+/// answer — is sent as a close code after the upgrade rather than as an HTTP status before it, for the
+/// same reason as : a constrained WebSocket client cannot read the status of a
+/// failed upgrade, and these are exactly the cases where the user needs to be told something specific.
+///
+///
+public static class RelayProtocol
+{
+ /// The /api/v1/meta feature flag advertising that this server relays at all.
+ public const string Feature = "relay";
+
+ /// Where a ticket is asked for, with a .
+ public const string TicketsPath = "/api/v1/relay/tickets";
+
+ /// The path the relay socket is served from.
+ ///
+ /// A client dials rather than building this itself,
+ /// because a deployment may publish the relay under a different host than the API.
+ ///
+ public const string ConnectPath = "/api/v1/relay/connect";
+
+ /// The only subprotocol this version speaks.
+ public const string SubProtocol = "dodossh.relay.v1";
+
+ ///
+ /// The prefix of the second subprotocol an upgrade offers, which carries the ticket.
+ ///
+ ///
+ /// The header rather than the query string, because a URL is what proxies, access logs and traces
+ /// keep. The server echoes alone and never the ticket.
+ ///
+ public const string TicketProtocolPrefix = "ticket.";
+
+ ///
+ /// Close code for a caller, or a node, already holding as many relay sessions as it may.
+ ///
+ ///
+ /// The same number as , deliberately, for the
+ /// same meaning. Retrying at once is what caused it.
+ ///
+ public const int TooManySessionsCloseCode = 4429;
+
+ ///
+ /// Close code for a target that did not accept the connection.
+ ///
+ ///
+ /// The one failure a user needs told in so many words, because from the laptop it looks exactly like
+ /// the direct connection that failed and sent them to the relay in the first place. It says that the
+ /// server could not reach the machine, which is a different problem with a different owner.
+ ///
+ public const int TargetUnreachableCloseCode = 4502;
+
+ /// Close code for a session that carried no bytes for the idle timeout.
+ public const int IdleTimeoutCloseCode = 4408;
+
+ /// Close code for a session that reached the maximum lifetime this server allows.
+ public const int DurationLimitCloseCode = 4409;
+}
+
+
///
/// A request for permission to relay to a host.
///
@@ -11,8 +84,10 @@ namespace DodoSSH.Contracts;
///
/// The host to reach.
///
-/// A configured port forward to use instead of the host's own SSH port. Resolved server-side the
-/// same way.
+/// A configured port forward to use instead of the host's own SSH port. Reserved, and refused when
+/// set: a port forward lives inside the host's encrypted payload, so the server has no plaintext port
+/// to resolve it to, and taking one from the client is the open proxy this record exists to prevent.
+/// Serving it needs a plaintext concession of its own, argued the way ADR 0004 argues the host's.
///
public sealed record RelayTicketRequest(
Guid HostId,
diff --git a/src/DodoSSH.Domain/Relay.cs b/src/DodoSSH.Domain/Relay.cs
new file mode 100644
index 0000000..4df2620
--- /dev/null
+++ b/src/DodoSSH.Domain/Relay.cs
@@ -0,0 +1,123 @@
+namespace DodoSSH.Domain;
+
+///
+/// A relay ticket that has been redeemed.
+///
+///
+///
+/// The replay guard, and the only state the relay shares between nodes — see ADR 0004. A ticket is
+/// verified without the database; what the database adds is that it is verified once, by a
+/// unique insert on that a second redemption loses whichever node it reaches.
+///
+///
+/// A row is worth keeping only while its ticket could still verify, so is
+/// stored to let redemption sweep the rows that can no longer matter. Nothing else reads this table,
+/// and it carries nothing about the target: the audit record is .
+///
+///
+public sealed class RelayTicketUse
+{
+ /// The ticket's own id. Primary key, and the whole of the guard.
+ public Guid TicketId { get; set; }
+
+ /// When the ticket stops verifying, after which this row guards nothing.
+ public DateTimeOffset ExpiresAtUtc { get; set; }
+
+ /// When it was redeemed.
+ public DateTimeOffset UsedAtUtc { get; set; }
+}
+
+///
+/// The audit record of one relay session.
+///
+///
+///
+/// What ADR 0004 says remains auditable in relay mode, and nothing more: who, to where, for how long,
+/// how many bytes each way and why it ended. The relay forwards SSH ciphertext, so there is no content
+/// to record and no column that pretends otherwise.
+///
+///
+/// Written twice — once when the target answers, once when the session ends — each time from a scope
+/// opened for that write alone. A session lasts hours and a scope held across it is a pooled database
+/// connection held across it, which is the failure ADR 0004 records.
+///
+///
+/// No foreign keys to the host or the vault, deliberately. An audit row has to outlive what it is
+/// about: a host deleted a minute after a session is exactly the host an operator will want the
+/// session for.
+///
+///
+public sealed class RelaySession
+{
+ /// The session id, minted with the ticket and returned to the client. Primary key.
+ public Guid Id { get; set; }
+
+ /// Who opened it.
+ public Guid UserId { get; set; }
+
+ /// The host it reached.
+ public Guid HostId { get; set; }
+
+ /// The vault the host was in when the ticket was issued.
+ public Guid VaultId { get; set; }
+
+ /// The hostname as the host row carried it at issue.
+ public string TargetHost { get; set; } = string.Empty;
+
+ /// The address actually dialled, which is the one that answered.
+ /// Null only for a session whose target never answered.
+ public string? TargetAddress { get; set; }
+
+ /// The port dialled.
+ public int TargetPort { get; set; }
+
+ /// The client's address as this node saw it, for the audit ADR 0004 promises.
+ public string? ClientAddress { get; set; }
+
+ /// When the socket was accepted.
+ public DateTimeOffset StartedAtUtc { get; set; }
+
+ /// When it closed, or null while it is open — or if the node died under it.
+ public DateTimeOffset? EndedAtUtc { get; set; }
+
+ /// Bytes forwarded from the client to the target.
+ public long BytesClientToTarget { get; set; }
+
+ /// Bytes forwarded from the target to the client.
+ public long BytesTargetToClient { get; set; }
+
+ /// Why it ended. while open.
+ public RelayCloseReason CloseReason { get; set; }
+}
+
+/// Why a relay session ended.
+///
+/// Numbered as RelaySessionCloseReason on the wire is, and held to it by a test rather than by
+/// hope: the endpoint converts between the two by value.
+///
+public enum RelayCloseReason
+{
+ /// Not a legal value for a closed session; what an open one carries.
+ Unspecified = 0,
+
+ /// The client closed the connection.
+ ClientClosed = 1,
+
+ /// The target closed the connection.
+ TargetClosed = 2,
+
+ /// No traffic within the idle timeout.
+ IdleTimeout = 3,
+
+ /// The maximum session duration was reached.
+ DurationLimit = 4,
+
+ /// The server is shutting down and drained the session.
+ ServerShutdown = 5,
+
+ /// The connection to the target failed.
+ TargetUnreachable = 6,
+
+ /// An error ended the session.
+ Error = 7,
+}
diff --git a/src/DodoSSH.Infrastructure/Configurations/RelayConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/RelayConfigurations.cs
new file mode 100644
index 0000000..353fd0f
--- /dev/null
+++ b/src/DodoSSH.Infrastructure/Configurations/RelayConfigurations.cs
@@ -0,0 +1,61 @@
+using DodoSSH.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DodoSSH.Infrastructure.Configurations;
+
+/// Maps .
+public sealed class RelayTicketUseConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("relay_ticket_use");
+
+ // The key is the guard. A second redemption of the same ticket is a unique violation, on whichever
+ // node it reaches, and nothing weaker — a lookup followed by an insert — would be one.
+ builder.HasKey(u => u.TicketId);
+ builder.Property(u => u.TicketId).ValueGeneratedNever();
+
+ // For the sweep, which deletes by expiry.
+ builder.HasIndex(u => u.ExpiresAtUtc);
+ }
+}
+
+/// Maps .
+public sealed class RelaySessionConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("relay_session");
+ builder.HasKey(s => s.Id);
+
+ // Minted with the ticket and handed to the client before the row exists, so the client can name
+ // the session it opened.
+ builder.Property(s => s.Id).ValueGeneratedNever();
+
+ builder.Property(s => s.TargetHost).HasMaxLength(255).IsRequired();
+
+ // An address's text form: 45 characters is the longest IPv6 spelling with an embedded IPv4.
+ builder.Property(s => s.TargetAddress).HasMaxLength(45);
+ builder.Property(s => s.ClientAddress).HasMaxLength(45);
+
+ builder.Property(s => s.CloseReason).HasConversion();
+
+ builder.HasIndex(s => new { s.UserId, s.StartedAtUtc });
+ builder.HasIndex(s => new { s.HostId, s.StartedAtUtc });
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_relay_session_port_range",
+ "target_port BETWEEN 1 AND 65535"));
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_relay_session_bytes",
+ "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0"));
+ }
+}
diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs
index e59cc67..5be6bc0 100644
--- a/src/DodoSSH.Infrastructure/DodoDbContext.cs
+++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs
@@ -87,6 +87,12 @@ public class DodoDbContext(DbContextOptions options) : DbContext(
/// Applied-operation receipts, for exactly-once retries.
public DbSet SyncOperationReceipts => Set();
+ /// Redeemed relay tickets: the replay guard ADR 0004 shares between nodes.
+ public DbSet RelayTicketUses => Set();
+
+ /// The relay's audit record, one row per session.
+ public DbSet RelaySessions => Set();
+
///
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/src/DodoSSH.Infrastructure/Migrations/20260928120443_AddRelaySessions.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260928120443_AddRelaySessions.Designer.cs
new file mode 100644
index 0000000..ca72a75
--- /dev/null
+++ b/src/DodoSSH.Infrastructure/Migrations/20260928120443_AddRelaySessions.Designer.cs
@@ -0,0 +1,1907 @@
+//
+using System;
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DodoSSH.Infrastructure.Migrations
+{
+ [DbContext(typeof(DodoDbContext))]
+ [Migration("20260928120443_AddRelaySessions")]
+ partial class AddRelaySessions
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("dodo")
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("DodoSSH.Domain.Device", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("EnrolledAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("enrolled_at_utc");
+
+ b.Property("LastSeenAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at_utc");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("Platform")
+ .HasColumnType("integer")
+ .HasColumnName("platform");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("public_key");
+
+ b.Property("RevokedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at_utc");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_device");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_device_user_id");
+
+ b.ToTable("device", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
+ {
+ b.Property("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("sequence");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("EncryptionPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("encryption_public_key");
+
+ b.Property("Generation")
+ .HasColumnType("integer")
+ .HasColumnName("generation");
+
+ b.Property("Hash")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("hash");
+
+ b.Property("PreviousHash")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("previous_hash");
+
+ b.Property("SigningPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("signing_public_key");
+
+ b.Property("StatementSignature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("statement_signature");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Sequence")
+ .HasName("pk_key_log");
+
+ b.HasIndex("Hash")
+ .IsUnique()
+ .HasDatabaseName("ix_key_log_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_key_log_user_id");
+
+ b.ToTable("key_log", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.RelaySession", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("BytesClientToTarget")
+ .HasColumnType("bigint")
+ .HasColumnName("bytes_client_to_target");
+
+ b.Property("BytesTargetToClient")
+ .HasColumnType("bigint")
+ .HasColumnName("bytes_target_to_client");
+
+ b.Property("ClientAddress")
+ .HasMaxLength(45)
+ .HasColumnType("character varying(45)")
+ .HasColumnName("client_address");
+
+ b.Property("CloseReason")
+ .HasColumnType("integer")
+ .HasColumnName("close_reason");
+
+ b.Property("EndedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ended_at_utc");
+
+ b.Property("HostId")
+ .HasColumnType("uuid")
+ .HasColumnName("host_id");
+
+ b.Property("StartedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("started_at_utc");
+
+ b.Property("TargetAddress")
+ .HasMaxLength(45)
+ .HasColumnType("character varying(45)")
+ .HasColumnName("target_address");
+
+ b.Property("TargetHost")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("target_host");
+
+ b.Property("TargetPort")
+ .HasColumnType("integer")
+ .HasColumnName("target_port");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.HasKey("Id")
+ .HasName("pk_relay_session");
+
+ b.HasIndex("HostId", "StartedAtUtc")
+ .HasDatabaseName("ix_relay_session_host_id_started_at_utc");
+
+ b.HasIndex("UserId", "StartedAtUtc")
+ .HasDatabaseName("ix_relay_session_user_id_started_at_utc");
+
+ b.ToTable("relay_session", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_relay_session_bytes", "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0");
+
+ t.HasCheckConstraint("ck_relay_session_port_range", "target_port BETWEEN 1 AND 65535");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.RelayTicketUse", b =>
+ {
+ b.Property("TicketId")
+ .HasColumnType("uuid")
+ .HasColumnName("ticket_id");
+
+ b.Property("ExpiresAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at_utc");
+
+ b.Property("UsedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at_utc");
+
+ b.HasKey("TicketId")
+ .HasName("pk_relay_ticket_use");
+
+ b.HasIndex("ExpiresAtUtc")
+ .HasDatabaseName("ix_relay_ticket_use_expires_at_utc");
+
+ b.ToTable("relay_ticket_use", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("Hostname")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("hostname");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("Port")
+ .HasColumnType("integer")
+ .HasColumnName("port");
+
+ b.Property("RelayEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("relay_enabled");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_host");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_host_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_host_vault_id_change_sequence");
+
+ b.ToTable("host", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_host_port_range", "port IS NULL OR (port BETWEEN 1 AND 65535)");
+
+ t.HasCheckConstraint("ck_host_relay_target", "(relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)\nOR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)");
+
+ t.HasCheckConstraint("ck_host_version", "version >= 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
+ {
+ b.Property("OperationId")
+ .HasColumnType("uuid")
+ .HasColumnName("operation_id");
+
+ b.Property("AppliedChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("applied_change_sequence");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("ResultVersion")
+ .HasColumnType("integer")
+ .HasColumnName("result_version");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.HasKey("OperationId")
+ .HasName("pk_sync_operation_receipt");
+
+ b.HasIndex("VaultId", "CreatedAtUtc")
+ .HasDatabaseName("ix_sync_operation_receipt_vault_id_created_at_utc");
+
+ b.ToTable("sync_operation_receipt", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.Team", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("description");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("citext")
+ .HasColumnName("slug");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_team");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_team_slug")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.ToTable("team", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("InvitedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("invited_by_user_id");
+
+ b.Property("JoinedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("joined_at_utc");
+
+ b.Property("Role")
+ .HasColumnType("integer")
+ .HasColumnName("role");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid")
+ .HasColumnName("team_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_team_membership");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_team_membership_user_id");
+
+ b.HasIndex("TeamId", "UserId")
+ .IsUnique()
+ .HasDatabaseName("ix_team_membership_team_id_user_id")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.ToTable("team_membership", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.UserAccount", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("DisplayName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("display_name");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("citext")
+ .HasColumnName("email");
+
+ b.Property("EnrolledAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("enrolled_at_utc");
+
+ b.Property("Issuer")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("issuer");
+
+ b.Property("LastSeenAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at_utc");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("subject");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_user_account");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("ix_user_account_email")
+ .HasFilter("email IS NOT NULL AND deleted_at_utc IS NULL");
+
+ b.HasIndex("Issuer", "Subject")
+ .IsUnique()
+ .HasDatabaseName("ix_user_account_issuer_subject");
+
+ b.ToTable("user_account", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.UserKey", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("EncryptionPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("encryption_public_key");
+
+ b.Property("FingerprintSha256")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("fingerprint_sha256");
+
+ b.Property("Generation")
+ .HasColumnType("integer")
+ .HasColumnName("generation");
+
+ b.Property("IdentityProviderBinding")
+ .HasColumnType("jsonb")
+ .HasColumnName("identity_provider_binding");
+
+ b.Property("IsCurrent")
+ .HasColumnType("boolean")
+ .HasColumnName("is_current");
+
+ b.Property("RevokedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at_utc");
+
+ b.Property("SigningPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("signing_public_key");
+
+ b.Property("Statement")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("statement");
+
+ b.Property("StatementSignature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("statement_signature");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_key");
+
+ b.HasIndex("FingerprintSha256")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_fingerprint_sha256");
+
+ b.HasIndex("UserId")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_current")
+ .HasFilter("is_current");
+
+ b.HasIndex("UserId", "Generation")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_user_id_generation");
+
+ b.ToTable("user_key", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("KdfAlgorithm")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("kdf_algorithm");
+
+ b.Property("KdfMemoryKibibytes")
+ .HasColumnType("integer")
+ .HasColumnName("kdf_memory_kibibytes");
+
+ b.Property("KdfParallelism")
+ .HasColumnType("integer")
+ .HasColumnName("kdf_parallelism");
+
+ b.Property("KdfPasses")
+ .HasColumnType("integer")
+ .HasColumnName("kdf_passes");
+
+ b.Property("KdfSalt")
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("kdf_salt");
+
+ b.Property("Kind")
+ .HasColumnType("integer")
+ .HasColumnName("kind");
+
+ b.Property("LastUsedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at_utc");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Wrap")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("wrap");
+
+ b.Property("WrapVersion")
+ .HasColumnType("integer")
+ .HasColumnName("wrap_version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_user_key_wrap");
+
+ b.HasIndex("DeviceId")
+ .HasDatabaseName("ix_user_key_wrap_device_id");
+
+ b.HasIndex("UserId", "DeviceId")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_wrap_user_device")
+ .HasFilter("device_id IS NOT NULL");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_wrap_user_kind")
+ .HasFilter("device_id IS NULL");
+
+ b.ToTable("user_key_wrap", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_user_key_wrap_device", "(kind = 2 AND device_id IS NOT NULL) OR (kind <> 2 AND device_id IS NULL)");
+
+ t.HasCheckConstraint("ck_user_key_wrap_kdf", "(kind IN (1, 3) AND kdf_algorithm IS NOT NULL AND kdf_salt IS NOT NULL\n AND kdf_memory_kibibytes IS NOT NULL AND kdf_passes IS NOT NULL\n AND kdf_parallelism IS NOT NULL)\nOR (kind IN (2, 4) AND kdf_algorithm IS NULL AND kdf_salt IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.Vault", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("OwnerKind")
+ .HasColumnType("integer")
+ .HasColumnName("owner_kind");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_user_id");
+
+ b.Property("RekeyReason")
+ .HasColumnType("integer")
+ .HasColumnName("rekey_reason");
+
+ b.Property("RekeyRequired")
+ .HasColumnType("boolean")
+ .HasColumnName("rekey_required");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid")
+ .HasColumnName("team_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_vault");
+
+ b.HasIndex("OwnerUserId")
+ .HasDatabaseName("ix_vault_owner_user_id");
+
+ b.HasIndex("TeamId")
+ .HasDatabaseName("ix_vault_team_id");
+
+ b.ToTable("vault", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_vault_key_generation", "key_generation >= 1");
+
+ t.HasCheckConstraint("ck_vault_owner", "(owner_kind = 1 AND owner_user_id IS NOT NULL AND team_id IS NULL)\nOR (owner_kind = 2 AND team_id IS NOT NULL AND owner_user_id IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultActivityLogEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_activity_log_entry");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_activity_log_entry_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_activity_log_entry_vault_id_change_sequence");
+
+ b.ToTable("activity_log_entry", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_activity_log_entry_version", "version >= 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultChange", b =>
+ {
+ b.Property("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("sequence");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
+
+ b.Property("ActorUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_user_id");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasColumnType("integer")
+ .HasColumnName("entity_type");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at_utc");
+
+ b.Property("Operation")
+ .HasColumnType("integer")
+ .HasColumnName("operation");
+
+ b.Property("Revision")
+ .HasColumnType("integer")
+ .HasColumnName("revision");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.HasKey("Sequence")
+ .HasName("pk_sync_change");
+
+ b.HasIndex("VaultId", "Sequence")
+ .HasDatabaseName("ix_sync_change_vault_id_sequence");
+
+ b.HasIndex("VaultId", "EntityId", "Sequence")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
+
+ b.ToTable("sync_change", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultConnectionLogEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_connection_log_entry");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_connection_log_entry_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_connection_log_entry_vault_id_change_sequence");
+
+ b.ToTable("connection_log_entry", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_connection_log_entry_version", "version >= 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property