From 7a7bb361396e03707f1f12b10fb61f5d31d91280 Mon Sep 17 00:00:00 2001 From: mia Date: Wed, 24 Apr 2024 22:44:53 -0700 Subject: initial commit --- .gitignore | 3 + .vscode/settings.json | 5 ++ LICENSE | 18 ++++ PKGBUILD | 17 ++++ README.md | 8 ++ example/example.com.zone | 12 +++ example/hook | 15 ++++ example/zoner.toml | 2 + pyproject.toml | 7 ++ zoner.py | 226 +++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 313 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 PKGBUILD create mode 100644 README.md create mode 100644 example/example.com.zone create mode 100644 example/hook create mode 100644 example/zoner.toml create mode 100644 pyproject.toml create mode 100644 zoner.py 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 `, 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 = "" +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() -- cgit 1.4.1