// Setup Wizard backend. // // Three sync GETs (status / suggest-name / dns-check) plus one async POST // (save) that hands off to the host task system. The lock file lives at // /app/frontend/data/.setup_complete — under the existing frontend bind-mount // so the container can read it and the host's setupApply can write it // without us having to add a new bind-mount to docker-compose.yml. // // Sync endpoints intentionally do NOT round-trip through the task daemon — // suggest-name and dns-check are pure read-only operations that we // reimplement in JS, so they return in <50ms instead of waiting for the next // cron tick. const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const dns = require('dns').promises; const https = require('https'); const { requireAuth } = require('../utils/middleware.js'); const { pokeFifo } = require('../utils/fifo.js'); const router = express.Router(); const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks'); const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo'); const SETUP_LOCK_FILE = path.join(__dirname, '..', '..', 'frontend', 'data', '.setup_complete'); const ADJECTIVES = [ 'Quantum', 'Neutrino', 'Photon', 'Plasma', 'Quasar', 'Pulsar', 'Tachyon', 'Boson', 'Fermion', 'Hadron', 'Gluon', 'Muon', 'Higgs', 'Entangled', 'Singular', 'Warped', 'Tunneling', 'Coherent', 'Superposed', 'Spectral', 'Orbital', 'Cosmic', 'Stellar', 'Nebular', 'Astral', 'Gravitic', 'Inertial', 'Relativistic', 'Helical', 'Toroidal', 'Holographic', 'Cryogenic', 'Crystalline', 'Resonant', 'Harmonic', 'Phasic', 'Drifting', 'Spinning', 'Pulsing', 'Hyper' ]; const NOUNS = [ 'Frog', 'Fox', 'Otter', 'Raven', 'Wolf', 'Yak', 'Lynx', 'Owl', 'Hawk', 'Crow', 'Newt', 'Wren', 'Eel', 'Crab', 'Squid', 'Octopus', 'Mantis', 'Cobra', 'Viper', 'Ferret', 'Badger', 'Penguin', 'Panda', 'Lemur', 'Quark', 'Nebula', 'Comet', 'Nova', 'Eclipse', 'Aurora', 'Vortex', 'Helix', 'Halo', 'Phoenix', 'Hydra', 'Kraken', 'Sphinx', 'Specter', 'Phantom', 'Glyph' ]; function generateInstallName() { const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; return `${adj}${noun}`; } // Install order is enforced server-side. Monitoring goes first so apps // installing later detect a live Prometheus/Grafana and wire their metrics // export at install time — Traefik, CrowdSec et al. are monitoring consumers. // Grafana follows Prometheus because its datasource points at it. const INSTALL_TIERS = [ ['prometheus', 'grafana'], ['traefik', 'crowdsec'] ]; function sortAppsByTier(apps) { const rank = new Map(); let r = 0; for (const tier of INSTALL_TIERS) for (const slug of tier) rank.set(slug, r++); return [...apps].sort((a, b) => { const ra = rank.has(a) ? rank.get(a) : Infinity; const rb = rank.has(b) ? rank.get(b) : Infinity; if (ra !== rb) return ra - rb; return apps.indexOf(a) - apps.indexOf(b); }); } function fetchPublicIp() { return new Promise((resolve) => { const req = https.get('https://api.ipify.org', { timeout: 3000 }, (res) => { let body = ''; res.on('data', (chunk) => { body += chunk; }); res.on('end', () => resolve(body.trim() || null)); }); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); }); } router.get('/status', requireAuth, async (req, res) => { const complete = fs.existsSync(SETUP_LOCK_FILE); res.json({ complete }); }); router.get('/suggest-name', requireAuth, (req, res) => { res.set('Cache-Control', 'no-store'); res.json({ name: generateInstallName() }); }); router.get('/dns-check', requireAuth, async (req, res) => { const domain = String(req.query.domain || '').trim().toLowerCase(); if (!domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) { return res.status(400).json({ matches: false, error: 'invalid domain' }); } const [serverIp, domainIps] = await Promise.all([ fetchPublicIp(), dns.resolve4(domain).catch(() => []) ]); const domainIp = domainIps[0] || null; const matches = !!(serverIp && domainIp && serverIp === domainIp); res.json({ matches, server_ip: serverIp, domain_ip: domainIp }); }); // Each ticked app becomes its own `libreportal app install ` task — // using the same task type the WebUI's app-install pipeline already // understands, so the user sees individual progress per app instead of // one opaque "setup apply" task. The first task writes the configs, the // last marks the wizard complete; in between, the recommended apps run // sequentially because the host daemon processes the FIFO in order. async function enqueueTask(spec) { const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const task = { id, command: spec.command, type: spec.type, app: spec.app || 'libreportal', config: spec.config || 'setup-wizard', status: 'queued', createdAt: new Date().toISOString(), startedAt: null, completedAt: null, heartbeatAt: null, exitCode: null, errorMessage: null, setupGroup: spec.setupGroup, setupRole: spec.setupRole // 'config' | 'app' | 'finalize' }; const taskPath = path.join(TASKS_DIR, `${id}.json`); const tmp = `${taskPath}.tmp`; await fsp.writeFile(tmp, JSON.stringify(task, null, 2)); await fsp.rename(tmp, taskPath); pokeFifo(FIFO_PATH, id); // Tiny stagger so each task gets a unique Date.now()-based id. await new Promise(r => setTimeout(r, 2)); return id; } router.post('/save', requireAuth, async (req, res) => { const payload = req.body || {}; if (!payload.install_name || !/^[a-zA-Z0-9-]+$/.test(payload.install_name)) { return res.status(400).json({ error: 'invalid install_name' }); } if (!payload.timezone) { return res.status(400).json({ error: 'timezone required' }); } // Experience level seeds the WebUI's Beginner/Advanced UI mode default. // Optional — old WebUIs may not send it — and constrained to the // enum so a bad value can't smuggle anything into the bash applier. if (payload.install_level !== undefined) { if (payload.install_level !== 'beginner' && payload.install_level !== 'advanced') { return res.status(400).json({ error: 'invalid install_level' }); } } // Domains are optional but each entry must be a valid hostname. Cap at // 9 because the config schema only has CFG_DOMAIN_1..CFG_DOMAIN_9. const domainRe = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i; payload.domains = Array.isArray(payload.domains) ? payload.domains.map(d => String(d).trim().toLowerCase()).filter(Boolean) : []; if (payload.domains.length > 9) payload.domains = payload.domains.slice(0, 9); for (const d of payload.domains) { if (!domainRe.test(d)) return res.status(400).json({ error: `invalid domain: ${d}` }); } payload.apps = Array.isArray(payload.apps) ? payload.apps.filter(a => /^[a-z0-9_-]+$/i.test(a)) : []; payload.apps = sortAppsByTier(payload.apps); // Validate appOptions — shape: { : { : bool, ... } } const optsIn = (payload.appOptions && typeof payload.appOptions === 'object') ? payload.appOptions : {}; const safeOpts = {}; for (const [slug, opts] of Object.entries(optsIn)) { if (!/^[a-z0-9_-]+$/i.test(slug)) continue; if (!payload.apps.includes(slug)) continue; if (!opts || typeof opts !== 'object') continue; safeOpts[slug] = {}; for (const [k, v] of Object.entries(opts)) { if (/^[a-z0-9_-]+$/i.test(k) && typeof v === 'boolean') safeOpts[slug][k] = v; } } payload.appOptions = safeOpts; const wantsTraefik = payload.apps.includes('traefik'); if (wantsTraefik) { if (!payload.traefik_email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.traefik_email)) { return res.status(400).json({ error: 'traefik_email required when installing Traefik' }); } } else { delete payload.traefik_email; } const setupGroup = `setup_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const b64 = Buffer.from(JSON.stringify(payload)).toString('base64'); try { await fsp.mkdir(TASKS_DIR, { recursive: true }); const taskIds = []; taskIds.push(await enqueueTask({ command: `libreportal setup config ${b64}`, type: 'setup-config', setupGroup, setupRole: 'config' })); for (const appName of payload.apps) { // Convert appOptions sub-flags into the framework's config_variables // arg. Convention: sub-option on app maps to // CFG___ENABLED. dockerInstallApp parses these and writes // them into the template config before calling install. let command = `libreportal app install ${appName}`; const opts = payload.appOptions[appName] || {}; const cfgPairs = []; const slugUpper = appName.toUpperCase().replace(/-/g, '_'); for (const [optId, value] of Object.entries(opts)) { if (typeof value !== 'boolean') continue; cfgPairs.push(`CFG_${slugUpper}_${optId.toUpperCase()}_ENABLED=${value}`); } if (cfgPairs.length) command += ` ${cfgPairs.join('|')}`; taskIds.push(await enqueueTask({ command, type: 'app-install', app: appName, setupGroup, setupRole: 'app' })); } const finalizeId = await enqueueTask({ command: `libreportal setup finalize`, type: 'setup-finalize', setupGroup, setupRole: 'finalize' }); taskIds.push(finalizeId); res.status(201).json({ setupGroup, taskIds, firstTaskId: taskIds[0], finalizeTaskId: finalizeId, installName: payload.install_name }); } catch (err) { console.error('[setup] save failed:', err); res.status(500).json({ error: 'failed to enqueue setup tasks' }); } }); module.exports = router;