DNS provider: PowerDNS API and RFC 2136 with TSIG
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 1m34s

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.<name> 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 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 12:22:51 +01:00
1 parent 16f76955ac
commit 07be25ac92
15 files changed
+873 -19

No files matched your search

+4
View File
@@ -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.<name>` 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
+2
View File
@@ -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.
+50
View File
@@ -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 (`<host>.<zone>`, `<host>-<iface>.<zone>`).
```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.<name> 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
+1 -1
View File
@@ -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)
+3
View File
@@ -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"`
}
+561
View File
@@ -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.<name>; 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: <host>.<zone> for a machine's main address,
// <host>-<iface>.<zone> 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
}
+136
View File
@@ -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)
}
}
+12
View File
@@ -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)
+7 -5
View File
@@ -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
)
+14 -10
View File
@@ -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=
+9 -2
View File
@@ -152,8 +152,11 @@ func (a *App) extFor(x IPAddr, sn *Subnet) extAddr {
// extDNSName: the machine's main address gets <host>.<zone>; addresses on
// other subnets (storage, IB, a tagged VLAN) get <host>-<iface>.<zone>. 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
}
+2 -1
View File
@@ -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)
+61
View File
@@ -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
}
+8
View File
@@ -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 != "" {
+3
View File
@@ -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
}