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.
///
/// 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 EmailVerifiedClaim 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 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.
///
public string NameClaim { get; set; } = "name";
}
/// 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.
///
///
/// Nothing reads this setting yet, and that is the point of saying so here. The relay is
/// an ADR and a contract; there is no endpoint and no dialler. This option exists so the shape
/// is settled before the code is, which makes it exactly the kind of comment that gets read as a
/// description of an implemented control. It is not one. Whoever builds the dialler owes ADR 0004
/// §3 in full — loopback, link-local, 0.0.0.0/8, multicast and the cloud metadata addresses
/// denied with no configuration escape, and IPv4-mapped IPv6 normalised and re-checked — and this
/// flag governs RFC1918 only, never that list.
///
///
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; }
}