First version
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
.venv
|
122
README.md
Normal file
122
README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Technitium Zone Exporter
|
||||
|
||||
This tool watches a directory for changes in [Technitium DNS Server](https://technitium.com/dns/) zone files.
|
||||
When a change is detected, it:
|
||||
|
||||
1. Exports zones via the Technitium DNS API.
|
||||
2. Writes the exported zones into a Git repository.
|
||||
3. Commits (and optionally pushes) the changes.
|
||||
|
||||
Useful for keeping DNS zones under version control automatically.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
- Watches any directory (using `watchdog`) and debounces rapid changes.
|
||||
- Can export **all zones** or just the zone corresponding to the changed file.
|
||||
- Commits with timestamp and changed file info.
|
||||
- Reads config from environment variables.
|
||||
- Runs continuously as a systemd service.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
- Python 3.8+
|
||||
- [watchdog](https://pypi.org/project/watchdog/)
|
||||
- [requests](https://pypi.org/project/requests/)
|
||||
- Git installed and repo initialized at the target directory
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
pip install watchdog requests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|------------------------|----------|----------------------------------------------------------|
|
||||
| `TECHNITIUM_ZONE_DIR` | *(none)* | Directory where Technitium stores the zone files. |
|
||||
| `TECHNITIUM_API_BASE` | *(none)* | Technitium URL with protocol and port |
|
||||
| `TECHNITIUM_API_TOKEN` | *(none)* | API token for Technitium DNS. |
|
||||
| `GIT_REPO_DIR` | *(none)* | Zone Git repository path |
|
||||
| `GIT_AUTHOR_NAME` | *(none)* | Author name of the git commits |
|
||||
| `GIT_AUTHOR_EMAIL` | *(none)* | Mail address of the autor of git commits |
|
||||
| `GIT_PUSH` | *(none)* | Boolean (True/False) to enable commits push |
|
||||
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`). |
|
||||
|
||||
---
|
||||
|
||||
## Running Manually
|
||||
|
||||
Export all zones immediately:
|
||||
```bash
|
||||
TECHNITIUM_API_TOKEN="yourtoken" LOG_LEVEL=DEBUG python3 technitium_zone_exporter.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running as a Systemd Service
|
||||
|
||||
### 1. Service file
|
||||
Create `/etc/systemd/system/technitium-zone-exporter.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Technitium DNS zone auto-exporter
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/technitium_zone_exporter/src/technitium_zone_exporter.py
|
||||
WorkingDirectory=/opt/technitium_zone_exporter
|
||||
EnvironmentFile=/etc/technitium-zone-exporter.env
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### 2. Environment file
|
||||
Create `/etc/technitium-zone-exporter.env`:
|
||||
|
||||
```bash
|
||||
TECHNITIUM_ZONE_DIR=technitium_zone_dir
|
||||
TECHNITIUM_API_BASE=technitium_url
|
||||
TECHNITIUM_API_TOKEN=technitium_token
|
||||
|
||||
GIT_REPO_DIR=git_repo_dir
|
||||
GIT_AUTHOR_NAME=technitium_git_user
|
||||
GIT_AUTHOR_EMAIL=technitium_git_user_mail
|
||||
GIT_PUSH=True
|
||||
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
### 3. Enable & start
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable technitium-zone-exporter
|
||||
sudo systemctl start technitium-zone-exporter
|
||||
sudo systemctl status technitium-zone-exporter
|
||||
```
|
||||
|
||||
Logs:
|
||||
```bash
|
||||
journalctl -u technitium-zone-exporter -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- The script automatically runs:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "Technitium zone export: <timestamp>"
|
||||
git push
|
||||
```
|
||||
- Make sure the service user has push access to the remote repo.
|
39
src/DebouncedHandler.py
Normal file
39
src/DebouncedHandler.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import threading
|
||||
import logging
|
||||
|
||||
from watchdog.events import PatternMatchingEventHandler
|
||||
|
||||
from config import *
|
||||
from helpers import export_single_zone
|
||||
|
||||
# Internal state for debounce
|
||||
debounce_timer = None
|
||||
debounce_lock = threading.Lock()
|
||||
|
||||
def run_export(trigger_path):
|
||||
global debounce_timer
|
||||
with debounce_lock:
|
||||
debounce_timer = None
|
||||
try:
|
||||
export_single_zone(trigger_path)
|
||||
except Exception:
|
||||
logging.exception("Export run failed.")
|
||||
|
||||
def schedule_export(trigger_path):
|
||||
global debounce_timer
|
||||
with debounce_lock:
|
||||
if debounce_timer is not None:
|
||||
debounce_timer.cancel()
|
||||
debounce_timer = threading.Timer(DEBOUNCE_SECONDS, run_export, args=(trigger_path,))
|
||||
debounce_timer.daemon = True
|
||||
debounce_timer.start()
|
||||
logging.debug("Debounce timer started/reset (%.1fs)", DEBOUNCE_SECONDS)
|
||||
|
||||
class DebouncedHandler(PatternMatchingEventHandler):
|
||||
def __init__(self, patterns=None, ignore_patterns=None, ignore_directories=False, case_sensitive=True):
|
||||
super().__init__(patterns=patterns or ["*"], ignore_patterns=ignore_patterns or [], ignore_directories=ignore_directories, case_sensitive=case_sensitive)
|
||||
|
||||
def on_any_event(self, event):
|
||||
# When any matching event occurs, start/reset debounce timer
|
||||
logging.debug(f"Filesystem event: {event.event_type} on {event.src_path}")
|
||||
schedule_export(event.src_path)
|
30
src/config.py
Normal file
30
src/config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
# Directory to watch for changes (e.g., technitium config folder where zone files are modified)
|
||||
WATCH_DIR = os.environ.get("TECHNITIUM_ZONE_DIR")
|
||||
|
||||
# Git repo directory where exports will be stored (must be a git repo)
|
||||
GIT_REPO_DIR = os.environ.get("GIT_REPO_DIR")
|
||||
|
||||
# Technitium API settings
|
||||
TECHNITIUM_API_BASE = os.environ.get("TECHNITIUM_API_BASE")
|
||||
API_TOKEN = os.environ.get("TECHNITIUM_API_TOKEN")
|
||||
|
||||
# API endpoints
|
||||
LIST_ZONES_ENDPOINT = "/api/zones/list"
|
||||
EXPORT_ZONE_ENDPOINT = "/api/zones/export"
|
||||
|
||||
# Git options
|
||||
GIT_AUTHOR_NAME = os.environ.get("GIT_AUTHOR_NAME")
|
||||
GIT_AUTHOR_EMAIL = os.environ.get("GIT_AUTHOR_EMAIL")
|
||||
GIT_PUSH = os.environ.get("GIT_PUSH")
|
||||
|
||||
# Debounce (seconds) to coalesce many quick FS events into a single export
|
||||
DEBOUNCE_SECONDS = 2.0
|
||||
|
||||
# Domain regex
|
||||
DOMAIN_FRAGMENT_RE = re.compile(r"([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9][a-z0-9\-]*)+)", re.IGNORECASE)
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
19
src/git.py
Normal file
19
src/git.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from config import *
|
||||
|
||||
def run_git_cmd(args, check=True, capture_output=False):
|
||||
cmd = ["git", "-C", GIT_REPO_DIR] + args
|
||||
logging.debug(f"Running git: {" ".join(cmd)}")
|
||||
return subprocess.run(cmd, check=check, capture_output=capture_output, text=True)
|
||||
|
||||
|
||||
def ensure_git_repo():
|
||||
gitdir = Path(GIT_REPO_DIR) / ".git"
|
||||
if not gitdir.exists():
|
||||
logging.error(f"Git repo not found at {GIT_REPO_DIR} (no .git directory). Initialize or set correct path.")
|
||||
sys.exit(2)
|
132
src/helpers.py
Normal file
132
src/helpers.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from config import *
|
||||
from git import run_git_cmd, ensure_git_repo
|
||||
from technitium import list_zones, export_zone
|
||||
|
||||
def write_zone_export(zone_name, content) -> Path:
|
||||
dest_dir = Path(GIT_REPO_DIR)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = zone_name.replace("/", "_")
|
||||
out_path = dest_dir / f"db.{safe_name}"
|
||||
mode = "w"
|
||||
logging.info(f"Writing export for zone {zone_name} -> {out_path}")
|
||||
with open(out_path, mode, encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return out_path
|
||||
|
||||
def commit_and_push(changed_files, trigger_path):
|
||||
# Stage files
|
||||
try:
|
||||
# Add only the exports folder (keeps repo tidy)
|
||||
run_git_cmd(["add", "-A"])
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.exception(f"git add failed: {e}")
|
||||
return
|
||||
|
||||
# Check if there is anything to commit
|
||||
try:
|
||||
# git diff --cached --quiet will exit 0 if no changes staged
|
||||
subprocess.run(["git", "-C", GIT_REPO_DIR, "diff", "--cached", "--quiet"], check=True)
|
||||
logging.info("No changes to commit (nothing staged).")
|
||||
return
|
||||
except subprocess.CalledProcessError:
|
||||
# Non-zero return means there are changes staged.
|
||||
pass
|
||||
|
||||
changed_list_text = "\n".join(str(p) for p in changed_files)
|
||||
ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
commit_msg = f"Technitium zone export: {ts}\n\nTrigger: {trigger_path}\n\nChanged files:\n{changed_list_text}\n"
|
||||
env = os.environ.copy()
|
||||
env["GIT_AUTHOR_NAME"] = GIT_AUTHOR_NAME
|
||||
env["GIT_AUTHOR_EMAIL"] = GIT_AUTHOR_EMAIL
|
||||
try:
|
||||
run_git_cmd(["commit", "-m", commit_msg], check=True)
|
||||
logging.info("Committed changes to git.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.exception(f"git commit failed: {e}")
|
||||
return
|
||||
|
||||
if GIT_PUSH:
|
||||
try:
|
||||
run_git_cmd(["push"], check=True)
|
||||
logging.info("Pushed commit to remote.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.exception(f"git push failed: {e}")
|
||||
|
||||
def extract_domain_from_path(path: str) -> str|None:
|
||||
name = Path(path).name
|
||||
name_no_ext = name.rstrip(".zone")
|
||||
|
||||
candidates = set()
|
||||
|
||||
if DOMAIN_FRAGMENT_RE.search(name_no_ext):
|
||||
found = DOMAIN_FRAGMENT_RE.findall(name_no_ext)
|
||||
for f in found:
|
||||
return f
|
||||
|
||||
return None
|
||||
|
||||
def export_single_zone(trigger_path: str) -> list[Path]:
|
||||
logging.info(f"Starting export of single zone for trigger path {trigger_path})")
|
||||
ensure_git_repo()
|
||||
domain = extract_domain_from_path(trigger_path)
|
||||
|
||||
try:
|
||||
zones = list_zones()
|
||||
except Exception as e:
|
||||
logging.exception(f"Failed to list zones from API; falling back to full export: {e}")
|
||||
return export_all_zones(trigger_path)
|
||||
|
||||
if domain is not None:
|
||||
for zone in zones:
|
||||
zone_name = zone.get("name")
|
||||
if zone_name == domain:
|
||||
logging.info(f"Single matching zone found: {zone_name}")
|
||||
try:
|
||||
content = export_zone(zone)
|
||||
out = write_zone_export(zone_name, content)
|
||||
commit_and_push([out], trigger_path)
|
||||
return [out]
|
||||
except Exception as e:
|
||||
logging.exception(f"Failed to export zone {zone_name}; falling back to full export: {e}")
|
||||
return export_all_zones(trigger_path)
|
||||
|
||||
logging.info(f"No unique match found for {domain}; falling back to full export")
|
||||
return export_all_zones(trigger_path)
|
||||
|
||||
else:
|
||||
logging.info(f"No domain found for trigger path {trigger_path}; falling back to full export")
|
||||
return export_all_zones(trigger_path)
|
||||
|
||||
def export_all_zones(trigger_path: str ="filesystem-change") -> list[Path]:
|
||||
logging.info(f"Starting export of all zones (trigger={trigger_path})")
|
||||
ensure_git_repo()
|
||||
|
||||
try:
|
||||
zones = list_zones()
|
||||
except Exception as e:
|
||||
logging.exception(f"Failed to list zones from API: {e}")
|
||||
return []
|
||||
|
||||
written_files = []
|
||||
for z in zones:
|
||||
# zone may be a dict with keys like 'id' and 'domain' — adapt to your API result shape
|
||||
zone_name = z.get("name")
|
||||
try:
|
||||
content = export_zone(z)
|
||||
out = write_zone_export(zone_name, content)
|
||||
written_files.append(out)
|
||||
except Exception as e:
|
||||
logging.exception(f"Failed to export zone {zone_name}: {e}")
|
||||
|
||||
if written_files:
|
||||
commit_and_push(written_files, trigger_path)
|
||||
else:
|
||||
logging.info("No zone files were written; skipping commit.")
|
||||
|
||||
return written_files
|
29
src/technitium.py
Normal file
29
src/technitium.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from config import *
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
def list_zones() -> list[dict]:
|
||||
url = f"{TECHNITIUM_API_BASE.rstrip("/")}{LIST_ZONES_ENDPOINT}?token={API_TOKEN}"
|
||||
logging.debug(f"Listing zones from {url}")
|
||||
r = session.get(url, timeout=15)
|
||||
r.raise_for_status()
|
||||
try:
|
||||
response = r.json()
|
||||
except ValueError:
|
||||
logging.error(f"List zones endpoint did not return JSON; got: {r.text}")
|
||||
raise
|
||||
try:
|
||||
return response['response']['zones']
|
||||
except KeyError:
|
||||
logging.error(f"Response did not include zones; got {response}")
|
||||
raise
|
||||
|
||||
|
||||
def export_zone(zone_name) -> str:
|
||||
url = f"{TECHNITIUM_API_BASE.rstrip("/")}{EXPORT_ZONE_ENDPOINT}?token={API_TOKEN}&zone={zone_name}"
|
||||
r = session.get(url, timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.text
|
53
src/technitium_zone_exporter.py
Normal file
53
src/technitium_zone_exporter.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from watchdog.observers import Observer
|
||||
from pathlib import Path
|
||||
|
||||
from config import *
|
||||
from helpers import export_all_zones
|
||||
from DebouncedHandler import DebouncedHandler
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
def main():
|
||||
# sanity checks
|
||||
if not Path(WATCH_DIR).exists():
|
||||
logging.error(f"Watch directory does not exist: {WATCH_DIR}")
|
||||
sys.exit(1)
|
||||
if not Path(GIT_REPO_DIR).exists():
|
||||
logging.error(f"Git repo directory does not exist: {GIT_REPO_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
logging.info(f"Watching {WATCH_DIR} for changes; exports will be written to {GIT_REPO_DIR}")
|
||||
event_handler = DebouncedHandler(ignore_directories=False)
|
||||
|
||||
observer = Observer()
|
||||
observer.schedule(event_handler, WATCH_DIR, recursive=True)
|
||||
observer.start()
|
||||
|
||||
# initial export on startup
|
||||
try:
|
||||
export_all_zones(trigger_path="startup")
|
||||
except Exception as e:
|
||||
logging.exception(f"Initial export failed: {e}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Stopping watcher...")
|
||||
finally:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
Reference in New Issue
Block a user