Files
mischlabs/server.js
Kroonk 29b3b623eb
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
Add NAS status proxy
2026-05-21 16:22:34 +02:00

312 lines
9.1 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 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}`);
});