Add admin container restart controls
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>
|
<title>MischLabs Admin</title>
|
||||||
<meta name="description" content="MischLabs dashboard administration.">
|
<meta name="description" content="MischLabs dashboard administration.">
|
||||||
<meta name="theme-color" content="#0f172a">
|
<meta name="theme-color" content="#0f172a">
|
||||||
<link rel="stylesheet" href="/style.css?v=13">
|
<link rel="stylesheet" href="/style.css?v=14">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="shell hero admin-hero">
|
<header class="shell hero admin-hero">
|
||||||
@@ -80,6 +80,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/admin.js?v=4" defer></script>
|
<script src="/admin.js?v=5" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
46
admin.js
46
admin.js
@@ -25,6 +25,7 @@ const containerStatus = document.querySelector('#containerStatus');
|
|||||||
|
|
||||||
let config = null;
|
let config = null;
|
||||||
let tokenSet = null;
|
let tokenSet = null;
|
||||||
|
let opsRefreshTimer = null;
|
||||||
|
|
||||||
function base64UrlEncode(buffer) {
|
function base64UrlEncode(buffer) {
|
||||||
return btoa(String.fromCharCode(...new Uint8Array(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));
|
.sort((a, b) => important.indexOf(a.name) - important.indexOf(b.name));
|
||||||
|
|
||||||
containerStatus.innerHTML = rows.map((container) => `
|
containerStatus.innerHTML = rows.map((container) => `
|
||||||
<div class="ops-line">
|
<div class="ops-line container-line" data-container="${container.name}">
|
||||||
<strong>${container.name}</strong>
|
<strong>${container.name}</strong>
|
||||||
<span>${container.status}</span>
|
<span>${container.status}</span>
|
||||||
|
<button class="ghost-button mini restart-container" type="button" ${container.restartable ? '' : 'disabled'}>
|
||||||
|
Neustarten
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).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() {
|
async function refreshOps() {
|
||||||
|
|||||||
173
server.js
173
server.js
@@ -4,12 +4,30 @@ const fs = require('node:fs');
|
|||||||
const fsp = require('node:fs/promises');
|
const fsp = require('node:fs/promises');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { URL } = require('node:url');
|
const { URL } = require('node:url');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 80);
|
const PORT = Number(process.env.PORT || 80);
|
||||||
const dockerPublicDir = path.join(__dirname, 'public');
|
const dockerPublicDir = path.join(__dirname, 'public');
|
||||||
const PUBLIC_DIR = fs.existsSync(dockerPublicDir) ? dockerPublicDir : __dirname;
|
const PUBLIC_DIR = fs.existsSync(dockerPublicDir) ? dockerPublicDir : __dirname;
|
||||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||||
const WATCHTOWER_CONTAINER = process.env.WATCHTOWER_CONTAINER || 'watchtower';
|
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 = [
|
const DISK_TARGETS = [
|
||||||
{ id: 'volume1', label: 'Volume 1', path: process.env.STATUS_VOLUME1_PATH || '/host/volume1', mount: '/volume1:/host/volume1:ro' },
|
{ 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' },
|
{ 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'
|
'https://tom.mischlabs.de'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let jwksCache = null;
|
||||||
|
let jwksCacheUntil = 0;
|
||||||
|
|
||||||
function json(res, status, payload) {
|
function json(res, status, payload) {
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
@@ -48,12 +69,12 @@ function json(res, status, payload) {
|
|||||||
res.end(JSON.stringify(payload));
|
res.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dockerRequest(endpoint) {
|
function dockerRequest(endpoint, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = http.request({
|
const req = http.request({
|
||||||
socketPath: DOCKER_SOCKET,
|
socketPath: DOCKER_SOCKET,
|
||||||
path: endpoint,
|
path: endpoint,
|
||||||
method: 'GET'
|
method: options.method || 'GET'
|
||||||
}, (res) => {
|
}, (res) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
res.setEncoding('utf8');
|
res.setEncoding('utf8');
|
||||||
@@ -73,10 +94,113 @@ function dockerRequest(endpoint) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
|
if (options.body) req.write(options.body);
|
||||||
req.end();
|
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) {
|
function dockerLogs(container, tail = 160) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const endpoint = `/containers/${encodeURIComponent(container)}/logs?stdout=1&stderr=1&tail=${tail}`;
|
const endpoint = `/containers/${encodeURIComponent(container)}/logs?stdout=1&stderr=1&tail=${tail}`;
|
||||||
@@ -123,6 +247,7 @@ async function getContainers() {
|
|||||||
image: container.Image,
|
image: container.Image,
|
||||||
status: container.Status,
|
status: container.Status,
|
||||||
state: container.State,
|
state: container.State,
|
||||||
|
restartable: canRestartContainer(container),
|
||||||
ports: (container.Ports || []).map((port) => ({
|
ports: (container.Ports || []).map((port) => ({
|
||||||
privatePort: port.PrivatePort,
|
privatePort: port.PrivatePort,
|
||||||
publicPort: port.PublicPort,
|
publicPort: port.PublicPort,
|
||||||
@@ -132,6 +257,33 @@ async function getContainers() {
|
|||||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
})).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() {
|
async function getDisks() {
|
||||||
const disks = [];
|
const disks = [];
|
||||||
|
|
||||||
@@ -326,6 +478,23 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
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);
|
await serveStatic(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
14
style.css
14
style.css
@@ -446,6 +446,12 @@ main {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghost-button:disabled {
|
||||||
|
color: var(--dim);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -555,6 +561,14 @@ main {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container-line span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-line .ghost-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ops-line.is-warning span {
|
.ops-line.is-warning span {
|
||||||
color: #fde68a;
|
color: #fde68a;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user