initial commit
This commit is contained in:
commit
9331bbe246
15 changed files with 314 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.14
|
||||||
2
README.md
Normal file
2
README.md
Normal 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
8
config.toml
Normal 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
23
ddns.service
Normal 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
15
ddns.timer
Normal 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
7
pyproject.toml
Normal 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
7
src/config.py
Normal 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
36
src/ip.py
Normal 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
24
src/main.py
Normal 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
33
src/nsupdate.py
Normal 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
41
src/state.py
Normal 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
96
src/wireguard.py
Normal 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
3
state.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"ip": "175.198.8.23"
|
||||||
|
}
|
||||||
8
uv.lock
generated
Normal file
8
uv.lock
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ddns"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
Loading…
Add table
Add a link
Reference in a new issue