Public Access
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.
95 lines
3.8 KiB
C#
95 lines
3.8 KiB
C#
using DodoSSH.Domain;
|
|
|
|
namespace DodoSSH.Api.Features.Relay;
|
|
|
|
/// <summary>Source-generated log messages for the relay.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="TargetRejected"/> is the one place the reason for a refused target is written down. The
|
|
/// client is told nothing specific, deliberately — see <c>RelayTicketEndpoint</c> — so this line is how
|
|
/// an operator answers "why will the relay not reach this host".
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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);
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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);
|
|
}
|