From 5016065f2c1b959e578bc18cfcab226db970b401 Mon Sep 17 00:00:00 2001 From: mia Date: Sat, 8 Jun 2024 22:56:14 -0700 Subject: initial commit --- .envrc | 2 ++ .gitignore | 3 ++ config | 5 +++ lib.py | 49 +++++++++++++++++++++++++++ porkbun.sh | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ register.py | 7 ++++ renew.py | 31 +++++++++++++++++ 7 files changed, 206 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 config create mode 100644 lib.py create mode 100755 porkbun.sh create mode 100644 register.py create mode 100644 renew.py diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3446535 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +# shellcheck shell=sh +export PYTHONPATH="$PYTHONPATH:$COMMIA" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c67030d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/__pycache__/ +/state.tar +/state/ diff --git a/config b/config new file mode 100644 index 0000000..a94e776 --- /dev/null +++ b/config @@ -0,0 +1,5 @@ +CA=letsencrypt +CHALLENGETYPE=dns-01 +BASEDIR=state +HOOK=./porkbun.sh +CONTACT_EMAIL=mia@mia.jetzt diff --git a/lib.py b/lib.py new file mode 100644 index 0000000..3aee069 --- /dev/null +++ b/lib.py @@ -0,0 +1,49 @@ +import os +import shutil +import tarfile +import time + +from commia.bearer import get_key, has_key, keys, set_key +from commia.prelude import * + +state_path = Path("state.tar") +state_dir = Path("state") + + +def remote_fresh() -> bool: + remote_modtime = int(get_key(keys.certificates.state_modtime)) + stat = state_path.stat() + local_modtime = round(stat.st_mtime) + return remote_modtime > local_modtime + + +def pull(): + local_exists = state_path.exists() + remote_exists = has_key(keys.certificates.state) + updated = False + if not local_exists and not remote_exists: + tarfile.open(state_path, "x").close() + updated = True + elif remote_exists: + if not local_exists or remote_fresh(): + print("[*] pulling state") + state_path.write_bytes(get_key(keys.certificates.state, decode=False)) + updated = True + if not updated: + return + if state_dir.exists(): + shutil.rmtree(state_dir) + state_dir.mkdir() + tar = tarfile.open(state_path, "r") + tar.extractall(state_dir, filter="data") + + +def push(): + os.remove(state_path) + tar = tarfile.open(state_path, "w") + for name in os.listdir(state_dir): + tar.add(state_dir / name, name, recursive=True) + tar.close() + state_data = state_path.read_bytes() + set_key(keys.certificates.state, state_data) + set_key(keys.certificates.state_modtime, str(round(time.time()))) diff --git a/porkbun.sh b/porkbun.sh new file mode 100755 index 0000000..1daae2e --- /dev/null +++ b/porkbun.sh @@ -0,0 +1,109 @@ +# dns hook for https://github.com/dehydrated-io/dehydrated +# shellcheck shell=bash + +# some code taken from: +# https://github.com/silkeh/pdns_api.sh +# https://github.com/spfguru/dehydrated4googlecloud + + +function porkbun-request() { + local endpoint="$1" json="$2" + + url="https://porkbun.com/api/json/v3/$endpoint" + body=$(echo "$json" | jq '.apikey = "'"$porkbun_api_key"'" | .secretapikey = "'"$porkbun_secret_key"'"') + resp=$(curl -X POST -sSL "$url" -H content-type:application/json --data "$body") + status=$(echo "$resp" | jq -r '.status') + if [ "$status" != "SUCCESS" ]; then + >&2 echo " ! Request to $url returned $status: $(echo "$resp" | jq -r '.message')" + exit 1 + fi + echo "$resp" +} + +function join() { local IFS="$1"; shift; echo "$*"; } + +function split-domain() { + local domain="${1}" + local all + + mapfile -t all < <(porkbun-request 'domain/listAll' '{}' | jq -r '.domains | map(.domain) | join(" ")') + + IFS='.' read -ra parts <<< "${domain}" + for (( i=${#parts[@]}-1; i>=0; i-- )); do + step="$(join . "${parts[@]:i}")" + # shellcheck disable=SC2128 # nuh uh + for check in $all; do + if [ "$step" = "$check" ]; then + echo -e "$step\n$(join . "${parts[@]:0:i}")" + return + fi + done + done + exit 1 +} + +function deploy_challenge() { + local domain="${1}" token_value="${3}" + echo " = Deploying challenge for $domain" + + mapfile -t split < <(split-domain "$domain") + challenge="_acme-challenge.${split[1]}" + porkbun-request "dns/create/${split[0]}" '{ + "name": "'"$challenge"'", + "type": "TXT", + "content": "'"$token_value"'" + }' > /dev/null + + echo " = Waiting for record to propogate" + for nameserver in $(dig "${split[0]}" NS +short); do + echo " = Checking NS $nameserver" + verified=false + while [ $verified = false ]; do + result="$(dig +short "_acme-challenge.$domain" txt "@$nameserver")" + while IFS= read -r line; do + line="${line//'"'/''}" + if [ "$line" = "$token_value" ]; then + verified=true + break + fi + done <<< "$result" + [ $verified = false ] && sleep 1 + done + done + + echo " = Deployed" +} + +function clean_challenge() { + local domain="${1}" token_value="${3}" + echo " = Cleaning up challenge for $domain" + + mapfile -t split < <(split-domain "$domain") + mapfile -t ids < <(porkbun-request "dns/retrieve/${split[0]}" '{}' \ + | jq -r '.records + | map(select(.name == "_acme-challenge.'"$domain"'" and .type == "TXT" and .content == "'"$token_value"'")) + | map(.id) + | join(" ")') + if [ "${#ids[@]}" != 0 ]; then + # shellcheck disable=SC2128 # nuh uh + for id in $ids; do + porkbun-request "dns/delete/${split[0]}/$id" '{}' > /dev/null + done + fi +} + +function config-get() { + local name="$1" + bash -c "source ~/.keys/sysconf.sh && echo \$$name" +} + +HANDLER="$1"; shift +if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge)$ ]]; then + porkbun_api_key=$(config-get PORKBUN_API_KEY) + porkbun_secret_key=$(config-get PORKBUN_SECRET_KEY) + if [ -z "$porkbun_api_key$porkbun_secret_key" ]; then + echo "missing porkbun secrets" + exit 1 + fi + "$HANDLER" "$@" +fi diff --git a/register.py b/register.py new file mode 100644 index 0000000..b2ff276 --- /dev/null +++ b/register.py @@ -0,0 +1,7 @@ +from commia.prelude import * + +from lib import pull, push + +pull() +run(["dehydrated", "--register", "--accept-terms"]) +push() diff --git a/renew.py b/renew.py new file mode 100644 index 0000000..d7efed4 --- /dev/null +++ b/renew.py @@ -0,0 +1,31 @@ +import io +from tarfile import TarFile + +from commia.bearer import keys, set_key +from commia.prelude import * + +from lib import pull, push, state_dir + +domains = [ + "mia.jetzt", + "outskirts.town", + "standardtld.com", + "void.rehab", +] + +pull() +with (state_dir / "domains.txt").open("w") as fh: + for domain in domains: + fh.write(f"{domain} *.{domain}\n") +run(["dehydrated", "--cron"]) +run(["dehydrated", "--cleanup"]) +push() + +print("[*] packaging certs") +buffer = io.BytesIO() +tar = TarFile("certs.tar", "w", buffer) +for domain in domains: + tar.add(state_dir / f"certs/{domain}/fullchain.pem", f"{domain}.crt") + tar.add(state_dir / f"certs/{domain}/privkey.pem", f"{domain}.key") +tar.close() +set_key(keys.certificates.certs, buffer.getvalue()) -- cgit 1.4.1