fix: surface DDNS registration failure during setup wizard
Unit Tests / test (push) Successful in 7m34s

Two problems on fresh install with pic_ngo mode:

1. Caddy crashed at startup because ddns.token was empty (registration
   hadn't completed yet), producing a bare `token` keyword in the
   Caddyfile that Caddy rejects with "wrong argument count".
   Fix: fall back to lan mode in _caddyfile_pic_ngo when the token is
   empty so Caddy always starts cleanly. The Caddyfile is regenerated
   once registration completes and the token is persisted.

2. DDNS registration failures were silently swallowed — the wizard
   showed "Setup complete!" with no indication that HTTPS wouldn't work.
   This made it look like everything was fine when the subdomain was
   never registered (e.g. name already taken from a previous install,
   or transient network error).
   Fix: capture the exception, classify it (name_taken vs transient),
   and return it as a `warnings` list in the setup response. The wizard
   done screen now shows amber warning cards with actionable text instead
   of auto-redirecting, giving the user a "Continue to login" button and
   a clear explanation of what went wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:52:00 -04:00
parent 7ef294fd65
commit 6bd5f02b03
2 changed files with 47 additions and 8 deletions
+19 -2
View File
@@ -247,6 +247,7 @@ class SetupManager:
self.config_manager.set_ddns_config(ddns_cfg) self.config_manager.set_ddns_config(ddns_cfg)
# ── trigger DDNS registration for pic_ngo ───────────────────────── # ── trigger DDNS registration for pic_ngo ─────────────────────────
warnings: List[str] = []
if domain_mode == 'pic_ngo': if domain_mode == 'pic_ngo':
try: try:
from ddns_manager import DDNSManager from ddns_manager import DDNSManager
@@ -254,13 +255,29 @@ class SetupManager:
ddns_mgr.register(cell_name, '') ddns_mgr.register(cell_name, '')
logger.info(f'DDNS registered: {cell_name}.pic.ngo') logger.info(f'DDNS registered: {cell_name}.pic.ngo')
except Exception as exc: except Exception as exc:
logger.warning(f'DDNS registration failed (will retry at next heartbeat): {exc}') msg = str(exc)
logger.warning(f'DDNS registration failed: {msg}')
if '409' in msg or 'taken' in msg.lower():
warnings.append(
f'The name "{cell_name}" is already registered on pic.ngo. '
'HTTPS will not be active until you re-register: go to '
'Settings → DDNS and click Re-register, or choose a different name.'
)
else:
warnings.append(
'DDNS registration could not be completed right now '
f'({msg}). The cell will retry automatically. '
'HTTPS will activate once registration succeeds.'
)
# ── mark setup complete (must be last) ───────────────────────── # ── mark setup complete (must be last) ─────────────────────────
self.config_manager.set_identity_field('setup_complete', True) self.config_manager.set_identity_field('setup_complete', True)
logger.info(f"Setup completed. cell_name={cell_name!r}, domain_mode={domain_mode!r}") logger.info(f"Setup completed. cell_name={cell_name!r}, domain_mode={domain_mode!r}")
return {'success': True, 'redirect': '/login'} result: Dict[str, Any] = {'success': True, 'redirect': '/login'}
if warnings:
result['warnings'] = warnings
return result
finally: finally:
try: try:
+28 -6
View File
@@ -540,8 +540,9 @@ function Step4Review({ domainType, domainName, timezone, onBack, onSubmit, submi
export default function Setup() { export default function Setup() {
const navigate = useNavigate(); const navigate = useNavigate();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [setupWarnings, setSetupWarnings] = useState([]);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState('');
@@ -600,9 +601,13 @@ export default function Setup() {
}; };
try { try {
await setupAPI.complete(payload); const res = await setupAPI.complete(payload);
const warnings = res?.data?.warnings || [];
setSetupWarnings(warnings);
setDone(true); setDone(true);
setTimeout(() => navigate('/login', { replace: true }), 2000); if (warnings.length === 0) {
setTimeout(() => navigate('/login', { replace: true }), 2000);
}
} catch (e) { } catch (e) {
setSubmitError( setSubmitError(
e?.response?.data?.errors?.join(' ') || e?.response?.data?.errors?.join(' ') ||
@@ -617,10 +622,27 @@ export default function Setup() {
if (done) { if (done) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-950"> <div className="flex items-center justify-center min-h-screen bg-gray-950">
<div className="text-center"> <div className="text-center max-w-md px-4">
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" /> <CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-white mb-2">Setup complete!</h2> <h2 className="text-lg font-semibold text-white mb-2">Setup complete!</h2>
<p className="text-sm text-gray-400">Redirecting to login...</p> {setupWarnings.length > 0 ? (
<div className="mt-4 text-left space-y-3">
{setupWarnings.map((w, i) => (
<div key={i} className="flex gap-2 bg-amber-900/40 border border-amber-700 rounded-lg p-3">
<AlertCircle className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-200">{w}</p>
</div>
))}
<button
onClick={() => navigate('/login', { replace: true })}
className="mt-4 w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium"
>
Continue to login
</button>
</div>
) : (
<p className="text-sm text-gray-400">Redirecting to login...</p>
)}
</div> </div>
</div> </div>
); );