diff options
author | Franck Cuny <franck@fcuny.net> | 2022-05-07 15:57:10 -0700 |
---|---|---|
committer | Franck Cuny <franck@fcuny.net> | 2022-05-07 15:57:10 -0700 |
commit | 0103bbc594e6b63f7d918b3e700fb7a6d69a901a (patch) | |
tree | 4224dbf9db47b50ad6edeabb0f1d0887d060b7ea /cmd | |
parent | repo: add support for direnv (diff) | |
download | world-0103bbc594e6b63f7d918b3e700fb7a6d69a901a.tar.gz |
cmd: add a command to update fcuny.xyz
I'm using fcuny.xyz as a domain to run a number of services on a host using the IP provided by Tailscale. Instead of manually updating the DNS configuration in the console every time I create a new subdomain, let's do this with a small program. The program query Tailscale API to get the IP address of the host `tahoe`, and then query the DNS API to see if anything is missing or is mis-configured. If it's the case, it will resolve the problems.
Diffstat (limited to '')
-rw-r--r-- | cmd/dnsupdate/main.go | 125 | ||||
-rw-r--r-- | cmd/dnsupdate/ts.go | 89 |
2 files changed, 214 insertions, 0 deletions
diff --git a/cmd/dnsupdate/main.go b/cmd/dnsupdate/main.go new file mode 100644 index 0000000..59ed67a --- /dev/null +++ b/cmd/dnsupdate/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "fmt" + "log" + + dns "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", + "drone", + "music", + "unifi", +} + +func main() { + ctx := context.Background() + + // we only care about IPv4 for now + tsIpV4Addresses, _, err := getTsIpsDevice(ctx, TS_DEVICE_NAME) + if err != nil { + log.Fatalf("failed to get the IP addresses for %s: %v", TS_DEVICE_NAME, err) + } + + svc, err := dns.NewService(ctx) + if err != nil { + log.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 { + log.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 { + log.Fatalf("failed to get the list of records: %+v", err) + } + + 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 { + log.Printf("will delete %s (incorrect IPv4 addresses)\n", subdomain) + recordSetsToDelete = append(recordSetsToDelete, r) + } + } + } + if !found { + log.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 { + log.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 { + log.Fatalf("failed to apply the change: %+v", err) + } + } +} diff --git a/cmd/dnsupdate/ts.go b/cmd/dnsupdate/ts.go new file mode 100644 index 0000000..4d3ebb3 --- /dev/null +++ b/cmd/dnsupdate/ts.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + + "inet.af/netaddr" +) + +type device struct { + Hostname string `json:"hostname"` + ID string `json:"id"` + Addresses []string `json:"addresses"` +} + +const ( + TS_NAME = "franck.cuny@gmail.com" + TS_API_DOMAIN = "api.tailscale.com" +) + +func getTsDevice(ctx context.Context, deviceName string) (*device, error) { + apiKey, found := os.LookupEnv("TS_API_KEY") + if !found { + return nil, errors.New("the environment variable TS_API_KEY is not set") + } + + url := fmt.Sprintf("https://%s/api/v2/tailnet/%s/devices", TS_API_DOMAIN, TS_NAME) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + req.SetBasicAuth(apiKey, "") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok status code %d returned from tailscale api: %s", resp.StatusCode, resp.Status) + } + var buf struct { + Devices []device `json:"devices"` + } + if err := json.NewDecoder(resp.Body).Decode(&buf); err != nil { + return nil, err + } + + for _, d := range buf.Devices { + if d.Hostname == deviceName { + return &d, nil + } + } + return nil, fmt.Errorf("could not find the tailscale device named %s", deviceName) +} + +// Get the Tailscale IPv4 and IPv6 addresses associated with the given device. +func getTsIpsDevice(ctx context.Context, device string) ([]string, []string, error) { + ts_device, err := getTsDevice(ctx, device) + if err != nil { + return nil, nil, fmt.Errorf("failed to get Tailscale device information: %v", err) + } + + var ( + tsIpV4Addresses = []string{} + tsIpV6Addresses = []string{} + ) + for _, ipString := range ts_device.Addresses { + // we convert the string to a netaddr.IP so we can check if + // it's an IP v4 or v6. We need to know what's the version in + // order to use it properly when creating/updating the + // record. Then we convert it back as a string, since this is + // what the DNS API expect. + ip := netaddr.MustParseIP(ipString) + if ip.Is4() { + tsIpV4Addresses = append(tsIpV4Addresses, ip.String()) + } else { + tsIpV6Addresses = append(tsIpV6Addresses, ip.String()) + } + } + + return tsIpV4Addresses, tsIpV6Addresses, nil +} |