diff --git a/Infrastructure/engine.Tests/PangolinResourceReconcileTests.cs b/Infrastructure/engine.Tests/PangolinResourceReconcileTests.cs new file mode 100644 index 0000000..1deac91 --- /dev/null +++ b/Infrastructure/engine.Tests/PangolinResourceReconcileTests.cs @@ -0,0 +1,262 @@ +using System.Text.Json; +using Homelab.Infrastructure.Converge; +using Homelab.Infrastructure.Shapes; +using Xunit; + +namespace Homelab.Infrastructure.Tests; + +// Pangolin resource reconciliation (issue #309). The provisioner used to be add-only: +// find-or-create by fullDomain, and an existing resource was skipped ENTIRELY, target +// included. Editing a target in a shape therefore updated desired state and never reached +// Pangolin — converge reported "0 drifted, 3 up-to-date" while pulse.lab.chrison.dev +// pointed at a container that had just been stopped. +// +// These drive the real ApplyAsync with a fake exec standing in for `curl` inside the CT, +// so the assertions are about the HTTP calls actually issued. +public sealed class PangolinResourceReconcileTests : IDisposable +{ + private readonly string _secrets = Path.Combine(Path.GetTempPath(), $"hl309-{Guid.NewGuid():n}.env"); + + public PangolinResourceReconcileTests() => + // A file, not process env: SecretsEnv folds the environment in, and mutating that + // from a test leaks into every other test in the run. + File.WriteAllText(_secrets, "PANGOLIN_API_KEY=test-key\n"); + + public void Dispose() + { + try { File.Delete(_secrets); } catch { /* best-effort */ } + } + + // ---- fixtures --------------------------------------------------------- + + // One declared resource: traefik.lab.chrison.dev → http://localhost:8080. + private static Shape Shape(string ip = "localhost", int port = 8080, string method = "http", bool? sso = null) + { + var s = new Shape { Metadata = new ShapeMetadata { Name = "pangolin" } }; + s.Spec.Ctid = "2013"; + s.Spec.Node = "nuc-01"; + s.Spec.Config["dashboardUrl"] = "https://pangolin.chrison.dev"; + s.Spec.Config["baseDomain"] = "chrison.dev"; + s.Spec.Config["edge"] = "cloudflared"; // ssl=false; keeps Cloudflare out of it + s.Spec.Config["org"] = "chrison-dev"; + var res = new Dictionary + { + ["name"] = "Traefik Dashboard", + ["subdomain"] = "traefik", + ["zone"] = "lab", + ["target"] = new Dictionary { ["ip"] = ip, ["port"] = port, ["method"] = method }, + }; + if (sso is not null) res["sso"] = sso.Value; + s.Spec.Config["resources"] = new List { res }; + return s; + } + + // Live resource list. `sso` is deliberately an INTEGER — that is what Pangolin + // actually returns (SQLite-backed), and GetBoolean() throws on it. + private static string ResourcesJson(int targetCount = 1, object? sso = null, bool ssl = false) => + JsonSerializer.Serialize(new + { + data = new + { + resources = new[] + { + new + { + resourceId = 1, + fullDomain = "traefik.lab.chrison.dev", + ssl, + sso = sso ?? 1, + targets = Enumerable.Range(1, targetCount) + .Select(i => new { targetId = i, ip = "localhost", port = 8080, enabled = true }).ToArray(), + }, + }, + }, + success = true, + }); + + private static string TargetsJson(string ip = "localhost", int port = 8080, string method = "http") => + JsonSerializer.Serialize(new + { + data = new { targets = new[] { new { targetId = 1, ip, port, method, enabled = true, siteId = 1 } } }, + success = true, + }); + + private sealed class FakeExec : INodeExec + { + private readonly Func _reply; + public List Commands { get; } = new(); + public FakeExec(Func reply) => _reply = reply; + public Task OnNodeAsync(string node, string cmd, CancellationToken ct = default) + { Commands.Add(cmd); return Task.FromResult(_reply(cmd)); } + public Task InContainerAsync(string node, string ctid, string cmd, CancellationToken ct = default) + { Commands.Add(cmd); return Task.FromResult(_reply(cmd)); } + } + + // Standard happy-path API surface; `resources` and `targets` are the interesting knobs. + private FakeExec Api(string resources, string targets, Func? extra = null) + { + var marker = PangolinProvisioner.DesiredMarker(Shape()); + return new FakeExec(cmd => + { + var custom = extra?.Invoke(cmd); + if (custom is not null) return custom; + if (cmd.Contains("homelab-managed")) return new ExecResult(0, $"# homelab-managed: {marker}", ""); + if (cmd.Contains("/v1/org/chrison-dev/domains")) return new ExecResult(0, + """{"data":{"domains":[{"domainId":"domain1","baseDomain":"chrison.dev"}]},"success":true}""", ""); + if (cmd.Contains("/v1/org/chrison-dev/sites")) return new ExecResult(0, + """{"data":{"sites":[{"siteId":1,"type":"local"}]},"success":true}""", ""); + if (cmd.Contains("/v1/org/chrison-dev/resources")) return new ExecResult(0, resources, ""); + if (cmd.Contains("/v1/resource/1/targets")) return new ExecResult(0, targets, ""); + return new ExecResult(0, """{"success":true,"data":{}}""", ""); + }); + } + + private async Task<(ApplyResult Result, FakeExec Exec)> RunAsync(Shape shape, FakeExec exec) + { + var ctx = new ConvergeContext(exec, SecretsEnv.Load(_secrets), new Dictionary(), Deriver: null!); + var result = await new PangolinProvisioner().ApplyAsync(shape, ctx); + return (result, exec); + } + + private static bool IsWrite(string cmd) => + cmd.Contains("-X POST", StringComparison.Ordinal) || cmd.Contains("-X PUT", StringComparison.Ordinal); + + // ---- in sync ---------------------------------------------------------- + + [Fact] + public async Task InSync_WritesNothing() + { + var (result, exec) = await RunAsync(Shape(), Api(ResourcesJson(), TargetsJson())); + + Assert.Equal(ApplyOutcome.NoChange, result.Outcome); + Assert.Contains("0 retargeted", result.Message); + Assert.DoesNotContain(exec.Commands, c => c.Contains("/v1/target/", StringComparison.Ordinal) && IsWrite(c)); + } + + [Fact] + public async Task IntegerSso_IsNotMistakenForDrift() + { + // Pangolin returns sso as 1, the shape's default is `true`. Reading that with + // GetBoolean() throws, and treating a non-bool as false would re-gate all 15 + // resources on every single run. + var (result, exec) = await RunAsync(Shape(), Api(ResourcesJson(sso: 1), TargetsJson())); + + Assert.Contains("0 re-gated", result.Message); + Assert.DoesNotContain(exec.Commands, c => IsGateWrite(c)); + } + + // A gate write is POST /v1/resource/{id} — NOT /v1/resource/{id}/target, which is the + // target-create call and would otherwise match a naive substring check. + private static bool IsGateWrite(string cmd) => + IsWrite(cmd) && cmd.TrimEnd().EndsWith("/v1/resource/1", StringComparison.Ordinal); + + // ---- target drift ----------------------------------------------------- + + [Fact] + public async Task IpDrift_IssuesTargetUpdateWithSiteId() + { + // The #309 scenario: shape moved to a new host, live still points at the old one. + var (result, exec) = await RunAsync(Shape(ip: "10.10.0.41"), Api(ResourcesJson(), TargetsJson(ip: "10.10.0.40"))); + + Assert.Equal(ApplyOutcome.Applied, result.Outcome); + Assert.Contains("1 retargeted", result.Message); + Assert.Contains("10.10.0.40", result.Message); // reports before → after + Assert.Contains("10.10.0.41", result.Message); + + var write = Assert.Single(exec.Commands, c => c.Contains("/v1/target/1", StringComparison.Ordinal) && IsWrite(c)); + Assert.Contains("\"ip\":\"10.10.0.41\"", write); + // siteId is REQUIRED even when unchanged — omitting it 400s. + Assert.Contains("\"siteId\":1", write); + } + + [Fact] + public async Task PortDrift_IssuesTargetUpdate() + { + var (result, _) = await RunAsync(Shape(port: 8081), Api(ResourcesJson(), TargetsJson(port: 8080))); + Assert.Contains("1 retargeted", result.Message); + } + + [Fact] + public async Task MethodDrift_IsDetected() + { + // `method` is absent from the embedded target list on GET resources, so catching + // this is the whole reason for the extra GET /resource/{id}/targets call. + var (result, _) = await RunAsync(Shape(method: "https"), Api(ResourcesJson(), TargetsJson(method: "http"))); + Assert.Contains("1 retargeted", result.Message); + } + + [Fact] + public async Task DisabledTarget_IsReEnabled() + { + var targets = JsonSerializer.Serialize(new + { + data = new { targets = new[] { new { targetId = 1, ip = "localhost", port = 8080, method = "http", enabled = false, siteId = 1 } } }, + success = true, + }); + var (result, exec) = await RunAsync(Shape(), Api(ResourcesJson(), targets)); + + Assert.Contains("1 retargeted", result.Message); + var write = Assert.Single(exec.Commands, c => c.Contains("/v1/target/1", StringComparison.Ordinal) && IsWrite(c)); + Assert.Contains("\"enabled\":true", write); + } + + // ---- gates ------------------------------------------------------------ + + [Fact] + public async Task SsoDisabledLive_IsReGated() + { + // Security-relevant (#238): a resource created before sso defaulted ON, or flipped + // off by hand in the UI, must not stay open forever. + var (result, exec) = await RunAsync(Shape(), Api(ResourcesJson(sso: 0), TargetsJson())); + + Assert.Equal(ApplyOutcome.Applied, result.Outcome); + Assert.Contains("1 re-gated", result.Message); + Assert.Contains(exec.Commands, c => IsGateWrite(c) && c.Contains("\"sso\":true", StringComparison.Ordinal)); + } + + [Fact] + public async Task ExplicitSsoFalse_IsHonouredAndNotFoughtEveryRun() + { + // Native clients (Plex, audiobookshelf) opt out. Live already 0 → no write. + var (result, exec) = await RunAsync(Shape(sso: false), Api(ResourcesJson(sso: 0), TargetsJson())); + + Assert.Contains("0 re-gated", result.Message); + Assert.DoesNotContain(exec.Commands, c => IsGateWrite(c)); + } + + // ---- guardrails ------------------------------------------------------- + + [Fact] + public async Task MultipleTargets_AreLeftAlone() + { + // Pangolin supports several targets per resource for load balancing; our shapes + // declare one. Rewriting the first of several would destroy a hand-built config, + // which the add-only guardrail in CLAUDE.md exists to prevent. + var (result, exec) = await RunAsync(Shape(ip: "10.10.0.41"), Api(ResourcesJson(targetCount: 3), TargetsJson())); + + Assert.Contains("left alone", result.Message); + Assert.DoesNotContain(exec.Commands, c => c.Contains("/v1/target/", StringComparison.Ordinal) && IsWrite(c)); + } + + [Fact] + public async Task ZeroTargets_GetsOneAdded() + { + var (result, exec) = await RunAsync(Shape(), Api(ResourcesJson(targetCount: 0), TargetsJson())); + + Assert.Contains("target added", result.Message); + Assert.Contains(exec.Commands, c => c.Contains("/v1/resource/1/target", StringComparison.Ordinal) && IsWrite(c)); + } + + [Fact] + public async Task ResourceListUnreachable_FailsInsteadOfReportingSuccess() + { + // Reporting a clean run when the API could not be read is the exact failure mode + // #309 is about. A read error must not look like "nothing to do". + var exec = Api(ResourcesJson(), TargetsJson(), + extra: cmd => cmd.Contains("/v1/org/chrison-dev/resources") ? new ExecResult(1, "", "connection refused") : null); + var (result, _) = await RunAsync(Shape(), exec); + + Assert.Equal(ApplyOutcome.Failed, result.Outcome); + Assert.Contains("cannot reconcile safely", result.Message); + } +} diff --git a/Infrastructure/engine/Converge/Provisioners.cs b/Infrastructure/engine/Converge/Provisioners.cs index aac65d2..61945e8 100644 --- a/Infrastructure/engine/Converge/Provisioners.cs +++ b/Infrastructure/engine/Converge/Provisioners.cs @@ -702,11 +702,22 @@ public async Task ApplyAsync(Shape s, ConvergeContext ctx) } // Reconcile declared Pangolin resources (admin UIs) via the integration API on the - // CT (:3003, Bearer org key). Add-only: find-or-create by fullDomain, set the target - // (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. + // CT (:3003, Bearer org key). Find-or-create by fullDomain, then RECONCILE — 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. + // + // WAS ADD-ONLY, AND THAT BROKE PUBLIC ROUTES SILENTLY (#309). Editing a declared + // resource's target updated desired state and never reached Pangolin: existence was + // checked by fullDomain and an existing resource was skipped entirely. During the + // monitoring migration (#303) the Pulse UI moved from CT 4000 to CT 4001 and the + // shape was updated, but converge reported "0 to create, 0 drifted, 3 up-to-date" — + // a completely clean plan — while the live target still pointed at 10.10.0.40, a + // container that had just been stopped. pulse.lab.chrison.dev was down and converge + // said everything was fine. Now the target (ip/port/method/enabled) and the + // ssl/sso gates are all compared and corrected. + // // Returns (msg, changed, failedReason). private static async Task<(string? msg, bool changed, string? failed)> ReconcileResourcesAsync( Shape s, ConvergeContext ctx, string node, string ctid) @@ -747,14 +758,25 @@ public async Task ApplyAsync(Shape s, ConvergeContext ctx) siteId = nsi.GetInt32(); } - // existing resources, by fullDomain (add-only idempotency) - var existing = new HashSet(StringComparer.OrdinalIgnoreCase); + // Existing resources by fullDomain. One GET carries resourceId, ssl, sso AND the + // embedded targets, so the common no-drift case costs a single call. + var existing = new Dictionary(StringComparer.OrdinalIgnoreCase); var (eok, eroot) = await pg.CallAsync("GET", $"/org/{org}/resources", null, ct); - if (eok) - foreach (var r in DataArray(eroot, "resources")) - if (r.TryGetProperty("fullDomain", out var fd) && fd.GetString() is { } f) existing.Add(f); + if (!eok) return (null, false, "pangolin: GET resources failed — cannot reconcile safely"); + foreach (var r in DataArray(eroot, "resources")) + { + if (!r.TryGetProperty("fullDomain", out var fd) || fd.GetString() is not { } f) continue; + if (!r.TryGetProperty("resourceId", out var ri)) continue; + existing[f] = new LiveResource( + ri.GetInt32(), + Truthy(r, "ssl"), + Truthy(r, "sso"), + r.TryGetProperty("targets", out var tarr) && tarr.ValueKind == JsonValueKind.Array + ? tarr.EnumerateArray().Count() : 0); + } - int total = 0, created = 0; + int total = 0, created = 0, retargeted = 0, regated = 0; + var notes = new List(); foreach (var it in items) { if (it is not System.Collections.IDictionary rd) continue; @@ -765,7 +787,6 @@ public async Task ApplyAsync(Shape s, ConvergeContext ctx) var zone = rd["zone"]?.ToString(); var pgSub = string.IsNullOrEmpty(zone) ? sub : $"{sub}.{zone}"; var fqdn = $"{pgSub}.{baseDomain}"; - if (existing.Contains(fqdn)) continue; var name = rd["name"]?.ToString() ?? sub; var tgt = rd["target"] as System.Collections.IDictionary; var tip = tgt?["ip"]?.ToString() ?? "localhost"; @@ -777,6 +798,30 @@ public async Task ApplyAsync(Shape s, ConvergeContext ctx) // for native clients that can't render the SSO interstitial (e.g. Plex, abs). var sso = ResourceSsoEnabled(rd); + if (existing.TryGetValue(fqdn, out var live)) + { + // ── EXISTS: reconcile rather than skip (#309) ────────────────────────── + var (tmsg, tchanged, tfail) = await ReconcileTargetAsync( + pg, live, siteId.Value, tip, tmethod, tport, fqdn, ct); + if (tfail is not null) return (null, false, tfail); + if (tmsg is not null) notes.Add(tmsg); + if (tchanged) retargeted++; + + // ssl/sso are cheap to compare (already fetched) and drift the same way. + // sso especially: a resource created before the default-ON decision (#238), + // or flipped off by hand in the UI, would otherwise stay open forever. + if (live.Ssl != publicWildcard || live.Sso != sso) + { + var (gok, _) = await pg.CallAsync("POST", $"/resource/{live.Id}", + JsonSerializer.Serialize(new { ssl = publicWildcard, sso }), ct); + if (!gok) return (null, false, $"pangolin: failed to update ssl/sso on {fqdn}"); + notes.Add($"{fqdn}: ssl {live.Ssl}→{publicWildcard}, sso {live.Sso}→{sso}"); + regated++; + } + continue; + } + + // ── ABSENT: create ──────────────────────────────────────────────────────── var (rok, rroot) = await pg.CallAsync("PUT", $"/org/{org}/resource", JsonSerializer.Serialize(new { name, subdomain = pgSub, http = true, protocol = "tcp", domainId }), ct); if (!rok || !Data(rroot).TryGetProperty("resourceId", out var rid)) @@ -789,9 +834,81 @@ await pg.CallAsync("PUT", $"/resource/{resourceId}/target", await pg.CallAsync("POST", $"/resource/{resourceId}", JsonSerializer.Serialize(new { ssl = publicWildcard, sso }), ct); created++; } - return ($"{total} resource(s) declared, {created} created", created > 0, null); + + var summary = $"{total} resource(s) declared, {created} created, {retargeted} retargeted, {regated} re-gated"; + if (notes.Count > 0) summary += "\n " + string.Join("\n ", notes); + return (summary, created + retargeted + regated > 0, null); + } + + // What a live Pangolin resource looks like, as far as reconciliation cares. + // TargetCount comes from the embedded list on GET /org/{org}/resources; the per-target + // detail needs its own call because that embedded form omits `method`. + private readonly record struct LiveResource(int Id, bool Ssl, bool Sso, int TargetCount); + + // Bring one resource's target in line with the shape. + // + // The embedded target list on GET resources carries ip/port/enabled but NOT `method`, + // so detail comes from GET /resource/{id}/targets — one extra call per DECLARED and + // already-existing resource. Comparing only the embedded fields would have left + // method drift undetectable, i.e. it would reproduce the exact silent-no-op class of + // bug this issue is about, just narrower. + // + // MULTI-TARGET RESOURCES ARE LEFT ALONE. Pangolin supports several targets per + // resource for load balancing; our shapes only ever declare one. Rewriting the first + // of several would silently destroy a hand-built config, which the add-only guardrail + // in CLAUDE.md exists to prevent — so that case reports and skips instead. + private static async Task<(string? msg, bool changed, string? failed)> ReconcileTargetAsync( + PangolinClient pg, LiveResource live, int siteId, + string ip, string method, int port, string fqdn, CancellationToken ct) + { + if (live.TargetCount > 1) + return ($"{fqdn}: {live.TargetCount} targets live (load-balanced?) — left alone, reconcile by hand", false, null); + + if (live.TargetCount == 0) + { + var (aok, _) = await pg.CallAsync("PUT", $"/resource/{live.Id}/target", + JsonSerializer.Serialize(new { siteId, ip, method, port, enabled = true }), ct); + return aok + ? ($"{fqdn}: target added → {method}://{ip}:{port}", true, null) + : (null, false, $"pangolin: failed to add target for {fqdn}"); + } + + var (tok, troot) = await pg.CallAsync("GET", $"/resource/{live.Id}/targets", null, ct); + if (!tok) return (null, false, $"pangolin: GET targets failed for {fqdn}"); + var t = DataArray(troot, "targets").FirstOrDefault(); + if (t.ValueKind != JsonValueKind.Object || !t.TryGetProperty("targetId", out var tid)) + return (null, false, $"pangolin: no usable target on {fqdn}"); + + var liveIp = t.TryGetProperty("ip", out var ipv) ? ipv.GetString() ?? "" : ""; + var livePort = t.TryGetProperty("port", out var pv) && pv.TryGetInt32(out var pi) ? pi : -1; + var liveMethod = t.TryGetProperty("method", out var mv) ? mv.GetString() ?? "" : ""; + var liveEnabled = Truthy(t, "enabled"); + + if (string.Equals(liveIp, ip, StringComparison.OrdinalIgnoreCase) && livePort == port + && string.Equals(liveMethod, method, StringComparison.OrdinalIgnoreCase) && liveEnabled) + return (null, false, null); + + // siteId is REQUIRED on this call even when unchanged — omitting it 400s with + // 'expected number, received undefined at "siteId"'. + var (uok, _) = await pg.CallAsync("POST", $"/target/{tid.GetInt32()}", + JsonSerializer.Serialize(new { siteId, ip, method, port, enabled = true }), ct); + if (!uok) return (null, false, $"pangolin: failed to update target for {fqdn}"); + return ($"{fqdn}: target {liveMethod}://{liveIp}:{livePort} → {method}://{ip}:{port}", true, null); } + // Pangolin returns SQLite-backed booleans as either true/false or 1/0 depending on + // the endpoint — `ssl` arrives as a JSON bool while `sso` arrives as an integer. A + // plain GetBoolean() throws on the latter, so read both shapes. + private static bool Truthy(JsonElement o, string prop) => + o.TryGetProperty(prop, out var v) && v.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => v.TryGetInt32(out var i) && i != 0, + JsonValueKind.String => bool.TryParse(v.GetString(), out var b) && b, + _ => false, + }; + // 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). diff --git a/docs/adr/ADR-0007-pangolin-remote-access.md b/docs/adr/ADR-0007-pangolin-remote-access.md index fb2a36c..3dc8997 100644 --- a/docs/adr/ADR-0007-pangolin-remote-access.md +++ b/docs/adr/ADR-0007-pangolin-remote-access.md @@ -234,8 +234,13 @@ deploy shape and the behind-cloudflared model. Findings: 1. **Per-hostname map lives in the DSL (renamable IaC).** The hostname → backend map is declared in the Pangolin stack DSL (`spec.config.resources[]` in `stacks/Core/pangolin.lxc.yaml`) and - provisioned via the :3003 integration API (add-only, idempotent by `fullDomain`) — so a DNS name is - changed by editing the DSL and re-converging, never hand-set in the UI. The DSL must gain a + provisioned via the :3003 integration API (**reconciled** — found by `fullDomain`, then target + `ip`/`port`/`method`/`enabled` and the `ssl`/`sso` gates are compared and corrected; #309) — so a + DNS name is changed by editing the DSL and re-converging, never hand-set in the UI. This was + *add-only* until #309, which meant editing a target changed desired state and never reached + Pangolin: converge reported a clean plan while the live route pointed at a stopped container. + A resource carrying **more than one** target is left alone, since our shapes only ever declare + one and rewriting the first of several would destroy hand-built load balancing. The DSL must gain a **wildcard-zone** dimension per resource (`lab` / `arr`) → `fullDomain = ..chrison.dev` (today it assumes one-level `.chrison.dev`). Zones are set (`*.lab` = general lab admin UIs; `*.arr` = arr stack admin UIs); the exact `subdomain.zone` per service is an operational pass diff --git a/stacks/Core/pangolin.lxc.yaml b/stacks/Core/pangolin.lxc.yaml index d657814..f04e76b 100644 --- a/stacks/Core/pangolin.lxc.yaml +++ b/stacks/Core/pangolin.lxc.yaml @@ -97,7 +97,11 @@ spec: enableIntegrationApi: true # integration API on :3003 — declarative resource mgmt # Declarative resources (#136): the provisioner upserts these via the integration API - # (add-only, idempotent by fullDomain) using PANGOLIN_API_KEY. A `zone` (lab|arr) makes + # using PANGOLIN_API_KEY, RECONCILING each one (#309): found by fullDomain, then the target + # (ip/port/method/enabled) and the ssl/sso gates are compared against live and corrected, so + # editing an entry here actually moves the live route. A resource with MORE THAN ONE target is + # left alone — we only ever declare one, and rewriting the first of several would destroy + # hand-built load balancing. 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). # sso defaults ON (the auth gate); set `sso: false` only for native clients that can't do