This commit is contained in:
root
2025-10-12 20:39:48 +00:00
parent 5fcc7fdbf1
commit 1d0ece6940
2 changed files with 57 additions and 145 deletions
+27 -63
View File
@@ -238,9 +238,8 @@ const DEFAULT_PORTAINER_SSH_CONFIG = {
host: '', host: '',
port: 22, port: 22,
username: '', username: '',
extraSshArgs: [], password: '',
privateKey: '', extraSshArgs: []
privateKeyPassphrase: ''
}; };
const normalizeString = (value, fallback = '') => { const normalizeString = (value, fallback = '') => {
@@ -283,14 +282,7 @@ const normalizeExtraArgs = (value, fallback = []) => {
return Array.isArray(fallback) ? [...fallback] : []; return Array.isArray(fallback) ? [...fallback] : [];
}; };
const normalizePrivateKey = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.privateKey) => { const normalizePassword = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.password) => {
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 === undefined) return fallback;
if (value === null) return ''; if (value === null) return '';
return String(value); return String(value);
@@ -303,14 +295,14 @@ const getPortainerSshConfig = () => {
} }
try { try {
const parsed = stored.value ? JSON.parse(stored.value) : {}; const parsed = stored.value ? JSON.parse(stored.value) : {};
const decryptedKey = parsed.privateKeyEncrypted ? decryptSensitive(parsed.privateKeyEncrypted) : ''; const decryptedPassword = parsed.passwordEncrypted ? decryptSensitive(parsed.passwordEncrypted) : null;
const fallbackPassword = parsed.password !== undefined ? parsed.password : null;
return { return {
host: normalizeString(parsed.host), host: normalizeString(parsed.host),
port: normalizePort(parsed.port), port: normalizePort(parsed.port),
username: normalizeString(parsed.username), username: normalizeString(parsed.username),
extraSshArgs: normalizeExtraArgs(parsed.extraSshArgs), password: normalizePassword(decryptedPassword ?? fallbackPassword),
privateKey: decryptedKey, extraSshArgs: normalizeExtraArgs(parsed.extraSshArgs)
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);
@@ -324,8 +316,7 @@ 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, passwordEncrypted: config.password ? encryptSensitive(config.password) : null
privateKeyPassphraseEncrypted: config.privateKeyPassphrase ? encryptSensitive(config.privateKeyPassphrase) : null
}; };
setSetting(PORTAINER_SSH_CONFIG_KEY, JSON.stringify(payload)); setSetting(PORTAINER_SSH_CONFIG_KEY, JSON.stringify(payload));
}; };
@@ -334,9 +325,8 @@ const mergeSshConfig = (base, overrides = {}) => ({
host: normalizeString(overrides.host, base.host), host: normalizeString(overrides.host, base.host),
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), password: normalizePassword(overrides.password, base.password),
privateKey: normalizePrivateKey(overrides.privateKey, base.privateKey), extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs)
privateKeyPassphrase: normalizePrivateKeyPassphrase(overrides.privateKeyPassphrase, base.privateKeyPassphrase)
}); });
const savePortainerSshConfig = (payload = {}) => { const savePortainerSshConfig = (payload = {}) => {
@@ -351,36 +341,17 @@ const deletePortainerSshConfig = () => {
return { ...DEFAULT_PORTAINER_SSH_CONFIG }; return { ...DEFAULT_PORTAINER_SSH_CONFIG };
}; };
const createTempPrivateKeyFile = (privateKey) => { const createTempAskPassScript = (secret) => {
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 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackpulse-askpass-'));
const scriptPath = path.join(tmpDir, 'askpass.sh'); const scriptPath = path.join(tmpDir, 'askpass.sh');
const scriptContent = "#!/bin/sh\nprintf '%s\\n' \"$STACKPULSE_SSH_PASS\"\n"; const scriptContent = "#!/bin/sh\nprintf '%s\n' \"$STACKPULSE_SSH_PASS\"\n";
fs.writeFileSync(scriptPath, scriptContent, { mode: 0o700 }); fs.writeFileSync(scriptPath, scriptContent, { mode: 0o700 });
return { return {
env: { env: {
SSH_ASKPASS: scriptPath, SSH_ASKPASS: scriptPath,
SSH_ASKPASS_REQUIRE: 'force', SSH_ASKPASS_REQUIRE: 'force',
DISPLAY: process.env.DISPLAY || ':9999', DISPLAY: process.env.DISPLAY || ':9999',
STACKPULSE_SSH_PASS: passphrase STACKPULSE_SSH_PASS: secret
}, },
cleanup: () => { cleanup: () => {
try { try {
@@ -404,7 +375,10 @@ const buildSshCommandArgs = (config) => {
const args = [ const args = [
'-p', String(sshConfig.port), '-p', String(sshConfig.port),
'-o', 'StrictHostKeyChecking=no' '-o', 'StrictHostKeyChecking=no',
'-o', 'PreferredAuthentications=password',
'-o', 'PubkeyAuthentication=no',
'-o', 'NumberOfPasswordPrompts=1'
]; ];
const cleanupTasks = []; const cleanupTasks = [];
@@ -414,14 +388,14 @@ const buildSshCommandArgs = (config) => {
} }
}; };
if (!sshConfig.privateKeyPassphrase) { let envOverrides = {};
args.push('-o', 'BatchMode=yes'); if (sshConfig.password) {
} const { env, cleanup: dispose } = createTempAskPassScript(sshConfig.password);
envOverrides = { ...envOverrides, ...env };
if (sshConfig.privateKey) {
const { filePath, cleanup: dispose } = createTempPrivateKeyFile(sshConfig.privateKey);
registerCleanup(dispose); registerCleanup(dispose);
args.push('-i', filePath); args.push('-o', 'BatchMode=no');
} else {
args.push('-o', 'BatchMode=yes');
} }
if (sshConfig.extraSshArgs.length) { if (sshConfig.extraSshArgs.length) {
@@ -431,13 +405,6 @@ const buildSshCommandArgs = (config) => {
} }
} }
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}`);
const cleanup = () => { const cleanup = () => {
@@ -1237,8 +1204,7 @@ 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), passwordStored: Boolean(ssh.password)
privateKeyPassphraseStored: Boolean(ssh.privateKeyPassphrase)
} }
}); });
}); });
@@ -1253,8 +1219,7 @@ 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), passwordStored: Boolean(config.password)
privateKeyPassphraseStored: Boolean(config.privateKeyPassphrase)
} }
}); });
} catch (err) { } catch (err) {
@@ -1272,8 +1237,7 @@ 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, passwordStored: false
privateKeyPassphraseStored: false
} }
}); });
}); });
+30 -82
View File
@@ -95,8 +95,7 @@ const createEmptySshDraft = () => ({
host: '', host: '',
port: '22', port: '22',
username: '', username: '',
privateKey: '', password: '',
privateKeyPassphrase: '',
extraSshArgs: '' extraSshArgs: ''
}); });
@@ -127,10 +126,8 @@ export default function Maintenance() {
const [updateActionError, setUpdateActionError] = useState(""); const [updateActionError, setUpdateActionError] = useState("");
const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft()); const [sshDraft, setSshDraft] = useState(() => createEmptySshDraft());
const [sshKeyStored, setSshKeyStored] = useState(false); const [sshPasswordStored, setSshPasswordStored] = useState(false);
const [sshPassphraseStored, setSshPassphraseStored] = useState(false); const [showPassword, setShowPassword] = 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);
@@ -145,10 +142,8 @@ export default function Maintenance() {
useEffect(() => { useEffect(() => {
if (!sshConfig) { if (!sshConfig) {
setSshDraft(createEmptySshDraft()); setSshDraft(createEmptySshDraft());
setSshKeyStored(false); setSshPasswordStored(false);
setSshPassphraseStored(false); setShowPassword(false);
setShowPrivateKey(false);
setShowPassphrase(false);
setSshTestResult(null); setSshTestResult(null);
return; return;
} }
@@ -156,14 +151,11 @@ export default function Maintenance() {
host: sshConfig.host ?? '', host: sshConfig.host ?? '',
port: String(sshConfig.port ?? '22'), port: String(sshConfig.port ?? '22'),
username: sshConfig.username ?? '', username: sshConfig.username ?? '',
privateKey: '', password: '',
privateKeyPassphrase: '',
extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : '' extraSshArgs: Array.isArray(sshConfig.extraSshArgs) ? sshConfig.extraSshArgs.join('\n') : ''
}); });
setSshKeyStored(Boolean(sshConfig.privateKeyStored)); setSshPasswordStored(Boolean(sshConfig.passwordStored));
setSshPassphraseStored(Boolean(sshConfig.privateKeyPassphraseStored)); setShowPassword(false);
setShowPrivateKey(false);
setShowPassphrase(false);
setSshTestResult(null); setSshTestResult(null);
}, [sshConfig]); }, [sshConfig]);
@@ -273,11 +265,8 @@ export default function Maintenance() {
const handleSshDraftChange = useCallback((field, value) => { const handleSshDraftChange = useCallback((field, value) => {
setSshDraft((prev) => ({ ...prev, [field]: value })); setSshDraft((prev) => ({ ...prev, [field]: value }));
if (field === 'privateKey') { if (field === 'password') {
setSshKeyStored(false); setSshPasswordStored(false);
}
if (field === 'privateKeyPassphrase') {
setSshPassphraseStored(false);
} }
}, []); }, []);
@@ -286,29 +275,21 @@ export default function Maintenance() {
host: sshDraft.host.trim(), host: sshDraft.host.trim(),
port: Number.parseInt(sshDraft.port, 10) || 22, port: Number.parseInt(sshDraft.port, 10) || 22,
username: sshDraft.username.trim(), username: sshDraft.username.trim(),
extraSshArgs: sshDraft.extraSshArgs extraSshArgs: (sshDraft.extraSshArgs || '')
.split(/\r?\n/) .split(/\r?\n/)
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter(Boolean) .filter(Boolean)
}; };
const rawKey = (sshDraft.privateKey ?? ''); const rawPassword = sshDraft.password ?? '';
const sanitizedKey = rawKey.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); if (rawPassword) {
if (sanitizedKey) { normalized.password = rawPassword;
normalized.privateKey = sanitizedKey; } else if (!sshPasswordStored) {
} else if (!sshKeyStored) { normalized.password = '';
normalized.privateKey = '';
}
const rawPassphrase = sshDraft.privateKeyPassphrase ?? '';
if (rawPassphrase) {
normalized.privateKeyPassphrase = rawPassphrase;
} else if (!sshPassphraseStored) {
normalized.privateKeyPassphrase = '';
} }
return normalized; return normalized;
}, [sshDraft, sshKeyStored, sshPassphraseStored]); }, [sshDraft, sshPasswordStored]);
const handleScriptSave = useCallback(async () => { const handleScriptSave = useCallback(async () => {
if (!scriptConfig) return; if (!scriptConfig) return;
@@ -350,8 +331,7 @@ export default function Maintenance() {
setSshSaving(true); setSshSaving(true);
await saveSshConfig(normalizedSshDraft); await saveSshConfig(normalizedSshDraft);
setSshTestResult(null); setSshTestResult(null);
setShowPrivateKey(false); setShowPassword(false);
setShowPassphrase(false);
showToast({ showToast({
variant: "success", variant: "success",
title: "SSH-Konfiguration gespeichert", title: "SSH-Konfiguration gespeichert",
@@ -389,10 +369,8 @@ export default function Maintenance() {
setSshDeleting(true); setSshDeleting(true);
await deleteSshConfig(); await deleteSshConfig();
setSshDraft(createEmptySshDraft()); setSshDraft(createEmptySshDraft());
setSshKeyStored(false); setSshPasswordStored(false);
setSshPassphraseStored(false); setShowPassword(false);
setShowPrivateKey(false);
setShowPassphrase(false);
setSshTestResult(null); setSshTestResult(null);
showToast({ showToast({
variant: "info", variant: "info",
@@ -766,60 +744,30 @@ export default function Maintenance() {
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
<div className="flex items-center justify-between text-xs uppercase tracking-wide text-gray-400"> <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> <label htmlFor="maintenance-ssh-password" className="cursor-pointer">Passwort</label>
<button <button
type="button" type="button"
onClick={() => setShowPrivateKey((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning} disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
className="text-[11px] font-medium text-purple-300 transition hover:text-purple-200 disabled:opacity-50" className="text-[11px] font-medium text-purple-300 transition hover:text-purple-200 disabled:opacity-50"
> >
{showPrivateKey ? 'Verbergen' : 'Anzeigen'} {showPassword ? '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>
<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> </button>
</div> </div>
<input <input
id="maintenance-ssh-passphrase" id="maintenance-ssh-password"
type={showPassphrase ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={sshDraft.privateKeyPassphrase} value={sshDraft.password}
onChange={(event) => handleSshDraftChange('privateKeyPassphrase', event.target.value)} onChange={(event) => handleSshDraftChange('password', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning} 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" 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" placeholder="Passwort für den SSH-Benutzer"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
{sshPassphraseStored && !sshDraft.privateKeyPassphrase && ( {sshPasswordStored && !sshDraft.password && (
<span className="text-[11px] text-gray-400"> <span className="text-[11px] text-gray-400">
Eine Passphrase ist gespeichert. Neuer Wert ersetzt sie oder lösche die Konfiguration unten. Ein Passwort ist gespeichert. Neuer Inhalt ersetzt es oder lösche die Konfiguration unten.
</span> </span>
)} )}
</div> </div>