mirror of
https://github.com/hanzoai/dns.git
synced 2026-10-30 18:40:55 +00:00
hanzodns becomes the one DNS control plane: authoritative zones served by its
own CoreDNS store AND third-party providers (Cloudflare) via the org's
KMS-sealed token. Both are first-class and coexist per-zone.
- Provider interface (provider.go) mirroring the platform dns-provider.ts:
Type/ListZones/ZoneIDForName/ListRecords/Create/Update/DeleteRecord.
- Cloudflare connector (cloudflare.go) ported from cloudflare.ts — CF API v4
CRUD, token only on the Authorization header, fail-closed, never logged.
- Per-org credential sourcing (kms.go): providerFor reads the sealed token from
KMS by RELAYING the caller's own validated bearer to the KMS REST API
(/v1/kms/orgs/{org}/secrets/integrations/cloudflare/api_token). hanzodns holds
no standing cross-tenant credential — KMS enforces org isolation. org is the
IAM claim; auth-method-agnostic (apikey + oauth seal the same coord).
- Zone.provider dispatch (api.go): authoritative -> local store; cloudflare ->
provider (source of truth) with a local-store mirror so CoreDNS serves it and
listings stay consistent. OIDC wired into the auth chain so org + bearer reach
the provider path.
- Durable authoritative zones (store_snapshot.go): opt-in file snapshot via
HANZO_DNS_STATE_DIR — load on startup, atomic rewrite on every mutation, so a
restart preserves native zones. Unset keeps the in-memory store.
Tests: CF connector, KMS bearer-relay (incl. foreign-org refusal), the full
create/list/delete dispatch for a Cloudflare zone, the native authoritative
path, and snapshot round-trip across a restart. go test -tags metrics green.
140 lines
3.8 KiB
Go
140 lines
3.8 KiB
Go
package hanzodns
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
)
|
|
|
|
// snapshotFile is the state file name written under the configured state dir.
|
|
const snapshotFile = "hanzodns-state.json"
|
|
|
|
// snapshotZone is a zone plus its records in serializable form.
|
|
type snapshotZone struct {
|
|
Zone Zone `json:"zone"`
|
|
Records []Record `json:"records"`
|
|
}
|
|
|
|
// snapshotState is the whole store rendered for the durable snapshot.
|
|
type snapshotState struct {
|
|
Zones []snapshotZone `json:"zones"`
|
|
}
|
|
|
|
// Export captures a consistent point-in-time copy of the store. Zones and
|
|
// records are sorted so the serialized snapshot is stable across writes.
|
|
func (s *Store) Export() snapshotState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
names := make([]string, 0, len(s.zones))
|
|
for name := range s.zones {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
out := snapshotState{Zones: make([]snapshotZone, 0, len(names))}
|
|
for _, name := range names {
|
|
zd := s.zones[name]
|
|
recs := make([]Record, 0, len(zd.records))
|
|
for _, r := range zd.records {
|
|
recs = append(recs, *r)
|
|
}
|
|
sort.Slice(recs, func(i, j int) bool { return recs[i].ID < recs[j].ID })
|
|
out.Zones = append(out.Zones, snapshotZone{Zone: zd.zone, Records: recs})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Import replaces the store's contents with the snapshot. Used once at startup
|
|
// to rehydrate authoritative (and mirrored) zones after a restart.
|
|
func (s *Store) Import(state snapshotState) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.zones = make(map[string]*zoneData, len(state.Zones))
|
|
for _, sz := range state.Zones {
|
|
key := normZone(sz.Zone.Name)
|
|
zd := &zoneData{zone: sz.Zone, records: make(map[string]*Record, len(sz.Records))}
|
|
zd.zone.Name = key
|
|
if zd.zone.Provider == "" {
|
|
zd.zone.Provider = ProviderAuthoritative
|
|
}
|
|
for i := range sz.Records {
|
|
r := sz.Records[i]
|
|
zd.records[r.ID] = &r
|
|
}
|
|
s.zones[key] = zd
|
|
}
|
|
}
|
|
|
|
// snapshotter persists a Store to a single JSON file. Writes are serialized and
|
|
// atomic (temp file + rename), so a crash mid-write never corrupts the state.
|
|
type snapshotter struct {
|
|
path string
|
|
store *Store
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// EnableSnapshot makes the store durable under dir: it loads an existing
|
|
// snapshot (if any) at startup and rewrites the snapshot after every mutation.
|
|
// When dir is empty the store stays purely in-memory (the prior behavior), so
|
|
// durability is opt-in via HANZO_DNS_STATE_DIR without changing defaults.
|
|
func EnableSnapshot(store *Store, dir string) error {
|
|
if dir == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return fmt.Errorf("hanzodns: create state dir: %w", err)
|
|
}
|
|
snap := &snapshotter{path: filepath.Join(dir, snapshotFile), store: store}
|
|
|
|
if err := snap.load(); err != nil {
|
|
return err
|
|
}
|
|
store.onChange = snap.save
|
|
return nil
|
|
}
|
|
|
|
// load reads the snapshot into the store if the file exists. A missing file is
|
|
// a clean first boot, not an error.
|
|
func (s *snapshotter) load() error {
|
|
data, err := os.ReadFile(s.path)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("hanzodns: read snapshot: %w", err)
|
|
}
|
|
var state snapshotState
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return fmt.Errorf("hanzodns: parse snapshot: %w", err)
|
|
}
|
|
s.store.Import(state)
|
|
return nil
|
|
}
|
|
|
|
// save writes the current store state atomically. It is the store's onChange
|
|
// hook, invoked after each mutation once the store lock is released.
|
|
func (s *snapshotter) save() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := json.MarshalIndent(s.store.Export(), "", " ")
|
|
if err != nil {
|
|
log.Errorf("hanzodns: marshal snapshot: %s", err)
|
|
return
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o640); err != nil {
|
|
log.Errorf("hanzodns: write snapshot: %s", err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmp, s.path); err != nil {
|
|
log.Errorf("hanzodns: commit snapshot: %s", err)
|
|
_ = os.Remove(tmp)
|
|
}
|
|
}
|