initial commit

This commit is contained in:
bitdori 2026-07-15 12:07:38 +00:00
commit 9331bbe246
15 changed files with 314 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.14

2
README.md Normal file
View file

@ -0,0 +1,2 @@
dig +short A bitdori.org @139.162.118.154
dig -4 @resolver1.opendns.com -t a myip.opendns.com +short

8
config.toml Normal file
View file

@ -0,0 +1,8 @@
server = "139.162.118.154"
zone = "bitdori.org"
hostname = "bitdori.org"
ttl = 300
keyfile = "./ddns-key.bitdori.key"

23
ddns.service Normal file
View file

@ -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

15
ddns.timer Normal file
View file

@ -0,0 +1,15 @@
[Unit]
Description=Run DDNS updater periodically
[Timer]
OnBootSec=30s
OnUnitActiveSec=5min
Persistent=true
Unit=ddns.service
[Install]
WantedBy=timers.target

7
pyproject.toml Normal file
View file

@ -0,0 +1,7 @@
[project]
name = "ddns"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = []

7
src/config.py Normal file
View file

@ -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)

36
src/ip.py Normal file
View file

@ -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")

24
src/main.py Normal file
View file

@ -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

33
src/nsupdate.py Normal file
View file

@ -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")

41
src/state.py Normal file
View file

@ -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)
)

96
src/wireguard.py Normal file
View file

@ -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,
)

3
state.json Normal file
View file

@ -0,0 +1,3 @@
{
"ip": "175.198.8.23"
}

8
uv.lock generated Normal file
View file

@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "ddns"
version = "0.1.0"
source = { virtual = "." }