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.
172 lines
6.5 KiB
C#
172 lines
6.5 KiB
C#
using System.Net;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.WebUtilities;
|
|
|
|
namespace DodoSSH.Api.Features.Relay;
|
|
|
|
/// <summary>
|
|
/// Everything the relay socket needs to know, decided by the ticket endpoint.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it
|
|
/// to resolve again — <see cref="TargetHost"/> 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.
|
|
/// </remarks>
|
|
/// <param name="TicketId">The replay guard's key.</param>
|
|
/// <param name="SessionId">The audit row's id, already given to the client.</param>
|
|
/// <param name="UserId">Who asked.</param>
|
|
/// <param name="HostId">The host it is for.</param>
|
|
/// <param name="VaultId">The host's vault at issue.</param>
|
|
/// <param name="TargetHost">The hostname as stored, for the audit row.</param>
|
|
/// <param name="TargetAddresses">The permitted addresses the name resolved to, in the order to try.</param>
|
|
/// <param name="TargetPort">The port.</param>
|
|
/// <param name="ExpiresAt">When the ticket stops verifying.</param>
|
|
internal sealed record RelayTicket(
|
|
Guid TicketId,
|
|
Guid SessionId,
|
|
Guid UserId,
|
|
Guid HostId,
|
|
Guid VaultId,
|
|
string TargetHost,
|
|
IReadOnlyList<IPAddress> TargetAddresses,
|
|
int TargetPort,
|
|
DateTimeOffset ExpiresAt);
|
|
|
|
/// <summary>
|
|
/// Seals and opens relay tickets.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The expiry is inside the payload and checked against <see cref="TimeProvider"/>, 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock)
|
|
{
|
|
/// <summary>The purpose string, which isolates these payloads from every other protector's.</summary>
|
|
private const string Purpose = "DodoSSH.Relay.Ticket";
|
|
|
|
private const byte LayoutVersion = 1;
|
|
|
|
/// <summary>More addresses than any real name resolves to, and a bound on what a ticket can carry.</summary>
|
|
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()));
|
|
}
|
|
|
|
/// <summary>Opens a ticket, or answers null for one that is forged, damaged or expired.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|