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 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 DISK_TARGETS = [ { id: 'volume1', label: 'Volume 1', path: process.env.STATUS_VOLUME1_PATH || '/host/volume1' }, { id: 'volume2', label: 'Volume 2', path: process.env.STATUS_VOLUME2_PATH || '/host/volume2' }, { 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' ]; 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) { return new Promise((resolve, reject) => { const req = http.request({ socketPath: DOCKER_SOCKET, path: endpoint, method: 'GET' }, (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); req.end(); }); } 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, 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 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')) { 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; } } return sessions.at(-1) || 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; } await serveStatic(req, res); }); server.listen(PORT, () => { console.log(`MischLabs dashboard listening on ${PORT}`); });