diff --git a/internal/agent/client.go b/internal/agent/client.go index a80d946..5c734d8 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -276,6 +276,10 @@ func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) { go a.runForward(ctx, msg.SessionID, msg.Port) case proto.CtrlTask: go a.runTask(ctx, msg.TaskID) + case proto.CtrlOpenSSH: + if msg.SSH != nil { + go a.runSSH(ctx, msg.SessionID, *msg.SSH) + } } } diff --git a/internal/agent/ssh.go b/internal/agent/ssh.go new file mode 100644 index 0000000..af9503a --- /dev/null +++ b/internal/agent/ssh.go @@ -0,0 +1,238 @@ +package agent + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// An SSH terminal: the agent connects to another host by SSH and bridges the +// remote shell to the session, as it does a local one. The panel is told how +// the connection goes (connecting, signing in, in) to show it. + +// sshKnownHosts holds the host keys seen, next to the agent's state: a host +// is trusted the first time, and a changed key is refused. +func (a *Agent) sshKnownHosts() string { + return filepath.Join(filepath.Dir(a.cfg.StatePath), "ssh_known_hosts") +} + +var knownHostsMu sync.Mutex + +// hostKeyCheck trusts a new host and remembers its key; a host whose key +// has changed since is refused, as ssh would. +func (a *Agent) hostKeyCheck(seen *string) ssh.HostKeyCallback { + return func(hostname string, _ net.Addr, key ssh.PublicKey) error { + knownHostsMu.Lock() + defer knownHostsMu.Unlock() + fp := ssh.FingerprintSHA256(key) + *seen = fp + path := a.sshKnownHosts() + line := hostname + " " + key.Type() + " " + if f, err := os.Open(path); err == nil { + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) != 3 || fields[0] != hostname { + continue + } + if fields[1] == key.Type() && fields[2] == fp { + f.Close() + return nil + } + if fields[1] == key.Type() { + f.Close() + return fmt.Errorf("ключ хоста %s изменился: был %s, теперь %s. Если так и должно быть, удалите строку с этим хостом из %s на агенте", + hostname, fields[2], fp, path) + } + } + f.Close() + } + _ = os.MkdirAll(filepath.Dir(path), 0o700) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err == nil { + _, _ = f.WriteString(line + fp + "\n") + f.Close() + } + return nil + } +} + +// sshAuth is how to sign in: the key given, the password given (also +// answered to keyboard-interactive prompts), or the agent user's own keys. +func sshAuth(t proto.SSHTarget) ([]ssh.AuthMethod, error) { + var methods []ssh.AuthMethod + if t.Key != "" { + var signer ssh.Signer + var err error + if t.Passphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(t.Key), []byte(t.Passphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(t.Key)) + } + var missing *ssh.PassphraseMissingError + if errors.As(err, &missing) { + return nil, errors.New("ключ защищён фразой - введите её") + } + if err != nil { + return nil, fmt.Errorf("ключ не читается: %v", err) + } + methods = append(methods, ssh.PublicKeys(signer)) + } + if t.Password != "" { + pw := t.Password + methods = append(methods, ssh.Password(pw), + ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) { + ans := make([]string, len(qs)) + for i := range ans { + ans[i] = pw + } + return ans, nil + })) + } + if len(methods) == 0 { + var signers []ssh.Signer + for _, n := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} { + b, err := os.ReadFile(filepath.Join(homeDir(""), ".ssh", n)) + if err != nil { + continue + } + if s, err := ssh.ParsePrivateKey(b); err == nil { + signers = append(signers, s) + } + } + if len(signers) == 0 { + return nil, errors.New("не задан ни пароль, ни ключ, и в ~/.ssh агента нет ключей без фразы") + } + methods = append(methods, ssh.PublicKeys(signers...)) + } + return methods, nil +} + +// sshShell is the remote shell as a stream: its output (stdout and stderr, +// one on a PTY) to read, keystrokes to write. +type sshShell struct { + client *ssh.Client + sess *ssh.Session + stdin io.WriteCloser + out *io.PipeReader + once sync.Once +} + +func (s *sshShell) Read(b []byte) (int, error) { return s.out.Read(b) } +func (s *sshShell) Write(b []byte) (int, error) { return s.stdin.Write(b) } +func (s *sshShell) Resize(cols, rows uint16) { + if cols > 0 && rows > 0 { + _ = s.sess.WindowChange(int(rows), int(cols)) + } +} +func (s *sshShell) Close() error { + s.once.Do(func() { + _ = s.sess.Close() + _ = s.client.Close() + _ = s.out.Close() + }) + return nil +} + +// runSSH connects to the target and bridges its shell to the session. +func (a *Agent) runSSH(parent context.Context, sid string, t proto.SSHTarget) { + status := func(st, note string) { + a.postOut(context.Background(), sid, proto.OutMessage{Status: st, Note: note}) + } + fail := func(err error) { + log.Printf("ssh %s: %v", sid, err) + status(proto.SSHErr, err.Error()) + a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("\x1b[31m" + err.Error() + "\x1b[0m\r\n"))}) + a.postOut(context.Background(), sid, proto.OutMessage{Exit: true}) + } + if t.Port == 0 { + t.Port = 22 + } + addr := net.JoinHostPort(t.Host, strconv.Itoa(t.Port)) + auth, err := sshAuth(t) + if err != nil { + fail(err) + return + } + status(proto.SSHDial, "подключение к "+addr) + d := net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} + conn, err := d.DialContext(parent, "tcp", addr) + if err != nil { + fail(fmt.Errorf("не подключиться к %s: %v", addr, err)) + return + } + status(proto.SSHAuth, "вход как "+t.User) + var fp string + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + c, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{ + User: t.User, + Auth: auth, + HostKeyCallback: a.hostKeyCheck(&fp), + Timeout: 15 * time.Second, + }) + if err != nil { + conn.Close() + msg := strings.TrimPrefix(err.Error(), "ssh: handshake failed: ") + if strings.Contains(msg, "unable to authenticate") { + msg = "сервер не принял вход как " + t.User + " - проверьте пароль или ключ" + } + fail(errors.New(msg)) + return + } + _ = conn.SetDeadline(time.Time{}) + client := ssh.NewClient(c, chans, reqs) + sess, err := client.NewSession() + if err != nil { + client.Close() + fail(fmt.Errorf("сессия не открылась: %v", err)) + return + } + modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 115200, ssh.TTY_OP_OSPEED: 115200} + if err := sess.RequestPty("xterm-256color", 24, 80, modes); err != nil { + sess.Close() + client.Close() + fail(fmt.Errorf("терминал не выделен: %v", err)) + return + } + stdin, _ := sess.StdinPipe() + pr, pw := io.Pipe() + sess.Stdout = pw + sess.Stderr = pw + if err := sess.Shell(); err != nil { + sess.Close() + client.Close() + fail(fmt.Errorf("оболочка не запустилась: %v", err)) + return + } + sh := &sshShell{client: client, sess: sess, stdin: stdin, out: pr} + go func() { _ = sess.Wait(); pw.Close() }() + // Keep-alives, so a dead host is noticed and the session ends. + go func() { + tk := time.NewTicker(30 * time.Second) + defer tk.Stop() + for range tk.C { + if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil { + sh.Close() + return + } + } + }() + log.Printf("ssh %s: %s@%s up (host key %s)", sid, t.User, addr, fp) + status(proto.SSHOK, t.User+"@"+addr+" · "+fp) + a.bridgeStream(parent, sid, sh, sh.Resize) + log.Printf("ssh %s: ended", sid) +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 24de5c6..aff0acc 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -18,8 +18,23 @@ const ( CtrlOpenForward = "open_forward" // CtrlTask tells the agent to fetch and run the one-shot task TaskID. CtrlTask = "task" + // CtrlOpenSSH tells the agent to open an SSH connection to SSH and bridge + // its shell to the session id, as a terminal. + CtrlOpenSSH = "open_ssh" ) +// SSHTarget is where an SSH terminal connects and how it signs in: a +// password, a private key (PEM or OpenSSH, with its passphrase if it has +// one), or, with neither, the agent user's own keys in ~/.ssh. +type SSHTarget struct { + Host string `json:"host"` + Port int `json:"port,omitempty"` + User string `json:"user"` + Password string `json:"password,omitempty"` + Key string `json:"key,omitempty"` + Passphrase string `json:"passphrase,omitempty"` +} + // EnrollRequest is sent once by a freshly installed agent. type EnrollRequest struct { Name string `json:"name"` @@ -38,11 +53,12 @@ type EnrollResponse struct { // ControlMessage is the body returned by the control long-poll. type ControlMessage struct { - Type string `json:"type"` - SessionID string `json:"session_id,omitempty"` - Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial - TaskID string `json:"task_id,omitempty"` // CtrlTask - User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own) + Type string `json:"type"` + SessionID string `json:"session_id,omitempty"` + Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial + TaskID string `json:"task_id,omitempty"` // CtrlTask + User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own) + SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH } // Transports an agent can reach the server by. @@ -96,8 +112,20 @@ type InBatch struct { type OutMessage struct { Data string `json:"data,omitempty"` // base64 bytes Exit bool `json:"exit,omitempty"` + // Status tells the panel how an SSH terminal's connection goes: one of + // the SSHStatus values, with Note to show. + Status string `json:"status,omitempty"` + Note string `json:"note,omitempty"` } +// The steps of an SSH terminal's connection, as OutMessage.Status. +const ( + SSHDial = "dial" // connecting to the host + SSHAuth = "auth" // the host answered, signing in + SSHOK = "ok" // signed in, the shell runs + SSHErr = "err" // it failed; Note says why +) + // --- one-shot tasks (file manager, native in the agent) --- const ( diff --git a/internal/server/handlers_panel_term.go b/internal/server/handlers_panel_term.go index 8dc6415..7380042 100644 --- a/internal/server/handlers_panel_term.go +++ b/internal/server/handlers_panel_term.go @@ -3,8 +3,12 @@ package server import ( "context" "encoding/json" + "io" "log" + "net" "net/http" + "strconv" + "strings" "time" "github.com/coder/websocket" @@ -17,7 +21,9 @@ import ( // same transport the agent uses), so it works through any reverse proxy without // WebSocket upgrade support. -// POST /api/panel/term/{agentID}/open - create a terminal session. +// POST /api/panel/term/{agentID}/open - create a terminal session: a shell +// on the agent, or with {"ssh": {...}} in the body a shell the agent opens +// by SSH on another host. func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { agentID := r.PathValue("agentID") agent, err := s.db.GetAgent(agentID) @@ -26,6 +32,33 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + var body struct { + SSH *proto.SSHTarget `json:"ssh"` + } + if r.ContentLength != 0 { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + } + if t := body.SSH; t != nil { + t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User) + if t.Port == 0 { + t.Port = 22 + } + if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 || strings.ContainsAny(t.Host+t.User, " \t\r\n/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "нужны хост, порт 1-65535 и пользователь"}) + return + } + sess := s.hub.CreateSSH(agentID, *t) + if admin := s.currentAdmin(r); admin != nil { + s.db.Audit(admin.ID, agentID, "open_ssh:"+t.User+"@"+net.JoinHostPort(t.Host, strconv.Itoa(t.Port))) + } + writeJSON(w, map[string]string{"session_id": sess.ID}) + return + } sess := s.hub.CreateSession(agentID, agent.LoginUser) if admin := s.currentAdmin(r); admin != nil { s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser()) diff --git a/internal/server/hub.go b/internal/server/hub.go index 8c2df3c..a757cda 100644 --- a/internal/server/hub.go +++ b/internal/server/hub.go @@ -100,6 +100,13 @@ func (h *Hub) CreateSession(agentID, user string) *Session { return s } +// CreateSSH registers a terminal session the agent opens by SSH to t. +func (h *Hub) CreateSSH(agentID string, t proto.SSHTarget) *Session { + s := h.newSession(agentID) + h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenSSH, SessionID: s.ID, SSH: &t}) + return s +} + // CreateForward registers a port-forward stream (agent dials 127.0.0.1:port). func (h *Hub) CreateForward(agentID string, port int) *Session { s := h.newSession(agentID) diff --git a/internal/server/web/static/terminal.js b/internal/server/web/static/terminal.js index c89168c..c130801 100644 --- a/internal/server/web/static/terminal.js +++ b/internal/server/web/static/terminal.js @@ -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 = '
' + '
' + '
'; - 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', 'SSH'); 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', 'SSH'); + 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('+Новый терминал здесь' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '', () => { closeMenu(); openTerminal(here); }); + item('' + KEYI + 'SSH-подключениеагент откроет SSH к другому хосту', + () => { closeMenu(); openSSH(here); }); const other = item('⇄На другой машиневыбрать из списка›', () => { 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 = ''; + 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 = + '
' + + '
' + KEYI + 'SSH-подключение' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + + '
Вход
' + + '
' + + '
' + + '' + + '' + + '

' + + '
' + + '
'; + 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) { diff --git a/internal/server/web/templates/terminal.html b/internal/server/web/templates/terminal.html index ec17a70..659f85f 100644 --- a/internal/server/web/templates/terminal.html +++ b/internal/server/web/templates/terminal.html @@ -10,6 +10,42 @@