DDNS compatible with Technitium API and RFC2136
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.idea
|
||||||
|
.env
|
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# copy requirements.txt to current folder
|
||||||
|
COPY src/requirements.txt .
|
||||||
|
|
||||||
|
# install runtime deps
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY src/technitium-ddns.py .
|
||||||
|
RUN chmod +x ./technitium-ddns.py
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/python", "/app/update_tdns_records.py"]
|
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
technitium-ddns:
|
||||||
|
build: .
|
||||||
|
container_name: technitium-ddns
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
1
src/requirements.txt
Normal file
1
src/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dnspython
|
231
src/technitium-ddns.py
Normal file
231
src/technitium-ddns.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
update_tdns_records.py
|
||||||
|
|
||||||
|
Supports two update backends:
|
||||||
|
- HTTP_API: Technitium DNS HTTP API (token-based)
|
||||||
|
- RFC2136: standard DNS dynamic update (optionally TSIG authenticated)
|
||||||
|
|
||||||
|
Environment variables (main):
|
||||||
|
- BACKEND ("HTTP_API" or "RFC2136") default "HTTP_API"
|
||||||
|
- RUN_ONCE "1" to run once and exit, default "0" (run loop)
|
||||||
|
- UPDATE_INTERVAL seconds between runs when not RUN_ONCE, default 300
|
||||||
|
- ZONE e.g. "pippo.com"
|
||||||
|
- RECORD_NAME e.g. "fi-vg3" (the script will append the zone: fi-vg3.pippo.com)
|
||||||
|
- TTL integer TTL for records, default 300
|
||||||
|
- SKIP_IPV6 "1" to skip AAAA handling
|
||||||
|
-- HTTP API specific --
|
||||||
|
- TDNS_HOST e.g. "http://technitium:5380"
|
||||||
|
- TDNS_API_TOKEN your Technitium API token
|
||||||
|
-- RFC2136 specific --
|
||||||
|
- RFC2136_SERVER IP or hostname of authoritative server (or Technitium host)
|
||||||
|
- RFC2136_PORT port, default 53
|
||||||
|
- RFC2136_TSIG_NAME optional TSIG key name
|
||||||
|
- RFC2136_TSIG_KEY optional TSIG key (base64)
|
||||||
|
- RFC2136_TSIG_ALGO optional TSIG algorithm, e.g. "hmac-sha256." (note trailing dot accepted)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
from typing import Optional, Tuple, Dict, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import dns.resolver
|
||||||
|
import dns.update
|
||||||
|
import dns.query
|
||||||
|
import dns.tsigkeyring
|
||||||
|
import dns.exception
|
||||||
|
|
||||||
|
# Configuration loading
|
||||||
|
BACKEND = os.environ.get("BACKEND", "HTTP_API").upper()
|
||||||
|
RUN_ONCE = os.environ.get("RUN_ONCE", "0") == "1"
|
||||||
|
UPDATE_INTERVAL = int(os.environ.get("UPDATE_INTERVAL", "300"))
|
||||||
|
|
||||||
|
ZONE = os.environ.get("ZONE").strip().rstrip(".")
|
||||||
|
RECORD_NAME = os.environ.get("RECORD_NAME").strip()
|
||||||
|
TTL = int(os.environ.get("TTL", "300"))
|
||||||
|
SKIP_IPV6 = os.environ.get("SKIP_IPV6", "0") == "1"
|
||||||
|
|
||||||
|
TDNS_HOST = os.environ.get("TDNS_HOST")
|
||||||
|
TDNS_API_TOKEN = os.environ.get("TDNS_API_TOKEN")
|
||||||
|
|
||||||
|
RFC2136_SERVER = os.environ.get("RFC2136_SERVER")
|
||||||
|
RFC2136_PORT = int(os.environ.get("RFC2136_PORT", "53"))
|
||||||
|
RFC2136_TSIG_NAME = os.environ.get("RFC2136_TSIG_NAME")
|
||||||
|
RFC2136_TSIG_KEY = os.environ.get("RFC2136_TSIG_KEY")
|
||||||
|
RFC2136_TSIG_ALGO = os.environ.get("RFC2136_TSIG_ALGO", "hmac-sha256.")
|
||||||
|
|
||||||
|
FULL_DOMAIN = f"{RECORD_NAME}.{ZONE}".rstrip(".")
|
||||||
|
|
||||||
|
# helpers to fetch public IPs
|
||||||
|
def get_public_ipv4(timeout=10) -> Optional[str]:
|
||||||
|
candidates = [
|
||||||
|
("https://api.ipify.org", {"format": "text"}),
|
||||||
|
("https://v4.ifconfig.co/ip", None),
|
||||||
|
("https://ifconfig.me/ip", None),
|
||||||
|
]
|
||||||
|
for url, params in candidates:
|
||||||
|
try:
|
||||||
|
r = requests.get(url, params=params, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
ip = r.text.strip()
|
||||||
|
# basic validation
|
||||||
|
socket.inet_pton(socket.AF_INET, ip)
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_public_ipv6(timeout=10) -> Optional[str]:
|
||||||
|
candidates = [
|
||||||
|
("https://api64.ipify.org", {"format": "text"}),
|
||||||
|
("https://v6.ifconfig.co/ip", None),
|
||||||
|
]
|
||||||
|
for url, params in candidates:
|
||||||
|
try:
|
||||||
|
r = requests.get(url, params=params, timeout=timeout)
|
||||||
|
r.raise_for_status()
|
||||||
|
ip = r.text.strip()
|
||||||
|
socket.inet_pton(socket.AF_INET6, ip)
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Technitium HTTP API backend
|
||||||
|
def http_api_update(record_type: str, ip: str) -> Tuple[bool, Any]:
|
||||||
|
"""
|
||||||
|
Uses Technitium /api/zones/records/add with overwrite=true.
|
||||||
|
"""
|
||||||
|
if not TDNS_API_TOKEN:
|
||||||
|
return False, "Missing TDNS_API_TOKEN"
|
||||||
|
endpoint = TDNS_HOST.rstrip("/") + "/api/zones/records/add"
|
||||||
|
params = {
|
||||||
|
"token": TDNS_API_TOKEN,
|
||||||
|
"domain": FULL_DOMAIN,
|
||||||
|
"type": record_type,
|
||||||
|
"ttl": str(TTL),
|
||||||
|
"text": ip,
|
||||||
|
"overwrite": "true",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
r = requests.get(endpoint, params=params, timeout=15)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return False, f"HTTP {r.status_code}: {r.text}"
|
||||||
|
try:
|
||||||
|
return True, r.json()
|
||||||
|
except ValueError:
|
||||||
|
return True, r.text
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
# RFC2136 backend
|
||||||
|
def rfc2136_update(record_type: str, ip: str) -> Tuple[bool, Any]:
|
||||||
|
"""
|
||||||
|
Build a DNS update packet for ZONE, replace the RR for FULL_DOMAIN of type record_type.
|
||||||
|
Requires RFC2136_SERVER to be set. Optional TSIG key via RFC2136_TSIG_* env vars.
|
||||||
|
"""
|
||||||
|
if not RFC2136_SERVER:
|
||||||
|
return False, "Missing RFC2136_SERVER env"
|
||||||
|
try:
|
||||||
|
# optionally create tsig keyring
|
||||||
|
keyring = None
|
||||||
|
if RFC2136_TSIG_NAME and RFC2136_TSIG_KEY:
|
||||||
|
key_name = RFC2136_TSIG_NAME.rstrip(".")
|
||||||
|
keyring = dns.tsigkeyring.from_text({key_name: RFC2136_TSIG_KEY})
|
||||||
|
update = dns.update.Update(ZONE, keyring=keyring, keyname=RFC2136_TSIG_NAME, keyalgorithm=RFC2136_TSIG_ALGO if RFC2136_TSIG_ALGO else None)
|
||||||
|
# delete existing records for that name/type and add new
|
||||||
|
update.delete(FULL_DOMAIN + ".", record_type)
|
||||||
|
update.add(FULL_DOMAIN + ".", TTL, record_type, ip)
|
||||||
|
# send to server
|
||||||
|
response = dns.query.tcp(update, RFC2136_SERVER, timeout=10, port=RFC2136_PORT)
|
||||||
|
rcode = response.rcode()
|
||||||
|
if rcode == 0:
|
||||||
|
return True, "OK"
|
||||||
|
else:
|
||||||
|
return False, f"DNS rcode {rcode}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
# function to query current records (to avoid unnecessary updates)
|
||||||
|
def query_current_records(record_type: str) -> list:
|
||||||
|
try:
|
||||||
|
resolver = dns.resolver
|
||||||
|
resolver.nameservers = ['192.168.178.2']
|
||||||
|
answers = resolver.resolve(FULL_DOMAIN, record_type, lifetime=5)
|
||||||
|
return [r.to_text() for r in answers]
|
||||||
|
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout, dns.resolver.NoNameservers):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update_once() -> Dict[str, Any]:
|
||||||
|
out = {"domain": FULL_DOMAIN, "backend": BACKEND, "results": []}
|
||||||
|
v4 = get_public_ipv4()
|
||||||
|
v6 = None if SKIP_IPV6 else get_public_ipv6()
|
||||||
|
|
||||||
|
if not v4 and not v6:
|
||||||
|
out["error"] = "no_public_ip"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# IPv4
|
||||||
|
if v4:
|
||||||
|
current = query_current_records("A")
|
||||||
|
if current and v4 in current:
|
||||||
|
out["results"].append({"type": "A", "ip": v4, "status": "skipped", "reason": "already_set", "current": current})
|
||||||
|
else:
|
||||||
|
if BACKEND == "HTTP_API":
|
||||||
|
ok, info = http_api_update("A", v4)
|
||||||
|
else:
|
||||||
|
ok, info = rfc2136_update("A", v4)
|
||||||
|
out["results"].append({"type": "A", "ip": v4, "ok": ok, "info": info})
|
||||||
|
else:
|
||||||
|
out["results"].append({"type": "A", "ip": None, "status": "skipped", "reason": "no_ipv4"})
|
||||||
|
|
||||||
|
# IPv6
|
||||||
|
if not SKIP_IPV6:
|
||||||
|
if v6:
|
||||||
|
current6 = query_current_records("AAAA")
|
||||||
|
if current6 and v6 in current6:
|
||||||
|
out["results"].append({"type": "AAAA", "ip": v6, "status": "skipped", "reason": "already_set", "current": current6})
|
||||||
|
else:
|
||||||
|
if BACKEND == "HTTP_API":
|
||||||
|
ok, info = http_api_update("AAAA", v6)
|
||||||
|
else:
|
||||||
|
ok, info = rfc2136_update("AAAA", v6)
|
||||||
|
out["results"].append({"type": "AAAA", "ip": v6, "ok": ok, "info": info})
|
||||||
|
else:
|
||||||
|
out["results"].append({"type": "AAAA", "ip": None, "status": "skipped", "reason": "no_ipv6"})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def pretty_print(result: Dict[str, Any]):
|
||||||
|
from pprint import pformat
|
||||||
|
print(pformat(result))
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Starting updater for {FULL_DOMAIN} (zone {ZONE}) backend={BACKEND}")
|
||||||
|
if BACKEND not in ("HTTP_API", "RFC2136"):
|
||||||
|
print("ERROR: BACKEND must be HTTP_API or RFC2136", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if BACKEND == "HTTP_API" and not TDNS_API_TOKEN:
|
||||||
|
print("ERROR: TDNS_API_TOKEN required for HTTP_API backend", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if BACKEND == "RFC2136" and not RFC2136_SERVER:
|
||||||
|
print("ERROR: RFC2136_SERVER required for RFC2136 backend", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if not ZONE:
|
||||||
|
print("ERROR: ZONE not defined", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if not RECORD_NAME:
|
||||||
|
print("ERROR: RECORD_NAME not defined", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
res = update_once()
|
||||||
|
pretty_print(res)
|
||||||
|
if RUN_ONCE:
|
||||||
|
break
|
||||||
|
time.sleep(UPDATE_INTERVAL)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Reference in New Issue
Block a user