Add NAS status proxy
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
This commit is contained in:
311
server.js
Normal file
311
server.js
Normal file
@@ -0,0 +1,311 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user