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