// 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//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 ` 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 ` 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//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;