aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md41
-rw-r--r--args.py52
-rwxr-xr-x[-rw-r--r--]main.py47
-rwxr-xr-xplugin.sh14
-rw-r--r--updater/acmedns.py164
-rw-r--r--updater/utils.py21
7 files changed, 157 insertions, 183 deletions
diff --git a/.gitignore b/.gitignore
index 1cec515..679e9e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
*.secret
**/__pycache__
+certbot \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6618828
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# hatecomputers.club dns updater & certbot plugin
+
+this is a simple wrapper over hatecomputers.club's dns api
+
+## dns creation steps
+
+1. obtain an api key at [hatecomputers.club](https://hatecomputers.club)
+2. put it in `apikey.secret`
+3. modify `records.json` to your liking
+4. `./main.py --create --records-file=records.json`
+
+## certbot plugin
+
+follow the above to generate an api key.
+
+if you use the split-zone dns provided by hatecomputers.club and run your own certificate
+authority, you can try something like:
+```bash
+REQUESTS_CA_BUNDLE=~/armin/roots.pem certbot certonly \
+ --manual --manual-auth-hook ./plugin.sh \
+ --preferred-challenges dns \
+ -d *.internal.simponic.xyz \
+ --config-dir ./certbot \
+ --work-dir ./certbot \
+ --logs-dir ./certbot \
+ --server https://ca.internal.simponic.xyz/acme/ACME/directory \
+ --email simponic@hatecomputers.club \
+ --agree-tos \
+ --no-eff-email
+```
+
+otherwise:
+```bash
+sudo certbot certonly \
+ --manual --manual-auth-hook ./plugin.sh \
+ --preferred-challenges dns \
+ -d *.simponic.hatecomputers.club \
+ --email simponic@hatecomputers.club \
+ --agree-tos \
+ --no-eff-email
+```
diff --git a/args.py b/args.py
index b4d70d5..a3f400d 100644
--- a/args.py
+++ b/args.py
@@ -4,26 +4,56 @@ 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(
+ "--endpoint", default="https://hatecomputers.club", help="API endpoint"
+ )
+ parser.add_argument(
+ "--api-key-file",
+ default="apikey.secret",
+ help="path to file containing the API key",
+ )
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=False)
- parser.add_argument("--acme-storage", default="acme.json")
+ parser.add_argument(
+ "--public-suffixes",
+ default="hatecomputers.club",
+ help="comma separated list of public suffixes",
+ )
+ parser.add_argument(
+ "--dns-propogate-time",
+ default=20,
+ type=int,
+ help="time to sleep to allow DNS to propogate",
+ )
+ parser.add_argument(
+ "--certbot", action="store_true", default=False, help="enable certbot mode"
+ )
+ parser.add_argument(
+ "--certbot-domain", required=False, help="splat/domain to validate with certbot"
+ )
+ parser.add_argument(
+ "--certbot-validation", required=False, help="validation token for certbot"
+ )
- parser.add_argument("--sync", action="store_true", default=False)
- parser.add_argument("--records-file", default="records.json")
+ parser.add_argument(
+ "--create",
+ action="store_true",
+ default=False,
+ help="upload records file to API to sync",
+ )
+ parser.add_argument("--records-file", default="records.json", help="records file")
args = parser.parse_args()
- if (args.certbot) and (not args.acme_url):
- parser.error("--acme-url is required when --certbot is used")
- if (args.certbot) and (args.sync):
- parser.error("--sync cannot be used in combination with --certbot")
+ if (args.certbot) and (not args.certbot_domain):
+ parser.error("--certbot-domain is required when --certbot is used")
+ if (args.certbot) and (not args.certbot_validation):
+ parser.error("--certbot-validation is required when --certbot is used")
+ if args.certbot:
+ args.public_suffixes = args.public_suffixes.split(",")
return args
diff --git a/main.py b/main.py
index 98cf488..8693c9d 100644..100755
--- a/main.py
+++ b/main.py
@@ -1,14 +1,37 @@
+#!/usr/bin/env python3
+
+import os
import json
import logging
+import time
from updater.adapter import HatecomputersDNSAdapter
+from updater.utils import record_transformer
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)
+def certbot_mode(args, dns_api_adapter, record_transformer):
+ domain = args.certbot_domain
+ if domain.startswith("*."):
+ domain = domain[2:]
+ logging.info(f"processing domain {domain}")
+
+ record = {
+ "ttl": 60,
+ "name": "_acme-challenge." + domain,
+ "type": "TXT",
+ "content": args.certbot_validation,
+ }
+ record = record_transformer(record)
+ logging.info(f"creating record {record}")
+ dns_api_adapter.post_record(record)
+
+ logging.info(
+ f"eeping out for {args.dns_propogate_time}s, to allow DNS propogation. look at this cute little guy 🐢 until then!!"
+ )
+ time.sleep(args.dns_propogate_time)
+
+ logging.info(f"updating record for {domain} with {args.certbot_validation}")
if __name__ == "__main__":
@@ -17,10 +40,18 @@ if __name__ == "__main__":
logging.root.setLevel(args.log_level)
api_key = open(args.api_key_file, "r").read().strip()
- adapter = HatecomputersDNSAdapter(args.endpoint, api_key)
+ dns_api_adapter = HatecomputersDNSAdapter(args.endpoint, api_key)
- if args.sync:
- sync_records(adapter, args.records_file)
+ if args.create:
+ records_file = open(args.records_file, "r")
+ dns_records = json.load(records_file)
+ dns_api_adapter.post_records(dns_records)
if args.certbot:
- logging.info("certbot mode")
+ certbot_mode(
+ args,
+ dns_api_adapter,
+ record_transformer(args.public_suffixes),
+ )
+
+ logging.info("done")
diff --git a/plugin.sh b/plugin.sh
new file mode 100755
index 0000000..6474385
--- /dev/null
+++ b/plugin.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+unset REQUESTS_CA_BUNDLE
+
+API_KEY_FILE=/Users/lizzy/git/simponic/dns-updater/apikey.secret
+ENDPOINT=https://hatecomputers.club
+PUBLIC_SUFFIXES=.hatecomputers.club
+
+./main.py --certbot \
+ --public-suffixes=$PUBLIC_SUFFIXES \
+ --certbot-domain=$CERTBOT_DOMAIN \
+ --certbot-validation=$CERTBOT_VALIDATION \
+ --endpoint=$ENDPOINT \
+ --api-key-file=$API_KEY_FILE
diff --git a/updater/acmedns.py b/updater/acmedns.py
deleted file mode 100644
index 400f91e..0000000
--- a/updater/acmedns.py
+++ /dev/null
@@ -1,164 +0,0 @@
-#!/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/utils.py b/updater/utils.py
new file mode 100644
index 0000000..bf54d0e
--- /dev/null
+++ b/updater/utils.py
@@ -0,0 +1,21 @@
+import logging
+
+
+def record_transformer(public_suffixes):
+ def transform(record):
+ name = record["name"]
+ suffixes = [suffix for suffix in public_suffixes if name.endswith(suffix)]
+ suffix = suffixes[0] if suffixes else None
+
+ if suffix:
+ logging.debug(f"stripping {suffix} from {name} as it is a public suffix")
+
+ record["name"] = name[: -len(suffix)]
+ record["internal"] = "off"
+ return record
+
+ logging.debug(f"keeping {name} as it is not a public suffix")
+ record["internal"] = "on"
+ return record
+
+ return transform