From 07be25ac924df9e50121e916f07fd794a9c663fb Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Thu, 29 Oct 2026 12:22:51 +0100 Subject: [PATCH] DNS provider: PowerDNS API and RFC 2136 with TSIG Rollcall writes its machines' A/AAAA records into the site's DNS when no IPAM authority publishes them. Names follow the IPAM rule. - powerdns: RRsets owned by comment account "rollcall"; others are reported as conflicts and left alone. - rfc2136: TSIG-signed updates with a _rollcall. TXT marker. Changing a record requires the marker, creating one requires the name to be free, so foreign records are never overwritten. Written records are kept in dns_records and queried on every sync, so out-of-band changes are repaired (marker present) or reported (someone else's record). - Sync on allocation/release and every sync_interval; rollcall dns, rollcall dns sync [-dry], doctor check. Tested against netops PowerDNS and BIND 9 (conflict, repair, wrong TSIG secret). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + INSTALL.md | 2 + README.md | 50 ++++ cmd/rollcall/client.go | 2 +- cmd/rollcall/config.go | 3 + cmd/rollcall/dnsprov.go | 561 +++++++++++++++++++++++++++++++++++ cmd/rollcall/dnsprov_test.go | 136 +++++++++ cmd/rollcall/doctor.go | 12 + cmd/rollcall/go.mod | 12 +- cmd/rollcall/go.sum | 24 +- cmd/rollcall/ipam.go | 11 +- cmd/rollcall/main.go | 3 +- cmd/rollcall/netapi.go | 61 ++++ cmd/rollcall/server.go | 8 + cmd/rollcall/store.go | 3 + 15 files changed, 873 insertions(+), 19 deletions(-) create mode 100644 cmd/rollcall/dnsprov.go create mode 100644 cmd/rollcall/dnsprov_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 78bc53f..dc9b32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ publish a tag that has no section here. - Proxy DHCP: a VLAN in `proxy` mode (`rollcall vlan add ... -proxy-dhcp`, web UI, `bootstrap.sh --proxy-dhcp`) leaves addresses to the site's DHCP server and only answers PXE; machines keep DHCP unless a static range is set aside; doctor and discover know the mode. +- DNS provider: `dns` in `config.json` writes machines' A/AAAA records into the site's DNS, through + the PowerDNS API (ownership by RRset comment) or RFC 2136 dynamic update with TSIG (ownership by a + `_rollcall.` TXT marker and update prerequisites, so foreign records are never overwritten; + drift is detected by querying). `rollcall dns`, `rollcall dns sync [-dry]`, a doctor check. ### Fixed - dnsmasq busy loop in proxy mode (a dnsmasq bug with tagged proxy ranges, found with gdb): proxy diff --git a/INSTALL.md b/INSTALL.md index 6d9cf89..b442b22 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -149,6 +149,8 @@ region's version. ## Integrations - **NetBox (or Infoblox) as the address authority.** `ipam` in `config.json`: see README, External IPAM. +- **DNS names without an IPAM authority.** `dns` in `config.json` (PowerDNS API or RFC 2136 + with TSIG): see README, DNS provider. - **Certificates for machines.** Delegate a name-constrained intermediate to an ACME server: see README, Certificate authority. diff --git a/README.md b/README.md index 02b4d4c..a628a40 100644 --- a/README.md +++ b/README.md @@ -587,6 +587,56 @@ The provider is an interface (reserve, owned records, update, delete, delegated prefixes); the NetBox driver is the first. An Infoblox WAPI driver maps them to `record:host`/`fixedaddress` with extensible attributes. +## DNS provider (PowerDNS, RFC 2136) + +Without an IPAM authority whose DNS sync publishes names, Rollcall can write +its machines' A/AAAA records into the site's DNS itself. Names follow the +IPAM rule above (`.`, `-.`). + +```json +"dns": { + "provider": "powerdns", + "zone": "int.example.com", + "url": "http://dns.int.example.com:8081", + "api_key_file": "/etc/rollcall/pdns.key" +} +``` + +```json +"dns": { + "provider": "rfc2136", + "zone": "int.example.com", + "server": "ns1.int.example.com:53", + "tsig_name": "rollcall-key", + "tsig_algorithm": "hmac-sha256", + "tsig_secret_file": "/etc/rollcall/tsig.secret" +} +``` + +- **powerdns** uses the HTTP API. Rollcall's RRsets carry a comment with + account `rollcall`; RRsets without it are never changed or deleted. +- **rfc2136** is a TSIG-signed dynamic update, for BIND, Knot, PowerDNS, + Windows DNS and Infoblox. Next to each name Rollcall writes a marker + `_rollcall. TXT "heritage=rollcall"`. A change to an existing record + needs the marker as a prerequisite; a new record needs the name to be + free. The server checks both atomically, so a record someone else owns is + never overwritten. What Rollcall wrote is kept in the database, and each + sync queries those records. A record changed behind Rollcall's back is + repaired when the marker is still there. When someone else's record has + taken the name, it is reported as a conflict. + For BIND, grant the key the zone: `update-policy { grant rollcall-key zonesub ANY; };`. + +A sync runs on every allocation and release and every `sync_interval` +(default 5m), with `ttl` defaulting to 300. + +``` +rollcall dns # provider, last sync, conflicts +rollcall dns sync -dry # what would change +rollcall dns sync +``` + +Conflicts and errors appear there, in the log and in `rollcall doctor`. + ## Lab `lab/` documents a Proxmox SDN test setup: an isolated vnet, a PXE server VM diff --git a/cmd/rollcall/client.go b/cmd/rollcall/client.go index c323f90..3a89cbb 100644 --- a/cmd/rollcall/client.go +++ b/cmd/rollcall/client.go @@ -276,7 +276,7 @@ func runClient(cmd string, args []string) error { return c.locationCmd(args) case "bmc": return c.bmcCmd(args) - case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam": + case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns": return c.netCmd(cmd, args) case "admin-cert": return c.adminCertCmd(args) diff --git a/cmd/rollcall/config.go b/cmd/rollcall/config.go index ddf244e..c869688 100644 --- a/cmd/rollcall/config.go +++ b/cmd/rollcall/config.go @@ -51,6 +51,9 @@ type Config struct { // servers can take 4-5 minutes to POST, longer when many boot together) JobStartTimeout string `json:"job_start_timeout"` + // DNS records for machines written directly (PowerDNS API or RFC 2136) + DNS DNSConfig `json:"dns"` + // external IPAM authority (NetBox; Infoblox at sites that run it) IPAM IPAMConfig `json:"ipam"` } diff --git a/cmd/rollcall/dnsprov.go b/cmd/rollcall/dnsprov.go new file mode 100644 index 0000000..a5c0f20 --- /dev/null +++ b/cmd/rollcall/dnsprov.go @@ -0,0 +1,561 @@ +package main + +// DNS provider: Rollcall writes its machines' A/AAAA records into the site's +// DNS itself - for sites without an IPAM authority whose DNS sync would do it. +// +// - powerdns: the HTTP API; Rollcall's RRsets carry a comment with +// account "rollcall" and only those are changed or deleted. +// - rfc2136: dynamic update with TSIG (BIND, Infoblox, Windows DNS, +// PowerDNS, Knot). Next to each name Rollcall writes a marker TXT at +// _rollcall.; every change of an existing record requires that +// marker (a prerequisite), a new record requires the name to be free, +// so a record someone else owns is never overwritten. What Rollcall +// wrote is kept in dns_records, since a zone cannot be listed; each +// sync queries those records, so changes made behind its back are seen. +// +// Names follow the IPAM rule: . for a machine's main address, +// -. elsewhere. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/miekg/dns" +) + +type DNSConfig struct { + Provider string `json:"provider"` // "" | powerdns | rfc2136 + Zone string `json:"zone"` // e.g. int.example.com + TTL int `json:"ttl"` // default 300 + SyncInterval string `json:"sync_interval"` + // powerdns + URL string `json:"url"` // http://dns:8081 (server "localhost") + APIKeyFile string `json:"api_key_file"` // X-API-Key + // rfc2136 + Server string `json:"server"` // host:53 + TSIGName string `json:"tsig_name"` // key name + TSIGAlgorithm string `json:"tsig_algorithm"` // hmac-sha256 (default), hmac-sha512 ... + TSIGSecretFile string `json:"tsig_secret_file"` // base64 secret +} + +const dnsMarkerPrefix = "_rollcall." +const dnsMarkerValue = "heritage=rollcall" + +type dnsRecord struct { + Name string `json:"name"` // fqdn with the trailing dot + Type string `json:"type"` // A | AAAA + Values []string `json:"values"` +} + +func (r dnsRecord) key() string { return r.Name + " " + r.Type } + +type dnsReport struct { + Provider string `json:"provider"` + Zone string `json:"zone"` + At time.Time `json:"at"` + DryRun bool `json:"dry_run,omitempty"` + Upserted []string `json:"upserted"` + Deleted []string `json:"deleted"` + Conflicts []string `json:"conflicts"` + Error string `json:"error,omitempty"` +} + +type dnsProvider interface { + Name() string + // Owned: Rollcall's records in the zone, and the names+types someone else holds. + Owned(ctx context.Context) (owned map[string]dnsRecord, foreign map[string]bool, err error) + // Upsert writes one RRset; conflict when the provider refuses because it is not ours. + Upsert(ctx context.Context, r dnsRecord, existed bool) (conflict bool, err error) + Delete(ctx context.Context, r dnsRecord) error +} + +type dnsState struct { + mu sync.Mutex + kick chan struct{} + last dnsReport +} + +func (c DNSConfig) zone() string { return dns.Fqdn(strings.ToLower(strings.TrimSuffix(c.Zone, "."))) } +func (c DNSConfig) ttl() int { + if c.TTL > 0 { + return c.TTL + } + return 300 +} + +func newDNSProvider(c DNSConfig, st *Store) (dnsProvider, error) { + if c.Provider == "" { + return nil, nil + } + if c.Zone == "" { + return nil, errors.New("dns.zone is required") + } + switch c.Provider { + case "powerdns": + key, err := os.ReadFile(c.APIKeyFile) + if err != nil { + return nil, fmt.Errorf("dns.api_key_file: %w", err) + } + if c.URL == "" { + return nil, errors.New("dns.url is required") + } + return &pdnsProvider{base: strings.TrimRight(c.URL, "/") + "/api/v1/servers/localhost", key: strings.TrimSpace(string(key)), + zone: c.zone(), ttl: c.ttl(), hc: &http.Client{Timeout: 30 * time.Second}}, nil + case "rfc2136": + sec, err := os.ReadFile(c.TSIGSecretFile) + if err != nil { + return nil, fmt.Errorf("dns.tsig_secret_file: %w", err) + } + alg := strings.ToLower(orDefault(c.TSIGAlgorithm, "hmac-sha256")) + if !strings.HasSuffix(alg, ".") { + alg += "." + } + if c.Server == "" || c.TSIGName == "" { + return nil, errors.New("dns.server and dns.tsig_name are required") + } + return &rfc2136Provider{server: c.Server, zone: c.zone(), ttl: c.ttl(), keyName: dns.Fqdn(c.TSIGName), alg: alg, + secret: strings.TrimSpace(string(sec)), store: st}, nil + } + return nil, fmt.Errorf("dns.provider %q: powerdns or rfc2136", c.Provider) +} + +// ---------------------------------------------------------------- App side + +func (a *App) startDNS() error { + p, err := newDNSProvider(a.cfg.DNS, a.store) + if err != nil || p == nil { + return err + } + a.dnsp = p + a.dnsSt = &dnsState{kick: make(chan struct{}, 1)} + every := 5 * time.Minute + if d, err := time.ParseDuration(a.cfg.DNS.SyncInterval); err == nil && d >= 10*time.Second { + every = d + } + go func() { + a.kickDNS() + for { + select { + case <-a.dnsSt.kick: + case <-time.After(every): + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + rep := a.reconcileDNS(ctx, false) + cancel() + if rep.Error != "" { + log.Printf("dns %s: %s", p.Name(), rep.Error) + } else if n := len(rep.Upserted) + len(rep.Deleted) + len(rep.Conflicts); n > 0 { + log.Printf("dns %s: wrote %d, deleted %d, conflicts %d %v", p.Name(), len(rep.Upserted), len(rep.Deleted), len(rep.Conflicts), rep.Conflicts) + } + } + }() + log.Printf("dns: %s for zone %s", p.Name(), a.cfg.DNS.zone()) + return nil +} + +func (a *App) kickDNS() { + if a.dnsSt == nil { + return + } + select { + case a.dnsSt.kick <- struct{}{}: + default: + } +} + +// desiredDNS groups Rollcall's addresses into RRsets inside the zone. +func desiredDNS(local []IPAddr, subnets []Subnet, zone string) map[string]dnsRecord { + want := map[string]dnsRecord{} + z := strings.TrimSuffix(zone, ".") + for _, x := range local { + name := dnsNameIn(x, subnetByRef(subnets, x.Subnet), z) + if name == "" { + continue + } + fq := dns.Fqdn(name) + if !dns.IsSubDomain(dns.Fqdn(z), fq) { + continue + } + t := "A" + if strings.Contains(x.IP, ":") { + t = "AAAA" + } + r := want[fq+" "+t] + r.Name, r.Type = fq, t + if !hasTag(r.Values, x.IP) { + r.Values = append(r.Values, x.IP) + } + sort.Strings(r.Values) + want[r.key()] = r + } + return want +} + +func (a *App) reconcileDNS(ctx context.Context, dry bool) dnsReport { + rep := dnsReport{At: time.Now().UTC(), DryRun: dry, Upserted: []string{}, Deleted: []string{}, Conflicts: []string{}} + if a.dnsp == nil { + rep.Error = "no DNS provider configured (dns.provider in config.json)" + return rep + } + rep.Provider, rep.Zone = a.dnsp.Name(), a.cfg.DNS.zone() + a.dnsSt.mu.Lock() + defer a.dnsSt.mu.Unlock() + defer func() { + if !dry { + a.dnsSt.last = rep + } + }() + local, err := a.store.IPs(ctx, "", "") + if err != nil { + rep.Error = err.Error() + return rep + } + a.reconcileDNSWith(ctx, desiredDNS(local, a.subnetsSnapshot(), a.cfg.DNS.zone()), &rep) + return rep +} + +func (a *App) reconcileDNSWith(ctx context.Context, want map[string]dnsRecord, rep *dnsReport) { + owned, foreign, err := a.dnsp.Owned(ctx) + if err != nil { + rep.Error = err.Error() + return + } + keys := make([]string, 0, len(want)) + for k := range want { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + w := want[k] + o, mine := owned[k] + if mine && strings.Join(o.Values, ",") == strings.Join(w.Values, ",") { + continue + } + if !mine && foreign[k] { + rep.Conflicts = append(rep.Conflicts, strings.TrimSuffix(k, " A")+" exists and is not Rollcall's") + continue + } + if rep.DryRun { + rep.Upserted = append(rep.Upserted, k) + continue + } + conflict, err := a.dnsp.Upsert(ctx, w, mine) + switch { + case err != nil: + rep.Error = fmt.Sprintf("%s: %v", k, err) + return + case conflict: + rep.Conflicts = append(rep.Conflicts, k+" exists and is not Rollcall's") + default: + rep.Upserted = append(rep.Upserted, k) + } + } + var gone []string + for k := range owned { + if _, ok := want[k]; !ok { + gone = append(gone, k) + } + } + sort.Strings(gone) + for _, k := range gone { + rep.Deleted = append(rep.Deleted, k) + if !rep.DryRun { + if err := a.dnsp.Delete(ctx, owned[k]); err != nil { + rep.Error = fmt.Sprintf("delete %s: %v", k, err) + return + } + } + } +} + +func (a *App) handleDNS(w http.ResponseWriter, r *http.Request) { + if a.dnsp == nil { + writeJSON(w, 200, obj{"provider": "", "comment": "no DNS provider (an IPAM authority's DNS sync, or dnsmasq host files only)"}) + return + } + a.dnsSt.mu.Lock() + last := a.dnsSt.last + a.dnsSt.mu.Unlock() + writeJSON(w, 200, obj{"provider": a.dnsp.Name(), "zone": a.cfg.DNS.zone(), "last_sync": last}) +} + +func (a *App) handleDNSSync(w http.ResponseWriter, r *http.Request) { + rep := a.reconcileDNS(r.Context(), r.URL.Query().Get("dry") != "") + code := 200 + if rep.Error != "" { + code = 502 + if a.dnsp == nil { + code = 400 + } + } + writeJSON(w, code, rep) +} + +// ---------------------------------------------------------------- PowerDNS + +type pdnsProvider struct { + base, key, zone string + ttl int + hc *http.Client +} + +func (p *pdnsProvider) Name() string { return "powerdns" } + +func (p *pdnsProvider) do(ctx context.Context, method, path string, body, out any) error { + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, p.base+path, rd) + if err != nil { + return err + } + req.Header.Set("X-API-Key", p.key) + req.Header.Set("Content-Type", "application/json") + resp, err := p.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, trunc(strings.TrimSpace(string(b)), 300)) + } + if out != nil { + return json.Unmarshal(b, out) + } + return nil +} + +func (p *pdnsProvider) Owned(ctx context.Context) (map[string]dnsRecord, map[string]bool, error) { + var z struct { + RRsets []struct { + Name string `json:"name"` + Type string `json:"type"` + Records []struct{ Content string } `json:"records"` + Comments []struct{ Account string } `json:"comments"` + } `json:"rrsets"` + } + if err := p.do(ctx, "GET", "/zones/"+url.PathEscape(p.zone), nil, &z); err != nil { + return nil, nil, err + } + owned, foreign := map[string]dnsRecord{}, map[string]bool{} + for _, rr := range z.RRsets { + if rr.Type != "A" && rr.Type != "AAAA" { + continue + } + r := dnsRecord{Name: strings.ToLower(rr.Name), Type: rr.Type} + for _, x := range rr.Records { + r.Values = append(r.Values, x.Content) + } + sort.Strings(r.Values) + mine := false + for _, c := range rr.Comments { + mine = mine || c.Account == "rollcall" + } + if mine { + owned[r.key()] = r + } else { + foreign[r.key()] = true + } + } + return owned, foreign, nil +} + +func (p *pdnsProvider) Upsert(ctx context.Context, r dnsRecord, _ bool) (bool, error) { + recs := []obj{} + for _, v := range r.Values { + recs = append(recs, obj{"content": v, "disabled": false}) + } + return false, p.do(ctx, "PATCH", "/zones/"+url.PathEscape(p.zone), obj{"rrsets": []obj{{"name": r.Name, "type": r.Type, "ttl": p.ttl, + "changetype": "REPLACE", "records": recs, "comments": []obj{{"content": "rollcall", "account": "rollcall"}}}}}, nil) +} + +func (p *pdnsProvider) Delete(ctx context.Context, r dnsRecord) error { + return p.do(ctx, "PATCH", "/zones/"+url.PathEscape(p.zone), obj{"rrsets": []obj{{"name": r.Name, "type": r.Type, "changetype": "DELETE"}}}, nil) +} + +// ---------------------------------------------------------------- RFC 2136 + +const dnsRecordsSchema = ` +CREATE TABLE IF NOT EXISTS dns_records ( + zone text NOT NULL, + name text NOT NULL, + type text NOT NULL, + vals jsonb NOT NULL, + updated timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (zone, name, type) +); +` + +type rfc2136Provider struct { + server, zone, keyName, alg, secret string + ttl int + store *Store +} + +func (p *rfc2136Provider) Name() string { return "rfc2136" } + +func (p *rfc2136Provider) Owned(ctx context.Context) (map[string]dnsRecord, map[string]bool, error) { + rows, err := p.store.pool.Query(ctx, `SELECT name, type, vals FROM dns_records WHERE zone=$1`, p.zone) + if err != nil { + return nil, nil, err + } + defer rows.Close() + owned := map[string]dnsRecord{} + for rows.Next() { + var r dnsRecord + var vals []byte + if err := rows.Scan(&r.Name, &r.Type, &vals); err != nil { + return nil, nil, err + } + _ = json.Unmarshal(vals, &r.Values) + owned[r.key()] = r + } + if err := rows.Err(); err != nil { + return nil, nil, err + } + rows.Close() + // the table says what we wrote; the server says what is there now. A record + // changed or a marker removed behind our back shows up as a difference, and + // the next Upsert either repairs it or finds someone else's record (conflict). + for k, r := range owned { + live, err := p.lookup(r.Name, r.Type) + if err != nil { + return nil, nil, err + } + marker, err := p.lookup(dnsMarkerPrefix+r.Name, "TXT") + if err != nil { + return nil, nil, err + } + if !slices.Contains(marker, strconv.Quote(dnsMarkerValue)) { + live = nil + } + r.Values = live + owned[k] = r + } + return owned, map[string]bool{}, nil // foreign names show up as refused updates +} + +// lookup asks the primary for one RRset; it returns the values, sorted, nil if none. +func (p *rfc2136Provider) lookup(name, typ string) ([]string, error) { + m := new(dns.Msg) + m.SetQuestion(name, dns.StringToType[typ]) + m.SetTsig(p.keyName, p.alg, 300, time.Now().Unix()) + c := &dns.Client{Net: "tcp", Timeout: 10 * time.Second, TsigSecret: map[string]string{p.keyName: p.secret}} + r, _, err := c.Exchange(m, p.server) + if err != nil { + return nil, fmt.Errorf("query %s %s: %w", name, typ, err) + } + if r.Rcode != dns.RcodeSuccess && r.Rcode != dns.RcodeNameError { + return nil, fmt.Errorf("query %s %s: %s", name, typ, dns.RcodeToString[r.Rcode]) + } + var out []string + for _, rr := range r.Answer { + if rr.Header().Rrtype == dns.StringToType[typ] && strings.EqualFold(rr.Header().Name, name) { + out = append(out, strings.TrimPrefix(rr.String(), rr.Header().String())) + } + } + sort.Strings(out) + return out, nil +} + +func (p *rfc2136Provider) marker(name string) dns.RR { + rr, _ := dns.NewRR(fmt.Sprintf("%s%s %d IN TXT %q", dnsMarkerPrefix, name, p.ttl, dnsMarkerValue)) + return rr +} + +func (p *rfc2136Provider) rrs(r dnsRecord) []dns.RR { + var out []dns.RR + for _, v := range r.Values { + if rr, err := dns.NewRR(fmt.Sprintf("%s %d IN %s %s", r.Name, p.ttl, r.Type, v)); err == nil { + out = append(out, rr) + } + } + return out +} + +// send signs and sends an update; it returns the response code. +func (p *rfc2136Provider) send(m *dns.Msg) (int, error) { + m.SetTsig(p.keyName, p.alg, 300, time.Now().Unix()) + c := &dns.Client{Net: "tcp", Timeout: 10 * time.Second, TsigSecret: map[string]string{p.keyName: p.secret}} + r, _, err := c.Exchange(m, p.server) + if err != nil { + return 0, err + } + return r.Rcode, nil +} + +func (p *rfc2136Provider) Upsert(ctx context.Context, r dnsRecord, existed bool) (bool, error) { + conflict, err := p.upsert(ctx, r, existed) + if conflict && existed && err == nil { + // our marker is gone: if the name is free now, write it again as new; + // if someone else's record is there, that is a real conflict + return p.upsert(ctx, r, false) + } + return conflict, err +} + +func (p *rfc2136Provider) upsert(ctx context.Context, r dnsRecord, existed bool) (bool, error) { + m := new(dns.Msg) + m.SetUpdate(p.zone) + set := &dns.ANY{Hdr: dns.RR_Header{Name: r.Name, Rrtype: dns.StringToType[r.Type], Class: dns.ClassINET}} + if existed { + m.Used([]dns.RR{p.marker(r.Name)}) // our marker must still be there + m.RemoveRRset([]dns.RR{set}) + } else { + m.RRsetNotUsed([]dns.RR{set}) // and the name must be free of this type + } + m.Insert(p.rrs(r)) + m.Insert([]dns.RR{p.marker(r.Name)}) + rc, err := p.send(m) + if err != nil { + return false, err + } + switch rc { + case dns.RcodeSuccess: + vals, _ := json.Marshal(r.Values) + _, err := p.store.pool.Exec(ctx, `INSERT INTO dns_records(zone, name, type, vals) VALUES ($1,$2,$3,$4) + ON CONFLICT (zone, name, type) DO UPDATE SET vals=$4, updated=now()`, p.zone, r.Name, r.Type, vals) + return false, err + case dns.RcodeYXRrset, dns.RcodeNXRrset, dns.RcodeYXDomain, dns.RcodeNameError: + return true, nil + } + return false, fmt.Errorf("update refused: %s", dns.RcodeToString[rc]) +} + +func (p *rfc2136Provider) Delete(ctx context.Context, r dnsRecord) error { + m := new(dns.Msg) + m.SetUpdate(p.zone) + m.Used([]dns.RR{p.marker(r.Name)}) + m.RemoveRRset([]dns.RR{&dns.ANY{Hdr: dns.RR_Header{Name: r.Name, Rrtype: dns.StringToType[r.Type], Class: dns.ClassINET}}}) + var others int + _ = p.store.pool.QueryRow(ctx, `SELECT count(*) FROM dns_records WHERE zone=$1 AND name=$2 AND type<>$3`, p.zone, r.Name, r.Type).Scan(&others) + if others == 0 { // the last of our types at this name: the marker goes too + m.RemoveRRset([]dns.RR{&dns.ANY{Hdr: dns.RR_Header{Name: dnsMarkerPrefix + r.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET}}}) + } + rc, err := p.send(m) + if err != nil { + return err + } + if rc != dns.RcodeSuccess && rc != dns.RcodeNXRrset { // NXRRSET: our marker is gone, it is not ours any more + return fmt.Errorf("update refused: %s", dns.RcodeToString[rc]) + } + _, err = p.store.pool.Exec(ctx, `DELETE FROM dns_records WHERE zone=$1 AND name=$2 AND type=$3`, p.zone, r.Name, r.Type) + return err +} diff --git a/cmd/rollcall/dnsprov_test.go b/cmd/rollcall/dnsprov_test.go new file mode 100644 index 0000000..866195c --- /dev/null +++ b/cmd/rollcall/dnsprov_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "testing" +) + +func TestDesiredDNS(t *testing.T) { + subnets := []Subnet{{CIDR: "10.0.0.0/24", Purpose: "provisioning"}, {CIDR: "10.20.0.0/24", Purpose: "storage"}, {CIDR: "fd00::/64", Purpose: "provisioning"}} + local := []IPAddr{ + {IP: "10.0.0.50", Subnet: "10.0.0.0/24", MachineID: "m1", Hostname: "node01", Iface: "eno1", Kind: "auto"}, + {IP: "fd00::50", Subnet: "fd00::/64", MachineID: "m1", Hostname: "node01", Iface: "eno1", Kind: "auto"}, + {IP: "10.20.0.50", Subnet: "10.20.0.0/24", MachineID: "m1", Hostname: "node01", Iface: "bond0.200", Kind: "auto"}, + {IP: "10.0.0.5", Subnet: "10.0.0.0/24", Hostname: "core switch", Kind: "reserved"}, // not a DNS label: skipped + {IP: "10.0.0.6", Subnet: "10.0.0.0/24", Hostname: "vip", Kind: "reserved"}, + } + got := desiredDNS(local, subnets, "int.example.") + var keys []string + for k, r := range got { + keys = append(keys, k+"="+strings.Join(r.Values, ",")) + } + sort.Strings(keys) + want := "node01-bond0-200.int.example. A=10.20.0.50 node01.int.example. A=10.0.0.50 node01.int.example. AAAA=fd00::50 vip.int.example. A=10.0.0.6" + if strings.Join(keys, " ") != want { + t.Errorf("desired:\n got %s\nwant %s", strings.Join(keys, " "), want) + } +} + +// fakePDNS: a zone held in memory behind the PowerDNS zone API. +type fakePDNS struct { + mu sync.Mutex + rrsets map[string]map[string]any // name type -> rrset +} + +func (f *fakePDNS) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Header.Get("X-API-Key") != "k" { + http.Error(w, "auth", 401) + return + } + switch r.Method { + case "GET": + out := []any{} + for _, v := range f.rrsets { + out = append(out, v) + } + _ = json.NewEncoder(w).Encode(map[string]any{"rrsets": out}) + case "PATCH": + var b struct{ RRsets []map[string]any } + _ = json.NewDecoder(r.Body).Decode(&b) + for _, rr := range b.RRsets { + k := rr["name"].(string) + " " + rr["type"].(string) + if rr["changetype"] == "DELETE" { + delete(f.rrsets, k) + } else { + f.rrsets[k] = rr + } + } + w.WriteHeader(204) + } +} + +func rrset(name, typ string, account string, vals ...string) map[string]any { + recs := []any{} + for _, v := range vals { + recs = append(recs, map[string]any{"content": v}) + } + c := []any{} + if account != "" { + c = append(c, map[string]any{"account": account}) + } + return map[string]any{"name": name, "type": typ, "records": recs, "comments": c} +} + +func TestDNSReconcilePowerDNS(t *testing.T) { + f := &fakePDNS{rrsets: map[string]map[string]any{ + "printer.int.example. A": rrset("printer.int.example.", "A", "", "10.0.0.9"), // someone else's + "node02.int.example. A": rrset("node02.int.example.", "A", "", "10.0.0.99"), // someone else's, same name as ours + "node01.int.example. A": rrset("node01.int.example.", "A", "rollcall", "10.0.0.40"), // ours, outdated + "gone.int.example. A": rrset("gone.int.example.", "A", "rollcall", "10.0.0.41"), // ours, released + "other.int.example. A": rrset("other.int.example.", "A", "netbox", "10.0.0.42"), // another tool's + }} + srv := httptest.NewServer(f) + defer srv.Close() + keyFile := filepath.Join(t.TempDir(), "key") + _ = os.WriteFile(keyFile, []byte("k\n"), 0o600) + p, err := newDNSProvider(DNSConfig{Provider: "powerdns", Zone: "int.example", URL: srv.URL, APIKeyFile: keyFile}, nil) + if err != nil { + t.Fatal(err) + } + a := &App{cfg: &Config{}, dnsp: p, dnsSt: &dnsState{kick: make(chan struct{}, 1)}} + want := map[string]dnsRecord{ + "node01.int.example. A": {Name: "node01.int.example.", Type: "A", Values: []string{"10.0.0.50"}}, + "node02.int.example. A": {Name: "node02.int.example.", Type: "A", Values: []string{"10.0.0.51"}}, + "node03.int.example. A": {Name: "node03.int.example.", Type: "A", Values: []string{"10.0.0.52"}}, + } + rep := dnsReport{} + a.reconcileDNSWith(context.Background(), want, &rep) + if rep.Error != "" { + t.Fatal(rep.Error) + } + if strings.Join(rep.Upserted, ",") != "node01.int.example. A,node03.int.example. A" || strings.Join(rep.Deleted, ",") != "gone.int.example. A" || len(rep.Conflicts) != 1 { + t.Errorf("report: %+v", rep) + } + vals := func(k string) string { + rr, ok := f.rrsets[k] + if !ok { + return "-" + } + var v []string + for _, r := range rr["records"].([]any) { + v = append(v, r.(map[string]any)["content"].(string)) + } + return strings.Join(v, ",") + } + for k, w := range map[string]string{"printer.int.example. A": "10.0.0.9", "node02.int.example. A": "10.0.0.99", "node01.int.example. A": "10.0.0.50", + "node03.int.example. A": "10.0.0.52", "gone.int.example. A": "-", "other.int.example. A": "10.0.0.42"} { + if got := vals(k); got != w { + t.Errorf("%s: %s, want %s", k, got, w) + } + } + again := dnsReport{} + a.reconcileDNSWith(context.Background(), want, &again) + if len(again.Upserted)+len(again.Deleted) != 0 { + t.Errorf("second pass changed things: %+v", again) + } +} diff --git a/cmd/rollcall/doctor.go b/cmd/rollcall/doctor.go index 6485862..a6587e1 100644 --- a/cmd/rollcall/doctor.go +++ b/cmd/rollcall/doctor.go @@ -283,6 +283,18 @@ func doctorCmd(args []string) error { d.ok("os images", fmt.Sprintf("%d in the catalog", len(oss))) } } + var dnsSt map[string]any + if err := c.do("GET", "/api/v1/dns", nil, &dnsSt); err == nil && dnsSt["provider"] != "" && dnsSt["provider"] != nil { + last, _ := dnsSt["last_sync"].(map[string]any) + switch e, _ := last["error"].(string); { + case e != "": + d.fail("dns provider", e, "check dns.* in config.json; rollcall dns sync") + case last["conflicts"] != nil && len(last["conflicts"].([]any)) > 0: + d.warn("dns provider", fmt.Sprintf("%v: %d names held by someone else", dnsSt["provider"], len(last["conflicts"].([]any))), "rollcall dns: those records are left alone") + default: + d.ok("dns provider", fmt.Sprintf("%v, zone %v, in sync", dnsSt["provider"], dnsSt["zone"])) + } + } var ipam map[string]any if err := c.do("GET", "/api/v1/ipam", nil, &ipam); err == nil && ipam["provider"] != "" && ipam["provider"] != nil { last, _ := ipam["last_sync"].(map[string]any) diff --git a/cmd/rollcall/go.mod b/cmd/rollcall/go.mod index 916c913..4a9795c 100644 --- a/cmd/rollcall/go.mod +++ b/cmd/rollcall/go.mod @@ -1,6 +1,6 @@ module rollcall -go 1.24.2 +go 1.25.0 require ( github.com/charmbracelet/bubbletea v1.3.10 @@ -9,7 +9,8 @@ require ( github.com/jackc/pgx/v5 v5.7.5 github.com/klauspost/compress v1.18.0 github.com/mattn/go-isatty v0.0.24 - golang.org/x/crypto v0.37.0 + github.com/miekg/dns v1.1.73 + golang.org/x/crypto v0.54.0 software.sslmate.com/src/go-pkcs12 v0.7.4 ) @@ -33,7 +34,8 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect ) diff --git a/cmd/rollcall/go.sum b/cmd/rollcall/go.sum index 47a89bb..1a03e3f 100644 --- a/cmd/rollcall/go.sum +++ b/cmd/rollcall/go.sum @@ -41,6 +41,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -58,19 +60,21 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/cmd/rollcall/ipam.go b/cmd/rollcall/ipam.go index 8b613dd..3565bd4 100644 --- a/cmd/rollcall/ipam.go +++ b/cmd/rollcall/ipam.go @@ -152,8 +152,11 @@ func (a *App) extFor(x IPAddr, sn *Subnet) extAddr { // extDNSName: the machine's main address gets .; addresses on // other subnets (storage, IB, a tagged VLAN) get -.. A // subnet's domain replaces the zone when it lies inside it. -func (a *App) extDNSName(x IPAddr, sn *Subnet) string { - zone := strings.TrimSuffix(strings.ToLower(a.cfg.IPAM.DNSZone), ".") +func (a *App) extDNSName(x IPAddr, sn *Subnet) string { return dnsNameIn(x, sn, a.cfg.IPAM.DNSZone) } + +// dnsNameIn is the naming rule shared by the IPAM authority and the DNS provider. +func dnsNameIn(x IPAddr, sn *Subnet, zone string) string { + zone = strings.TrimSuffix(strings.ToLower(zone), ".") if sn != nil && sn.Domain != "" { // a subnet domain counts only inside the authority's zone; "pxe.local" stays local d := strings.TrimSuffix(strings.ToLower(sn.Domain), ".") if zone == "" || d == zone || strings.HasSuffix(d, "."+zone) { @@ -194,6 +197,9 @@ func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) { } } ok, err := a.store.ClaimIP(ctx, x) + if ok { + a.kickDNS() + } if a.ipam != nil && (err != nil || !ok) { a.kickIPAM() // the record we just made is reconciled away or re-owned } @@ -201,6 +207,7 @@ func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) { } func (a *App) kickIPAM() { + a.kickDNS() // the same address changes move DNS records if a.ipamSt == nil { return } diff --git a/cmd/rollcall/main.go b/cmd/rollcall/main.go index 094829a..bca9302 100644 --- a/cmd/rollcall/main.go +++ b/cmd/rollcall/main.go @@ -65,6 +65,7 @@ func usage() { rollcall range add CIDR dynamic|reserved|static START END | rm ID rollcall ip list [-subnet CIDR] | reserve IP [-name N] | release IP rollcall admin-cert request NAME | list | revoke NAME|SERIAL admin client certificate (browser/CLI without the token) + rollcall dns [status] | sync [-dry] DNS records written directly (PowerDNS API / RFC 2136) rollcall ipam [status] | sync [-dry] | prefixes | import external IPAM (NetBox): state, reconcile, delegated prefixes rollcall layout list | show NAME | add NAME FILE.json | rm NAME rollcall net show ID | set ID [-layout L] [-rack R] [-static IFACE=IP] [-unstatic IP] @@ -141,7 +142,7 @@ func main() { case "list", "show", "events", "power", "bootdev", "recommission", "delete", "images", "profile", "assign", "unassign", "overlay", "render", "deploy", "release", "jobs", "job", "tag", "tags", "location", - "bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "admin-cert": + "bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns", "admin-cert": if err := runClient(cmd, args); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) diff --git a/cmd/rollcall/netapi.go b/cmd/rollcall/netapi.go index f09efae..51cb9fb 100644 --- a/cmd/rollcall/netapi.go +++ b/cmd/rollcall/netapi.go @@ -803,6 +803,8 @@ func (c *client) netCmd(cmd string, args []string) error { os.Stdout.Write(data) case "ipam": return c.ipamCmd(sub, args) + case "dns": + return c.dnsCmd(sub, args) } return nil } @@ -984,3 +986,62 @@ func printNet(d obj) { b, _ := json.MarshalIndent(d["network_config"], " ", " ") fmt.Printf(" network-config:\n %s\n", b) } + +func (c *client) dnsCmd(sub string, args []string) error { + show := func(rep dnsReport) { + for _, x := range rep.Upserted { + fmt.Printf("%-9s %s\n", "write", x) + } + for _, x := range rep.Deleted { + fmt.Printf("%-9s %s\n", "delete", x) + } + for _, x := range rep.Conflicts { + fmt.Printf("%-9s %s\n", "conflict", x) + } + dry := "" + if rep.DryRun { + dry = " (dry run, nothing changed)" + } + fmt.Printf("%s %s: %d written, %d deleted, %d conflicts%s\n", orDash(rep.Provider), rep.Zone, len(rep.Upserted), len(rep.Deleted), len(rep.Conflicts), dry) + if rep.Error != "" { + fmt.Println("error:", rep.Error) + } + } + switch sub { + case "", "status": + var st struct { + Provider string `json:"provider"` + Zone string `json:"zone"` + Last dnsReport `json:"last_sync"` + } + if err := c.do("GET", "/api/v1/dns", nil, &st); err != nil { + return err + } + if st.Provider == "" { + fmt.Println("no DNS provider (dns.provider in config.json: powerdns or rfc2136)") + return nil + } + fmt.Printf("provider %s, zone %s\n", st.Provider, st.Zone) + if !st.Last.At.IsZero() { + fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05")) + show(st.Last) + } + case "sync": + fs := newFlags("dns sync") + dry := fs.Bool("dry", false, "show what would change") + _ = fs.Parse(args[min(1, len(args)):]) + q := "" + if *dry { + q = "?dry=1" + } + var rep dnsReport + err := c.do("POST", "/api/v1/dns/sync"+q, nil, &rep) + if rep.Provider != "" || rep.Error != "" { + show(rep) + } + return err + default: + usage() + } + return nil +} diff --git a/cmd/rollcall/server.go b/cmd/rollcall/server.go index ddf6ed2..321aa5d 100644 --- a/cmd/rollcall/server.go +++ b/cmd/rollcall/server.go @@ -39,6 +39,9 @@ type App struct { ipamSt *ipamState adminCC adminCertCache + + dnsp dnsProvider // nil = no direct DNS writes + dnsSt *dnsState } type apiError struct { @@ -233,6 +236,8 @@ func (a *App) routes() http.Handler { mux.HandleFunc("POST /api/v1/ips", a.adminAuth(a.handleIPs)) mux.HandleFunc("DELETE /api/v1/ips/{ip}", a.adminAuth(a.handleIPDelete)) mux.HandleFunc("GET /api/v1/ipam", a.adminAuth(a.handleIPAM)) + mux.HandleFunc("GET /api/v1/dns", a.adminAuth(a.handleDNS)) + mux.HandleFunc("POST /api/v1/dns/sync", a.adminAuth(a.handleDNSSync)) mux.HandleFunc("GET /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts)) mux.HandleFunc("POST /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts)) mux.HandleFunc("DELETE /api/v1/admin-certs/{ref}", a.adminAuth(a.handleAdminCertRevoke)) @@ -725,6 +730,9 @@ func serve(cfgPath string) error { if err := app.startIPAM(); err != nil { return fmt.Errorf("ipam: %w", err) } + if err := app.startDNS(); err != nil { + return fmt.Errorf("dns: %w", err) + } go app.jobWatchdog() handler := app.routes() if cfg.TLSListen != "" { diff --git a/cmd/rollcall/store.go b/cmd/rollcall/store.go index 02d4029..4de16b1 100644 --- a/cmd/rollcall/store.go +++ b/cmd/rollcall/store.go @@ -236,6 +236,9 @@ func openStore(ctx context.Context, url string) (*Store, error) { if _, err := pool.Exec(ctx, adminCertSchema); err != nil { return nil, fmt.Errorf("schema: %w", err) } + if _, err := pool.Exec(ctx, dnsRecordsSchema); err != nil { + return nil, fmt.Errorf("schema: %w", err) + } return &Store{pool: pool}, nil }