From 4234d4e38a107cc71c33978234bbb55c37f6f8dd Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 16 Aug 2026 21:17:39 +1200 Subject: [PATCH] Serve the Pangolin-fronted zones from local DNS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *.lab / *.arr / *.iot are grey-cloud A records at Cloudflare pointing to the home WAN IP, so inside the house they resolved to the WAN too and every admin UI was reached by NAT hairpin back through the pangolin-https port-forward. That made the forward load-bearing for LAN access rather than only for remote access, so ADR-0007's "reversible exit" of closing :443 would have taken the whole admin surface down at home as well. Three controller-local records decouple the two. Wildcards, not the ~16 individual records #419 estimated: the controller supports them and matches arbitrary labels, which was verified against the live gateway (the Azure Lab's *.topaz.local.dev already answers any label under it). A new Pangolin resource now needs no DNS work at all. The mechanism is a new `staticDns` list on the UnifiNetwork shape, reconciled by converge-unifi against the v2 site API. Add-only and drift-correcting, like the rest: a record that exists but answers differently is corrected, and the 17 records belonging to other things are listed and left alone. Matching is by name AND type, because an A and a CNAME for one name are distinct rows — matching on name alone would make converge try to turn one into the other. TTL is only claimed when the shape states one, or every run would rewrite the controller's default forever. An update carries the live record's untouched fields forward: v2 rejects partial updates, so a full replacement rebuilt from defaults would silently reset fields the shape says nothing about. Deliberately NOT overridden: pangolin.chrison.dev, which is orange-clouded and rides the core CF tunnel that serves the SSO redirect every resource bounces through; and vpn.chrison.dev, which has to resolve to the WAN address from inside the house too or the client dials its own LAN. Verified live. All three zones resolve to 10.10.0.13 including a brand-new label; the UIs answer 302 to the SSO gate; that gate still resolves via Cloudflare; the Let's Encrypt cert is served directly by Traefik on the LAN path; and a second run reports no changes. Closes #419. Refs #314 — this codifies the UniFi half; pairing a record with a Pangolin resource declaration is the remaining part. Co-Authored-By: Claude Opus 5 (1M context) --- .../engine.Tests/UnifiStaticDnsTests.cs | 174 ++++++++++++++++++ .../engine/Homelab.Infrastructure.csproj | 2 +- Infrastructure/engine/Program.cs | 36 +++- Infrastructure/engine/Unifi/UnifiConverge.cs | 31 +++- .../engine/Unifi/UnifiNetworkSpec.cs | 9 + Infrastructure/engine/Unifi/UnifiStaticDns.cs | 146 +++++++++++++++ Infrastructure/schema/shape.schema.json | 18 ++ Infrastructure/unifi/network.yaml | 39 +++- 8 files changed, 443 insertions(+), 12 deletions(-) create mode 100644 Infrastructure/engine.Tests/UnifiStaticDnsTests.cs create mode 100644 Infrastructure/engine/Unifi/UnifiStaticDns.cs diff --git a/Infrastructure/engine.Tests/UnifiStaticDnsTests.cs b/Infrastructure/engine.Tests/UnifiStaticDnsTests.cs new file mode 100644 index 0000000..abea689 --- /dev/null +++ b/Infrastructure/engine.Tests/UnifiStaticDnsTests.cs @@ -0,0 +1,174 @@ +using Homelab.Infrastructure.Unifi; +using UnifiSharp.Legacy; +using Xunit; + +namespace Homelab.Infrastructure.Tests; + +// Pure tests for the controller-local DNS reconcile (#314/#419). No controller — +// everything that decides whether to write is a function of (declared, live). +public sealed class UnifiStaticDnsTests +{ + private static StaticDnsSpec Spec(string name = "*.lab.chrison.dev", string value = "10.10.0.13", + string type = "A", int? ttl = null, bool enabled = true) => + new() { Name = name, Value = value, Type = type, Ttl = ttl, Enabled = enabled }; + + private static UnifiStaticDnsRecord Live(string key = "*.lab.chrison.dev", string value = "10.10.0.13", + string type = "A", int ttl = 300, bool enabled = true) => + new() { Id = "live-1", Key = key, Value = value, RecordType = type, Ttl = ttl, Enabled = enabled }; + + // ---- the reconcile decision ---- + + [Fact] + public void Matching_live_state_is_a_no_op() + { + var item = UnifiStaticDns.Plan(Spec(), [Live()]); + Assert.Equal(StaticDnsAction.NoChange, item.Action); + Assert.Empty(item.Changes); + } + + [Fact] + public void A_missing_record_is_a_create() + { + var item = UnifiStaticDns.Plan(Spec(), [Live("*.arr.chrison.dev")]); + Assert.Equal(StaticDnsAction.Create, item.Action); + Assert.Null(item.LiveId); + } + + [Fact] + public void A_record_answering_differently_is_drift_not_a_second_record() + { + // A name can only answer one way, so "already exists" is not the question — + // "does it answer what the shape says" is. This is the case create-if-missing + // would call healthy while the LAN resolved to the wrong host. + var item = UnifiStaticDns.Plan(Spec(value: "10.10.0.13"), [Live(value: "118.67.199.127")]); + + Assert.Equal(StaticDnsAction.Update, item.Action); + Assert.Equal("live-1", item.LiveId); + Assert.Contains("value: 118.67.199.127 → 10.10.0.13", item.Changes); + } + + [Fact] + public void A_disabled_record_is_drift() + { + var item = UnifiStaticDns.Plan(Spec(), [Live(enabled: false)]); + Assert.Contains("enabled: False → True", item.Changes); + } + + [Fact] + public void The_same_name_with_a_different_type_is_a_different_record() + { + // An A and a CNAME for one name are distinct rows on the controller; matching on + // name alone would make converge try to turn one into the other. + var item = UnifiStaticDns.Plan(Spec(type: "A"), [Live(type: "CNAME", value: "elsewhere.example")]); + Assert.Equal(StaticDnsAction.Create, item.Action); + } + + // ---- what the shape does and does not claim ---- + + [Fact] + public void Ttl_is_only_claimed_when_the_shape_states_one() + { + // Otherwise every converge would rewrite the controller's default forever. + Assert.Equal(StaticDnsAction.NoChange, UnifiStaticDns.Plan(Spec(ttl: null), [Live(ttl: 900)]).Action); + Assert.Contains("ttl: 900 → 60", UnifiStaticDns.Plan(Spec(ttl: 60), [Live(ttl: 900)]).Changes); + } + + [Fact] + public void Cosmetic_differences_are_not_drift() + { + // A trailing dot and case are the same name; a zero-padded octet is the same address. + Assert.Equal(StaticDnsAction.NoChange, + UnifiStaticDns.Plan(Spec(name: "*.lab.chrison.dev"), [Live(key: "*.LAB.Chrison.Dev.")]).Action); + Assert.Equal(StaticDnsAction.NoChange, + UnifiStaticDns.Plan(Spec(value: "10.10.0.13"), [Live(value: "10.10.000.13")]).Action); + } + + [Fact] + public void A_cname_value_compares_as_a_name_not_an_address() + { + var item = UnifiStaticDns.Plan( + Spec(type: "CNAME", value: "traefik.lab.chrison.dev"), + [Live(type: "CNAME", value: "Traefik.Lab.Chrison.Dev.")]); + Assert.Equal(StaticDnsAction.NoChange, item.Action); + } + + // ---- the write body ---- + + [Fact] + public void ToRecord_sends_a_complete_record_because_v2_rejects_partials() + { + var r = UnifiStaticDns.ToRecord(Spec()); + + Assert.Equal("*.lab.chrison.dev", r.Key); + Assert.Equal("A", r.RecordType); + Assert.Equal("10.10.0.13", r.Value); + Assert.Equal(300, r.Ttl); // controller default, sent explicitly + Assert.True(r.Enabled); + Assert.Null(r.Id); // create carries no id + } + + [Fact] + public void An_update_carries_the_live_records_untouched_fields_forward() + { + // A full replacement that rebuilt from defaults would silently reset fields the + // shape says nothing about — the exact hazard of a replace-only endpoint. + var live = Live(ttl: 900) with { Priority = 7, Weight = 3, Port = 8443 }; + var r = UnifiStaticDns.ToRecord(Spec(value: "10.10.0.99"), live); + + Assert.Equal("live-1", r.Id); + Assert.Equal("10.10.0.99", r.Value); + Assert.Equal(900, r.Ttl); // not reset to 300 + Assert.Equal(7, r.Priority); + Assert.Equal(3, r.Weight); + Assert.Equal(8443, r.Port); + } + + [Fact] + public void A_stated_ttl_wins_over_the_live_one() + { + Assert.Equal(60, UnifiStaticDns.ToRecord(Spec(ttl: 60), Live(ttl: 900)).Ttl); + } + + // ---- add-only ---- + + [Fact] + public void Undeclared_records_are_listed_and_never_touched() + { + // The controller carries a dozen records owned by other things — the Azure Lab's + // wildcard set, the node names. Reporting them is the whole contract. + var live = new[] + { + Live("*.lab.chrison.dev"), + Live("*.topaz.local.dev", "10.50.0.10"), + Live("hpe-01.homelab.chrison.internal", "10.0.0.13"), + }; + + var undeclared = UnifiStaticDns.Undeclared(live, [Spec("*.lab.chrison.dev")]); + + Assert.Equal(["*.topaz.local.dev", "hpe-01.homelab.chrison.internal"], undeclared.Select(u => u.Key)); + } + + [Fact] + public void Undeclared_matching_ignores_case_and_trailing_dots() + { + var live = new[] { Live("*.LAB.chrison.dev.") }; + Assert.Empty(UnifiStaticDns.Undeclared(live, [Spec("*.lab.chrison.dev")])); + } + + // ---- the actual #419 declaration ---- + + [Fact] + public void The_three_pangolin_zones_plan_as_creates_against_a_controller_that_has_none() + { + var declared = new[] + { + Spec("*.lab.chrison.dev"), Spec("*.arr.chrison.dev"), Spec("*.iot.chrison.dev"), + }; + var live = new[] { Live("*.topaz.local.dev", "10.50.0.10") }; + + var plan = declared.Select(d => UnifiStaticDns.Plan(d, live)).ToList(); + + Assert.All(plan, p => Assert.Equal(StaticDnsAction.Create, p.Action)); + Assert.All(plan, p => Assert.Equal("10.10.0.13", p.Desired.Value)); + } +} diff --git a/Infrastructure/engine/Homelab.Infrastructure.csproj b/Infrastructure/engine/Homelab.Infrastructure.csproj index a014d9e..db9c2d9 100644 --- a/Infrastructure/engine/Homelab.Infrastructure.csproj +++ b/Infrastructure/engine/Homelab.Infrastructure.csproj @@ -29,7 +29,7 @@ adapter requires UNIFI_USERNAME/UNIFI_PASSWORD, which secrets.env does not carry, so converge-unifi could not authenticate at all (#416) — plus the rest/user endpoints the DHCP-reservation reconcile is built on. --> - +