From d3b14e6bc04385bc7804fa997777509fee5bf988 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 28 Jul 2026 14:33:54 +0200 Subject: [PATCH] Add configuration, OIDC auth wiring and discovery endpoints (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options, JWT bearer validation, the /meta and .well-known endpoints, and a dev compose stack with Keycloak. Verified end to end: compose up, migrate, run, both discovery endpoints return correct payloads, and readiness reports the schema current. Configuration: - Strongly-typed options for Server, Oidc, Relay and Sync, all ValidateOnStart. A self-hosted server that boots half-configured and fails later per-request is far harder to diagnose than one that refuses to start and names the bad setting. - Cross-field validation the annotations cannot express: relay needs a WebSocketUrl when enabled, idle timeout must be under max session duration, item payload cap under batch cap. - Startup warnings for combinations that are individually valid but dangerous together: RequireHttpsMetadata false outside Development, and AllowEmailLinking (which turns any token bearing a victim's email into account takeover, hence default false). Auth: - JwtBearer with ClockSkew cut to 30s from the 5-minute default; five minutes of slack on a credential granting vault ciphertext access is more than any clock needs. - IncludeErrorDetails off, and a FallbackPolicy so an endpoint without an explicit policy still requires a caller rather than silently being public. Discovery, per ADR 0002: - /api/v1/meta reports versions, features and push caps. - /.well-known/dodossh-configuration is the onboarding story: the user types one server URL and the client discovers OIDC authority, client id, scopes and relay endpoint. Two environment problems found by actually running the stack: - PostgreSQL 18 changed its data mount point. Mounting /var/lib/postgresql/data — correct through 17 — makes the image refuse to start; 18+ wants a single mount at /var/lib/postgresql with the cluster in a subdirectory. - Keycloak moved to host port 18080. An unrelated Apache Tomcat on this machine holds 127.0.0.1:8080, and a loopback-specific bind beats Docker's 0.0.0.0 publish for "localhost". It presents as Keycloak 404ing every realm while its own log says the import succeeded, which is a genuinely misleading failure. Also: CA1848 is enforced, not advisory — warnings are errors, so the .editorconfig comment claiming otherwise was wrong. Startup and health logging now uses [LoggerMessage]. And a clean rebuild is back to zero warnings; the incremental build had been hiding 40 in test projects (banned Guid.NewGuid, an obsolete Testcontainers constructor, and two analyzer families that are genuinely noise under a test host). Verified: 0 warnings on a clean rebuild, 122 tests pass, format clean. --- .editorconfig | 7 +- Directory.Packages.props | 1 + deploy/docker-compose.dev.yml | 59 +++++++ deploy/keycloak/realm-dodossh.json | 81 +++++++++ src/DodoSSH.Api/DodoSSH.Api.csproj | 8 + .../Features/Meta/MetaEndpoints.cs | 91 ++++++++++ src/DodoSSH.Api/Program.cs | 13 ++ src/DodoSSH.Api/Setup/Auth.cs | 67 +++++++ src/DodoSSH.Api/Setup/Configuration.cs | 74 ++++++++ src/DodoSSH.Api/Setup/DodoOptions.cs | 167 ++++++++++++++++++ src/DodoSSH.Api/Setup/EndpointRegistration.cs | 29 +++ src/DodoSSH.Api/Setup/Persistence.cs | 85 +++++++++ src/DodoSSH.Api/Setup/StartupLog.cs | 38 ++++ src/DodoSSH.Api/appsettings.Development.json | 16 ++ src/DodoSSH.Api/appsettings.json | 30 +++- src/DodoSSH.Api/packages.lock.json | 160 +++++++++++++++++ tests/Directory.Build.props | 9 +- .../PostgresFixture.cs | 6 +- .../SchemaConstraintTests.cs | 16 +- 19 files changed, 941 insertions(+), 16 deletions(-) create mode 100644 deploy/docker-compose.dev.yml create mode 100644 deploy/keycloak/realm-dodossh.json create mode 100644 src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs create mode 100644 src/DodoSSH.Api/Setup/Auth.cs create mode 100644 src/DodoSSH.Api/Setup/Configuration.cs create mode 100644 src/DodoSSH.Api/Setup/DodoOptions.cs create mode 100644 src/DodoSSH.Api/Setup/EndpointRegistration.cs create mode 100644 src/DodoSSH.Api/Setup/Persistence.cs create mode 100644 src/DodoSSH.Api/Setup/StartupLog.cs diff --git a/.editorconfig b/.editorconfig index 04c606b..8945f0d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -98,9 +98,10 @@ dotnet_diagnostic.IDE0055.severity = error # meaningful in the Avalonia client, which re-enables CA2007 in its own .editorconfig. dotnet_diagnostic.CA2007.severity = none -# Prefer LoggerMessage source generation over ILogger extension calls — allocation-free -# and gives structured events by construction. Warning, so it is visible but not a wall -# during early development; raised to error once the logging pass lands in M4. +# Require LoggerMessage source generation over ILogger extension calls — allocation-free, +# and event ids plus message templates become a greppable inventory rather than string +# literals scattered through the code. Effectively an error, since warnings are errors; +# stated as such rather than pretending it is advisory. dotnet_diagnostic.CA1848.severity = warning # Exceptions carry ProblemDetails codes, not localised text. diff --git a/Directory.Packages.props b/Directory.Packages.props index f7c0bb1..e67d992 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..5df19e6 --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,59 @@ +# Development dependencies only: PostgreSQL and Keycloak. +# +# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the +# schema is still churning. The production stack is deploy/docker-compose.yml. +# +# docker compose -f deploy/docker-compose.dev.yml up -d +# dotnet run --project src/DodoSSH.Api +# +# Keycloak admin console: http://localhost:18080 (admin / admin) +# Test user: alice / alice + +name: dodossh-dev + +services: + postgres: + image: postgres:18-alpine + container_name: dodossh-dev-postgres + environment: + POSTGRES_DB: dodossh + POSTGRES_USER: dodossh + POSTGRES_PASSWORD: dodossh + # Deterministic collation, matching the server's InvariantGlobalization. + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + ports: + - "5432:5432" + volumes: + # PostgreSQL 18+ wants a single mount at /var/lib/postgresql, with the cluster in a + # subdirectory. Mounting /var/lib/postgresql/data directly — which was correct for 17 + # and earlier — makes the image refuse to start, and pg_upgrade --link cannot cross the + # mount boundary later. + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dodossh -d dodossh"] + interval: 5s + timeout: 5s + retries: 10 + + keycloak: + image: quay.io/keycloak/keycloak:26.4 + container_name: dodossh-dev-keycloak + # start-dev, never in production: it disables HTTPS enforcement and uses an in-memory + # database. The realm is imported on every start so this stays disposable. + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + ports: + # 18080, not 8080. Port 8080 is heavily contested on developer machines — a stray Tomcat + # or WSL relay bound to 127.0.0.1 wins over Docker's 0.0.0.0 publish for "localhost", + # which presents as Keycloak returning 404 for every realm and is thoroughly confusing to + # debug. Keycloak derives the token issuer from the request host, so the port simply has + # to match Oidc:Authority. + - "18080:8080" + volumes: + - ./keycloak:/opt/keycloak/data/import:ro + +volumes: + postgres-data: diff --git a/deploy/keycloak/realm-dodossh.json b/deploy/keycloak/realm-dodossh.json new file mode 100644 index 0000000..bed404f --- /dev/null +++ b/deploy/keycloak/realm-dodossh.json @@ -0,0 +1,81 @@ +{ + "realm": "dodossh", + "enabled": true, + "displayName": "DodoSSH (development)", + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "accessTokenLifespan": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + + "clients": [ + { + "clientId": "dodossh-desktop", + "name": "DodoSSH Desktop", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "implicitFlowEnabled": false, + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://127.0.0.1:*/*" + }, + "redirectUris": [ + "http://127.0.0.1:*/callback", + "http://localhost:*/callback" + ], + "webOrigins": [], + "protocolMappers": [ + { + "name": "dodossh-api-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "dodossh-api", + "access.token.claim": "true", + "id.token.claim": "false" + } + } + ] + } + ], + + "users": [ + { + "username": "alice", + "enabled": true, + "emailVerified": true, + "email": "alice@example.com", + "firstName": "Alice", + "lastName": "Example", + "credentials": [ + { + "type": "password", + "value": "alice", + "temporary": false + } + ] + }, + { + "username": "bob", + "enabled": true, + "emailVerified": true, + "email": "bob@example.com", + "firstName": "Bob", + "lastName": "Example", + "credentials": [ + { + "type": "password", + "value": "bob", + "temporary": false + } + ] + } + ] +} diff --git a/src/DodoSSH.Api/DodoSSH.Api.csproj b/src/DodoSSH.Api/DodoSSH.Api.csproj index f82e071..b92bf3d 100644 --- a/src/DodoSSH.Api/DodoSSH.Api.csproj +++ b/src/DodoSSH.Api/DodoSSH.Api.csproj @@ -12,6 +12,14 @@ + + + + + + + + diff --git a/src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs b/src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs new file mode 100644 index 0000000..43af9c9 --- /dev/null +++ b/src/DodoSSH.Api/Features/Meta/MetaEndpoints.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using DodoSSH.Api.Setup; +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Options; + +namespace DodoSSH.Api.Features.Meta; + +/// +/// Capability and discovery endpoints. +/// +/// +/// These replace URL-based API versioning. When client and server upgrade independently — which is +/// the normal case for self-hosted software — a client needs to ask what this particular server +/// supports rather than assume. See ADR 0002. +/// +internal static class MetaEndpoints +{ + /// Sync semantics version. Bumped when push or pull behaviour changes. + internal const int SyncProtocolVersion = 1; + + /// Feature flag for the relay. + internal const string RelayFeature = "relay"; + + internal static IEndpointRouteBuilder MapMetaEndpoints(this IEndpointRouteBuilder app) + { + // Anonymous by necessity: a client must be able to discover how to authenticate before it + // can authenticate. + app.MapGet("/api/v1/meta", GetMeta) + .AllowAnonymous() + .WithName("GetMeta") + .WithSummary("Server capabilities, versions and limits."); + + app.MapGet("/.well-known/dodossh-configuration", GetConfiguration) + .AllowAnonymous() + .WithName("GetDodoSshConfiguration") + .WithSummary("Everything a client needs to begin authenticating, from one URL."); + + return app; + } + + private static Ok GetMeta( + IOptions sync, + IOptions relay, + IOptions server) + { + List features = ["teams"]; + if (relay.Value.Enabled) + { + features.Add(RelayFeature); + } + + return TypedResults.Ok(new MetaResponse( + ServerVersion: ServerVersion, + ApiVersions: [1], + SyncProtocolVersion: SyncProtocolVersion, + CryptoSpecVersion: CryptoSpec.CurrentAadVersion, + Features: features, + MinClientVersion: server.Value.MinClientVersion, + MaxOperationsPerPush: sync.Value.MaxOperationsPerPush, + MaxPayloadBytes: sync.Value.MaxPayloadBytes, + MaxItemPayloadBytes: sync.Value.MaxItemPayloadBytes)); + } + + private static Ok GetConfiguration( + IOptions oidc, + IOptions relay, + IOptions server) + { + var relayOptions = relay.Value; + + return TypedResults.Ok(new DodoSshConfiguration( + ApiBaseUrl: new Uri(server.Value.PublicBaseUrl, UriKind.Absolute), + Oidc: new OidcConfiguration( + Authority: new Uri(oidc.Value.Authority, UriKind.Absolute), + ClientId: oidc.Value.ClientId, + Scopes: [.. oidc.Value.Scopes], + LoopbackRedirectPattern: oidc.Value.LoopbackRedirectPattern), + Relay: new RelayConfiguration( + Enabled: relayOptions.Enabled, + WebSocketUrl: relayOptions.Enabled && relayOptions.WebSocketUrl is not null + ? new Uri(relayOptions.WebSocketUrl, UriKind.Absolute) + : null))); + } + + private static string ServerVersion { get; } = + typeof(MetaEndpoints).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? "0.0.0"; +} diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index 08f26b0..8c627c8 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -2,6 +2,13 @@ using DodoSSH.Api.Setup; var builder = WebApplication.CreateBuilder(args); +// Docker secrets, if mounted. Optional so the same image works with plain environment variables. +builder.Configuration.AddKeyPerFile("/run/secrets", optional: true); +builder.Configuration.AddEnvironmentVariables(prefix: "DODOSSH_"); + +builder.Services.AddDodoOptions(); +builder.Services.AddDodoPersistence(builder.Configuration); +builder.Services.AddDodoAuthentication(); builder.Services.AddDodoOpenApi(); builder.Services.AddDodoHealthChecks(); @@ -16,11 +23,17 @@ var app = builder.Build(); // produces redirect loops behind a proxy. HTTPS in development comes from the // launch profile instead. +app.UseAuthentication(); +app.UseAuthorization(); + app.MapDodoHealthChecks(); +app.MapDodoEndpoints(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } +app.WarnOnRiskyConfiguration(); + await app.RunAsync().ConfigureAwait(false); diff --git a/src/DodoSSH.Api/Setup/Auth.cs b/src/DodoSSH.Api/Setup/Auth.cs new file mode 100644 index 0000000..93ef70c --- /dev/null +++ b/src/DodoSSH.Api/Setup/Auth.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace DodoSSH.Api.Setup; + +/// Authentication and authorization wiring. +/// +/// The API validates bearer access tokens only. It never runs a browser flow itself: the desktop +/// app is a public client using Authorization Code with PKCE and a loopback redirect, and it talks +/// to the identity provider directly. +/// +internal static class Auth +{ + /// Policy requiring an authenticated caller. + internal const string AuthenticatedPolicy = "Authenticated"; + + /// Policy requiring a caller who has completed key enrollment. + internal const string EnrolledPolicy = "Enrolled"; + + internal static IServiceCollection AddDodoAuthentication(this IServiceCollection services) + { + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + // Bound late so options validation has already run and the values are known good. + var oidc = services.BuildServiceProvider().GetRequiredService>().Value; + + options.Authority = oidc.Authority; + options.Audience = oidc.Audience; + options.RequireHttpsMetadata = oidc.RequireHttpsMetadata; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + RequireExpirationTime = true, + + // 30 seconds, not the 5-minute default. A five-minute grace period on a + // credential that grants vault ciphertext access is far more slack than any + // sane clock needs. + ClockSkew = TimeSpan.FromSeconds(30), + }; + + // Tokens are the one thing that must never reach a log or a trace. + options.IncludeErrorDetails = false; + }); + + services.AddAuthorization(options => + { + options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser()); + + // Enrollment state lives in the database, so the real handler arrives with the + // enrollment feature. Registered now so endpoint groups can reference the policy name + // and the endpoint-inventory test has something to assert against. + options.AddPolicy(EnrolledPolicy, policy => policy.RequireAuthenticatedUser()); + + // Deny by default: an endpoint without an explicit policy still requires a caller. + options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy); + }); + + return services; + } +} diff --git a/src/DodoSSH.Api/Setup/Configuration.cs b/src/DodoSSH.Api/Setup/Configuration.cs new file mode 100644 index 0000000..3efec49 --- /dev/null +++ b/src/DodoSSH.Api/Setup/Configuration.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Options; + +namespace DodoSSH.Api.Setup; + +/// Binds and validates strongly-typed options. +/// +/// Every section uses ValidateOnStart. A self-hosted server that boots half-configured and +/// fails later, per-request, is far harder to diagnose than one that refuses to start and says +/// which setting is wrong. +/// +internal static class Configuration +{ + internal static IServiceCollection AddDodoOptions(this IServiceCollection services) + { + services.AddOptions() + .BindConfiguration(ServerOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .BindConfiguration(OidcOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .BindConfiguration(SyncOptions.SectionName) + .ValidateDataAnnotations() + .Validate( + options => options.MaxItemPayloadBytes <= options.MaxPayloadBytes, + "Sync:MaxItemPayloadBytes must not exceed Sync:MaxPayloadBytes.") + .Validate( + options => options.DefaultPullLimit <= options.MaxPullLimit, + "Sync:DefaultPullLimit must not exceed Sync:MaxPullLimit.") + .ValidateOnStart(); + + services.AddOptions() + .BindConfiguration(RelayOptions.SectionName) + .ValidateDataAnnotations() + .Validate( + options => !options.Enabled || !string.IsNullOrWhiteSpace(options.WebSocketUrl), + "Relay:WebSocketUrl is required when Relay:Enabled is true.") + .Validate( + options => options.IdleTimeout < options.MaxSessionDuration, + "Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.") + .ValidateOnStart(); + + return services; + } + + /// + /// Warns about configuration that is individually valid but dangerous in combination. + /// + /// + /// These cannot be hard failures — each is legitimate in some deployment — but silently + /// accepting them produces subtly broken security properties that nobody notices. + /// + internal static WebApplication WarnOnRiskyConfiguration(this WebApplication app) + { + var logger = app.Services.GetRequiredService().CreateLogger("DodoSSH.Startup"); + var oidc = app.Services.GetRequiredService>().Value; + + if (!oidc.RequireHttpsMetadata && !app.Environment.IsDevelopment()) + { + StartupLog.InsecureMetadataOutsideDevelopment(logger); + } + + if (oidc.AllowEmailLinking) + { + StartupLog.EmailLinkingEnabled(logger); + } + + return app; + } +} diff --git a/src/DodoSSH.Api/Setup/DodoOptions.cs b/src/DodoSSH.Api/Setup/DodoOptions.cs new file mode 100644 index 0000000..ce79c20 --- /dev/null +++ b/src/DodoSSH.Api/Setup/DodoOptions.cs @@ -0,0 +1,167 @@ +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. + /// + 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; } +} diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs new file mode 100644 index 0000000..0b1bd84 --- /dev/null +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -0,0 +1,29 @@ +using DodoSSH.Api.Features.Meta; + +namespace DodoSSH.Api.Setup; + +/// +/// The single, explicit list of every endpoint module. +/// +/// +/// Deliberately not reflection-based discovery. Explicit registration gives predictable startup, +/// survives trimming, and makes every route greppable — and a route that silently disappears +/// because an assembly was not scanned is a genuinely nasty failure. The cost is one line per +/// module. +/// +internal static class EndpointRegistration +{ + internal static WebApplication MapDodoEndpoints(this WebApplication app) + { + app.MapMetaEndpoints(); + + // Registered as each feature lands: + // Identity — /me, enrollment, key rotation, devices + // Directory — public-key lookup + // Vaults — grants, rekey, ACL + // Sync — pull and push + // Relay — tickets and the WebSocket + // Teams, Audit, Admin + return app; + } +} diff --git a/src/DodoSSH.Api/Setup/Persistence.cs b/src/DodoSSH.Api/Setup/Persistence.cs new file mode 100644 index 0000000..b05880b --- /dev/null +++ b/src/DodoSSH.Api/Setup/Persistence.cs @@ -0,0 +1,85 @@ +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace DodoSSH.Api.Setup; + +/// Database wiring. +internal static class Persistence +{ + private const string ConnectionStringName = "Postgres"; + + internal static IServiceCollection AddDodoPersistence( + this IServiceCollection services, + IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString(ConnectionStringName) + ?? throw new InvalidOperationException( + $"ConnectionStrings:{ConnectionStringName} is not configured."); + + services.AddDbContext(options => options + .UseNpgsql(connectionString, npgsql => npgsql + .MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName) + // Retry on transient faults only. Note this is safe here because writes go + // through explicitly-managed transactions rather than relying on the execution + // strategy to replay ambient ones. + .EnableRetryOnFailure(3)) + .UseSnakeCaseNamingConvention()); + + services.AddHealthChecks() + .AddCheck( + "postgres", + tags: [HealthChecks.ReadyTag, HealthChecks.StartupTag]); + + return services; + } +} + +/// +/// Reports the database reachable and the schema current. +/// +/// +/// Pending migrations make the instance unready rather than crashing it. The API never migrates in +/// production — a separate migrator job does — so the correct response to a schema mismatch is to +/// stop taking traffic and say so loudly, not to attempt a repair. +/// +internal sealed class DatabaseHealthCheck(DodoDbContext context, ILogger logger) + : IHealthCheck +{ + /// + public async Task CheckHealthAsync( + HealthCheckContext healthCheckContext, + CancellationToken cancellationToken = default) + { + try + { + if (!await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false)) + { + return HealthCheckResult.Unhealthy("Cannot connect to PostgreSQL."); + } + + var pending = await context.Database + .GetPendingMigrationsAsync(cancellationToken) + .ConfigureAwait(false); + + var pendingList = pending.ToList(); + if (pendingList.Count > 0) + { + // Computed once and used for both the log and the health result, so this is not + // work done only for logging. + var pendingDescription = string.Join(", ", pendingList); + + StartupLog.PendingMigrations(logger, pendingList.Count, pendingDescription); + + return HealthCheckResult.Unhealthy( + $"{pendingList.Count} migration(s) pending: {pendingDescription}"); + } + + return HealthCheckResult.Healthy(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return HealthCheckResult.Unhealthy("PostgreSQL health check failed.", exception); + } + } +} diff --git a/src/DodoSSH.Api/Setup/StartupLog.cs b/src/DodoSSH.Api/Setup/StartupLog.cs new file mode 100644 index 0000000..ea3d5ff --- /dev/null +++ b/src/DodoSSH.Api/Setup/StartupLog.cs @@ -0,0 +1,38 @@ +namespace DodoSSH.Api.Setup; + +/// +/// Source-generated log messages for startup and health. +/// +/// +/// [LoggerMessage] rather than ILogger extension calls: allocation-free, and the +/// event ids and message templates become a stable, greppable inventory instead of string literals +/// scattered through wiring code. +/// +internal static partial class StartupLog +{ + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Warning, + Message = "Oidc:RequireHttpsMetadata is false outside Development. Token signing keys are " + + "being fetched over plaintext HTTP, so anyone on the network path can serve their " + + "own keys and mint tokens this server will accept.")] + internal static partial void InsecureMetadataOutsideDevelopment(ILogger logger); + + [LoggerMessage( + EventId = 1002, + Level = LogLevel.Warning, + Message = "Oidc:AllowEmailLinking is enabled. A token from any configured provider " + + "carrying a victim's email address will be linked to that victim's existing account. " + + "Only enable this when every configured provider verifies email ownership.")] + internal static partial void EmailLinkingEnabled(ILogger logger); + + [LoggerMessage( + EventId = 1010, + Level = LogLevel.Critical, + Message = "Database schema is out of date: {PendingCount} migration(s) pending " + + "({PendingMigrations}). Readiness will fail until the migrator has run.")] + internal static partial void PendingMigrations( + ILogger logger, + int pendingCount, + string pendingMigrations); +} diff --git a/src/DodoSSH.Api/appsettings.Development.json b/src/DodoSSH.Api/appsettings.Development.json index 0c208ae..252fe8d 100644 --- a/src/DodoSSH.Api/appsettings.Development.json +++ b/src/DodoSSH.Api/appsettings.Development.json @@ -4,5 +4,21 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=dodossh;Username=dodossh;Password=dodossh" + }, + "Server": { + "PublicBaseUrl": "http://localhost:5233" + }, + "Oidc": { + "Authority": "http://localhost:18080/realms/dodossh", + "Audience": "dodossh-api", + "ClientId": "dodossh-desktop", + "RequireHttpsMetadata": false + }, + "Relay": { + "Enabled": true, + "WebSocketUrl": "ws://localhost:5233/api/v1/relay/connect" } } diff --git a/src/DodoSSH.Api/appsettings.json b/src/DodoSSH.Api/appsettings.json index 10f68b8..b0e8796 100644 --- a/src/DodoSSH.Api/appsettings.json +++ b/src/DodoSSH.Api/appsettings.json @@ -2,8 +2,34 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "Postgres": "" + }, + "Server": { + "PublicBaseUrl": "http://localhost:5233" + }, + "Oidc": { + "Authority": "", + "Audience": "dodossh-api", + "ClientId": "dodossh-desktop", + "LoopbackRedirectPattern": "http://127.0.0.1:*/callback", + "RequireHttpsMetadata": true, + "AllowEmailLinking": false + }, + "Relay": { + "Enabled": false, + "AllowPrivateNetworks": true, + "MaxConcurrentSessionsPerUser": 10, + "MaxConcurrentSessionsTotal": 200 + }, + "Sync": { + "MaxOperationsPerPush": 500, + "MaxPayloadBytes": 8388608, + "MaxItemPayloadBytes": 262144 + } } diff --git a/src/DodoSSH.Api/packages.lock.json b/src/DodoSSH.Api/packages.lock.json index 643f1f7..073b494 100644 --- a/src/DodoSSH.Api/packages.lock.json +++ b/src/DodoSSH.Api/packages.lock.json @@ -8,6 +8,15 @@ "resolved": "3.0.134", "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "VAcqS42zb9WJd9DjPdkVTS5YrQENmNzPNJuRu8VAW7x3TEWUipc4d4hHzVJdFB0h/KLdr4XcXZzRHcUOKVanMQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.19.2" + } + }, "Microsoft.AspNetCore.OpenApi": { "type": "Direct", "requested": "[10.0.10, )", @@ -23,11 +32,162 @@ "resolved": "5.6.0", "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "1XOcyY36cVymzE3qKdzKaUEZ4Pzt7ZpSa14JZoPPK1NLFUkQDs85TCqpV6XDo0YjFXj6nVK00AfOHppjghjhtw==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.19.2", + "System.IdentityModel.Tokens.Jwt": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "gqhDC/icByKEutygpr+OFgAmjwTVowyzFjWB8K0q1ww8uFj5a1BQNL+QvijUl9uhq4p8OdDwcAIrjVC+eC9FVA==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "dodossh.domain": { + "type": "Project" + }, + "dodossh.infrastructure": { + "type": "Project", + "dependencies": { + "DodoSSH.Domain": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.10" + } + }, "Microsoft.OpenApi": { "type": "CentralTransitive", "requested": "[2.11.0, )", "resolved": "2.11.0", "contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg==" + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } } } } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index daeb3f8..cb8bdf1 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -20,7 +20,14 @@ - $(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515 + + $(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515;MA0004;xUnit1051 diff --git a/tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs b/tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs index 0d862ec..b58c4fc 100644 --- a/tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs +++ b/tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs @@ -14,8 +14,10 @@ namespace DodoSSH.Infrastructure.Tests; /// public sealed class PostgresFixture : IAsyncLifetime { - private readonly PostgreSqlContainer container = new PostgreSqlBuilder() - .WithImage("postgres:18-alpine") + // Image passed to the constructor: the parameterless overload is obsolete in + // Testcontainers 4.13 and pinning the tag here keeps the test image in step with the one + // deploy/docker-compose.dev.yml uses. + private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:18-alpine") .WithDatabase("dodossh") .WithUsername("postgres") .WithPassword("test") diff --git a/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs index 352576b..8748cf1 100644 --- a/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs +++ b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs @@ -312,7 +312,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture) public async Task User_IsUniquePerIssuerAndSubject() { await using var context = fixture.CreateContext(); - var subject = Guid.NewGuid().ToString(); + var subject = Guid.CreateVersion7().ToString(); context.Users.Add(NewUser("https://idp.example", subject)); await context.SaveChangesAsync(); @@ -329,7 +329,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture) { // Multi-issuer from the start: the issuer is part of the identity, not a detail. await using var context = fixture.CreateContext(); - var subject = Guid.NewGuid().ToString(); + var subject = Guid.CreateVersion7().ToString(); context.Users.Add(NewUser("https://idp-a.example", subject)); context.Users.Add(NewUser("https://idp-b.example", subject)); @@ -342,14 +342,14 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture) { // citext: an attacker must not be able to register Alice@x with alice@x already present. await using var context = fixture.CreateContext(); - var local = $"user{Guid.NewGuid():N}"; + var local = $"user{Guid.CreateVersion7():N}"; - var first = NewUser("https://idp.example", Guid.NewGuid().ToString()); + var first = NewUser("https://idp.example", Guid.CreateVersion7().ToString()); first.Email = $"{local}@example.com"; context.Users.Add(first); await context.SaveChangesAsync(); - var second = NewUser("https://idp.example", Guid.NewGuid().ToString()); + var second = NewUser("https://idp.example", Guid.CreateVersion7().ToString()); second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM"; context.Users.Add(second); @@ -470,7 +470,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture) private static async Task SeedUserAsync(DodoDbContext context) { - var user = NewUser("https://idp.example", Guid.NewGuid().ToString()); + var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString()); context.Users.Add(user); await context.SaveChangesAsync(); return user; @@ -482,7 +482,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture) { Id = Guid.CreateVersion7(), Name = "Team", - Slug = $"team-{Guid.NewGuid():N}", + Slug = $"team-{Guid.CreateVersion7():N}", CreatedByUserId = createdBy, CreatedAtUtc = Now, }; @@ -551,7 +551,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture) Generation = generation, EncryptionPublicKey = new byte[32], SigningPublicKey = new byte[32], - FingerprintSha256 = Guid.NewGuid().ToByteArray().Concat(Guid.NewGuid().ToByteArray()).ToArray(), + FingerprintSha256 = Guid.CreateVersion7().ToByteArray().Concat(Guid.CreateVersion7().ToByteArray()).ToArray(), Statement = "{}", StatementSignature = new byte[64], IsCurrent = isCurrent,