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