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 DASHBOARD_CLIENT_ID = process.env.DASHBOARD_CLIENT_ID || ADMIN_CLIENT_ID; const FAVORITES_FILE = process.env.FAVORITES_FILE || path.join(__dirname, 'data', 'favorites.json'); 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) { return verifyOidcRequest(req, { clientId: ADMIN_CLIENT_ID, requireAdmin: true }); } async function verifyUserRequest(req) { return verifyOidcRequest(req, { clientId: DASHBOARD_CLIENT_ID, requireAdmin: false }); } async function verifyOidcRequest(req, { clientId, requireAdmin }) { 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(clientId) && claims.azp !== clientId) throw authError('invalid_audience'); const username = String(claims.preferred_username || '').toLowerCase(); const email = String(claims.email || '').toLowerCase(); if (requireAdmin && !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 readRequestBody(req, maxBytes = 128 * 1024) { return new Promise((resolve, reject) => { const chunks = []; let total = 0; req.on('data', (chunk) => { total += chunk.length; if (total > maxBytes) { reject(Object.assign(new Error('payload_too_large'), { statusCode: 413 })); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function userKeyFromClaims(claims) { return String(claims.sub || claims.preferred_username || claims.email || '').replace(/[^a-zA-Z0-9_.@-]/g, '_'); } function publicUserFromClaims(claims) { return { id: claims.sub, username: claims.preferred_username || null, email: claims.email || null, name: claims.name || claims.preferred_username || claims.email || 'Nutzer' }; } async function readFavoritesStore() { try { const raw = await fsp.readFile(FAVORITES_FILE, 'utf8'); return JSON.parse(raw); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; } } async function writeFavoritesStore(store) { await fsp.mkdir(path.dirname(FAVORITES_FILE), { recursive: true }); const tmp = `${FAVORITES_FILE}.${process.pid}.tmp`; await fsp.writeFile(tmp, JSON.stringify(store, null, 2), 'utf8'); await fsp.rename(tmp, FAVORITES_FILE); } function cleanText(value, max = 80) { return String(value || '').trim().replace(/\s+/g, ' ').slice(0, max); } function cleanColor(value) { const color = String(value || '').trim(); return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#67e8f9'; } function cleanUrl(value) { const url = new URL(String(value || '').trim()); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Ungueltige URL.'); return url.href; } function sanitizeFavorites(input) { if (!Array.isArray(input)) throw new Error('Favoriten muessen ein Array sein.'); return input.slice(0, 80).map((item, index) => { const name = cleanText(item.name, 64); if (!name) throw new Error('Jeder Favorit braucht eine Beschriftung.'); return { id: cleanText(item.id, 80) || crypto.randomUUID(), name, url: cleanUrl(item.url), group: cleanText(item.group || 'Favoriten', 40) || 'Favoriten', description: cleanText(item.description, 120), color: cleanColor(item.color), icon: cleanText(item.icon || 'star', 32), logoUrl: item.logoUrl ? cleanUrl(item.logoUrl) : '', order: Number.isFinite(Number(item.order)) ? Number(item.order) : index }; }).sort((a, b) => a.order - b.order).map((item, index) => ({ ...item, order: index })); } async function getUserFavorites(claims) { const store = await readFavoritesStore(); const key = userKeyFromClaims(claims); return Array.isArray(store[key]?.favorites) ? store[key].favorites : []; } async function saveUserFavorites(claims, favorites) { const store = await readFavoritesStore(); const key = userKeyFromClaims(claims); store[key] = { user: publicUserFromClaims(claims), updatedAt: new Date().toISOString(), favorites: sanitizeFavorites(favorites) }; await writeFavoritesStore(store); return store[key].favorites; } 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(); }); } function execContainerCommand(containerName, cmd) { return new Promise(async (resolve, reject) => { try { const bodyStr = JSON.stringify({ AttachStdout: true, AttachStderr: true, Cmd: cmd }); const createResult = await dockerRequest(`/containers/${encodeURIComponent(containerName)}/exec`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }, body: bodyStr }); const execId = createResult?.Id; if (!execId) { reject(new Error(`Failed to create exec instance for container ${containerName}`)); return; } const startBody = JSON.stringify({ Detach: false, Tty: false }); const req = http.request({ socketPath: DOCKER_SOCKET, path: `/exec/${execId}/start`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(startBody) } }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(`Docker exec start returned status ${res.statusCode}`)); return; } const raw = Buffer.concat(chunks); let cleanText = ''; 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; cleanText += raw.slice(start, end).toString('utf8'); offset = end; } if (!cleanText && raw.length > 0) { cleanText = raw.toString('utf8'); } resolve(cleanText.trim()); }); }); req.on('error', reject); req.write(startBody); req.end(); } catch (error) { reject(error); } }); } 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/auth/config')) { if (req.method !== 'GET') { json(res, 405, { error: 'Method not allowed' }); return; } json(res, 200, { authority: AUTHORITY, clientId: DASHBOARD_CLIENT_ID, scopes: 'openid profile email' }); return; } if (req.url?.startsWith('/api/favorites')) { try { const claims = await verifyUserRequest(req); if (req.method === 'GET') { json(res, 200, { user: publicUserFromClaims(claims), favorites: await getUserFavorites(claims) }); return; } if (req.method === 'PUT') { const body = await readRequestBody(req); const payload = JSON.parse(body || '{}'); const favorites = await saveUserFavorites(claims, payload.favorites); json(res, 200, { user: publicUserFromClaims(claims), favorites }); return; } json(res, 405, { error: 'Method not allowed' }); } catch (error) { const status = error.statusCode || (['missing_token', 'invalid_token', 'expired_token', 'not_allowed', 'invalid_audience'].includes(error.message) ? 401 : 500); json(res, status, { error: error.message }); } return; } 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); }); async function syncKeycloakSessionSettings(retries = 12, delayMs = 15000) { for (let attempt = 1; attempt <= retries; attempt++) { try { console.log(`[Keycloak Sync] SSO session duration automation check (Attempt ${attempt}/${retries})...`); const inspect = await dockerRequest('/containers/auth/json').catch(() => null); if (!inspect) { console.log('[Keycloak Sync] "auth" container not found or Docker socket inaccessible. Skipping settings automation.'); return; } const state = inspect.State?.Status || inspect.state; if (state !== 'running') { console.log(`[Keycloak Sync] "auth" container is in status "${state}". Waiting for it to run...`); if (attempt < retries) { await new Promise((r) => setTimeout(r, delayMs)); continue; } return; } const env = inspect.Config?.Env || []; const adminUser = env.find(e => e.startsWith('KC_BOOTSTRAP_ADMIN_USERNAME='))?.split('=')[1] || env.find(e => e.startsWith('KEYCLOAK_ADMIN='))?.split('=')[1] || 'admin'; const adminPass = env.find(e => e.startsWith('KC_BOOTSTRAP_ADMIN_PASSWORD='))?.split('=')[1] || env.find(e => e.startsWith('KEYCLOAK_ADMIN_PASSWORD='))?.split('=')[1]; if (!adminPass) { console.log('[Keycloak Sync] Could not extract admin password from container environment. Skipping settings automation.'); return; } console.log('[Keycloak Sync] Authenticating kcadm.sh inside Keycloak container...'); const loginCmd = [ '/opt/keycloak/bin/kcadm.sh', 'config', 'credentials', '--server', 'http://localhost:8080', '--realm', 'master', '--user', adminUser, '--password', adminPass ]; await execContainerCommand('auth', loginCmd); console.log('[Keycloak Sync] Updating session settings in the "mischlabs" realm...'); const updateCmd = [ '/opt/keycloak/bin/kcadm.sh', 'update', 'realms/mischlabs', '-s', 'ssoSessionIdleTimeout=2592000', // 30 days '-s', 'ssoSessionMaxLifespan=7776000', // 90 days '-s', 'ssoSessionIdleTimeoutRememberMe=2592000', // 30 days '-s', 'ssoSessionMaxLifespanRememberMe=7776000', // 90 days '-s', 'offlineSessionIdleTimeout=7776000', // 90 days '-s', 'offlineSessionMaxLifespan=15552000', // 180 days '-s', 'offlineSessionMaxLifespanEnabled=true' ]; const updateOutput = await execContainerCommand('auth', updateCmd); console.log('[Keycloak Sync] Realm settings updated successfully:', updateOutput); return; } catch (error) { console.warn(`[Keycloak Sync] Attempt ${attempt} failed: ${error.message}`); if (attempt < retries) { console.log(`[Keycloak Sync] Retrying in ${delayMs / 1000} seconds...`); await new Promise((r) => setTimeout(r, delayMs)); } else { console.error('[Keycloak Sync] All attempts to automate Keycloak session settings failed.'); } } } } server.listen(PORT, () => { console.log(`MischLabs dashboard listening on ${PORT}`); cleanupManualWatchtowerContainers().catch((error) => { console.warn(`Manual Watchtower cleanup failed: ${error.message}`); }); syncKeycloakSessionSettings().catch((error) => { console.warn(`Keycloak session synchronization failed: ${error.message}`); }); });