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>
570 lines
22 KiB
JavaScript
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;
|