feat: saved SSH hosts per user, the way in sealed with AES-GCM under a server key kept beside the database

This commit is contained in:
redtea committed 2026-10-15 22:18:27 +03:00
1 parent 1f930e5aea
commit 94bf292494
10 files changed
+347 -7

No files matched your search

+4
View File
@@ -852,3 +852,7 @@ dialog.uidlg { max-width:420px; width:92vw; }
.uidlg form { padding:18px; display:grid; gap:14px; }
.uidlg .ui-text { margin:0; white-space:pre-wrap; line-height:1.45; }
.uidlg .ui-input[hidden] { display:none; }
.sshdlg .chk { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); }
.sshdlg .chk input { width:auto; }
.sshdlg .saved-row[hidden] { display:none; }
.sshdlg .saved-row .frow { align-items:center; }
+48 -5
View File
@@ -550,6 +550,8 @@
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
'<div class="dlg-b">' +
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
'<div class="f saved-row"><span>Сохранённый хост</span><div class="frow"><select name="saved" class="grow"><option value="">новый хост</option></select>' +
'<button type="button" class="btn ghost small icon close x" data-delsaved title="Удалить из сохранённых" aria-label="Удалить из сохранённых" hidden>' + X + '</button></div></div>' +
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
@@ -562,6 +564,7 @@
'<input type="file" hidden></div>' +
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
'<label class="chk save-row"><input type="checkbox" name="save"> Сохранить хост и вход - на сервере, зашифровано, только для вас</label>' +
'<p class="sshmsg"></p>' +
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
'</div></form>';
@@ -592,13 +595,40 @@
}
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
d.addEventListener('click', ev => { if (ev.target === d) close(); });
let agents = [];
f.onsubmit = ev => {
let agents = [], saved = [];
// A saved host: its address shows, the way in stays on the server.
const useSaved = () => {
const h = saved.find(x => String(x.id) === f.saved.value);
d.querySelector('[data-delsaved]').hidden = !h;
d.querySelectorAll('.seg, .auth, .save-row').forEach(el => { el.style.display = h ? 'none' : ''; });
if (!h) { setAuth(auth); return; }
f.host.value = h.host; f.port.value = h.port; f.user.value = h.user;
};
f.saved.onchange = useSaved;
const loadSaved = () => fetch('/api/panel/ssh/hosts', { cache: 'no-store' }).then(r => r.json()).then(list => {
saved = list || [];
const keep = f.saved.value;
f.saved.innerHTML = '<option value="">новый хост</option>' + saved.map(h => '<option value="' + h.id + '">' +
String(h.label).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) + '</option>').join('');
f.saved.value = saved.some(h => String(h.id) === keep) ? keep : '';
d.querySelector('.saved-row').hidden = !saved.length;
useSaved();
}).catch(() => {});
d.querySelector('[data-delsaved]').onclick = async () => {
const h = saved.find(x => String(x.id) === f.saved.value);
if (!h || !await UI.confirm('Удалить «' + h.label + '» из сохранённых?', { ok: 'Удалить', danger: true })) return;
await fetch('/api/panel/ssh/hosts/' + h.id + '/delete', { method: 'POST' });
f.saved.value = '';
loadSaved();
};
f.onsubmit = async ev => {
ev.preventDefault();
const a = agents.find(x => x.id === f.agent.value);
if (!a) { msg.textContent = 'выберите машину'; return; }
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
if (auth === 'password') {
if (f.saved.value) {
t.saved = Number(f.saved.value);
} else if (auth === 'password') {
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
t.password = f.password.value;
} else if (auth === 'key') {
@@ -606,7 +636,15 @@
t.key = f.key.value;
if (f.passphrase.value) t.passphrase = f.passphrase.value;
}
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ }
if (!t.saved && f.save.checked) {
// Saved first, then used by its id like any saved host.
const r = await fetch('/api/panel/ssh/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ auth: auth }, t)) }).then(x => x.json()).catch(() => ({ ok: false, error: 'ошибка сети' }));
if (!r.ok) { msg.textContent = r.error || 'не сохранилось'; return; }
f.save.checked = false;
loadSaved();
}
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth, saved: t.saved || 0 })); } catch (e) { /* not kept */ }
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
d.querySelector('.keyname').textContent = '';
if (sshMode === 'files') {
@@ -649,7 +687,12 @@
if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
fill();
}).catch(() => {});
if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth };
if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth, saved: last.saved };
f.saved.value = '';
loadSaved().then(() => {
// The host used last, if it was a saved one and still is.
if (last.saved && saved.some(h => h.id === last.saved && h.host === last.host)) { f.saved.value = String(last.saved); useSaved(); }
});
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
d.showModal();