Public Access
A scan of the working tree and all 335 commits for anything that should not be public found no leaked secret. What it did find was three places where a reader is told something the repository does not back up, and in a public repository a confident wrong note is worse than silence: nobody goes and checks a sentence that sounds settled. The ignore list stopped at *.pfx, *.p12 and *.snk, so a keystore was not covered at all. The one that matters is the release keystore ADR 0011 rule 1 keeps off runners — drop it in the tree while cutting a release and nothing would have said a word. *.keystore and *.jks now, with build/nightly.keystore negated: an ignore rule cannot untrack a tracked file, so the negation changes no behaviour and is there so the next reader does not take the committed one for an accident. ADR 0014 is cited beside it, because "a signing key is committed here on purpose" is a claim that needs its argument within reach. Also gone from that list: a negation for deploy/.env.example, which has never existed in any commit. RelayOptions.AllowPrivateNetworks said loopback, link-local and cloud metadata ranges "are denied unconditionally and are not configurable" — present tense, in the voice of a control that exists. Nothing reads the setting. There is no relay endpoint and no dialler; the relay is ADR 0004 and a contract type. So the sentence most likely to be read by somebody auditing this repository's SSRF posture was describing code nobody has written. It now says that outright, and hands the obligation to whoever builds the dialler with ADR 0004 §3 named in full, including the IPv4-mapped IPv6 re-check, which is the clause a fresh implementation is likeliest to miss. The flag governs RFC1918 and never that list, which was the other half the old wording ran together. docker-compose.dev.yml pointed at deploy/docker-compose.yml as "the production stack". That file has never existed here either. Says what does ship — the image the CI image job builds and pushes — and puts a compose file to run it where it belongs, in M4's self-hosting guide. Comments and an ignore list only; no behaviour moves. The API builds clean, and the two tests that mention the dev compose file read its Postgres version, which is untouched. Left alone deliberately: release-android.ps1 and release-windows.ps1 pass the keystore password and the forge token as arguments to a child process, where another process on the machine can read them off the command line. That is a real if small hole and a code change rather than a comment — the vpk half needs somebody to find out whether it takes the token from the environment first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
296 lines
13 KiB
C#
296 lines
13 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>
|
|
/// <b>Nothing reads this setting yet, and that is the point of saying so here.</b> 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, <c>0.0.0.0/8</c>, 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public bool AllowPrivateNetworks { get; set; } = true;
|
|
|
|
/// <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; }
|
|
}
|