diff --git a/Makefile b/Makefile index f0949cb..9e99ea1 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,8 @@ test-e2e-deps test-e2e-api test-e2e-ui test-e2e-wg test-e2e \ reset-test-admin-pass \ 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) 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="; \ 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: diff --git a/scripts/ddns_update.py b/scripts/ddns_update.py new file mode 100644 index 0000000..7d6d0d1 --- /dev/null +++ b/scripts/ddns_update.py @@ -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()) diff --git a/scripts/setup_cell.py b/scripts/setup_cell.py index 0c053ab..172185a 100644 --- a/scripts/setup_cell.py +++ b/scripts/setup_cell.py @@ -238,6 +238,74 @@ def ensure_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(): import secrets as _secrets 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) ensure_session_secret() bootstrap_admin_password() + register_with_ddns(cell_name) print() print('--- Setup complete! Run: make start ---')