From 5019ef26ad706d1eab97db986ae93c3dd62e20a3 Mon Sep 17 00:00:00 2001 From: Alejandro Mery Date: Mon, 11 Sep 2023 21:42:56 +0000 Subject: [PATCH] WIP Signed-off-by: Alejandro Mery --- cmd/jpictl/dns.go | 116 ++++++++++++++++++++++++++++++++++++++ go.mod | 4 +- go.sum | 5 ++ pkg/dns/dns.go | 31 ++++++++++ pkg/dns/host.go | 31 ++++++++++ pkg/dns/manager.go | 127 +++++++++++++++++++++++++++++++++++++++++ pkg/dns/provider.go | 36 ++++++++++++ pkg/dns/write.go | 134 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 cmd/jpictl/dns.go create mode 100644 pkg/dns/dns.go create mode 100644 pkg/dns/host.go create mode 100644 pkg/dns/manager.go create mode 100644 pkg/dns/provider.go create mode 100644 pkg/dns/write.go diff --git a/cmd/jpictl/dns.go b/cmd/jpictl/dns.go new file mode 100644 index 0000000..1ee2304 --- /dev/null +++ b/cmd/jpictl/dns.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "os" + "time" + + "github.com/spf13/cobra" + + "git.jpi.io/amery/jpictl/pkg/cluster" + "git.jpi.io/amery/jpictl/pkg/dns" +) + +const ( + DNSTimeout = 10 * time.Second +) + +func newDNSManager(ctx context.Context, m *cluster.Cluster) (*dns.Manager, error) { + 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 + } + + return mgr, nil +} + +// Command +var dnsCmd = &cobra.Command{ + Use: "dns", +} + +var dnsSyncCmd = &cobra.Command{ + Use: "sync", + PreRun: setVerbosity, + RunE: func(_ *cobra.Command, _ []string) error { + m, err := cfg.LoadZones(true) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), DNSTimeout) + defer cancel() + + _, err = newDNSManager(ctx, m) + if err != nil { + return err + } + + return nil + }, +} + +var dnsWriteCmd = &cobra.Command{ + Use: "write", + PreRun: setVerbosity, + RunE: func(_ *cobra.Command, _ []string) error { + m, err := cfg.LoadZones(true) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), DNSTimeout) + defer cancel() + + mgr, err := newDNSManager(ctx, m) + if err != nil { + return err + } + + _, err = mgr.WriteTo(os.Stdout) + return err + }, +} + +var dnsAddCmd = &cobra.Command{ + Use: "add", + PreRun: setVerbosity, + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, +} + +var dnsRemoveCmd = &cobra.Command{ + Use: "remove", + PreRun: setVerbosity, + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, +} + +func init() { + rootCmd.AddCommand(dnsCmd) + + dnsCmd.AddCommand(dnsSyncCmd) + dnsCmd.AddCommand(dnsWriteCmd) + dnsCmd.AddCommand(dnsAddCmd) + dnsCmd.AddCommand(dnsRemoveCmd) +} diff --git a/go.mod b/go.mod index 9bf6945..83a2d8e 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,12 @@ require ( darvaza.org/slog/handlers/discard v0.4.5 github.com/gofrs/uuid/v5 v5.0.0 github.com/hack-pad/hackpadfs v0.2.1 + github.com/libdns/cloudflare v0.1.0 + github.com/libdns/libdns v0.2.1 github.com/mgechev/revive v1.3.3 github.com/spf13/cobra v1.7.0 golang.org/x/crypto v0.12.0 + golang.org/x/net v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -40,7 +43,6 @@ require ( github.com/rs/zerolog v1.30.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/mod v0.12.0 // indirect - golang.org/x/net v0.14.0 // indirect golang.org/x/sys v0.12.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.12.0 // indirect diff --git a/go.sum b/go.sum index 290f114..0248d22 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,11 @@ github.com/hack-pad/hackpadfs v0.2.1 h1:FelFhIhv26gyjujoA/yeFO+6YGlqzmc9la/6iKMI github.com/hack-pad/hackpadfs v0.2.1/go.mod h1:khQBuCEwGXWakkmq8ZiFUvUZz84ZkJ2KNwKvChs4OrU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/libdns/cloudflare v0.1.0 h1:93WkJaGaiXCe353LHEP36kAWCUw0YjFqwhkBkU2/iic= +github.com/libdns/cloudflare v0.1.0/go.mod h1:a44IP6J1YH6nvcNl1PverfJviADgXUnsozR3a7vBKN8= +github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= +github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= diff --git a/pkg/dns/dns.go b/pkg/dns/dns.go new file mode 100644 index 0000000..a0d1db6 --- /dev/null +++ b/pkg/dns/dns.go @@ -0,0 +1,31 @@ +// Package dns manages DNS entries for the cluster +package dns + +import "net/netip" + +// A Config defines a Region +type Config struct { + // Name is the identifier of this Region + Name string + // Regions are a list of (sub)regions that belong to this Region + Regions []string + // Zones are a list of Zones that directly belong to this Region + Zones []string +} + +type Region struct { + Name string +} + +type Zone struct { + Name string + + Machines map[int]*Machine +} + +type Machine struct { + ID int + + Active bool + Addrs []netip.Addr +} diff --git a/pkg/dns/host.go b/pkg/dns/host.go new file mode 100644 index 0000000..4592f52 --- /dev/null +++ b/pkg/dns/host.go @@ -0,0 +1,31 @@ +package dns + +import ( + "context" + "io/fs" + "net/netip" +) + +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, + Machines: make(map[int]*Machine), + } + + mgr.zones[zone] = z + } + + z.Machines[id] = &Machine{ + ID: id, + Active: active, + Addrs: addrs, + } + + return nil +} diff --git a/pkg/dns/manager.go b/pkg/dns/manager.go new file mode 100644 index 0000000..edabf77 --- /dev/null +++ b/pkg/dns/manager.go @@ -0,0 +1,127 @@ +package dns + +import ( + "errors" + "strings" + "sync" + + "darvaza.org/core" + "darvaza.org/slog" + "golang.org/x/net/publicsuffix" + + "git.jpi.io/amery/jpictl/pkg/cluster" +) + +// Manager is a DNS Manager instance +type Manager struct { + mu sync.Mutex + + domain string + suffix string + zones map[string]*Zone + + 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 { + if hint != "" { + err = core.Wrap(err, hint) + } + return err + } +} + +// 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.p == nil { + opts = append(opts, WithProvider(nil)) + } + + if mgr.l == nil { + opts = append(opts, WithLogger(nil)) + } + + if mgr.domain == "" || mgr.suffix == "" { + return errors.New("domain not specified") + } + + mgr.zones = make(map[string]*Zone) + + 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 := new(Manager) + + 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 +} diff --git a/pkg/dns/provider.go b/pkg/dns/provider.go new file mode 100644 index 0000000..6a53a24 --- /dev/null +++ b/pkg/dns/provider.go @@ -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 +} diff --git a/pkg/dns/write.go b/pkg/dns/write.go new file mode 100644 index 0000000..b09da50 --- /dev/null +++ b/pkg/dns/write.go @@ -0,0 +1,134 @@ +package dns + +import ( + "bytes" + "fmt" + "io" + "net/netip" + "sort" + "time" + + "darvaza.org/core" + "github.com/libdns/libdns" +) + +func (mgr *Manager) WriteTo(w io.Writer) (int64, error) { + var buf bytes.Buffer + + var zones sort.StringSlice + + // sort zones + for name := range mgr.zones { + zones = append(zones, name) + } + sort.Sort(zones) + + for _, name := range zones { + z := mgr.zones[name] + + if buf.Len() > 0 { + _, _ = buf.WriteRune('\n') + } + + // servers + err := mgr.writeZoneMachinesToBuffer(&buf, name, z) + if err != nil { + return 0, err + } + } + + return buf.WriteTo(w) +} + +func (mgr *Manager) writeRecordsToBuffer(buf *bytes.Buffer, records ...libdns.Record) { + for _, rr := range records { + fqdn := fmt.Sprintf("%s.%s", rr.Name, mgr.domain) + + _, _ = fmt.Fprintf(buf, "%s\t%v\tIN\t%s\t%v\n", + fqdn, int(rr.TTL/time.Second), rr.Type, rr.Value) + } +} + +func (mgr *Manager) writeZoneMachinesToBuffer(buf *bytes.Buffer, zone_name string, z *Zone) error { + // title + _, _ = fmt.Fprintf(buf, ";\n; %s\n;\n", zone_name) + + records := mgr.genMachineRecords(z) + mgr.writeRecordsToBuffer(buf, records...) + + // zone alias + _, _ = fmt.Fprintf(buf, "; %s\n", zone_name) + records = mgr.genAliasRecords(zone_name, true, z) + mgr.Sort(records) + mgr.writeRecordsToBuffer(buf, records...) + + return nil +} + +func (mgr *Manager) genMachineRecords(z *Zone) []libdns.Record { + var out []libdns.Record + + for _, p := range z.Machines { + pqdn := fmt.Sprintf("%s-%v%s", z.Name, p.ID, mgr.suffix) + + for _, addr := range p.Addrs { + out = append(out, libdns.Record{ + Name: pqdn, + Type: core.IIf(addr.Is6(), "AAAA", "A"), + TTL: time.Second, + Value: addr.String(), + }) + } + } + + mgr.Sort(out) + return out +} + +func (mgr *Manager) genAliasRecords(name string, activeOnly bool, zones ...*Zone) []libdns.Record { + var out []libdns.Record + + fqdn := fmt.Sprintf("%s%s", name, mgr.suffix) + for _, z := range zones { + for _, p := range z.Machines { + if p.Active || !activeOnly { + for _, addr := range p.Addrs { + out = append(out, libdns.Record{ + Name: fqdn, + Type: core.IIf(addr.Is6(), "AAAA", "A"), + TTL: time.Second, + Value: addr.String(), + }) + } + } + } + } + + return out +} + +func (mgr *Manager) Sort(records []libdns.Record) { + sort.SliceStable(records, func(i, j int) bool { + a := &records[i] + b := &records[j] + switch { + case a.Name < b.Name: + return true + case a.Name > b.Name: + return false + case a.Type < b.Type: + return true + case a.Type > b.Type: + return false + case a.Type == "A" || a.Type == "AAAA": + var aAddr, bAddr netip.Addr + aAddr.UnmarshalText([]byte(a.Value)) + bAddr.UnmarshalText([]byte(b.Value)) + return aAddr.Less(bAddr) + case a.Value < b.Value: + return true + default: + return false + } + }) +}