Files
zeekay 16e9b4d525 fix(hanzodns): org-scope the store + fail-closed auth (red HIGH/MED/LOW)
Red found the authoritative store had no tenant isolation: only the CF-token
read was org-scoped, so on the shared multi-tenant instance any authenticated
org could list/read/mutate/delete another org's zones and records (DNS hijack /
outage). Fixes:

HIGH — org-scope the store structurally. Zone carries an Org (the validated
`owner` claim, stamped server-side); every Store method takes org and funnels
through zoneForOrg(org,name), so a caller can never reach another org's zone
even if a handler forgets the check. List filters to the caller's org;
get/mutate/delete of a foreign or absent zone is 'not found' (404, no existence
confirmation); zone names are globally unique on the shared nameservers so a
name taken by another org is a conflict. /v1/dns/sync derives the owner from the
ctx principal (never the body — the dead SyncZoneRequest.OrgID is removed) and
refuses to overwrite another org's zone.

MED — auth fails closed. authChain denies (503) when neither OIDC nor a static
key is configured; the anonymous path is reachable only behind an explicit
HANZO_DNS_DEV_INSECURE=1 opt-in (authMiddleware no longer allow-alls on an empty
key). OIDC audience is a comma-separated allowlist (string-or-array aud), and a
token with no exp is rejected.

LOW — Cloudflare updates now PATCH only the caller's changed fields (a stale
local mirror can't revert an out-of-band upstream change); a create/update
mirror failure is logged, not swallowed.

Tests: TestCrossTenantIsolation (org B gets 404 on list/get/create/update/
delete of org A's zone+records, org A's data intact), TestSyncIsOrgScoped,
TestAuthChainFailsClosedByDefault, TestDevInsecureAllows. go test -tags metrics
-race green; go build ./... green.
2026-07-18 10:58:01 -07:00
..
2026-03-26 22:41:28 -07:00
2026-03-26 22:41:28 -07:00

hanzodns

Name

hanzodns - REST API for dynamic DNS zone and record management.

Description

The hanzodns plugin exposes a REST API that allows creating, reading, updating, and deleting DNS zones and records at runtime. Records added through the API are immediately available for DNS resolution by CoreDNS.

The plugin runs a side-car HTTP server (default :8443) alongside CoreDNS and stores records in-memory. The store is designed to be swappable for a persistent backend (e.g., PostgreSQL) in the future.

Syntax

hanzodns [ADDR]
  • ADDR - the address to listen on for the HTTP API (default :8443).

Authentication

The API has one auth boundary, selected by configuration, and fails closed:

  • OIDC (production, multi-tenant). Set HANZO_DNS_OIDC_ISSUER (e.g. https://hanzo.id) to validate the caller's IAM JWT on every endpoint (except /v1/dns/health). The middleware requires a signed, unexpired token, extracts the org (the owner claim), and keeps the caller's bearer for the provider path. Set HANZO_DNS_OIDC_AUDIENCE to a comma-separated allowlist to also enforce the audience (reject tokens minted for other services) — required in production.
  • Static key (standalone, single-tenant). With no issuer, HANZO_DNS_API_KEY requires a shared Bearer token.
  • Denied. With neither configured the API returns 503 — the anonymous "allow all" is reachable ONLY behind an explicit HANZO_DNS_DEV_INSECURE=1 opt-in, never by default.
Authorization: Bearer <token>

Tenant isolation

Every zone is owned by the org that created it (the validated owner claim), and org is the single isolation key enforced inside the store, not just at the edge: a caller only ever lists, reads, or mutates its own org's zones and records. A request for another org's zone or record is 404 (existence is never confirmed cross-tenant), and /v1/dns/sync can only create or replace the caller's own zones. Multi-tenant deployments therefore require OIDC (the static-key mode is single-tenant).

Providers

A zone is served by one backend, named by its provider:

  • authoritative (default) — records live in this CoreDNS store and are answered locally. This is Hanzo's own nameservers; fully first-class.
  • cloudflare — records are managed in the org's connected Cloudflare account. The plugin reads the org's scoped API token from Hanzo KMS (path /orgs/{org}/integrations/cloudflare/api_token) by relaying the caller's own validated bearer to the KMS REST API ({HANZO_DNS_KMS_URL}/v1/kms/orgs/{org}/secrets/..., default https://api.hanzo.ai). The plugin holds no standing cross-tenant credential — KMS enforces org isolation, and the token is never logged. Provider-backed records are also mirrored into the local store so CoreDNS can serve the zone and listings stay consistent.

Create a Cloudflare-backed zone by passing provider on zone creation:

curl -X POST https://dns.hanzo.ai/v1/dns/zones \
  -H "Authorization: Bearer $ORG_JWT" \
  -d '{"zone": "example.com", "provider": "cloudflare"}'

Durability

Authoritative zones are held in memory. Set HANZO_DNS_STATE_DIR to a writable directory to make them durable: the store loads its last snapshot on startup and rewrites it (atomically) after every mutation, so a restart preserves native zones. Unset keeps the pure in-memory store. Cross-replica consistency and reschedule durability are provided by the platform re-pushing authoritative zones via POST /v1/dns/sync (the CRD 150-dns-records reconcile).

API Endpoints

Method Path Description
GET /v1/dns/health Health check (no auth)
GET /v1/dns/zones List all zones
POST /v1/dns/zones Create a zone
GET /v1/dns/zones/{zone} Get zone details
DELETE /v1/dns/zones/{zone} Delete a zone
GET /v1/dns/zones/{zone}/records List records in a zone
POST /v1/dns/zones/{zone}/records Create a record
GET /v1/dns/zones/{zone}/records/{id} Get a record
PATCH /v1/dns/zones/{zone}/records/{id} Update a record
DELETE /v1/dns/zones/{zone}/records/{id} Delete a record

Record Types

A, AAAA, CNAME, MX, TXT, SRV, NS, SOA, CAA

Examples

Enable the API on the default port:

. {
    hanzodns
    forward . 8.8.8.8
}

Listen on a custom port:

. {
    hanzodns :9443
    forward . 8.8.8.8
}

Create a zone and record via curl:

# Create zone
curl -X POST http://localhost:8443/v1/dns/zones \
  -H "Authorization: Bearer $HANZO_DNS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"zone": "example.com"}'

# Add A record
curl -X POST http://localhost:8443/v1/dns/zones/example.com/records \
  -H "Authorization: Bearer $HANZO_DNS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "www", "type": "A", "content": "1.2.3.4", "ttl": 300}'

# Query it
dig @localhost www.example.com A