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