From 469fc88f1acef6eac72a6ea3fe728408bef68817 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 12 Jul 2026 13:09:37 +1200 Subject: [PATCH] fix(pangolin): gate converge-created resources with SSO by default (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReconcileResourcesAsync set `ssl` on create but never `sso`, and the integration-API create defaults `sso` to null (OPEN). So a from-scratch converge would create admin UIs with NO auth gate — publicly reachable — contradicting the "gated by Pangolin auth" contract. Set sso explicitly on create: default ON, with a per-resource `sso: false` opt-out for native clients that can't render the SSO interstitial (Plex, audiobookshelf). Also codify the live `power.lab.chrison.dev` resource (PowerOrchestrator dashboard + Wake/Sleep/Arm control API, #191) declaratively so it survives converge; the add-only reconcile skips the already-live one. Extracted the sso decision to ResourceSsoEnabled + a theory test. Co-Authored-By: Claude Opus 4.8 (1M context) --- Infrastructure/engine.Tests/ConvergeCoreTests.cs | 13 +++++++++++++ Infrastructure/engine/Converge/Provisioners.cs | 15 ++++++++++++++- stacks/Core/pangolin.lxc.yaml | 13 +++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Infrastructure/engine.Tests/ConvergeCoreTests.cs b/Infrastructure/engine.Tests/ConvergeCoreTests.cs index 2ccee86..e9440e2 100644 --- a/Infrastructure/engine.Tests/ConvergeCoreTests.cs +++ b/Infrastructure/engine.Tests/ConvergeCoreTests.cs @@ -568,6 +568,19 @@ public void Pangolin_WildcardARecords_Empty_WhenPublicIpUnset_OrCloudflaredEdge( Assert.Empty(PangolinProvisioner.WildcardARecords(cf)); } + [Theory] + [InlineData(null, true)] // unset → gated (the security default; API create leaves it OPEN, #238) + [InlineData(true, true)] + [InlineData(false, false)] // explicit opt-out for native clients (Plex/abs) + [InlineData("true", true)] // YAML scalars can arrive as strings + [InlineData("false", false)] + public void Pangolin_Resource_SsoDefaultsOn_UnlessOptedOut(object? sso, bool expected) + { + var rd = new Dictionary { ["subdomain"] = "x", ["zone"] = "lab" }; + if (sso is not null) rd["sso"] = sso; + Assert.Equal(expected, PangolinProvisioner.ResourceSsoEnabled(rd)); + } + [Fact] public void Pangolin_ComposeYaml_PinsEeImage_TraefikPublishesPorts_NoGerbilByDefault() { diff --git a/Infrastructure/engine/Converge/Provisioners.cs b/Infrastructure/engine/Converge/Provisioners.cs index 2726727..f0c3f3b 100644 --- a/Infrastructure/engine/Converge/Provisioners.cs +++ b/Infrastructure/engine/Converge/Provisioners.cs @@ -705,6 +705,7 @@ public async Task ApplyAsync(Shape s, ConvergeContext ctx) // (via a local-type site). A resource may carry a wildcard `zone` (lab|arr) → // fullDomain = ... ssl follows the edge mode: // public-wildcard → Traefik terminates TLS (true); cloudflared → CF does (false). + // sso defaults ON (Pangolin auth gates the UI); a resource opts out with sso: false. // Returns (msg, changed, failedReason). private static async Task<(string? msg, bool changed, string? failed)> ReconcileResourcesAsync( Shape s, ConvergeContext ctx, string node, string ctid) @@ -769,6 +770,11 @@ public async Task ApplyAsync(Shape s, ConvergeContext ctx) var tip = tgt?["ip"]?.ToString() ?? "localhost"; var tmethod = tgt?["method"]?.ToString() ?? "http"; var tport = int.TryParse(tgt?["port"]?.ToString(), out var pp) ? pp : 80; + // sso gate: default ON — admin UIs must sit behind Pangolin auth (badger). The + // integration-API create defaults sso to null (OPEN), so we MUST set it explicitly + // or the resource is born publicly reachable. A resource may opt out (sso: false) + // for native clients that can't render the SSO interstitial (e.g. Plex, abs). + var sso = ResourceSsoEnabled(rd); var (rok, rroot) = await pg.CallAsync("PUT", $"/org/{org}/resource", JsonSerializer.Serialize(new { name, subdomain = pgSub, http = true, protocol = "tcp", domainId }), ct); @@ -778,12 +784,19 @@ public async Task ApplyAsync(Shape s, ConvergeContext ctx) await pg.CallAsync("PUT", $"/resource/{resourceId}/target", JsonSerializer.Serialize(new { siteId, ip = tip, method = tmethod, port = tport, enabled = true }), ct); // ssl: public-wildcard → Traefik terminates TLS (true); cloudflared → CF does (false). - await pg.CallAsync("POST", $"/resource/{resourceId}", JsonSerializer.Serialize(new { ssl = publicWildcard }), ct); + // sso: gate the resource behind Pangolin auth unless it explicitly opts out. + await pg.CallAsync("POST", $"/resource/{resourceId}", JsonSerializer.Serialize(new { ssl = publicWildcard, sso }), ct); created++; } return ($"{total} resource(s) declared, {created} created", created > 0, null); } + // Whether a declared resource is gated by Pangolin auth. Default ON: the integration-API + // create leaves sso null (OPEN), so a resource is only gated if we set it — this decision + // is security-relevant (#238). Opt out with sso: false only for native clients (Plex/abs). + internal static bool ResourceSsoEnabled(System.Collections.IDictionary rd) => + !(rd["sso"] is { } raw && bool.TryParse(raw.ToString(), out var v) && !v); + // response.data is sometimes an array, sometimes { : array } — normalise both. private static IEnumerable DataArray(JsonElement root, string key) { diff --git a/stacks/Core/pangolin.lxc.yaml b/stacks/Core/pangolin.lxc.yaml index e3ec654..79be299 100644 --- a/stacks/Core/pangolin.lxc.yaml +++ b/stacks/Core/pangolin.lxc.yaml @@ -100,14 +100,23 @@ spec: # (add-only, idempotent by fullDomain) using PANGOLIN_API_KEY. A `zone` (lab|arr) makes # fullDomain = ..chrison.dev, served on :443 with the wildcard cert and # gated by Pangolin auth (badger). In public-wildcard mode ssl is ON (Traefik terminates). - # A new service is just an entry here + a grey-cloud A record (handled by converge) — no - # per-host cert, no per-host cloudflared ingress. + # sso defaults ON (the auth gate); set `sso: false` only for native clients that can't do + # the SSO interstitial (e.g. Plex, audiobookshelf). A new service is just an entry here + + # a grey-cloud A record (handled by converge) — no per-host cert, no per-host cloudflared ingress. resources: # Pangolin's own Traefik dashboard — first migrated UI (kept from #136). - name: Traefik Dashboard subdomain: traefik zone: lab target: { ip: localhost, port: 8080, method: http } + # PowerOrchestrator dashboard/control API (#191) — systemd service on nuc-01's legacy + # IP; CT 2013 reaches it cross-VLAN via the 10.10.0.1 gateway. sso is REQUIRED here: the + # control API (Wake/Sleep/Arm) has no native auth (#238). + - name: Power Orchestrator + subdomain: power + zone: lab + sso: true + target: { ip: 192.168.179.1, port: 8080, method: http } # ── arr admin UIs (Phase 5 cutover — uncomment + set the target IP once the media # CT has a DHCP reservation / static IP; they're DHCP today on VLAN 1010): # - { name: Prowlarr, subdomain: prowlarr, zone: arr, target: { ip: 10.10.x.x, port: 9696, method: http } }