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.
232 lines
9.2 KiB
C#
232 lines
9.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Issues the ticket that lets exactly one relay socket reach exactly one host.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every refusal about the target is the same refusal.</b> 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 <c>relay-target-rejected</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class RelayTicketEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
IVaultAccessService vaultAccess,
|
|
DodoDbContext database,
|
|
IRelayNetwork network,
|
|
RelayTicketCodec codec,
|
|
RelaySessionRegistry sessions,
|
|
IOptions<RelayOptions> options,
|
|
TimeProvider clock,
|
|
ILogger<RelayTicketEndpoint> logger)
|
|
: Endpoint<RelayTicketRequest, Results<Ok<RelayTicketResponse>, ProblemHttpResult>>
|
|
{
|
|
/// <summary>What every target refusal says, whatever the reason was.</summary>
|
|
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.";
|
|
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<RelayTicketResponse>, 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the host, and decides whether this caller may be relayed to it at all.
|
|
/// </summary>
|
|
/// <returns>The stored target, or the reason there is none — for the log, never for the caller.</returns>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the stored name, and keeps the addresses the policy permits.
|
|
/// </summary>
|
|
/// <returns>The permitted addresses, possibly none; or null when the name did not resolve.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The addresses kept are the normalised ones, so what is dialled is exactly what was judged rather
|
|
/// than a second spelling of it.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<IPAddress[]?> 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);
|
|
}
|
|
}
|