feat: SSH terminal - the agent connects by SSH to another host (password, key file or its own keys) and the shell opens in a tab with the connection's progress shown; host keys trusted on first use, a changed one refused
This commit is contained in:
1 parent
5a204ae9bb
commit
5a4dc596d5
7 files changed
+515
-21
No files matched your search
@@ -30,8 +30,11 @@
|
||||
|
||||
// ---------- one shell ----------
|
||||
class Session {
|
||||
constructor(agent) {
|
||||
// ssh, when given, is where the agent connects by SSH for this shell:
|
||||
// {host, port, user} and the secret to sign in with, sent once on open.
|
||||
constructor(agent, ssh) {
|
||||
this.agent = agent;
|
||||
this.ssh = ssh || null;
|
||||
this.sid = null; this.closed = false; this.connected = false;
|
||||
this.state = 'wait'; this.text = 'подключение…';
|
||||
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
|
||||
@@ -40,8 +43,9 @@
|
||||
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
|
||||
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
||||
'<div class="pane-body"></div>';
|
||||
this.el.querySelector('.pane-title').textContent = agent.user + '@' + agent.host;
|
||||
this.el.querySelector('.pane-title').title = agent.name;
|
||||
this.el.querySelector('.pane-title').textContent = who(this);
|
||||
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
|
||||
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||
this.term = new Terminal({
|
||||
cursorBlink: true, fontSize: touch ? 13 : 14,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
||||
@@ -67,14 +71,29 @@
|
||||
}
|
||||
async open() {
|
||||
try {
|
||||
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', { method: 'POST' });
|
||||
let opt = { method: 'POST' };
|
||||
if (this.ssh) {
|
||||
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
|
||||
// The password or key goes once, to the agent; the page keeps no copy.
|
||||
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
|
||||
}
|
||||
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
|
||||
if (!r.ok) {
|
||||
this.setState('err', 'ошибка открытия (' + r.status + ')');
|
||||
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
||||
let why = 'HTTP ' + r.status;
|
||||
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
|
||||
this.setState('err', 'ошибка открытия: ' + why);
|
||||
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
this.sid = (await r.json()).session_id;
|
||||
this.setState('wait', 'ожидание агента…');
|
||||
// An agent from before SSH terminals does not answer the request.
|
||||
if (this.ssh) setTimeout(() => {
|
||||
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
|
||||
this.setState('err', 'агент не начал SSH-подключение');
|
||||
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
|
||||
}
|
||||
}, 20000);
|
||||
this.fit();
|
||||
// A WebSocket if it connects; the long polls otherwise, and from a
|
||||
// dropped WebSocket on for the rest of this session.
|
||||
@@ -109,14 +128,22 @@
|
||||
take(j) {
|
||||
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
||||
for (const f of j.frames || []) {
|
||||
if (f.status) this.sshStatus(f.status, f.note || '');
|
||||
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
||||
if (f.data) {
|
||||
if (!this.connected) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
|
||||
this.term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// sshStatus shows how the agent's SSH connection goes.
|
||||
sshStatus(st, note) {
|
||||
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
|
||||
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
|
||||
else if (st === 'err') this.setState('err', 'SSH: ' + note);
|
||||
}
|
||||
async poll() {
|
||||
while (!this.closed && this.sid) {
|
||||
let r;
|
||||
@@ -130,7 +157,7 @@
|
||||
finish(msg) {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
this.setState('off', 'соединение закрыто');
|
||||
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
|
||||
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
// Input goes out one request at a time, so on a slow link a keystroke can
|
||||
@@ -183,6 +210,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// who is what a shell is signed in as, for titles.
|
||||
function who(s) {
|
||||
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
|
||||
}
|
||||
|
||||
// ---------- tabs ----------
|
||||
const tabs = [];
|
||||
let active = null, activePane = null;
|
||||
@@ -212,8 +244,8 @@
|
||||
}
|
||||
function tabTitle(t) {
|
||||
if (t.title) return t.title;
|
||||
const a = t.panes[0].agent;
|
||||
return t.panes.length > 1 ? a.name + ' +' + (t.panes.length - 1) : a.name;
|
||||
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name;
|
||||
return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : name;
|
||||
}
|
||||
// Tab widths: as wide as the title wants, from a comfortable minimum up to
|
||||
// a maximum; when they do not fit, all give way evenly down to a floor,
|
||||
@@ -224,7 +256,7 @@
|
||||
function fitTabs() {
|
||||
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
||||
const live = tabs;
|
||||
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME)));
|
||||
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME + (t.panes.some(p => p.ssh) ? 46 : 0))));
|
||||
const strip = $('tabs');
|
||||
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
||||
let cap = W.max;
|
||||
@@ -243,8 +275,13 @@
|
||||
|
||||
function renderTab(t) {
|
||||
t.el.querySelector('.tab-title').textContent = tabTitle(t);
|
||||
const ssh = t.panes.some(s => s.ssh);
|
||||
if (ssh !== !!t.el.querySelector('.badge-ssh')) {
|
||||
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||
else t.el.querySelector('.badge-ssh').remove();
|
||||
}
|
||||
requestAnimationFrame(fitTabs);
|
||||
t.el.title = t.panes.map(s => s.agent.user + '@' + s.agent.host).join(', ');
|
||||
t.el.title = t.panes.map(who).join(', ');
|
||||
// The tab's dot: red if any shell failed, amber while one connects.
|
||||
const st = t.panes.map(s => s.state);
|
||||
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
|
||||
@@ -270,6 +307,7 @@
|
||||
function showStatus() {
|
||||
if (!activePane) { statusEl.textContent = ''; return; }
|
||||
statusEl.textContent = activePane.text;
|
||||
statusEl.title = activePane.text;
|
||||
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
||||
}
|
||||
// One tab: a plain terminal, the strip folded into the corner "+".
|
||||
@@ -279,9 +317,9 @@
|
||||
document.body.classList.toggle('multi', multi);
|
||||
setTimeout(() => active && layout(active, false), 280);
|
||||
}
|
||||
function openTerminal(agent) {
|
||||
function openTerminal(agent, ssh) {
|
||||
$('empty').hidden = true;
|
||||
const t = makeTab([new Session(agent)]);
|
||||
const t = makeTab([new Session(agent, ssh)]);
|
||||
activate(t);
|
||||
}
|
||||
function closeTab(t) {
|
||||
@@ -590,6 +628,8 @@
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||
() => { closeMenu(); openTerminal(here); });
|
||||
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>',
|
||||
() => { closeMenu(); openSSH(here); });
|
||||
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||
other.remove();
|
||||
const list = document.createElement('div');
|
||||
@@ -633,6 +673,114 @@
|
||||
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
|
||||
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !addMenu.hidden) closeMenu(); });
|
||||
|
||||
// ---------- the SSH dialog ----------
|
||||
const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
|
||||
let sshDlg = null;
|
||||
function openSSH(here) {
|
||||
if (!sshDlg) sshDlg = makeSSHDialog();
|
||||
sshDlg.show(here);
|
||||
}
|
||||
function makeSSHDialog() {
|
||||
const d = document.createElement('dialog');
|
||||
d.className = 'sshdlg';
|
||||
d.innerHTML =
|
||||
'<form method="dialog" autocomplete="off">' +
|
||||
'<div class="dlg-h"><span class="ico">' + KEYI + '</span><strong style="flex:1">SSH-подключение</strong>' +
|
||||
'<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="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>' +
|
||||
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' +
|
||||
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
|
||||
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||||
'<div class="auth" data-for="key" hidden>' +
|
||||
'<textarea name="key" rows="5" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>' +
|
||||
'<div class="frow"><button type="button" class="btn ghost small" data-load>Загрузить файл…</button><span class="muted keyname"></span>' +
|
||||
'<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>' +
|
||||
'<p class="sshmsg"></p>' +
|
||||
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
||||
'</div></form>';
|
||||
document.body.appendChild(d);
|
||||
const f = d.querySelector('form'), msg = d.querySelector('.sshmsg');
|
||||
let auth = 'password';
|
||||
const setAuth = a => {
|
||||
auth = a;
|
||||
d.querySelectorAll('.seg button').forEach(b => b.classList.toggle('on', b.dataset.auth === a));
|
||||
d.querySelectorAll('.auth').forEach(el => { el.hidden = el.dataset.for !== a; });
|
||||
};
|
||||
d.querySelectorAll('.seg button').forEach(b => { b.onclick = () => setAuth(b.dataset.auth); });
|
||||
d.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => close(); });
|
||||
const file = d.querySelector('input[type=file]');
|
||||
d.querySelector('[data-load]').onclick = () => file.click();
|
||||
file.onchange = () => {
|
||||
const fl = file.files[0];
|
||||
if (!fl) return;
|
||||
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; }
|
||||
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; });
|
||||
file.value = '';
|
||||
};
|
||||
// then runs once the dialog is shut and focus is back where it was, so
|
||||
// what it focuses stays focused.
|
||||
function close(then) {
|
||||
d.classList.add('closing');
|
||||
setTimeout(() => { d.classList.remove('closing'); d.close(); if (then) then(); }, 160);
|
||||
}
|
||||
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
|
||||
d.addEventListener('click', ev => { if (ev.target === d) close(); });
|
||||
let agents = [];
|
||||
f.onsubmit = 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.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
|
||||
t.password = f.password.value;
|
||||
} else if (auth === 'key') {
|
||||
if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; }
|
||||
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 */ }
|
||||
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
|
||||
d.querySelector('.keyname').textContent = '';
|
||||
close(() => openTerminal(a, t));
|
||||
};
|
||||
return {
|
||||
show(here) {
|
||||
msg.textContent = '';
|
||||
let last = {};
|
||||
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
|
||||
agents = [here];
|
||||
const fill = () => {
|
||||
f.agent.innerHTML = '';
|
||||
agents.forEach(a => {
|
||||
const o = document.createElement('option');
|
||||
o.value = a.id; o.textContent = a.name + ' · ' + a.user + '@' + a.host + (a.online === false ? ' (не в сети)' : '');
|
||||
o.disabled = a.online === false;
|
||||
f.agent.appendChild(o);
|
||||
});
|
||||
f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : here.id;
|
||||
};
|
||||
fill();
|
||||
fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => {
|
||||
agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online }));
|
||||
if (!agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||
fill();
|
||||
}).catch(() => {});
|
||||
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();
|
||||
// On a phone the keyboard waits for a tap.
|
||||
if (desk) (last.host ? (last.auth === 'password' || !last.auth ? f.password : f.host) : f.host).focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- page ----------
|
||||
function refit() {
|
||||
if (touch && window.visualViewport) {
|
||||
|
||||
Reference in new issue
Block a user