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:
1 parent
1f930e5aea
commit
94bf292494
10 files changed
+347
-7
No files matched your search
@@ -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; }
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
|
||||
|
||||
Reference in new issue
Block a user