restore inspect answers "what would a restore from here bring?" without writing
anything: hosts, apps with sizes, and the domains — read out of the
system-config snapshot with engineDumpFile, the same way the preflight reads an
app manifest. Knowing a backup hands you six domains of which four point
elsewhere, before committing, is the difference between a rebuild and a
surprise.
restore connect is the WebUI entry point: creates the location from a base64
payload, redeems the repository password from the single-use secret channel,
inspects. Deliberately does not engineInitLocation — every other path that
creates a location initialises it because it is about to write there; this one
reads a repository that already exists. This is what unblocks the constraint
app_portable.sh records: a .lpapp could live in the WebUI because nothing
secret crosses from browser to host, and the repository restore could not. The
secret:<ref> channel is that missing piece.
A wrong password is the ordinary case and the user retries, so a failed connect
removes the location it just made. Otherwise every attempt left another
half-configured destination behind.
Three things found by using it:
- locationRemove never worked. It unlinked as the container user, but
configs/ is manager-owned, so it was always denied — and the result was
never checked, so isSuccessful printed anyway and a "removed" location came
back on the next listing. Now runInstallOp, and the directory is checked.
- webuiSecretSweep had no callers. An abandoned flow left its repository
password on disk forever. The sweep now runs in /api/setup/secret before
each write, tied to the one event guaranteed to happen.
- Adoption took the WebUI down. config-adopt chowned every adopted file to
manager:manager 0640, and webui_logins is bind-mounted into the container,
which then could not read its own credentials: exit 137 with no log line.
It also clamped every parent directory it passed through, closing
configs/webui and configs/backup to the container user.
The fix is a principle, not a special case: a restore replaces the CONTENT
of a config file and nothing else. The live install already knows who may
read each one. Adoption preserves the destination's ownership and mode,
defaults closed only for a file that did not exist, and never
re-permissions a directory it passes through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
14 KiB
JavaScript
358 lines
14 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' });
|
|
}
|
|
|
|
// Sweep before writing. A reference is redeemed once by the applier, but a
|
|
// flow the user abandons — closed the tab, hit a validation error, never
|
|
// pressed Save — leaves its secret behind, and these are repository
|
|
// passwords. webuiSecretSweep exists for this and had no callers at all,
|
|
// so nothing ever ran it; doing it here ties the cleanup to the one event
|
|
// that is guaranteed to happen whenever secrets are being created.
|
|
try {
|
|
const cutoff = Date.now() - 15 * 60 * 1000;
|
|
for (const name of await fsp.readdir(SECRET_DIR)) {
|
|
const f = path.join(SECRET_DIR, name);
|
|
const st = await fsp.stat(f).catch(() => null);
|
|
if (st && st.isFile() && st.mtimeMs < cutoff) await fsp.unlink(f).catch(() => {});
|
|
}
|
|
} catch { /* a sweep that fails must never block storing the new value */ }
|
|
|
|
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;
|