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 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; 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) { const name = normalizeContainerName(container.name || container.Names?.[0]); if (!name || RESTART_BLOCKLIST.has(name)) return false; if (name.endsWith('_db') || name.includes('-db')) return false; return container.state === 'running' || container.State === 'running'; } 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), 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) { 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)) { 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() { 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' }); return { id: created.Id.slice(0, 12), fullId: created.Id, name, image: WATCHTOWER_RUN_IMAGE, startedAt: new Date().toISOString() }; } async function removeContainer(id) { await dockerRequest(`/containers/${encodeURIComponent(id)}?force=1`, { method: 'DELETE' }); } async function getWatchtowerRun(runId, cleanup = false) { 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 }; if (cleanup && !payload.running) { removeContainer(inspect.Id).catch(() => {}); payload.cleanedUp = true; } return payload; } 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 parseWatchtower(lines) { const sessions = []; let current = null; for (const line of lines) { const timeMatch = line.match(/time="([^"]+)"/); const 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 /') ) { current = current || { startedAt: time, found: [], warnings: [] }; } if (line.includes('Found new')) { current = current || { startedAt: time, found: [], warnings: [] }; current.found.push(line.replace(/^.*msg="/, '').replace(/"$/, '')); } if (line.includes('level=warning')) { current = current || { startedAt: time, found: [], warnings: [] }; current.warnings.push(line.replace(/^.*msg="/, '').replace(/"$/, '')); } const done = line.match(/Session done(?:\\"|") Failed=(\d+) Scanned=(\d+) Updated=(\d+)/); if (done) { current = current || { startedAt: time, found: [], warnings: [] }; current.finishedAt = time; current.failed = Number(done[1]); current.scanned = Number(done[2]); current.updated = Number(done[3]); sessions.push(current); current = null; } } if (sessions.length) return sessions.at(-1); const lastDoneLine = [...lines].reverse().find((line) => line.includes('Session done')); const done = lastDoneLine?.match(/Failed=(\d+) Scanned=(\d+) Updated=(\d+)/); if (done) { const time = lastDoneLine.match(/time="([^"]+)"/)?.[1] || null; return { startedAt: null, finishedAt: time, failed: Number(done[1]), scanned: Number(done[2]), updated: Number(done[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).catch((error) => [`watchtower logs unavailable: ${error.message}`]), getServices() ]); return { generatedAt: new Date().toISOString(), containers, disks, watchtower: { container: WATCHTOWER_CONTAINER, 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]))); } 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\/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); const requestUrl = new URL(req.url, `http://${req.headers.host}`); const cleanup = requestUrl.searchParams.get('cleanup') === '1'; json(res, 200, await getWatchtowerRun(decodeURIComponent(watchtowerRunMatch[1]), cleanup)); } 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}`); });