Skip to content

Add static DNS (v2 site API) to the legacy adapter - #18

Merged
ChrisonSimtian merged 1 commit into
mainfrom
feat/static-dns
Aug 16, 2026
Merged

Add static DNS (v2 site API) to the legacy adapter#18
ChrisonSimtian merged 1 commit into
mainfrom
feat/static-dns

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Why

Controller-local DNS records are how a name resolves on the LAN without any public zone being involved, and they were reachable from nothing but curl. Homelab needs them to declare internal names as IaC (Homelab#314) and to stop LAN access to the Pangolin-fronted zones depending on NAT hairpin (Homelab#419).

The v2 surface is not the legacy surface

Static DNS lives on the v2 site API, which shares the host and the auth but none of the conventions:

legacy …/api/s/<site> v2 …/v2/api/site/<site>
response { "meta": {...}, "data": [...] } bare JSON array / object
update partial — send only changed fields full replacement
a partial PUT fine 400 Validation failed

Treating them alike compiles and passes every unit test, then fails against a real controller — so the difference is encoded in the types rather than left to a caller to remember. UnifiStaticDnsRecord is non-nullable throughout where the legacy DTOs are nullable: on v2 a partial is not something you can send, and the type shouldn't suggest otherwise. A test pins that every field serializes even at its default value, because the omit-nulls serializer would otherwise quietly thin the body into a rejected partial.

Verified before writing any of it

Probed against the live controller with a throwaway record, then deleted:

POST   -> 200, returns the created record with _id
PUT /{id}  full body     -> 200, replaced
PUT /{id}  partial body  -> 400 Validation failed
DELETE /{id}             -> 200, empty body

Wildcards are supported and match arbitrary labels — *.topaz.local.dev already answers randomtest123.topaz.local.dev on this controller. That's what makes Homelab#419 three records instead of the sixteen it estimated.

Shape

  • UnifiLegacyOptions gains Site (parsed from the base URL rather than assumed) and SiteV2Url.
  • UnifiLegacySession gains SendAbsoluteAsync — a v2 path can't be reached relative to the legacy base. Auth, TLS handling and the 401 policy are unchanged and shared.
  • UnifiLegacyClient gains ListStaticDnsAsync / CreateStaticDnsAsync / UpdateStaticDnsAsync / DeleteStaticDnsAsync.

Unlike rest/user, this one does get a delete: a DNS record carries no history worth preserving, so there's no reversible middle state to prefer.

Tests

36 total, 33 passing / 3 skipped (the destructive ones, correctly gated on session auth). Includes a read-only live test that exercises the whole v2 path against the real controller.

Bumps Chrison.UnifiSharp to 0.3.0. Chrison.UnifiSharp.Api stays at the already-published 10.4.57, so this release has none of the dependency-indexing race 0.2.0 hit.

🤖 Generated with Claude Code

Controller-local DNS records are how a name resolves on the LAN without any public zone
being involved, and they were reachable from nothing but curl. Homelab needs them to
declare internal names as IaC (Homelab#314) and to stop LAN access to the
Pangolin-fronted zones depending on NAT hairpin (Homelab#419).

They live on the v2 site API, which is a different surface from everything the adapter
covered so far and does not share its conventions:

- responses are bare JSON arrays/objects, not the {meta,data} envelope
- an update is a FULL REPLACEMENT; a PUT carrying only the changed field is answered
  400 Validation failed, where the legacy rest/user path accepts exactly that
- it hangs off /proxy/network/v2/api/site/<site>, outside the legacy site base

So UnifiStaticDnsRecord is non-nullable throughout while the legacy DTOs are nullable:
on v2 a partial is not something you can send, and the type should not imply it is.
Every field serializes even at its default, which a test pins.

UnifiLegacyOptions gains Site (parsed from the base URL rather than assumed) and
SiteV2Url; UnifiLegacySession gains SendAbsoluteAsync, since a v2 path cannot be reached
relative to the legacy base. Auth, TLS handling and the 401 policy are unchanged and
shared.

Verified against the live controller before writing any of it: POST returns the created
record with _id at status 200, PUT with a full body replaces, PUT with a partial is
rejected, DELETE returns 200 with an empty body. Wildcard keys are supported and match
arbitrary labels. A read-only live test covers the v2 path end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisonSimtian ChrisonSimtian added the enhancement New feature or request label Aug 16, 2026
@ChrisonSimtian
ChrisonSimtian merged commit a523ee5 into main Aug 16, 2026
1 check passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/static-dns branch August 16, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant