feat(converge): wire wildcard grey-cloud A records into Pangolin (#221) - #223
Merged
Conversation
Codify the Cloudflare half of #221 so a bare-metal rebuild restores public ingress. The PangolinProvisioner now reconciles a grey-cloud (proxied:false) A record per wildcard zone — *.lab.chrison.dev + *.arr.chrison.dev → the home WAN IP — wiring the CreateARecordAsync added in #219 (it had no caller). - New config key `publicIp` on the pangolin shape (the home :443 WAN IP). - WildcardARecords(shape): pure, testable seam — one record per LE wildcard SAN, gated on edge=public-wildcard + publicIp set (empty in cloudflared mode). - ReconcileWildcardDnsAsync: add-only, idempotent by existence (DnsExistsAsync), ManagedComment-stamped; skipped-not-failed when publicIp or the CF token is absent. Reuses Traefik's DNS-01 token (CF_DNS_API_TOKEN / CF_API_TOKEN). Out of scope (gated on #102, blocked): representing the UniFi WAN :443 port-forward as IaC. Documented in the shape comment; it stays hand-managed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ChrisonSimtian
added a commit
that referenced
this pull request
Jun 30, 2026
…102) (#225) Completes the **UniFi half of #221** — codifies the WAN `:443` → `10.10.0.13:443` port-forward to Pangolin that ADR-0007 stood up by hand. (The Cloudflare wildcard-DNS half is **#223**; #221 closes when both land.) Unblocks the write-path goal of **#102**. ## What A new `converge-unifi` engine command reconciles a declarative UniFi network desired-state via UnifiSharp's **legacy write adapter** (merged in UnifiSharp#12; the official integration API can't write port-forwards yet). - **`Infrastructure/unifi/network.yaml`** (`kind: UnifiNetwork`) — declares the `pangolin-https` port-forward. Pairs with the grey-cloud `*.lab`/`*.arr` A-records from #223: DNS points the hostnames at the home WAN IP, this forwards `:443` in. - **`Unifi/UnifiNetworkSpec` + `UnifiConverge`** — YAML model, a **pure add-only planner** (creates declared rules missing *by name*; never deletes hand-managed ones — same guardrail as the Cloudflare reconcile, CLAUDE.md), `ToLegacy` mapping, and apply. - **`converge-unifi <file> [--apply]`** — **dry-run by default**; `--apply` writes. Auth is the classic controller **session** (`UNIFI_LEGACY_BASE_URL` / `UNIFI_USERNAME` / `UNIFI_PASSWORD`). - Pin **UnifiSharp 0.1.0-preview.27** (exact, like ProxmoxSharp — a GH-Packages index lag can't silently grab an older preview without the `Legacy` namespace) and bump the vendored submodule to the matching merged commit. ## Verified end-to-end (against `.containers/unifi`) ``` converge-unifi … — dry-run → ~ pangolin-https → wan :443 ⇒ 10.10.0.13:443/tcp (would create) converge-unifi … --apply → + pangolin-https … (created) converge-unifi … — dry-run → = pangolin-https (present) → nothing to do # idempotent, add-only ``` Plus **5 unit tests** (add-only plan, case-insensitive match, never-delete-unmanaged, field mapping, YAML load). **89 engine tests green.** ## Auth note for live use The reconcile uses **session auth** (proven against the test container). To run it against the live Cloud Gateway, set `UNIFI_LEGACY_BASE_URL` + a local-admin `UNIFI_USERNAME`/`UNIFI_PASSWORD`. (The live gateway also accepts its `X-API-KEY` on the legacy API; adding optional X-API-KEY auth to the adapter is a clean future follow-up if you'd rather reuse the existing `UNIFI_API_KEY`.) Like #223, this PR codifies + proves the reconcile without applying to the live gateway. 🤖 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.
Closes the Cloudflare half of #221 — makes the
*.lab/*.arrpublic-ingress DNS reproducible from a converge instead of the records that were created out-of-band during the ADR-0007 rollout.What
PangolinProvisionernow 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:443port-forward. This wiresCloudflareApi.CreateARecordAsync(added in #219 with no caller).publicIpconfig 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 onedge: public-wildcard+publicIpset (empty in cloudflared mode → no public:443).ReconcileWildcardDnsAsync— add-only, idempotent by existence (DnsExistsAsyncbefore create),ManagedComment-stamped (so a future cloudflared provisioner re-exposes trimmed hostnames — DNS/Access are add-only (never pruned) #195 A-record prune leaves hand-managed records alone). Skipped, not failed, whenpublicIpor 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
--applyis a no-op against the existing records:DnsExistsAsyncmatches them by name and the create is skipped. Note the out-of-band records were made directly via the CF API and likely lack theManagedComment, 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
:443port-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 newWildcardARecordsgating tests: public-wildcard+publicIp → 2 records; publicIp-unset / cloudflared-edge → empty).converge stacks/Core --planrenders the new step:ensure grey-cloud A record(s) *.lab.chrison.dev + *.arr.chrison.dev → 118.67.199.127 (add-only)andPlan OK.🤖 Generated with Claude Code