diff --git a/scripts/ts-rebuild-dns b/scripts/ts-rebuild-dns new file mode 100755 index 0000000..486c7c9 --- /dev/null +++ b/scripts/ts-rebuild-dns @@ -0,0 +1,115 @@ +#! /usr/bin/env python3 +import argparse +import sys +import logging +import subprocess +import requests +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session # pip install requests-oauthlib + +logger = logging.getLogger(__name__) + + +def main() -> int: + # Handle program arguments + ap = argparse.ArgumentParser( + prog="ts-rebuild-dns", description="Writes Tailscale hostnames into DNS" + ) + ap.add_argument( + "-v", "--verbose", help="Enable verbose logging", action="store_true" + ) + args = ap.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s: %(message)s", + ) + + # Read relevant secrets + tailscale_oauth_client_id = "k2riEfMYLA11CNTRL" + tailscale_oauth_client_secret = ( + subprocess.check_output( + ["op", "read", "op://Personal/ysffqv6vsom2hixv37iiotmtbm/credential"] + ) + .decode() + .strip() + ) + cloudflare_api_token = ( + subprocess.check_output( + ["op", "read", "op://Personal/7hrhdwzhpasoqegmlv7wprjzaa/credential"] + ) + .decode() + .strip() + ) + + # Authenticate with Tailscale + tailscale_client = BackendApplicationClient(client_id=tailscale_oauth_client_id) + tailscale_oauth = OAuth2Session(client=tailscale_client) + tailscale_token = tailscale_oauth.fetch_token( + token_url="https://api.tailscale.com/api/v2/oauth/token", + client_id=tailscale_oauth_client_id, + client_secret=tailscale_oauth_client_secret, + ) + + # Get the list of Tailscale devices + tailscale_devices = tailscale_oauth.get( + "https://api.tailscale.com/api/v2/tailnet/-/devices" + ).json() + + # Build sets of DNS records + records = [] + for device in tailscale_devices["devices"]: + name = device["name"].split(".")[0] + for address in device["addresses"]: + if ":" in address: + records.append(("AAAA", f"{name}.mesh.ewpratten.net", address)) + else: + records.append(("A", f"{name}.mesh.ewpratten.net", address)) + + # Fetch all existing records from Cloudflare + cloudflare_records = requests.get( + f"https://api.cloudflare.com/client/v4/zones/3d8ef70ae28b8a5d97a200550dc95ed1/dns_records", + headers={"Authorization": f"Bearer {cloudflare_api_token}"}, + ).json()["result"] + + # Only look at records under the mesh subdomain + cloudflare_records = [ + record + for record in cloudflare_records + if record["name"].endswith(".mesh.ewpratten.net") + ] + + # Delete all records that are stale + for record in cloudflare_records: + if (record["type"], record["name"], record["content"]) not in records: + logger.info(f"Deleting {record['type']} record {record['name']} -> {record['content']}") + requests.delete( + f"https://api.cloudflare.com/client/v4/zones/3d8ef70ae28b8a5d97a200550dc95ed1/dns_records/{record['id']}", + headers={"Authorization": f"Bearer {cloudflare_api_token}"}, + ) + + # Add all records that are missing + for record in records: + if not any( + r["type"] == record[0] and r["name"] == record[1] and r["content"] == record[2] + for r in cloudflare_records + ): + logger.info(f"Adding {record[0]} record {record[1]} -> {record[2]}") + requests.post( + f"https://api.cloudflare.com/client/v4/zones/3d8ef70ae28b8a5d97a200550dc95ed1/dns_records", + headers={"Authorization": f"Bearer {cloudflare_api_token}"}, + json={ + "type": record[0], + "name": record[1], + "content": record[2], + "ttl": 120, + "proxied": False, + }, + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())