dns: introduce DNS Manager and BIND config writer #24
Merged
amery
merged 8 commits from pr-amery-dns
into main
10 months ago
10 changed files with 656 additions and 1 deletions
@ -0,0 +1,82 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"os" |
||||||
|
|
||||||
|
"github.com/spf13/cobra" |
||||||
|
|
||||||
|
"git.jpi.io/amery/jpictl/pkg/cluster" |
||||||
|
"git.jpi.io/amery/jpictl/pkg/dns" |
||||||
|
) |
||||||
|
|
||||||
|
func newDNSManager(m *cluster.Cluster) (*dns.Manager, error) { |
||||||
|
ctx := context.TODO() |
||||||
|
|
||||||
|
domain := m.Domain |
||||||
|
if m.Name != "" { |
||||||
|
domain = m.Name + "." + domain |
||||||
|
} |
||||||
|
|
||||||
|
mgr, err := dns.NewManager(dns.WithDomain(domain), dns.WithLogger(log)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
m.ForEachZone(func(z *cluster.Zone) bool { |
||||||
|
z.ForEachMachine(func(p *cluster.Machine) bool { |
||||||
|
err = mgr.AddHost(ctx, z.Name, p.ID, true, p.PublicAddresses...) |
||||||
|
return err != nil |
||||||
|
}) |
||||||
|
|
||||||
|
return err != nil |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
m.ForEachRegion(func(r *cluster.Region) bool { |
||||||
|
r.ForEachZone(func(z *cluster.Zone) bool { |
||||||
|
err = mgr.AddRegion(ctx, r.Name, z.Name) |
||||||
|
return err != nil |
||||||
|
}) |
||||||
|
|
||||||
|
return err != nil |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return mgr, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Command
|
||||||
|
var dnsCmd = &cobra.Command{ |
||||||
|
Use: "dns", |
||||||
|
} |
||||||
|
|
||||||
|
var dnsWriteCmd = &cobra.Command{ |
||||||
|
Use: "write", |
||||||
|
Short: "dns write generates public DNS records", |
||||||
|
PreRun: setVerbosity, |
||||||
|
RunE: func(_ *cobra.Command, _ []string) error { |
||||||
|
m, err := cfg.LoadZones(true) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
mgr, err := newDNSManager(m) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
_, err = mgr.WriteTo(os.Stdout) |
||||||
|
return err |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
func init() { |
||||||
|
rootCmd.AddCommand(dnsCmd) |
||||||
|
|
||||||
|
dnsCmd.AddCommand(dnsWriteCmd) |
||||||
|
} |
@ -0,0 +1,38 @@ |
|||||||
|
// Package dns manages DNS entries for the cluster
|
||||||
|
package dns |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"net/netip" |
||||||
|
) |
||||||
|
|
||||||
|
// Zone represents a set of hosts with high affinity
|
||||||
|
type Zone struct { |
||||||
|
Name string |
||||||
|
|
||||||
|
Hosts map[int]*Host |
||||||
|
} |
||||||
|
|
||||||
|
func (z *Zone) String() string { |
||||||
|
if z == nil { |
||||||
|
return "undetermined" |
||||||
|
} |
||||||
|
return z.Name |
||||||
|
} |
||||||
|
|
||||||
|
// Host represents a member of the cluster
|
||||||
|
type Host struct { |
||||||
|
zone *Zone |
||||||
|
|
||||||
|
ID int |
||||||
|
Active bool |
||||||
|
Addrs []netip.Addr |
||||||
|
} |
||||||
|
|
||||||
|
func (p *Host) String() string { |
||||||
|
if p == nil { |
||||||
|
return "undetermined" |
||||||
|
} |
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%v", p.zone, p.ID) |
||||||
|
} |
@ -0,0 +1,173 @@ |
|||||||
|
package dns |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"errors" |
||||||
|
"io/fs" |
||||||
|
"net/netip" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"darvaza.org/core" |
||||||
|
"darvaza.org/slog" |
||||||
|
"git.jpi.io/amery/jpictl/pkg/cluster" |
||||||
|
"golang.org/x/net/publicsuffix" |
||||||
|
) |
||||||
|
|
||||||
|
// Manager is a DNS Manager instance
|
||||||
|
type Manager struct { |
||||||
|
domain string |
||||||
|
suffix string |
||||||
|
zones map[string]*Zone |
||||||
|
regions map[string][]string |
||||||
|
|
||||||
|
p Provider |
||||||
|
l slog.Logger |
||||||
|
} |
||||||
|
|
||||||
|
// ManagerOption configures a Manager
|
||||||
|
type ManagerOption func(*Manager) error |
||||||
|
|
||||||
|
func newErrorManagerOption(err error, hint string) ManagerOption { |
||||||
|
return func(*Manager) error { |
||||||
|
return core.Wrap(err, hint) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// WithProvider attaches a libdns Provider to the Manager
|
||||||
|
func WithProvider(p Provider) ManagerOption { |
||||||
|
var err error |
||||||
|
|
||||||
|
if p == nil { |
||||||
|
p, err = DefaultDNSProvider() |
||||||
|
} |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return newErrorManagerOption(err, "WithProvider") |
||||||
|
} |
||||||
|
|
||||||
|
return func(mgr *Manager) error { |
||||||
|
mgr.p = p |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// WithLogger attaches a logger to the Manager
|
||||||
|
func WithLogger(log slog.Logger) ManagerOption { |
||||||
|
if log == nil { |
||||||
|
log = cluster.DefaultLogger() |
||||||
|
} |
||||||
|
|
||||||
|
return func(mgr *Manager) error { |
||||||
|
mgr.l = log |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (mgr *Manager) setDefaults() error { |
||||||
|
var opts []ManagerOption |
||||||
|
|
||||||
|
if mgr.l == nil { |
||||||
|
opts = append(opts, WithLogger(nil)) |
||||||
|
} |
||||||
|
|
||||||
|
if mgr.domain == "" || mgr.suffix == "" { |
||||||
|
return errors.New("domain not specified") |
||||||
|
} |
||||||
|
|
||||||
|
for _, opt := range opts { |
||||||
|
if err := opt(mgr); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// WithDomain specifies where the manager operates
|
||||||
|
func WithDomain(domain string) ManagerOption { |
||||||
|
base, err := publicsuffix.EffectiveTLDPlusOne(domain) |
||||||
|
if err != nil { |
||||||
|
return newErrorManagerOption(err, "publicsuffix") |
||||||
|
} |
||||||
|
|
||||||
|
suffix := strings.TrimSuffix(domain, base) |
||||||
|
if suffix != "" { |
||||||
|
suffix = "." + suffix[:len(suffix)-1] |
||||||
|
} |
||||||
|
|
||||||
|
return func(mgr *Manager) error { |
||||||
|
mgr.domain = base |
||||||
|
mgr.suffix = suffix |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// NewManager creates a DNS manager with the
|
||||||
|
func NewManager(opts ...ManagerOption) (*Manager, error) { |
||||||
|
mgr := &Manager{ |
||||||
|
zones: make(map[string]*Zone), |
||||||
|
regions: make(map[string][]string), |
||||||
|
} |
||||||
|
|
||||||
|
for _, opt := range opts { |
||||||
|
if err := opt(mgr); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if err := mgr.setDefaults(); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return mgr, nil |
||||||
|
} |
||||||
|
|
||||||
|
// AddHost registers a host
|
||||||
|
func (mgr *Manager) AddHost(_ context.Context, zone string, id int, |
||||||
|
active bool, addrs ...netip.Addr) error { |
||||||
|
//
|
||||||
|
if zone == "" || id <= 0 { |
||||||
|
return fs.ErrInvalid |
||||||
|
} |
||||||
|
|
||||||
|
z, ok := mgr.zones[zone] |
||||||
|
if !ok { |
||||||
|
z = &Zone{ |
||||||
|
Name: zone, |
||||||
|
Hosts: make(map[int]*Host), |
||||||
|
} |
||||||
|
|
||||||
|
mgr.zones[zone] = z |
||||||
|
} |
||||||
|
|
||||||
|
p := &Host{ |
||||||
|
zone: z, |
||||||
|
ID: id, |
||||||
|
Active: active, |
||||||
|
Addrs: SortAddrSlice(addrs), |
||||||
|
} |
||||||
|
z.Hosts[id] = p |
||||||
|
|
||||||
|
if log, ok := mgr.l.Debug().WithEnabled(); ok { |
||||||
|
log.WithField("subsystem", "dns"). |
||||||
|
WithField("zone", zone). |
||||||
|
WithField("host", p.String()). |
||||||
|
WithField("active", active). |
||||||
|
Print() |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// AddRegion specifies a new region and the zones it contains
|
||||||
|
func (mgr *Manager) AddRegion(_ context.Context, region string, zones ...string) error { |
||||||
|
mgr.regions[region] = append(mgr.regions[region], zones...) |
||||||
|
|
||||||
|
if log, ok := mgr.l.Debug().WithEnabled(); ok { |
||||||
|
for _, zoneName := range zones { |
||||||
|
log.WithField("subsystem", "dns"). |
||||||
|
WithField("region", region). |
||||||
|
WithField("zone", zoneName).Print() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
package dns |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"os" |
||||||
|
|
||||||
|
"github.com/libdns/cloudflare" |
||||||
|
"github.com/libdns/libdns" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
// CloudflareAPIToken is the environment variable
|
||||||
|
// containing the API Token
|
||||||
|
CloudflareAPIToken = "CLOUDFLARE_DNS_API_TOKEN" |
||||||
|
) |
||||||
|
|
||||||
|
// Provider manages DNS entries
|
||||||
|
type Provider interface { |
||||||
|
libdns.RecordGetter |
||||||
|
libdns.RecordDeleter |
||||||
|
} |
||||||
|
|
||||||
|
// DefaultDNSProvider returns a cloudflare DNS provider
|
||||||
|
// using an API Token from env [CloudflareAPIToken]
|
||||||
|
func DefaultDNSProvider() (*cloudflare.Provider, error) { |
||||||
|
s := os.Getenv(CloudflareAPIToken) |
||||||
|
if s == "" { |
||||||
|
return nil, fmt.Errorf("%q: %s", CloudflareAPIToken, "not found") |
||||||
|
} |
||||||
|
|
||||||
|
p := &cloudflare.Provider{ |
||||||
|
APIToken: s, |
||||||
|
} |
||||||
|
|
||||||
|
return p, nil |
||||||
|
} |
@ -0,0 +1,212 @@ |
|||||||
|
package dns |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"net/netip" |
||||||
|
"sort" |
||||||
|
"time" |
||||||
|
|
||||||
|
"darvaza.org/core" |
||||||
|
"github.com/libdns/libdns" |
||||||
|
) |
||||||
|
|
||||||
|
func (mgr *Manager) fqdn(name string) string { |
||||||
|
return fmt.Sprintf("%s.%s.", name, mgr.domain) |
||||||
|
} |
||||||
|
|
||||||
|
// SortAddrSlice sorts a slice of [netip.Addr]
|
||||||
|
func SortAddrSlice(s []netip.Addr) []netip.Addr { |
||||||
|
sort.Slice(s, func(i, j int) bool { |
||||||
|
return s[i].Less(s[j]) |
||||||
|
}) |
||||||
|
return s |
||||||
|
} |
||||||
|
|
||||||
|
// SortAddrRecords sorts a slice of [AddrRecord]
|
||||||
|
// by Name and Address
|
||||||
|
func SortAddrRecords(s []AddrRecord) []AddrRecord { |
||||||
|
sort.Slice(s, func(i, j int) bool { |
||||||
|
return s[i].Name < s[j].Name |
||||||
|
}) |
||||||
|
|
||||||
|
for _, p := range s { |
||||||
|
SortAddrSlice(p.Addr) |
||||||
|
} |
||||||
|
|
||||||
|
return s |
||||||
|
} |
||||||
|
|
||||||
|
// SortRegions sorts regions. first by length those 3-character
|
||||||
|
// or shorter, and then by length. It's mostly aimed at
|
||||||
|
// supporting ISO-3166 order
|
||||||
|
func SortRegions(regions []string) []string { |
||||||
|
sort.Slice(regions, func(i, j int) bool { |
||||||
|
r1, r2 := regions[i], regions[j] |
||||||
|
|
||||||
|
switch { |
||||||
|
case len(r1) < 4: |
||||||
|
switch { |
||||||
|
case len(r1) < len(r2): |
||||||
|
return true |
||||||
|
case len(r1) > len(r2): |
||||||
|
return false |
||||||
|
default: |
||||||
|
return r1 < r2 |
||||||
|
} |
||||||
|
case len(r2) < 4: |
||||||
|
return false |
||||||
|
default: |
||||||
|
return r1 < r2 |
||||||
|
} |
||||||
|
}) |
||||||
|
return regions |
||||||
|
} |
||||||
|
|
||||||
|
// AddrRecord represents an A or AAAA record
|
||||||
|
type AddrRecord struct { |
||||||
|
Name string |
||||||
|
Addr []netip.Addr |
||||||
|
} |
||||||
|
|
||||||
|
// Sort sorts the addresses of the record
|
||||||
|
func (rr *AddrRecord) Sort() { |
||||||
|
SortAddrSlice(rr.Addr) |
||||||
|
} |
||||||
|
|
||||||
|
// Export converts the record into libdns.Record
|
||||||
|
func (rr *AddrRecord) Export() []libdns.Record { |
||||||
|
out := make([]libdns.Record, len(rr.Addr)) |
||||||
|
for i, addr := range rr.Addr { |
||||||
|
out[i] = libdns.Record{ |
||||||
|
Name: rr.Name, |
||||||
|
TTL: time.Second * 1, |
||||||
|
Type: core.IIf(addr.Is6(), "AAAA", "A"), |
||||||
|
Value: addr.String(), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
// WriteTo writes the record in BIND notation
|
||||||
|
func (rr *AddrRecord) WriteTo(w io.Writer) (int64, error) { |
||||||
|
var total int |
||||||
|
for _, addr := range rr.Addr { |
||||||
|
n, err := fmt.Fprint(w, |
||||||
|
rr.Name, "\t", |
||||||
|
1, "\t", |
||||||
|
core.IIf(addr.Is6(), "AAAA", "A"), "\t", |
||||||
|
addr.String(), "\n") |
||||||
|
|
||||||
|
switch { |
||||||
|
case err != nil: |
||||||
|
return 0, err |
||||||
|
case n > 0: |
||||||
|
total += n |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return int64(total), nil |
||||||
|
} |
||||||
|
|
||||||
|
// String converts the record into BIND entries
|
||||||
|
func (rr *AddrRecord) String() string { |
||||||
|
var buf bytes.Buffer |
||||||
|
_, _ = rr.WriteTo(&buf) |
||||||
|
return buf.String() |
||||||
|
} |
||||||
|
|
||||||
|
func (mgr *Manager) genRegionsSorted() []string { |
||||||
|
regions := make([]string, 0, len(mgr.regions)) |
||||||
|
for name := range mgr.regions { |
||||||
|
regions = append(regions, name) |
||||||
|
} |
||||||
|
|
||||||
|
return SortRegions(regions) |
||||||
|
} |
||||||
|
|
||||||
|
func (mgr *Manager) genAllAddrRecords() []AddrRecord { |
||||||
|
var out []AddrRecord |
||||||
|
|
||||||
|
cache := make(map[string][]netip.Addr) |
||||||
|
|
||||||
|
// zones
|
||||||
|
for _, z := range mgr.zones { |
||||||
|
// hosts
|
||||||
|
s := mgr.genZoneHostRecords(z) |
||||||
|
out = append(out, s...) |
||||||
|
|
||||||
|
// zone alias
|
||||||
|
addrs := mgr.genZoneAddresses(z) |
||||||
|
name := z.Name |
||||||
|
|
||||||
|
out = append(out, AddrRecord{ |
||||||
|
Name: name + mgr.suffix, |
||||||
|
Addr: addrs, |
||||||
|
}) |
||||||
|
|
||||||
|
// and cache for regions
|
||||||
|
cache[name] = addrs |
||||||
|
} |
||||||
|
|
||||||
|
for _, name := range mgr.genRegionsSorted() { |
||||||
|
var addrs []netip.Addr |
||||||
|
|
||||||
|
for _, z := range mgr.regions[name] { |
||||||
|
addrs = append(addrs, cache[z]...) |
||||||
|
} |
||||||
|
|
||||||
|
rec := AddrRecord{ |
||||||
|
Name: name + mgr.suffix, |
||||||
|
Addr: addrs, |
||||||
|
} |
||||||
|
rec.Sort() |
||||||
|
|
||||||
|
out = append(out, rec) |
||||||
|
} |
||||||
|
|
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
func (*Manager) genZoneAddresses(z *Zone) []netip.Addr { |
||||||
|
var out []netip.Addr |
||||||
|
|
||||||
|
for _, p := range z.Hosts { |
||||||
|
if p.Active { |
||||||
|
out = append(out, p.Addrs...) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
SortAddrSlice(out) |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
func (mgr *Manager) genZoneHostRecords(z *Zone) []AddrRecord { |
||||||
|
out := make([]AddrRecord, 0, len(z.Hosts)) |
||||||
|
|
||||||
|
for _, p := range z.Hosts { |
||||||
|
rec := AddrRecord{ |
||||||
|
Name: p.String() + mgr.suffix, |
||||||
|
Addr: p.Addrs, |
||||||
|
} |
||||||
|
out = append(out, rec) |
||||||
|
} |
||||||
|
|
||||||
|
SortAddrRecords(out) |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
func (mgr *Manager) genRegionAddressesCached(name string, |
||||||
|
zones map[string][]netip.Addr) []netip.Addr { |
||||||
|
//
|
||||||
|
var addrs []netip.Addr |
||||||
|
|
||||||
|
for _, zoneName := range mgr.regions[name] { |
||||||
|
addrs = append(addrs, zones[zoneName]...) |
||||||
|
} |
||||||
|
|
||||||
|
SortAddrSlice(addrs) |
||||||
|
return addrs |
||||||
|
} |
@ -0,0 +1,62 @@ |
|||||||
|
package dns |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"net/netip" |
||||||
|
) |
||||||
|
|
||||||
|
// WriteTo writes the DNS data for the cluster
|
||||||
|
func (mgr *Manager) WriteTo(w io.Writer) (int64, error) { |
||||||
|
var buf bytes.Buffer |
||||||
|
|
||||||
|
cache := make(map[string][]netip.Addr) |
||||||
|
|
||||||
|
// zones
|
||||||
|
for _, z := range mgr.zones { |
||||||
|
mgr.writeZoneHosts(&buf, z) |
||||||
|
|
||||||
|
// zone alias
|
||||||
|
addrs := mgr.genZoneAddresses(z) |
||||||
|
zoneName := z.Name |
||||||
|
|
||||||
|
rr := AddrRecord{ |
||||||
|
Name: mgr.fqdn(zoneName + mgr.suffix), |
||||||
|
Addr: addrs, |
||||||
|
} |
||||||
|
_, _ = rr.WriteTo(&buf) |
||||||
|
|
||||||
|
// and cache for regions
|
||||||
|
cache[zoneName] = addrs |
||||||
|
} |
||||||
|
|
||||||
|
// regions, sorted
|
||||||
|
for _, name := range mgr.genRegionsSorted() { |
||||||
|
addrs := mgr.genRegionAddressesCached(name, cache) |
||||||
|
|
||||||
|
mgr.writeRegionAddresses(&buf, name, addrs) |
||||||
|
} |
||||||
|
|
||||||
|
return buf.WriteTo(w) |
||||||
|
} |
||||||
|
|
||||||
|
func (mgr *Manager) writeZoneHosts(w io.Writer, z *Zone) { |
||||||
|
_, _ = fmt.Fprintf(w, ";\n; %s\n;\n", z.Name) |
||||||
|
|
||||||
|
for _, rr := range mgr.genZoneHostRecords(z) { |
||||||
|
rr.Name = mgr.fqdn(rr.Name) |
||||||
|
_, _ = rr.WriteTo(w) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (mgr *Manager) writeRegionAddresses(w io.Writer, name string, addrs []netip.Addr) { |
||||||
|
_, _ = fmt.Fprintf(w, "; %s\n", name) |
||||||
|
|
||||||
|
rr := AddrRecord{ |
||||||
|
Name: mgr.fqdn(name + mgr.suffix), |
||||||
|
Addr: addrs, |
||||||
|
} |
||||||
|
|
||||||
|
_, _ = rr.WriteTo(w) |
||||||
|
} |
Loading…
Reference in new issue