Public Access
Let a host marked for the relay actually be reached through it
The relay checkbox said it was not wired up, and the plan said the server half was built and shipped. It was not: there was ADR 0004, the contract records, RelayOptions and a CHECK constraint, and RelayOptions said in its own remarks that there was no endpoint and no dialler. So both halves are built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md. Server, in Features/Relay: - POST /api/v1/relay/tickets makes every decision: the host, Connect on its vault, the relay switched on, and the stored name resolved once and judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8, multicast and the metadata endpoints are refused with no configuration escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the operator's denials, allow-list and private-network switch. What passes goes into a Data Protection ticket, and nothing resolves the name again. Every target refusal is the same 403, so the endpoint cannot be used to map the operator's DNS; the real reason is logged. - GET /api/v1/relay/connect takes the ticket as a second subprotocol, checks its expiry against TimeProvider and spends it with a unique insert into relay_ticket_use before accepting the upgrade. After the upgrade a refusal is a close code: 4502 for a host the server could not reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain. - relay_session is the audit row, written from short-lived scopes. Client: - RelayBridge is a loopback SOCKS5 listener that accepts once and pipes to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so the connection disposes it on every way a session ends and a failed connect disposes it before throwing. - ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for the ticket, opens the socket, starts the bridge and turns every refusal into a sentence. - VaultViewModel and TransfersViewModel reach a relayed host through it, never fall back to dialling it directly, and replace SSH.NET's transport error with the relay's own reason when it gave one. Neither side ever cancels a WebSocket operation. Cancelling one aborts the socket, and an aborted socket sends no close frame, which is the only way the client learns the server could not reach the host. AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives. Three departures from ADR 0004, recorded in it: awaited copy loops rather than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket= fallback, and no device id in the ticket. Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a real login through the bridge, API client 30, contracts 25. Not run: the Android build (its change is copy only) and the Alpine layout oracle.
This commit is contained in:
1 parent
54bc142dce
commit
b20f7cae04
51 files changed
+6455
-88
No files matched your search
@@ -0,0 +1,123 @@
|
||||
namespace DodoSSH.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A relay ticket that has been redeemed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <em>once</em>, by a
|
||||
/// unique insert on <see cref="TicketId"/> that a second redemption loses whichever node it reaches.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A row is worth keeping only while its ticket could still verify, so <see cref="ExpiresAtUtc"/> 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 <see cref="RelaySession"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RelayTicketUse
|
||||
{
|
||||
/// <summary>The ticket's own id. Primary key, and the whole of the guard.</summary>
|
||||
public Guid TicketId { get; set; }
|
||||
|
||||
/// <summary>When the ticket stops verifying, after which this row guards nothing.</summary>
|
||||
public DateTimeOffset ExpiresAtUtc { get; set; }
|
||||
|
||||
/// <summary>When it was redeemed.</summary>
|
||||
public DateTimeOffset UsedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The audit record of one relay session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RelaySession
|
||||
{
|
||||
/// <summary>The session id, minted with the ticket and returned to the client. Primary key.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Who opened it.</summary>
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>The host it reached.</summary>
|
||||
public Guid HostId { get; set; }
|
||||
|
||||
/// <summary>The vault the host was in when the ticket was issued.</summary>
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
/// <summary>The hostname as the host row carried it at issue.</summary>
|
||||
public string TargetHost { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The address actually dialled, which is the one that answered.</summary>
|
||||
/// <remarks>Null only for a session whose target never answered.</remarks>
|
||||
public string? TargetAddress { get; set; }
|
||||
|
||||
/// <summary>The port dialled.</summary>
|
||||
public int TargetPort { get; set; }
|
||||
|
||||
/// <summary>The client's address as this node saw it, for the audit ADR 0004 promises.</summary>
|
||||
public string? ClientAddress { get; set; }
|
||||
|
||||
/// <summary>When the socket was accepted.</summary>
|
||||
public DateTimeOffset StartedAtUtc { get; set; }
|
||||
|
||||
/// <summary>When it closed, or null while it is open — or if the node died under it.</summary>
|
||||
public DateTimeOffset? EndedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Bytes forwarded from the client to the target.</summary>
|
||||
public long BytesClientToTarget { get; set; }
|
||||
|
||||
/// <summary>Bytes forwarded from the target to the client.</summary>
|
||||
public long BytesTargetToClient { get; set; }
|
||||
|
||||
/// <summary>Why it ended. <see cref="RelayCloseReason.Unspecified"/> while open.</summary>
|
||||
public RelayCloseReason CloseReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Why a relay session ended.</summary>
|
||||
/// <remarks>
|
||||
/// Numbered as <c>RelaySessionCloseReason</c> on the wire is, and held to it by a test rather than by
|
||||
/// hope: the endpoint converts between the two by value.
|
||||
/// </remarks>
|
||||
public enum RelayCloseReason
|
||||
{
|
||||
/// <summary>Not a legal value for a closed session; what an open one carries.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>The client closed the connection.</summary>
|
||||
ClientClosed = 1,
|
||||
|
||||
/// <summary>The target closed the connection.</summary>
|
||||
TargetClosed = 2,
|
||||
|
||||
/// <summary>No traffic within the idle timeout.</summary>
|
||||
IdleTimeout = 3,
|
||||
|
||||
/// <summary>The maximum session duration was reached.</summary>
|
||||
DurationLimit = 4,
|
||||
|
||||
/// <summary>The server is shutting down and drained the session.</summary>
|
||||
ServerShutdown = 5,
|
||||
|
||||
/// <summary>The connection to the target failed.</summary>
|
||||
TargetUnreachable = 6,
|
||||
|
||||
/// <summary>An error ended the session.</summary>
|
||||
Error = 7,
|
||||
}
|
||||
Reference in new issue
Block a user