Add temporary danger restart mode
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
<title>MischLabs Admin</title>
|
||||
<meta name="description" content="MischLabs dashboard administration.">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<link rel="stylesheet" href="/style.css?v=16">
|
||||
<link rel="stylesheet" href="/style.css?v=17">
|
||||
</head>
|
||||
<body>
|
||||
<header class="shell hero admin-hero">
|
||||
@@ -73,7 +73,9 @@
|
||||
<section class="ops-card ops-card-wide">
|
||||
<div class="ops-card-head">
|
||||
<span>Container</span>
|
||||
<button class="ghost-button mini danger-button" id="dangerModeButton" type="button">Danger entsperren</button>
|
||||
</div>
|
||||
<div class="danger-state" id="dangerState" hidden></div>
|
||||
<div id="containerStatus" class="ops-list">Noch nicht geladen.</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -81,6 +83,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js?v=9" defer></script>
|
||||
<script src="/admin.js?v=10" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
69
admin.js
69
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) {
|
||||
<div class="ops-line container-line" data-container="${container.name}">
|
||||
<strong>${container.name}</strong>
|
||||
<span>${container.status}</span>
|
||||
<button class="ghost-button mini restart-container" type="button" title="${container.restartable ? 'Container neu starten' : (container.restartBlockReason || 'Neustart gesperrt')}" ${container.restartable ? '' : 'disabled'}>
|
||||
<button class="ghost-button mini restart-container ${dangerActive && !container.restartable ? 'is-danger-enabled' : ''}" type="button" title="${container.restartable ? 'Container neu starten' : (dangerActive ? 'Danger-Modus aktiv: Neustart erlaubt' : (container.restartBlockReason || 'Neustart gesperrt'))}" ${(container.restartable || dangerActive) ? '' : 'disabled'}>
|
||||
Neustarten
|
||||
</button>
|
||||
</div>
|
||||
@@ -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}`;
|
||||
|
||||
54
server.js
54
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' });
|
||||
|
||||
24
style.css
24
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user