Files
mischlabs/server.js
Kroonk e98130d274
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
Add manual Watchtower trigger
2026-05-21 16:43:59 +02:00

558 lines
17 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 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: true,
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),
name,
image: WATCHTOWER_RUN_IMAGE,
startedAt: new Date().toISOString()
};
}
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;
}
await serveStatic(req, res);
});
server.listen(PORT, () => {
console.log(`MischLabs dashboard listening on ${PORT}`);
});