Portainer SSH Connection

This commit is contained in:
root
2025-10-10 20:10:42 +00:00
parent 96b1446292
commit d25d34b8cd
3 changed files with 572 additions and 8 deletions
+306 -4
View File
@@ -6,6 +6,9 @@ import http from 'http';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import path from 'path'; import path from 'path';
import fs from 'fs';
import os from 'os';
import crypto from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { db } from './db/index.js'; import { db } from './db/index.js';
import { import {
@@ -82,6 +85,7 @@ const REDEPLOY_TYPES = {
const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null; 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_SCRIPT_SETTING_KEY = 'portainer_update_script';
const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config';
const DEFAULT_PORTAINER_UPDATE_SCRIPT = [ const DEFAULT_PORTAINER_UPDATE_SCRIPT = [
'docker stop portainer', 'docker stop portainer',
@@ -166,6 +170,215 @@ 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: ''
};
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 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 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
};
} 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
};
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)
});
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 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', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=no'
];
let cleanup = () => {};
if (sshConfig.privateKey) {
const { filePath, cleanup: dispose } = createTempPrivateKeyFile(sshConfig.privateKey);
cleanup = dispose;
args.push('-i', filePath);
}
if (sshConfig.extraSshArgs.length) {
args.push(...sshConfig.extraSshArgs);
}
args.push(`${sshConfig.username}@${sshConfig.host}`);
return { args, sshConfig, cleanup };
};
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, cleanup } = buildSshCommandArgs(baseConfig);
const sshArgs = [...args, 'echo', '__PORTAINER_SSH_TEST__'];
try {
return await new Promise((resolve, reject) => {
const child = spawn('ssh', sshArgs, { env: process.env, 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 () => { const detectPortainerContainer = async () => {
try { try {
const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, { const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, {
@@ -380,10 +593,39 @@ const executePortainerUpdateScript = async (script) => {
return; return;
} }
return new Promise((resolve, reject) => { const sshConfig = getPortainerSshConfig();
const child = spawn('bash', ['-lc', `set -e\n${normalized}`], { 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${normalized}`], {
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, 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 child = spawn('ssh', sshArgs, {
env: process.env, env: process.env,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['pipe', 'pipe', 'pipe']
}); });
child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout')); child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout'));
@@ -393,12 +635,20 @@ const executePortainerUpdateScript = async (script) => {
if (code === 0) { if (code === 0) {
resolve(); resolve();
} else { } 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
${normalized}
`);
child.stdin.end();
}); });
return sshPromise.finally(() => cleanup());
}; };
let currentPortainerUpdatePromise = null; let currentPortainerUpdatePromise = null;
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => { const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
@@ -696,6 +946,7 @@ app.get('/api/maintenance/portainer-status', async (req, res) => {
app.get('/api/maintenance/config', (req, res) => { app.get('/api/maintenance/config', (req, res) => {
const custom = getCustomPortainerScript(); const custom = getCustomPortainerScript();
const effective = getEffectivePortainerScript(); const effective = getEffectivePortainerScript();
const ssh = getPortainerSshConfig();
res.json({ res.json({
maintenance: getMaintenanceState(), maintenance: getMaintenanceState(),
@@ -707,10 +958,61 @@ app.get('/api/maintenance/config', (req, res) => {
effective: effective.script, effective: effective.script,
source: effective.source, source: effective.source,
updatedAt: effective.updatedAt updatedAt: effective.updatedAt
},
ssh: {
host: ssh.host,
port: ssh.port,
username: ssh.username,
extraSshArgs: ssh.extraSshArgs,
privateKeyStored: Boolean(ssh.privateKey)
} }
}); });
}); });
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)
}
});
} 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
}
});
});
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) => { app.put('/api/maintenance/update-script', (req, res) => {
if (portainerUpdateState.running) { if (portainerUpdateState.running) {
return res.status(409).json({ return res.status(409).json({
+243 -2
View File
@@ -90,17 +90,29 @@ const resolveStackType = (type) => {
return type ?? "-"; return type ?? "-";
}; };
const createEmptySshDraft = () => ({
host: '',
port: '22',
username: '',
privateKey: '',
extraSshArgs: ''
});
export default function Maintenance() { export default function Maintenance() {
const { showToast } = useToast(); const { showToast } = useToast();
const { const {
maintenance: maintenanceMeta, maintenance: maintenanceMeta,
update: updateState, update: updateState,
script: scriptConfig, script: scriptConfig,
ssh: sshConfig,
loading: maintenanceLoading, loading: maintenanceLoading,
error: maintenanceError, error: maintenanceError,
triggerUpdate, triggerUpdate,
saveScript, saveScript,
resetScript resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
} = useMaintenance(); } = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active); const maintenanceActive = Boolean(maintenanceMeta?.active);
@@ -112,12 +124,40 @@ export default function Maintenance() {
const [updateActionLoading, setUpdateActionLoading] = useState(false); const [updateActionLoading, setUpdateActionLoading] = useState(false);
const [updateActionError, setUpdateActionError] = useState(""); const [updateActionError, setUpdateActionError] = useState("");
const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft());
const [sshKeyStored, setSshKeyStored] = useState(false);
const [showPrivateKey, setShowPrivateKey] = useState(false);
const [sshSaving, setSshSaving] = useState(false);
const [sshTesting, setSshTesting] = useState(false);
const [sshDeleting, setSshDeleting] = useState(false);
const [sshTestResult, setSshTestResult] = useState(null);
useEffect(() => { useEffect(() => {
if (!scriptConfig) return; if (!scriptConfig) return;
const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? ""; const nextValue = scriptConfig.custom ?? scriptConfig.default ?? scriptConfig.effective ?? "";
setScriptDraft(nextValue); setScriptDraft(nextValue);
}, [scriptConfig]); }, [scriptConfig]);
useEffect(() => {
if (!sshConfig) {
setSshDraft(createEmptySshDraft());
setSshKeyStored(false);
setShowPrivateKey(false);
setSshTestResult(null);
return;
}
setSshDraft({
host: sshConfig.host ?? '',
port: String(sshConfig.port ?? '22'),
username: sshConfig.username ?? '',
privateKey: '',
extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : ''
});
setSshKeyStored(Boolean(sshConfig.privateKeyStored));
setShowPrivateKey(false);
setSshTestResult(null);
}, [sshConfig]);
const scriptBaseline = useMemo(() => { const scriptBaseline = useMemo(() => {
if (!scriptConfig) return ""; if (!scriptConfig) return "";
if (scriptConfig.source === "custom" && typeof scriptConfig.custom === "string") { if (scriptConfig.source === "custom" && typeof scriptConfig.custom === "string") {
@@ -222,6 +262,35 @@ export default function Maintenance() {
return { groups, duplicateCount }; return { groups, duplicateCount };
}, [duplicates]); }, [duplicates]);
const handleSshDraftChange = useCallback((field, value) => {
setSshDraft((prev) => ({ ...prev, [field]: value }));
if (field === 'privateKey') {
setSshKeyStored(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 = '';
}
return normalized;
}, [sshDraft, sshKeyStored]);
const handleScriptSave = useCallback(async () => { const handleScriptSave = useCallback(async () => {
if (!scriptConfig) return; if (!scriptConfig) return;
try { try {
@@ -257,6 +326,65 @@ export default function Maintenance() {
} }
}, [resetScript, showToast]); }, [resetScript, showToast]);
const handleSshSaveConfig = useCallback(async () => {
try {
setSshSaving(true);
await saveSshConfig(normalizedSshDraft);
setSshTestResult(null);
setShowPrivateKey(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);
setShowPrivateKey(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 () => { const handleTriggerUpdate = useCallback(async () => {
if (maintenanceActive || updateRunning) { if (maintenanceActive || updateRunning) {
showToast({ showToast({
@@ -575,7 +703,120 @@ export default function Maintenance() {
</div> </div>
)} )}
<div className="mt-6 grid gap-6 lg:grid-cols-2"> <div className="mt-6 grid gap-6 lg:grid-cols-2 xl:grid-cols-3">
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">SSH-Verbindung</h3>
</div>
<div className="mt-3 grid gap-3 text-sm text-gray-200">
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Host</span>
<input
type="text"
value={sshDraft.host}
onChange={(event) => handleSshDraftChange('host', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm text-gray-100 focus:border-purple-500 focus:outline-none"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Port</span>
<input
type="number"
min="1"
value={sshDraft.port}
onChange={(event) => handleSshDraftChange('port', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm text-gray-100 focus:border-purple-500 focus:outline-none"
/>
</label>
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Benutzer</span>
<input
type="text"
value={sshDraft.username}
onChange={(event) => handleSshDraftChange('username', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm text-gray-100 focus:border-purple-500 focus:outline-none"
/>
</label>
</div>
<div className="grid gap-1">
<div className="flex items-center justify-between text-xs uppercase tracking-wide text-gray-400">
<label htmlFor="maintenance-ssh-private-key" className="cursor-pointer">Privater SSH-Key</label>
<button
type="button"
onClick={() => setShowPrivateKey((prev) => !prev)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="text-[11px] font-medium text-purple-300 transition hover:text-purple-200 disabled:opacity-50"
>
{showPrivateKey ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
<textarea
id="maintenance-ssh-private-key"
rows={6}
value={sshDraft.privateKey}
onChange={(event) => handleSshDraftChange('privateKey', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 font-mono text-xs text-gray-100 focus:border-purple-500 focus:outline-none"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
autoComplete="off"
spellCheck={false}
style={showPrivateKey ? undefined : { WebkitTextSecurity: 'disc', MozTextSecurity: 'disc' }}
/>
{sshKeyStored && !sshDraft.privateKey && (
<span className="text-[11px] text-gray-400">
Ein privater Schlüssel ist gespeichert. Neuer Inhalt ersetzt ihn oder lösche die Konfiguration unten.
</span>
)}
</div>
<label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Weitere SSH-Argumente</span>
<textarea
rows={3}
value={sshDraft.extraSshArgs}
onChange={(event) => handleSshDraftChange('extraSshArgs', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 font-mono text-[11px] text-gray-100 focus:border-purple-500 focus:outline-none"
placeholder="je Zeile ein Argument (optional)"
/>
</label>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={handleSshSaveConfig}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-purple-500 disabled:opacity-50"
>
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
</button>
<button
type="button"
onClick={handleSshTestConnection}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="rounded-lg border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 disabled:opacity-50"
>
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
</button>
<button
type="button"
onClick={handleSshDeleteConfig}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="rounded-lg border border-red-600 px-4 py-2 text-sm font-medium text-red-200 transition hover:bg-red-900/40 disabled:opacity-50"
>
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
</button>
</div>
{sshTestResult && (
<p className={`mt-3 text-xs ${sshTestResult.success ? 'text-green-300' : 'text-red-300'}`}>
{sshTestResult.success ? 'SSH-Verbindung erfolgreich.' : `SSH-Verbindung fehlgeschlagen: ${sshTestResult.error}`}
</p>
)}
</div>
<div className="rounded-md border border-gray-700 bg-gray-900/40 p-4"> <div className="rounded-md border border-gray-700 bg-gray-900/40 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Update-Skript</h3> <h3 className="text-sm font-semibold text-white">Update-Skript</h3>
+23 -2
View File
@@ -7,6 +7,7 @@ const INITIAL_STATE = {
maintenance: null, maintenance: null,
update: null, update: null,
script: null, script: null,
ssh: null,
loading: true, loading: true,
error: "", error: "",
lastUpdated: null lastUpdated: null
@@ -33,6 +34,7 @@ export default function MaintenanceProvider({ children }) {
maintenance: data.maintenance ?? null, maintenance: data.maintenance ?? null,
update: data.update ?? null, update: data.update ?? null,
script: data.script ?? null, script: data.script ?? null,
ssh: data.ssh ?? null,
loading: false, loading: false,
error: "", error: "",
lastUpdated: new Date() lastUpdated: new Date()
@@ -102,10 +104,26 @@ export default function MaintenanceProvider({ children }) {
await fetchConfig(); await fetchConfig();
}, [fetchConfig]); }, [fetchConfig]);
const saveSshConfig = useCallback(async (config) => {
await axios.put("/api/maintenance/ssh-config", config);
await fetchConfig();
}, [fetchConfig]);
const deleteSshConfig = useCallback(async () => {
await axios.delete("/api/maintenance/ssh-config");
await fetchConfig();
}, [fetchConfig]);
const testSshConnection = useCallback(async (config) => {
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
return response.data;
}, []);
const value = useMemo(() => ({ const value = useMemo(() => ({
maintenance: state.maintenance, maintenance: state.maintenance,
update: state.update, update: state.update,
script: state.script, script: state.script,
ssh: state.ssh,
loading: state.loading, loading: state.loading,
error: state.error, error: state.error,
lastUpdated: state.lastUpdated, lastUpdated: state.lastUpdated,
@@ -113,8 +131,11 @@ export default function MaintenanceProvider({ children }) {
refreshUpdateStatus, refreshUpdateStatus,
triggerUpdate, triggerUpdate,
saveScript, saveScript,
resetScript resetScript,
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript]); saveSshConfig,
deleteSshConfig,
testSshConnection
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
return ( return (
<MaintenanceContext.Provider value={value}> <MaintenanceContext.Provider value={value}>