diff --git a/backend/index.js b/backend/index.js index 9814dce..46a7bd1 100644 --- a/backend/index.js +++ b/backend/index.js @@ -6,6 +6,9 @@ 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 { @@ -82,12 +85,13 @@ const REDEPLOY_TYPES = { const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null; const PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script'; +const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config'; const DEFAULT_PORTAINER_UPDATE_SCRIPT = [ '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' + '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'); let portainerUpdateState = { @@ -132,17 +136,19 @@ const getPortainerUpdateStatus = () => ({ const getCustomPortainerScript = () => { const row = getSetting(PORTAINER_SCRIPT_SETTING_KEY); if (!row) return null; - const value = typeof row.value === 'string' ? row.value.trim() : ''; - if (!value) return null; + const raw = typeof row.value === 'string' ? row.value : ''; + if (!raw.trim()) return null; return { - script: row.value, + script: normalizeScriptText(raw), updatedAt: row.updated_at || null }; }; +const normalizeScriptText = (script) => String(script ?? '').replace(/\r\n/g, '\n'); + const saveCustomPortainerScript = (script) => { - const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim(); - if (!normalized) { + const normalized = normalizeScriptText(script); + if (!normalized.trim()) { deleteSetting(PORTAINER_SCRIPT_SETTING_KEY); return null; } @@ -166,6 +172,295 @@ const getEffectivePortainerScript = () => { }; }; + +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: '', + extraSshArgs: [], + privateKey: '', + privateKeyPassphrase: '' +}; + +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 normalizePrivateKey = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.privateKey) => { + if (value === undefined) return fallback; + if (value === null) return ''; + if (typeof value !== 'string') return String(value); + return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); +}; + +const normalizePrivateKeyPassphrase = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.privateKeyPassphrase) => { + if (value === undefined) return fallback; + if (value === null) return ''; + return String(value); +}; + +const getPortainerSshConfig = () => { + const stored = getSetting(PORTAINER_SSH_CONFIG_KEY); + if (!stored) { + return { ...DEFAULT_PORTAINER_SSH_CONFIG }; + } + try { + const parsed = stored.value ? JSON.parse(stored.value) : {}; + const decryptedKey = parsed.privateKeyEncrypted ? decryptSensitive(parsed.privateKeyEncrypted) : ''; + return { + host: normalizeString(parsed.host), + port: normalizePort(parsed.port), + username: normalizeString(parsed.username), + extraSshArgs: normalizeExtraArgs(parsed.extraSshArgs), + privateKey: decryptedKey, + privateKeyPassphrase: parsed.privateKeyPassphraseEncrypted ? decryptSensitive(parsed.privateKeyPassphraseEncrypted) : '' + }; + } catch (err) { + console.warn('⚠️ [Maintenance] Konnte Portainer SSH Konfiguration nicht parsen:', err.message); + return { ...DEFAULT_PORTAINER_SSH_CONFIG }; + } +}; + +const persistPortainerSshConfig = (config) => { + const payload = { + host: config.host, + port: config.port, + username: config.username, + extraSshArgs: config.extraSshArgs, + privateKeyEncrypted: config.privateKey ? encryptSensitive(config.privateKey) : null, + privateKeyPassphraseEncrypted: config.privateKeyPassphrase ? encryptSensitive(config.privateKeyPassphrase) : null + }; + setSetting(PORTAINER_SSH_CONFIG_KEY, JSON.stringify(payload)); +}; + +const mergeSshConfig = (base, overrides = {}) => ({ + host: normalizeString(overrides.host, base.host), + port: normalizePort(overrides.port, base.port), + username: normalizeString(overrides.username, base.username), + extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs), + privateKey: normalizePrivateKey(overrides.privateKey, base.privateKey), + privateKeyPassphrase: normalizePrivateKeyPassphrase(overrides.privateKeyPassphrase, base.privateKeyPassphrase) +}); + +const savePortainerSshConfig = (payload = {}) => { + const current = getPortainerSshConfig(); + const next = mergeSshConfig(current, payload); + persistPortainerSshConfig(next); + return next; +}; + +const deletePortainerSshConfig = () => { + deleteSetting(PORTAINER_SSH_CONFIG_KEY); + return { ...DEFAULT_PORTAINER_SSH_CONFIG }; +}; + +const createTempPrivateKeyFile = (privateKey) => { + const normalized = normalizePrivateKey(privateKey, ''); + const content = normalized.endsWith('\n') ? normalized : `${normalized}\n`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackpulse-ssh-')); + const filePath = path.join(tmpDir, 'id_rsa'); + fs.writeFileSync(filePath, content, { mode: 0o600 }); + return { + filePath, + cleanup: () => { + try { + fs.unlinkSync(filePath); + } catch (err) { } + try { + fs.rmdirSync(tmpDir); + } catch (err) { } + } + }; +}; + +const createTempAskPassScript = (passphrase) => { + 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: passphrase + }, + 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' + ]; + + const cleanupTasks = []; + const registerCleanup = (fn) => { + if (typeof fn === 'function') { + cleanupTasks.push(fn); + } + }; + + if (!sshConfig.privateKeyPassphrase) { + args.push('-o', 'BatchMode=yes'); + } + + if (sshConfig.privateKey) { + const { filePath, cleanup: dispose } = createTempPrivateKeyFile(sshConfig.privateKey); + registerCleanup(dispose); + args.push('-i', filePath); + } + + if (sshConfig.extraSshArgs.length) { + const expanded = sshConfig.extraSshArgs.flatMap((entry) => tokenizeSshArgLine(entry)); + if (expanded.length) { + args.push(...expanded); + } + } + + let envOverrides = {}; + if (sshConfig.privateKeyPassphrase) { + const { env, cleanup: dispose } = createTempAskPassScript(sshConfig.privateKeyPassphrase); + envOverrides = { ...envOverrides, ...env }; + registerCleanup(dispose); + } + + 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 = () => getPortainerSshConfig(); + +const testSshConnection = async (configOverride = null) => { + const baseConfig = configOverride + ? mergeSshConfig(getPortainerSshConfig(), configOverride) + : ensureSshConfigReady(); + const hasHost = Boolean(baseConfig.host && baseConfig.username); + if (!hasHost) { + throw new Error('SSH-Konfiguration ist unvollständig (Host/Benutzer erforderlich).'); + } + + 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 containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, { @@ -367,23 +662,52 @@ const maintenanceGuard = (req, res, next) => { 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)); + text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).forEach((line) => addUpdateLog(line, level)); }; const executePortainerUpdateScript = async (script) => { - const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim(); - if (!normalized) { + const normalized = normalizeScriptText(script); + if (!normalized.trim()) { addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning'); return; } - return new Promise((resolve, reject) => { - const child = spawn('bash', ['-lc', `set -e\n${normalized}`], { - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'] + 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')); @@ -393,12 +717,19 @@ const executePortainerUpdateScript = async (script) => { if (code === 0) { resolve(); } else { - reject(new Error(`Update-Skript beendet mit Exit-Code ${code}`)); + 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 }) => { @@ -696,6 +1027,7 @@ app.get('/api/maintenance/portainer-status', async (req, res) => { app.get('/api/maintenance/config', (req, res) => { const custom = getCustomPortainerScript(); const effective = getEffectivePortainerScript(); + const ssh = getPortainerSshConfig(); res.json({ maintenance: getMaintenanceState(), @@ -707,10 +1039,64 @@ app.get('/api/maintenance/config', (req, res) => { effective: effective.script, source: effective.source, updatedAt: effective.updatedAt + }, + ssh: { + host: ssh.host, + port: ssh.port, + username: ssh.username, + extraSshArgs: ssh.extraSshArgs, + privateKeyStored: Boolean(ssh.privateKey), + privateKeyPassphraseStored: Boolean(ssh.privateKeyPassphrase) } }); }); +app.put('/api/maintenance/ssh-config', (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, + privateKeyStored: Boolean(config.privateKey), + privateKeyPassphraseStored: Boolean(config.privateKeyPassphrase) + } + }); + } 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', (req, res) => { + const config = deletePortainerSshConfig(); + res.json({ + success: true, + ssh: { + host: config.host, + port: config.port, + username: config.username, + extraSshArgs: config.extraSshArgs, + privateKeyStored: false, + privateKeyPassphraseStored: false + } + }); +}); + +app.post('/api/maintenance/test-ssh', 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', (req, res) => { if (portainerUpdateState.running) { return res.status(409).json({ diff --git a/frontend/src/Maintenance.jsx b/frontend/src/Maintenance.jsx index edaf9f8..e89ef16 100644 --- a/frontend/src/Maintenance.jsx +++ b/frontend/src/Maintenance.jsx @@ -32,7 +32,8 @@ const LOG_LEVEL_STYLES = { warning: "text-amber-300", error: "text-red-300", stdout: "text-gray-300", - stderr: "text-orange-300" + stderr: "text-orange-300", + debug: "text-slate-400" }; const formatLogTimestamp = (value) => { @@ -90,17 +91,30 @@ const resolveStackType = (type) => { return type ?? "-"; }; +const createEmptySshDraft = () => ({ + host: '', + port: '22', + username: '', + privateKey: '', + privateKeyPassphrase: '', + extraSshArgs: '' +}); + export default function Maintenance() { const { showToast } = useToast(); const { maintenance: maintenanceMeta, update: updateState, script: scriptConfig, + ssh: sshConfig, loading: maintenanceLoading, error: maintenanceError, triggerUpdate, saveScript, - resetScript + resetScript, + saveSshConfig, + deleteSshConfig, + testSshConnection } = useMaintenance(); const maintenanceActive = Boolean(maintenanceMeta?.active); @@ -112,12 +126,47 @@ export default function Maintenance() { const [updateActionLoading, setUpdateActionLoading] = useState(false); const [updateActionError, setUpdateActionError] = useState(""); + const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft()); + const [sshKeyStored, setSshKeyStored] = useState(false); + const [sshPassphraseStored, setSshPassphraseStored] = useState(false); + const [showPrivateKey, setShowPrivateKey] = useState(false); + const [showPassphrase, setShowPassphrase] = useState(false); + const [sshSaving, setSshSaving] = useState(false); + const [sshTesting, setSshTesting] = useState(false); + const [sshDeleting, setSshDeleting] = useState(false); + const [sshTestResult, setSshTestResult] = useState(null); + useEffect(() => { if (!scriptConfig) return; const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? ""; setScriptDraft(nextValue); }, [scriptConfig]); + useEffect(() => { + if (!sshConfig) { + setSshDraft(createEmptySshDraft()); + setSshKeyStored(false); + setSshPassphraseStored(false); + setShowPrivateKey(false); + setShowPassphrase(false); + setSshTestResult(null); + return; + } + setSshDraft({ + host: sshConfig.host ?? '', + port: String(sshConfig.port ?? '22'), + username: sshConfig.username ?? '', + privateKey: '', + privateKeyPassphrase: '', + extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : '' + }); + setSshKeyStored(Boolean(sshConfig.privateKeyStored)); + setSshPassphraseStored(Boolean(sshConfig.privateKeyPassphraseStored)); + setShowPrivateKey(false); + setShowPassphrase(false); + setSshTestResult(null); + }, [sshConfig]); + const scriptBaseline = useMemo(() => { if (!scriptConfig) return ""; if (scriptConfig.source === "custom" && typeof scriptConfig.custom === "string") { @@ -222,6 +271,45 @@ export default function Maintenance() { return { groups, duplicateCount }; }, [duplicates]); + const handleSshDraftChange = useCallback((field, value) => { + setSshDraft((prev) => ({ ...prev, [field]: value })); + if (field === 'privateKey') { + setSshKeyStored(false); + } + if (field === 'privateKeyPassphrase') { + setSshPassphraseStored(false); + } + }, []); + + const normalizedSshDraft = useMemo(() => { + const normalized = { + host: sshDraft.host.trim(), + port: Number.parseInt(sshDraft.port, 10) || 22, + username: sshDraft.username.trim(), + extraSshArgs: sshDraft.extraSshArgs + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean) + }; + + const rawKey = (sshDraft.privateKey ?? ''); + const sanitizedKey = rawKey.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + if (sanitizedKey) { + normalized.privateKey = sanitizedKey; + } else if (!sshKeyStored) { + normalized.privateKey = ''; + } + + const rawPassphrase = sshDraft.privateKeyPassphrase ?? ''; + if (rawPassphrase) { + normalized.privateKeyPassphrase = rawPassphrase; + } else if (!sshPassphraseStored) { + normalized.privateKeyPassphrase = ''; + } + + return normalized; + }, [sshDraft, sshKeyStored, sshPassphraseStored]); + const handleScriptSave = useCallback(async () => { if (!scriptConfig) return; try { @@ -257,6 +345,68 @@ export default function Maintenance() { } }, [resetScript, showToast]); + const handleSshSaveConfig = useCallback(async () => { + try { + setSshSaving(true); + await saveSshConfig(normalizedSshDraft); + setSshTestResult(null); + setShowPrivateKey(false); + setShowPassphrase(false); + showToast({ + variant: "success", + title: "SSH-Konfiguration gespeichert", + description: "Verbindungseinstellungen wurden aktualisiert." + }); + } catch (err) { + const message = err.response?.data?.error || err.message || "SSH-Konfiguration konnte nicht gespeichert werden"; + showToast({ variant: "error", title: "Speichern fehlgeschlagen", description: message }); + } finally { + setSshSaving(false); + } + }, [normalizedSshDraft, saveSshConfig, showToast]); + + const handleSshTestConnection = useCallback(async () => { + try { + setSshTesting(true); + const result = await testSshConnection(normalizedSshDraft); + setSshTestResult({ success: true, timestamp: new Date(), details: result?.result }); + showToast({ + variant: "success", + title: "SSH-Verbindung erfolgreich", + description: "Verbindung zum Portainer-Host wurde hergestellt." + }); + } catch (err) { + const message = err.response?.data?.error || err.message || "SSH-Verbindung fehlgeschlagen"; + setSshTestResult({ success: false, timestamp: new Date(), error: message }); + showToast({ variant: "error", title: "SSH-Test fehlgeschlagen", description: message }); + } finally { + setSshTesting(false); + } + }, [normalizedSshDraft, testSshConnection, showToast]); + + const handleSshDeleteConfig = useCallback(async () => { + try { + setSshDeleting(true); + await deleteSshConfig(); + setSshDraft(createEmptySshDraft()); + setSshKeyStored(false); + setSshPassphraseStored(false); + setShowPrivateKey(false); + setShowPassphrase(false); + setSshTestResult(null); + showToast({ + variant: "info", + title: "SSH-Konfiguration gelöscht", + description: "Die Verbindungseinstellungen wurden zurückgesetzt." + }); + } catch (err) { + const message = err.response?.data?.error || err.message || "SSH-Konfiguration konnte nicht gelöscht werden"; + showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: message }); + } finally { + setSshDeleting(false); + } + }, [deleteSshConfig, showToast]); + const handleTriggerUpdate = useCallback(async () => { if (maintenanceActive || updateRunning) { showToast({ @@ -575,7 +725,149 @@ export default function Maintenance() { )} -
+ {sshTestResult.success ? 'SSH-Verbindung erfolgreich.' : `SSH-Verbindung fehlgeschlagen: ${sshTestResult.error}`} +
+ )} +