Files
DodoSSH/src/DodoSSH.Api/Setup/DodoOptions.cs
T
jaap-jan b20f7cae04
ci / build and test (pull_request) Successful in 6m27s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 15m47s
ci / api image (pull_request) Failing after 28s
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.
2026-09-28 14:37:20 +02:00

323 lines
14 KiB
C#

using System.ComponentModel.DataAnnotations;
namespace DodoSSH.Api.Setup;
/// <summary>Identity provider settings.</summary>
/// <remarks>
/// Provider-agnostic by design. Claim names differ between providers — Keycloak nests roles at
/// <c>realm_access.roles</c>, Entra uses <c>roles</c> and <c>groups</c>, Auth0 namespaces them,
/// Authentik uses <c>groups</c> — so the mapping is configuration rather than code.
/// </remarks>
public sealed class OidcOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Oidc";
/// <summary>Issuer URL. Discovery and JWKS are fetched from here.</summary>
[Required]
[Url]
public string Authority { get; set; } = string.Empty;
/// <summary>Expected audience of access tokens.</summary>
[Required]
public string Audience { get; set; } = string.Empty;
/// <summary>Public client identifier the desktop app uses.</summary>
[Required]
public string ClientId { get; set; } = string.Empty;
/// <summary>Scopes the client should request.</summary>
public IList<string> Scopes { get; } = ["openid", "profile", "email", "offline_access"];
/// <summary>
/// Registered loopback redirect pattern. RFC 8252: a native client uses a loopback redirect on
/// an ephemeral port with the system browser, never a custom scheme and never an embedded
/// browser, so the user can see the real address bar.
/// </summary>
/// <remarks>
/// No wildcard in the port. RFC 8252 requires a native client to use an ephemeral loopback port, and
/// providers implement that by ignoring the port when the host is a loopback literal — Keycloak
/// included. Writing <c>http://127.0.0.1:*/callback</c> looks more explicit and is worse: Keycloak
/// parses the <c>*</c> as a literal port and rejects every real redirect with "Invalid parameter:
/// redirect_uri". Pinning the path is the part that matters, since it stops another local process
/// having a code delivered somewhere else.
/// </remarks>
public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback";
/// <summary>Whether HTTPS metadata is required. Only ever false for local development.</summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Whether a new identity-provider subject may be linked to an existing account by matching
/// email.
/// </summary>
/// <remarks>
/// Defaults to false, and must stay that way. If an attacker can obtain a token from any
/// configured provider carrying a victim's email address, email linking hands them the
/// victim's account.
/// </remarks>
public bool AllowEmailLinking { get; set; }
/// <summary>Claim type holding the user's email.</summary>
public string EmailClaim { get; set; } = "email";
/// <summary>Claim type holding the user's display name.</summary>
/// <remarks>
/// The email and the display name are read for display and for the directory's address lookup, and
/// for nothing that decides access. There was an <c>EmailVerifiedClaim</c> beside these, read by
/// exactly one feature — claiming a team invitation addressed to that address — and it went with
/// the invitations. Nothing in this server now trusts the email claim to grant anything, which is
/// the property <see cref="AllowEmailLinking"/> spends its whole doc comment defending; re-adding a
/// setting here would be the first step back towards a token's address being a way in.
/// </remarks>
public string NameClaim { get; set; } = "name";
}
/// <summary>Schema management.</summary>
public sealed class DatabaseOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Database";
/// <summary>
/// Whether the API applies pending migrations as it starts.
/// </summary>
/// <remarks>
/// <para>
/// On by default, because the alternative asks every self-hosted operator to run a second thing in
/// the right order and gives them an instance that boots and then fails readiness when they do not.
/// The schema and the code that expects it ship in the same image, so the image is the natural place
/// for the two to be reconciled.
/// </para>
/// <para>
/// Turn it off where the deployment already owns schema changes: a migrator job in the release
/// pipeline, a rollout where the new code must run against the old schema first, or a database user
/// that is deliberately denied DDL. With it off the behaviour is exactly what it was before this
/// setting existed — pending migrations fail readiness and say which, and nothing repairs itself.
/// </para>
/// </remarks>
public bool AutoMigrate { get; set; } = true;
}
/// <summary>Relay settings. See ADR 0004.</summary>
public sealed class RelayOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Relay";
/// <summary>Whether this deployment offers a relay at all.</summary>
public bool Enabled { get; set; }
/// <summary>Public WebSocket URL clients should dial. Required when enabled.</summary>
public string? WebSocketUrl { get; set; }
/// <summary>
/// Whether RFC1918 and other private ranges may be dialled.
/// </summary>
/// <remarks>
/// <para>
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
/// </para>
/// <para>
/// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
/// IPv6 unique-local — and never the list <c>RelayAddressPolicy</c> refuses with no configuration
/// escape: loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses.
/// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
/// </para>
/// </remarks>
public bool AllowPrivateNetworks { get; set; } = true;
/// <summary>
/// Networks, in CIDR form, that a relay target must fall inside. Empty means any network the other
/// rules permit.
/// </summary>
/// <remarks>
/// When set, this is the whole answer for the ranges it names: a target inside one is permitted even
/// with <see cref="AllowPrivateNetworks"/> off, because "only 10.20.0.0/16" is the precise thing an
/// operator means by setting both. It still cannot reach the non-overridable list, and
/// <see cref="DeniedNetworks"/> still wins over it.
/// </remarks>
public IList<string> AllowedNetworks { get; } = [];
/// <summary>Networks, in CIDR form, that a relay target may never fall inside.</summary>
/// <remarks>
/// Checked before <see cref="AllowedNetworks"/>, so a deny inside an allow is a hole in it — the way
/// round that lets an operator carve the database subnet out of an otherwise permitted range.
/// </remarks>
public IList<string> DeniedNetworks { get; } = [];
/// <summary>
/// A directory to keep the ticket key ring in. Unset means the platform default.
/// </summary>
/// <remarks>
/// Losing the key ring is harmless in the way losing <c>Sync:CursorSigningKey</c> is: a ticket lives
/// thirty seconds, so a restart invalidates at most the connects in flight and the client asks
/// again. What does need it is more than one node — every node must be able to read every other's
/// tickets for round-robin to work, so a multi-node deployment points this at shared storage.
/// </remarks>
public string? KeyRingDirectory { get; set; }
/// <summary>Maximum concurrent sessions per user.</summary>
[Range(1, 1000)]
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
/// <summary>Maximum concurrent sessions per node.</summary>
[Range(1, 100_000)]
public int MaxConcurrentSessionsTotal { get; set; } = 200;
/// <summary>Maximum session lifetime.</summary>
public TimeSpan MaxSessionDuration { get; set; } = TimeSpan.FromHours(12);
/// <summary>Idle timeout.</summary>
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>Timeout for the outbound TCP connect.</summary>
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Ticket lifetime. Deliberately tiny: single-use and single-host.</summary>
public TimeSpan TicketLifetime { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>How long to keep draining live sessions during shutdown.</summary>
public TimeSpan DrainTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>Realtime push settings. See ADR 0012.</summary>
/// <remarks>
/// Every one of these bounds a socket rather than a feature: with the whole thing off, or every cap
/// met, clients synchronise on their timer exactly as they did before this existed. That is what
/// makes it safe for an operator to turn any of them down.
/// </remarks>
public sealed class EventsOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Events";
/// <summary>
/// Whether this deployment pushes vault changes at all.
/// </summary>
/// <remarks>
/// On by default, unlike the relay: this needs no outbound network, no target resolution and no
/// new trust, and a deployment behind a proxy that will not upgrade should say so here rather than
/// have every client discover it by failing.
/// </remarks>
public bool Enabled { get; set; } = true;
/// <summary>Maximum concurrent sockets per node.</summary>
[Range(1, 100_000)]
public int MaxConnectionsTotal { get; set; } = 500;
/// <summary>
/// Maximum concurrent sockets per account.
/// </summary>
/// <remarks>
/// Per account rather than per device, because the server cannot see a device here. Eight is a
/// laptop, a desktop, a phone and room to reconnect before the old socket has been reaped.
/// </remarks>
[Range(1, 1000)]
public int MaxConnectionsPerUser { get; set; } = 8;
/// <summary>
/// How many notices may be queued for one socket before the oldest are dropped.
/// </summary>
/// <remarks>
/// A notice names a vault and a position, so a newer one subsumes the one it replaces. The depth
/// therefore buys smoothness over a brief stall and nothing else — losing the tail of a burst
/// costs a client nothing, because the newest notice still says to pull.
/// </remarks>
[Range(1, 10_000)]
public int OutboundQueueDepth { get; set; } = 64;
/// <summary>
/// How often the server pings an idle socket.
/// </summary>
/// <remarks>
/// Below the sixty seconds most reverse proxies idle out at, because a silent socket that a proxy
/// has quietly dropped is indistinguishable from a quiet one until something is sent down it.
/// </remarks>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// How often an open socket re-reads which vaults its account may follow.
/// </summary>
/// <remarks>
/// The backstop for a grant withdrawn mid-connection. Grants and membership changes publish
/// immediately, so this is what covers the paths that do not — and what bounds the window if one
/// is ever added without remembering to.
/// </remarks>
public TimeSpan AccessRefreshInterval { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// The longest any one socket may live, regardless of its token.
/// </summary>
/// <remarks>
/// A socket normally ends at its access token's expiry, which is far shorter. This is the bound
/// for a provider that issues long-lived tokens, and it is what makes "no connection is older than
/// this" a property of the server rather than of the identity provider's configuration.
/// </remarks>
public TimeSpan MaxConnectionDuration { get; set; } = TimeSpan.FromHours(12);
}
/// <summary>Sync protocol limits.</summary>
public sealed class SyncOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Sync";
/// <summary>Maximum operations in one push. Enforced before the transaction opens.</summary>
[Range(1, 10_000)]
public int MaxOperationsPerPush { get; set; } = 500;
/// <summary>Maximum total ciphertext in one push.</summary>
[Range(1024, 1024L * 1024 * 1024)]
public long MaxPayloadBytes { get; set; } = 8L * 1024 * 1024;
/// <summary>Maximum ciphertext for a single item.</summary>
[Range(1024, 1024L * 1024 * 1024)]
public long MaxItemPayloadBytes { get; set; } = 256L * 1024;
/// <summary>Default page size for a pull.</summary>
[Range(1, 10_000)]
public int DefaultPullLimit { get; set; } = 200;
/// <summary>Maximum page size for a pull.</summary>
[Range(1, 10_000)]
public int MaxPullLimit { get; set; } = 1000;
/// <summary>How long tombstones are retained before collection.</summary>
[Range(1, 3650)]
public int TombstoneRetentionDays { get; set; } = 90;
/// <summary>
/// Key used to sign sync cursors, base64.
/// </summary>
/// <remarks>
/// Cursors are integrity-tagged so a tampered one is rejected rather than silently
/// mis-serving. Generated per deployment; losing it only invalidates in-flight cursors, since
/// clients simply resync from the beginning.
/// </remarks>
public string? CursorSigningKey { get; set; }
}
/// <summary>Server identity and client compatibility.</summary>
public sealed class ServerOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Server";
/// <summary>Public base URL clients should use for API calls.</summary>
[Required]
[Url]
public string PublicBaseUrl { get; set; } = string.Empty;
/// <summary>
/// Oldest client version this server will serve.
/// </summary>
/// <remarks>
/// Self-hosted means version skew is normal, not exceptional. A client below this must be
/// shown a clear remediation screen rather than failing obscurely mid-sync.
/// </remarks>
public string? MinClientVersion { get; set; }
}