fix(pangolin): live-validated fixes from the EE stand-up (#168) - #219
Merged
Conversation
…0007) Standing up CT 2013 surfaced several real bugs (each found by inspecting the live cluster). All fixed + unit-tested; the EE stack is now running on 2013. Create-vars (CommunityScriptsCreator): - emit var_gateway — a static-IP CT had only its /16 route, no default route, so no internet egress (apt "network unreachable"); the gateway was never passed. - emit var_keyctl — Docker-in-unprivileged-LXC needs keyctl=1 (build.func supports it). - feed install prompts with `yes <ans> |` instead of a finite printf (can't under-supply / mis-order). NOTE: doesn't help the ct/docker.sh hang, which is a build.func post-create regression over non-interactive SSH (tracked in #11). Pangolin provisioner (public-wildcard / Docker EE): - install Docker non-interactively (get.docker.com) in the deploy preamble — the shape moves to app: debian (avoids ct/docker.sh's interactive Portainer/socket prompts that hang over SSH); var_nesting+var_keyctl make the base CT Docker-capable. - publish the integration API on 127.0.0.1:3003 so the resource reconcile (curl localhost:3003 via pct exec) can reach it; it lived only on the container network. - mark-on-SUCCESS: stamp the managed marker to /opt/pangolin/.homelab-managed as the LAST deploy step (after `docker compose up`), not in config.yml up front — a partial failure no longer leaves a "current" marker that wedges the next converge into a skip. Cloudflared provisioner: - CoerceScalar: originRequest scalars arrive from YamlDotNet as strings, so noTLSVerify serialized as "true" and Cloudflare rejected the ingress re-push (code 1056, "cannot unmarshal string ... into bool"). Coerce bools/numbers. This had been latent — the Core ingress had never actually been re-pushed until now. Shapes: - stacks/Core/pangolin.lxc.yaml: app: debian + provisioner: pangolin, nameserver 10.10.0.1 (VLAN 1010 firewalls public DNS; gateway resolver works). - stacks/Core/cloudflared-hpe-01.lxc.yaml: re-point pangolin.chrison.dev → CT 2013 (the dashboard cutover; old native CT 2012 stays as rollback). 84 tests pass. Live: converge re-points the tunnel + deploys the EE stack (pangolin healthy, traefik on :80/:443); resource reconcile pending the new org's API key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Pangolin org SLUG is 'chrison-dev' (display name 'Chrison.dev'), not 'chrison'. With the wrong id the integration API returned 403 'Key does not have access to this organization' and the resource reconcile failed. Verified live: GET /org/chrison-dev/domains → chrison.dev (verified wildcard, certResolver letsencrypt). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 29, 2026
Closed
Closed
ChrisonSimtian
added a commit
that referenced
this pull request
Jun 30, 2026
… (#223) Closes the **Cloudflare half** of #221 — makes the `*.lab` / `*.arr` public-ingress DNS reproducible from a converge instead of the records that were created out-of-band during the ADR-0007 rollout. ## What `PangolinProvisioner` now reconciles a **grey-cloud (`proxied:false`) A record per wildcard zone** — `*.lab.chrison.dev` + `*.arr.chrison.dev` → the home WAN IP — so every `*.<zone>` hostname Traefik mints an LE wildcard cert for actually resolves to the home `:443` port-forward. This wires `CloudflareApi.CreateARecordAsync` (added in #219 with **no caller**). - New `publicIp` config key on the pangolin shape (`118.67.199.127`, the home WAN IP). - `WildcardARecords(shape)` — pure, testable seam: one record per LE wildcard SAN, gated on `edge: public-wildcard` **+** `publicIp` set (empty in cloudflared mode → no public `:443`). - `ReconcileWildcardDnsAsync` — **add-only**, idempotent by existence (`DnsExistsAsync` before create), `ManagedComment`-stamped (so a future #195 A-record prune leaves hand-managed records alone). **Skipped, not failed**, when `publicIp` or the CF token is absent, so a plan/dev run still converges the rest. Reuses Traefik's DNS-01 token (`CF_DNS_API_TOKEN` / `CF_API_TOKEN`) — no new secret. ## Idempotency / add-only The first real `--apply` is a **no-op** against the existing records: `DnsExistsAsync` matches them by name and the create is skipped. Note the out-of-band records were made directly via the CF API and likely **lack** the `ManagedComment`, so converge *skips* them rather than *adopts* them — recreating them to add the comment would be a delete+recreate of a live ingress record, which violates the add-only guardrail, so they're left as-is. There is no A-record prune yet, so this is harmless. ## Out of scope (gated on #102, `blocked`) Representing the **UniFi WAN `:443` port-forward** as IaC needs the UniFi write path (#102). It stays hand-managed for now; documented in the shape comment. #221 stays open until that half lands. ## Test plan - `dotnet test Infrastructure/engine.Tests/` → **86 passed** (incl. 2 new `WildcardARecords` gating tests: public-wildcard+publicIp → 2 records; publicIp-unset / cloudflared-edge → empty). - `converge stacks/Core --plan` renders the new step: `ensure grey-cloud A record(s) *.lab.chrison.dev + *.arr.chrison.dev → 118.67.199.127 (add-only)` and `Plan OK`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 12, 2026
ChrisonSimtian
added a commit
that referenced
this pull request
Jul 12, 2026
) (#239) ## Description Closes the core of #238. **Correction to the original issue:** the EE / `*.lab` public-ingress model, the rollout fixes, and the wildcard grey-cloud A-record converge are **already in `main`** (#215 / #216 / #219 / #221 / #223) — the "unmerged branches" premise in #238 was stale. What actually remained was a **latent security bug** plus codifying the one live resource that was created out-of-band. ### The bug `ReconcileResourcesAsync` set `ssl` on resource create but **never set `sso`**. The Pangolin integration-API create defaults `sso` to `null` (**OPEN**), so a from-scratch `converge` would create every admin UI with **no auth gate — publicly reachable** — directly contradicting the "gated by Pangolin auth (badger)" contract in the stack yaml. Confirmed via the code path + the `sso:null` observed when `power.lab` was created by hand through the same `PUT /resource` call. ### The fix - 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 — the case flagged in the media-exposure notes). - Extracted the decision to `ResourceSsoEnabled` + a theory test. - Codified the live **`power.lab.chrison.dev`** resource (PowerOrchestrator dashboard + Wake/Sleep/Arm control API, #191) into the declarative `resources:` so it survives converge. The add-only reconcile skips the already-live one, so no disruption. ## Testing - `dotnet test` — **96 passed**, incl. the new `Pangolin_Resource_SsoDefaultsOn_UnlessOptedOut` theory (5 cases). - Live `power.lab.chrison.dev` already verified end-to-end (valid LE cert + SSO 302). ## Out of scope (remaining #238 follow-ups) - **UniFi WAN `:443` port-forward** is still hand-managed — already documented in `pangolin.lxc.yaml` and gated on #102 (UniFi write path). - **Legacy CT-2012 retirement** (the pre-EE native install) — retire once nothing rides it. - **Update-existing / prune** — the reconcile stays add-only (create-by-fullDomain); it won't retrofit `sso` onto resources created before this fix (none are open today — `traefik.lab` and `power.lab` are both gated). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #215/#216 — the bugs found while actually standing up the Docker EE Pangolin on CT 2013 (each diagnosed against the live cluster). The EE stack is now running on 2013; this PR makes it reproducible.
Create-vars (
CommunityScriptsCreator)var_gateway— a static-IP CT got only its/16route, no default route → no internet egress (apt "network unreachable"). The gateway was parsed but never passed toct/<app>.sh.var_keyctl— Docker-in-unprivileged-LXC needskeyctl=1(build.funcsupports the var).yes <ans> |prompt feeding instead of a finiteprintf(can't under-supply/mis-order). A robustness improvement; it was a red herring for the create hang — the real cause was abuild.funcwhiptail prompt for the missing gateway (fixed byvar_gatewayabove).Pangolin provisioner (Docker EE / public-wildcard)
get.docker.com) in the deploy preamble; the shape moves toapp: debianto dodgect/docker.sh's interactive Portainer/socket prompts.nesting+keyctlmake the base CT Docker-capable.127.0.0.1:3003so the resource reconcile (curl localhost:3003viapct exec) can reach it — it previously lived only on the container network./opt/pangolin/.homelab-managedas the last step (afterdocker compose up), not inconfig.ymlup front. A partial failure no longer leaves a "current" marker that wedges the next converge into skipping the deploy.Cloudflared provisioner
CoerceScalar—originRequestscalars arrive from YamlDotNet as strings, sonoTLSVerifyserialized as"true"and Cloudflare rejected the ingress re-push (code 1056). Coerce bools/numbers. Latent until now — the Core ingress had never actually been re-pushed.Shapes
pangolin.lxc.yaml:app: debian+provisioner: pangolin,nameserver: 10.10.0.1(VLAN 1010 firewalls public DNS; the gateway resolver works).cloudflared-hpe-01.lxc.yaml: re-pointpangolin.chrison.dev→ CT 2013 (dashboard cutover; old native 2012 stays as rollback).Verified
dotnet test→ 84 pass (new:CoerceScalar,:3003publish, mark-on-success ordering). Liveconverge --apply: tunnel re-pointed (ingress re-pushed), EE stack deployed (pangolinhealthy,traefikon:80/:443),:3003→200. Resource reconcile is pending the new org's API key (manual EE setup step).Still open
build.funccreate-path hang is fixed by thevar_gatewayemission above (a static-IP CT with no gateway made build.func prompt over non-interactive SSH). Validated end-to-end./admin/license, then create an org API key →PANGOLIN_API_KEYso the resource reconcile completes.🤖 Generated with Claude Code