From 8ace84d8f19d5238d62e5022340ab40d82bf1770 Mon Sep 17 00:00:00 2001 From: John Maguire Date: Fri, 19 Jan 2024 04:07:20 -0500 Subject: [PATCH] Make it work --- cloudflare.go | 86 ++++++++++++++++++++++++++++++++++++++++---- config.go | 13 +++++-- defined.go | 80 +++++++++++++++++++++++++++++++++++++---- examples/config.toml | 15 ++++---- go.mod | 16 ++++++++- go.sum | 46 ++++++++++++++++++++++++ main.go | 59 +++++++++++++++++++++++++----- 7 files changed, 283 insertions(+), 32 deletions(-) diff --git a/cloudflare.go b/cloudflare.go index 13a4891..2df303e 100644 --- a/cloudflare.go +++ b/cloudflare.go @@ -1,20 +1,92 @@ package main -import "fmt" +import ( + "context" + "fmt" + + "github.com/cloudflare/cloudflare-go" +) type Record struct { ID string Name string } -func IterateRecords(cfToken string, zoneID string, fn func(record Record) error) error { - return fmt.Errorf("not implemented") +func GetZoneID(cf *cloudflare.API, zoneName string) (string, error) { + zones, err := cf.ListZones(context.Background()) + if err != nil { + return "", fmt.Errorf("failed to list zones: %w", err) + } + + for _, z := range zones { + if z.Name == zoneName { + return z.ID, nil + } + } + + return "", fmt.Errorf("zone %s not found", zoneName) +} + +func IterateRecords(cf *cloudflare.API, zoneID string, fn func(record Record) error) error { + recs, _, err := cf.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{}) + if err != nil { + return fmt.Errorf("failed to list DNS records: %w", err) + } + + for _, r := range recs { + r := Record{ID: r.ID, Name: r.Name} + if err := fn(r); err != nil { + // TODO better error handling + return fmt.Errorf("error in callback for record %+v: %w", r, err) + } + } + + return nil } -func CreateRecord(cfToken string, zoneID string, hostname string, ip string) error { - return fmt.Errorf("not implemented") +func CreateRecord(cf *cloudflare.API, zoneID string, hostname string, ip string) error { + // Check if the record already exists + recs, _, err := cf.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{ + Name: hostname, + }) + if err != nil { + return fmt.Errorf("failed to list DNS records: %w", err) + } + + if len(recs) > 0 { + // update the record + _, err := cf.UpdateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.UpdateDNSRecordParams{ + ID: recs[0].ID, + Type: "A", + Name: hostname, + Content: ip, + TTL: 1, + Proxied: cloudflare.BoolPtr(false), + }) + if err != nil { + return fmt.Errorf("failed to update DNS record: %w", err) + } + } else { + _, err := cf.CreateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.CreateDNSRecordParams{ + Type: "A", + Name: hostname, + Content: ip, + TTL: 1, + Proxied: cloudflare.BoolPtr(false), + }) + if err != nil { + return fmt.Errorf("failed to create DNS record: %w", err) + } + } + + return nil } -func DeleteRecord(cfToken string, zoneID string, recordID string) error { - return fmt.Errorf("not implemented") +func DeleteRecord(cf *cloudflare.API, zoneID string, recordID string) error { + err := cf.DeleteDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), recordID) + if err != nil { + return fmt.Errorf("failed to delete DNS record: %w", err) + } + + return nil } diff --git a/config.go b/config.go index c28523a..58ce7cb 100644 --- a/config.go +++ b/config.go @@ -27,12 +27,12 @@ type AppConfig struct { Cloudflare CloudflareConfig `toml:"cloudflare"` // Defined is the configuration for the Defined Networking API. - DefinedNet DefinedConfig `toml:"defined"` + DefinedNet DefinedConfig `toml:"definednet"` } type CloudflareConfig struct { APIToken string `toml:"api_token"` - ZoneID string `toml:"zone_id"` + ZoneName string `toml:"zone_name"` } type DefinedConfig struct { @@ -40,11 +40,20 @@ type DefinedConfig struct { } func LoadConfig(path string) (*AppConfig, error) { + // Load config from file config, err := newConfigFromFile(path) if err != nil { return nil, err } + + // Optionally load secrets from the environment config.readEnv() + + // Default AppendSuffix to the zone name + if config.AppendSuffix == "" { + config.AppendSuffix = config.Cloudflare.ZoneName + } + return config, nil } diff --git a/defined.go b/defined.go index d126376..2025a96 100644 --- a/defined.go +++ b/defined.go @@ -1,14 +1,82 @@ package main -import "fmt" +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) type Host struct { - ID string - IPAddress string - Hostname string - Tags []string + ID string `json:"id"` + IPAddress string `json:"ipAddress"` + Hostname string `json:"name"` + Tags []string `json:"tags"` +} + +type hostsResponse struct { + Data []Host `json:"data"` + Metadata struct { + HasNextPage bool `json:"hasNextPage"` + Cursor string `json:"cursor"` + } `json:"metadata"` } func FilterHosts(dnToken string, filterFunc func(Host) bool) ([]Host, error) { - return nil, fmt.Errorf("not implemented") + hosts := []Host{} + + cursor := "" + for { + // Fetch the next page of hosts + params := url.Values{ + "cursor": []string{cursor}, + "pageSize": []string{"500"}, + } + + req, err := http.NewRequest("GET", "https://api.defined.net/v1/hosts?"+params.Encode(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+dnToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, body) + } + + // Decode the response body into a slice of Hosts + var respHosts hostsResponse + err = json.Unmarshal(body, &respHosts) + if err != nil { + return nil, err + } + + // Filter the hosts + for _, host := range respHosts.Data { + if filterFunc(host) { + hosts = append(hosts, host) + } + } + + // Fetch the next page if there is one + if !respHosts.Metadata.HasNextPage { + break + } + cursor = respHosts.Metadata.Cursor + } + + return hosts, nil } diff --git a/examples/config.toml b/examples/config.toml index 48bc68e..60166e4 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -1,8 +1,8 @@ # required_tags is a list of tags that must be present on the host -# for it to be considered for DNS registration -required_tags = ["public-dns:yes"] +# for it to be considered for DNS registration. Optional. +required_tags = ["publish:yes"] # required_suffix will only register hosts with this domain (must match the -# full domain suffix) +# full domain suffix.) Optional. required_suffix = "" # trim_suffix will remove the domain from the hostname (e.g. host.example.com # will become host) @@ -16,11 +16,12 @@ append_suffix = "nebula.example.com" prune_records = true [cloudflare] -# api_token is the Cloudflare API token +# api_token is the Cloudflare API token. It needs the `Zone:DNS:Edit` +# permission for the zone specified in zone_name. api_token = "" -# zone_id is the DNS zone ID to create records in -zone_id = "" +# zone_name is the DNS zone (domain) to create records in (e.g. example.com) +zone_name = "" [definednet] -# api_token is the Defined.net API token +# api_token is the Defined.net API token. It needs the `hosts:list` permission. api_token = "" diff --git a/go.mod b/go.mod index c5e6225..8ba444e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,21 @@ go 1.21.1 require ( github.com/BurntSushi/toml v1.3.2 + github.com/cloudflare/cloudflare-go v0.86.0 github.com/urfave/cli/v3 v3.0.0-alpha8 ) -require github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect +require ( + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/rs/zerolog v1.31.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect +) diff --git a/go.sum b/go.sum index 660ab7c..22e6035 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,60 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/cloudflare/cloudflare-go v0.86.0 h1:jEKN5VHNYNYtfDL2lUFLTRo+nOVNPFxpXTstVx0rqHI= +github.com/cloudflare/cloudflare-go v0.86.0/go.mod h1:wYW/5UP02TUfBToa/yKbQHV+r6h1NnJ1Je7XjuGM4Jw= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= +github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +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= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/urfave/cli/v3 v3.0.0-alpha8 h1:H+qxFPoCkGzdF8KUMs2fEOZl5io/1QySgUiGfar8occ= github.com/urfave/cli/v3 v3.0.0-alpha8/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 978d1c1..cf47841 100644 --- a/main.go +++ b/main.go @@ -15,13 +15,14 @@ import ( "os" "strings" + "github.com/cloudflare/cloudflare-go" + "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" ) func main() { if err := mainWithErr(); err != nil { - fmt.Printf("Error: %s\n", err) - os.Exit(1) + log.Fatal().Err(err).Send() } } @@ -39,16 +40,34 @@ func mainWithErr() error { Action: func(ctx context.Context, c *cli.Command) error { // Read the config file cfg, err := LoadConfig(c.String("config")) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + cf, err := cloudflare.NewWithAPIToken(cfg.Cloudflare.APIToken) if err != nil { return err } - fmt.Printf("%#v\n", cfg) + // Find the Cloudflare zone ID for the zone we're interested in + zoneID, err := GetZoneID(cf, cfg.Cloudflare.ZoneName) + if err != nil { + return fmt.Errorf("failed to get zone ID: %w", err) + } + + log.Info().Str("zoneID", zoneID).Msgf("Found Cloudflare zone ID for %s", cfg.Cloudflare.ZoneName) // Filter the DN hosts based on the following criteria: // - Presence of a specific tag (e.g. "public-dns:yes") // - Hostname contains a specific suffix (e.g. ".example.com") + log.Info(). + Str("requiredSuffix", cfg.RequiredSuffix). + Str("requiredTags", strings.Join(cfg.RequiredTags, ",")). + Msg("Collecting eligible Defined.net Managed Nebula hosts") + hosts, err := FilterHosts(cfg.DefinedNet.APIToken, func(h Host) bool { + // FIXME check valid fqdn + // Make sure any required suffix is present if !strings.HasSuffix(h.Hostname, cfg.RequiredSuffix) { return false @@ -69,22 +88,30 @@ func mainWithErr() error { return true }) if err != nil { - return err + return fmt.Errorf("failed to collect eligible hosts: %w", err) } + log.Info().Int("eligibleHosts", len(hosts)).Msgf("Found %d eligible hosts", len(hosts)) + // Create an A record for each host that matches the criteria pointing to // the host's IP address. Create a map of valid hostnames as we go. hostnames := map[string]struct{}{} for _, host := range hosts { hostname := host.Hostname + l := log.Info().Str("initialHostname", hostname) if cfg.TrimSuffix { hostname = trimSuffix(hostname) + l = l.Str("trimmedHostname", hostname) } + hostname = strings.ToLower(hostname + "." + cfg.AppendSuffix) + l.Str("finalHostname", hostname). + Str("ipAddress", host.IPAddress). + Msg("Creating Cloudflare DNS record") - err := CreateRecord(cfg.Cloudflare.APIToken, cfg.Cloudflare.ZoneID, hostname, host.IPAddress) + err := CreateRecord(cf, zoneID, hostname, host.IPAddress) if err != nil { // TODO: Log the error and continue - return err + return fmt.Errorf("failed to create record: %w", err) } hostnames[hostname] = struct{}{} @@ -93,15 +120,29 @@ func mainWithErr() error { // For any hosts within the target zone that do not have a corresponding // host in Defined Networking, delete the A record if cfg.PruneRecords { - err := IterateRecords(cfg.Cloudflare.APIToken, cfg.Cloudflare.ZoneID, func(r Record) error { + log.Info().Str("zoneID", zoneID). + Msg("Pruning Cloudflare DNS records") + + err := IterateRecords(cf, zoneID, func(r Record) error { + if !strings.HasSuffix(r.Name, cfg.AppendSuffix) { + return nil + } + if _, ok := hostnames[r.Name]; !ok { - return DeleteRecord(cfg.Cloudflare.APIToken, cfg.Cloudflare.ZoneID, r.ID) + log.Info().Str("recordID", r.ID). + Str("recordName", r.Name). + Msg("Pruning stale DNS record") + + err := DeleteRecord(cf, zoneID, r.ID) + if err != nil { + return fmt.Errorf("failed to delete record: %w", err) + } } return nil }) if err != nil { - return err + return fmt.Errorf("error during host prune iteration: %w", err) } }