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
@@ -276,6 +276,10 @@ func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
|
|||||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||||
case proto.CtrlTask:
|
case proto.CtrlTask:
|
||||||
go a.runTask(ctx, msg.TaskID)
|
go a.runTask(ctx, msg.TaskID)
|
||||||
|
case proto.CtrlOpenSSH:
|
||||||
|
if msg.SSH != nil {
|
||||||
|
go a.runSSH(ctx, msg.SessionID, *msg.SSH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+33
-5
@@ -18,8 +18,23 @@ const (
|
|||||||
CtrlOpenForward = "open_forward"
|
CtrlOpenForward = "open_forward"
|
||||||
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
||||||
CtrlTask = "task"
|
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.
|
// EnrollRequest is sent once by a freshly installed agent.
|
||||||
type EnrollRequest struct {
|
type EnrollRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -38,11 +53,12 @@ type EnrollResponse struct {
|
|||||||
|
|
||||||
// ControlMessage is the body returned by the control long-poll.
|
// ControlMessage is the body returned by the control long-poll.
|
||||||
type ControlMessage struct {
|
type ControlMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
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.
|
// Transports an agent can reach the server by.
|
||||||
@@ -96,8 +112,20 @@ type InBatch struct {
|
|||||||
type OutMessage struct {
|
type OutMessage struct {
|
||||||
Data string `json:"data,omitempty"` // base64 bytes
|
Data string `json:"data,omitempty"` // base64 bytes
|
||||||
Exit bool `json:"exit,omitempty"`
|
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) ---
|
// --- one-shot tasks (file manager, native in the agent) ---
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
@@ -17,7 +21,9 @@ import (
|
|||||||
// same transport the agent uses), so it works through any reverse proxy without
|
// same transport the agent uses), so it works through any reverse proxy without
|
||||||
// WebSocket upgrade support.
|
// 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) {
|
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||||
agentID := r.PathValue("agentID")
|
agentID := r.PathValue("agentID")
|
||||||
agent, err := s.db.GetAgent(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)
|
http.NotFound(w, r)
|
||||||
return
|
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)
|
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
||||||
if admin := s.currentAdmin(r); admin != nil {
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ func (h *Hub) CreateSession(agentID, user string) *Session {
|
|||||||
return s
|
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).
|
// CreateForward registers a port-forward stream (agent dials 127.0.0.1:port).
|
||||||
func (h *Hub) CreateForward(agentID string, port int) *Session {
|
func (h *Hub) CreateForward(agentID string, port int) *Session {
|
||||||
s := h.newSession(agentID)
|
s := h.newSession(agentID)
|
||||||
|
|||||||
@@ -30,8 +30,11 @@
|
|||||||
|
|
||||||
// ---------- one shell ----------
|
// ---------- one shell ----------
|
||||||
class Session {
|
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.agent = agent;
|
||||||
|
this.ssh = ssh || null;
|
||||||
this.sid = null; this.closed = false; this.connected = false;
|
this.sid = null; this.closed = false; this.connected = false;
|
||||||
this.state = 'wait'; this.text = 'подключение…';
|
this.state = 'wait'; this.text = 'подключение…';
|
||||||
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
|
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>' +
|
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>' +
|
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
|
||||||
'<div class="pane-body"></div>';
|
'<div class="pane-body"></div>';
|
||||||
this.el.querySelector('.pane-title').textContent = agent.user + '@' + agent.host;
|
this.el.querySelector('.pane-title').textContent = who(this);
|
||||||
this.el.querySelector('.pane-title').title = agent.name;
|
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({
|
this.term = new Terminal({
|
||||||
cursorBlink: true, fontSize: touch ? 13 : 14,
|
cursorBlink: true, fontSize: touch ? 13 : 14,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
|
||||||
@@ -67,14 +71,29 @@
|
|||||||
}
|
}
|
||||||
async open() {
|
async open() {
|
||||||
try {
|
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) {
|
if (!r.ok) {
|
||||||
this.setState('err', 'ошибка открытия (' + r.status + ')');
|
let why = 'HTTP ' + r.status;
|
||||||
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.sid = (await r.json()).session_id;
|
this.sid = (await r.json()).session_id;
|
||||||
this.setState('wait', 'ожидание агента…');
|
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();
|
this.fit();
|
||||||
// A WebSocket if it connects; the long polls otherwise, and from a
|
// A WebSocket if it connects; the long polls otherwise, and from a
|
||||||
// dropped WebSocket on for the rest of this session.
|
// dropped WebSocket on for the rest of this session.
|
||||||
@@ -109,14 +128,22 @@
|
|||||||
take(j) {
|
take(j) {
|
||||||
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
if (j.closed) { this.finish('[сессия завершена]'); return false; }
|
||||||
for (const f of j.frames || []) {
|
for (const f of j.frames || []) {
|
||||||
|
if (f.status) this.sshStatus(f.status, f.note || '');
|
||||||
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
if (f.exit) { this.finish('[сессия завершена]'); return false; }
|
||||||
if (f.data) {
|
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));
|
this.term.write(b64ToBytes(f.data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
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() {
|
async poll() {
|
||||||
while (!this.closed && this.sid) {
|
while (!this.closed && this.sid) {
|
||||||
let r;
|
let r;
|
||||||
@@ -130,7 +157,7 @@
|
|||||||
finish(msg) {
|
finish(msg) {
|
||||||
if (this.ended) return;
|
if (this.ended) return;
|
||||||
this.ended = true;
|
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');
|
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
|
// 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 ----------
|
// ---------- tabs ----------
|
||||||
const tabs = [];
|
const tabs = [];
|
||||||
let active = null, activePane = null;
|
let active = null, activePane = null;
|
||||||
@@ -212,8 +244,8 @@
|
|||||||
}
|
}
|
||||||
function tabTitle(t) {
|
function tabTitle(t) {
|
||||||
if (t.title) return t.title;
|
if (t.title) return t.title;
|
||||||
const a = t.panes[0].agent;
|
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name;
|
||||||
return t.panes.length > 1 ? a.name + ' +' + (t.panes.length - 1) : a.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
|
// 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,
|
// a maximum; when they do not fit, all give way evenly down to a floor,
|
||||||
@@ -224,7 +256,7 @@
|
|||||||
function fitTabs() {
|
function fitTabs() {
|
||||||
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
||||||
const live = tabs;
|
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 strip = $('tabs');
|
||||||
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
||||||
let cap = W.max;
|
let cap = W.max;
|
||||||
@@ -243,8 +275,13 @@
|
|||||||
|
|
||||||
function renderTab(t) {
|
function renderTab(t) {
|
||||||
t.el.querySelector('.tab-title').textContent = tabTitle(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);
|
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.
|
// The tab's dot: red if any shell failed, amber while one connects.
|
||||||
const st = t.panes.map(s => s.state);
|
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');
|
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() {
|
function showStatus() {
|
||||||
if (!activePane) { statusEl.textContent = ''; return; }
|
if (!activePane) { statusEl.textContent = ''; return; }
|
||||||
statusEl.textContent = activePane.text;
|
statusEl.textContent = activePane.text;
|
||||||
|
statusEl.title = activePane.text;
|
||||||
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
||||||
}
|
}
|
||||||
// One tab: a plain terminal, the strip folded into the corner "+".
|
// One tab: a plain terminal, the strip folded into the corner "+".
|
||||||
@@ -279,9 +317,9 @@
|
|||||||
document.body.classList.toggle('multi', multi);
|
document.body.classList.toggle('multi', multi);
|
||||||
setTimeout(() => active && layout(active, false), 280);
|
setTimeout(() => active && layout(active, false), 280);
|
||||||
}
|
}
|
||||||
function openTerminal(agent) {
|
function openTerminal(agent, ssh) {
|
||||||
$('empty').hidden = true;
|
$('empty').hidden = true;
|
||||||
const t = makeTab([new Session(agent)]);
|
const t = makeTab([new Session(agent, ssh)]);
|
||||||
activate(t);
|
activate(t);
|
||||||
}
|
}
|
||||||
function closeTab(t) {
|
function closeTab(t) {
|
||||||
@@ -590,6 +628,8 @@
|
|||||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
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>',
|
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||||
() => { closeMenu(); openTerminal(here); });
|
() => { 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>', () => {
|
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||||
other.remove();
|
other.remove();
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
@@ -633,6 +673,114 @@
|
|||||||
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
|
$('emptyadd').onclick = ev => openMenu(ev.currentTarget);
|
||||||
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !addMenu.hidden) closeMenu(); });
|
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 ----------
|
// ---------- page ----------
|
||||||
function refit() {
|
function refit() {
|
||||||
if (touch && window.visualViewport) {
|
if (touch && window.visualViewport) {
|
||||||
|
|||||||
@@ -10,6 +10,42 @@
|
|||||||
<style>
|
<style>
|
||||||
#status.st-ok { color:#37c871; }
|
#status.st-ok { color:#37c871; }
|
||||||
#status.st-err { color:#ff5c5c; }
|
#status.st-err { color:#ff5c5c; }
|
||||||
|
#status { display:inline-block; vertical-align:bottom; min-width:0; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
/* SSH: the badge on a pane, and the dialog to connect. */
|
||||||
|
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
|
||||||
|
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
|
||||||
|
dialog.sshdlg { max-width:460px; }
|
||||||
|
dialog.sshdlg[open] { animation:dlg-in .2s ease; }
|
||||||
|
dialog.sshdlg[open]::backdrop { animation:dlg-fade .2s ease; }
|
||||||
|
dialog.sshdlg.closing { animation:dlg-out .16s ease forwards; }
|
||||||
|
dialog.sshdlg.closing::backdrop { animation:dlg-fade .16s ease reverse forwards; }
|
||||||
|
@keyframes dlg-in { from { opacity:0; transform:translateY(10px) scale(.97); } }
|
||||||
|
@keyframes dlg-out { to { opacity:0; transform:translateY(6px) scale(.98); } }
|
||||||
|
@keyframes dlg-fade { from { opacity:0; } }
|
||||||
|
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
|
||||||
|
.sshdlg .dlg-b { display:grid; gap:12px; }
|
||||||
|
.sshdlg .f { display:grid; gap:5px; margin:0; }
|
||||||
|
.sshdlg .f > span { font-size:12px; color:var(--muted); }
|
||||||
|
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
|
||||||
|
.sshdlg .frow .grow { flex:1; min-width:0; }
|
||||||
|
.sshdlg .frow .port { width:92px; flex:none; }
|
||||||
|
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
|
||||||
|
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
|
||||||
|
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||||
|
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
|
||||||
|
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
|
||||||
|
.sshdlg .auth[hidden] { display:none; }
|
||||||
|
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
|
||||||
|
.sshdlg p.auth { margin:0; font-size:13px; }
|
||||||
|
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||||
|
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
|
||||||
|
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
|
||||||
|
transition:background .18s, color .18s; }
|
||||||
|
.sshdlg .seg button:hover { color:var(--text); }
|
||||||
|
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
|
||||||
|
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
|
||||||
|
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
|
||||||
|
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
|
||||||
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||||
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||||
header.topbar { flex:none; }
|
header.topbar { flex:none; }
|
||||||
@@ -40,7 +76,7 @@
|
|||||||
pointer-events:none; transition:left .12s ease; }
|
pointer-events:none; transition:left .12s ease; }
|
||||||
.tabmark[hidden] { display:none; }
|
.tabmark[hidden] { display:none; }
|
||||||
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||||
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); } .dot.wait { background:#e6a23c; }
|
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
|
||||||
/* The terminals' area. */
|
/* The terminals' area. */
|
||||||
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||||
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||||
|
|||||||
Reference in new issue
Block a user