Fix DDNS registration and wizard pre-fill after installer run
Unit Tests / test (push) Successful in 15m29s
Unit Tests / test (push) Successful in 15m29s
DDNS registration (setup_cell.py): - Replace pyotp dependency with stdlib TOTP (HMAC-SHA1, RFC 6238) pyotp is only available inside the Docker container, not on the host where setup_cell.py runs — registration was silently skipped every time - OTP header still sent if generation succeeds; omitted gracefully if not Wizard pre-fill (setup_manager + Setup.jsx): - GET /api/setup/status now returns 'preconfigured' dict with cell_name, domain_mode, domain_name, and provider tokens from installer-written config - Setup.jsx fetches status on mount and pre-fills all form state so the user only needs to set password, services, and timezone — not re-enter the identity they already configured in the bash installer - Fails silently so wizard still works on fresh installs with no config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+12
-1
@@ -74,11 +74,22 @@ class SetupManager:
|
|||||||
return bool(self.config_manager.get_identity().get('setup_complete', False))
|
return bool(self.config_manager.get_identity().get('setup_complete', False))
|
||||||
|
|
||||||
def get_setup_status(self) -> Dict[str, Any]:
|
def get_setup_status(self) -> Dict[str, Any]:
|
||||||
"""Return current setup status and wizard metadata."""
|
"""Return current setup status, wizard metadata, and any pre-configured identity."""
|
||||||
|
identity = self.config_manager.get_identity()
|
||||||
|
preconfigured = {
|
||||||
|
k: v for k, v in {
|
||||||
|
'cell_name': identity.get('cell_name', ''),
|
||||||
|
'domain_mode': identity.get('domain_mode', ''),
|
||||||
|
'domain_name': identity.get('domain_name', ''),
|
||||||
|
'cloudflare_api_token': identity.get('cloudflare_api_token', ''),
|
||||||
|
'duckdns_token': identity.get('duckdns_token', ''),
|
||||||
|
}.items() if v
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
'complete': self.is_setup_complete(),
|
'complete': self.is_setup_complete(),
|
||||||
'available_services': AVAILABLE_SERVICES,
|
'available_services': AVAILABLE_SERVICES,
|
||||||
'available_timezones': AVAILABLE_TIMEZONES,
|
'available_timezones': AVAILABLE_TIMEZONES,
|
||||||
|
'preconfigured': preconfigured,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── validation ────────────────────────────────────────────────────────
|
# ── validation ────────────────────────────────────────────────────────
|
||||||
|
|||||||
+16
-13
@@ -304,25 +304,28 @@ def register_with_ddns(cell_name: str) -> None:
|
|||||||
print(f'[WARN] Could not detect public IP: {e} — skipping DDNS registration')
|
print(f'[WARN] Could not detect public IP: {e} — skipping DDNS registration')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Generate TOTP code (requires pyotp; if not available fall back gracefully)
|
# Generate TOTP using stdlib only — no third-party package needed
|
||||||
|
otp = ''
|
||||||
try:
|
try:
|
||||||
import pyotp
|
import base64 as _b64, hashlib as _hl, hmac as _hmac, struct as _struct
|
||||||
otp = pyotp.TOTP(DDNS_TOTP_SECRET).now()
|
import time as _time
|
||||||
except ImportError:
|
_key = _b64.b32decode(DDNS_TOTP_SECRET.upper())
|
||||||
# Try python3 -c as a subprocess fallback
|
_t = int(_time.time()) // 30
|
||||||
try:
|
_h = _hmac.new(_key, _struct.pack('>Q', _t), _hl.sha1).digest()
|
||||||
otp = subprocess.check_output(
|
_offset = _h[-1] & 0xF
|
||||||
['python3', '-c', f"import pyotp; print(pyotp.TOTP('{DDNS_TOTP_SECRET}').now())"]
|
_code = _struct.unpack('>I', _h[_offset:_offset + 4])[0] & 0x7FFFFFFF
|
||||||
).decode().strip()
|
otp = f'{_code % 1_000_000:06d}'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[WARN] pyotp not available and fallback failed: {e} — skipping DDNS')
|
print(f'[WARN] Could not generate OTP: {e} — registering without OTP header')
|
||||||
return
|
|
||||||
|
|
||||||
data = json.dumps({'name': cell_name, 'ip': public_ip}).encode()
|
data = json.dumps({'name': cell_name, 'ip': public_ip}).encode()
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if otp:
|
||||||
|
headers['X-Register-OTP'] = otp
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f'{DDNS_URL}/register',
|
f'{DDNS_URL}/register',
|
||||||
data=data,
|
data=data,
|
||||||
headers={'Content-Type': 'application/json', 'X-Register-OTP': otp},
|
headers=headers,
|
||||||
method='POST',
|
method='POST',
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -300,3 +300,23 @@ def test_get_setup_status_timezones_match_module_constant(setup_manager, mock_co
|
|||||||
mock_config_manager.get_identity.return_value = {}
|
mock_config_manager.get_identity.return_value = {}
|
||||||
status = setup_manager.get_setup_status()
|
status = setup_manager.get_setup_status()
|
||||||
assert status['available_timezones'] == AVAILABLE_TIMEZONES
|
assert status['available_timezones'] == AVAILABLE_TIMEZONES
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_setup_status_preconfigured_empty_when_identity_blank(setup_manager, mock_config_manager):
|
||||||
|
mock_config_manager.get_identity.return_value = {}
|
||||||
|
status = setup_manager.get_setup_status()
|
||||||
|
assert status['preconfigured'] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_setup_status_preconfigured_returns_installer_values(setup_manager, mock_config_manager):
|
||||||
|
mock_config_manager.get_identity.return_value = {
|
||||||
|
'cell_name': 'myhome',
|
||||||
|
'domain_mode': 'pic_ngo',
|
||||||
|
'domain_name': 'myhome.pic.ngo',
|
||||||
|
}
|
||||||
|
status = setup_manager.get_setup_status()
|
||||||
|
pre = status['preconfigured']
|
||||||
|
assert pre['cell_name'] == 'myhome'
|
||||||
|
assert pre['domain_mode'] == 'pic_ngo'
|
||||||
|
assert pre['domain_name'] == 'myhome.pic.ngo'
|
||||||
|
assert 'cloudflare_api_token' not in pre
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react';
|
import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react';
|
||||||
import { setupAPI } from '../services/api';
|
import { setupAPI } from '../services/api';
|
||||||
@@ -789,6 +789,25 @@ export default function Setup() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
|
||||||
|
// Pre-fill from installer config if present
|
||||||
|
useEffect(() => {
|
||||||
|
setupAPI.getStatus()
|
||||||
|
.then(res => {
|
||||||
|
const pre = res.data?.preconfigured;
|
||||||
|
if (!pre) return;
|
||||||
|
if (pre.cell_name) setCellName(pre.cell_name);
|
||||||
|
if (pre.domain_mode) {
|
||||||
|
if (pre.domain_mode === 'pic_ngo') setDomainType('pic_ngo');
|
||||||
|
else if (pre.domain_mode === 'lan') setDomainType('lan');
|
||||||
|
else { setDomainType('custom'); setCustomMethod(pre.domain_mode); }
|
||||||
|
}
|
||||||
|
if (pre.domain_name) setCustomDomain(pre.domain_name);
|
||||||
|
if (pre.cloudflare_api_token) setCloudflareToken(pre.cloudflare_api_token);
|
||||||
|
if (pre.duckdns_token) setDuckdnsToken(pre.duckdns_token);
|
||||||
|
})
|
||||||
|
.catch(() => {}); // fail silently — wizard works from scratch
|
||||||
|
}, []);
|
||||||
|
|
||||||
const skipStep4 = domainType === 'lan';
|
const skipStep4 = domainType === 'lan';
|
||||||
|
|
||||||
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
|
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
|
||||||
|
|||||||
Reference in New Issue
Block a user