From 9331bbe246ab9416681ec9abf0a5c436f4c7fd77 Mon Sep 17 00:00:00 2001 From: bitdori Date: Wed, 15 Jul 2026 12:07:38 +0000 Subject: [PATCH] initial commit --- .gitignore | 10 +++++ .python-version | 1 + README.md | 2 + config.toml | 8 ++++ ddns.service | 23 ++++++++++++ ddns.timer | 15 ++++++++ pyproject.toml | 7 ++++ src/config.py | 7 ++++ src/ip.py | 36 ++++++++++++++++++ src/main.py | 24 ++++++++++++ src/nsupdate.py | 33 +++++++++++++++++ src/state.py | 41 +++++++++++++++++++++ src/wireguard.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++++ state.json | 3 ++ uv.lock | 8 ++++ 15 files changed, 314 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 config.toml create mode 100644 ddns.service create mode 100644 ddns.timer create mode 100644 pyproject.toml create mode 100644 src/config.py create mode 100644 src/ip.py create mode 100644 src/main.py create mode 100644 src/nsupdate.py create mode 100644 src/state.py create mode 100644 src/wireguard.py create mode 100644 state.json create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea85afd --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +dig +short A bitdori.org @139.162.118.154 +dig -4 @resolver1.opendns.com -t a myip.opendns.com +short diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..9eff59c --- /dev/null +++ b/config.toml @@ -0,0 +1,8 @@ +server = "139.162.118.154" +zone = "bitdori.org" + +hostname = "bitdori.org" + +ttl = 300 + +keyfile = "./ddns-key.bitdori.key" diff --git a/ddns.service b/ddns.service new file mode 100644 index 0000000..3c35cef --- /dev/null +++ b/ddns.service @@ -0,0 +1,23 @@ +[Unit] +Description=bitdori DDNS updater +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot + +WorkingDirectory=/opt/bitdori/ddns + +ExecStart=/opt/bitdori/ddns/.venv/bin/python \ + /opt/bitdori/ddns/src/main.py + +User=odroid +Group=odroid + +Nice=10 + +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/ddns.timer b/ddns.timer new file mode 100644 index 0000000..b33551c --- /dev/null +++ b/ddns.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Run DDNS updater periodically + +[Timer] + +OnBootSec=30s + +OnUnitActiveSec=5min + +Persistent=true + +Unit=ddns.service + +[Install] +WantedBy=timers.target diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6cf34f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "ddns" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [] diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..0627834 --- /dev/null +++ b/src/config.py @@ -0,0 +1,7 @@ +import tomllib +from pathlib import Path + + +def load_config(path: str = "config.toml") -> dict: + with Path(path).open("rb") as f: + return tomllib.load(f) diff --git a/src/ip.py b/src/ip.py new file mode 100644 index 0000000..e893ec3 --- /dev/null +++ b/src/ip.py @@ -0,0 +1,36 @@ +import subprocess + +SERVERS = [ + ("resolver1.opendns.com", "myip.opendns.com"), + ("resolver2.opendns.com", "myip.opendns.com"), + ("resolver3.opendns.com", "myip.opendns.com"), + ("resolver4.opendns.com", "myip.opendns.com"), + ("ns1-1.akamaitech.net", "whoami.akamai.net"), +] + + +def get_public_ip(): + for server, host in SERVERS: + try: + result = subprocess.run( + [ + "dig", + "+short", + host, + f"@{server}", + ], + capture_output=True, + text=True, + check=True, + timeout=5, + ) + + ip = result.stdout.strip() + + if ip: + return ip + + except Exception: + pass + + raise RuntimeError("cannot determine public IP") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..f334dc2 --- /dev/null +++ b/src/main.py @@ -0,0 +1,24 @@ +from config import load_config +from ip import get_public_ip +from state import load_state, save_success, save_failure +from nsupdate import update_dns + + +cfg = load_config() + +ip = get_public_ip() + +state = load_state() + +if state["ip"] == ip: + print("IP unchanged.") + raise SystemExit(0) + +try: + update_dns(cfg, ip) + save_success(ip) + print("DNS updated.") + +except Exception: + save_failure() + raise diff --git a/src/nsupdate.py b/src/nsupdate.py new file mode 100644 index 0000000..00ba056 --- /dev/null +++ b/src/nsupdate.py @@ -0,0 +1,33 @@ +import subprocess + + +def update_dns(cfg: dict, ip: str) -> None: + commands = f"""\ +server {cfg["server"]} +zone {cfg["zone"]} +update delete {cfg["hostname"]} A +update add {cfg["hostname"]} {cfg["ttl"]} A {ip} +send +""" + + print("===== nsupdate input =====") + print(commands) + + result = subprocess.run( + ["nsupdate", "-k", cfg["keyfile"]], + input=commands, + text=True, + capture_output=True, + ) + + print("===== return code =====") + print(result.returncode) + + print("===== stdout =====") + print(result.stdout) + + print("===== stderr =====") + print(result.stderr) + + if result.returncode != 0: + raise RuntimeError("nsupdate failed") diff --git a/src/state.py b/src/state.py new file mode 100644 index 0000000..55801a2 --- /dev/null +++ b/src/state.py @@ -0,0 +1,41 @@ +# src/state.py + +from pathlib import Path +from datetime import datetime +import json + + +STATE_FILE = Path("state.json") + + +def load_state() -> dict: + if not STATE_FILE.exists(): + return { + "ip": "", + "updated_at": "", + "failure_count": 0, + } + + return json.loads(STATE_FILE.read_text()) + + +def save_success(ip: str): + state = { + "ip": ip, + "updated_at": datetime.now().astimezone().isoformat(), + "failure_count": 0, + } + + STATE_FILE.write_text( + json.dumps(state, indent=4) + ) + + +def save_failure(): + state = load_state() + + state["failure_count"] += 1 + + STATE_FILE.write_text( + json.dumps(state, indent=4) + ) diff --git a/src/wireguard.py b/src/wireguard.py new file mode 100644 index 0000000..10c96fe --- /dev/null +++ b/src/wireguard.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import ipaddress +import shutil +import socket +import subprocess +from dataclasses import dataclass + + +class WireGuardUnavailable(RuntimeError): + """WireGuard 터널을 통해 DDNS 서버에 도달할 수 없을 때 발생.""" + + +@dataclass(frozen=True) +class WireGuardStatus: + interface: str + server_ip: str + route_ok: bool + dns_ok: bool + + +def _run(command: list[str], timeout: float = 3.0) -> str: + try: + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + timeout=timeout, + ) + except FileNotFoundError as exc: + raise WireGuardUnavailable( + f"명령을 찾을 수 없습니다: {command[0]}" + ) from exc + except subprocess.TimeoutExpired as exc: + raise WireGuardUnavailable( + f"명령 실행 시간이 초과되었습니다: {' '.join(command)}" + ) from exc + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() if exc.stderr else "오류 내용 없음" + raise WireGuardUnavailable( + f"명령 실행 실패: {' '.join(command)}: {stderr}" + ) from exc + + return result.stdout.strip() + + +def check_interface(interface: str = "wg0") -> None: + if shutil.which("wg") is None: + raise WireGuardUnavailable("wg 명령이 설치되어 있지 않습니다.") + + interfaces = _run(["wg", "show", "interfaces"]).split() + + if interface not in interfaces: + raise WireGuardUnavailable( + f"WireGuard 인터페이스 {interface!r}가 활성화되지 않았습니다." + ) + + +def check_route(server_ip: str, interface: str = "wg0") -> None: + ipaddress.ip_address(server_ip) + + output = _run(["ip", "route", "get", server_ip]) + + if f"dev {interface}" not in output: + raise WireGuardUnavailable( + f"{server_ip} 경로가 {interface}를 사용하지 않습니다: {output}" + ) + + +def check_dns_port(server_ip: str, port: int = 53) -> None: + # TCP 53 확인. 실제 nsupdate는 UDP 또는 TCP를 사용할 수 있으므로 + # 이후 dig/nsupdate 테스트도 별도로 수행한다. + try: + with socket.create_connection((server_ip, port), timeout=3.0): + return + except OSError as exc: + raise WireGuardUnavailable( + f"{server_ip}:{port}에 연결할 수 없습니다." + ) from exc + + +def require_wireguard( + server_ip: str = "10.77.0.1", + interface: str = "wg0", +) -> WireGuardStatus: + check_interface(interface) + check_route(server_ip, interface) + check_dns_port(server_ip) + + return WireGuardStatus( + interface=interface, + server_ip=server_ip, + route_ok=True, + dns_ok=True, + ) diff --git a/state.json b/state.json new file mode 100644 index 0000000..577f72d --- /dev/null +++ b/state.json @@ -0,0 +1,3 @@ +{ + "ip": "175.198.8.23" +} \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f19bf1d --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "ddns" +version = "0.1.0" +source = { virtual = "." }