Skip to content

fix(pangolin): live-validated fixes from the EE stand-up (#168) - #219

Merged
ChrisonSimtian merged 2 commits into
mainfrom
fix/pangolin-ee-rollout-fixes
Jun 29, 2026
Merged

fix(pangolin): live-validated fixes from the EE stand-up (#168)#219
ChrisonSimtian merged 2 commits into
mainfrom
fix/pangolin-ee-rollout-fixes

Conversation

@ChrisonSimtian

@ChrisonSimtian ChrisonSimtian commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

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 /16 route, no default route → no internet egress (apt "network unreachable"). The gateway was parsed but never passed to ct/<app>.sh.
  • var_keyctl — Docker-in-unprivileged-LXC needs keyctl=1 (build.func supports the var).
  • yes <ans> | prompt feeding instead of a finite printf (can't under-supply/mis-order). A robustness improvement; it was a red herring for the create hang — the real cause was a build.func whiptail prompt for the missing gateway (fixed by var_gateway above).

Pangolin provisioner (Docker EE / public-wildcard)

  • Install Docker non-interactively (get.docker.com) in the deploy preamble; the shape moves to app: debian to dodge ct/docker.sh's interactive Portainer/socket prompts. nesting+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 previously lived only on the container network.
  • Mark-on-SUCCESS — stamp the marker to /opt/pangolin/.homelab-managed as the last 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 skipping the deploy.

Cloudflared provisioner

  • CoerceScalaroriginRequest scalars arrive from YamlDotNet as strings, so noTLSVerify serialized 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-point pangolin.chrison.devCT 2013 (dashboard cutover; old native 2012 stays as rollback).

Verified

dotnet test84 pass (new: CoerceScalar, :3003 publish, mark-on-success ordering). Live converge --apply: tunnel re-pointed (ingress re-pushed), EE stack deployed (pangolin healthy, traefik on :80/:443), :3003→200. Resource reconcile is pending the new org's API key (manual EE setup step).

Still open

  • The build.func create-path hang is fixed by the var_gateway emission above (a static-IP CT with no gateway made build.func prompt over non-interactive SSH). Validated end-to-end.
  • Manual EE setup + license activation at /admin/license, then create an org API key → PANGOLIN_API_KEY so the resource reconcile completes.

🤖 Generated with Claude Code

…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>
@ChrisonSimtian
ChrisonSimtian merged commit 3b056dc into main Jun 29, 2026
6 checks passed
@ChrisonSimtian
ChrisonSimtian deleted the fix/pangolin-ee-rollout-fixes branch June 29, 2026 11:01
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant