librelad e4872ab511 refactor(paths): single source of truth for a relocatable, split layout (phase 1)
Introduce scripts/source/paths.sh as the canonical path resolver for three
independently-relocatable roots:
  LP_SYSTEM_DIR      manager-owned control plane (configs/logs/install/db/ssl/ssh/migrate)
  LP_CONTAINERS_DIR  container-user-owned live app data
  LP_BACKUPS_DIR     container-user-owned backup repos (own mount-able)

Roots come from the environment when set (install bakes them; CLI/app inherit
from init.sh), else default to /libreportal-*. A transitional compat default
keeps EXISTING installs (legacy single /docker tree, by config marker) on /docker
until a deliberate reinstall, so deploying this never strands a running box.

- init.sh derives the same vars inline (self-contained for the bare /root/init.sh
  reinstall case); paths.sh mirrors it for the standalone task/check processors,
  which now self-locate their scripts dir and source it.
- Replace functional /docker literals with the derived vars across runtime,
  install, backup, crontab, crowdsec/restic, headscale, and reinstall paths;
  clean the inert '== /docker/containers/*' guard fallbacks to the variable form.
- backend: CONTAINERS_DIR now from LP_CONTAINERS_DIR (compose env, filled at
  generation via a new CONTAINERS_DIR_TAG), legacy-safe default for un-recreated
  containers.
- backup default path falls back to the backups root; exclude paths.sh from the
  sourced-file arrays (bootstrap file, sourced explicitly).

The CLI-wrapper heredoc + root helpers still reference /docker; those get baked
in phase 3. No layout/ownership change yet (phase 2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-25 15:09:39 +01:00

570 lines
22 KiB
JavaScript

// Per-app service routes.
//
// The "Services" tab on the app page asks: for app X, what compose
// services are defined, are they running, and how long have they been
// up — plus give me a restart button and a live log tail.
//
// Implementation notes:
// - The libreportal-service container does NOT have the `docker` CLI
// installed; it only has the docker socket bind-mounted. So instead
// of shelling out to `docker`, we talk to the Docker Engine HTTP API
// directly over the unix socket. That means no extra system deps and
// no group-level privilege grants — node only sees what the mounted
// socket lets it see.
// - Restart still goes through the existing task system. The bash task
// processor runs on the host (where `docker` IS available) so its
// `docker compose restart …` command works fine.
// - URLs / port chips for each service are read client-side from the
// existing /data/apps/generated/apps-services.json — no backend
// surface needed for that.
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const http = require('http');
const { spawn } = require('child_process');
const { requireAuth } = require('../utils/middleware.js');
const { pokeFifo } = require('../utils/fifo.js');
const { fileConfig } = require('../utils/config.js');
const router = express.Router();
const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks');
const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo');
// Host live-app-data root. Provided by the compose env (LP_CONTAINERS_DIR, filled
// from the host's containers root at generation — see scripts/source/paths.sh).
// Falls back to the legacy /docker path so a container that hasn't been recreated
// since the split-layout change keeps working until it is.
const CONTAINERS_DIR = process.env.LP_CONTAINERS_DIR || '/docker/containers';
const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json');
// =====================================================================
// Docker socket discovery
// =====================================================================
// Whichever socket the host bind-mounted into us — that's the one we
// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts
// /run/user/<uid>/docker.sock. No fallback to a docker group, no sudo,
// no daemon auth tokens — just the unix socket the host already chose
// to expose.
function detectDockerSocket() {
if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock';
try {
for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const sock = `/run/user/${entry.name}/docker.sock`;
if (fs.existsSync(sock)) return sock;
}
} catch { /* /run/user not readable — that's fine */ }
return null;
}
const DOCKER_SOCKET = detectDockerSocket();
console.log(
DOCKER_SOCKET
? `[services] Docker API socket: ${DOCKER_SOCKET}`
: '[services] WARNING: no docker socket found — services tab will be empty'
);
// =====================================================================
// Tiny Docker HTTP API client
// =====================================================================
// The Docker daemon speaks HTTP/1.1 over a unix socket. Versioning is
// pinned to v1.41 (Docker 20.10+, far older than anything this project
// supports).
const DOCKER_API_VERSION = 'v1.41';
function dockerRequest(method, pathname, query) {
return new Promise((resolve, reject) => {
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
const req = http.request(
{
socketPath: DOCKER_SOCKET,
method,
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
headers: { 'Host': 'docker', 'Accept': 'application/json' }
},
(res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (res.statusCode >= 200 && res.statusCode < 300) {
try { resolve(body ? JSON.parse(body) : null); }
catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); }
} else {
reject(new Error(`Docker API ${res.statusCode}: ${body}`));
}
});
}
);
req.on('error', reject);
req.end();
});
}
// Streaming GET — caller gets the raw IncomingMessage so they can pipe
// or parse the multiplexed log frames.
function dockerStream(pathname, query) {
return new Promise((resolve, reject) => {
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
const req = http.request(
{
socketPath: DOCKER_SOCKET,
method: 'GET',
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
headers: { 'Host': 'docker' }
},
(res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ stream: res, req });
} else {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => reject(new Error(
`Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}`
)));
}
}
);
req.on('error', reject);
req.end();
});
}
// Map Docker's verbose state info to a UX-friendly status line.
// running → "Up 2 hours"
// exited → "Exited (0) 5 minutes ago"
// restarting→ "Restarting"
function statusLineFromContainer(c) {
// `Status` from /containers/json is already exactly the human form
// we want ("Up 4 minutes", "Exited (0) 2 hours ago", etc.).
return c.Status || c.State || '';
}
// =====================================================================
// Validation helpers
// =====================================================================
const SAFE_NAME = /^[a-zA-Z0-9_.-]+$/;
function safeName(name) { return typeof name === 'string' && SAFE_NAME.test(name); }
// SSE-wrap `tail -F -n <tail> <file>` and emit `log` events line-by-line so
// the frontend renders host logs through the existing viewer with zero
// changes. We use file-based tailing instead of journalctl because the
// libreportal container is Alpine-based and journalctl plumbing into a
// non-systemd container is heavier than the value. CrowdSec writes
// /var/log/crowdsec.log and /var/log/crowdsec-firewall-bouncer.log by
// default — the libreportal compose bind-mounts /var/log:/host/var/log:ro
// so log paths in apps-services.json carry the /host prefix.
//
// -F (not -f): retries on missing files and follows log rotation, so a
// briefly-absent file (e.g., before the agent has started) doesn't kill the
// stream.
// Stream bounds — keep tail from forking forever and a chatty log from
// drowning the SSE channel. All three are user-configurable via
// configs/webui/webui_logs; 0 disables the limit (max-duration is the only
// one where 0 is dangerous — left to the operator's judgement).
function streamLimitsFromConfig() {
const idleMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES);
const maxMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES);
const lps = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC);
return {
idleMs: Number.isFinite(idleMin) && idleMin >= 0 ? idleMin * 60_000 : 10 * 60_000,
maxMs: Number.isFinite(maxMin) && maxMin >= 0 ? maxMin * 60_000 : 60 * 60_000,
maxLps: Number.isFinite(lps) && lps > 0 ? lps : 200
};
}
function streamHostLogFile(unit, logFile, tail, res, send, ping) {
// Whitelist: paths must live under the bind-mounted /host/var/log/ tree
// to prevent a malformed apps-services.json from reading anywhere on
// disk. apps-services.json itself is generator-produced, but defence in
// depth.
if (typeof logFile !== 'string' || !logFile.startsWith('/host/var/log/') || logFile.includes('..')) {
send('error', { message: `Refusing to tail untrusted log path: ${logFile}` });
send('end', { code: 400 });
clearInterval(ping);
return res.end();
}
const limits = streamLimitsFromConfig();
send('ready', {
at: Date.now(), tail, transport: 'systemd', unit, logFile,
limits: { idleMinutes: limits.idleMs / 60000, maxMinutes: limits.maxMs / 60000, maxLinesPerSec: limits.maxLps }
});
const child = spawn('tail', ['-F', '-n', String(tail), logFile], {
stdio: ['ignore', 'pipe', 'pipe']
});
// Resource ceilings. cleanup() unwinds everything; called from req-close,
// tail-exit, hard-cap timeout, and idle-disconnect path.
let lastLineAt = Date.now();
let rateWindowStart = Date.now();
let rateWindowLines = 0;
let rateDroppedThisWindow = 0;
// 0 = disabled — skip the timer entirely.
const hardCapTimer = limits.maxMs > 0 ? setTimeout(() => {
send('end', { code: 0, reason: 'max-duration', limitMinutes: limits.maxMs / 60000 });
cleanup();
try { res.end(); } catch { /* already done */ }
}, limits.maxMs) : null;
const idleTimer = limits.idleMs > 0 ? setInterval(() => {
if (Date.now() - lastLineAt > limits.idleMs) {
send('end', { code: 0, reason: 'idle-timeout', limitMinutes: limits.idleMs / 60000 });
cleanup();
try { res.end(); } catch { /* already done */ }
}
}, 60_000) : null;
const cleanup = () => {
clearInterval(ping);
if (idleTimer) clearInterval(idleTimer);
if (hardCapTimer) clearTimeout(hardCapTimer);
try { child.kill('SIGTERM'); } catch { /* already gone */ }
};
res.req.on('close', cleanup);
// stdout = log lines; stderr usually = "cannot open" notices from tail
// when the file doesn't exist yet — surface as `log` lines too so the
// user sees what's happening without panicking the viewer.
const linebuf = (which) => {
let buf = '';
return (chunk) => {
buf += chunk.toString('utf8');
const lines = buf.split('\n');
buf = lines.pop();
if (!lines.length) return;
lastLineAt = Date.now();
// Rate limit: rolling 1-second window. Lines past the ceiling drop;
// emit a single notice at window-close so the user knows a flood is
// ongoing without us spamming the notice line every iteration.
const now = Date.now();
if (now - rateWindowStart >= 1000) {
if (rateDroppedThisWindow > 0) {
send('log', { stream: 'meta', lines: [`[rate-limit: ${rateDroppedThisWindow} line(s) dropped in the last second]`] });
}
rateWindowStart = now;
rateWindowLines = 0;
rateDroppedThisWindow = 0;
}
const remaining = limits.maxLps - rateWindowLines;
if (remaining <= 0) {
rateDroppedThisWindow += lines.length;
return;
}
if (lines.length > remaining) {
send('log', { stream: which, lines: lines.slice(0, remaining) });
rateDroppedThisWindow += lines.length - remaining;
rateWindowLines = limits.maxLps;
} else {
send('log', { stream: which, lines });
rateWindowLines += lines.length;
}
};
};
child.stdout.on('data', linebuf('stdout'));
child.stderr.on('data', linebuf('stderr'));
child.on('error', (err) => {
send('error', { message: `tail spawn failed: ${err.message}` });
send('end', { code: 1 });
cleanup();
try { res.end(); } catch { /* already done */ }
});
child.on('exit', (code) => {
send('end', { code: code ?? 0 });
cleanup();
try { res.end(); } catch { /* already done */ }
});
}
// Look up a service entry in apps-services.json (the generator-produced file
// the frontend already consumes). Host-installed apps are emitted by
// webui_services.sh with `transport: 'systemd'` and a `unit` field — that's
// our signal to route logs to journalctl instead of `docker logs`.
//
// The lookup also doubles as an allow-list: we ONLY journalctl units that
// appear in this file, so a caller can't request `journalctl -u
// arbitrary.service`. The names there originate from CFG_*_HOST_SERVICES
// declared in container configs.
async function lookupServiceTransport(appName, serviceName) {
try {
const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8');
const data = JSON.parse(raw);
const entries = Array.isArray(data?.services) ? data.services : [];
for (const s of entries) {
if (s.app !== appName) continue;
if (s.serviceName !== serviceName && s.name !== serviceName) continue;
if (s.transport === 'systemd' && typeof s.unit === 'string') {
return { transport: 'systemd', unit: s.unit, logFile: s.logFile || null };
}
return { transport: 'docker' };
}
} catch { /* fall through to docker default */ }
return { transport: 'docker' };
}
function appComposeFile(appName) {
return path.join(CONTAINERS_DIR, appName, 'docker-compose.yml');
}
// =====================================================================
// GET /api/apps/:appName/services/status
// → [{ serviceName, state, statusText, containerName, containerId }]
// =====================================================================
router.get('/:appName/services/status', requireAuth, async (req, res) => {
const { appName } = req.params;
if (!safeName(appName)) return res.status(400).json({ error: 'Invalid app name' });
try {
const filters = JSON.stringify({
label: [`com.docker.compose.project=${appName}`]
});
const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters });
const services = (containers || [])
.map(c => {
const labels = c.Labels || {};
const serviceName = labels['com.docker.compose.service'];
if (!serviceName) return null;
// c.Names is like ['/libreportal-service'] — strip leading slash.
const containerName = (c.Names && c.Names[0] || '').replace(/^\//, '');
return {
serviceName,
state: c.State || 'unknown',
statusText: statusLineFromContainer(c),
containerName,
containerId: c.Id
};
})
.filter(Boolean);
// Merge in synthetic host-service entries from apps-services.json.
// webui_services.sh emits transport=systemd rows for HOST_INSTALL apps;
// they don't appear in Docker but should still render on the Services
// tab so the user can see status + tail logs for the host agent(s).
try {
const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8');
const data = JSON.parse(raw);
for (const s of (data?.services || [])) {
if (s.app !== appName) continue;
if (s.transport !== 'systemd') continue;
services.push({
serviceName: s.serviceName || s.name,
state: s.status === 'active' ? 'running' : 'exited',
statusText: s.status === 'active' ? 'Active (host service)' : 'Inactive (host service)',
containerName: s.unit || s.serviceName,
containerId: null,
transport: 'systemd',
unit: s.unit
});
}
} catch { /* file may not exist yet on fresh install */ }
res.json(services);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// =====================================================================
// POST /api/apps/:appName/services/:serviceName/restart
// Creates a task that runs `docker compose restart <service>` on the
// host. The host has `docker` available; this container does not.
// =====================================================================
router.post('/:appName/services/:serviceName/restart', requireAuth, async (req, res) => {
const { appName, serviceName } = req.params;
if (!safeName(appName) || !safeName(serviceName)) {
return res.status(400).json({ error: 'Invalid app or service name' });
}
const compose = appComposeFile(appName);
if (!fs.existsSync(compose)) {
return res.status(404).json({ error: `Compose file not found: ${compose}` });
}
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const task = {
id,
command: `docker compose -f "${compose}" restart "${serviceName}"`,
type: 'service-restart',
app: appName,
config: serviceName,
status: 'queued',
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
heartbeatAt: null,
exitCode: null,
errorMessage: null
};
try {
await fsp.mkdir(TASKS_DIR, { recursive: true });
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);
res.status(201).json(task);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// =====================================================================
// GET /api/apps/:appName/services/:serviceName/logs
// SSE-wraps the Docker /containers/<id>/logs?follow=1 stream.
// Docker multiplexes stdout+stderr into 8-byte-framed chunks unless
// the container has tty=true; we handle both.
// =====================================================================
router.get('/:appName/services/:serviceName/logs', requireAuth, async (req, res) => {
const { appName, serviceName } = req.params;
if (!safeName(appName) || !safeName(serviceName)) {
return res.status(400).json({ error: 'Invalid app or service name' });
}
const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200));
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const send = (event, data) => {
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
};
// Heartbeat for reverse proxies during quiet logs.
const ping = setInterval(() => {
try { res.write(': ping\n\n'); } catch { /* gone */ }
}, 25_000);
// Fork: host-installed services (transport=systemd) get journalctl
// instead of `docker logs`. Lookup is via apps-services.json which also
// doubles as the unit-name allow-list — only units declared in
// CFG_*_HOST_SERVICES make it into that file.
const transport = await lookupServiceTransport(appName, serviceName);
if (transport.transport === 'systemd') {
if (!transport.logFile) {
send('error', { message: `Host service ${transport.unit} has no logFile configured.` });
send('end', { code: 404 });
clearInterval(ping);
return res.end();
}
return streamHostLogFile(transport.unit, transport.logFile, tail, res, send, ping);
}
let containerInspect, logStreamHandle;
const cleanup = () => {
clearInterval(ping);
try { logStreamHandle?.req.destroy(); } catch { /* already gone */ }
};
req.on('close', cleanup);
try {
// 1. Resolve the container that owns this compose service.
const filters = JSON.stringify({
label: [
`com.docker.compose.project=${appName}`,
`com.docker.compose.service=${serviceName}`
]
});
const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters });
if (!containers || containers.length === 0) {
send('error', { message: `No container found for ${appName}/${serviceName}` });
send('end', { code: 404 });
cleanup();
return res.end();
}
const containerId = containers[0].Id;
// 2. Inspect once to learn whether the container has a TTY (changes
// how the log stream is framed).
containerInspect = await dockerRequest('GET', `/containers/${containerId}/json`);
const hasTty = !!(containerInspect.Config && containerInspect.Config.Tty);
send('ready', { at: Date.now(), tail, tty: hasTty });
// 3. Open the log stream.
logStreamHandle = await dockerStream(`/containers/${containerId}/logs`, {
stdout: '1',
stderr: '1',
follow: '1',
tail: String(tail),
timestamps: '0'
});
const stream = logStreamHandle.stream;
if (hasTty) {
// Plain text — just split on newlines.
let buf = '';
stream.on('data', chunk => {
buf += chunk.toString('utf8');
const lines = buf.split('\n');
buf = lines.pop();
if (lines.length) send('log', { stream: 'stdout', lines });
});
stream.on('end', () => {
if (buf) send('log', { stream: 'stdout', lines: [buf] });
send('end', { code: 0 });
cleanup();
try { res.end(); } catch { /* already done */ }
});
} else {
// Multiplexed framing:
// [stream_type:1][0:3][size:4 BE][payload:size]
// stream_type: 1=stdout, 2=stderr (0=stdin, never seen here)
let pending = Buffer.alloc(0);
let stdoutBuf = '';
let stderrBuf = '';
const flush = (which, line) => {
const buf = which === 'stdout' ? stdoutBuf : stderrBuf;
const all = buf + line;
const lines = all.split('\n');
const tailPart = lines.pop();
if (which === 'stdout') stdoutBuf = tailPart; else stderrBuf = tailPart;
if (lines.length) send('log', { stream: which, lines });
};
stream.on('data', chunk => {
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
while (pending.length >= 8) {
const streamType = pending[0];
const size = pending.readUInt32BE(4);
if (pending.length < 8 + size) break; // wait for more bytes
const payload = pending.slice(8, 8 + size).toString('utf8');
pending = pending.slice(8 + size);
flush(streamType === 2 ? 'stderr' : 'stdout', payload);
}
});
stream.on('end', () => {
if (stdoutBuf) send('log', { stream: 'stdout', lines: [stdoutBuf] });
if (stderrBuf) send('log', { stream: 'stderr', lines: [stderrBuf] });
send('end', { code: 0 });
cleanup();
try { res.end(); } catch { /* already done */ }
});
}
stream.on('error', err => {
send('error', { message: err.message });
cleanup();
try { res.end(); } catch { /* already done */ }
});
} catch (err) {
send('error', { message: err.message });
send('end', { code: 500 });
cleanup();
try { res.end(); } catch { /* already done */ }
}
});
module.exports = router;