Portainer Update

This commit is contained in:
root
2025-10-11 18:53:51 +00:00
parent d25d34b8cd
commit 56891182d5
2 changed files with 168 additions and 33 deletions
+115 -31
View File
@@ -91,7 +91,7 @@ const DEFAULT_PORTAINER_UPDATE_SCRIPT = [
'docker stop portainer', 'docker stop portainer',
'docker rm portainer', 'docker rm portainer',
'docker pull portainer/portainer-ee:lts', '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'); ].join('\n');
let portainerUpdateState = { let portainerUpdateState = {
@@ -136,17 +136,19 @@ const getPortainerUpdateStatus = () => ({
const getCustomPortainerScript = () => { const getCustomPortainerScript = () => {
const row = getSetting(PORTAINER_SCRIPT_SETTING_KEY); const row = getSetting(PORTAINER_SCRIPT_SETTING_KEY);
if (!row) return null; if (!row) return null;
const value = typeof row.value === 'string' ? row.value.trim() : ''; const raw = typeof row.value === 'string' ? row.value : '';
if (!value) return null; if (!raw.trim()) return null;
return { return {
script: row.value, script: normalizeScriptText(raw),
updatedAt: row.updated_at || null updatedAt: row.updated_at || null
}; };
}; };
const normalizeScriptText = (script) => String(script ?? '').replace(/\r\n/g, '\n');
const saveCustomPortainerScript = (script) => { const saveCustomPortainerScript = (script) => {
const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim(); const normalized = normalizeScriptText(script);
if (!normalized) { if (!normalized.trim()) {
deleteSetting(PORTAINER_SCRIPT_SETTING_KEY); deleteSetting(PORTAINER_SCRIPT_SETTING_KEY);
return null; return null;
} }
@@ -209,7 +211,8 @@ const DEFAULT_PORTAINER_SSH_CONFIG = {
port: 22, port: 22,
username: '', username: '',
extraSshArgs: [], extraSshArgs: [],
privateKey: '' privateKey: '',
privateKeyPassphrase: ''
}; };
const normalizeString = (value, fallback = '') => { const normalizeString = (value, fallback = '') => {
@@ -222,6 +225,23 @@ const normalizePort = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.port) => {
return Number.isNaN(parsed) ? fallback : parsed; 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 = []) => { const normalizeExtraArgs = (value, fallback = []) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map((entry) => String(entry).trim()).filter(Boolean); return value.map((entry) => String(entry).trim()).filter(Boolean);
@@ -242,6 +262,12 @@ const normalizePrivateKey = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.priv
return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); 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 getPortainerSshConfig = () => {
const stored = getSetting(PORTAINER_SSH_CONFIG_KEY); const stored = getSetting(PORTAINER_SSH_CONFIG_KEY);
if (!stored) { if (!stored) {
@@ -255,7 +281,8 @@ const getPortainerSshConfig = () => {
port: normalizePort(parsed.port), port: normalizePort(parsed.port),
username: normalizeString(parsed.username), username: normalizeString(parsed.username),
extraSshArgs: normalizeExtraArgs(parsed.extraSshArgs), extraSshArgs: normalizeExtraArgs(parsed.extraSshArgs),
privateKey: decryptedKey privateKey: decryptedKey,
privateKeyPassphrase: parsed.privateKeyPassphraseEncrypted ? decryptSensitive(parsed.privateKeyPassphraseEncrypted) : ''
}; };
} catch (err) { } catch (err) {
console.warn('⚠️ [Maintenance] Konnte Portainer SSH Konfiguration nicht parsen:', err.message); console.warn('⚠️ [Maintenance] Konnte Portainer SSH Konfiguration nicht parsen:', err.message);
@@ -269,7 +296,8 @@ const persistPortainerSshConfig = (config) => {
port: config.port, port: config.port,
username: config.username, username: config.username,
extraSshArgs: config.extraSshArgs, extraSshArgs: config.extraSshArgs,
privateKeyEncrypted: config.privateKey ? encryptSensitive(config.privateKey) : null privateKeyEncrypted: config.privateKey ? encryptSensitive(config.privateKey) : null,
privateKeyPassphraseEncrypted: config.privateKeyPassphrase ? encryptSensitive(config.privateKeyPassphrase) : null
}; };
setSetting(PORTAINER_SSH_CONFIG_KEY, JSON.stringify(payload)); setSetting(PORTAINER_SSH_CONFIG_KEY, JSON.stringify(payload));
}; };
@@ -279,7 +307,8 @@ const mergeSshConfig = (base, overrides = {}) => ({
port: normalizePort(overrides.port, base.port), port: normalizePort(overrides.port, base.port),
username: normalizeString(overrides.username, base.username), username: normalizeString(overrides.username, base.username),
extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs), extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs),
privateKey: normalizePrivateKey(overrides.privateKey, base.privateKey) privateKey: normalizePrivateKey(overrides.privateKey, base.privateKey),
privateKeyPassphrase: normalizePrivateKeyPassphrase(overrides.privateKeyPassphrase, base.privateKeyPassphrase)
}); });
const savePortainerSshConfig = (payload = {}) => { const savePortainerSshConfig = (payload = {}) => {
@@ -313,6 +342,29 @@ const createTempPrivateKeyFile = (privateKey) => {
}; };
}; };
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 buildSshCommandArgs = (config) => {
const sshConfig = mergeSshConfig(DEFAULT_PORTAINER_SSH_CONFIG, config); const sshConfig = mergeSshConfig(DEFAULT_PORTAINER_SSH_CONFIG, config);
if (!sshConfig.host) { if (!sshConfig.host) {
@@ -324,23 +376,52 @@ const buildSshCommandArgs = (config) => {
const args = [ const args = [
'-p', String(sshConfig.port), '-p', String(sshConfig.port),
'-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=no' '-o', 'StrictHostKeyChecking=no'
]; ];
let cleanup = () => {}; const cleanupTasks = [];
const registerCleanup = (fn) => {
if (typeof fn === 'function') {
cleanupTasks.push(fn);
}
};
if (!sshConfig.privateKeyPassphrase) {
args.push('-o', 'BatchMode=yes');
}
if (sshConfig.privateKey) { if (sshConfig.privateKey) {
const { filePath, cleanup: dispose } = createTempPrivateKeyFile(sshConfig.privateKey); const { filePath, cleanup: dispose } = createTempPrivateKeyFile(sshConfig.privateKey);
cleanup = dispose; registerCleanup(dispose);
args.push('-i', filePath); args.push('-i', filePath);
} }
if (sshConfig.extraSshArgs.length) { if (sshConfig.extraSshArgs.length) {
args.push(...sshConfig.extraSshArgs); 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}`); args.push(`${sshConfig.username}@${sshConfig.host}`);
return { args, sshConfig, cleanup };
const cleanup = () => {
while (cleanupTasks.length) {
const task = cleanupTasks.pop();
try {
task();
} catch (err) { }
}
};
return { args, sshConfig, cleanup, env: envOverrides };
}; };
const ensureSshConfigReady = () => getPortainerSshConfig(); const ensureSshConfigReady = () => getPortainerSshConfig();
@@ -354,12 +435,13 @@ const testSshConnection = async (configOverride = null) => {
throw new Error('SSH-Konfiguration ist unvollständig (Host/Benutzer erforderlich).'); throw new Error('SSH-Konfiguration ist unvollständig (Host/Benutzer erforderlich).');
} }
const { args, cleanup } = buildSshCommandArgs(baseConfig); const { args, env: envOverrides, cleanup } = buildSshCommandArgs(baseConfig);
const sshArgs = [...args, 'echo', '__PORTAINER_SSH_TEST__']; const sshArgs = [...args, 'echo', '__PORTAINER_SSH_TEST__'];
try { try {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn('ssh', sshArgs, { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); const childEnv = { ...process.env, ...envOverrides };
const child = spawn('ssh', sshArgs, { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; let output = '';
let errorOutput = ''; let errorOutput = '';
child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.stdout.on('data', (chunk) => { output += chunk.toString(); });
@@ -580,26 +662,25 @@ const maintenanceGuard = (req, res, next) => {
const logScriptOutput = (data, level) => { const logScriptOutput = (data, level) => {
if (!data) return; if (!data) return;
const text = data.toString(); const text = data.toString();
text.split(/\r?\n/) text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).forEach((line) => addUpdateLog(line, level));
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => addUpdateLog(line, level));
}; };
const executePortainerUpdateScript = async (script) => { const executePortainerUpdateScript = async (script) => {
const normalized = String(script ?? '').replace(/\r?\n/g, '\n').trim(); const normalized = normalizeScriptText(script);
if (!normalized) { if (!normalized.trim()) {
addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning'); addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning');
return; return;
} }
const scriptWithUnixNewlines = normalized.endsWith('\n') ? normalized : `${normalized}\n`;
const sshConfig = getPortainerSshConfig(); const sshConfig = getPortainerSshConfig();
const useSsh = Boolean(sshConfig.host && sshConfig.username); const useSsh = Boolean(sshConfig.host && sshConfig.username);
if (!useSsh) { if (!useSsh) {
addUpdateLog('Führe Update-Skript lokal auf dem StackPulse-Host aus.', 'info'); addUpdateLog('Führe Update-Skript lokal auf dem StackPulse-Host aus.', 'info');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn('bash', ['-lc', `set -e\n${normalized}`], { const child = spawn('bash', ['-lc', `set -e\n${scriptWithUnixNewlines}`], {
env: process.env, env: process.env,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe']
}); });
@@ -617,14 +698,15 @@ const executePortainerUpdateScript = async (script) => {
}); });
} }
const { args, cleanup } = buildSshCommandArgs(sshConfig); const { args, env: envOverrides, cleanup } = buildSshCommandArgs(sshConfig);
const sshArgs = [...args, 'bash', '-s']; const sshArgs = [...args, 'bash', '-s'];
addUpdateLog(`Verbinde zu ${sshConfig.username}@${sshConfig.host} für Update-Skript`, 'info'); addUpdateLog(`Verbinde zu ${sshConfig.username}@${sshConfig.host} für Update-Skript`, 'info');
const sshPromise = new Promise((resolve, reject) => { const sshPromise = new Promise((resolve, reject) => {
const childEnv = { ...process.env, ...envOverrides };
const child = spawn('ssh', sshArgs, { const child = spawn('ssh', sshArgs, {
env: process.env, env: childEnv,
stdio: ['pipe', 'pipe', 'pipe'] stdio: ['pipe', 'pipe', 'pipe']
}); });
@@ -640,8 +722,7 @@ const executePortainerUpdateScript = async (script) => {
}); });
child.stdin.write(`set -e child.stdin.write(`set -e
${normalized} ${scriptWithUnixNewlines}`);
`);
child.stdin.end(); child.stdin.end();
}); });
@@ -964,7 +1045,8 @@ app.get('/api/maintenance/config', (req, res) => {
port: ssh.port, port: ssh.port,
username: ssh.username, username: ssh.username,
extraSshArgs: ssh.extraSshArgs, extraSshArgs: ssh.extraSshArgs,
privateKeyStored: Boolean(ssh.privateKey) privateKeyStored: Boolean(ssh.privateKey),
privateKeyPassphraseStored: Boolean(ssh.privateKeyPassphrase)
} }
}); });
}); });
@@ -979,7 +1061,8 @@ app.put('/api/maintenance/ssh-config', (req, res) => {
port: config.port, port: config.port,
username: config.username, username: config.username,
extraSshArgs: config.extraSshArgs, extraSshArgs: config.extraSshArgs,
privateKeyStored: Boolean(config.privateKey) privateKeyStored: Boolean(config.privateKey),
privateKeyPassphraseStored: Boolean(config.privateKeyPassphrase)
} }
}); });
} catch (err) { } catch (err) {
@@ -997,7 +1080,8 @@ app.delete('/api/maintenance/ssh-config', (req, res) => {
port: config.port, port: config.port,
username: config.username, username: config.username,
extraSshArgs: config.extraSshArgs, extraSshArgs: config.extraSshArgs,
privateKeyStored: false privateKeyStored: false,
privateKeyPassphraseStored: false
} }
}); });
}); });
+53 -2
View File
@@ -32,7 +32,8 @@ const LOG_LEVEL_STYLES = {
warning: "text-amber-300", warning: "text-amber-300",
error: "text-red-300", error: "text-red-300",
stdout: "text-gray-300", stdout: "text-gray-300",
stderr: "text-orange-300" stderr: "text-orange-300",
debug: "text-slate-400"
}; };
const formatLogTimestamp = (value) => { const formatLogTimestamp = (value) => {
@@ -95,6 +96,7 @@ const createEmptySshDraft = () => ({
port: '22', port: '22',
username: '', username: '',
privateKey: '', privateKey: '',
privateKeyPassphrase: '',
extraSshArgs: '' extraSshArgs: ''
}); });
@@ -126,7 +128,9 @@ export default function Maintenance() {
const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft()); const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft());
const [sshKeyStored, setSshKeyStored] = useState(false); const [sshKeyStored, setSshKeyStored] = useState(false);
const [sshPassphraseStored, setSshPassphraseStored] = useState(false);
const [showPrivateKey, setShowPrivateKey] = useState(false); const [showPrivateKey, setShowPrivateKey] = useState(false);
const [showPassphrase, setShowPassphrase] = useState(false);
const [sshSaving, setSshSaving] = useState(false); const [sshSaving, setSshSaving] = useState(false);
const [sshTesting, setSshTesting] = useState(false); const [sshTesting, setSshTesting] = useState(false);
const [sshDeleting, setSshDeleting] = useState(false); const [sshDeleting, setSshDeleting] = useState(false);
@@ -142,7 +146,9 @@ export default function Maintenance() {
if (!sshConfig) { if (!sshConfig) {
setSshDraft(createEmptySshDraft()); setSshDraft(createEmptySshDraft());
setSshKeyStored(false); setSshKeyStored(false);
setSshPassphraseStored(false);
setShowPrivateKey(false); setShowPrivateKey(false);
setShowPassphrase(false);
setSshTestResult(null); setSshTestResult(null);
return; return;
} }
@@ -151,10 +157,13 @@ export default function Maintenance() {
port: String(sshConfig.port ?? '22'), port: String(sshConfig.port ?? '22'),
username: sshConfig.username ?? '', username: sshConfig.username ?? '',
privateKey: '', privateKey: '',
privateKeyPassphrase: '',
extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : '' extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : ''
}); });
setSshKeyStored(Boolean(sshConfig.privateKeyStored)); setSshKeyStored(Boolean(sshConfig.privateKeyStored));
setSshPassphraseStored(Boolean(sshConfig.privateKeyPassphraseStored));
setShowPrivateKey(false); setShowPrivateKey(false);
setShowPassphrase(false);
setSshTestResult(null); setSshTestResult(null);
}, [sshConfig]); }, [sshConfig]);
@@ -267,6 +276,9 @@ export default function Maintenance() {
if (field === 'privateKey') { if (field === 'privateKey') {
setSshKeyStored(false); setSshKeyStored(false);
} }
if (field === 'privateKeyPassphrase') {
setSshPassphraseStored(false);
}
}, []); }, []);
const normalizedSshDraft = useMemo(() => { const normalizedSshDraft = useMemo(() => {
@@ -288,8 +300,15 @@ export default function Maintenance() {
normalized.privateKey = ''; normalized.privateKey = '';
} }
const rawPassphrase = sshDraft.privateKeyPassphrase ?? '';
if (rawPassphrase) {
normalized.privateKeyPassphrase = rawPassphrase;
} else if (!sshPassphraseStored) {
normalized.privateKeyPassphrase = '';
}
return normalized; return normalized;
}, [sshDraft, sshKeyStored]); }, [sshDraft, sshKeyStored, sshPassphraseStored]);
const handleScriptSave = useCallback(async () => { const handleScriptSave = useCallback(async () => {
if (!scriptConfig) return; if (!scriptConfig) return;
@@ -332,6 +351,7 @@ export default function Maintenance() {
await saveSshConfig(normalizedSshDraft); await saveSshConfig(normalizedSshDraft);
setSshTestResult(null); setSshTestResult(null);
setShowPrivateKey(false); setShowPrivateKey(false);
setShowPassphrase(false);
showToast({ showToast({
variant: "success", variant: "success",
title: "SSH-Konfiguration gespeichert", title: "SSH-Konfiguration gespeichert",
@@ -370,7 +390,9 @@ export default function Maintenance() {
await deleteSshConfig(); await deleteSshConfig();
setSshDraft(createEmptySshDraft()); setSshDraft(createEmptySshDraft());
setSshKeyStored(false); setSshKeyStored(false);
setSshPassphraseStored(false);
setShowPrivateKey(false); setShowPrivateKey(false);
setShowPassphrase(false);
setSshTestResult(null); setSshTestResult(null);
showToast({ showToast({
variant: "info", variant: "info",
@@ -772,6 +794,35 @@ export default function Maintenance() {
</span> </span>
)} )}
</div> </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-passphrase" className="cursor-pointer">Passphrase</label>
<button
type="button"
onClick={() => setShowPassphrase((prev) => !prev)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="text-[11px] font-medium text-purple-300 transition hover:text-purple-200 disabled:opacity-50"
>
{showPassphrase ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
<input
id="maintenance-ssh-passphrase"
type={showPassphrase ? 'text' : 'password'}
value={sshDraft.privateKeyPassphrase}
onChange={(event) => handleSshDraftChange('privateKeyPassphrase', 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"
placeholder="optional"
autoComplete="off"
spellCheck={false}
/>
{sshPassphraseStored && !sshDraft.privateKeyPassphrase && (
<span className="text-[11px] text-gray-400">
Eine Passphrase ist gespeichert. Neuer Wert ersetzt sie oder lösche die Konfiguration unten.
</span>
)}
</div>
<label className="grid gap-1"> <label className="grid gap-1">
<span className="text-xs uppercase tracking-wide text-gray-400">Weitere SSH-Argumente</span> <span className="text-xs uppercase tracking-wide text-gray-400">Weitere SSH-Argumente</span>
<textarea <textarea