Menu
Public repository / 4fe4eb7c1983febeca1ce0b127f5eed7640d5be0
AkurAI-EC2 / libexec/akurai-ec2-dns.py
Latest change d8aa29121486f4b02d0016888d94fa66cac36ffe - Checkpoint WIP + rustfmt + AkurAI Build CI/CD pipeline by Ólafur Búi Ólafsson
#!/usr/bin/env python3
"""Manage AkurAI EC2 DNS zones, with optional legacy 1984 Hosting access."""
from __future__ import annotations
import argparse
import datetime
import html
import http.cookiejar
import json
import os
import re
import socket
import ssl
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import tomllib
from dataclasses import asdict, dataclass
BASE = "https://1984.hosting"
class DnsError(RuntimeError):
pass
@dataclass(frozen=True)
class Record:
id: str
fqdn: str
type: str
ttl: int | None
value: str
class Client:
def __init__(self) -> None:
self.jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
self.csrf = ""
def request(self, path: str, data: dict[str, str] | None = None, referer: str | None = None) -> str:
body = urllib.parse.urlencode(data).encode() if data is not None else None
headers = {"User-Agent": "akurai-ec2-dns/1"}
if referer:
headers["Referer"] = referer
if self.csrf:
headers["X-CSRFToken"] = self.csrf
try:
with self.opener.open(urllib.request.Request(BASE + path, body, headers), timeout=20) as response:
return response.read().decode("utf-8", "replace")
except urllib.error.URLError as exc:
raise DnsError(f"1984 Hosting request failed: {exc.reason}") from exc
def login(self) -> None:
username = os.environ.get("One984HOSTING_Username", "")
password = os.environ.get("One984HOSTING_Password", "")
session = os.environ.get("One984HOSTING_SESSIONID_COOKIE", "")
csrf_cookie = os.environ.get("One984HOSTING_CSRFTOKEN_COOKIE", "")
if session and csrf_cookie:
for raw in (session, csrf_cookie):
name, _, value = raw.partition("=")
self.jar.set_cookie(http.cookiejar.Cookie(0, name, value, None, False, "1984.hosting", True,
False, "/", True, False, None, False, None, None, {}))
self.csrf = csrf_cookie.partition("=")[2]
auth = json.loads(self.request("/api/auth/"))
if auth.get("ok"):
return
raise DnsError("cached 1984 Hosting session is invalid")
if not username or not password:
raise DnsError("1984 Hosting credentials unavailable; inject PassVault values as "
"One984HOSTING_Username and One984HOSTING_Password")
self.request("/accounts/login/")
self.csrf = next((cookie.value for cookie in self.jar if cookie.name == "csrftoken"), "")
otp = ""
secret = os.environ.get("One984HOSTING_TOTP_Secret", "")
if secret:
try:
otp = subprocess.run(["oathtool", "--base32", "--totp", secret], check=True,
text=True, capture_output=True).stdout.strip()
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
raise DnsError("oathtool is required for the configured 1984 Hosting TOTP") from exc
result = json.loads(self.request("/api/auth/", {
"username": username,
"password": password,
"otpkey": otp,
}, BASE + "/accounts/login/"))
if not result.get("loggedin"):
raise DnsError("1984 Hosting login failed")
self.csrf = next((cookie.value for cookie in self.jar if cookie.name == "csrftoken"), self.csrf)
def zone_id(self, zone: str) -> str:
page = self.request("/domains")
match = re.search(r'href=["\']/domains/(zone/\d+)["\'][^>]*>[^<]*' + re.escape(zone), page, re.I)
if not match:
match = re.search(r'(zone/\d+)[^\n]{0,800}' + re.escape(zone), page, re.I)
if not match:
raise DnsError(f"managed DNS zone not found: {zone}")
return match.group(1)
def records(self, zone: str) -> list[Record]:
zid = self.zone_id(zone)
page = self.request(f"/domains/{zid}")
rows = re.findall(r'<tr\b[^>]*>.*?</tr>', page, re.I | re.S)
records: list[Record] = []
for row in rows:
entry = re.search(r'entry_(\d+)', row)
if not entry:
continue
text = html.unescape(re.sub(r'<[^>]+>', ' ', row))
cells = [re.sub(r'\s+', ' ', html.unescape(re.sub(r'<[^>]+>', ' ', cell))).strip()
for cell in re.findall(r'<td\b[^>]*>(.*?)</td>', row, re.I | re.S)]
joined = " | ".join(cells) if cells else re.sub(r'\s+', ' ', text).strip()
rtype_match = re.search(r'\b(A|AAAA|CAA|CNAME|MX|NS|SRV|TXT)\b', joined, re.I)
ttl_match = re.search(r'\b(\d{2,6})\b', joined)
if not rtype_match:
continue
rtype = rtype_match.group(1).upper()
host = next((cell for cell in cells if cell == "@" or cell.endswith(zone) or
re.fullmatch(r"[A-Za-z0-9_*.-]+", cell or "") and cell.upper() != rtype), "@")
fqdn = zone if host == "@" else host if host.endswith(zone) else f"{host}.{zone}"
value = cells[-1] if cells else joined
records.append(Record(entry.group(1), fqdn.rstrip('.'), rtype,
int(ttl_match.group(1)) if ttl_match else None, value))
return records
def add(self, zone: str, fqdn: str, rtype: str, value: str, ttl: int) -> None:
self.zone_id(zone)
host = "@" if fqdn.rstrip(".") == zone else fqdn.removesuffix("." + zone)
payload = {"entry": "new", "type": rtype, "ttl": str(ttl), "zone": zone,
"host": host, "rdata": f'"{value}"' if rtype == "TXT" else value}
result = json.loads(self.request("/domains/entry/", payload, BASE + "/domains"))
if result.get("haserrors") or result.get("auth") is False:
raise DnsError(f"1984 Hosting rejected {rtype} record for {fqdn}")
def delete(self, zone: str, record_id: str) -> None:
zid = self.zone_id(zone)
result = json.loads(self.request("/domains/delentry/", {"entry": record_id},
BASE + f"/domains/{zid}"))
if not result.get("ok"):
raise DnsError(f"1984 Hosting failed to delete entry {record_id}")
class Ec2Client:
def __init__(self) -> None:
self.cli = os.environ.get("AKURAI_EC2_CLI", "akurai-ec2")
@staticmethod
def _path(zone: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9.-]+", zone):
raise DnsError(f"invalid zone: {zone}")
return f"/etc/akurai-dns/zones/{zone}.toml"
def _run(self, remote: str, content: str | None = None) -> str:
result = subprocess.run([self.cli, "ssh", remote], input=content, text=True,
capture_output=True)
if result.returncode:
raise DnsError(result.stderr.strip() or "EC2 DNS operation failed")
return result.stdout
def _load(self, zone: str) -> tuple[str, dict]:
text = self._run(f"sudo cat {self._path(zone)}")
try:
return text, tomllib.loads(text)
except tomllib.TOMLDecodeError as exc:
raise DnsError(f"invalid EC2 zone file for {zone}: {exc}") from exc
def records(self, zone: str) -> list[Record]:
_, data = self._load(zone)
ttl = int(data.get("zone", {}).get("ttl", 900))
records = []
for index, item in enumerate(data.get("record", [])):
host = str(item.get("name", "@")).rstrip(".")
fqdn = zone if host == "@" else host if host.endswith(zone) else f"{host}.{zone}"
records.append(Record(str(index), fqdn, str(item.get("type", "")).upper(),
int(item.get("ttl", ttl)), str(item.get("value", ""))))
return records
@staticmethod
def _record_blocks(text: str) -> list[re.Match[str]]:
return list(re.finditer(r"(?ms)^\[\[record\]\]\n.*?(?=^\[\[record\]\]|\Z)", text))
@staticmethod
def _bump_serial(text: str) -> str:
match = re.search(r"(?m)^serial\s*=\s*(\d+)\s*$", text)
if not match:
raise DnsError("zone SOA serial is missing")
serial = max(int(match.group(1)) + 1, int(datetime.datetime.now(datetime.UTC).strftime("%Y%m%d01")))
return text[:match.start(1)] + str(serial) + text[match.end(1):]
def _save(self, zone: str, text: str) -> None:
path = self._path(zone)
try:
tomllib.loads(text)
except tomllib.TOMLDecodeError as exc:
raise DnsError(f"refusing to save invalid EC2 zone file for {zone}: {exc}") from exc
remote = (
f"set -euo pipefail; tmp=$(mktemp); backup={path}.bak-$(date -u +%Y%m%d-%H%M%S); "
"cat >$tmp; "
f"sudo cp {path} $backup; sudo install -m 0644 $tmp {path}; rm -f $tmp; "
"if ! sudo systemctl reload akurai-dns.service || "
"! systemctl is-active --quiet akurai-dns.service; then "
f"sudo cp $backup {path}; sudo systemctl restart akurai-dns.service; exit 1; fi"
)
self._run(remote, text)
def delete(self, zone: str, record_id: str) -> None:
text, _ = self._load(zone)
blocks = self._record_blocks(text)
index = int(record_id)
if index < 0 or index >= len(blocks):
raise DnsError(f"EC2 DNS record index no longer exists: {record_id}")
block = blocks[index]
updated = text[:block.start()] + text[block.end():]
self._save(zone, self._bump_serial(updated).rstrip() + "\n")
def add(self, zone: str, fqdn: str, rtype: str, value: str, ttl: int) -> None:
text, data = self._load(zone)
host = "@" if fqdn.rstrip(".") == zone else fqdn.removesuffix("." + zone)
zone_ttl = int(data.get("zone", {}).get("ttl", 900))
lines = ["[[record]]", f'name = {json.dumps(host)}', f'type = {json.dumps(rtype)}']
if ttl != zone_ttl:
lines.append(f"ttl = {ttl}")
lines.append(f'value = {json.dumps(value)}')
updated = self._bump_serial(text).rstrip() + "\n\n" + "\n".join(lines) + "\n"
self._save(zone, updated)
def infer_zone(fqdn: str, explicit: str | None) -> str:
if explicit:
return explicit.rstrip(".")
labels = fqdn.rstrip(".").split(".")
if len(labels) < 2:
raise DnsError("a fully qualified domain name is required")
return ".".join(labels[-2:])
def public_values(fqdn: str, rtype: str) -> list[str]:
url = "https://dns.google/resolve?" + urllib.parse.urlencode({"name": fqdn, "type": rtype})
try:
with urllib.request.urlopen(url, timeout=15, context=ssl.create_default_context()) as response:
payload = json.load(response)
return [str(answer["data"]).strip('"') for answer in payload.get("Answer", [])
if int(answer.get("type", 0)) > 0]
except (urllib.error.URLError, ValueError, KeyError, socket.timeout) as exc:
raise DnsError(f"public DNS verification failed: {exc}") from exc
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(prog="akurai-ec2 dns")
sub = root.add_subparsers(dest="action", required=True)
for name in ("list", "get"):
p = sub.add_parser(name)
p.add_argument("name")
p.add_argument("--zone")
p.add_argument("--type")
p.add_argument("--provider", choices=("ec2", "1984"), default=os.environ.get("AKURAI_DNS_PROVIDER", "ec2"))
p = sub.add_parser("upsert")
p.add_argument("name"); p.add_argument("type"); p.add_argument("value")
p.add_argument("--zone"); p.add_argument("--ttl", type=int, default=900); p.add_argument("--yes", action="store_true")
p.add_argument("--provider", choices=("ec2", "1984"), default=os.environ.get("AKURAI_DNS_PROVIDER", "ec2"))
p = sub.add_parser("delete")
p.add_argument("name"); p.add_argument("--zone"); p.add_argument("--type", required=True)
p.add_argument("--value"); p.add_argument("--yes", action="store_true")
p.add_argument("--provider", choices=("ec2", "1984"), default=os.environ.get("AKURAI_DNS_PROVIDER", "ec2"))
p = sub.add_parser("verify")
p.add_argument("name"); p.add_argument("--type", default="A"); p.add_argument("--absent", action="store_true")
return root
def main() -> int:
args = parser().parse_args()
fqdn = args.name.rstrip(".")
if args.action == "verify":
values = public_values(fqdn, args.type.upper())
ok = not values if args.absent else bool(values)
print(json.dumps({"name": fqdn, "type": args.type.upper(), "values": values,
"state": "absent" if not values else "present", "ok": ok}, separators=(",", ":")))
return 0 if ok else 1
if args.action in ("delete", "upsert") and not args.yes:
raise DnsError(f"{args.action} requires --yes")
zone = infer_zone(fqdn, args.zone)
client = Ec2Client() if args.provider == "ec2" else Client()
if isinstance(client, Client):
client.login()
records = client.records(zone)
selected = [record for record in records if (args.action == "list" or record.fqdn == fqdn) and
(not getattr(args, "type", None) or record.type == args.type.upper())]
if args.action in ("list", "get"):
print(json.dumps({"provider": args.provider, "zone": zone,
"records": [asdict(record) for record in selected]}, separators=(",", ":")))
return 0
# The mutation gate runs before authentication so an accidental command
# cannot even open a privileged provider session.
if args.action == "delete":
if args.value is not None:
selected = [record for record in selected if args.value in record.value]
if len(selected) != 1:
raise DnsError(f"refusing deletion: expected exactly one matching record, found {len(selected)}")
client.delete(zone, selected[0].id)
remaining = [record for record in client.records(zone)
if record.fqdn == selected[0].fqdn and record.type == selected[0].type]
if remaining:
raise DnsError("matching record still present after deletion")
print(json.dumps({"deleted": asdict(selected[0]), "ok": True}, separators=(",", ":")))
return 0
if args.action == "upsert":
ordered = reversed(selected) if isinstance(client, Ec2Client) else selected
for record in ordered:
client.delete(zone, record.id)
client.add(zone, fqdn, args.type.upper(), args.value, args.ttl)
print(json.dumps({"name": fqdn, "type": args.type.upper(), "value": args.value,
"ttl": args.ttl, "replaced": len(selected), "ok": True}, separators=(",", ":")))
return 0
raise DnsError("unsupported DNS action")
if __name__ == "__main__":
try:
raise SystemExit(main())
except DnsError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, separators=(",", ":")), file=sys.stderr)
raise SystemExit(1)