diff --git a/api/setup_manager.py b/api/setup_manager.py index 788fd28..e7adc23 100644 --- a/api/setup_manager.py +++ b/api/setup_manager.py @@ -74,11 +74,22 @@ class SetupManager: return bool(self.config_manager.get_identity().get('setup_complete', False)) 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 { 'complete': self.is_setup_complete(), 'available_services': AVAILABLE_SERVICES, 'available_timezones': AVAILABLE_TIMEZONES, + 'preconfigured': preconfigured, } # ── validation ──────────────────────────────────────────────────────── diff --git a/scripts/setup_cell.py b/scripts/setup_cell.py index 9f1ada8..a7d372a 100644 --- a/scripts/setup_cell.py +++ b/scripts/setup_cell.py @@ -304,25 +304,28 @@ def register_with_ddns(cell_name: str) -> None: print(f'[WARN] Could not detect public IP: {e} — skipping DDNS registration') return - # Generate TOTP code (requires pyotp; if not available fall back gracefully) + # Generate TOTP using stdlib only — no third-party package needed + otp = '' 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 + import base64 as _b64, hashlib as _hl, hmac as _hmac, struct as _struct + import time as _time + _key = _b64.b32decode(DDNS_TOTP_SECRET.upper()) + _t = int(_time.time()) // 30 + _h = _hmac.new(_key, _struct.pack('>Q', _t), _hl.sha1).digest() + _offset = _h[-1] & 0xF + _code = _struct.unpack('>I', _h[_offset:_offset + 4])[0] & 0x7FFFFFFF + otp = f'{_code % 1_000_000:06d}' + except Exception as e: + print(f'[WARN] Could not generate OTP: {e} — registering without OTP header') 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( f'{DDNS_URL}/register', data=data, - headers={'Content-Type': 'application/json', 'X-Register-OTP': otp}, + headers=headers, method='POST', ) try: diff --git a/tests/test_setup_manager.py b/tests/test_setup_manager.py index c21e55e..4d63be3 100644 --- a/tests/test_setup_manager.py +++ b/tests/test_setup_manager.py @@ -300,3 +300,23 @@ def test_get_setup_status_timezones_match_module_constant(setup_manager, mock_co mock_config_manager.get_identity.return_value = {} status = setup_manager.get_setup_status() 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 diff --git a/webui/src/pages/Setup.jsx b/webui/src/pages/Setup.jsx index f3d792d..a6bbdda 100644 --- a/webui/src/pages/Setup.jsx +++ b/webui/src/pages/Setup.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react'; import { setupAPI } from '../services/api'; @@ -789,6 +789,25 @@ export default function Setup() { const [submitting, setSubmitting] = useState(false); 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 goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));