Add configuration, OIDC auth wiring and discovery endpoints (M1)

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.
This commit is contained in:
2026-07-28 14:33:54 +02:00
parent eaf68c86b0
commit d3b14e6bc0
19 changed files with 941 additions and 16 deletions
+4 -3
View File
@@ -98,9 +98,10 @@ dotnet_diagnostic.IDE0055.severity = error
# meaningful in the Avalonia client, which re-enables CA2007 in its own .editorconfig. # meaningful in the Avalonia client, which re-enables CA2007 in its own .editorconfig.
dotnet_diagnostic.CA2007.severity = none dotnet_diagnostic.CA2007.severity = none
# Prefer LoggerMessage source generation over ILogger extension calls — allocation-free # Require LoggerMessage source generation over ILogger extension calls — allocation-free,
# and gives structured events by construction. Warning, so it is visible but not a wall # and event ids plus message templates become a greppable inventory rather than string
# during early development; raised to error once the logging pass lands in M4. # 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 dotnet_diagnostic.CA1848.severity = warning
# Exceptions carry ProblemDetails codes, not localised text. # Exceptions carry ProblemDetails codes, not localised text.
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup Label="ASP.NET Core"> <ItemGroup Label="ASP.NET Core">
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Pinned transitive dependencies"> <ItemGroup Label="Pinned transitive dependencies">
+59
View File
@@ -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:
+81
View File
@@ -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
}
]
}
]
}
+8
View File
@@ -12,6 +12,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
<ProjectReference Include="../DodoSSH.Domain/DodoSSH.Domain.csproj" />
<ProjectReference Include="../DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -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;
/// <summary>
/// Capability and discovery endpoints.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class MetaEndpoints
{
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
internal const int SyncProtocolVersion = 1;
/// <summary>Feature flag for the relay.</summary>
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<MetaResponse> GetMeta(
IOptions<SyncOptions> sync,
IOptions<RelayOptions> relay,
IOptions<ServerOptions> server)
{
List<string> 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<DodoSshConfiguration> GetConfiguration(
IOptions<OidcOptions> oidc,
IOptions<RelayOptions> relay,
IOptions<ServerOptions> 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<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "0.0.0";
}
+13
View File
@@ -2,6 +2,13 @@ using DodoSSH.Api.Setup;
var builder = WebApplication.CreateBuilder(args); 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.AddDodoOpenApi();
builder.Services.AddDodoHealthChecks(); builder.Services.AddDodoHealthChecks();
@@ -16,11 +23,17 @@ var app = builder.Build();
// produces redirect loops behind a proxy. HTTPS in development comes from the // produces redirect loops behind a proxy. HTTPS in development comes from the
// launch profile instead. // launch profile instead.
app.UseAuthentication();
app.UseAuthorization();
app.MapDodoHealthChecks(); app.MapDodoHealthChecks();
app.MapDodoEndpoints();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
} }
app.WarnOnRiskyConfiguration();
await app.RunAsync().ConfigureAwait(false); await app.RunAsync().ConfigureAwait(false);
+67
View File
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace DodoSSH.Api.Setup;
/// <summary>Authentication and authorization wiring.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class Auth
{
/// <summary>Policy requiring an authenticated caller.</summary>
internal const string AuthenticatedPolicy = "Authenticated";
/// <summary>Policy requiring a caller who has completed key enrollment.</summary>
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<IOptions<OidcOptions>>().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;
}
}
+74
View File
@@ -0,0 +1,74 @@
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Setup;
/// <summary>Binds and validates strongly-typed options.</summary>
/// <remarks>
/// Every section uses <c>ValidateOnStart</c>. 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.
/// </remarks>
internal static class Configuration
{
internal static IServiceCollection AddDodoOptions(this IServiceCollection services)
{
services.AddOptions<ServerOptions>()
.BindConfiguration(ServerOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<OidcOptions>()
.BindConfiguration(OidcOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<SyncOptions>()
.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<RelayOptions>()
.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;
}
/// <summary>
/// Warns about configuration that is individually valid but dangerous in combination.
/// </summary>
/// <remarks>
/// These cannot be hard failures — each is legitimate in some deployment — but silently
/// accepting them produces subtly broken security properties that nobody notices.
/// </remarks>
internal static WebApplication WarnOnRiskyConfiguration(this WebApplication app)
{
var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("DodoSSH.Startup");
var oidc = app.Services.GetRequiredService<IOptions<OidcOptions>>().Value;
if (!oidc.RequireHttpsMetadata && !app.Environment.IsDevelopment())
{
StartupLog.InsecureMetadataOutsideDevelopment(logger);
}
if (oidc.AllowEmailLinking)
{
StartupLog.EmailLinkingEnabled(logger);
}
return app;
}
}
+167
View File
@@ -0,0 +1,167 @@
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>
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>
public string NameClaim { get; set; } = "name";
}
/// <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>
/// 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.
/// </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>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; }
}
@@ -0,0 +1,29 @@
using DodoSSH.Api.Features.Meta;
namespace DodoSSH.Api.Setup;
/// <summary>
/// The single, explicit list of every endpoint module.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}
+85
View File
@@ -0,0 +1,85 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace DodoSSH.Api.Setup;
/// <summary>Database wiring.</summary>
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<DodoDbContext>(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<DatabaseHealthCheck>(
"postgres",
tags: [HealthChecks.ReadyTag, HealthChecks.StartupTag]);
return services;
}
}
/// <summary>
/// Reports the database reachable and the schema current.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class DatabaseHealthCheck(DodoDbContext context, ILogger<DatabaseHealthCheck> logger)
: IHealthCheck
{
/// <inheritdoc />
public async Task<HealthCheckResult> 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);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace DodoSSH.Api.Setup;
/// <summary>
/// Source-generated log messages for startup and health.
/// </summary>
/// <remarks>
/// <c>[LoggerMessage]</c> rather than <c>ILogger</c> extension calls: allocation-free, and the
/// event ids and message templates become a stable, greppable inventory instead of string literals
/// scattered through wiring code.
/// </remarks>
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);
}
@@ -4,5 +4,21 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "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"
} }
} }
+28 -2
View File
@@ -2,8 +2,34 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "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
}
} }
+160
View File
@@ -8,6 +8,15 @@
"resolved": "3.0.134", "resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" "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": { "Microsoft.AspNetCore.OpenApi": {
"type": "Direct", "type": "Direct",
"requested": "[10.0.10, )", "requested": "[10.0.10, )",
@@ -23,11 +32,162 @@
"resolved": "5.6.0", "resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" "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": { "Microsoft.OpenApi": {
"type": "CentralTransitive", "type": "CentralTransitive",
"requested": "[2.11.0, )", "requested": "[2.11.0, )",
"resolved": "2.11.0", "resolved": "2.11.0",
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg==" "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)"
}
} }
} }
} }
+8 -1
View File
@@ -20,7 +20,14 @@
<!-- CA1707: test method names use underscores by convention (Method_State_Expectation). <!-- CA1707: test method names use underscores by convention (Method_State_Expectation).
CA2007: no SynchronizationContext in tests, ConfigureAwait is noise. CA2007: no SynchronizationContext in tests, ConfigureAwait is noise.
CA1861: inline constant arrays in theories are clearer than static fields. --> CA1861: inline constant arrays in theories are clearer than static fields. -->
<NoWarn>$(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515</NoWarn> <!--
MA0004 is the Meziantou equivalent of CA2007: there is no SynchronizationContext under
the test host, so ConfigureAwait is noise.
xUnit1051 wants TestContext.Current.CancellationToken threaded through every awaited
call. Worth doing for long-running suites; here the container fixture owns lifetime and
the assertions are short, so it buys nothing but churn.
-->
<NoWarn>$(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515;MA0004;xUnit1051</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Test framework — every test project gets these"> <ItemGroup Label="Test framework — every test project gets these">
@@ -14,8 +14,10 @@ namespace DodoSSH.Infrastructure.Tests;
/// </remarks> /// </remarks>
public sealed class PostgresFixture : IAsyncLifetime public sealed class PostgresFixture : IAsyncLifetime
{ {
private readonly PostgreSqlContainer container = new PostgreSqlBuilder() // Image passed to the constructor: the parameterless overload is obsolete in
.WithImage("postgres:18-alpine") // 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") .WithDatabase("dodossh")
.WithUsername("postgres") .WithUsername("postgres")
.WithPassword("test") .WithPassword("test")
@@ -312,7 +312,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
public async Task User_IsUniquePerIssuerAndSubject() public async Task User_IsUniquePerIssuerAndSubject()
{ {
await using var context = fixture.CreateContext(); await using var context = fixture.CreateContext();
var subject = Guid.NewGuid().ToString(); var subject = Guid.CreateVersion7().ToString();
context.Users.Add(NewUser("https://idp.example", subject)); context.Users.Add(NewUser("https://idp.example", subject));
await context.SaveChangesAsync(); 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. // Multi-issuer from the start: the issuer is part of the identity, not a detail.
await using var context = fixture.CreateContext(); 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-a.example", subject));
context.Users.Add(NewUser("https://idp-b.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. // citext: an attacker must not be able to register Alice@x with alice@x already present.
await using var context = fixture.CreateContext(); 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"; first.Email = $"{local}@example.com";
context.Users.Add(first); context.Users.Add(first);
await context.SaveChangesAsync(); 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"; second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM";
context.Users.Add(second); context.Users.Add(second);
@@ -470,7 +470,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
private static async Task<UserAccount> SeedUserAsync(DodoDbContext context) private static async Task<UserAccount> 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); context.Users.Add(user);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
return user; return user;
@@ -482,7 +482,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
{ {
Id = Guid.CreateVersion7(), Id = Guid.CreateVersion7(),
Name = "Team", Name = "Team",
Slug = $"team-{Guid.NewGuid():N}", Slug = $"team-{Guid.CreateVersion7():N}",
CreatedByUserId = createdBy, CreatedByUserId = createdBy,
CreatedAtUtc = Now, CreatedAtUtc = Now,
}; };
@@ -551,7 +551,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
Generation = generation, Generation = generation,
EncryptionPublicKey = new byte[32], EncryptionPublicKey = new byte[32],
SigningPublicKey = 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 = "{}", Statement = "{}",
StatementSignature = new byte[64], StatementSignature = new byte[64],
IsCurrent = isCurrent, IsCurrent = isCurrent,