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,