using System.ComponentModel.DataAnnotations; namespace DodoSSH.Api.Setup; /// Identity provider settings. /// /// Provider-agnostic by design. Claim names differ between providers — Keycloak nests roles at /// realm_access.roles, Entra uses roles and groups, Auth0 namespaces them, /// Authentik uses groups — so the mapping is configuration rather than code. /// public sealed class OidcOptions { /// Configuration section name. public const string SectionName = "Oidc"; /// Issuer URL. Discovery and JWKS are fetched from here. [Required] [Url] public string Authority { get; set; } = string.Empty; /// Expected audience of access tokens. [Required] public string Audience { get; set; } = string.Empty; /// Public client identifier the desktop app uses. [Required] public string ClientId { get; set; } = string.Empty; /// Scopes the client should request. public IList Scopes { get; } = ["openid", "profile", "email", "offline_access"]; /// /// 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. /// /// /// 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 http://127.0.0.1:*/callback looks more explicit and is worse: Keycloak /// parses the * 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. /// public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback"; /// Whether HTTPS metadata is required. Only ever false for local development. public bool RequireHttpsMetadata { get; set; } = true; /// /// Whether a new identity-provider subject may be linked to an existing account by matching /// email. /// /// /// 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. /// public bool AllowEmailLinking { get; set; } /// Claim type holding the user's email. public string EmailClaim { get; set; } = "email"; /// Claim type holding the user's display name. public string NameClaim { get; set; } = "name"; /// /// Claim type asserting that the provider has verified the user's email. /// /// /// /// Read for exactly one purpose: deciding whether a pending team invitation addressed to that /// email may be claimed. Nothing else in this server trusts the email claim for anything, and /// records why — a token from any configured provider carrying a /// victim's address must not confer access to anything of theirs. An invitation is access, so it /// needs the same bar. /// /// /// Absence is a refusal, not a default. A provider that does not send this claim leaves /// every invitation pending for ever, which is visible on the teams screen and diagnosable in the /// log. There is deliberately no option to trust an unverified address instead: a flag that exists /// is a flag somebody turns on for the afternoon their provider is misconfigured, and this is the /// one it must not be possible to turn on. /// /// public string EmailVerifiedClaim { get; set; } = "email_verified"; } /// Schema management. public sealed class DatabaseOptions { /// Configuration section name. public const string SectionName = "Database"; /// /// Whether the API applies pending migrations as it starts. /// /// /// /// 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. /// /// /// 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. /// /// public bool AutoMigrate { get; set; } = true; } /// Relay settings. See ADR 0004. public sealed class RelayOptions { /// Configuration section name. public const string SectionName = "Relay"; /// Whether this deployment offers a relay at all. public bool Enabled { get; set; } /// Public WebSocket URL clients should dial. Required when enabled. public string? WebSocketUrl { get; set; } /// /// Whether RFC1918 and other private ranges may be dialled. /// /// /// 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. Loopback, /// link-local and cloud metadata ranges are denied unconditionally and are not configurable. /// public bool AllowPrivateNetworks { get; set; } = true; /// Maximum concurrent sessions per user. [Range(1, 1000)] public int MaxConcurrentSessionsPerUser { get; set; } = 10; /// Maximum concurrent sessions per node. [Range(1, 100_000)] public int MaxConcurrentSessionsTotal { get; set; } = 200; /// Maximum session lifetime. public TimeSpan MaxSessionDuration { get; set; } = TimeSpan.FromHours(12); /// Idle timeout. public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10); /// Timeout for the outbound TCP connect. public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5); /// Ticket lifetime. Deliberately tiny: single-use and single-host. public TimeSpan TicketLifetime { get; set; } = TimeSpan.FromSeconds(30); /// How long to keep draining live sessions during shutdown. public TimeSpan DrainTimeout { get; set; } = TimeSpan.FromSeconds(30); } /// Realtime push settings. See ADR 0012. /// /// 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. /// public sealed class EventsOptions { /// Configuration section name. public const string SectionName = "Events"; /// /// Whether this deployment pushes vault changes at all. /// /// /// 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. /// public bool Enabled { get; set; } = true; /// Maximum concurrent sockets per node. [Range(1, 100_000)] public int MaxConnectionsTotal { get; set; } = 500; /// /// Maximum concurrent sockets per account. /// /// /// 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. /// [Range(1, 1000)] public int MaxConnectionsPerUser { get; set; } = 8; /// /// How many notices may be queued for one socket before the oldest are dropped. /// /// /// 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. /// [Range(1, 10_000)] public int OutboundQueueDepth { get; set; } = 64; /// /// How often the server pings an idle socket. /// /// /// 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. /// public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(30); /// /// How often an open socket re-reads which vaults its account may follow. /// /// /// 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. /// public TimeSpan AccessRefreshInterval { get; set; } = TimeSpan.FromMinutes(5); /// /// The longest any one socket may live, regardless of its token. /// /// /// 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. /// public TimeSpan MaxConnectionDuration { get; set; } = TimeSpan.FromHours(12); } /// Sync protocol limits. public sealed class SyncOptions { /// Configuration section name. public const string SectionName = "Sync"; /// Maximum operations in one push. Enforced before the transaction opens. [Range(1, 10_000)] public int MaxOperationsPerPush { get; set; } = 500; /// Maximum total ciphertext in one push. [Range(1024, 1024L * 1024 * 1024)] public long MaxPayloadBytes { get; set; } = 8L * 1024 * 1024; /// Maximum ciphertext for a single item. [Range(1024, 1024L * 1024 * 1024)] public long MaxItemPayloadBytes { get; set; } = 256L * 1024; /// Default page size for a pull. [Range(1, 10_000)] public int DefaultPullLimit { get; set; } = 200; /// Maximum page size for a pull. [Range(1, 10_000)] public int MaxPullLimit { get; set; } = 1000; /// How long tombstones are retained before collection. [Range(1, 3650)] public int TombstoneRetentionDays { get; set; } = 90; /// /// Key used to sign sync cursors, base64. /// /// /// 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. /// public string? CursorSigningKey { get; set; } } /// Server identity and client compatibility. public sealed class ServerOptions { /// Configuration section name. public const string SectionName = "Server"; /// Public base URL clients should use for API calls. [Required] [Url] public string PublicBaseUrl { get; set; } = string.Empty; /// /// Oldest client version this server will serve. /// /// /// 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. /// public string? MinClientVersion { get; set; } }