Integrate DDNS registration and IP update into installer
Unit Tests / test (push) Failing after 8m57s

setup_cell.py: register_with_ddns() called at end of setup — detects
public IP via api.ipify.org, generates TOTP code from DDNS_TOTP_SECRET,
POSTs to DDNS /register, saves token to data/api/.ddns_token (mode 600).
Idempotent: skips if token file already exists. Fails gracefully if
DDNS_TOTP_SECRET is unset or network is unreachable.

scripts/ddns_update.py: standalone script for periodic IP updates.
Reads token from data/api/.ddns_token, fetches current public IP,
compares to cached last IP (data/api/.ddns_last_ip) and calls /update
only when the IP has actually changed.

Makefile: add ddns-update (run update script) and ddns-register (force
re-registration by removing old token then calling register_with_ddns).
Usage: DDNS_TOTP_SECRET=<secret> make ddns-register

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 02:28:02 -04:00
parent 15376b67c7
commit ffe1dbeed6
3 changed files with 172 additions and 1 deletions
+17 -1
View File
@@ -12,7 +12,8 @@
test-e2e-deps test-e2e-api test-e2e-ui test-e2e-wg test-e2e \ test-e2e-deps test-e2e-api test-e2e-ui test-e2e-wg test-e2e \
reset-test-admin-pass \ reset-test-admin-pass \
show-admin-password reset-admin-password \ show-admin-password reset-admin-password \
show-routes add-peer list-peers show-routes add-peer list-peers \
ddns-update ddns-register
# Detect docker compose command (v2 plugin preferred, fallback to v1 standalone) # Detect docker compose command (v2 plugin preferred, fallback to v1 standalone)
DC := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose") DC := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
@@ -335,6 +336,21 @@ add-peer:
echo "Usage: make add-peer PEER_NAME=name PEER_IP=10.0.0.x PEER_KEY=<pubkey>"; \ echo "Usage: make add-peer PEER_NAME=name PEER_IP=10.0.0.x PEER_KEY=<pubkey>"; \
fi fi
# ── DDNS ─────────────────────────────────────────────────────────────────────
ddns-update:
@python3 scripts/ddns_update.py
ddns-register:
@DDNS_TOTP_SECRET="$(DDNS_TOTP_SECRET)" python3 -c "\
import os, sys; sys.path.insert(0, 'scripts'); \
from setup_cell import register_with_ddns, _read_existing_ip_range; \
import json; \
cfg = json.load(open('config/api/cell_config.json')) if os.path.exists('config/api/cell_config.json') else {}; \
name = cfg.get('_identity', {}).get('cell_name', os.environ.get('CELL_NAME', 'mycell')); \
import os; os.remove('data/api/.ddns_token') if os.path.exists('data/api/.ddns_token') else None; \
register_with_ddns(name)"
# ── Dev ─────────────────────────────────────────────────────────────────────── # ── Dev ───────────────────────────────────────────────────────────────────────
dev: dev:
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Update the cell's DDNS record with the current public IP.
Called by: make ddns-update
systemd timer (optional, see scripts/pic-ddns-update.timer)
Reads the DDNS token from data/api/.ddns_token (written by setup_cell.py).
Exits 0 on success or if already up to date, non-zero on failure.
"""
import json
import os
import sys
import urllib.error
import urllib.request
DDNS_URL = os.environ.get('DDNS_URL', 'https://ddns.pic.ngo/api/v1')
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOKEN_FILE = os.path.join(ROOT, 'data', 'api', '.ddns_token')
IP_CACHE_FILE = os.path.join(ROOT, 'data', 'api', '.ddns_last_ip')
def get_public_ip() -> str:
return urllib.request.urlopen('https://api.ipify.org', timeout=5).read().decode().strip()
def read_token() -> str:
if not os.path.exists(TOKEN_FILE):
print('ERROR: DDNS token not found. Run "make setup" to register.', file=sys.stderr)
sys.exit(1)
return open(TOKEN_FILE).read().strip()
def read_last_ip() -> str:
try:
return open(IP_CACHE_FILE).read().strip()
except FileNotFoundError:
return ''
def write_last_ip(ip: str) -> None:
with open(IP_CACHE_FILE, 'w') as f:
f.write(ip)
def main() -> int:
try:
public_ip = get_public_ip()
except Exception as e:
print(f'ERROR: Could not detect public IP: {e}', file=sys.stderr)
return 1
last_ip = read_last_ip()
if public_ip == last_ip:
print(f'DDNS: IP unchanged ({public_ip}) — no update needed')
return 0
token = read_token()
data = json.dumps({'token': token, 'ip': public_ip}).encode()
req = urllib.request.Request(
f'{DDNS_URL}/update',
data=data,
headers={'Content-Type': 'application/json'},
method='PUT',
)
try:
resp = urllib.request.urlopen(req, timeout=10)
result = json.loads(resp.read())
if result.get('updated'):
write_last_ip(public_ip)
print(f'DDNS: Updated to {public_ip}')
return 0
else:
print(f'ERROR: Unexpected response: {result}', file=sys.stderr)
return 1
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f'ERROR: DDNS update failed ({e.code}): {body}', file=sys.stderr)
return 1
except Exception as e:
print(f'ERROR: DDNS update failed: {e}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
+69
View File
@@ -238,6 +238,74 @@ def ensure_session_secret():
print('[CREATED] data/api/.session_secret') print('[CREATED] data/api/.session_secret')
DDNS_URL = os.environ.get('DDNS_URL', 'https://ddns.pic.ngo/api/v1')
DDNS_TOTP_SECRET = os.environ.get('DDNS_TOTP_SECRET', '')
def register_with_ddns(cell_name: str) -> None:
"""Register cell_name.pic.ngo with the DDNS server using TOTP auth.
Idempotent: if a token file already exists the registration is skipped.
Skipped silently if DDNS_TOTP_SECRET is not set.
"""
token_path = os.path.join(ROOT, 'data', 'api', '.ddns_token')
if os.path.exists(token_path):
print('[EXISTS] DDNS registration — token already present')
return
if not DDNS_TOTP_SECRET:
print('[SKIP] DDNS_TOTP_SECRET not set — skipping DDNS registration')
return
import urllib.request
import urllib.error
# Detect public IP
try:
public_ip = urllib.request.urlopen(
'https://api.ipify.org', timeout=5
).read().decode().strip()
except Exception as e:
print(f'[WARN] Could not detect public IP: {e} — skipping DDNS registration')
return
# Generate TOTP code (requires pyotp; if not available fall back gracefully)
try:
import pyotp
otp = pyotp.TOTP(DDNS_TOTP_SECRET).now()
except ImportError:
# Try python3 -c as a subprocess fallback
try:
otp = subprocess.check_output(
['python3', '-c', f"import pyotp; print(pyotp.TOTP('{DDNS_TOTP_SECRET}').now())"]
).decode().strip()
except Exception as e:
print(f'[WARN] pyotp not available and fallback failed: {e} — skipping DDNS')
return
data = json.dumps({'name': cell_name, 'ip': public_ip}).encode()
req = urllib.request.Request(
f'{DDNS_URL}/register',
data=data,
headers={'Content-Type': 'application/json', 'X-Register-OTP': otp},
method='POST',
)
try:
resp = urllib.request.urlopen(req, timeout=10)
result = json.loads(resp.read())
token = result['token']
os.makedirs(os.path.dirname(token_path), exist_ok=True)
with open(token_path, 'w') as f:
f.write(token)
os.chmod(token_path, 0o600)
print(f'[CREATED] DDNS registration: {result["subdomain"]} ip={public_ip}')
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f'[WARN] DDNS registration failed ({e.code}): {body}')
except Exception as e:
print(f'[WARN] DDNS registration failed: {e}')
def bootstrap_admin_password(): def bootstrap_admin_password():
import secrets as _secrets import secrets as _secrets
users_file = os.path.join(ROOT, 'data', 'api', 'auth_users.json') users_file = os.path.join(ROOT, 'data', 'api', 'auth_users.json')
@@ -303,6 +371,7 @@ def main():
write_caddy_config(ip_range, cell_name, domain) write_caddy_config(ip_range, cell_name, domain)
ensure_session_secret() ensure_session_secret()
bootstrap_admin_password() bootstrap_admin_password()
register_with_ddns(cell_name)
print() print()
print('--- Setup complete! Run: make start ---') print('--- Setup complete! Run: make start ---')