From 0c7ac77127e47f0130ab4961303eedd245c0ca6f Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Wed, 10 Apr 2024 16:13:16 -0600 Subject: [PATCH] initial setup --- .gitignore | 1 + args.py | 22 +++++ main.py | 26 ++++++ records.json | 204 +++++++++++++++++++++++++-------------------- requirements.txt | 1 + script.py | 37 -------- updater/acmedns.py | 164 ++++++++++++++++++++++++++++++++++++ updater/adapter.py | 24 ++++++ 8 files changed, 351 insertions(+), 128 deletions(-) create mode 100644 args.py create mode 100644 main.py create mode 100644 requirements.txt delete mode 100644 script.py create mode 100644 updater/acmedns.py create mode 100644 updater/adapter.py diff --git a/.gitignore b/.gitignore index bceb371..1cec515 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.secret +**/__pycache__ diff --git a/args.py b/args.py new file mode 100644 index 0000000..69599e2 --- /dev/null +++ b/args.py @@ -0,0 +1,22 @@ +import argparse + + +def get_args(): + parser = argparse.ArgumentParser() + + parser.add_argument("--endpoint", default="https://hatecomputers.club") + parser.add_argument("--api-key-file", default="apikey.secret") + parser.add_argument("--records-file", default="records.json") + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + ) + + parser.add_argument("--certbot", action="store_true", default=False) + parser.add_argument("--acme-url", required=True) + parser.add_argument("--acme-storage", default="acme.json") + + parser.add_argument("--sync", action="store_true", default=False) + + return parser.parse_args() diff --git a/main.py b/main.py new file mode 100644 index 0000000..98cf488 --- /dev/null +++ b/main.py @@ -0,0 +1,26 @@ +import json +import logging + +from updater.adapter import HatecomputersDNSAdapter +from args import get_args + + +def sync_records(adapter, records_path): + records_file = open(records_path, "r") + dns_records = json.load(records_file) + adapter.post_records(dns_records) + + +if __name__ == "__main__": + args = get_args() + logging.basicConfig() + logging.root.setLevel(args.log_level) + + api_key = open(args.api_key_file, "r").read().strip() + adapter = HatecomputersDNSAdapter(args.endpoint, api_key) + + if args.sync: + sync_records(adapter, args.records_file) + + if args.certbot: + logging.info("certbot mode") diff --git a/records.json b/records.json index 1629eda..f46db30 100644 --- a/records.json +++ b/records.json @@ -1,93 +1,115 @@ [ - { - "type": "A", - "name": "johan.internal.simponic.xyz.", - "content": "100.64.0.5", - "ttl": "43200", - "internal": "on" - }, - { - "type": "A", - "name": "europa.internal.simponic.xyz.", - "content": "100.64.0.8", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "vaultwarden.internal.simponic.xyz.", - "content": "johan.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "lldap.internal.simponic.xyz.", - "content": "johan.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "ca.internal.simponic.xyz.", - "content": "johan.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "pihole.internal.simponic.xyz.", - "content": "johan.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "owncloud.internal.simponic.xyz.", - "content": "europa.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "jellyfin.internal.simponic.xyz.", - "content": "europa.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "drone.internal.simponic.xyz.", - "content": "europa.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "scurvy.internal.simponic.xyz.", - "content": "europa.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "roundcube.internal.simponic.xyz.", - "content": "europa.internal.simponic.xyz.", - "ttl": "43200", - "internal": "on" - }, - { - "type": "CNAME", - "name": "simponic.endpoints", - "content": "levi.simponic.xyz.", - "ttl": "43200", - "internal": "off" - }, - { - "type": "CNAME", - "name": "simponic", - "content": "simponic.xyz", - "ttl": "43200", - "internal": "off" - } + { + "type": "A", + "name": "johan.internal.simponic.xyz.", + "content": "100.64.0.5", + "ttl": "43200", + "internal": "on" + }, + { + "type": "A", + "name": "europa.internal.simponic.xyz.", + "content": "100.64.0.8", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "vaultwarden.internal.simponic.xyz.", + "content": "johan.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "lldap.internal.simponic.xyz.", + "content": "johan.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "ca.internal.simponic.xyz.", + "content": "johan.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "pihole.internal.simponic.xyz.", + "content": "johan.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "owncloud.internal.simponic.xyz.", + "content": "europa.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "jellyfin.internal.simponic.xyz.", + "content": "europa.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "drone.internal.simponic.xyz.", + "content": "europa.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "scurvy.internal.simponic.xyz.", + "content": "europa.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "roundcube.internal.simponic.xyz.", + "content": "europa.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "simponic.endpoints", + "content": "levi.simponic.xyz.", + "ttl": "43200", + "internal": "off" + }, + { + "type": "CNAME", + "name": "simponic", + "content": "simponic.xyz.", + "ttl": "43200", + "internal": "off" + }, + + { + "type": "A", + "name": "armin.internal.simponic.xyz", + "content": "100.64.0.6", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "dev.armin.internal.simponic.xyz", + "content": "armin.internal.simponic.xyz", + "ttl": "43200", + "internal": "on" + }, + { + "type": "CNAME", + "name": "traefik.armin.internal.simponic.xyz.", + "content": "armin.internal.simponic.xyz.", + "ttl": "43200", + "internal": "on" + } ] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 diff --git a/script.py b/script.py deleted file mode 100644 index 4315de9..0000000 --- a/script.py +++ /dev/null @@ -1,37 +0,0 @@ -import json -import requests -import time -import logging - -RECORDS_FILE = "records.json" -ENDPOINT = "https://hatecomputers.club" -API_KEY = open('apikey.secret', 'r').read().strip() - -class HatecomputersDNSAdapter: - def __init__(self, endpoint, api_key): - self.endpoint = endpoint - self.session = requests.Session() - self.headers = {'Authorization': 'Bearer ' + api_key} - - def post_record(self, record): - endpoint = self.endpoint + "/dns" - logging.info(f"adding {record} to {endpoint}") - - self.session.post(endpoint, headers=self.headers, data=record) - - def post_records(self, dns_entries, eepy_time=0.25): - for record in dns_entries: - self.post_record(record) - - logging.info(f"sleeping for {eepy_time}") - time.sleep(eepy_time) - -if __name__ == "__main__": - logging.basicConfig() - logging.root.setLevel(logging.NOTSET) - - records_file = open(RECORDS_FILE, 'r') - dns_records = json.load(records_file) - - adapter = HatecomputersDNSAdapter(ENDPOINT, API_KEY) - adapter.post_records(dns_records) diff --git a/updater/acmedns.py b/updater/acmedns.py new file mode 100644 index 0000000..400f91e --- /dev/null +++ b/updater/acmedns.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +import json +import os +import requests +import sys + +### EDIT THESE: Configuration values ### + +# URL to acme-dns instance +ACMEDNS_URL = "https://auth.acme-dns.io" +# Path for acme-dns credential storage +STORAGE_PATH = "/etc/letsencrypt/acmedns.json" +# Whitelist for address ranges to allow the updates from +# Example: ALLOW_FROM = ["192.168.10.0/24", "::1/128"] +ALLOW_FROM = [] +# Force re-registration. Overwrites the already existing acme-dns accounts. +FORCE_REGISTER = False + +### DO NOT EDIT BELOW THIS POINT ### +### HERE BE DRAGONS ### + +DOMAIN = os.environ["CERTBOT_DOMAIN"] +if DOMAIN.startswith("*."): + DOMAIN = DOMAIN[2:] +VALIDATION_DOMAIN = "_acme-challenge." + DOMAIN +VALIDATION_TOKEN = os.environ["CERTBOT_VALIDATION"] + + +class AcmeDnsClient(object): + """ + Handles the communication with ACME-DNS API + """ + + def __init__(self, acmedns_url): + self.acmedns_url = acmedns_url + + def register_account(self, allowfrom): + """Registers a new ACME-DNS account""" + + if allowfrom: + # Include whitelisted networks to the registration call + reg_data = {"allowfrom": allowfrom} + res = requests.post( + self.acmedns_url + "/register", data=json.dumps(reg_data) + ) + else: + res = requests.post(self.acmedns_url + "/register") + if res.status_code == 201: + # The request was successful + return res.json() + else: + # Encountered an error + msg = ( + "Encountered an error while trying to register a new acme-dns " + "account. HTTP status {}, Response body: {}" + ) + print(msg.format(res.status_code, res.text)) + sys.exit(1) + + def update_txt_record(self, account, txt): + """Updates the TXT challenge record to ACME-DNS subdomain.""" + update = {"subdomain": account["subdomain"], "txt": txt} + headers = { + "X-Api-User": account["username"], + "X-Api-Key": account["password"], + "Content-Type": "application/json", + } + res = requests.post( + self.acmedns_url + "/update", headers=headers, data=json.dumps(update) + ) + if res.status_code == 200: + # Successful update + return + else: + msg = ( + "Encountered an error while trying to update TXT record in " + "acme-dns. \n" + "------- Request headers:\n{}\n" + "------- Request body:\n{}\n" + "------- Response HTTP status: {}\n" + "------- Response body: {}" + ) + s_headers = json.dumps(headers, indent=2, sort_keys=True) + s_update = json.dumps(update, indent=2, sort_keys=True) + s_body = json.dumps(res.json(), indent=2, sort_keys=True) + print(msg.format(s_headers, s_update, res.status_code, s_body)) + sys.exit(1) + + +class Storage(object): + def __init__(self, storagepath): + self.storagepath = storagepath + self._data = self.load() + + def load(self): + """Reads the storage content from the disk to a dict structure""" + data = dict() + filedata = "" + try: + with open(self.storagepath, "r") as fh: + filedata = fh.read() + except IOError as e: + if os.path.isfile(self.storagepath): + # Only error out if file exists, but cannot be read + print("ERROR: Storage file exists but cannot be read") + sys.exit(1) + try: + data = json.loads(filedata) + except ValueError: + if len(filedata) > 0: + # Storage file is corrupted + print("ERROR: Storage JSON is corrupted") + sys.exit(1) + return data + + def save(self): + """Saves the storage content to disk""" + serialized = json.dumps(self._data) + try: + with os.fdopen( + os.open(self.storagepath, os.O_WRONLY | os.O_CREAT, 0o600), "w" + ) as fh: + fh.truncate() + fh.write(serialized) + except IOError as e: + print("ERROR: Could not write storage file.") + sys.exit(1) + + def put(self, key, value): + """Puts the configuration value to storage and sanitize it""" + # If wildcard domain, remove the wildcard part as this will use the + # same validation record name as the base domain + if key.startswith("*."): + key = key[2:] + self._data[key] = value + + def fetch(self, key): + """Gets configuration value from storage""" + try: + return self._data[key] + except KeyError: + return None + + +if __name__ == "__main__": + # Init + client = AcmeDnsClient(ACMEDNS_URL) + storage = Storage(STORAGE_PATH) + + # Check if an account already exists in storage + account = storage.fetch(DOMAIN) + if FORCE_REGISTER or not account: + # Create and save the new account + account = client.register_account(ALLOW_FROM) + storage.put(DOMAIN, account) + storage.save() + + # Display the notification for the user to update the main zone + msg = "Please add the following CNAME record to your main DNS zone:\n{}" + cname = "{} CNAME {}.".format(VALIDATION_DOMAIN, account["fulldomain"]) + print(msg.format(cname)) + + # Update the TXT record in acme-dns instance + client.update_txt_record(account, VALIDATION_TOKEN) diff --git a/updater/adapter.py b/updater/adapter.py new file mode 100644 index 0000000..b0980a3 --- /dev/null +++ b/updater/adapter.py @@ -0,0 +1,24 @@ +import requests +import time +import logging + + +class HatecomputersDNSAdapter: + def __init__(self, endpoint, api_key, logger=None): + self.endpoint = endpoint + self.session = requests.Session() + self.headers = {"Authorization": "Bearer " + api_key} + self.logger = logger or logging.getLogger(__name__) + + def post_record(self, record): + endpoint = self.endpoint + "/dns" + self.logger.info(f"adding {record} to {endpoint}") + + self.session.post(endpoint, headers=self.headers, data=record) + + def post_records(self, dns_entries, eepy_time=0.25): + for record in dns_entries: + self.post_record(record) + + self.logger.info(f"sleeping for {eepy_time}") + time.sleep(eepy_time)