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); } }