Public Access
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code.
176 lines
7.2 KiB
C#
176 lines
7.2 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
|
|
namespace DodoSSH.Api.Setup;
|
|
|
|
/// <summary>Identity provider settings.</summary>
|
|
/// <remarks>
|
|
/// Provider-agnostic by design. Claim names differ between providers — Keycloak nests roles at
|
|
/// <c>realm_access.roles</c>, Entra uses <c>roles</c> and <c>groups</c>, Auth0 namespaces them,
|
|
/// Authentik uses <c>groups</c> — so the mapping is configuration rather than code.
|
|
/// </remarks>
|
|
public sealed class OidcOptions
|
|
{
|
|
/// <summary>Configuration section name.</summary>
|
|
public const string SectionName = "Oidc";
|
|
|
|
/// <summary>Issuer URL. Discovery and JWKS are fetched from here.</summary>
|
|
[Required]
|
|
[Url]
|
|
public string Authority { get; set; } = string.Empty;
|
|
|
|
/// <summary>Expected audience of access tokens.</summary>
|
|
[Required]
|
|
public string Audience { get; set; } = string.Empty;
|
|
|
|
/// <summary>Public client identifier the desktop app uses.</summary>
|
|
[Required]
|
|
public string ClientId { get; set; } = string.Empty;
|
|
|
|
/// <summary>Scopes the client should request.</summary>
|
|
public IList<string> Scopes { get; } = ["openid", "profile", "email", "offline_access"];
|
|
|
|
/// <summary>
|
|
/// Registered loopback redirect pattern. RFC 8252: a native client uses a loopback redirect on
|
|
/// an ephemeral port with the system browser, never a custom scheme and never an embedded
|
|
/// browser, so the user can see the real address bar.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// No wildcard in the port. RFC 8252 requires a native client to use an ephemeral loopback port, and
|
|
/// providers implement that by ignoring the port when the host is a loopback literal — Keycloak
|
|
/// included. Writing <c>http://127.0.0.1:*/callback</c> looks more explicit and is worse: Keycloak
|
|
/// parses the <c>*</c> as a literal port and rejects every real redirect with "Invalid parameter:
|
|
/// redirect_uri". Pinning the path is the part that matters, since it stops another local process
|
|
/// having a code delivered somewhere else.
|
|
/// </remarks>
|
|
public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback";
|
|
|
|
/// <summary>Whether HTTPS metadata is required. Only ever false for local development.</summary>
|
|
public bool RequireHttpsMetadata { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether a new identity-provider subject may be linked to an existing account by matching
|
|
/// email.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Defaults to false, and must stay that way. If an attacker can obtain a token from any
|
|
/// configured provider carrying a victim's email address, email linking hands them the
|
|
/// victim's account.
|
|
/// </remarks>
|
|
public bool AllowEmailLinking { get; set; }
|
|
|
|
/// <summary>Claim type holding the user's email.</summary>
|
|
public string EmailClaim { get; set; } = "email";
|
|
|
|
/// <summary>Claim type holding the user's display name.</summary>
|
|
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; }
|
|
}
|