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.<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:
1 parent
16f76955ac
commit
07be25ac92
15 files changed
+873
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user