aboutsummaryrefslogtreecommitdiff
path: root/cmd/dnsupdate
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/dnsupdate')
-rw-r--r--cmd/dnsupdate/README.org7
-rw-r--r--cmd/dnsupdate/main.go126
-rw-r--r--cmd/dnsupdate/ts.go89
3 files changed, 222 insertions, 0 deletions
diff --git a/cmd/dnsupdate/README.org b/cmd/dnsupdate/README.org
new file mode 100644
index 0000000..a80e407
--- /dev/null
+++ b/cmd/dnsupdate/README.org
@@ -0,0 +1,7 @@
+#+TITLE: dnsupdate
+
+Utility to update the managed zone for =fcuny.xyz= in Google Cloud.
+
+I use the domain =fcuny.xyz= to run a number of services on an IP provided by Tailscale. I don't want these domains to be visible on the web, but I also want to have a valid HTTPS certificate for them. By having a proper DNS I can use ACME to get the certificates, without making them available.
+
+Instead of updating the subdomains through the [[https://console.cloud.google.com/net-services/dns/zones/fcuny-xyz/details?project=fcuny-homelab][console]], I can now run this program.
diff --git a/cmd/dnsupdate/main.go b/cmd/dnsupdate/main.go
new file mode 100644
index 0000000..6748ee9
--- /dev/null
+++ b/cmd/dnsupdate/main.go
@@ -0,0 +1,126 @@
+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",
+ "cs",
+ "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
+}