Integrate DDNS registration and IP update into installer
Unit Tests / test (push) Failing after 8m57s
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:
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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 ---')
|
||||||
|
|||||||
Reference in New Issue
Block a user