aboutsummaryrefslogtreecommitdiff
path: root/updater
diff options
context:
space:
mode:
authorElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-04-10 16:13:16 -0600
committerElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-04-10 16:13:16 -0600
commit0c7ac77127e47f0130ab4961303eedd245c0ca6f (patch)
tree27fa0ea407703606c54d09303eab07afb6ae5328 /updater
parent7c62833cb3dfc28d279bc72c6a68a7a907488759 (diff)
downloadhatecomputers-dns-updater-0c7ac77127e47f0130ab4961303eedd245c0ca6f.tar.gz
hatecomputers-dns-updater-0c7ac77127e47f0130ab4961303eedd245c0ca6f.zip
initial setup
Diffstat (limited to 'updater')
-rw-r--r--updater/acmedns.py164
-rw-r--r--updater/adapter.py24
2 files changed, 188 insertions, 0 deletions
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)