The step asked one question — pick a destination, or "not now" — while the
system underneath already had a full location model: eight backend types, per
location engine, path mode, credentials and retention, and a generated
locations.json carrying all of it. None of that was reachable during setup, so a
second destination, or even seeing where the first one points, meant finding the
Backup page afterwards.
Now it mirrors the Storage step — the choice above, the list below:
Backups Automatic — daily, on a schedule | Manual
Destinations Local disk [default] /libreportal-backups/1 [Edit]
+ Add destination
Automatic/Manual needed a setting, because there was no off switch:
crontabSetupBackupScheduler installed the entry unconditionally. CFG_BACKUP_MODE
is explicit rather than overloading "empty schedule", so it reads properly in
the config editor too, and Manual REMOVES an entry that is already installed
rather than merely declining to add one — otherwise answering Manual changes
nothing. The schedule itself is left alone, so switching back restores the time
the user picked.
Destinations are seeded from locations.json, so the default one is shown and
editable instead of being discovered later, and only entries the user actually
added or changed are submitted. A destination on the same disk as the app data
says so on the card rather than in a paragraph under the step.
Remote destinations are what the secret channel was for. The wizard payload is
base64'd into a task's command string and tasks are recorded world-readable, so
a password is POSTed to /api/setup/secret, which writes it where only the
manager can read it and returns an opaque reference; the reference travels in
the payload and setup_apply redeems it once, at the write. A reference that
cannot be redeemed leaves the password alone and says so, rather than blanking
it.
Verified in the browser on a clean install: the step renders both modes, lists
the existing destination at its resolved path, and the add dialog swaps between
local and remote fields. scripts/dev/lp-backup-setup-test covers the apply side,
including that what reaches the config is the secret and never the reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
13 KiB
JavaScript
343 lines
13 KiB
JavaScript
// 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', 'trivy']
|
|
];
|
|
|
|
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 <name>` 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;
|
|
}
|
|
|
|
// Check a path full of .lpapp exports, without importing anything.
|
|
//
|
|
// Enqueues the host-side check and returns immediately; the result lands in
|
|
// frontend/data/system/import_check.json, which the wizard polls. Read-only,
|
|
// and a .lpapp is not encrypted, so no secret crosses this boundary — unlike a
|
|
// backup repository, which is why that one stays in the terminal installer.
|
|
// Hand a secret to the host without it ever reaching a command line.
|
|
//
|
|
// Everything else the wizard submits travels as part of a task's command
|
|
// string, which is recorded in frontend/data/tasks/*.json — 0644, inside a
|
|
// world-readable directory — and is visible in `ps` while the task runs. That
|
|
// is acceptable for a hostname; it is not for a backup repository password,
|
|
// which decrypts every backup the user has.
|
|
//
|
|
// So the value is written into a drop directory that root prepared for exactly
|
|
// this (see `libreportal-ownership secret-dir`): owned <container>:<manager>,
|
|
// mode 2730, so the setgid bit gives this file the manager's group and nobody
|
|
// else can read or even list it. The caller gets back an opaque reference and
|
|
// puts THAT in the task; the manager redeems it once, at the moment of the
|
|
// write, and unlinks it.
|
|
//
|
|
// The value is never logged, never echoed back, and never written anywhere
|
|
// else.
|
|
const SECRET_DIR = '/app/frontend/data/.secrets';
|
|
|
|
router.post('/secret', requireAuth, async (req, res) => {
|
|
const value = (req.body && typeof req.body.value === 'string') ? req.body.value : null;
|
|
if (value === null || value === '') {
|
|
return res.status(400).json({ error: 'A value is required' });
|
|
}
|
|
// Not a size limit for its own sake: this directory is readable by the
|
|
// manager, so it should never become somewhere to park arbitrary data.
|
|
if (Buffer.byteLength(value, 'utf8') > 4096) {
|
|
return res.status(413).json({ error: 'Value too large' });
|
|
}
|
|
|
|
try {
|
|
// Absent means the host has not run `libreportal-ownership secret-dir`.
|
|
// Creating it here would get the ownership wrong — only root can set
|
|
// <container>:<manager> — and a directory this container owned outright
|
|
// would not be readable by the manager, so fail loudly instead.
|
|
if (!fs.existsSync(SECRET_DIR)) {
|
|
return res.status(503).json({ error: 'Secret channel is not set up on this host' });
|
|
}
|
|
|
|
const id = require('crypto').randomBytes(16).toString('hex');
|
|
const file = path.join(SECRET_DIR, id);
|
|
// 0640 explicitly rather than relying on the process umask: owner writes,
|
|
// the manager's group reads, nobody else.
|
|
await fsp.writeFile(file, value, { mode: 0o640, flag: 'wx' });
|
|
res.json({ ok: true, ref: `secret:${id}` });
|
|
} catch (e) {
|
|
// Deliberately not echoing the exception: it can contain the path, and on
|
|
// some failures the value.
|
|
res.status(500).json({ error: 'Could not store the value' });
|
|
}
|
|
});
|
|
|
|
router.post('/import-check', requireAuth, async (req, res) => {
|
|
const p = String((req.body && req.body.path) || '').trim();
|
|
if (!p || !p.startsWith('/')) {
|
|
return res.status(400).json({ error: 'An absolute path is required' });
|
|
}
|
|
// Shell-quote: this reaches a command line, and a path is user input.
|
|
const quoted = `'${p.replace(/'/g, "'\\''")}'`;
|
|
try {
|
|
const id = await enqueueTask({
|
|
command: `libreportal app import-check ${quoted} --publish`,
|
|
type: 'import',
|
|
app: 'libreportal',
|
|
setupRole: 'config'
|
|
});
|
|
res.json({ ok: true, taskId: id });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message || String(e) });
|
|
}
|
|
});
|
|
|
|
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: { <appSlug>: { <optId>: 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 <opt> on app <slug> maps to
|
|
// CFG_<SLUG>_<OPT>_ENABLED. dockerInstallApp parses these and writes
|
|
// them into the template config before calling install<App>.
|
|
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({
|
|
// Pass the group id so finalize can inspect this run's app-install tasks
|
|
// and report whether every selected app actually installed.
|
|
command: `libreportal setup finalize ${setupGroup}`,
|
|
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;
|