import express from 'express'; import dotenv from 'dotenv'; import https from 'https'; import axios from 'axios'; import http from 'http'; import { spawn } from 'child_process'; import { Server } from 'socket.io'; import path from 'path'; import fs from 'fs'; import os from 'os'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { db } from './db/index.js'; import { ensureDatabaseSchema } from './db/schemaEnsure.js'; import { ensureSuperuserFromEnv, getSuperuserSummary, hasSuperuser, registerSuperuser, removeSuperuser, findUserByIdentifier, markUserLogin, verifyPassword, SUPERUSER_GROUP_NAME } from './auth/superuser.js'; import { logEvent, buildEventLogFilter, deleteEventLogById, deleteEventLogsByFilters, exportEventLogsByFilters } from './logging/eventLogs.js'; import { getSetting, setSetting, deleteSetting } from './db/settings.js'; import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js'; import { ensureDefaultsFromEnv, getActiveApiKey, getActiveServerUrl, hasServer, hasApiKey, getSetupStatus, completeSetup, removeServer, setServerApiKey, getServerById, ensureServer, getServerConnection, updateServerDetails, getServerStatus, getAgentConfig, setAgentConfig, generateAgentToken } from './setup/index.js'; import { listUsers, getUserById, updateUserGroups, createUser, updateUserDetails, deleteUser, updateUserActiveStatus, getUserSecurityPhrase, renewUserSecurityPhrase, markSecurityPhraseDownloaded, ensureSecurityPhrasesForExistingUsers, verifySecurityPhraseForUsername, setUserPassword } from './users/index.js'; import { listGroups, createGroup, getGroupById, updateGroupDetails, deleteGroup } from './groups/index.js'; import { getPermissionStructure, getPermissionValuesByGroup, saveGroupPermissionValues, clearGroupPermissionValues, getSuperuserPermissionMap, getEffectivePermissionsForGroups, hasRequiredPermission } from './permissions/index.js'; dotenv.config(); ensureDatabaseSchema(); ensureSuperuserFromEnv(); ensureDefaultsFromEnv(); try { const initializedCount = ensureSecurityPhrasesForExistingUsers(); if (initializedCount > 0) { console.log(`ℹ️ Sicherheitsschlüssel für ${initializedCount} bestehende Benutzer initialisiert`); } } catch (error) { console.error('⚠️ Initialisierung der Sicherheitsschlüssel für bestehende Benutzer fehlgeschlagen:', error); } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); app.get('*', (req, res, next) => { if (req.path.startsWith('/api')) return next(); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); const PORT = 4001; const agent = new https.Agent({ rejectUnauthorized: false }); const resolvePortainerBaseUrl = () => { const envUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL.trim() : ''; if (envUrl) return envUrl; const activeUrl = getActiveServerUrl(); return activeUrl ? activeUrl.trim() : ''; }; const axiosInstance = axios.create({ httpsAgent: agent }); axiosInstance.interceptors.request.use((config) => { const currentBase = typeof config.baseURL === 'string' ? config.baseURL.trim() : ''; const effectiveBase = currentBase || resolvePortainerBaseUrl(); if (!effectiveBase) { throw new Error('PORTAINER_URL_NOT_CONFIGURED'); } config.baseURL = effectiveBase; const apiKey = getActiveApiKey(); if (apiKey) { config.headers = config.headers || {}; config.headers["X-API-Key"] = apiKey; } else if (config.headers?.["X-API-Key"]) { delete config.headers["X-API-Key"]; } return config; }); const PORTAINER_ENVIRONMENT_CACHE_TTL_MS = 5 * 60 * 1000; let cachedPortainerEnvironment = { id: null, fetchedAt: 0 }; let resolvingEnvironmentPromise = null; const resolvePortainerEnvironmentId = async () => { const now = Date.now(); if (cachedPortainerEnvironment.id && now - cachedPortainerEnvironment.fetchedAt < PORTAINER_ENVIRONMENT_CACHE_TTL_MS) { return cachedPortainerEnvironment.id; } if (resolvingEnvironmentPromise) { return resolvingEnvironmentPromise; } resolvingEnvironmentPromise = (async () => { try { const endpointsRes = await axiosInstance.get('/api/endpoints'); const endpoints = Array.isArray(endpointsRes.data) ? endpointsRes.data : []; if (!endpoints.length) { const error = new Error('PORTAINER_ENVIRONMENT_NOT_FOUND'); error.code = 'PORTAINER_ENVIRONMENT_NOT_FOUND'; throw error; } const preferred = endpoints.find((endpoint) => endpoint.Type === 1 && (!endpoint.URL || endpoint.URL === '')) || endpoints[0]; const resolvedId = preferred?.Id ?? preferred?.ID ?? null; if (!resolvedId) { const error = new Error('PORTAINER_ENVIRONMENT_NOT_FOUND'); error.code = 'PORTAINER_ENVIRONMENT_NOT_FOUND'; throw error; } cachedPortainerEnvironment = { id: String(resolvedId), fetchedAt: Date.now() }; return cachedPortainerEnvironment.id; } catch (error) { const normalized = error.response?.data?.message || error.message || 'PORTAINER_ENVIRONMENT_NOT_FOUND'; console.error('⚠️ [Portainer] Environment-Erkennung fehlgeschlagen:', normalized); const wrapped = new Error('PORTAINER_ENVIRONMENT_NOT_FOUND'); wrapped.code = 'PORTAINER_ENVIRONMENT_NOT_FOUND'; throw wrapped; } finally { resolvingEnvironmentPromise = null; } })(); return resolvingEnvironmentPromise; }; const normalizeExternalPortainerUrl = (value = '') => { if (typeof value !== 'string') return ''; const trimmed = value.trim(); if (!trimmed) return ''; try { const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed); const candidate = hasProtocol ? trimmed : `https://${trimmed}`; const normalized = new URL(candidate); normalized.hash = ''; return normalized.toString().replace(/\/$/, ''); } catch { return ''; } }; const createPortainerSetupClient = ({ baseURL, apiKey }) => { if (!baseURL) { const error = new Error('SERVER_URL_REQUIRED'); error.code = 'SERVER_URL_REQUIRED'; throw error; } if (!apiKey) { const error = new Error('API_KEY_REQUIRED'); error.code = 'API_KEY_REQUIRED'; throw error; } return axios.create({ baseURL, httpsAgent: agent, headers: { 'X-API-Key': apiKey } }); }; const extractPortainerCredentials = (payload = {}) => { const rawServer = payload.server ?? {}; const serverUrl = typeof rawServer.url === 'string' ? rawServer.url : typeof payload.serverUrl === 'string' ? payload.serverUrl : typeof payload.url === 'string' ? payload.url : ''; const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey : typeof payload.key === 'string' ? payload.key : typeof payload.api === 'string' ? payload.api : typeof payload?.api?.value === 'string' ? payload.api.value : ''; return { serverUrl: serverUrl ? serverUrl.trim() : '', apiKey: apiKey ? apiKey.trim() : '' }; }; const REDEPLOY_PHASES = { QUEUED: 'queued', STARTED: 'started', SUCCESS: 'success', ERROR: 'error', INFO: 'info' }; const redeployingStacks = new Map(); const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || 'sp_auth_token'; const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 12; const envSessionTtl = Number(process.env.AUTH_SESSION_TTL_MS); const AUTH_SESSION_TTL_MS = Number.isFinite(envSessionTtl) && envSessionTtl > 0 ? envSessionTtl : DEFAULT_SESSION_TTL_MS; const parseBooleanEnv = (value, fallback) => { if (value === undefined || value === null) return fallback; const normalized = String(value).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; if (['0', 'false', 'no', 'off'].includes(normalized)) return false; return fallback; }; const AUTH_COOKIE_SECURE = parseBooleanEnv(process.env.AUTH_COOKIE_SECURE, false); const AUTH_COOKIE_OPTIONS = { httpOnly: true, sameSite: 'lax', secure: AUTH_COOKIE_SECURE, path: '/' }; const activeSessions = new Map(); const passwordResetTickets = new Map(); const PASSWORD_RESET_TTL_MS = 1000 * 60 * 15; const PUBLIC_API_ROUTES = [ { method: 'GET', matcher: /^\/api\/auth\/superuser\/status$/ }, { method: 'POST', matcher: /^\/api\/auth\/superuser\/register$/ }, { method: 'POST', matcher: /^\/api\/auth\/login$/ }, { method: 'POST', matcher: /^\/api\/auth\/logout$/ }, { method: 'GET', matcher: /^\/api\/auth\/session$/ }, { method: 'POST', matcher: /^\/api\/auth\/recover\/verify$/ }, { method: 'POST', matcher: /^\/api\/auth\/recover\/reset$/ }, { method: 'GET', matcher: /^\/api\/setup\/status$/ }, { method: 'POST', matcher: /^\/api\/setup\/complete$/ }, { method: 'POST', matcher: /^\/api\/setup\/test-portainer$/ }, { method: 'POST', matcher: /^\/api\/setup\/portainer-stacks$/ } ]; const sanitizeUser = (user) => ({ id: user.id, username: user.username, email: user.email, avatarColor: user.avatar_color || null }); const buildUserSessionPayload = (userId) => { const record = getUserById(userId); if (!record || !record.isActive) { return null; } const groups = Array.isArray(record.groups) ? record.groups : []; const groupIds = groups .map((group) => Number(group.id)) .filter((groupId) => Number.isFinite(groupId) && groupId > 0); const isSuperuser = groups.some( (group) => typeof group?.name === 'string' && group.name.toLowerCase() === SUPERUSER_GROUP_NAME ); const permissions = isSuperuser ? getSuperuserPermissionMap() : getEffectivePermissionsForGroups(groupIds); return { user: { id: record.id, username: record.username, email: record.email || null, avatarColor: record.avatarColor || null, groups, isSuperuser, securityPhraseDownloadedAt: record.securityPhraseDownloadedAt || null, requiresSecurityPhraseDownload: !record.securityPhraseDownloadedAt }, permissions }; }; const cleanupExpiredSessions = () => { const now = Date.now(); for (const [token, session] of activeSessions.entries()) { if (!session || session.expiresAt <= now) { activeSessions.delete(token); } } }; const removeSessionsForUser = (userId) => { if (!userId) return; for (const [token, session] of activeSessions.entries()) { if (session?.userId === userId) { activeSessions.delete(token); } } }; const cleanupExpiredPasswordResetTickets = () => { const now = Date.now(); for (const [token, ticket] of passwordResetTickets.entries()) { if (!ticket || ticket.expiresAt <= now) { passwordResetTickets.delete(token); } } }; const removePasswordResetTicketsForUser = (userId) => { if (!userId) return; for (const [token, ticket] of passwordResetTickets.entries()) { if (ticket?.userId === userId) { passwordResetTickets.delete(token); } } }; const createPasswordResetTicketForUser = (userId) => { cleanupExpiredPasswordResetTickets(); removePasswordResetTicketsForUser(userId); const token = crypto.randomBytes(32).toString('hex'); const expiresAt = Date.now() + PASSWORD_RESET_TTL_MS; passwordResetTickets.set(token, { userId, expiresAt }); return { token, expiresAt }; }; const getPasswordResetTicket = (token) => { if (!token) { return null; } cleanupExpiredPasswordResetTickets(); const record = passwordResetTickets.get(token); if (!record) { return null; } if (record.expiresAt <= Date.now()) { passwordResetTickets.delete(token); return null; } return record; }; const createSessionForUser = (user) => { cleanupExpiredSessions(); removeSessionsForUser(user.id); const token = crypto.randomBytes(48).toString('hex'); const expiresAt = Date.now() + AUTH_SESSION_TTL_MS; activeSessions.set(token, { userId: user.id, expiresAt }); return { token, expiresAt }; }; const getSessionRecord = (token) => { if (!token) return null; cleanupExpiredSessions(); const record = activeSessions.get(token); if (!record) return null; if (record.expiresAt <= Date.now()) { activeSessions.delete(token); return null; } return record; }; const touchSession = (token) => { if (!token) return; const record = activeSessions.get(token); if (!record) return; record.expiresAt = Date.now() + AUTH_SESSION_TTL_MS; activeSessions.set(token, record); }; const extractAuthToken = (req) => { const rawCookie = req.headers?.cookie; if (rawCookie && typeof rawCookie === 'string') { const parts = rawCookie.split(';'); for (const part of parts) { const [name, ...rest] = part.trim().split('='); if (name === AUTH_COOKIE_NAME) { return rest.join('='); } } } const authHeader = req.headers?.authorization || req.headers?.Authorization; if (authHeader && typeof authHeader === 'string') { const lower = authHeader.toLowerCase(); if (lower.startsWith('bearer ')) { return authHeader.slice(7).trim(); } } return null; }; const setAuthCookie = (res, token) => { if (!token || !res) return; if (typeof res.cookie === 'function') { res.cookie(AUTH_COOKIE_NAME, token, { ...AUTH_COOKIE_OPTIONS, maxAge: AUTH_SESSION_TTL_MS }); } else { res.setHeader('Set-Cookie', `${AUTH_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax`); } }; const clearAuthCookie = (res) => { if (!res) return; if (typeof res.clearCookie === 'function') { res.clearCookie(AUTH_COOKIE_NAME, { ...AUTH_COOKIE_OPTIONS, maxAge: 0 }); } else { res.setHeader('Set-Cookie', `${AUTH_COOKIE_NAME}=; Path=/; Max-Age=0`); } }; const isPublicApiRoute = (req) => { return PUBLIC_API_ROUTES.some(({ method, matcher }) => req.method === method && matcher.test(req.path)); }; const isActiveRedeployPhase = (phase) => phase === REDEPLOY_PHASES.QUEUED || phase === REDEPLOY_PHASES.STARTED; const resolveRedeployPhase = (phase, message) => { if (phase) return phase; if (message) return REDEPLOY_PHASES.INFO; return REDEPLOY_PHASES.SUCCESS; }; const server = http.createServer(app); const io = new Server(server, { path: "/socket.io", cors: { origin: "*" } }); io.on("connection", (socket) => { console.log(`🔌 [Socket] Client verbunden: ${socket.id}`); }); const broadcastRedeployStatus = ({ stackId, stackName, phase, message }) => { if (!stackId) return; const resolvedPhase = resolveRedeployPhase(phase, message); const isRedeploying = isActiveRedeployPhase(resolvedPhase); if (isRedeploying) { redeployingStacks.set(String(stackId), { phase: resolvedPhase, stackName: stackName || null, message: message || null, updatedAt: Date.now() }); } else if (resolvedPhase === REDEPLOY_PHASES.SUCCESS || resolvedPhase === REDEPLOY_PHASES.ERROR || resolvedPhase === 'idle') { redeployingStacks.delete(String(stackId)); } const payload = { stackId, stackName, phase: resolvedPhase, message, isRedeploying, redeployPhase: resolvedPhase }; io.emit("redeployStatus", payload); const label = stackName ? `${stackName} (${stackId})` : `Stack ${stackId}`; console.log(`🔄 [RedeployStatus] ${label} -> ${resolvedPhase}${message ? `: ${message}` : ""}`); }; const REDEPLOY_TYPES = { SINGLE: 'Einzeln', ALL: 'Alle', SELECTION: 'Auswahl', MAINTENANCE: 'Wartung' }; const logStackEvent = ({ stackId, stackName, status, message, redeployType = null, metadata = {} }) => { const metadataPayload = { ...metadata }; if (redeployType) { metadataPayload.redeployType = redeployType; } const hasMetadata = Object.keys(metadataPayload).length > 0; const serverContextId = getActiveServerUrl() || null; logEvent({ category: 'stack', eventType: redeployType ?? null, action: 'redeploy', status: status ?? null, entityType: 'stack', entityId: stackId !== undefined && stackId !== null ? String(stackId) : null, entityName: stackName ?? null, contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: message ?? null, metadata: hasMetadata ? metadataPayload : null }); }; const parseJsonColumn = (value) => { if (value === undefined || value === null || value === '') return null; try { return JSON.parse(value); } catch { return null; } }; const SELF_STACK_SETTING_KEY = 'self_stack_id'; let cachedSelfStackId = null; let selfStackLoaded = false; const loadSelfStackId = () => { try { const stored = getSetting(SELF_STACK_SETTING_KEY); if (stored && stored.value) { cachedSelfStackId = String(stored.value); } else if (process.env.SELF_STACK_ID) { cachedSelfStackId = String(process.env.SELF_STACK_ID); } else { cachedSelfStackId = null; } } catch (error) { console.warn('⚠️ [Setup] Konnte Self-Stack-ID nicht laden:', error.message); cachedSelfStackId = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null; } finally { selfStackLoaded = true; } }; const getSelfStackId = () => { if (!selfStackLoaded) { loadSelfStackId(); } return cachedSelfStackId; }; const persistSelfStackId = (value) => { const normalized = typeof value === 'string' ? value.trim() : value !== undefined && value !== null ? String(value).trim() : ''; if (!normalized) { deleteSetting(SELF_STACK_SETTING_KEY); cachedSelfStackId = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null; } else { setSetting(SELF_STACK_SETTING_KEY, normalized); cachedSelfStackId = normalized; } selfStackLoaded = true; return cachedSelfStackId; }; const getEnvSelfStackId = () => { const raw = typeof process.env.SELF_STACK_ID === 'string' ? process.env.SELF_STACK_ID : ''; return raw.trim(); }; const applySelfStackStatus = (status) => { const envSelfStackId = getEnvSelfStackId(); const currentSelfStackId = getSelfStackId(); const envDefaults = { ...(status.envDefaults || {}) }; envDefaults.selfStackId = currentSelfStackId || envSelfStackId || envDefaults.selfStackId || ''; return { ...status, envDefaults, selfStack: { current: currentSelfStackId, envProvided: Boolean(envSelfStackId) } }; }; const buildSetupStatusResponse = () => applySelfStackStatus(getSetupStatus()); const isCommunityEdition = async () => { try { const summary = await fetchPortainerStatusSummary(); const edition = (summary?.edition || '').toLowerCase(); return edition.includes('community'); } catch { return false; } }; const pingAgent = async (agentUrl, agentToken) => { if (!agentUrl) return { online: false, error: 'agent_url_missing' }; const url = agentUrl.replace(/\/+$/, ''); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const resp = await fetch(`${url}/info`, { headers: { 'X-Agent-Token': agentToken || '' }, signal: controller.signal }); clearTimeout(timeout); if (!resp.ok) { return { online: false, error: `status_${resp.status}` }; } return { online: true, error: null }; } catch (err) { return { online: false, error: err?.message || 'agent_unreachable' }; } }; const LEGACY_PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script'; const LEGACY_PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config'; const selectPrimaryServerIdStmt = db.prepare('SELECT id FROM servers ORDER BY id ASC LIMIT 1'); const selectServerSshConfigByServerStmt = db.prepare('SELECT * FROM server_ssh_configs WHERE server_id = ?'); const selectAnyServerSshConfigStmt = db.prepare('SELECT * FROM server_ssh_configs ORDER BY server_id ASC LIMIT 1'); const upsertServerSshConfigStmt = db.prepare(` INSERT INTO server_ssh_configs (server_id, host, port, username, password_cipher, password_iv, password_tag, extra_args, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(server_id) DO UPDATE SET host = excluded.host, port = excluded.port, username = excluded.username, password_cipher = excluded.password_cipher, password_iv = excluded.password_iv, password_tag = excluded.password_tag, extra_args = excluded.extra_args, updated_at = CURRENT_TIMESTAMP `); const deleteServerSshConfigStmt = db.prepare('DELETE FROM server_ssh_configs WHERE server_id = ?'); const deleteAllServerSshConfigsStmt = db.prepare('DELETE FROM server_ssh_configs'); const selectServerUpdateScriptStmt = db.prepare('SELECT * FROM server_update_scripts WHERE server_id = ?'); const selectAnyServerUpdateScriptStmt = db.prepare('SELECT * FROM server_update_scripts ORDER BY server_id ASC LIMIT 1'); const upsertServerUpdateScriptStmt = db.prepare(` INSERT INTO server_update_scripts (server_id, custom_script, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(server_id) DO UPDATE SET custom_script = excluded.custom_script, updated_at = CURRENT_TIMESTAMP `); const deleteServerUpdateScriptStmt = db.prepare('DELETE FROM server_update_scripts WHERE server_id = ?'); const deleteAllServerUpdateScriptsStmt = db.prepare('DELETE FROM server_update_scripts'); const DEFAULT_PORTAINER_UPDATE_SCRIPT_BE = [ 'docker stop portainer', 'docker rm portainer', 'docker pull portainer/portainer-ee:lts', 'docker run -d -p 8000:8000 -p 9443:9443 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ee:lts' ].join('\n'); const DEFAULT_PORTAINER_UPDATE_SCRIPT_CE = [ 'docker stop portainer', 'docker rm portainer', 'docker pull portainer/portainer-ce:lts', 'docker run -d -p 8000:8000 -p 9443:9443 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:lts' ].join('\n'); const DEFAULT_PORTAINER_UPDATE_SCRIPT = DEFAULT_PORTAINER_UPDATE_SCRIPT_BE; let portainerUpdateState = { running: false, status: 'idle', stage: 'idle', startedAt: null, finishedAt: null, targetVersion: null, resultVersion: null, scriptSource: null, message: null, error: null, logs: [] }; const addUpdateLog = (message, level = 'info') => { const entry = { timestamp: new Date().toISOString(), level, message }; portainerUpdateState = { ...portainerUpdateState, logs: [...(portainerUpdateState.logs || []).slice(-50), entry] }; console.log(`🛠️ [PortainerUpdate:${level}] ${message}`); }; const updatePortainerState = (partial = {}) => { portainerUpdateState = { ...portainerUpdateState, ...partial }; return portainerUpdateState; }; const getPortainerUpdateStatus = () => ({ ...portainerUpdateState }); const normalizeScriptText = (script) => String(script ?? '').replace(/\r\n/g, '\n'); let legacyUpdateScriptMigrated = false; const migrateLegacyUpdateScript = (serverIdHint = null) => { if (legacyUpdateScriptMigrated) return; const stored = getSetting(LEGACY_PORTAINER_SCRIPT_SETTING_KEY); if (!stored) { legacyUpdateScriptMigrated = true; return; } const targetServerId = serverIdHint ?? resolvePrimaryServerId(); if (!targetServerId) { return; } const raw = typeof stored.value === 'string' ? stored.value : ''; const normalized = normalizeScriptText(raw); if (!normalized.trim()) { deleteSetting(LEGACY_PORTAINER_SCRIPT_SETTING_KEY); legacyUpdateScriptMigrated = true; return; } upsertServerUpdateScriptStmt.run(targetServerId, normalized); deleteSetting(LEGACY_PORTAINER_SCRIPT_SETTING_KEY); legacyUpdateScriptMigrated = true; }; const resolveStoredUpdateScriptRow = (preferredServerId = null, fallbackToAny = true) => { const primaryServerId = preferredServerId ?? resolvePrimaryServerId(); migrateLegacyUpdateScript(primaryServerId); if (primaryServerId) { const row = selectServerUpdateScriptStmt.get(primaryServerId); if (row) return { row, serverId: primaryServerId }; if (!fallbackToAny) { return { row: null, serverId: primaryServerId }; } } if (!fallbackToAny) { return { row: null, serverId: primaryServerId ?? null }; } const fallback = selectAnyServerUpdateScriptStmt.get(); if (fallback) { return { row: fallback, serverId: fallback.server_id }; } return { row: null, serverId: primaryServerId ?? null }; }; const resolveTargetServerIdForUpdateScript = (preferredServerId = null, fallbackToAny = true) => { if (preferredServerId) { return preferredServerId; } if (!fallbackToAny) { return null; } const primaryId = resolvePrimaryServerId(); if (primaryId) return primaryId; const fallback = selectAnyServerUpdateScriptStmt.get(); return fallback?.server_id ?? null; }; const getCustomPortainerScript = (serverId = null) => { const { row } = resolveStoredUpdateScriptRow(serverId, !serverId); if (!row) return null; const normalized = normalizeScriptText(row.custom_script ?? ''); if (!normalized.trim()) return null; return { script: normalized, updatedAt: row.updated_at || null }; }; const saveCustomPortainerScript = (script, serverId = null) => { const normalized = normalizeScriptText(script); const trimmed = normalized.trim(); const targetServerId = resolveTargetServerIdForUpdateScript(serverId, !serverId); if (!trimmed) { if (targetServerId) { deleteServerUpdateScriptStmt.run(targetServerId); } else { deleteAllServerUpdateScriptsStmt.run(); } deleteSetting(LEGACY_PORTAINER_SCRIPT_SETTING_KEY); return null; } if (!targetServerId) { throw new Error('SERVER_NOT_CONFIGURED'); } upsertServerUpdateScriptStmt.run(targetServerId, normalized); deleteSetting(LEGACY_PORTAINER_SCRIPT_SETTING_KEY); return normalized; }; const getEffectivePortainerScript = (serverId = null, defaultScript = DEFAULT_PORTAINER_UPDATE_SCRIPT) => { const custom = getCustomPortainerScript(serverId); if (custom) { return { script: custom.script, source: 'custom', updatedAt: custom.updatedAt }; } return { script: defaultScript, source: 'default', updatedAt: null }; }; const resolveServerEdition = async (serverId) => { try { const { server, apiKey } = getServerConnection(serverId); if (!server?.url || !apiKey) return 'Business Edition'; const client = createPortainerSetupClient({ baseURL: server.url, apiKey }); try { const licenseInfoRes = await client.get('/api/licenses/info'); const licenseInfo = licenseInfoRes.data ?? {}; const edition = extractPortainerEdition(licenseInfo) ?? 'Business Edition'; return edition; } catch { return 'Community Edition'; } } catch { return 'Business Edition'; } }; const SSH_ENCRYPTION_KEY = crypto.createHash('sha256') .update(process.env.PORTAINER_SSH_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-ssh-secret') .digest(); const encryptSensitive = (value) => { if (!value) return null; const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', SSH_ENCRYPTION_KEY, iv); const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); return { iv: iv.toString('base64'), content: encrypted.toString('base64'), tag: authTag.toString('base64') }; }; const decryptSensitive = (payload) => { if (!payload || !payload.content) return ''; try { const iv = Buffer.from(payload.iv, 'base64'); const content = Buffer.from(payload.content, 'base64'); const tag = Buffer.from(payload.tag, 'base64'); const decipher = crypto.createDecipheriv('aes-256-gcm', SSH_ENCRYPTION_KEY, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(content), decipher.final()]); return decrypted.toString('utf8'); } catch (err) { console.warn('⚠️ [Maintenance] Konnte privaten SSH Schlüssel nicht entschlüsseln:', err.message); return ''; } }; const DEFAULT_PORTAINER_SSH_CONFIG = { host: '', port: 22, username: '', password: '', extraSshArgs: [] }; const deserializeSshExtraArgs = (value) => { if (!value) return []; try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed; } if (typeof parsed === 'string') { return parsed.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean); } return []; } catch { return []; } }; const serializeSshExtraArgs = (value) => JSON.stringify(normalizeExtraArgs(value)); const normalizeString = (value, fallback = '') => { if (value === undefined || value === null) return fallback; return String(value).trim(); }; const normalizePort = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.port) => { const parsed = Number.parseInt(value, 10); return Number.isNaN(parsed) ? fallback : parsed; }; const tokenizeSshArgLine = (line) => { if (!line) return []; const tokens = []; const regex = /"([^"]*)"|'([^']*)'|(\S+)/g; let match; while ((match = regex.exec(line)) !== null) { if (match[1] !== undefined) { tokens.push(match[1]); } else if (match[2] !== undefined) { tokens.push(match[2]); } else if (match[3] !== undefined) { tokens.push(match[3]); } } return tokens; }; const normalizeExtraArgs = (value, fallback = []) => { if (Array.isArray(value)) { return value.map((entry) => String(entry).trim()).filter(Boolean); } if (typeof value === 'string') { return value .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean); } return Array.isArray(fallback) ? [...fallback] : []; }; const normalizePassword = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.password) => { if (value === undefined) return fallback; if (value === null) return ''; return String(value); }; const resolvePrimaryServerId = () => { const row = selectPrimaryServerIdStmt.get(); return row?.id ?? null; }; let legacySshConfigMigrated = false; const migrateLegacySshConfig = (serverIdHint = null) => { if (legacySshConfigMigrated) return; const stored = getSetting(LEGACY_PORTAINER_SSH_CONFIG_KEY); if (!stored) { legacySshConfigMigrated = true; return; } const targetServerId = serverIdHint ?? resolvePrimaryServerId(); if (!targetServerId) { return; } let parsed = {}; try { parsed = stored.value ? JSON.parse(stored.value) : {}; } catch (err) { console.warn('⚠️ [Maintenance] Konnte Legacy SSH Konfiguration nicht parsen:', err.message); legacySshConfigMigrated = true; deleteSetting(LEGACY_PORTAINER_SSH_CONFIG_KEY); return; } const decryptedPassword = parsed?.passwordEncrypted ? decryptSensitive(parsed.passwordEncrypted) : parsed?.password ?? null; const normalizedPayload = { host: normalizeString(parsed?.host), port: normalizePort(parsed?.port), username: normalizeString(parsed?.username), password: normalizePassword(decryptedPassword), extraSshArgs: normalizeExtraArgs(parsed?.extraSshArgs) }; const encryptedPassword = normalizedPayload.password ? encryptSensitive(normalizedPayload.password) : null; upsertServerSshConfigStmt.run( targetServerId, normalizedPayload.host, normalizedPayload.port, normalizedPayload.username, encryptedPassword?.content ?? null, encryptedPassword?.iv ?? null, encryptedPassword?.tag ?? null, serializeSshExtraArgs(normalizedPayload.extraSshArgs) ); deleteSetting(LEGACY_PORTAINER_SSH_CONFIG_KEY); legacySshConfigMigrated = true; }; const resolveStoredSshConfigRow = (preferredServerId = null, fallbackToAny = true) => { const primaryServerId = preferredServerId ?? resolvePrimaryServerId(); migrateLegacySshConfig(primaryServerId); if (primaryServerId) { const row = selectServerSshConfigByServerStmt.get(primaryServerId); if (row) { return { row, serverId: primaryServerId }; } if (!fallbackToAny) { return { row: null, serverId: primaryServerId }; } } if (!fallbackToAny) { return { row: null, serverId: primaryServerId ?? null }; } const fallback = selectAnyServerSshConfigStmt.get(); if (fallback) { return { row: fallback, serverId: fallback.server_id }; } return { row: null, serverId: primaryServerId ?? null }; }; const persistPortainerSshConfig = (serverId, config) => { if (!serverId) { throw new Error('SSH_SERVER_NOT_AVAILABLE'); } const encryptedPassword = config.password ? encryptSensitive(config.password) : null; upsertServerSshConfigStmt.run( serverId, normalizeString(config.host), normalizePort(config.port), normalizeString(config.username), encryptedPassword?.content ?? null, encryptedPassword?.iv ?? null, encryptedPassword?.tag ?? null, serializeSshExtraArgs(config.extraSshArgs) ); }; const getPortainerSshConfig = (serverId = null) => { const { row } = resolveStoredSshConfigRow(serverId, !serverId); if (!row) { return { ...DEFAULT_PORTAINER_SSH_CONFIG }; } const passwordPayload = row.password_cipher ? { content: row.password_cipher, iv: row.password_iv, tag: row.password_tag } : null; const decryptedPassword = passwordPayload ? decryptSensitive(passwordPayload) : ''; return { host: normalizeString(row.host), port: normalizePort(row.port), username: normalizeString(row.username), password: normalizePassword(decryptedPassword), extraSshArgs: normalizeExtraArgs(deserializeSshExtraArgs(row.extra_args)) }; }; const mergeSshConfig = (base, overrides = {}) => ({ host: normalizeString(overrides.host, base.host), port: normalizePort(overrides.port, base.port), username: normalizeString(overrides.username, base.username), password: normalizePassword(overrides.password, base.password), extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs) }); const resolveTargetServerIdForSshConfig = (preferredServerId = null, fallbackToAny = true) => { if (preferredServerId) { return preferredServerId; } if (!fallbackToAny) { return null; } const primaryId = resolvePrimaryServerId(); if (primaryId) return primaryId; const fallback = selectAnyServerSshConfigStmt.get(); return fallback?.server_id ?? null; }; const savePortainerSshConfig = (payload = {}, serverId = null) => { const current = getPortainerSshConfig(serverId); const next = mergeSshConfig(current, payload); const targetServerId = resolveTargetServerIdForSshConfig(serverId, !serverId); if (!targetServerId) { throw new Error('SERVER_NOT_CONFIGURED'); } persistPortainerSshConfig(targetServerId, next); return next; }; const deletePortainerSshConfig = (serverId = null) => { const targetServerId = resolveTargetServerIdForSshConfig(serverId, !serverId); if (targetServerId) { deleteServerSshConfigStmt.run(targetServerId); } else { deleteAllServerSshConfigsStmt.run(); } deleteSetting(LEGACY_PORTAINER_SSH_CONFIG_KEY); return { ...DEFAULT_PORTAINER_SSH_CONFIG }; }; const createTempAskPassScript = (secret) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackpulse-askpass-')); const scriptPath = path.join(tmpDir, 'askpass.sh'); const scriptContent = "#!/bin/sh\nprintf '%s\n' \"$STACKPULSE_SSH_PASS\"\n"; fs.writeFileSync(scriptPath, scriptContent, { mode: 0o700 }); return { env: { SSH_ASKPASS: scriptPath, SSH_ASKPASS_REQUIRE: 'force', DISPLAY: process.env.DISPLAY || ':9999', STACKPULSE_SSH_PASS: secret }, cleanup: () => { try { fs.unlinkSync(scriptPath); } catch (err) { } try { fs.rmdirSync(tmpDir); } catch (err) { } } }; }; const buildSshCommandArgs = (config) => { const sshConfig = mergeSshConfig(DEFAULT_PORTAINER_SSH_CONFIG, config); if (!sshConfig.host) { throw new Error('SSH Host ist nicht konfiguriert'); } if (!sshConfig.username) { throw new Error('SSH Benutzer ist nicht konfiguriert'); } const args = [ '-p', String(sshConfig.port), '-o', 'StrictHostKeyChecking=no', '-o', 'PreferredAuthentications=password', '-o', 'PubkeyAuthentication=no', '-o', 'NumberOfPasswordPrompts=1' ]; const cleanupTasks = []; const registerCleanup = (fn) => { if (typeof fn === 'function') { cleanupTasks.push(fn); } }; let envOverrides = {}; if (sshConfig.password) { const { env, cleanup: dispose } = createTempAskPassScript(sshConfig.password); envOverrides = { ...envOverrides, ...env }; registerCleanup(dispose); args.push('-o', 'BatchMode=no'); } else { args.push('-o', 'BatchMode=yes'); } if (sshConfig.extraSshArgs.length) { const expanded = sshConfig.extraSshArgs.flatMap((entry) => tokenizeSshArgLine(entry)); if (expanded.length) { args.push(...expanded); } } args.push(`${sshConfig.username}@${sshConfig.host}`); const cleanup = () => { while (cleanupTasks.length) { const task = cleanupTasks.pop(); try { task(); } catch (err) { } } }; return { args, sshConfig, cleanup, env: envOverrides }; }; const ensureSshConfigReady = (serverId = null) => { const config = getPortainerSshConfig(serverId); const hasHost = Boolean(config.host && config.username); if (!hasHost) { throw new Error('SSH-Konfiguration ist unvollständig (Host/Benutzer erforderlich).'); } return config; }; const testSshConnection = async (configOverride = null, serverId = null) => { const baseConfig = configOverride ? mergeSshConfig(getPortainerSshConfig(serverId), configOverride) : ensureSshConfigReady(serverId); const { args, env: envOverrides, cleanup } = buildSshCommandArgs(baseConfig); const sshArgs = [...args, 'echo', '__PORTAINER_SSH_TEST__']; try { return await new Promise((resolve, reject) => { const childEnv = { ...process.env, ...envOverrides }; const child = spawn('ssh', sshArgs, { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; let errorOutput = ''; child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.stderr.on('data', (chunk) => { errorOutput += chunk.toString(); }); child.on('error', reject); child.on('close', (code) => { if (code === 0 && output.includes('__PORTAINER_SSH_TEST__')) { resolve({ success: true, output: output.trim() }); } else { const message = errorOutput.trim() || `SSH Test fehlgeschlagen (Exit-Code ${code})`; reject(new Error(message)); } }); }); } finally { cleanup(); } }; const detectPortainerContainer = async () => { try { const endpointId = await resolvePortainerEnvironmentId(); const containersRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/json`, { params: { all: true } }); const containers = Array.isArray(containersRes.data) ? containersRes.data : []; const normalizeName = (value) => (typeof value === 'string' ? value.replace(/^\//, '').toLowerCase() : ''); const isPortainerContainer = (container = {}) => { const names = Array.isArray(container.Names) ? container.Names.map(normalizeName) : []; const image = String(container.Image ?? '').toLowerCase(); const labels = container.Labels || {}; if (labels['io.portainer.container']) return true; if (labels['io.portainer.role'] === 'instance') return true; if (names.includes('portainer') || names.includes('portainer_ce')) return true; if (image.includes('portainer/portainer')) return true; if (image.includes('portainer-ce')) return true; return false; }; const matchedContainer = containers.find((entry) => isPortainerContainer(entry)); if (!matchedContainer) { return { summary: null, error: 'Portainer-Container nicht gefunden' }; } const inspectRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/${matchedContainer.Id}/json`); const inspect = inspectRes.data ?? {}; const trimName = (value) => (typeof value === 'string' ? value.replace(/^\//, '') : value); const toArray = (value) => { if (!value) return []; if (Array.isArray(value)) return value.map((item) => String(item)); return [String(value)]; }; const summary = { id: inspect.Id ?? matchedContainer.Id ?? null, name: trimName(inspect.Name) ?? (matchedContainer.Names?.[0] ? trimName(matchedContainer.Names[0]) : null), image: inspect.Config?.Image ?? matchedContainer.Image ?? null, created: inspect.Created ?? null, entrypoint: toArray(inspect.Config?.Entrypoint), command: toArray(inspect.Config?.Cmd), args: toArray(inspect.Args), env: Array.isArray(inspect.Config?.Env) ? inspect.Config.Env : [], binds: Array.isArray(inspect.HostConfig?.Binds) ? inspect.HostConfig.Binds : [], mounts: Array.isArray(inspect.Mounts) ? inspect.Mounts.map((mount) => ({ type: mount.Type ?? null, source: mount.Source ?? null, destination: mount.Destination ?? null, mode: mount.Mode ?? null, rw: typeof mount.RW === 'boolean' ? mount.RW : null })) : [], labels: inspect.Config?.Labels ?? matchedContainer.Labels ?? {}, ports: inspect.HostConfig?.PortBindings ?? null, networks: inspect.NetworkSettings?.Networks ?? null, restartPolicy: inspect.HostConfig?.RestartPolicy ?? null }; return { summary, error: null }; } catch (err) { const message = err.response?.data?.message || err.message; return { summary: null, error: message }; } }; const waitForPortainerAvailability = async ({ timeoutMs = 5 * 60 * 1000, intervalMs = 5000 } = {}) => { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { await axiosInstance.get('/api/status'); return true; } catch (err) { await new Promise((resolve) => setTimeout(resolve, intervalMs)); } } return false; }; const normalizeVersion = (value) => { if (!value) return null; return String(value).trim().replace(/^v/i, ''); }; const semverParts = (value) => { const normalized = normalizeVersion(value); if (!normalized) return null; return normalized.split(/[.-]/).map((segment) => { const numericPart = segment.replace(/[^0-9].*$/, ''); const parsed = Number.parseInt(numericPart, 10); return Number.isNaN(parsed) ? 0 : parsed; }); }; const compareSemver = (a, b) => { const partsA = semverParts(a) || []; const partsB = semverParts(b) || []; const length = Math.max(partsA.length, partsB.length); for (let index = 0; index < length; index += 1) { const valueA = partsA[index] ?? 0; const valueB = partsB[index] ?? 0; if (valueA > valueB) return 1; if (valueA < valueB) return -1; } return 0; }; const extractPortainerEdition = (payload) => { if (!payload || typeof payload !== 'object') return null; const enumType = payload.type ?? payload.Type; if (enumType === 2 || enumType === 3) return 'Business Edition'; if (enumType === 1) return 'Community Edition'; return null; }; const extractPortainerBuild = (payload) => { if (!payload || typeof payload !== 'object') return null; return payload.BuildNumber ?? payload.Server?.Build ?? payload.VersionInfo?.BuildNumber ?? null; }; const fetchPortainerStatusSummary = async () => { const statusRes = await axiosInstance.get('/api/status'); let statusData = statusRes.data ?? {}; const currentVersion = statusData.Version ?? statusData.ServerVersion ?? statusData.Server?.Version ?? statusData.ServerInfo?.Version ?? statusData.ServerVersionNumber ?? null; let edition = null; const build = extractPortainerBuild(statusData); // Edition ausschließlich über Lizenz-Endpoint ableiten let effectiveEdition = null; let effectiveBuild = build; try { const licenseInfoRes = await axiosInstance.get('/api/licenses/info'); const licenseInfo = licenseInfoRes.data ?? {}; effectiveEdition = extractPortainerEdition(licenseInfo) ?? 'Business Edition'; } catch (err) { // Endpoint fehlt -> Community Edition effectiveEdition = 'Community Edition'; } // optional: Build über /api/system/status ergänzen, falls leer if (!effectiveBuild) { try { const systemStatusRes = await axiosInstance.get('/api/system/status'); const systemStatus = systemStatusRes.data ?? {}; effectiveBuild = extractPortainerBuild(systemStatus) ?? effectiveBuild; } catch (err) { // Build bleibt ggf. null } } const errors = {}; let latestVersion = null; const { summary: containerSummary, error: containerError } = await detectPortainerContainer(); if (containerError) { errors.container = containerError; } try { const githubRes = await axios.get('https://api.github.com/repos/portainer/portainer/releases/latest', { headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'StackPulse-Maintenance' }, timeout: 5000 }); latestVersion = githubRes.data?.tag_name ?? githubRes.data?.tagName ?? null; } catch (err) { const message = err.response?.data?.message || err.message; console.warn(`⚠️ [Maintenance] Konnte Portainer-Latest-Version nicht ermitteln: ${message}`); errors.latestVersion = message; } if (!currentVersion) { errors.currentVersion = 'Portainer-Version konnte nicht ermittelt werden'; } const normalizedCurrent = normalizeVersion(currentVersion); const normalizedLatest = normalizeVersion(latestVersion); const portainerFlag = typeof statusData.UpdateAvailable === 'boolean' ? statusData.UpdateAvailable : null; let updateAvailable = null; if (normalizedCurrent && normalizedLatest) { updateAvailable = compareSemver(normalizedCurrent, normalizedLatest) < 0; } else if (portainerFlag !== null) { updateAvailable = portainerFlag; } const responsePayload = { currentVersion, latestVersion, normalized: { current: normalizedCurrent, latest: normalizedLatest }, updateAvailable, portainerFlag, edition: edition ?? null, build: build ?? null, fetchedAt: new Date().toISOString(), container: containerSummary }; if (Object.keys(errors).length) { responsePayload.errors = errors; } return responsePayload; }; const maintenanceGuard = (req, res, next) => { if (!isMaintenanceModeActive()) { return next(); } return res.status(423).json({ error: 'Wartungsmodus aktiv', maintenance: getMaintenanceState(), update: getPortainerUpdateStatus() }); }; const requirePermission = (permissionKey, requiredLevel = 'full') => (req, res, next) => { if (!permissionKey) { return next(); } if (req.user?.isSuperuser) { return next(); } const permissions = req.userPermissions || {}; if (hasRequiredPermission(permissions, permissionKey, requiredLevel)) { return next(); } return res.status(403).json({ error: 'INSUFFICIENT_PERMISSIONS', permission: permissionKey, requiredLevel }); }; const requireAnyPermission = (candidates = []) => (req, res, next) => { if (!Array.isArray(candidates) || candidates.length === 0) { return next(); } if (req.user?.isSuperuser) { return next(); } const permissions = req.userPermissions || {}; const granted = candidates.some(({ key, level }) => typeof key === 'string' && hasRequiredPermission(permissions, key, level || 'full') ); if (granted) { return next(); } return res.status(403).json({ error: 'INSUFFICIENT_PERMISSIONS', permission: candidates.map((entry) => entry.key).filter(Boolean), requiredLevel: candidates.map((entry) => entry.level || 'full') }); }; const logScriptOutput = (data, level) => { if (!data) return; const text = data.toString(); text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).forEach((line) => addUpdateLog(line, level)); }; const executePortainerUpdateScript = async (script) => { const normalized = normalizeScriptText(script); if (!normalized.trim()) { addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning'); return; } const scriptWithUnixNewlines = normalized.endsWith('\n') ? normalized : `${normalized}\n`; const sshConfig = getPortainerSshConfig(); const useSsh = Boolean(sshConfig.host && sshConfig.username); if (!useSsh) { addUpdateLog('Führe Update-Skript lokal auf dem StackPulse-Host aus.', 'info'); return new Promise((resolve, reject) => { const child = spawn('bash', ['-lc', `set -e\n${scriptWithUnixNewlines}`], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout')); child.stderr.on('data', (chunk) => logScriptOutput(chunk, 'stderr')); child.on('error', (err) => reject(err)); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Update-Skript beendet mit Exit-Code ${code}`)); } }); }); } const { args, env: envOverrides, cleanup } = buildSshCommandArgs(sshConfig); const sshArgs = [...args, 'bash', '-s']; addUpdateLog(`Verbinde zu ${sshConfig.username}@${sshConfig.host} für Update-Skript`, 'info'); const sshPromise = new Promise((resolve, reject) => { const childEnv = { ...process.env, ...envOverrides }; const child = spawn('ssh', sshArgs, { env: childEnv, stdio: ['pipe', 'pipe', 'pipe'] }); child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout')); child.stderr.on('data', (chunk) => logScriptOutput(chunk, 'stderr')); child.on('error', (err) => reject(err)); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`SSH Befehl beendet mit Exit-Code ${code}`)); } }); child.stdin.write(`set -e ${scriptWithUnixNewlines}`); child.stdin.end(); }); return sshPromise.finally(() => cleanup()); }; let currentPortainerUpdatePromise = null; const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => { let maintenanceActivated = false; const serverContextId = getActiveServerUrl() || null; try { addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info'); updatePortainerState({ status: 'running', stage: 'activating-maintenance', message: 'Wartungsmodus wird aktiviert' }); const maintenanceDetails = { type: 'portainer-update', targetVersion: targetVersion ?? null, scriptSource }; const maintenanceState = activateMaintenanceMode({ message: 'Portainer Update läuft', extra: maintenanceDetails }); maintenanceActivated = true; logEvent({ category: 'wartung', eventType: 'portainer-update', action: 'aktualisieren', status: 'gestartet', entityType: 'service', entityId: 'portainer', entityName: 'Portainer', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`, metadata: { targetVersion: targetVersion ?? null, scriptSource }, source: 'system' }); logEvent({ category: 'wartung', eventType: 'Wartungsmodus', action: 'aktivieren', status: 'gestartet', entityType: 'system', entityId: 'wartung', entityName: 'StackPulse Wartung', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: 'Wartungsmodus aktiviert (Portainer Update)', metadata: { reason: 'portainer-update', scriptSource }, source: 'system' }); updatePortainerState({ stage: 'executing-script', message: 'Update-Skript wird ausgeführt' }); await executePortainerUpdateScript(script); addUpdateLog('Update-Skript erfolgreich abgeschlossen', 'info'); updatePortainerState({ stage: 'waiting', message: 'Warte auf Portainer-Verfügbarkeit' }); const available = await waitForPortainerAvailability({ timeoutMs: 5 * 60 * 1000, intervalMs: 5000 }); if (!available) { throw new Error('Portainer blieb nach dem Update unerreichbar'); } addUpdateLog('Portainer antwortet wieder. Ermittle Version…', 'info'); const statusAfter = await fetchPortainerStatusSummary().catch(() => null); const finalVersion = statusAfter?.currentVersion ?? null; logEvent({ category: 'wartung', eventType: 'portainer-update', action: 'aktualisieren', status: 'erfolgreich', entityType: 'service', entityId: 'portainer', entityName: 'Portainer', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`, metadata: { resultVersion: finalVersion ?? null }, source: 'system' }); updatePortainerState({ running: false, status: 'success', stage: 'completed', finishedAt: new Date().toISOString(), message: 'Portainer Update abgeschlossen', error: null, resultVersion: finalVersion }); addUpdateLog('Portainer Update erfolgreich abgeschlossen', 'success'); if (maintenanceActivated || maintenanceState?.active) { deactivateMaintenanceMode({ message: 'Portainer Update abgeschlossen' }); maintenanceActivated = false; logEvent({ category: 'wartung', eventType: 'Wartungsmodus', action: 'deaktivieren', status: 'erfolgreich', entityType: 'system', entityId: 'wartung', entityName: 'StackPulse Wartung', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: 'Wartungsmodus deaktiviert', metadata: { reason: 'portainer-update' }, source: 'system' }); } } catch (err) { const message = err?.message || 'Portainer Update fehlgeschlagen'; logEvent({ category: 'wartung', eventType: 'portainer-update', action: 'aktualisieren', status: 'fehler', entityType: 'service', entityId: 'portainer', entityName: 'Portainer', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message, source: 'system' }); updatePortainerState({ running: false, status: 'error', stage: 'failed', finishedAt: new Date().toISOString(), message, error: message }); addUpdateLog(message, 'error'); if (maintenanceActivated || isMaintenanceModeActive()) { deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' }); logEvent({ category: 'wartung', eventType: 'Wartungsmodus', action: 'deaktivieren', status: 'fehler', entityType: 'system', entityId: 'wartung', entityName: 'StackPulse Wartung', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: 'Wartungsmodus deaktiviert (Fehler)', metadata: { reason: 'portainer-update' }, source: 'system' }); } } finally { currentPortainerUpdatePromise = null; } }; const fetchPortainerStacks = async () => { const stacksRes = await axiosInstance.get('/api/stacks'); return Array.isArray(stacksRes.data) ? stacksRes.data : []; }; const buildStackCollections = (stacks = []) => { const currentSelfStackId = getSelfStackId(); const collections = new Map(); stacks.forEach((stack) => { const name = stack.Name || 'Unbenannt'; const isSelf = currentSelfStackId && String(stack.Id) === currentSelfStackId; const entry = collections.get(name); if (!entry) { collections.set(name, { canonical: stack, isSelf, members: [stack] }); return; } entry.members.push(stack); if (!entry.isSelf && isSelf) { entry.canonical = stack; entry.isSelf = true; } }); const canonicalStacks = []; const duplicates = []; collections.forEach((entry, name) => { canonicalStacks.push(entry.canonical); if (entry.members.length > 1) { const seenIds = new Set(); const duplicateEntries = entry.members.filter((member) => { const id = String(member.Id); if (id === String(entry.canonical.Id)) { return false; } if (seenIds.has(id)) { return false; } seenIds.add(id); return true; }); if (duplicateEntries.length > 0) { duplicates.push({ name, canonical: entry.canonical, members: duplicateEntries }); } } }); return { canonicalStacks, duplicates }; }; const loadStackCollections = async () => { const filteredStacks = await fetchPortainerStacks(); return { filteredStacks, ...buildStackCollections(filteredStacks) }; }; const isStackOutdated = async (stackId) => { try { const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`); return statusRes.data?.Status === 'outdated'; } catch (err) { console.error(`⚠️ Konnte Update-Status für Stack ${stackId} nicht ermitteln:`, err.message); return true; } }; const filterOutdatedStacks = async (stacks = []) => { const currentSelfStackId = getSelfStackId(); const results = await Promise.all( stacks.map(async (stack) => ({ stack, outdated: currentSelfStackId && String(stack.Id) === currentSelfStackId ? false : await isStackOutdated(stack.Id) })) ); return { eligibleStacks: results.filter((entry) => entry.outdated).map((entry) => entry.stack), skippedStacks: results.filter((entry) => !entry.outdated).map((entry) => entry.stack), }; }; const getRedeployMessages = (type) => { switch (type) { case REDEPLOY_TYPES.ALL: return { started: 'Redeploy ALL gestartet', success: 'Redeploy ALL abgeschlossen' }; case REDEPLOY_TYPES.SELECTION: return { started: 'Redeploy Auswahl gestartet', success: 'Redeploy Auswahl abgeschlossen' }; case REDEPLOY_TYPES.MAINTENANCE: return { started: 'Redeploy (Wartung) gestartet', success: 'Redeploy (Wartung) abgeschlossen' }; default: return { started: 'Redeploy gestartet', success: 'Redeploy erfolgreich abgeschlossen' }; } }; const shouldFallbackToStackFile = (message) => { if (!message) return false; const normalized = String(message).toLowerCase(); return normalized.includes('not created from git') || normalized.includes('no git configuration'); }; const redeployStackById = async (stackId, redeployType) => { let stack; const messages = getRedeployMessages(redeployType); try { const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`); stack = stackRes.data; const targetId = stack.Id || stackId; const targetName = stack.Name || `Stack ${stackId}`; broadcastRedeployStatus({ stackId: targetId, stackName: targetName, phase: REDEPLOY_PHASES.STARTED }); logStackEvent({ stackId: targetId, stackName: targetName, status: 'started', message: messages.started, redeployType }); const redeployViaStackFile = async () => { const fileRes = await axiosInstance.get(`/api/stacks/${stack.Id}/file`); const stackFileContent = fileRes.data?.StackFileContent; if (!stackFileContent) { throw new Error('Stack file konnte nicht geladen werden'); } if (stack.Type === 2) { const services = fileRes.data?.Config?.services || {}; for (const serviceName in services) { const imageName = services[serviceName].image; if (!imageName) continue; try { console.log(`🖼️ Pulling image "${imageName}" für Service "${serviceName}"`); await axiosInstance.post( `/api/endpoints/${stack.EndpointId}/docker/images/create?fromImage=${encodeURIComponent(imageName)}` ); } catch (err) { console.error(`❌ Fehler beim Pulling von Image "${imageName}":`, err.message); } } } const updatePayload = { StackFileContent: stackFileContent, Prune: false, PullImage: true, Env: stack.Env || [] }; const swarmId = stack.SwarmId || stack.SwarmID || fileRes.data?.SwarmID; if (swarmId) { updatePayload.SwarmID = swarmId; } await axiosInstance.put( `/api/stacks/${stack.Id}`, updatePayload, { params: { endpointId: stack.EndpointId } } ); }; const isGitStack = Boolean(stack.GitConfig?.RepositoryURL); let gitRedeploySucceeded = false; if (isGitStack) { try { console.log(`🔄 [Redeploy] Git Stack "${stack.Name}" (${stack.Id}) wird redeployed`); await axiosInstance.put(`/api/stacks/${stack.Id}/git/redeploy?endpointId=${stack.EndpointId}`); gitRedeploySucceeded = true; } catch (err) { const gitErrorMessage = err.response?.data?.message || err.message; if (shouldFallbackToStackFile(gitErrorMessage)) { console.warn(`⚠️ Git Redeploy nicht möglich für Stack "${stack.Name}" (${stack.Id}): ${gitErrorMessage}. Fallback auf Stack-Datei.`); } else { throw err; } } } if (!gitRedeploySucceeded) { console.log(`🔄 [Redeploy] Stack "${stack.Name}" (${stack.Id}) wird über Stack-Datei redeployed`); await redeployViaStackFile(); } broadcastRedeployStatus({ stackId: targetId, stackName: targetName, phase: REDEPLOY_PHASES.SUCCESS }); logStackEvent({ stackId: targetId, stackName: targetName, status: 'success', message: messages.success, redeployType }); return stack; } catch (err) { const errorMessage = err.response?.data?.message || err.message; const fallbackId = stack?.Id || stackId; const fallbackName = stack?.Name || `Stack ${stackId}`; broadcastRedeployStatus({ stackId: fallbackId, stackName: fallbackName, phase: REDEPLOY_PHASES.ERROR, message: errorMessage }); logStackEvent({ stackId: fallbackId, stackName: fallbackName, status: 'error', message: errorMessage, redeployType }); console.error(`❌ Fehler beim Redeploy von Stack ${fallbackName}:`, errorMessage); throw new Error(errorMessage); } }; app.use((req, res, next) => { if (!req.path.startsWith('/api')) { return next(); } if (req.method === 'OPTIONS' || isPublicApiRoute(req)) { return next(); } if (!hasSuperuser()) { return res.status(403).json({ error: 'SUPERUSER_REQUIRED' }); } if (!hasServer() || !hasApiKey()) { return res.status(403).json({ error: 'SETUP_REQUIRED' }); } const token = extractAuthToken(req); const session = getSessionRecord(token); if (!session) { clearAuthCookie(res); return res.status(401).json({ error: 'UNAUTHORIZED' }); } const userRecord = getUserById(session.userId); if (!userRecord || !userRecord.isActive) { activeSessions.delete(token); clearAuthCookie(res); return res.status(401).json({ error: 'UNAUTHORIZED' }); } const userGroups = Array.isArray(userRecord.groups) ? userRecord.groups : []; const groupIds = userGroups .map((group) => Number(group.id)) .filter((groupId) => Number.isFinite(groupId) && groupId > 0); const isSuperuser = userGroups.some( (group) => typeof group?.name === 'string' && group.name.toLowerCase() === SUPERUSER_GROUP_NAME ); const permissionsMap = isSuperuser ? getSuperuserPermissionMap() : getEffectivePermissionsForGroups(groupIds); req.user = { id: userRecord.id, username: userRecord.username, email: userRecord.email, avatarColor: userRecord.avatarColor || null, groups: userGroups, isSuperuser, securityPhraseDownloadedAt: userRecord.securityPhraseDownloadedAt || null, requiresSecurityPhraseDownload: !userRecord.securityPhraseDownloadedAt }; req.userPermissions = permissionsMap; req.authToken = token; touchSession(token); setAuthCookie(res, token); next(); }); // --- API Routes --- app.get('/api/setup/status', (req, res) => { try { const status = buildSetupStatusResponse(); res.json(status); } catch (error) { console.error('⚠️ [Setup] Statusabfrage fehlgeschlagen:', error); res.status(500).json({ error: 'SETUP_STATUS_FAILED' }); } }); app.post('/api/setup/test-portainer', async (req, res) => { const { serverUrl, apiKey } = extractPortainerCredentials(req.body); const normalizedUrl = normalizeExternalPortainerUrl(serverUrl); if (!normalizedUrl) { return res.status(400).json({ error: 'SERVER_URL_INVALID' }); } if (!apiKey) { return res.status(400).json({ error: 'API_KEY_REQUIRED' }); } try { const client = createPortainerSetupClient({ baseURL: normalizedUrl, apiKey }); const statusRes = await client.get('/api/status'); res.json({ success: true, status: statusRes.data ?? null }); } catch (error) { const message = error.response?.data?.message || error.message || 'PORTAINER_TEST_FAILED'; const status = error.response?.status ?? 400; res.status(status === 401 ? 401 : 400).json({ error: 'PORTAINER_TEST_FAILED', message }); } }); app.post('/api/setup/portainer-stacks', async (req, res) => { const { serverUrl, apiKey } = extractPortainerCredentials(req.body); const normalizedUrl = normalizeExternalPortainerUrl(serverUrl); if (!normalizedUrl) { return res.status(400).json({ error: 'SERVER_URL_INVALID' }); } if (!apiKey) { return res.status(400).json({ error: 'API_KEY_REQUIRED' }); } try { const client = createPortainerSetupClient({ baseURL: normalizedUrl, apiKey }); const stacksRes = await client.get('/api/stacks'); const stacks = Array.isArray(stacksRes.data) ? stacksRes.data : []; res.json({ success: true, stacks }); } catch (error) { const message = error.response?.data?.message || error.message || 'PORTAINER_STACK_FETCH_FAILED'; const status = error.response?.status ?? 400; res.status(status === 401 ? 401 : 400).json({ error: 'PORTAINER_STACK_FETCH_FAILED', message }); } }); app.get('/api/servers/:id/agent', requirePermission('maintenance-server-edit', 'read'), async (req, res) => { const { id } = req.params; try { const config = getAgentConfig(id, { autoCreate: true }); const editionFlag = await isCommunityEdition(); const { online, error } = await pingAgent(config?.agentUrl, config?.agentToken); res.json({ serverId: Number(id), agentUrl: config?.agentUrl || null, agentToken: config?.agentToken || null, online, agentError: error || null, community: editionFlag }); } catch (err) { res.status(400).json({ error: err.code || err.message || 'AGENT_CONFIG_FAILED' }); } }); app.put('/api/servers/:id/agent', requirePermission('maintenance-server-edit', 'full'), async (req, res) => { const { id } = req.params; try { const payload = { agentUrl: req.body?.agentUrl, agentToken: req.body?.agentToken }; // Token ist systemgeneriert; wenn keiner übergeben, wird Auto-Token gesetzt const config = setAgentConfig(id, { agentUrl: payload.agentUrl, agentToken: payload.agentToken || undefined }); res.json(config); } catch (err) { res.status(400).json({ error: err.code || err.message || 'AGENT_CONFIG_FAILED' }); } }); app.post('/api/setup/complete', (req, res) => { const { superuser: superuserInput, server: serverInput, apiKey: apiKeyInput, selfStackId: selfStackInput } = req.body ?? {}; const hasSelfStackInput = req.body ? Object.prototype.hasOwnProperty.call(req.body, 'selfStackId') : false; const initialStatus = getSetupStatus(); const needsSuperuser = initialStatus.requirements.superuser; const needsServer = initialStatus.requirements.server; const needsApiKey = initialStatus.requirements.apiKey; const created = {}; const normalizeServerInput = (input) => { if (!input) return null; const name = typeof input.name === 'string' ? input.name.trim() : ''; const url = typeof input.url === 'string' ? input.url.trim() : ''; if (!url) { return { name: name || '', url: '' }; } return { name: name || null, url }; }; const normalizeApiKeyInput = (input) => { if (!input) return null; if (typeof input === 'string') { const value = input.trim(); return { value, serverId: null }; } if (typeof input === 'object') { const rawValue = typeof input.value === 'string' ? input.value : typeof input.key === 'string' ? input.key : ''; const value = rawValue.trim(); const serverIdRaw = input.serverId !== undefined && input.serverId !== null ? Number(input.serverId) : null; const serverId = Number.isFinite(serverIdRaw) ? serverIdRaw : null; return { value, serverId }; } return null; }; try { const serverPayload = normalizeServerInput(serverInput); const apiKeyPayload = normalizeApiKeyInput(apiKeyInput); if (needsServer && (!serverPayload || !serverPayload.url)) { return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' }); } if (needsServer || (serverPayload && serverPayload.url)) { const setupResult = completeSetup({ server: serverPayload && serverPayload.url ? serverPayload : null }); created.server = setupResult.server; } if (needsSuperuser) { const username = typeof superuserInput?.username === 'string' ? superuserInput.username.trim() : ''; const email = typeof superuserInput?.email === 'string' ? superuserInput.email.trim() : ''; const password = typeof superuserInput?.password === 'string' ? superuserInput.password : ''; if (!username || !email || !password) { return res.status(400).json({ error: 'SUPERUSER_DETAILS_REQUIRED' }); } const user = registerSuperuser({ username, email, password }); created.superuser = user; } const statusSnapshot = getSetupStatus(); let targetServerId = apiKeyPayload?.serverId ?? created.server?.id ?? null; if (!targetServerId) { targetServerId = statusSnapshot.servers.items?.[0]?.id ?? null; } if (apiKeyPayload && apiKeyPayload.value) { if (!targetServerId) { return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' }); } const apiKeyResult = setServerApiKey({ serverId: targetServerId, apiKey: apiKeyPayload.value }); created.apiKey = apiKeyResult; } else if (needsApiKey) { return res.status(400).json({ error: 'API_KEY_REQUIRED' }); } if (targetServerId) { // Ensure agent token exists for CE servers const agent = setAgentConfig(targetServerId, { agentUrl: null, agentToken: undefined }); created.agent = agent; } if (hasSelfStackInput) { const normalizedSelfStackId = typeof selfStackInput === 'string' ? selfStackInput.trim() : selfStackInput !== undefined && selfStackInput !== null ? String(selfStackInput).trim() : ''; const nextSelfStackId = persistSelfStackId(normalizedSelfStackId); created.selfStackId = nextSelfStackId || null; } const finalStatus = buildSetupStatusResponse(); res.status(201).json({ success: finalStatus.setupComplete, status: finalStatus, created }); } catch (error) { const code = error.code || error.message; switch (code) { case 'SERVER_URL_REQUIRED': case 'SERVER_NAME_REQUIRED': case 'SERVER_DETAILS_REQUIRED': case 'USERNAME_REQUIRED': case 'EMAIL_INVALID': case 'PASSWORD_TOO_SHORT': case 'INVALID_PASSWORD': case 'API_KEY_REQUIRED': return res.status(400).json({ error: code }); case 'API_KEY_ENCRYPT_FAILED': console.error('⚠️ [Setup] API-Key Verschlüsselung fehlgeschlagen:', error); return res.status(500).json({ error: 'API_KEY_ENCRYPT_FAILED' }); case 'SERVER_NOT_FOUND': return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); case 'SUPERUSER_ALREADY_EXISTS': return res.status(409).json({ error: 'SUPERUSER_EXISTS' }); default: console.error('⚠️ [Setup] Fehler beim Abschluss:', error); return res.status(500).json({ error: 'SETUP_FAILED' }); } } }); app.get('/api/setup/servers/:id', requirePermission('maintenance-server-manage', 'read'), (req, res) => { const { id } = req.params; try { const detail = getServerStatus(id); res.json({ success: true, ...detail }); } catch (error) { const code = error.code || error.message; switch (code) { case 'SERVER_ID_INVALID': return res.status(400).json({ error: 'SERVER_ID_INVALID' }); case 'SERVER_NOT_FOUND': return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); default: console.error('⚠️ [Setup] Serverdetails konnten nicht geladen werden:', error); return res.status(500).json({ error: 'SERVER_FETCH_FAILED' }); } } }); app.get('/api/setup/servers/:id/check', requirePermission('maintenance-server-manage', 'read'), async (req, res) => { const { id } = req.params; try { const { server, apiKey } = getServerConnection(id); const agentConfig = getAgentConfig(id, { autoCreate: true }); if (!apiKey) { return res.json({ success: true, online: false, portainer: { currentVersion: null, latestVersion: null, updateAvailable: null, edition: null, build: null } }); } const client = createPortainerSetupClient({ baseURL: server.url, apiKey }); const statusRes = await client.get('/api/status'); let statusData = statusRes.data ?? {}; const currentVersion = statusData.Version ?? statusData.ServerVersion ?? statusData.Server?.Version ?? statusData.ServerInfo?.Version ?? statusData.ServerVersionNumber ?? null; let edition = null; let build = extractPortainerBuild(statusData); // Edition ausschließlich über Lizenz-Endpoint ableiten try { const licenseInfoRes = await client.get('/api/licenses/info'); const licenseInfo = licenseInfoRes.data ?? {}; edition = extractPortainerEdition(licenseInfo) ?? 'Business Edition'; } catch (err) { edition = 'Community Edition'; } // optional: Build über /api/system/status ergänzen, falls leer if (!build) { try { const systemStatusRes = await client.get('/api/system/status'); const systemStatus = systemStatusRes.data ?? {}; build = extractPortainerBuild(systemStatus) ?? build; } catch (err) { // Build bleibt ggf. null } } // Hole Version/Edition analog BE über /system/version (falls verfügbar) try { const sysVerRes = await client.get('/api/system/version'); const sysVer = sysVerRes.data ?? {}; const apiVersion = sysVer.Version || sysVer.version || null; const apiEdition = sysVer.Edition || sysVer.edition || null; if (apiVersion) { statusData = { ...statusData, Version: apiVersion }; } if (apiEdition) { edition = apiEdition; } } catch (err) { // ignore, CE ohne endpoint oder Fehler } let agentOnline = null; let agentError = null; let agentVersion = null; if ((edition || '').toLowerCase().includes('community') && agentConfig?.agentUrl) { const { online, error } = await pingAgent(agentConfig.agentUrl, agentConfig.agentToken); agentOnline = online; agentError = error; if (online) { try { const agentRes = await fetch(`${agentConfig.agentUrl.replace(/\/+$/, '')}/portainer/version`, { headers: { 'X-Agent-Token': agentConfig.agentToken || '' } }); if (agentRes.ok) { agentVersion = await agentRes.json(); } else { agentError = `agent_status_${agentRes.status}`; } } catch (err) { agentError = err?.message || 'agent_fetch_failed'; } } } const effectiveCurrentVersion = statusData.Version ?? statusData.ServerVersion ?? statusData.Server?.Version ?? statusData.ServerInfo?.Version ?? statusData.ServerVersionNumber ?? currentVersion; res.json({ success: true, online: true, portainer: { currentVersion: effectiveCurrentVersion, latestVersion: null, updateAvailable: false, edition, build }, agent: { url: agentConfig?.agentUrl || null, token: agentConfig?.agentToken || null, online: agentOnline, error: agentError } }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_ID_INVALID') { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } if (code === 'API_KEY_REQUIRED' || code === 'SERVER_URL_REQUIRED') { return res.status(400).json({ error: code }); } console.error('⚠️ [Setup] Server-Check fehlgeschlagen:', error); return res.status(500).json({ error: 'SERVER_CHECK_FAILED' }); } }); app.post('/api/setup/servers', requirePermission('maintenance-server-edit', 'full'), async (req, res) => { const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; const urlRaw = typeof req.body?.url === 'string' ? req.body.url.trim() : ''; const apiKeyRaw = typeof req.body?.apiKey === 'string' ? req.body.apiKey : typeof req.body?.key === 'string' ? req.body.key : ''; const normalizedExternalUrl = normalizeExternalPortainerUrl(urlRaw); if (!normalizedExternalUrl) { return res.status(400).json({ error: 'SERVER_URL_INVALID' }); } if (!apiKeyRaw.trim()) { return res.status(400).json({ error: 'API_KEY_REQUIRED' }); } try { // verify connectivity const client = createPortainerSetupClient({ baseURL: normalizedExternalUrl, apiKey: apiKeyRaw.trim() }); await client.get('/api/status'); const server = ensureServer({ name, url: normalizedExternalUrl }); setServerApiKey({ serverId: server.id, apiKey: apiKeyRaw.trim() }); const status = buildSetupStatusResponse(); res.status(201).json({ success: true, server, status }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_URL_REQUIRED' || code === 'SERVER_NAME_REQUIRED') { return res.status(400).json({ error: code }); } if (code === 'SERVER_URL_TAKEN') { return res.status(409).json({ error: 'SERVER_URL_TAKEN' }); } if (code === 'SERVER_ID_INVALID') { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } if (error.response?.status === 401) { return res.status(401).json({ error: 'API_KEY_INVALID', message: error.response?.data?.message || 'Portainer API-Key ungültig' }); } if (error.response?.status === 404) { return res.status(404).json({ error: 'SERVER_UNREACHABLE', message: error.response?.data?.message || 'Server nicht erreichbar' }); } console.error('⚠️ [Setup] Server konnte nicht erstellt werden:', error); return res.status(500).json({ error: 'SERVER_CREATE_FAILED' }); } }); app.get('/api/setup/servers/:id/ssh-config', requirePermission('maintenance-ssh-update', 'read'), (req, res) => { const serverId = Number(req.params.id); if (!Number.isFinite(serverId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } try { getServerById(serverId); const config = getPortainerSshConfig(serverId); res.json({ success: true, ssh: { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, passwordStored: Boolean(config.password) } }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } return res.status(500).json({ error: 'SSH_CONFIG_LOAD_FAILED' }); } }); app.put('/api/setup/servers/:id/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => { const serverId = Number(req.params.id); if (!Number.isFinite(serverId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } try { getServerById(serverId); const config = savePortainerSshConfig(req.body || {}, serverId); res.json({ success: true, ssh: { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, passwordStored: Boolean(config.password) } }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } console.error('❌ [Setup] Server-spezifische SSH-Konfiguration konnte nicht gespeichert werden:', error); return res.status(400).json({ error: code || 'SSH_CONFIG_SAVE_FAILED' }); } }); app.delete('/api/setup/servers/:id/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => { const serverId = Number(req.params.id); if (!Number.isFinite(serverId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } try { getServerById(serverId); const config = deletePortainerSshConfig(serverId); res.json({ success: true, ssh: { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, passwordStored: false } }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } console.error('❌ [Setup] Server-spezifische SSH-Konfiguration konnte nicht gelöscht werden:', error); return res.status(500).json({ error: 'SSH_CONFIG_DELETE_FAILED' }); } }); app.post('/api/setup/servers/:id/test-ssh', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => { const serverId = Number(req.params.id); if (!Number.isFinite(serverId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } try { getServerById(serverId); const override = req.body && Object.keys(req.body).length ? req.body : null; const result = await testSshConnection(override, serverId); res.json({ success: true, result }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } console.error('❌ [Setup] SSH Test für Server fehlgeschlagen:', error); res.status(500).json({ error: code || 'SSH_TEST_FAILED' }); } }); app.get('/api/setup/servers/:id/update-script', requirePermission('maintenance-ssh-update', 'read'), async (req, res) => { const serverId = Number(req.params.id); if (!Number.isFinite(serverId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } try { getServerById(serverId); const edition = (await resolveServerEdition(serverId) || '').toLowerCase(); const defaultScript = edition.includes('community') ? DEFAULT_PORTAINER_UPDATE_SCRIPT_CE : DEFAULT_PORTAINER_UPDATE_SCRIPT_BE; const script = getEffectivePortainerScript(serverId, defaultScript); const custom = getCustomPortainerScript(serverId); res.json({ success: true, script: { default: defaultScript, custom: custom?.script ?? null, customUpdatedAt: custom?.updatedAt ?? null, effective: script.script, source: script.source, updatedAt: script.updatedAt } }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } console.error('❌ [Setup] Update-Skript konnte nicht geladen werden:', error); res.status(500).json({ error: 'UPDATE_SCRIPT_LOAD_FAILED' }); } }); app.put('/api/setup/servers/:id/update-script', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => { const serverId = Number(req.params.id); if (!Number.isFinite(serverId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } if (portainerUpdateState.running) { return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.' }); } const incoming = req.body?.script; if (typeof incoming !== 'string') { return res.status(400).json({ error: 'Feld "script" (string) wird benötigt.' }); } try { getServerById(serverId); saveCustomPortainerScript(incoming, serverId); const custom = getCustomPortainerScript(serverId); const edition = (await resolveServerEdition(serverId) || '').toLowerCase(); const defaultScript = edition.includes('community') ? DEFAULT_PORTAINER_UPDATE_SCRIPT_CE : DEFAULT_PORTAINER_UPDATE_SCRIPT_BE; const effective = getEffectivePortainerScript(serverId, defaultScript); res.json({ success: true, script: { default: defaultScript, custom: custom?.script ?? null, customUpdatedAt: custom?.updatedAt ?? null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt } }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } console.error('❌ [Setup] Update-Skript konnte nicht gespeichert werden:', error); res.status(400).json({ error: code || 'UPDATE_SCRIPT_SAVE_FAILED' }); } }); app.delete('/api/setup/servers/:id/update-script', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => { const serverId = Number(req.params.id); if (!Number.isFinite(serverId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } if (portainerUpdateState.running) { return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.' }); } try { getServerById(serverId); saveCustomPortainerScript('', serverId); const edition = (await resolveServerEdition(serverId) || '').toLowerCase(); const defaultScript = edition.includes('community') ? DEFAULT_PORTAINER_UPDATE_SCRIPT_CE : DEFAULT_PORTAINER_UPDATE_SCRIPT_BE; const effective = getEffectivePortainerScript(serverId, defaultScript); res.json({ success: true, script: { default: defaultScript, custom: null, customUpdatedAt: null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt } }); } catch (error) { const code = error.code || error.message; if (code === 'SERVER_NOT_FOUND') { return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); } console.error('❌ [Setup] Update-Skript konnte nicht gelöscht werden:', error); res.status(400).json({ error: code || 'UPDATE_SCRIPT_DELETE_FAILED' }); } }); app.put('/api/setup/servers/:id', requirePermission('maintenance-server-edit', 'full'), (req, res) => { const { id } = req.params; const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; const url = typeof req.body?.url === 'string' ? req.body.url.trim() : ''; try { const server = updateServerDetails(id, { name, url }); const status = buildSetupStatusResponse(); res.json({ success: true, server, status }); } catch (error) { const code = error.code || error.message; switch (code) { case 'SERVER_ID_INVALID': return res.status(400).json({ error: 'SERVER_ID_INVALID' }); case 'SERVER_NOT_FOUND': return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); case 'SERVER_URL_REQUIRED': case 'SERVER_NAME_REQUIRED': return res.status(400).json({ error: code }); case 'SERVER_URL_TAKEN': return res.status(409).json({ error: 'SERVER_URL_TAKEN' }); default: console.error('⚠️ [Setup] Server konnte nicht aktualisiert werden:', error); return res.status(500).json({ error: 'SERVER_UPDATE_FAILED' }); } } }); app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => { const { id } = req.params; const numericId = Number(id); if (!Number.isFinite(numericId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } try { const result = removeServer(numericId); const status = buildSetupStatusResponse(); res.json({ success: true, removed: result, status }); } catch (error) { const code = error.code || error.message; switch (code) { case 'SERVER_ID_INVALID': return res.status(400).json({ error: 'SERVER_ID_INVALID' }); case 'SERVER_NOT_FOUND': return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); default: console.error('⚠️ [Setup] Server konnte nicht gelöscht werden:', error); return res.status(500).json({ error: 'SERVER_DELETE_FAILED' }); } } }); app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-edit', 'full'), (req, res) => { const { id } = req.params; const numericId = Number(id); if (!Number.isFinite(numericId)) { return res.status(400).json({ error: 'SERVER_ID_INVALID' }); } const rawValue = typeof req.body?.apiKey === 'string' ? req.body.apiKey : typeof req.body?.key === 'string' ? req.body.key : ''; try { const result = setServerApiKey({ serverId: numericId, apiKey: rawValue }); const status = buildSetupStatusResponse(); res.json({ success: true, updated: result, status }); } catch (error) { const code = error.code || error.message; switch (code) { case 'SERVER_ID_INVALID': return res.status(400).json({ error: 'SERVER_ID_INVALID' }); case 'SERVER_NOT_FOUND': return res.status(404).json({ error: 'SERVER_NOT_FOUND' }); case 'API_KEY_REQUIRED': return res.status(400).json({ error: 'API_KEY_REQUIRED' }); case 'API_KEY_ENCRYPT_FAILED': console.error('⚠️ [Setup] API-Key Verschlüsselung fehlgeschlagen:', error); return res.status(500).json({ error: 'API_KEY_ENCRYPT_FAILED' }); default: console.error('⚠️ [Setup] API-Key konnte nicht aktualisiert werden:', error); return res.status(500).json({ error: 'API_KEY_UPDATE_FAILED' }); } } }); app.put('/api/setup/self-stack', requirePermission('maintenance-server-edit', 'full'), (req, res) => { const hasPayload = req.body ? Object.prototype.hasOwnProperty.call(req.body, 'selfStackId') : false; if (!hasPayload) { return res.status(400).json({ error: 'SELF_STACK_ID_MISSING' }); } const inputValue = req.body.selfStackId; const normalizedSelfStackId = typeof inputValue === 'string' ? inputValue.trim() : inputValue !== undefined && inputValue !== null ? String(inputValue).trim() : ''; try { const nextValue = persistSelfStackId(normalizedSelfStackId) || null; const status = buildSetupStatusResponse(); res.json({ success: true, value: nextValue, status }); } catch (error) { console.error('⚠️ [Setup] Self-Stack-ID konnte nicht aktualisiert werden:', error); res.status(500).json({ error: 'SELF_STACK_ID_UPDATE_FAILED' }); } }); app.post('/api/auth/login', (req, res) => { if (!hasSuperuser()) { return res.status(403).json({ error: 'SUPERUSER_REQUIRED' }); } if (!hasServer() || !hasApiKey()) { return res.status(403).json({ error: 'SETUP_REQUIRED' }); } const { identifier, password } = req.body ?? {}; if (!identifier || !password) { return res.status(400).json({ error: 'MISSING_CREDENTIALS' }); } const user = findUserByIdentifier(identifier); if (!user) { return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); } if (!user.is_active) { return res.status(403).json({ error: 'USER_INACTIVE' }); } const passwordValid = verifyPassword(password, user.password_hash, user.password_salt); if (!passwordValid) { return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); } const session = createSessionForUser(user); markUserLogin(user.id); setAuthCookie(res, session.token); const payload = buildUserSessionPayload(user.id); if (payload) { res.json(payload); } else { res.json({ user: sanitizeUser(user), permissions: {} }); } }); app.post('/api/auth/logout', (req, res) => { const token = extractAuthToken(req); if (token) { activeSessions.delete(token); } clearAuthCookie(res); res.json({ success: true }); }); app.post('/api/auth/recover/verify', (req, res) => { const { username, phrase, words } = req.body ?? {}; try { const candidate = typeof phrase === 'string' && phrase.trim() ? phrase : words; const verification = verifySecurityPhraseForUsername(username, candidate); const ticket = createPasswordResetTicketForUser(verification.userId); res.json({ token: ticket.token, expiresIn: PASSWORD_RESET_TTL_MS }); } catch (error) { if (error.code === 'USERNAME_REQUIRED' || error.code === 'PHRASE_REQUIRED') { return res.status(400).json({ error: error.code }); } if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } if (error.code === 'PHRASE_NOT_INITIALIZED') { return res.status(409).json({ error: 'PHRASE_NOT_INITIALIZED' }); } if (error.code === 'PHRASE_MISMATCH') { return res.status(401).json({ error: 'PHRASE_MISMATCH' }); } if (error.code === 'INVALID_PASSWORD') { return res.status(400).json({ error: 'INVALID_PASSWORD' }); } console.error('⚠️ [Auth] Sicherheitsphrase-Überprüfung fehlgeschlagen:', error); res.status(500).json({ error: 'RECOVERY_VERIFY_FAILED' }); } }); app.post('/api/auth/recover/reset', (req, res) => { const { token, password, confirmPassword } = req.body ?? {}; if (!token || typeof token !== 'string' || !token.trim()) { return res.status(400).json({ error: 'TOKEN_REQUIRED' }); } if (typeof password !== 'string' || !password.trim()) { return res.status(400).json({ error: 'PASSWORD_REQUIRED' }); } if (typeof confirmPassword === 'string' && password !== confirmPassword) { return res.status(400).json({ error: 'PASSWORD_MISMATCH' }); } const trimmedToken = token.trim(); const ticket = getPasswordResetTicket(trimmedToken); if (!ticket) { return res.status(410).json({ error: 'TOKEN_INVALID_OR_EXPIRED' }); } try { setUserPassword(ticket.userId, password, { resetMethod: 'security-phrase' }); passwordResetTickets.delete(trimmedToken); removeSessionsForUser(ticket.userId); res.json({ success: true }); } catch (error) { if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') { return res.status(400).json({ error: error.code }); } if (error.code === 'USER_NOT_FOUND') { passwordResetTickets.delete(trimmedToken); return res.status(404).json({ error: 'USER_NOT_FOUND' }); } console.error('⚠️ [Auth] Passwort-Zurücksetzung fehlgeschlagen:', error); res.status(500).json({ error: 'RECOVERY_RESET_FAILED' }); } }); app.get('/api/auth/session', (req, res) => { if (!hasSuperuser()) { return res.status(403).json({ error: 'SUPERUSER_REQUIRED' }); } if (!hasServer() || !hasApiKey()) { return res.status(403).json({ error: 'SETUP_REQUIRED' }); } const token = extractAuthToken(req); const session = getSessionRecord(token); if (!session) { clearAuthCookie(res); return res.status(401).json({ error: 'UNAUTHORIZED' }); } const payload = buildUserSessionPayload(session.userId); if (!payload) { activeSessions.delete(token); clearAuthCookie(res); return res.status(401).json({ error: 'UNAUTHORIZED' }); } touchSession(token); setAuthCookie(res, token); res.json(payload); }); app.get('/api/users', requirePermission('users-access', 'read'), (req, res) => { try { const users = listUsers(); res.json({ items: users, total: users.length }); } catch (error) { console.error('⚠️ [Users] Abruf der Benutzerliste fehlgeschlagen:', error); res.status(500).json({ error: 'USERS_FETCH_FAILED' }); } }); app.post('/api/users', requirePermission('users-edit', 'full'), (req, res) => { const { username, email, password, groupId, avatarColor } = req.body ?? {}; try { const user = createUser({ username, email, password, groupId, avatarColor }, { actor: req.user }); res.status(201).json({ item: user }); } catch (error) { if (error.code === 'USERNAME_REQUIRED' || error.code === 'INVALID_GROUP_ID') { return res.status(400).json({ error: error.code }); } if (error.code === 'GROUP_NOT_FOUND') { return res.status(400).json({ error: 'GROUP_NOT_FOUND' }); } if (error.code === 'INVALID_EMAIL') { return res.status(400).json({ error: 'INVALID_EMAIL' }); } if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') { return res.status(400).json({ error: error.code }); } if (error.code === 'USERNAME_TAKEN' || error.code === 'EMAIL_TAKEN') { return res.status(409).json({ error: error.code }); } console.error('⚠️ [Users] Anlegen eines Benutzers fehlgeschlagen:', error); res.status(500).json({ error: 'USER_CREATE_FAILED' }); } }); app.get('/api/users/me/security-phrase', (req, res) => { const userId = req.user?.id; if (!userId) { return res.status(401).json({ error: 'UNAUTHORIZED' }); } try { const phrase = getUserSecurityPhrase(userId, { actor: req.user }); if (phrase.downloadedAt) { return res.status(409).json({ error: 'SECURITY_PHRASE_ALREADY_DOWNLOADED', downloadedAt: phrase.downloadedAt }); } res.json({ item: { userId: phrase.userId, words: phrase.words, downloadedAt: phrase.downloadedAt } }); } catch (error) { if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } console.error(`⚠️ [Users] Sicherheitsschlüssel konnte für Benutzer ${userId} nicht geladen werden:`, error); res.status(500).json({ error: 'USER_SECURITY_PHRASE_FETCH_FAILED' }); } }); app.post('/api/users/me/security-phrase/downloaded', (req, res) => { const userId = req.user?.id; if (!userId) { return res.status(401).json({ error: 'UNAUTHORIZED' }); } try { const result = markSecurityPhraseDownloaded(userId, { actor: req.user }); const sessionPayload = buildUserSessionPayload(userId); res.json({ item: { userId: result.userId, downloadedAt: result.downloadedAt }, session: sessionPayload }); } catch (error) { if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } console.error(`⚠️ [Users] Sicherheits-Download konnte nicht vermerkt werden (${userId}):`, error); res.status(500).json({ error: 'USER_SECURITY_PHRASE_DOWNLOAD_FAILED' }); } }); app.get('/api/users/:userId', requirePermission('users-access', 'read'), (req, res) => { const { userId } = req.params; const numericId = Number(userId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_USER_ID' }); } try { const user = getUserById(numericId); if (!user) { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } res.json({ item: user }); } catch (error) { console.error(`⚠️ [Users] Abruf der Benutzerdetails fehlgeschlagen (${userId}):`, error); res.status(500).json({ error: 'USER_FETCH_FAILED' }); } }); app.get('/api/users/:userId/security-phrase', requirePermission('users-security-phrase', 'read'), (req, res) => { const { userId } = req.params; const numericId = Number(userId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_USER_ID' }); } try { const phrase = getUserSecurityPhrase(numericId, { actor: req.user }); res.json({ item: phrase }); } catch (error) { if (error.code === 'INVALID_USER_ID') { return res.status(400).json({ error: 'INVALID_USER_ID' }); } if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } console.error(`⚠️ [Users] Sicherheitsschlüssel konnte nicht geladen werden (${userId}):`, error); res.status(500).json({ error: 'USER_SECURITY_PHRASE_FETCH_FAILED' }); } }); app.post('/api/users/:userId/security-phrase/renew', requirePermission('users-security-phrase', 'full'), (req, res) => { const { userId } = req.params; const numericId = Number(userId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_USER_ID' }); } try { const renewed = renewUserSecurityPhrase(numericId, { actor: req.user }); res.json({ item: renewed }); } catch (error) { if (error.code === 'INVALID_USER_ID') { return res.status(400).json({ error: 'INVALID_USER_ID' }); } if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } console.error(`⚠️ [Users] Sicherheitsschlüssel konnte nicht erneuert werden (${userId}):`, error); res.status(500).json({ error: 'USER_SECURITY_PHRASE_RENEW_FAILED' }); } }); app.put('/api/users/:userId', requirePermission('users-edit', 'full'), (req, res) => { const { userId } = req.params; const numericId = Number(userId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_USER_ID' }); } const { username, email, password, avatarColor, groupId, groupIds } = req.body ?? {}; try { const updatedUser = updateUserDetails(numericId, { username, email, password, avatarColor, groupId, groupIds }); res.json({ item: updatedUser }); } catch (error) { if (error.code === 'INVALID_USER_ID') { return res.status(400).json({ error: 'INVALID_USER_ID' }); } if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } if (error.code === 'USERNAME_REQUIRED') { return res.status(400).json({ error: 'USERNAME_REQUIRED' }); } if (error.code === 'INVALID_EMAIL') { return res.status(400).json({ error: 'INVALID_EMAIL' }); } if (error.code === 'USERNAME_TAKEN') { return res.status(409).json({ error: 'USERNAME_TAKEN' }); } if (error.code === 'EMAIL_TAKEN') { return res.status(409).json({ error: 'EMAIL_TAKEN' }); } if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') { return res.status(400).json({ error: error.code }); } if (error.code === 'INVALID_AVATAR_COLOR') { return res.status(400).json({ error: 'INVALID_AVATAR_COLOR' }); } if (error.code === 'GROUP_NOT_FOUND') { return res.status(400).json({ error: 'GROUP_NOT_FOUND', details: error.missingGroupIds || [] }); } if (error.code === 'GROUP_SUPERUSER_PROTECTED') { return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' }); } console.error(`⚠️ [Users] Aktualisierung der Benutzerdaten fehlgeschlagen (${userId}):`, error); res.status(500).json({ error: 'USER_UPDATE_FAILED' }); } }); app.put('/api/users/:userId/groups', requirePermission('users-edit', 'full'), (req, res) => { const { userId } = req.params; const numericId = Number(userId); const { groupIds } = req.body ?? {}; if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_USER_ID' }); } try { const updatedUser = updateUserGroups(numericId, Array.isArray(groupIds) ? groupIds : []); res.json({ item: updatedUser }); } catch (error) { if (error.code === 'INVALID_USER_ID') { return res.status(400).json({ error: 'INVALID_USER_ID' }); } if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } if (error.code === 'GROUP_NOT_FOUND') { return res.status(400).json({ error: 'GROUP_NOT_FOUND', details: error.missingGroupIds || [] }); } console.error(`⚠️ [Users] Aktualisierung der Gruppenzuordnung fehlgeschlagen (${userId}):`, error); res.status(500).json({ error: 'USER_GROUPS_UPDATE_FAILED' }); } }); app.delete('/api/users/:userId', requirePermission('users-delete', 'full'), (req, res) => { const { userId } = req.params; const numericId = Number(userId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_USER_ID' }); } try { deleteUser(numericId); res.json({ success: true }); } catch (error) { if (error.code === 'INVALID_USER_ID') { return res.status(400).json({ error: 'INVALID_USER_ID' }); } if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } if (error.code === 'USER_SUPERUSER_PROTECTED') { return res.status(403).json({ error: 'USER_SUPERUSER_PROTECTED' }); } console.error(`⚠️ [Users] Löschen fehlgeschlagen (${userId}):`, error); res.status(500).json({ error: 'USER_DELETE_FAILED' }); } }); app.put('/api/users/:userId/active', requirePermission('users-edit', 'full'), (req, res) => { const { userId } = req.params; const numericId = Number(userId); const { isActive } = req.body ?? {}; if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_USER_ID' }); } try { const updatedUser = updateUserActiveStatus(numericId, Boolean(isActive)); res.json({ item: updatedUser }); } catch (error) { if (error.code === 'INVALID_USER_ID') { return res.status(400).json({ error: 'INVALID_USER_ID' }); } if (error.code === 'USER_NOT_FOUND') { return res.status(404).json({ error: 'USER_NOT_FOUND' }); } if (error.code === 'USER_SUPERUSER_PROTECTED') { return res.status(403).json({ error: 'USER_SUPERUSER_PROTECTED' }); } console.error(`⚠️ [Users] Aktualisierung des Aktivstatus fehlgeschlagen (${userId}):`, error); res.status(500).json({ error: 'USER_STATUS_UPDATE_FAILED' }); } }); app.get( '/api/groups', requireAnyPermission([ { key: 'user-groups-access', level: 'read' }, { key: 'users-edit', level: 'read' } ]), (req, res) => { try { const groups = listGroups(); res.json({ items: groups, total: groups.length }); } catch (error) { console.error('⚠️ [Groups] Abruf der Benutzergruppenliste fehlgeschlagen:', error); res.status(500).json({ error: 'GROUPS_FETCH_FAILED' }); } } ); app.get( '/api/groups/:groupId', requireAnyPermission([ { key: 'user-groups-access', level: 'read' }, { key: 'users-edit', level: 'read' } ]), (req, res) => { const { groupId } = req.params; const numericId = Number(groupId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } try { const group = getGroupById(numericId); if (!group) { return res.status(404).json({ error: 'GROUP_NOT_FOUND' }); } res.json({ item: group }); } catch (error) { console.error(`⚠️ [Groups] Abruf der Gruppendetails fehlgeschlagen (${groupId}):`, error); res.status(500).json({ error: 'GROUP_FETCH_FAILED' }); } } ); app.get('/api/groups/:groupId/permissions', requirePermission('user-groups-edit', 'read'), (req, res) => { const { groupId } = req.params; const numericId = Number(groupId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } try { const group = getGroupById(numericId); if (!group) { return res.status(404).json({ error: 'GROUP_NOT_FOUND' }); } const sections = getPermissionStructure(); const isSuperuser = (group.name || '').toLowerCase() === SUPERUSER_GROUP_NAME; let values = {}; if (isSuperuser) { clearGroupPermissionValues(numericId); } else { values = getPermissionValuesByGroup(numericId); } res.json({ sections, values }); } catch (error) { console.error(`⚠️ [Groups] Abruf der Gruppenberechtigungen fehlgeschlagen (${groupId}):`, error); res.status(500).json({ error: 'GROUP_PERMISSIONS_FETCH_FAILED' }); } }); app.put('/api/groups/:groupId/permissions', requirePermission('user-groups-edit', 'full'), (req, res) => { const { groupId } = req.params; const numericId = Number(groupId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } try { const group = getGroupById(numericId); if (!group) { return res.status(404).json({ error: 'GROUP_NOT_FOUND' }); } const isSuperuser = (group.name || '').toLowerCase() === SUPERUSER_GROUP_NAME; if (isSuperuser) { clearGroupPermissionValues(numericId); return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' }); } const valuesPayload = req.body?.values ?? {}; const savedValues = saveGroupPermissionValues(numericId, valuesPayload); res.json({ success: true, values: savedValues }); } catch (error) { const serverError = error?.code; if (serverError === 'GROUP_NOT_FOUND') { return res.status(404).json({ error: 'GROUP_NOT_FOUND' }); } if (serverError === 'PERMISSION_INVALID_PAYLOAD') { return res.status(400).json({ error: 'PERMISSION_INVALID_PAYLOAD' }); } if (serverError === 'PERMISSION_INVALID_LEVEL') { return res.status(400).json({ error: 'PERMISSION_INVALID_LEVEL', permissionKey: error.permissionKey, level: error.level }); } if (serverError === 'PERMISSION_UNKNOWN_KEY') { return res.status(400).json({ error: 'PERMISSION_UNKNOWN_KEY', permissionKey: error.permissionKey }); } if (serverError === 'INVALID_GROUP_ID') { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } console.error(`⚠️ [Groups] Aktualisierung der Berechtigungen fehlgeschlagen (${groupId}):`, error); res.status(500).json({ error: 'GROUP_PERMISSIONS_UPDATE_FAILED' }); } }); app.post('/api/groups', requirePermission('user-groups-edit', 'full'), (req, res) => { const { name, description } = req.body ?? {}; try { const group = createGroup({ name, description }); res.status(201).json({ item: group }); } catch (error) { if (error.code === 'GROUP_NAME_REQUIRED') { return res.status(400).json({ error: 'GROUP_NAME_REQUIRED' }); } if (error.code === 'GROUP_NAME_TAKEN') { return res.status(409).json({ error: 'GROUP_NAME_TAKEN' }); } console.error('⚠️ [Groups] Anlegen der Benutzergruppe fehlgeschlagen:', error); res.status(500).json({ error: 'GROUP_CREATE_FAILED' }); } }); app.put('/api/groups/:groupId', requirePermission('user-groups-edit', 'full'), (req, res) => { const { groupId } = req.params; const numericId = Number(groupId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } const { name, description, avatarColor } = req.body ?? {}; try { const updatedGroup = updateGroupDetails(numericId, { name, description, avatarColor }); res.json({ item: updatedGroup }); } catch (error) { if (error.code === 'INVALID_GROUP_ID') { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } if (error.code === 'GROUP_NOT_FOUND') { return res.status(404).json({ error: 'GROUP_NOT_FOUND' }); } if (error.code === 'GROUP_NAME_REQUIRED') { return res.status(400).json({ error: 'GROUP_NAME_REQUIRED' }); } if (error.code === 'GROUP_NAME_TAKEN') { return res.status(409).json({ error: 'GROUP_NAME_TAKEN' }); } if (error.code === 'INVALID_AVATAR_COLOR') { return res.status(400).json({ error: 'INVALID_AVATAR_COLOR' }); } if (error.code === 'GROUP_SUPERUSER_PROTECTED') { return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' }); } console.error(`⚠️ [Groups] Aktualisierung der Gruppendaten fehlgeschlagen (${groupId}):`, error); res.status(500).json({ error: 'GROUP_UPDATE_FAILED' }); } }); app.delete('/api/groups/:groupId', requirePermission('user-groups-delete', 'full'), (req, res) => { const { groupId } = req.params; const numericId = Number(groupId); if (!Number.isFinite(numericId) || numericId <= 0) { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } try { deleteGroup(numericId); res.json({ success: true }); } catch (error) { if (error.code === 'INVALID_GROUP_ID') { return res.status(400).json({ error: 'INVALID_GROUP_ID' }); } if (error.code === 'GROUP_NOT_FOUND') { return res.status(404).json({ error: 'GROUP_NOT_FOUND' }); } if (error.code === 'GROUP_HAS_MEMBERS') { return res.status(409).json({ error: 'GROUP_HAS_MEMBERS', memberCount: error.memberCount || 0 }); } if (error.code === 'GROUP_SUPERUSER_PROTECTED') { return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' }); } console.error(`⚠️ [Groups] Löschen der Gruppe fehlgeschlagen (${groupId}):`, error); res.status(500).json({ error: 'GROUP_DELETE_FAILED' }); } }); // Superuser Setup app.get('/api/auth/superuser/status', (req, res) => { const exists = hasSuperuser(); const user = exists ? getSuperuserSummary() : null; res.json({ exists, user }); }); app.post('/api/auth/superuser/register', (req, res) => { if (hasSuperuser()) { return res.status(409).json({ error: 'SUPERUSER_EXISTS' }); } const { username, email, password } = req.body || {}; if (!username || !email || !password) { return res.status(400).json({ error: 'MISSING_FIELDS' }); } try { const user = registerSuperuser({ username, email, password }); res.status(201).json({ user }); } catch (error) { switch (error.code) { case 'USERNAME_REQUIRED': return res.status(400).json({ error: 'USERNAME_REQUIRED' }); case 'EMAIL_INVALID': return res.status(400).json({ error: 'EMAIL_INVALID' }); case 'PASSWORD_TOO_SHORT': return res.status(400).json({ error: 'PASSWORD_TOO_SHORT' }); case 'SUPERUSER_ALREADY_EXISTS': return res.status(409).json({ error: 'SUPERUSER_EXISTS' }); default: console.error('⚠️ Fehler beim Registrieren des Superusers:', error); return res.status(500).json({ error: 'INTERNAL_ERROR' }); } } }); app.delete('/api/auth/superuser', requirePermission('maintenance-superuser-delete', 'full'), (req, res) => { if (!hasSuperuser()) { return res.status(404).json({ error: 'SUPERUSER_NOT_FOUND' }); } try { const result = removeSuperuser(); res.json({ success: true, usersRemoved: result.usersRemoved, groupRemoved: result.groupRemoved }); } catch (error) { if (error.code === 'SUPERUSER_NOT_FOUND') { return res.status(404).json({ error: 'SUPERUSER_NOT_FOUND' }); } console.error('⚠️ Fehler beim Löschen des Superusers:', error); res.status(500).json({ error: 'INTERNAL_ERROR' }); } }); // Stacks abrufen app.get('/api/stacks', maintenanceGuard, async (req, res) => { console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet"); try { const { canonicalStacks, duplicates } = await loadStackCollections(); const duplicateNames = duplicates.map((entry) => entry.name); const duplicateNameSet = new Set(duplicateNames); if (duplicateNames.length) { console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${duplicateNames.join(', ')}`); } const currentSelfStackId = getSelfStackId(); const stacksWithStatus = await Promise.all( canonicalStacks.map(async (stack) => { try { const statusRes = await axiosInstance.get( `/api/stacks/${stack.Id}/images_status?refresh=true` ); const statusEmoji = statusRes.data.Status === 'outdated' ? '⚠️' : '✅'; const redeployMeta = redeployingStacks.get(String(stack.Id)); const redeployPhase = redeployMeta?.phase || null; return { ...stack, updateStatus: statusEmoji, redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED, redeployPhase, redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED, redeployDisabled: currentSelfStackId ? String(stack.Id) === currentSelfStackId : false, duplicateName: duplicateNameSet.has(stack.Name) }; } catch (err) { console.error(`❌ Fehler beim Abrufen des Status für Stack ${stack.Id}:`, err.message); const redeployMeta = redeployingStacks.get(String(stack.Id)); const redeployPhase = redeployMeta?.phase || null; return { ...stack, updateStatus: '❌', redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED, redeployPhase, redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED, redeployDisabled: currentSelfStackId ? String(stack.Id) === currentSelfStackId : false, duplicateName: duplicateNameSet.has(stack.Name) }; } }) ); stacksWithStatus.sort((a, b) => a.Name.localeCompare(b.Name)); console.log(`✅ GET /api/stacks: Abruf erfolgreich, ${stacksWithStatus.length} Stacks geladen`); res.json(stacksWithStatus); } catch (err) { console.error(`❌ Fehler beim Abrufen der Stacks:`, err.message); if (err.message === 'PORTAINER_URL_NOT_CONFIGURED') { return res.status(503).json({ error: 'PORTAINER_URL_NOT_CONFIGURED' }); } res.status(500).json({ error: err.message }); } }); app.get('/api/maintenance/portainer-status', requirePermission('maintenance-portainer', 'read'), async (req, res) => { console.log("🧭 [Maintenance] GET /api/maintenance/portainer-status: Prüfung gestartet"); try { const payload = await fetchPortainerStatusSummary(); res.json(payload); } catch (err) { const message = err.response?.data?.message || err.message || 'Unbekannter Fehler'; console.error(`❌ [Maintenance] Fehler beim Prüfen des Portainer-Status: ${message}`); if (message === 'PORTAINER_URL_NOT_CONFIGURED') { return res.status(503).json({ error: 'PORTAINER_URL_NOT_CONFIGURED' }); } res.status(500).json({ error: message }); } }); app.post('/api/maintenance/mode', requirePermission('maintenance-access', 'full'), (req, res) => { try { const { active, message } = req.body ?? {}; const normalizedMessage = typeof message === 'string' && message.trim() ? message.trim() : null; const nextState = active ? activateMaintenanceMode({ message: normalizedMessage }) : deactivateMaintenanceMode({ message: normalizedMessage }); res.json({ success: true, maintenance: nextState }); } catch (err) { const errorMessage = err.response?.data?.error || err.message || 'Fehler beim Aktualisieren des Wartungsmodus'; console.error('❌ [Maintenance] Fehler beim Ändern des Wartungsmodus:', errorMessage); res.status(500).json({ error: errorMessage }); } }); app.get('/api/maintenance/config', requirePermission('maintenance-server-manage', 'read'), (req, res) => { const custom = getCustomPortainerScript(); const effective = getEffectivePortainerScript(); const ssh = getPortainerSshConfig(); res.json({ maintenance: getMaintenanceState(), update: getPortainerUpdateStatus(), script: { default: DEFAULT_PORTAINER_UPDATE_SCRIPT, custom: custom?.script ?? null, customUpdatedAt: custom?.updatedAt ?? null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt }, ssh: { host: ssh.host, port: ssh.port, username: ssh.username, extraSshArgs: ssh.extraSshArgs, passwordStored: Boolean(ssh.password) } }); }); app.put('/api/maintenance/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => { try { const config = savePortainerSshConfig(req.body || {}); res.json({ success: true, ssh: { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, passwordStored: Boolean(config.password) } }); } catch (err) { console.error('❌ [Maintenance] Fehler beim Speichern der SSH-Konfiguration:', err.message); res.status(400).json({ error: err.message || 'Ungültige SSH-Konfiguration' }); } }); app.delete('/api/maintenance/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => { const config = deletePortainerSshConfig(); res.json({ success: true, ssh: { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, passwordStored: false } }); }); app.post('/api/maintenance/test-ssh', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => { try { const override = req.body && Object.keys(req.body).length ? req.body : null; const result = await testSshConnection(override); res.json({ success: true, result }); } catch (err) { console.error('❌ [Maintenance] SSH Test fehlgeschlagen:', err.message); res.status(500).json({ error: err.message || 'SSH Test fehlgeschlagen' }); } }); app.put('/api/maintenance/update-script', requirePermission('maintenance-ssh-update', 'full'), (req, res) => { if (portainerUpdateState.running) { return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.', update: getPortainerUpdateStatus() }); } const incoming = req.body?.script; if (typeof incoming !== 'string') { return res.status(400).json({ error: 'Feld "script" (string) wird benötigt.' }); } try { saveCustomPortainerScript(incoming); const custom = getCustomPortainerScript(); const effective = getEffectivePortainerScript(); res.json({ success: true, script: { default: DEFAULT_PORTAINER_UPDATE_SCRIPT, custom: custom?.script ?? null, customUpdatedAt: custom?.updatedAt ?? null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt } }); } catch (err) { console.error('❌ [Maintenance] Fehler beim Speichern des Update-Skripts:', err.message); res.status(400).json({ error: err.message || 'Ungültiges Update-Skript' }); } }); app.delete('/api/maintenance/update-script', requirePermission('maintenance-ssh-update', 'full'), (req, res) => { if (portainerUpdateState.running) { return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.', update: getPortainerUpdateStatus() }); } try { saveCustomPortainerScript(''); const effective = getEffectivePortainerScript(); res.json({ success: true, script: { default: DEFAULT_PORTAINER_UPDATE_SCRIPT, custom: null, customUpdatedAt: null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt } }); } catch (err) { console.error('❌ [Maintenance] Fehler beim Zurücksetzen des Update-Skripts:', err.message); res.status(400).json({ error: err.message || 'Update-Skript konnte nicht entfernt werden' }); } }); app.get('/api/maintenance/update-status', requirePermission('maintenance-update', 'read'), (req, res) => { res.json({ maintenance: getMaintenanceState(), update: getPortainerUpdateStatus() }); }); app.post('/api/maintenance/portainer-update', requirePermission('maintenance-update', 'full'), async (req, res) => { if (portainerUpdateState.running) { return res.status(409).json({ error: 'Ein Portainer-Update läuft bereits.', update: getPortainerUpdateStatus() }); } if (isMaintenanceModeActive()) { return res.status(423).json({ error: 'Wartungsmodus ist bereits aktiv.', maintenance: getMaintenanceState() }); } const overrideScript = typeof req.body?.script === 'string' ? req.body.script : null; let scriptSource = 'default'; let scriptToRun = DEFAULT_PORTAINER_UPDATE_SCRIPT; if (overrideScript && overrideScript.trim().length) { scriptSource = 'override'; scriptToRun = overrideScript.replace(/\r?\n/g, '\n'); } else { const effective = getEffectivePortainerScript(); scriptSource = effective.source; scriptToRun = effective.script; } let targetVersion = null; try { const statusBefore = await fetchPortainerStatusSummary(); targetVersion = statusBefore?.latestVersion ?? statusBefore?.currentVersion ?? null; } catch (err) { console.warn('⚠️ [Maintenance] Konnte Portainer-Status vor Update nicht ermitteln:', err.message); } updatePortainerState({ running: true, status: 'running', stage: 'initializing', startedAt: new Date().toISOString(), finishedAt: null, targetVersion, scriptSource, message: 'Portainer Update wird vorbereitet', error: null, logs: [] }); addUpdateLog('Vorbereitung abgeschlossen, Update wird gestartet', 'info'); res.json({ success: true, maintenance: getMaintenanceState(), update: getPortainerUpdateStatus() }); currentPortainerUpdatePromise = performPortainerUpdate({ script: scriptToRun, scriptSource, targetVersion }).catch((err) => { console.error('❌ [Maintenance] Portainer Update Fehler:', err.message); }); }); app.get( '/api/maintenance/duplicates', maintenanceGuard, requirePermission('maintenance-duplicates', 'read'), async (req, res) => { console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet"); try { const { duplicates } = await loadStackCollections(); const payload = duplicates .sort((a, b) => a.name.localeCompare(b.name)) .map((entry) => ({ name: entry.name, canonical: { Id: entry.canonical.Id, Name: entry.canonical.Name, EndpointId: entry.canonical.EndpointId, Type: entry.canonical.Type, Created: entry.canonical.Created }, duplicates: entry.members.map((stack) => ({ Id: stack.Id, Name: stack.Name, EndpointId: stack.EndpointId, Type: stack.Type, Created: stack.Created })) })); res.json({ total: payload.length, items: payload }); } catch (err) { console.error(`❌ [Maintenance] Fehler beim Abrufen der Duplikate:`, err.message); res.status(500).json({ error: 'Fehler beim Abrufen der doppelten Stacks' }); } }); app.post( '/api/maintenance/duplicates/cleanup', maintenanceGuard, requirePermission('maintenance-duplicates', 'full'), async (req, res) => { const canonicalId = req.body?.canonicalId; const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : []; const duplicateIds = duplicateIdsInput .map((value) => String(value).trim()) .filter((value) => value.length > 0); if (!canonicalId) { return res.status(400).json({ error: 'canonicalId ist erforderlich' }); } if (!duplicateIds.length) { return res.status(400).json({ error: 'duplicateIds ist erforderlich' }); } const canonicalIdStr = String(canonicalId); console.log(`🧹 [Maintenance] Bereinigung angefordert für Stack ${canonicalIdStr}. Ziel-IDs: ${duplicateIds.join(', ')}`); try { const { duplicates } = await loadStackCollections(); const target = duplicates.find((entry) => String(entry.canonical.Id) === canonicalIdStr); if (!target) { return res.status(404).json({ error: 'Kein doppelter Stack für diese ID gefunden' }); } const duplicatesToDelete = target.members.filter((stack) => duplicateIds.includes(String(stack.Id))); if (!duplicatesToDelete.length) { return res.status(400).json({ error: 'Keine passenden Duplikate gefunden' }); } logStackEvent({ stackId: target.canonical.Id, stackName: target.canonical.Name, status: 'started', message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`, redeployType: REDEPLOY_TYPES.MAINTENANCE, metadata: { duplicateIds: duplicateIds } }); const results = []; const errors = []; for (const stack of duplicatesToDelete) { try { await axiosInstance.delete(`/api/stacks/${stack.Id}`, { params: { endpointId: stack.EndpointId } }); console.log(`🧹 [Maintenance] Stack entfernt: ${stack.Name} (${stack.Id})`); results.push({ id: stack.Id, name: stack.Name, status: 'deleted' }); } catch (err) { const message = err.response?.data?.message || err.message; console.error(`❌ [Maintenance] Fehler beim Entfernen von Stack ${stack.Id}:`, message); errors.push({ id: stack.Id, message }); results.push({ id: stack.Id, name: stack.Name, status: 'error', message }); } } if (errors.length) { const failedIds = errors.map((entry) => entry.id).join(', '); logStackEvent({ stackId: target.canonical.Id, stackName: target.canonical.Name, status: 'error', message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`, redeployType: REDEPLOY_TYPES.MAINTENANCE, metadata: { duplicateIds: duplicateIds } }); return res.status(500).json({ success: false, canonical: { id: target.canonical.Id, name: target.canonical.Name }, results }); } logStackEvent({ stackId: target.canonical.Id, stackName: target.canonical.Name, status: 'success', message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`, redeployType: REDEPLOY_TYPES.MAINTENANCE, metadata: { removedDuplicates: results.filter((entry) => entry.status === 'deleted').map((entry) => entry.id) } }); res.json({ success: true, canonical: { id: target.canonical.Id, name: target.canonical.Name }, removed: results.length, results }); } catch (err) { const message = err.response?.data?.message || err.message; console.error(`❌ [Maintenance] Fehler bei der Bereinigung:`, message); res.status(500).json({ error: message || 'Fehler bei der Bereinigung' }); } }); // Event-Logs abrufen app.get('/api/logs', requirePermission('logs-access', 'read'), (req, res) => { const perPageParam = req.query.perPage ?? req.query.limit; const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500); const page = Math.max(parseInt(req.query.page, 10) || 1, 1); const limit = perPage === 'all' ? undefined : perPage; const offset = perPage === 'all' ? 0 : (page - 1) * perPage; const { whereClause, params } = buildEventLogFilter(req.query); const baseQuery = ` SELECT id, timestamp, category, event_type AS eventType, action, status, severity, entity_type AS entityType, entity_id AS entityId, entity_name AS entityName, actor_type AS actorType, actor_id AS actorId, actor_name AS actorName, source, context_type AS contextType, context_id AS contextId, context_label AS contextLabel, message, metadata FROM event_logs ${whereClause} ORDER BY datetime(timestamp) DESC `; const countQuery = ` SELECT COUNT(*) as total FROM event_logs ${whereClause} `; const query = limit !== undefined ? `${baseQuery} LIMIT @limit OFFSET @offset` : baseQuery; if (limit !== undefined) { params.limit = limit; params.offset = offset; } try { const stmt = db.prepare(query); const rawLogs = stmt.all(params); const logs = rawLogs.map((row) => { const metadata = parseJsonColumn(row.metadata); const legacyStack = row.entityType === 'stack' ? (row.entityId ?? null) : null; const legacyStackName = row.entityType === 'stack' ? (row.entityName ?? row.entityId ?? null) : null; const serverContext = row.contextType === 'server' ? (row.contextId ?? null) : null; return { ...row, entityId: row.entityId ?? null, actorId: row.actorId ?? null, contextId: row.contextId ?? null, metadata, stackId: legacyStack, stackName: legacyStackName, redeployType: row.eventType ?? null, server: serverContext }; }); const total = db.prepare(countQuery).get(params)?.total ?? logs.length; res.json({ total, items: logs, page, perPage: perPage === 'all' ? 'all' : limit }); } catch (err) { console.error('❌ Fehler beim Abrufen der Event-Logs:', err.message); res.status(500).json({ error: 'Fehler beim Abrufen der Logs' }); } }); app.delete('/api/logs/:id', requirePermission('logs-delete', 'full'), (req, res) => { const id = Number(req.params.id); if (Number.isNaN(id)) { return res.status(400).json({ error: 'Ungültige ID' }); } try { const changes = deleteEventLogById(id); if (!changes) { return res.status(404).json({ error: 'Eintrag nicht gefunden' }); } res.json({ success: true, deleted: changes }); } catch (err) { console.error('❌ Fehler beim Löschen des Event-Logs:', err.message); res.status(500).json({ error: 'Fehler beim Löschen des Logs' }); } }); app.delete('/api/logs', requirePermission('logs-delete', 'full'), (req, res) => { try { const deleted = deleteEventLogsByFilters(req.query); res.json({ success: true, deleted }); } catch (err) { console.error('❌ Fehler beim Löschen der Event-Logs:', err.message); res.status(500).json({ error: 'Fehler beim Löschen der Logs' }); } }); app.get('/api/logs/export', requirePermission('logs-export', 'full'), (req, res) => { const format = (req.query.format || 'txt').toLowerCase(); if (!['txt', 'sql'].includes(format)) { return res.status(400).json({ error: 'Ungültiges Export-Format' }); } try { const payload = exportEventLogsByFilters(req.query, format); res.setHeader('Content-Type', payload.contentType); res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`); res.send(payload.content); } catch (err) { console.error('❌ Fehler beim Export der Event-Logs:', err.message); res.status(500).json({ error: 'Fehler beim Export der Logs' }); } }); // Einzel-Redeploy app.put('/api/stacks/:id/redeploy', requirePermission('stacks-redeploy-single', 'full'), async (req, res) => { const { id } = req.params; console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`); try { await redeployStackById(id, REDEPLOY_TYPES.SINGLE); console.log(`✅ PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`); res.json({ success: true, message: 'Stack redeployed' }); } catch (err) { const errorMessage = err.message || 'Unbekannter Fehler beim Redeploy'; console.error(`❌ Fehler beim Redeploy von Stack ${id}:`, errorMessage); res.status(500).json({ error: errorMessage }); } }); // Redeploy ALL app.put( '/api/stacks/redeploy-all', maintenanceGuard, requirePermission('stacks-redeploy-all', 'full'), async (req, res) => { console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`); const serverContextId = getActiveServerUrl() || null; try { const stacksRes = await axiosInstance.get('/api/stacks'); const filteredStacks = Array.isArray(stacksRes.data) ? stacksRes.data : []; console.log("📦 Redeploy ALL für folgende Stacks:"); filteredStacks.forEach(s => console.log(` - ${s.Name}`)); const { eligibleStacks, skippedStacks } = await filterOutdatedStacks(filteredStacks); if (skippedStacks.length) { skippedStacks.forEach((stack) => { console.log(`⏭️ Übersprungen (aktuell): ${stack.Name} (${stack.Id})`); }); } const stackSummaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`); const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks'; logEvent({ category: 'stack', eventType: REDEPLOY_TYPES.ALL, action: 'redeploy-alle', status: 'gestartet', entityType: 'bulk-operation', entityId: 'redeploy-alle', entityName: 'Redeploy ALLE', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: `Redeploy ALLE gestartet für: ${stackSummary}`, metadata: { stacks: stackSummaryList }, source: 'system' }); if (!eligibleStacks.length) { console.log('ℹ️ Keine veralteten Stacks für Redeploy ALL vorhanden'); return res.json({ success: true, message: 'Keine veralteten Stacks gefunden' }); } eligibleStacks.forEach((stack) => { broadcastRedeployStatus({ stackId: stack.Id, stackName: stack.Name, phase: REDEPLOY_PHASES.QUEUED }); }); for (const stack of eligibleStacks) { try { await redeployStackById(stack.Id, REDEPLOY_TYPES.ALL); console.log(`✅ Redeploy ALL -> Stack ${stack.Name} (${stack.Id}) erfolgreich`); } catch (err) { console.error(`❌ Redeploy ALL -> Stack ${stack.Name} (${stack.Id}) fehlgeschlagen:`, err.message); } } logEvent({ category: 'stack', eventType: REDEPLOY_TYPES.ALL, action: 'redeploy-alle', status: 'erfolgreich', entityType: 'bulk-operation', entityId: 'redeploy-alle', entityName: 'Redeploy ALLE', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: 'Redeploy ALLE abgeschlossen', metadata: { processedStacks: stackSummaryList }, source: 'system' }); res.json({ success: true, message: 'Redeploy ALL abgeschlossen' }); } catch (err) { const message = err.response?.data?.message || err.message; logEvent({ category: 'stack', eventType: REDEPLOY_TYPES.ALL, action: 'redeploy-alle', status: 'error', entityType: 'bulk-operation', entityId: 'redeploy-alle', entityName: 'Redeploy ALLE', contextType: serverContextId ? 'server' : null, contextId: serverContextId, message, source: 'system' }); console.error('❌ Fehler bei Redeploy ALLE:', message); res.status(500).json({ error: message }); } }); // Redeploy selection app.put( '/api/stacks/redeploy-selection', maintenanceGuard, requirePermission('stacks-redeploy-selection', 'full'), async (req, res) => { const { stackIds } = req.body || {}; const totalCount = Array.isArray(stackIds) ? stackIds.length : 0; console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${totalCount} Stacks)`); if (!Array.isArray(stackIds) || stackIds.length === 0) { return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' }); } const serverContextId = getActiveServerUrl() || null; try { const normalizedIds = stackIds.map((id) => String(id)); const { filteredStacks } = await loadStackCollections(); const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack])); const missingIds = normalizedIds.filter((id) => !stacksById.has(id)); if (missingIds.length) { return res.status(400).json({ error: `Ungültige Stack-IDs: ${missingIds.join(', ')}` }); } const selectedStacks = normalizedIds.map((id) => stacksById.get(id)).filter(Boolean); if (!selectedStacks.length) { return res.status(400).json({ error: 'Keine gültigen Stacks für Redeploy Auswahl gefunden' }); } const { eligibleStacks, skippedStacks } = await filterOutdatedStacks(selectedStacks); if (skippedStacks.length) { skippedStacks.forEach((stack) => { console.log(`⏭️ Redeploy Auswahl übersprungen (aktuell): ${stack.Name} (${stack.Id})`); }); } const summaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`); const summaryText = summaryList.length ? summaryList.join(', ') : 'keine Stacks'; logEvent({ category: 'stack', eventType: REDEPLOY_TYPES.SELECTION, action: 'redeploy-auswahl', status: 'gestartet', entityType: 'bulk-operation', entityId: 'redeploy-auswahl', entityName: `Redeploy Auswahl (${stackIds.length})`, contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: `Redeploy Auswahl gestartet für: ${summaryText}`, metadata: { requestedStackIds: normalizedIds }, source: 'system' }); if (!eligibleStacks.length) { logEvent({ category: 'stack', eventType: REDEPLOY_TYPES.SELECTION, action: 'redeploy-auswahl', status: 'erfolgreich', entityType: 'bulk-operation', entityId: 'redeploy-auswahl', entityName: `Redeploy Auswahl (${stackIds.length})`, contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks', metadata: { requestedStackIds: normalizedIds, skipped: true }, source: 'system' }); return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' }); } eligibleStacks.forEach((stack) => { broadcastRedeployStatus({ stackId: stack.Id, stackName: stack.Name, phase: REDEPLOY_PHASES.QUEUED }); }); for (const stack of eligibleStacks) { try { await redeployStackById(stack.Id, REDEPLOY_TYPES.SELECTION); console.log(`✅ Redeploy Auswahl -> Stack ${stack.Name} (${stack.Id}) erfolgreich`); } catch (err) { console.error(`❌ Redeploy Auswahl -> Stack ${stack.Name} (${stack.Id}) fehlgeschlagen:`, err.message); } } logEvent({ category: 'stack', eventType: REDEPLOY_TYPES.SELECTION, action: 'redeploy-auswahl', status: 'erfolgreich', entityType: 'bulk-operation', entityId: 'redeploy-auswahl', entityName: `Redeploy Auswahl (${stackIds.length})`, contextType: serverContextId ? 'server' : null, contextId: serverContextId, message: 'Redeploy Auswahl abgeschlossen', metadata: { processedStackIds: eligibleStacks.map((stack) => String(stack.Id)) }, source: 'system' }); res.json({ success: true, message: 'Redeploy Auswahl abgeschlossen' }); } catch (err) { const message = err.response?.data?.message || err.message; const normalized = Array.isArray(stackIds) ? stackIds.map((id) => String(id)) : []; logEvent({ category: 'stack', eventType: REDEPLOY_TYPES.SELECTION, action: 'redeploy-auswahl', status: 'fehler', entityType: 'bulk-operation', entityId: 'redeploy-auswahl', entityName: `Redeploy Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`, contextType: serverContextId ? 'server' : null, contextId: serverContextId, message, metadata: { requestedStackIds: normalized }, source: 'system' }); console.error('❌ Fehler bei Redeploy Auswahl:', message); res.status(500).json({ error: message }); } }); server.listen(PORT, () => { console.log(`🚀 Server läuft auf Port ${PORT}`); });