diff --git a/admin.html b/admin.html index bfa89a5..7282d9e 100644 --- a/admin.html +++ b/admin.html @@ -6,7 +6,7 @@ MischLabs Admin - +
@@ -73,7 +73,9 @@
Container +
+
Noch nicht geladen.
@@ -81,6 +83,6 @@ - + diff --git a/admin.js b/admin.js index f69808f..4030059 100644 --- a/admin.js +++ b/admin.js @@ -20,6 +20,8 @@ const resetLocalButton = document.querySelector('#resetLocalButton'); const downloadConfigButton = document.querySelector('#downloadConfigButton'); const refreshOpsButton = document.querySelector('#refreshOpsButton'); const runWatchtowerButton = document.querySelector('#runWatchtowerButton'); +const dangerModeButton = document.querySelector('#dangerModeButton'); +const dangerState = document.querySelector('#dangerState'); const diskStatus = document.querySelector('#diskStatus'); const watchtowerStatus = document.querySelector('#watchtowerStatus'); const containerStatus = document.querySelector('#containerStatus'); @@ -29,6 +31,10 @@ let tokenSet = null; let opsRefreshTimer = null; let latestWatchtower = null; let lastManualWatchtowerRun = null; +let latestContainers = []; +let dangerToken = null; +let dangerExpiresAt = 0; +let dangerTimer = null; function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) @@ -352,6 +358,9 @@ function renderContainers(containers) { return; } + latestContainers = containers; + const dangerActive = Date.now() < dangerExpiresAt && Boolean(dangerToken); + const important = [ 'mischlabs', 'watchtower', @@ -374,7 +383,7 @@ function renderContainers(containers) {
${container.name} ${container.status} -
@@ -388,8 +397,9 @@ function renderContainers(containers) { async function restartContainer(row) { const name = row?.dataset.container; if (!name) return; + const dangerActive = Date.now() < dangerExpiresAt && Boolean(dangerToken); - const confirmed = window.confirm(`${name} jetzt neu starten? Der Dienst ist kurz nicht erreichbar.`); + const confirmed = window.confirm(`${name} jetzt neu starten? Der Dienst ist kurz nicht erreichbar.${dangerActive ? '\n\nDanger-Modus ist aktiv.' : ''}`); if (!confirmed) return; const button = row.querySelector('.restart-container'); @@ -403,7 +413,8 @@ async function restartContainer(row) { const response = await fetch(`/api/containers/${encodeURIComponent(name)}/restart`, { method: 'POST', headers: { - Authorization: `Bearer ${tokenSet.id_token}` + Authorization: `Bearer ${tokenSet.id_token}`, + ...(dangerActive ? { 'X-MischLabs-Danger-Token': dangerToken } : {}) } }); const payload = await response.json().catch(() => ({})); @@ -421,6 +432,57 @@ async function restartContainer(row) { } } +function updateDangerState() { + const remaining = Math.max(0, dangerExpiresAt - Date.now()); + if (!dangerToken || remaining <= 0) { + dangerToken = null; + dangerExpiresAt = 0; + dangerState.hidden = true; + dangerState.textContent = ''; + dangerModeButton.textContent = 'Danger entsperren'; + dangerModeButton.classList.remove('is-active-danger'); + window.clearInterval(dangerTimer); + dangerTimer = null; + if (latestContainers.length) renderContainers(latestContainers); + return; + } + + const seconds = Math.ceil(remaining / 1000); + dangerState.hidden = false; + dangerState.textContent = `Danger-Modus aktiv: geschuetzte Neustarts noch ${seconds}s erlaubt.`; + dangerModeButton.textContent = `Danger aktiv ${seconds}s`; + dangerModeButton.classList.add('is-active-danger'); +} + +async function unlockDangerMode() { + const confirmed = window.confirm('Danger-Modus fuer 2 Minuten aktivieren?\n\nDamit koennen auch geschuetzte Dienste wie Watchtower, Gitea, Keycloak oder Datenbanken neu gestartet werden. Nutze das nur bewusst.'); + if (!confirmed) return; + + dangerModeButton.disabled = true; + try { + const response = await fetch('/api/danger/unlock', { + 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}`); + + dangerToken = payload.token; + dangerExpiresAt = new Date(payload.expiresAt).getTime(); + updateDangerState(); + if (latestContainers.length) renderContainers(latestContainers); + window.clearInterval(dangerTimer); + dangerTimer = window.setInterval(updateDangerState, 1000); + } catch (error) { + dangerState.hidden = false; + dangerState.textContent = `Danger-Modus konnte nicht aktiviert werden: ${error.message}`; + } finally { + dangerModeButton.disabled = false; + } +} + async function runWatchtowerOnce() { const confirmed = window.confirm('Watchtower jetzt manuell starten? Je nach Updates koennen Dienste kurz neu starten.'); if (!confirmed) return; @@ -562,6 +624,7 @@ resetLocalButton.addEventListener('click', () => { downloadConfigButton.addEventListener('click', downloadConfig); refreshOpsButton.addEventListener('click', refreshOps); runWatchtowerButton.addEventListener('click', runWatchtowerOnce); +dangerModeButton.addEventListener('click', unlockDangerMode); boot().catch((error) => { loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`; diff --git a/server.js b/server.js index 47f4172..6b8d8b6 100644 --- a/server.js +++ b/server.js @@ -63,6 +63,7 @@ const serviceUrls = [ let jwksCache = null; let jwksCacheUntil = 0; const manualWatchtowerRuns = new Map(); +const dangerTokens = new Map(); function json(res, status, payload) { res.writeHead(status, { @@ -198,9 +199,11 @@ function normalizeContainerName(name) { return String(name || '').replace(/^\//, '').trim(); } -function canRestartContainer(container) { +function canRestartContainer(container, danger = false) { const name = normalizeContainerName(container.name || container.Names?.[0]); - if (!name || RESTART_BLOCKLIST.has(name)) return false; + if (!name) return false; + if (danger) return container.state === 'running' || container.State === 'running'; + if (RESTART_BLOCKLIST.has(name)) return false; if (name.endsWith('_db') || name.includes('-db')) return false; return container.state === 'running' || container.State === 'running'; } @@ -214,6 +217,32 @@ function restartBlockReason(container) { return null; } +function pruneDangerTokens() { + const now = Date.now(); + for (const [token, expiresAt] of dangerTokens.entries()) { + if (expiresAt <= now) dangerTokens.delete(token); + } +} + +function createDangerToken() { + pruneDangerTokens(); + const token = crypto.randomBytes(32).toString('base64url'); + const expiresAt = Date.now() + 2 * 60 * 1000; + dangerTokens.set(token, expiresAt); + return { + token, + expiresAt: new Date(expiresAt).toISOString() + }; +} + +function verifyDangerToken(req) { + pruneDangerTokens(); + const token = req.headers['x-mischlabs-danger-token']; + if (!token || Array.isArray(token)) return false; + const expiresAt = dangerTokens.get(token); + return Boolean(expiresAt && expiresAt > Date.now()); +} + function dockerLogs(container, tail = 160) { return new Promise((resolve, reject) => { const endpoint = `/containers/${encodeURIComponent(container)}/logs?stdout=1&stderr=1&tail=${tail}`; @@ -271,7 +300,7 @@ async function getContainers() { })).sort((a, b) => a.name.localeCompare(b.name)); } -async function restartContainer(containerName) { +async function restartContainer(containerName, danger = false) { const name = normalizeContainerName(containerName); if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) { const error = new Error('Ungueltiger Containername.'); @@ -285,7 +314,7 @@ async function restartContainer(containerName) { state: inspect.State?.Status }; - if (!canRestartContainer(container)) { + if (!canRestartContainer(container, danger)) { const error = new Error(`Container ${name} ist fuer Neustarts ueber das Dashboard gesperrt.`); error.statusCode = 403; throw error; @@ -645,7 +674,7 @@ const server = http.createServer(async (req, res) => { try { await verifyAdminRequest(req); - json(res, 200, await restartContainer(decodeURIComponent(restartMatch[1]))); + json(res, 200, await restartContainer(decodeURIComponent(restartMatch[1]), verifyDangerToken(req))); } 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 }); @@ -653,6 +682,21 @@ const server = http.createServer(async (req, res) => { return; } + if (req.url?.match(/^\/api\/danger\/unlock(?:[?#].*)?$/)) { + if (req.method !== 'POST') { + json(res, 405, { error: 'Method not allowed' }); + return; + } + + try { + await verifyAdminRequest(req); + json(res, 200, createDangerToken()); + } catch (error) { + json(res, error.statusCode || 500, { error: error.message }); + } + return; + } + if (req.url?.match(/^\/api\/watchtower\/run-once(?:[?#].*)?$/)) { if (req.method !== 'POST') { json(res, 405, { error: 'Method not allowed' }); diff --git a/style.css b/style.css index 2a75c22..71e4365 100644 --- a/style.css +++ b/style.css @@ -452,6 +452,19 @@ main { opacity: 0.48; } +.ghost-button.danger-button { + color: #fecdd3; + border-color: rgba(251, 113, 133, 0.28); +} + +.ghost-button.danger-button:hover, +.ghost-button.danger-button:focus-visible, +.ghost-button.danger-button.is-active-danger { + color: #fff; + background: rgba(251, 113, 133, 0.18); + border-color: rgba(251, 113, 133, 0.58); +} + .admin-shell { display: grid; gap: 14px; @@ -569,6 +582,17 @@ main { flex: 0 0 auto; } +.container-line .ghost-button.is-danger-enabled { + color: #fecdd3; + border-color: rgba(251, 113, 133, 0.48); +} + +.danger-state { + margin: -2px 0 10px; + color: #fecdd3; + font-size: 0.82rem; +} + .ops-line.is-warning span { color: #fde68a; }