Skip to content

feat(converge): wire wildcard grey-cloud A records into Pangolin (#221) - #223

Merged
ChrisonSimtian merged 1 commit into
mainfrom
feat/cf-wildcard-ingress-221
Jun 30, 2026
Merged

feat(converge): wire wildcard grey-cloud A records into Pangolin (#221)#223
ChrisonSimtian merged 1 commit into
mainfrom
feat/cf-wildcard-ingress-221

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

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).
  • ReconcileWildcardDnsAsyncadd-only, idempotent by existence (DnsExistsAsync before 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, 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

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
ChrisonSimtian merged commit d55aa4e into main Jun 30, 2026
6 checks passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/cf-wildcard-ingress-221 branch June 30, 2026 08:17
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>
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