🗝
about summary refs log tree commit diff
diff options
context:
space:
mode:
authormia <mia@mia.jetzt>2024-04-24 22:44:53 -0700
committermia <mia@mia.jetzt>2024-04-24 22:44:53 -0700
commit7a7bb361396e03707f1f12b10fb61f5d31d91280 (patch)
tree260b69dfa6d5d94810b4c833728e578a9e6a58c3
downloadzoner-7a7bb361396e03707f1f12b10fb61f5d31d91280.tar.gz
zoner-7a7bb361396e03707f1f12b10fb61f5d31d91280.zip
initial commit
-rw-r--r--.gitignore3
-rw-r--r--.vscode/settings.json5
-rw-r--r--LICENSE18
-rw-r--r--PKGBUILD17
-rw-r--r--README.md8
-rw-r--r--example/example.com.zone12
-rw-r--r--example/hook15
-rw-r--r--example/zoner.toml2
-rw-r--r--pyproject.toml7
-rw-r--r--zoner.py226
10 files changed, 313 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bd53336
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/pkg
+/src
+/*.pkg.*
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..d125dc5
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+    "shellcheck.ignorePatterns": {
+        "**/PKGBUILD": true
+    }
+}
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7659af8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,18 @@
+Copyright (c) 2024 mia
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/PKGBUILD b/PKGBUILD
new file mode 100644
index 0000000..880f455
--- /dev/null
+++ b/PKGBUILD
@@ -0,0 +1,17 @@
+pkgname=zoner
+pkgver=1
+pkgrel=1
+arch=(any)
+makedepends=(python-build python-installer python-wheel)
+depends=(python-requests)
+source=(pyproject.toml zoner.py)
+sha256sums=(SKIP SKIP)
+license=(MIT)
+
+build() {
+    python -m build --wheel --no-isolation
+}
+
+package() {
+    python -m installer --destdir="$pkgdir" "$srcdir"/dist/*.whl
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eee7298
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# zoner
+declarative dns management for porkbun
+
+# configuration
+create porkbun api credentials and insert them into `~/.config/zoner/zoner.toml` like in [example/zoner.toml](example/zoner.toml). then just run `zoner <domain>`, insert your data in the same format as [example/example.com.zone](example/example.com.zone). note that you need tabs between values, not spaces, but you can repeat tabs as much as you'd like
+
+# hook
+see [example/hook](example/hook) for an example hook that automatically purges dns entries from cloudflare
diff --git a/example/example.com.zone b/example/example.com.zone
new file mode 100644
index 0000000..27998b2
--- /dev/null
+++ b/example/example.com.zone
@@ -0,0 +1,12 @@
+; simple zoner example for a migadu email domain
+
+@	MX	10 aspmx1.migadu.com
+@	MX	20 aspmx2.migadu.com
+
+key1._domainkey	CNAME	key1.example.com._domainkey.migadu.com
+key2._domainkey	CNAME	key2.example.com._domainkey.migadu.com
+key3._domainkey	CNAME	key3.example.com._domainkey.migadu.com
+
+@		TXT	hosted-email-verify=abcdefgh
+@		TXT	v=spf1 include:spf.migadu.com -all
+_dmarc	TXT	v=DMARC1; p=quarantine;
diff --git a/example/hook b/example/hook
new file mode 100644
index 0000000..291419b
--- /dev/null
+++ b/example/hook
@@ -0,0 +1,15 @@
+#!/bin/bash
+
+# small example hook to purge 1.1.1.1's cache for all touched records
+
+for rec in "$@"; do
+	parts=($rec)
+	op=${parts[0]}
+	type=${parts[1]}
+	name=${parts[2]}
+	if [ "$op" != "create" ]; then
+		continue # only purge for creates
+	fi
+	echo "purge 1.1.1.1 $type $name"
+	curl -X POST "https://one.one.one.one/api/v1/purge?domain=$name&type=$type" | jq -r '.msg'
+done
diff --git a/example/zoner.toml b/example/zoner.toml
new file mode 100644
index 0000000..087eb58
--- /dev/null
+++ b/example/zoner.toml
@@ -0,0 +1,2 @@
+api_key = "<api key>"
+secret_key = "<secret key>"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..98d75e4
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,7 @@
+[project]
+name = "zoner"
+version = "1"
+dependencies = ["requests"]
+
+[project.scripts]
+zoner = "zoner:main"
diff --git a/zoner.py b/zoner.py
new file mode 100644
index 0000000..0056396
--- /dev/null
+++ b/zoner.py
@@ -0,0 +1,226 @@
+from pathlib import Path
+import tomllib
+import os
+import requests
+import subprocess
+import sys
+
+
+cfg_dir = Path.home() / ".config/zoner"
+cfg_path = cfg_dir / "zoner.toml"
+if not cfg_path.exists():
+    print(f"config file at {cfg_path} not found")
+    exit(1)
+cfg = tomllib.loads(cfg_path.read_text())
+
+
+class Record:
+    kind: str
+    name: str
+    value: str
+    ttl: int
+    meta: dict
+
+    def __init__(self, kind: str, name: str, value: str, ttl: int, **kwargs):
+        self.kind = kind
+        self.name = name
+        self.content = value
+        self.ttl = ttl
+        self.meta = kwargs
+
+    def __eq__(self, other: object):
+        if isinstance(other, Record):
+            return (
+                self.kind == other.kind
+                and self.name == other.name
+                and self.content == other.content
+                and self.ttl == other.ttl
+            )
+        else:
+            raise TypeError(f"cannot compare {type(self)} with {type(other)}")
+
+    def __str__(self):
+        return f"{self.kind}\t{self.name}\t{self.content}\t$ttl {self.ttl}"
+
+
+def parse(filename: Path, domain: str):
+    text = filename.read_text()
+    while "\t\t" in text:
+        text = text.replace("\t\t", "\t")
+    rules = {"ttl": 3600}
+    for line in text.splitlines():
+        if line.startswith(";") or len(line) == 0:  # comments and empty lines
+            continue
+        if line.startswith("$"):  # rule line
+            update_rules(rules, line)
+            continue
+        # record line
+        line_rules = rules
+        segments = line.split("\t")
+        [name, kind, content, *opts] = segments
+        if len(opts) > 0:  # line-scope rules
+            line_rules = line_rules.copy()
+            for statement in opts:
+                update_rules(line_rules, statement)
+        if name == "@":
+            recname = domain
+        else:
+            recname = f"{name}.{domain}"
+        yield Record(kind, recname, content, line_rules["ttl"], apiname=name)
+
+
+def update_rules(rules: dict, statement: str):
+    [key, value] = statement.split(" ")
+    match key:
+        case "$ttl":
+            rules["ttl"] = int(value)
+        case _:
+            raise ValueError(f"invalid rule {key}")
+
+
+def check_porkbun(resp: requests.Response):
+    if resp.status_code >= 400:
+        raise requests.HTTPError(
+            f"got code {resp.status_code}: {resp.json()}", response=resp
+        )
+
+
+def retrieve(domain: str):
+    resp = requests.post(
+        f"https://porkbun.com/api/json/v3/dns/retrieve/{domain}",
+        json={"apikey": cfg["api_key"], "secretapikey": cfg["secret_key"]},
+    )
+    check_porkbun(resp)
+    for record in resp.json()["records"]:
+        if record["type"] == "NS":
+            continue
+        if record["type"] == "MX":
+            # add prioty to content
+            record["content"] = f"{record['prio']} {record['content']}"
+        if record["ttl"] == None:
+            record["ttl"] = 600
+        else:
+            record["ttl"] = int(record["ttl"])
+        yield Record(
+            record["type"],
+            record["name"],
+            record["content"],
+            record["ttl"],
+            id=record["id"],
+        )
+
+
+def resolve(domain: str):
+    existing = list(retrieve(domain))
+    requested = list(parse(cfg_dir / f"{domain}.zone", domain))
+
+    to_delete = []
+    to_create = []
+    for item in existing:
+        for check in requested:
+            if item == check:
+                break
+        else:
+            to_delete.append(item)
+    for item in requested:
+        for check in existing:
+            if item == check:
+                break
+        else:
+            to_create.append(item)
+    return (to_delete, to_create)
+
+
+def balance(domain: str):
+    to_delete, to_create = resolve(domain)
+
+    if len(to_delete) != 0:
+        print("to delete:")
+        for record in to_delete:
+            print(record)
+        print()
+
+    if len(to_create) != 0:
+        print("to create:")
+        for record in to_create:
+            print(record)
+        print()
+
+    if len(to_delete) == 0 and len(to_create) == 0:
+        print("nothing to do")
+        exit()
+
+    ch = input("continue? [y/N] ")
+    if ch != "y":
+        exit()
+
+    for record in to_delete:
+        print(f"delete {record}")
+        resp = requests.post(
+            f"https://porkbun.com/api/json/v3/dns/delete/{domain}/{record.meta['id']}",
+            json={"apikey": cfg["api_key"], "secretapikey": cfg["secret_key"]},
+        )
+        check_porkbun(resp)
+
+    for record in to_create:
+        print(f"create {record}")
+        content = record.content
+        prio = None
+        if record.kind == "MX":
+            prio, content = content.split(" ")
+        resp = requests.post(
+            f"https://porkbun.com/api/json/v3/dns/create/{domain}",
+            json={
+                "apikey": cfg["api_key"],
+                "secretapikey": cfg["secret_key"],
+                "name": record.meta["apiname"],
+                "type": record.kind,
+                "content": content,
+                "ttl": str(record.ttl),
+                "prio": prio,
+            },
+        )
+        check_porkbun(resp)
+
+    changes = set()
+    for entry in to_create:
+        changes.add(f"create {entry.kind} {entry.name}")
+    for entry in to_delete:
+        changes.add(f"delete {entry.kind} {entry.name}")
+    return list(changes)
+
+
+def git_update(domain: str):
+    subprocess.check_call(["git", "add", f"{domain}.zone"], cwd=cfg_dir)
+    if len(subprocess.check_output(["git", "diff", "--cached"], cwd=cfg_dir)) > 0:
+        subprocess.check_call(["git", "commit", "-m", f"update {domain}"], cwd=cfg_dir)
+        subprocess.check_call(["git", "push"], cwd=cfg_dir)
+
+
+def main():
+    if len(sys.argv) == 1:  # selector
+        names = map(lambda path: path.name[:-5], cfg_dir.glob("*.zone"))
+        ret = subprocess.run(
+            ["fzf"], input="\n".join(names).encode(), stdout=subprocess.PIPE
+        )
+        if ret.returncode != 0:
+            return
+        domain = ret.stdout.decode().strip()
+    else:
+        domain = sys.argv[1]
+    subprocess.run([os.environ["EDITOR"], (cfg_dir / f"{domain}.zone").as_posix()])
+    changes = balance(domain)
+    if (cfg_dir / ".git").exists():
+        git_update(domain)
+    if (cfg_dir / "hook").exists():
+        args = []
+        subprocess.check_call(
+            [
+                (cfg_dir / "hook").as_posix(),
+                *changes,
+            ]
+        )
+
+
+if __name__ == "__main__":
+    main()