package main import ( "context" "flag" "fmt" "log" "os" "strings" "google.golang.org/api/dns/v1" ) const ( GCP_PROJECT_NAME = "fcuny-homelab" GCP_MANAGED_ZONE = "fcuny-xyz" TS_DEVICE_NAME = "tahoe" TTL = 300 ) var desiredRecords = []string{ "bt", "dash", "music", "unifi", } const usage = `Usage: dns-updater [OPTIONS] Options: -ts-api-key API key for tailscale Examples: $ dns-updater -ts-api-key $(passage credentials/tailscale) $ dns-updater -ts-api-key $(passage credentials/tailscale) update ` var l = log.New(os.Stderr, "", 0) func main() { var ( tsAPIKeyFlag string command string ) flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } flag.StringVar(&tsAPIKeyFlag, "ts-api-key", "", "the API key for tailscale") flag.Parse() if flag.NArg() > 1 { flag.Usage() os.Exit(1) } else { command = flag.Arg(0) if command != "" && command != "update" { flag.Usage() os.Exit(1) } } ctx := context.Background() // we only care about IPv4 for now tsIpV4Addresses, _, err := getTsIpsDevice(ctx, tsAPIKeyFlag, TS_DEVICE_NAME) if err != nil { l.Fatalf("failed to get the IP addresses for %s: %v", TS_DEVICE_NAME, err) } svc, err := dns.NewService(ctx) if err != nil { l.Fatalf("failed to create the client for Google Cloud DNS: %v", err) } zone, err := svc.ManagedZones.Get(GCP_PROJECT_NAME, GCP_MANAGED_ZONE).Context(ctx).Do() if err != nil { l.Fatalf("failed to get information about the managed zone %s: %+v", GCP_MANAGED_ZONE, err) } recordSets, err := svc.ResourceRecordSets.List(GCP_PROJECT_NAME, GCP_MANAGED_ZONE).Context(ctx).Do() if err != nil { l.Fatalf("failed to get the list of records: %+v", err) } switch command { case "update": updateRecords(ctx, svc, recordSets, zone, tsIpV4Addresses) default: listRecords(recordSets) } } func listRecords(recordSets *dns.ResourceRecordSetsListResponse) { for _, record := range recordSets.Rrsets { switch record.Type { case "A": ips := strings.Join(record.Rrdatas[:], ",") fmt.Printf("%-25s %4d %s\n", record.Name, record.Ttl, ips) } } } func updateRecords(ctx context.Context, svc *dns.Service, recordSets *dns.ResourceRecordSetsListResponse, zone *dns.ManagedZone, tsIpV4Addresses []string) { var ( existingRecordSets = []*dns.ResourceRecordSet{} recordSetsToAdd = []*dns.ResourceRecordSet{} recordSetsToDelete = []*dns.ResourceRecordSet{} ) for _, record := range recordSets.Rrsets { if record.Type == "A" { existingRecordSets = append(existingRecordSets, record) } } // first pass: create what's missing for _, subdomain := range desiredRecords { found := false subdomain = fmt.Sprintf("%s.%s", subdomain, zone.DnsName) for _, r := range existingRecordSets { if subdomain == r.Name && r.Type == "A" { // check that the IP addresses are correct ipsFound := 0 for _, rr := range r.Rrdatas { for _, ip := range tsIpV4Addresses { if rr == ip { ipsFound += 1 continue } } } // while we found the subdomain with the correct type, // we also need to make sure the list of IPs is // correct. If they are not, we delete the record and // add it again with the correct values. if ipsFound == len(tsIpV4Addresses) { found = true continue } else { l.Printf("will delete %s (incorrect IPv4 addresses)\n", subdomain) recordSetsToDelete = append(recordSetsToDelete, r) } } } if !found { l.Printf("will add %s\n", subdomain) r := &dns.ResourceRecordSet{ Name: subdomain, Type: "A", Ttl: TTL, Rrdatas: tsIpV4Addresses, } recordSetsToAdd = append(recordSetsToAdd, r) } } // second pass: delete what's not needed for _, r := range existingRecordSets { found := false for _, subdomain := range desiredRecords { subdomain = fmt.Sprintf("%s.%s", subdomain, zone.DnsName) if subdomain == r.Name && r.Type == "A" { found = true continue } } if !found { l.Printf("will delete %s\n", r.Name) recordSetsToDelete = append(recordSetsToDelete, r) } } if len(recordSetsToAdd) > 0 || len(recordSetsToDelete) > 0 { change := &dns.Change{Additions: recordSetsToAdd, Deletions: recordSetsToDelete} if _, err := svc.Changes.Create(GCP_PROJECT_NAME, GCP_MANAGED_ZONE, change).Context(ctx).Do(); err != nil { l.Fatalf("failed to apply the change: %+v", err) } } }