diff --git a/admin.html b/admin.html index f80a817..78482c8 100644 --- a/admin.html +++ b/admin.html @@ -6,7 +6,7 @@ MischLabs Admin - +
@@ -80,6 +80,6 @@ - + diff --git a/admin.js b/admin.js index cb5e627..25c4c31 100644 --- a/admin.js +++ b/admin.js @@ -25,6 +25,7 @@ const containerStatus = document.querySelector('#containerStatus'); let config = null; let tokenSet = null; +let opsRefreshTimer = null; function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) @@ -353,11 +354,54 @@ function renderContainers(containers) { .sort((a, b) => important.indexOf(a.name) - important.indexOf(b.name)); containerStatus.innerHTML = rows.map((container) => ` -
+
${container.name} ${container.status} +
`).join(''); + + containerStatus.querySelectorAll('.restart-container').forEach((button) => { + button.addEventListener('click', () => restartContainer(button.closest('.container-line'))); + }); +} + +async function restartContainer(row) { + const name = row?.dataset.container; + if (!name) return; + + const confirmed = window.confirm(`${name} jetzt neu starten? Der Dienst ist kurz nicht erreichbar.`); + if (!confirmed) return; + + const button = row.querySelector('.restart-container'); + const status = row.querySelector('span'); + const previousText = status.textContent; + + button.disabled = true; + status.textContent = 'Neustart laeuft...'; + + try { + const response = await fetch(`/api/containers/${encodeURIComponent(name)}/restart`, { + method: 'POST', + headers: { + Authorization: `Bearer ${tokenSet.id_token}` + } + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); + + status.textContent = `Neugestartet um ${new Date(payload.restartedAt).toLocaleTimeString('de-DE')}`; + window.clearTimeout(opsRefreshTimer); + opsRefreshTimer = window.setTimeout(refreshOps, 3500); + } catch (error) { + status.textContent = `Neustart fehlgeschlagen: ${error.message}`; + button.disabled = false; + window.setTimeout(() => { + status.textContent = previousText; + }, 6000); + } } async function refreshOps() { diff --git a/server.js b/server.js index 0281f9d..dfa5a1f 100644 --- a/server.js +++ b/server.js @@ -4,12 +4,30 @@ 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 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' }, @@ -40,6 +58,9 @@ const serviceUrls = [ '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', @@ -48,12 +69,12 @@ function json(res, status, payload) { res.end(JSON.stringify(payload)); } -function dockerRequest(endpoint) { +function dockerRequest(endpoint, options = {}) { return new Promise((resolve, reject) => { const req = http.request({ socketPath: DOCKER_SOCKET, path: endpoint, - method: 'GET' + method: options.method || 'GET' }, (res) => { let body = ''; res.setEncoding('utf8'); @@ -73,10 +94,113 @@ function dockerRequest(endpoint) { }); 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}`; @@ -123,6 +247,7 @@ async function getContainers() { image: container.Image, status: container.Status, state: container.State, + restartable: canRestartContainer(container), ports: (container.Ports || []).map((port) => ({ privatePort: port.PrivatePort, publicPort: port.PublicPort, @@ -132,6 +257,33 @@ async function getContainers() { })).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 getDisks() { const disks = []; @@ -326,6 +478,23 @@ const server = http.createServer(async (req, res) => { 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; + } + await serveStatic(req, res); }); diff --git a/style.css b/style.css index 155445d..5e33454 100644 --- a/style.css +++ b/style.css @@ -446,6 +446,12 @@ main { outline: none; } +.ghost-button:disabled { + color: var(--dim); + cursor: not-allowed; + opacity: 0.48; +} + .admin-shell { display: grid; gap: 14px; @@ -555,6 +561,14 @@ main { text-align: right; } +.container-line span { + flex: 1; +} + +.container-line .ghost-button { + flex: 0 0 auto; +} + .ops-line.is-warning span { color: #fde68a; }