All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
1025 lines
31 KiB
JavaScript
1025 lines
31 KiB
JavaScript
const http = require('node:http');
|
|
const https = require('node:https');
|
|
const fs = require('node:fs');
|
|
const fsp = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const { URL } = require('node:url');
|
|
const crypto = require('node:crypto');
|
|
|
|
const PORT = Number(process.env.PORT || 80);
|
|
const dockerPublicDir = path.join(__dirname, 'public');
|
|
const PUBLIC_DIR = fs.existsSync(dockerPublicDir) ? dockerPublicDir : __dirname;
|
|
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
|
const WATCHTOWER_CONTAINER = process.env.WATCHTOWER_CONTAINER || 'watchtower';
|
|
const WATCHTOWER_RUN_IMAGE = process.env.WATCHTOWER_RUN_IMAGE || 'containrrr/watchtower';
|
|
const WATCHTOWER_CONFIG_PATH = process.env.WATCHTOWER_CONFIG_PATH || '/volume2/docker/watchtower/config.json';
|
|
const WATCHTOWER_LOG_TAIL = Number(process.env.WATCHTOWER_LOG_TAIL || 1200);
|
|
const AUTHORITY = process.env.AUTHORITY || 'https://auth.mischlabs.de/realms/mischlabs';
|
|
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'mischlabs-admin';
|
|
const ADMIN_USERS = (process.env.ADMIN_USERS || 'mrdiderot').split(',').map((user) => user.trim().toLowerCase()).filter(Boolean);
|
|
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'mail.misch@pm.me').split(',').map((email) => email.trim().toLowerCase()).filter(Boolean);
|
|
const RESTART_BLOCKLIST = new Set((process.env.RESTART_BLOCKLIST || [
|
|
'mischlabs',
|
|
'watchtower',
|
|
'gitea',
|
|
'gitea-runner',
|
|
'auth',
|
|
'auth-db',
|
|
'cloudflared',
|
|
'nginx-proxy.manager',
|
|
'tailscale',
|
|
'nextcloud_db',
|
|
'michess_db'
|
|
].join(',')).split(',').map((name) => name.trim()).filter(Boolean));
|
|
const DISK_TARGETS = [
|
|
{ id: 'volume1', label: 'Volume 1', path: process.env.STATUS_VOLUME1_PATH || '/host/volume1', mount: '/volume1:/host/volume1:ro' },
|
|
{ id: 'volume2', label: 'Volume 2', path: process.env.STATUS_VOLUME2_PATH || '/host/volume2', mount: '/volume2:/host/volume2:ro' },
|
|
{ id: 'root', label: 'Root', path: '/' }
|
|
];
|
|
|
|
const MIME_TYPES = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
'.png': 'image/png',
|
|
'.svg': 'image/svg+xml; charset=utf-8',
|
|
'.ico': 'image/x-icon'
|
|
};
|
|
|
|
const serviceUrls = [
|
|
'https://mischlabs.de',
|
|
'https://auth.mischlabs.de/realms/mischlabs/account',
|
|
'https://drive.mischlabs.de',
|
|
'https://git.mischlabs.de',
|
|
'https://movies.mischlabs.de',
|
|
'https://audiobook.mischlabs.de',
|
|
'https://password.mischlabs.de',
|
|
'https://books.mischlabs.de',
|
|
'https://michess.mischlabs.de',
|
|
'https://tom.mischlabs.de'
|
|
];
|
|
|
|
let jwksCache = null;
|
|
let jwksCacheUntil = 0;
|
|
const manualWatchtowerRuns = new Map();
|
|
const dangerTokens = new Map();
|
|
|
|
function json(res, status, payload) {
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Cache-Control': 'no-store'
|
|
});
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function dockerRequest(endpoint, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request({
|
|
socketPath: DOCKER_SOCKET,
|
|
path: endpoint,
|
|
method: options.method || 'GET',
|
|
headers: options.headers || {}
|
|
}, (res) => {
|
|
let body = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => {
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`Docker API ${endpoint} returned ${res.statusCode}`));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
resolve(body ? JSON.parse(body) : null);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
if (options.body) req.write(options.body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function readJsonUrl(target) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = https.request(target, {
|
|
method: 'GET',
|
|
timeout: 8000,
|
|
headers: { 'User-Agent': 'MischLabs-Admin/1.0' }
|
|
}, (res) => {
|
|
let body = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => {
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`OIDC endpoint returned ${res.statusCode}`));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function getJwks() {
|
|
if (jwksCache && Date.now() < jwksCacheUntil) return jwksCache;
|
|
const config = await readJsonUrl(`${AUTHORITY}/.well-known/openid-configuration`);
|
|
const jwks = await readJsonUrl(config.jwks_uri);
|
|
jwksCache = jwks;
|
|
jwksCacheUntil = Date.now() + 60 * 60 * 1000;
|
|
return jwks;
|
|
}
|
|
|
|
function decodeJwtPart(part) {
|
|
const padded = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '=');
|
|
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
}
|
|
|
|
function authError(message) {
|
|
const error = new Error(message);
|
|
error.statusCode = 401;
|
|
return error;
|
|
}
|
|
|
|
async function verifyAdminRequest(req) {
|
|
const token = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
if (!token) throw authError('missing_token');
|
|
|
|
const [headerPart, payloadPart, signaturePart] = token.split('.');
|
|
if (!headerPart || !payloadPart || !signaturePart) throw authError('invalid_token');
|
|
|
|
let header;
|
|
let claims;
|
|
try {
|
|
header = decodeJwtPart(headerPart);
|
|
claims = decodeJwtPart(payloadPart);
|
|
} catch {
|
|
throw authError('invalid_token');
|
|
}
|
|
|
|
if (header.alg !== 'RS256') throw authError('unsupported_alg');
|
|
if (claims.iss !== AUTHORITY) throw authError('invalid_issuer');
|
|
if (claims.exp * 1000 < Date.now()) throw authError('expired_token');
|
|
|
|
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
if (!audience.includes(ADMIN_CLIENT_ID) && claims.azp !== ADMIN_CLIENT_ID) throw authError('invalid_audience');
|
|
|
|
const username = String(claims.preferred_username || '').toLowerCase();
|
|
const email = String(claims.email || '').toLowerCase();
|
|
if (!ADMIN_USERS.includes(username) && !ADMIN_EMAILS.includes(email)) throw authError('not_allowed');
|
|
|
|
const jwks = await getJwks();
|
|
const jwk = jwks.keys?.find((key) => key.kid === header.kid);
|
|
if (!jwk) throw authError('unknown_key');
|
|
|
|
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
|
const verifier = crypto.createVerify('RSA-SHA256');
|
|
verifier.update(`${headerPart}.${payloadPart}`);
|
|
verifier.end();
|
|
const signature = Buffer.from(signaturePart.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
if (!verifier.verify(publicKey, signature)) throw authError('invalid_signature');
|
|
|
|
return claims;
|
|
}
|
|
|
|
function normalizeContainerName(name) {
|
|
return String(name || '').replace(/^\//, '').trim();
|
|
}
|
|
|
|
function canRestartContainer(container, danger = false) {
|
|
const name = normalizeContainerName(container.name || container.Names?.[0]);
|
|
if (!name) return false;
|
|
if (danger) return container.state === 'running' || container.State === 'running';
|
|
if (RESTART_BLOCKLIST.has(name)) return false;
|
|
if (name.endsWith('_db') || name.includes('-db')) return false;
|
|
return container.state === 'running' || container.State === 'running';
|
|
}
|
|
|
|
function restartBlockReason(container) {
|
|
const name = normalizeContainerName(container.name || container.Names?.[0]);
|
|
if (!name) return 'Kein Containername.';
|
|
if (RESTART_BLOCKLIST.has(name)) return 'Geschuetzter Systemdienst.';
|
|
if (name.endsWith('_db') || name.includes('-db')) return 'Datenbank-Container.';
|
|
if (container.state !== 'running' && container.State !== 'running') return 'Container laeuft nicht.';
|
|
return null;
|
|
}
|
|
|
|
function pruneDangerTokens() {
|
|
const now = Date.now();
|
|
for (const [token, expiresAt] of dangerTokens.entries()) {
|
|
if (expiresAt <= now) dangerTokens.delete(token);
|
|
}
|
|
}
|
|
|
|
function createDangerToken() {
|
|
pruneDangerTokens();
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const expiresAt = Date.now() + 2 * 60 * 1000;
|
|
dangerTokens.set(token, expiresAt);
|
|
return {
|
|
token,
|
|
expiresAt: new Date(expiresAt).toISOString()
|
|
};
|
|
}
|
|
|
|
function verifyDangerToken(req) {
|
|
pruneDangerTokens();
|
|
const token = req.headers['x-mischlabs-danger-token'];
|
|
if (!token || Array.isArray(token)) return false;
|
|
const expiresAt = dangerTokens.get(token);
|
|
return Boolean(expiresAt && expiresAt > Date.now());
|
|
}
|
|
|
|
function dockerLogs(container, tail = 160) {
|
|
return new Promise((resolve, reject) => {
|
|
const endpoint = `/containers/${encodeURIComponent(container)}/logs?stdout=1&stderr=1&tail=${tail}`;
|
|
const req = http.request({
|
|
socketPath: DOCKER_SOCKET,
|
|
path: endpoint,
|
|
method: 'GET'
|
|
}, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`Docker logs returned ${res.statusCode}`));
|
|
return;
|
|
}
|
|
|
|
const raw = Buffer.concat(chunks);
|
|
const lines = [];
|
|
let offset = 0;
|
|
|
|
while (offset + 8 <= raw.length) {
|
|
const size = raw.readUInt32BE(offset + 4);
|
|
const start = offset + 8;
|
|
const end = start + size;
|
|
if (end > raw.length) break;
|
|
lines.push(raw.slice(start, end).toString('utf8'));
|
|
offset = end;
|
|
}
|
|
|
|
resolve((lines.length ? lines.join('') : raw.toString('utf8')).trim().split(/\r?\n/).filter(Boolean));
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function getContainers() {
|
|
const containers = await dockerRequest('/containers/json?all=0');
|
|
return containers.map((container) => ({
|
|
id: container.Id.slice(0, 12),
|
|
name: (container.Names?.[0] || '').replace(/^\//, ''),
|
|
image: container.Image,
|
|
status: container.Status,
|
|
state: container.State,
|
|
restartable: canRestartContainer(container),
|
|
restartBlockReason: restartBlockReason(container),
|
|
ports: (container.Ports || []).map((port) => ({
|
|
privatePort: port.PrivatePort,
|
|
publicPort: port.PublicPort,
|
|
type: port.Type,
|
|
ip: port.IP
|
|
}))
|
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
async function restartContainer(containerName, danger = false) {
|
|
const name = normalizeContainerName(containerName);
|
|
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
|
const error = new Error('Ungueltiger Containername.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const inspect = await dockerRequest(`/containers/${encodeURIComponent(name)}/json`);
|
|
const container = {
|
|
name: inspect.Name,
|
|
state: inspect.State?.Status
|
|
};
|
|
|
|
if (!canRestartContainer(container, danger)) {
|
|
const error = new Error(`Container ${name} ist fuer Neustarts ueber das Dashboard gesperrt.`);
|
|
error.statusCode = 403;
|
|
throw error;
|
|
}
|
|
|
|
await dockerRequest(`/containers/${encodeURIComponent(name)}/restart?t=10`, { method: 'POST' });
|
|
return {
|
|
name,
|
|
restartedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
async function runWatchtowerOnce() {
|
|
cleanupManualWatchtowerContainers().catch(() => {});
|
|
|
|
const name = `mischlabs-watchtower-run-once-${Date.now()}`;
|
|
const body = JSON.stringify({
|
|
Image: WATCHTOWER_RUN_IMAGE,
|
|
Cmd: ['--run-once'],
|
|
Labels: {
|
|
'com.mischlabs.role': 'manual-watchtower-run',
|
|
'com.centurylinklabs.watchtower.enable': 'false'
|
|
},
|
|
HostConfig: {
|
|
AutoRemove: false,
|
|
Binds: [
|
|
'/var/run/docker.sock:/var/run/docker.sock',
|
|
`${WATCHTOWER_CONFIG_PATH}:/config.json:ro`
|
|
]
|
|
}
|
|
});
|
|
|
|
const created = await dockerRequest(`/containers/create?name=${encodeURIComponent(name)}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(body)
|
|
},
|
|
body
|
|
});
|
|
|
|
await dockerRequest(`/containers/${created.Id}/start`, { method: 'POST' });
|
|
const payload = {
|
|
id: created.Id.slice(0, 12),
|
|
fullId: created.Id,
|
|
name,
|
|
image: WATCHTOWER_RUN_IMAGE,
|
|
state: 'running',
|
|
running: true,
|
|
exitCode: null,
|
|
startedAt: new Date().toISOString(),
|
|
finishedAt: null,
|
|
session: null,
|
|
tail: []
|
|
};
|
|
|
|
manualWatchtowerRuns.set(created.Id, payload);
|
|
monitorWatchtowerRun(created.Id).catch((error) => {
|
|
manualWatchtowerRuns.set(created.Id, {
|
|
...payload,
|
|
state: 'error',
|
|
running: false,
|
|
exitCode: 1,
|
|
finishedAt: new Date().toISOString(),
|
|
error: error.message
|
|
});
|
|
});
|
|
|
|
return payload;
|
|
}
|
|
|
|
async function removeContainer(id) {
|
|
await dockerRequest(`/containers/${encodeURIComponent(id)}?force=1`, { method: 'DELETE' });
|
|
}
|
|
|
|
async function waitContainer(id) {
|
|
return dockerRequest(`/containers/${encodeURIComponent(id)}/wait`, { method: 'POST' });
|
|
}
|
|
|
|
async function cleanupManualWatchtowerContainers() {
|
|
const containers = await dockerRequest('/containers/json?all=1');
|
|
await Promise.all((containers || [])
|
|
.filter((container) => (
|
|
container.Labels?.['com.mischlabs.role'] === 'manual-watchtower-run'
|
|
&& container.State !== 'running'
|
|
))
|
|
.map((container) => removeContainer(container.Id).catch(() => {})));
|
|
}
|
|
|
|
async function readWatchtowerRunContainer(runId) {
|
|
const id = normalizeContainerName(runId);
|
|
if (!id || !/^[a-fA-F0-9]{12,64}$/.test(id)) {
|
|
const error = new Error('Ungueltige Watchtower-Run-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const [inspect, logs] = await Promise.all([
|
|
dockerRequest(`/containers/${encodeURIComponent(id)}/json`),
|
|
dockerLogs(id, 260).catch((error) => [`watchtower run logs unavailable: ${error.message}`])
|
|
]);
|
|
|
|
const labels = inspect.Config?.Labels || {};
|
|
if (labels['com.mischlabs.role'] !== 'manual-watchtower-run') {
|
|
const error = new Error('Container gehoert nicht zu manuellen Watchtower-Laeufen.');
|
|
error.statusCode = 403;
|
|
throw error;
|
|
}
|
|
|
|
const state = inspect.State || {};
|
|
const parsed = Array.isArray(logs) ? parseWatchtower(logs) : null;
|
|
const payload = {
|
|
id: inspect.Id.slice(0, 12),
|
|
fullId: inspect.Id,
|
|
name: normalizeContainerName(inspect.Name),
|
|
image: inspect.Config?.Image || WATCHTOWER_RUN_IMAGE,
|
|
state: state.Status || 'unknown',
|
|
running: Boolean(state.Running),
|
|
exitCode: state.ExitCode,
|
|
startedAt: state.StartedAt || null,
|
|
finishedAt: state.FinishedAt && !state.FinishedAt.startsWith('0001-') ? state.FinishedAt : null,
|
|
session: parsed,
|
|
tail: Array.isArray(logs) ? logs.slice(-20) : logs
|
|
};
|
|
|
|
return payload;
|
|
}
|
|
|
|
async function monitorWatchtowerRun(runId) {
|
|
await waitContainer(runId).catch(() => null);
|
|
|
|
const payload = await readWatchtowerRunContainer(runId);
|
|
manualWatchtowerRuns.set(runId, payload);
|
|
await removeContainer(runId).catch(() => {});
|
|
}
|
|
|
|
async function getWatchtowerRun(runId) {
|
|
const id = normalizeContainerName(runId);
|
|
if (!id || !/^[a-fA-F0-9]{12,64}$/.test(id)) {
|
|
const error = new Error('Ungueltige Watchtower-Run-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (manualWatchtowerRuns.has(id)) {
|
|
const cached = manualWatchtowerRuns.get(id);
|
|
if (!cached.running) {
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const livePayload = await readWatchtowerRunContainer(id);
|
|
manualWatchtowerRuns.set(id, livePayload);
|
|
|
|
if (!livePayload.running) {
|
|
removeContainer(id).catch(() => {});
|
|
}
|
|
|
|
return livePayload;
|
|
} catch (error) {
|
|
if (manualWatchtowerRuns.has(id)) {
|
|
return manualWatchtowerRuns.get(id);
|
|
}
|
|
error.statusCode = error.statusCode || 404;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getDisks() {
|
|
const disks = [];
|
|
|
|
for (const target of DISK_TARGETS) {
|
|
try {
|
|
const stats = await fsp.statfs(target.path);
|
|
const total = Number(stats.blocks) * Number(stats.bsize);
|
|
const available = Number(stats.bavail) * Number(stats.bsize);
|
|
const used = total - available;
|
|
disks.push({
|
|
...target,
|
|
total,
|
|
used,
|
|
available,
|
|
usedPercent: total ? Math.round((used / total) * 100) : null
|
|
});
|
|
} catch (error) {
|
|
disks.push({ ...target, error: error.message });
|
|
}
|
|
}
|
|
|
|
return disks;
|
|
}
|
|
|
|
function isPathAllowed(userPath) {
|
|
if (!userPath) return false;
|
|
const resolved = path.resolve(userPath);
|
|
return resolved.startsWith('/host/volume1') || resolved.startsWith('/host/volume2') || resolved === '/';
|
|
}
|
|
|
|
async function getDirectorySize(dirPath, maxDepth = 3, currentDepth = 0) {
|
|
let size = 0;
|
|
try {
|
|
const stats = await fsp.lstat(dirPath);
|
|
if (stats.isSymbolicLink()) return 0;
|
|
if (stats.isFile()) {
|
|
return stats.size;
|
|
}
|
|
if (stats.isDirectory()) {
|
|
if (currentDepth >= maxDepth) return 0;
|
|
const files = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
const promises = files.map(async (file) => {
|
|
const fullPath = path.join(dirPath, file.name);
|
|
if (file.isSymbolicLink()) return 0;
|
|
if (file.isDirectory()) {
|
|
return getDirectorySize(fullPath, maxDepth, currentDepth + 1);
|
|
}
|
|
if (file.isFile()) {
|
|
try {
|
|
const fileStats = await fsp.lstat(fullPath);
|
|
return fileStats.size;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
return 0;
|
|
});
|
|
const sizes = await Promise.all(promises);
|
|
size = sizes.reduce((acc, curr) => acc + curr, 0);
|
|
}
|
|
} catch (err) {
|
|
// Ignore errors
|
|
}
|
|
return size;
|
|
}
|
|
|
|
async function scanDirectoryChildren(dirPath) {
|
|
if (!isPathAllowed(dirPath)) {
|
|
throw new Error('Unzulässiger Pfad. Zugriff verweigert.');
|
|
}
|
|
|
|
const files = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
const results = [];
|
|
|
|
const promises = files.map(async (file) => {
|
|
const fullPath = path.join(dirPath, file.name);
|
|
try {
|
|
if (file.isSymbolicLink()) {
|
|
results.push({ name: file.name, path: fullPath, isDirectory: false, size: 0, isLink: true });
|
|
return;
|
|
}
|
|
|
|
if (file.isDirectory()) {
|
|
const dirSize = await getDirectorySize(fullPath, 2);
|
|
results.push({ name: file.name, path: fullPath, isDirectory: true, size: dirSize });
|
|
} else {
|
|
const stats = await fsp.lstat(fullPath);
|
|
results.push({ name: file.name, path: fullPath, isDirectory: false, size: stats.size });
|
|
}
|
|
} catch {
|
|
results.push({ name: file.name, path: fullPath, isDirectory: file.isDirectory(), size: 0, error: true });
|
|
}
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
results.sort((a, b) => b.size - a.size);
|
|
return results;
|
|
}
|
|
|
|
function inspectService(urlStr) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const parsed = new URL(urlStr);
|
|
const client = parsed.protocol === 'https:' ? https : http;
|
|
const start = Date.now();
|
|
const req = client.get(parsed.href, {
|
|
timeout: 4000,
|
|
headers: { 'User-Agent': 'MischLabs-Diagnostics/1.0' }
|
|
}, (res) => {
|
|
const chunks = [];
|
|
let bodyLength = 0;
|
|
res.on('data', (chunk) => {
|
|
if (bodyLength < 2000) {
|
|
chunks.push(chunk);
|
|
bodyLength += chunk.length;
|
|
}
|
|
});
|
|
res.on('end', () => {
|
|
const duration = Date.now() - start;
|
|
const bodyPreview = Buffer.concat(chunks).toString('utf8').slice(0, 1000);
|
|
resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 400,
|
|
statusCode: res.statusCode,
|
|
statusMessage: res.statusMessage,
|
|
latency: duration,
|
|
headers: res.headers,
|
|
bodyPreview: bodyPreview
|
|
});
|
|
});
|
|
});
|
|
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
resolve({ ok: false, error: 'Timeout nach 4 Sekunden', latency: 4000 });
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
resolve({ ok: false, error: err.message, latency: Date.now() - start });
|
|
});
|
|
} catch (err) {
|
|
resolve({ ok: false, error: err.message, latency: 0 });
|
|
}
|
|
});
|
|
}
|
|
|
|
function parseWatchtower(lines) {
|
|
const sessions = [];
|
|
let current = null;
|
|
|
|
for (const line of lines) {
|
|
let time = null;
|
|
let isStart = false;
|
|
let isFound = false;
|
|
let isWarning = false;
|
|
let isDone = false;
|
|
let msg = '';
|
|
|
|
let failed = 0;
|
|
let scanned = 0;
|
|
let updated = 0;
|
|
|
|
// Try parsing as JSON first
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
time = parsed.time || null;
|
|
msg = parsed.msg || parsed.message || '';
|
|
|
|
if (
|
|
msg.includes('Running a one time update.')
|
|
|| msg.includes('Checking all containers')
|
|
|| msg.includes('Found new')
|
|
|| msg.includes('Stopping /')
|
|
|| msg.includes('Creating /')
|
|
) {
|
|
isStart = true;
|
|
}
|
|
|
|
if (msg.includes('Found new')) {
|
|
isFound = true;
|
|
}
|
|
|
|
if (parsed.level === 'warning' || parsed.level === 'warn') {
|
|
isWarning = true;
|
|
}
|
|
|
|
if (msg.includes('Session done')) {
|
|
isDone = true;
|
|
failed = Number(parsed.Failed ?? parsed.failed ?? 0);
|
|
scanned = Number(parsed.Scanned ?? parsed.scanned ?? 0);
|
|
updated = Number(parsed.Updated ?? parsed.updated ?? 0);
|
|
}
|
|
} catch {
|
|
// Fallback: Parse as standard text logfmt
|
|
const timeMatch = line.match(/time="([^"]+)"/);
|
|
time = timeMatch?.[1] || null;
|
|
|
|
if (
|
|
line.includes('Running a one time update.')
|
|
|| line.includes('Checking all containers')
|
|
|| line.includes('Found new')
|
|
|| line.includes('Stopping /')
|
|
|| line.includes('Creating /')
|
|
) {
|
|
isStart = true;
|
|
}
|
|
|
|
if (line.includes('Found new')) {
|
|
isFound = true;
|
|
const msgMatch = line.match(/msg="([^"]+)"/);
|
|
msg = msgMatch?.[1] || line;
|
|
}
|
|
|
|
if (line.includes('level=warning') || line.includes('level=warn')) {
|
|
isWarning = true;
|
|
const msgMatch = line.match(/msg="([^"]+)"/);
|
|
msg = msgMatch?.[1] || line;
|
|
}
|
|
|
|
const doneMatch = line.match(/Session done.*Failed=(\d+)\s+Scanned=(\d+)\s+Updated=(\d+)/);
|
|
if (doneMatch) {
|
|
isDone = true;
|
|
failed = Number(doneMatch[1]);
|
|
scanned = Number(doneMatch[2]);
|
|
updated = Number(doneMatch[3]);
|
|
}
|
|
}
|
|
|
|
// Process parsed information
|
|
if (isStart) {
|
|
current = current || { startedAt: time, found: [], warnings: [] };
|
|
}
|
|
|
|
if (isFound && current) {
|
|
current.found.push(msg || line);
|
|
}
|
|
|
|
if (isWarning && current) {
|
|
current.warnings.push(msg || line);
|
|
}
|
|
|
|
if (isDone) {
|
|
current = current || { startedAt: time, found: [], warnings: [] };
|
|
current.finishedAt = time;
|
|
current.failed = failed;
|
|
current.scanned = scanned;
|
|
current.updated = updated;
|
|
sessions.push(current);
|
|
current = null;
|
|
}
|
|
}
|
|
|
|
if (sessions.length) return sessions.at(-1);
|
|
|
|
// Ultimate fallback if no full session was structured sequentially but a "Session done" exists
|
|
for (const line of [...lines].reverse()) {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
const msg = parsed.msg || parsed.message || '';
|
|
if (msg.includes('Session done')) {
|
|
return {
|
|
startedAt: null,
|
|
finishedAt: parsed.time || null,
|
|
failed: Number(parsed.Failed ?? parsed.failed ?? 0),
|
|
scanned: Number(parsed.Scanned ?? parsed.scanned ?? 0),
|
|
updated: Number(parsed.Updated ?? parsed.updated ?? 0),
|
|
found: [],
|
|
warnings: []
|
|
};
|
|
}
|
|
} catch {
|
|
const doneMatch = line.match(/Session done.*Failed=(\d+)\s+Scanned=(\d+)\s+Updated=(\d+)/);
|
|
if (doneMatch) {
|
|
const timeMatch = line.match(/time="([^"]+)"/);
|
|
return {
|
|
startedAt: null,
|
|
finishedAt: timeMatch?.[1] || null,
|
|
failed: Number(doneMatch[1]),
|
|
scanned: Number(doneMatch[2]),
|
|
updated: Number(doneMatch[3]),
|
|
found: [],
|
|
warnings: []
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function probeUrl(target) {
|
|
return new Promise((resolve) => {
|
|
const started = Date.now();
|
|
const url = new URL(target);
|
|
const client = url.protocol === 'http:' ? http : https;
|
|
const req = client.request(url, {
|
|
method: 'HEAD',
|
|
timeout: 8000,
|
|
headers: { 'User-Agent': 'MischLabs-Status/1.0' }
|
|
}, (res) => {
|
|
res.resume();
|
|
resolve({
|
|
url: target,
|
|
ok: res.statusCode >= 200 && res.statusCode < 500,
|
|
statusCode: res.statusCode,
|
|
location: res.headers.location || null,
|
|
contentType: res.headers['content-type'] || null,
|
|
ms: Date.now() - started
|
|
});
|
|
});
|
|
|
|
req.on('timeout', () => {
|
|
req.destroy(new Error('timeout'));
|
|
});
|
|
req.on('error', (error) => {
|
|
resolve({
|
|
url: target,
|
|
ok: false,
|
|
error: error.message,
|
|
ms: Date.now() - started
|
|
});
|
|
});
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function getServices() {
|
|
return Promise.all(serviceUrls.map(probeUrl));
|
|
}
|
|
|
|
async function statusPayload() {
|
|
const [containers, disks, logs, services] = await Promise.all([
|
|
getContainers().catch((error) => ({ error: error.message })),
|
|
getDisks(),
|
|
dockerLogs(WATCHTOWER_CONTAINER, WATCHTOWER_LOG_TAIL).catch((error) => [`watchtower logs unavailable: ${error.message}`]),
|
|
getServices()
|
|
]);
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
containers,
|
|
disks,
|
|
watchtower: {
|
|
container: WATCHTOWER_CONTAINER,
|
|
generatedAt: new Date().toISOString(),
|
|
logTail: WATCHTOWER_LOG_TAIL,
|
|
lastSession: Array.isArray(logs) ? parseWatchtower(logs) : null,
|
|
tail: Array.isArray(logs) ? logs.slice(-20) : logs
|
|
},
|
|
services
|
|
};
|
|
}
|
|
|
|
async function serveStatic(req, res) {
|
|
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
let pathname = decodeURIComponent(requestUrl.pathname);
|
|
if (pathname === '/') pathname = '/index.html';
|
|
|
|
const filePath = path.normalize(path.join(PUBLIC_DIR, pathname));
|
|
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
res.writeHead(403);
|
|
res.end('Forbidden');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stat = await fsp.stat(filePath);
|
|
const target = stat.isDirectory() ? path.join(filePath, 'index.html') : filePath;
|
|
const ext = path.extname(target);
|
|
const headers = {
|
|
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
'X-Frame-Options': 'SAMEORIGIN'
|
|
};
|
|
|
|
if (path.basename(target) === 'sw.js') {
|
|
headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
|
} else if (path.basename(target) === 'services.json') {
|
|
headers['Cache-Control'] = 'no-cache';
|
|
} else if (['.css', '.js', '.png', '.svg'].includes(ext)) {
|
|
headers['Cache-Control'] = 'public, max-age=604800, immutable';
|
|
}
|
|
|
|
res.writeHead(200, headers);
|
|
fs.createReadStream(target).pipe(res);
|
|
} catch {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end('Not found');
|
|
}
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
if (req.url?.startsWith('/api/status')) {
|
|
try {
|
|
json(res, 200, await statusPayload());
|
|
} catch (error) {
|
|
json(res, 500, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const restartMatch = req.url?.match(/^\/api\/containers\/([^/?#]+)\/restart(?:[?#].*)?$/);
|
|
if (restartMatch) {
|
|
if (req.method !== 'POST') {
|
|
json(res, 405, { error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await verifyAdminRequest(req);
|
|
json(res, 200, await restartContainer(decodeURIComponent(restartMatch[1]), verifyDangerToken(req)));
|
|
} catch (error) {
|
|
const status = error.statusCode || (['missing_token', 'invalid_token', 'expired_token', 'not_allowed'].includes(error.message) ? 401 : 500);
|
|
json(res, status, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.url?.match(/^\/api\/danger\/unlock(?:[?#].*)?$/)) {
|
|
if (req.method !== 'POST') {
|
|
json(res, 405, { error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await verifyAdminRequest(req);
|
|
json(res, 200, createDangerToken());
|
|
} catch (error) {
|
|
json(res, error.statusCode || 500, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.url?.match(/^\/api\/watchtower\/run-once(?:[?#].*)?$/)) {
|
|
if (req.method !== 'POST') {
|
|
json(res, 405, { error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await verifyAdminRequest(req);
|
|
json(res, 202, await runWatchtowerOnce());
|
|
} catch (error) {
|
|
json(res, error.statusCode || 500, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const watchtowerRunMatch = req.url?.match(/^\/api\/watchtower\/runs\/([^/?#]+)(?:[?#].*)?$/);
|
|
if (watchtowerRunMatch) {
|
|
if (req.method !== 'GET') {
|
|
json(res, 405, { error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await verifyAdminRequest(req);
|
|
json(res, 200, await getWatchtowerRun(decodeURIComponent(watchtowerRunMatch[1])));
|
|
} catch (error) {
|
|
json(res, error.statusCode || 500, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const containerLogsMatch = req.url?.match(/^\/api\/containers\/([^/?#]+)\/logs(?:[?#].*)?$/);
|
|
if (containerLogsMatch) {
|
|
if (req.method !== 'GET') {
|
|
json(res, 405, { error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await verifyAdminRequest(req);
|
|
const containerName = decodeURIComponent(containerLogsMatch[1]);
|
|
const urlParams = new URL(req.url, 'http://localhost').searchParams;
|
|
const tail = Number(urlParams.get('tail') || 150);
|
|
const logs = await dockerLogs(containerName, tail);
|
|
json(res, 200, { name: containerName, logs });
|
|
} catch (error) {
|
|
json(res, error.statusCode || 500, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.url?.startsWith('/api/services/inspect')) {
|
|
if (req.method !== 'GET') {
|
|
json(res, 405, { error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await verifyAdminRequest(req);
|
|
const urlParams = new URL(req.url, 'http://localhost').searchParams;
|
|
const inspectUrl = urlParams.get('url');
|
|
if (!inspectUrl) {
|
|
json(res, 400, { error: 'Missing url parameter' });
|
|
return;
|
|
}
|
|
const data = await inspectService(inspectUrl);
|
|
json(res, 200, data);
|
|
} catch (error) {
|
|
json(res, error.statusCode || 500, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.url?.startsWith('/api/disks/scan-folder')) {
|
|
if (req.method !== 'GET') {
|
|
json(res, 405, { error: 'Method not allowed' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await verifyAdminRequest(req);
|
|
const urlParams = new URL(req.url, 'http://localhost').searchParams;
|
|
const scanPath = urlParams.get('path');
|
|
if (!scanPath) {
|
|
json(res, 400, { error: 'Missing path parameter' });
|
|
return;
|
|
}
|
|
const data = await scanDirectoryChildren(scanPath);
|
|
json(res, 200, { path: scanPath, children: data });
|
|
} catch (error) {
|
|
json(res, error.statusCode || 500, { error: error.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
await serveStatic(req, res);
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`MischLabs dashboard listening on ${PORT}`);
|
|
cleanupManualWatchtowerContainers().catch((error) => {
|
|
console.warn(`Manual Watchtower cleanup failed: ${error.message}`);
|
|
});
|
|
});
|