🗝
summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.envrc2
-rw-r--r--.gitignore3
-rw-r--r--config5
-rw-r--r--lib.py49
-rwxr-xr-xporkbun.sh109
-rw-r--r--register.py7
-rw-r--r--renew.py31
7 files changed, 206 insertions, 0 deletions
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())