feat: improve local terminal workflows
This commit is contained in:
@@ -430,6 +430,12 @@ func (s *Service) ConnectLocal() (models.SessionInfo, error) {
|
||||
return *info, nil
|
||||
}
|
||||
|
||||
// RestartLocalSession starts a new shell in an exited local terminal while
|
||||
// preserving its tab, scrollback, terminal geometry, and best-effort cwd.
|
||||
func (s *Service) RestartLocalSession(sessionID string, cols int, rows int) error {
|
||||
return s.sessions.RestartLocal(sessionID, cols, rows)
|
||||
}
|
||||
|
||||
// DisconnectSession closes a session by ID.
|
||||
func (s *Service) DisconnectSession(sessionID string) error {
|
||||
return s.sessions.Disconnect(sessionID)
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
package localshell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/nexus/internal/terminalenv"
|
||||
"github.com/creack/pty"
|
||||
@@ -15,26 +22,28 @@ import (
|
||||
|
||||
// Client manages a local shell PTY session.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ptmx *os.File
|
||||
shell string
|
||||
onData func([]byte)
|
||||
onError func(error)
|
||||
onClose func()
|
||||
closed bool
|
||||
cols uint16
|
||||
rows uint16
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ptmx *os.File
|
||||
shell string
|
||||
startDir string
|
||||
onData func([]byte)
|
||||
onError func(error)
|
||||
onClose func()
|
||||
closed bool
|
||||
cols uint16
|
||||
rows uint16
|
||||
}
|
||||
|
||||
// Config holds configuration for a local shell session.
|
||||
type Config struct {
|
||||
Shell string // Path to the shell binary (e.g. /bin/zsh)
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
OnData func([]byte)
|
||||
OnError func(error)
|
||||
OnClose func()
|
||||
Shell string // Path to the shell binary (e.g. /bin/zsh)
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
StartDir string // Initial working directory; defaults to the user's home directory.
|
||||
OnData func([]byte)
|
||||
OnError func(error)
|
||||
OnClose func()
|
||||
}
|
||||
|
||||
// DefaultShell returns the system default shell.
|
||||
@@ -67,13 +76,20 @@ func NewClient(cfg Config) *Client {
|
||||
if rows == 0 {
|
||||
rows = 24
|
||||
}
|
||||
startDir := cfg.StartDir
|
||||
if startDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
startDir = home
|
||||
}
|
||||
}
|
||||
return &Client{
|
||||
shell: shell,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
onData: cfg.OnData,
|
||||
onError: cfg.OnError,
|
||||
onClose: cfg.OnClose,
|
||||
shell: shell,
|
||||
startDir: startDir,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
onData: cfg.OnData,
|
||||
onError: cfg.OnError,
|
||||
onClose: cfg.OnClose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,17 +98,16 @@ func (c *Client) Start() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return fmt.Errorf("shell already closed")
|
||||
}
|
||||
if c.ptmx != nil {
|
||||
return fmt.Errorf("shell already running")
|
||||
}
|
||||
|
||||
cmd := exec.Command(c.shell)
|
||||
cmd.Env = terminalenv.LocalShellEnv(os.Environ())
|
||||
|
||||
// Start in the user's home directory.
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
cmd.Dir = home
|
||||
}
|
||||
cmd.Dir = c.startDir
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
||||
Cols: c.cols,
|
||||
@@ -106,7 +121,7 @@ func (c *Client) Start() error {
|
||||
c.ptmx = ptmx
|
||||
|
||||
// Read loop — forward PTY output to the frontend.
|
||||
go c.readLoop()
|
||||
go c.readLoop(ptmx)
|
||||
|
||||
// Wait for process exit.
|
||||
go func() {
|
||||
@@ -114,6 +129,9 @@ func (c *Client) Start() error {
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.closed = true
|
||||
if c.ptmx == ptmx {
|
||||
c.ptmx = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if !wasClosed && c.onClose != nil {
|
||||
@@ -126,6 +144,13 @@ func (c *Client) Start() error {
|
||||
|
||||
// Write sends data to the shell's stdin (PTY).
|
||||
func (c *Client) Write(data []byte) error {
|
||||
// Capture the shell's current directory before it handles a command or
|
||||
// EOF. This lets a replacement shell resume in the same directory after
|
||||
// an ordinary `exit`, without injecting shell-specific prompt hooks.
|
||||
if bytesContainCommandBoundary(data) {
|
||||
c.captureWorkingDirectory()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
ptmx := c.ptmx
|
||||
c.mu.Unlock()
|
||||
@@ -140,7 +165,15 @@ func (c *Client) Write(data []byte) error {
|
||||
// Resize updates the PTY window size.
|
||||
func (c *Client) Resize(cols, rows uint16) error {
|
||||
c.mu.Lock()
|
||||
if cols > 0 {
|
||||
c.cols = cols
|
||||
}
|
||||
if rows > 0 {
|
||||
c.rows = rows
|
||||
}
|
||||
ptmx := c.ptmx
|
||||
cols = c.cols
|
||||
rows = c.rows
|
||||
c.mu.Unlock()
|
||||
|
||||
if ptmx == nil {
|
||||
@@ -152,26 +185,36 @@ func (c *Client) Resize(cols, rows uint16) error {
|
||||
// Close terminates the shell process and PTY.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
ptmx := c.ptmx
|
||||
cmd := c.cmd
|
||||
c.ptmx = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.ptmx != nil {
|
||||
c.ptmx.Close()
|
||||
c.ptmx = nil
|
||||
if ptmx != nil {
|
||||
_ = ptmx.Close()
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
c.cmd.Process.Kill()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
// WorkingDirectory returns the last directory observed for the shell.
|
||||
func (c *Client) WorkingDirectory() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.startDir
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(ptmx *os.File) {
|
||||
defer ptmx.Close()
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := c.ptmx.Read(buf)
|
||||
n, err := ptmx.Read(buf)
|
||||
if n > 0 && c.onData != nil {
|
||||
c.onData(buf[:n])
|
||||
}
|
||||
@@ -179,10 +222,73 @@ func (c *Client) readLoop() {
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.mu.Unlock()
|
||||
if !wasClosed && c.onError != nil {
|
||||
if !wasClosed && !isNormalPTYClosure(err) && c.onError != nil {
|
||||
c.onError(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bytesContainCommandBoundary(data []byte) bool {
|
||||
for _, b := range data {
|
||||
if b == '\r' || b == '\n' || b == 0x04 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNormalPTYClosure(err error) bool {
|
||||
return errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) || errors.Is(err, syscall.EIO)
|
||||
}
|
||||
|
||||
func (c *Client) captureWorkingDirectory() {
|
||||
c.mu.Lock()
|
||||
cmd := c.cmd
|
||||
c.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dir := processWorkingDirectory(cmd.Process.Pid)
|
||||
if dir == "" || !isDirectory(dir) {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.cmd == cmd {
|
||||
c.startDir = dir
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func processWorkingDirectory(pid int) string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
dir, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
|
||||
if err == nil {
|
||||
return dir
|
||||
}
|
||||
case "darwin":
|
||||
// macOS does not expose /proc. lsof is part of the base system and its
|
||||
// field output gives us the cwd without parsing a human-readable table.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(line, "n") {
|
||||
return strings.TrimPrefix(line, "n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDirectory(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package localshell
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientUsesConfiguredDirectoryAndPreStartSize(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("local PTY test requires a Unix shell")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
startDir := filepath.Join(dir, "working")
|
||||
if err := os.Mkdir(startDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := filepath.Join(dir, "report-terminal.sh")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\npwd\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
var outputMu sync.Mutex
|
||||
closed := make(chan struct{})
|
||||
errors := make(chan error, 1)
|
||||
client := NewClient(Config{
|
||||
Shell: script,
|
||||
StartDir: startDir,
|
||||
Cols: 80,
|
||||
Rows: 24,
|
||||
OnData: func(data []byte) {
|
||||
outputMu.Lock()
|
||||
_, _ = output.Write(data)
|
||||
outputMu.Unlock()
|
||||
},
|
||||
OnError: func(err error) {
|
||||
errors <- err
|
||||
},
|
||||
OnClose: func() {
|
||||
close(closed)
|
||||
},
|
||||
})
|
||||
|
||||
// A frontend fit can arrive before the process starts. The stored size
|
||||
// must become the initial PTY geometry rather than being discarded.
|
||||
if err := client.Resize(132, 43); err != nil {
|
||||
t.Fatalf("Resize() before Start() error = %v", err)
|
||||
}
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errors:
|
||||
t.Fatalf("unexpected PTY read error: %v", err)
|
||||
case <-closed:
|
||||
case <-time.After(5 * time.Second):
|
||||
client.Close()
|
||||
t.Fatal("timed out waiting for local shell to exit")
|
||||
}
|
||||
|
||||
outputMu.Lock()
|
||||
got := strings.ReplaceAll(output.String(), "\r", "")
|
||||
outputMu.Unlock()
|
||||
if !strings.Contains(got, "43 132\n") {
|
||||
t.Fatalf("PTY output %q does not contain configured size 43 132", got)
|
||||
}
|
||||
if !strings.Contains(got, startDir+"\n") {
|
||||
t.Fatalf("PTY output %q does not contain start directory %q", got, startDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesContainCommandBoundary(t *testing.T) {
|
||||
for _, data := range [][]byte{[]byte("pwd\r"), []byte("pwd\n"), {0x04}} {
|
||||
if !bytesContainCommandBoundary(data) {
|
||||
t.Fatalf("bytesContainCommandBoundary(%q) = false, want true", data)
|
||||
}
|
||||
}
|
||||
if bytesContainCommandBoundary([]byte("pwd")) {
|
||||
t.Fatal("bytesContainCommandBoundary without enter/EOF = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCapturesWorkingDirectoryBeforeExit(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||
t.Skip("process cwd lookup is implemented on macOS and Linux")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
startDir := filepath.Join(dir, "start")
|
||||
nextDir := filepath.Join(startDir, "next")
|
||||
if err := os.MkdirAll(nextDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedDir, err := filepath.EvalSymlinks(nextDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
closed := make(chan struct{})
|
||||
client := NewClient(Config{
|
||||
Shell: "/bin/sh",
|
||||
StartDir: startDir,
|
||||
OnClose: func() {
|
||||
close(closed)
|
||||
},
|
||||
})
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Write([]byte("cd next\r")); err != nil {
|
||||
t.Fatalf("Write(cd) error = %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for client.WorkingDirectory() != expectedDir && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// The empty command boundary snapshots the directory after cd has run.
|
||||
if err := client.Write([]byte("\r")); err != nil {
|
||||
t.Fatalf("Write(snapshot) error = %v", err)
|
||||
}
|
||||
}
|
||||
if got := client.WorkingDirectory(); got != expectedDir {
|
||||
t.Fatalf("WorkingDirectory() = %q, want %q", got, expectedDir)
|
||||
}
|
||||
|
||||
if err := client.Write([]byte("exit\r")); err != nil {
|
||||
t.Fatalf("Write(exit) error = %v", err)
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for local shell to exit")
|
||||
}
|
||||
if got := client.WorkingDirectory(); got != expectedDir {
|
||||
t.Fatalf("WorkingDirectory() after exit = %q, want %q", got, expectedDir)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ func DefaultAppPreferences() AppPreferences {
|
||||
DefaultCursorStyle: DefaultCursorStyle,
|
||||
UseNativePaste: false,
|
||||
LocalShell: "", // empty means use system default
|
||||
LeviathanEnabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultAppPreferencesHideLeviathan(t *testing.T) {
|
||||
prefs := DefaultAppPreferences()
|
||||
if prefs.LeviathanEnabled {
|
||||
t.Fatal("LeviathanEnabled = true, want false by default")
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,7 @@ type AppPreferences struct {
|
||||
DefaultCursorStyle string `json:"defaultCursorStyle"`
|
||||
UseNativePaste bool `json:"useNativePaste"`
|
||||
LocalShell string `json:"localShell"` // path to shell binary for local terminal
|
||||
LeviathanEnabled bool `json:"leviathanEnabled"`
|
||||
}
|
||||
|
||||
// AuditEntry records an auditable action within the application.
|
||||
|
||||
+168
-71
@@ -46,6 +46,8 @@ type Session struct {
|
||||
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
||||
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
|
||||
localClient *localshell.Client // local shell PTY, nil for non-local sessions
|
||||
localShell string // configured shell path, retained for restart
|
||||
localCwd string // last known local working directory
|
||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||
profile *models.Profile
|
||||
recorder *recording.Recorder // active recording, nil if not recording
|
||||
@@ -214,7 +216,8 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
Info: info,
|
||||
Info: info,
|
||||
localShell: shell,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -226,43 +229,76 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
||||
"state": string(models.SessionConnecting),
|
||||
})
|
||||
|
||||
go func() {
|
||||
client := localshell.NewClient(localshell.Config{
|
||||
Shell: shell,
|
||||
Cols: 80,
|
||||
Rows: 24,
|
||||
OnData: func(data []byte) {
|
||||
m.emitEvent("session:data", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"data": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
// Write to active recording if present.
|
||||
m.mu.RLock()
|
||||
rec := sess.recorder
|
||||
m.mu.RUnlock()
|
||||
if rec != nil {
|
||||
rec.WriteOutput(data)
|
||||
}
|
||||
},
|
||||
OnError: func(err error) {
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionError
|
||||
sess.Info.Error = err.Error()
|
||||
m.mu.Unlock()
|
||||
go m.startLocalSession(sess, 80, 24)
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
OnClose: func() {
|
||||
m.handleSessionClosed(sessionID)
|
||||
},
|
||||
})
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
// RestartLocal starts a replacement shell inside an exited local session.
|
||||
// Keeping the session ID lets the frontend retain the tab and terminal buffer.
|
||||
func (m *Manager) RestartLocal(sessionID string, cols, rows int) error {
|
||||
m.mu.Lock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("sessions: session %q not found", sessionID)
|
||||
}
|
||||
if sess.Info.Protocol != models.ProtocolLocal {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("sessions: session %q is not local", sessionID)
|
||||
}
|
||||
if sess.localClient != nil || sess.Info.State == models.SessionConnecting || sess.Info.State == models.SessionConnected {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("sessions: local session %q is still running", sessionID)
|
||||
}
|
||||
if cols <= 0 {
|
||||
cols = 80
|
||||
}
|
||||
if rows <= 0 {
|
||||
rows = 24
|
||||
}
|
||||
sess.Info.State = models.SessionConnecting
|
||||
sess.Info.Error = ""
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionConnecting),
|
||||
})
|
||||
go m.startLocalSession(sess, cols, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) startLocalSession(sess *Session, cols, rows int) {
|
||||
m.mu.RLock()
|
||||
sessionID := sess.Info.ID
|
||||
shell := sess.localShell
|
||||
startDir := sess.localCwd
|
||||
m.mu.RUnlock()
|
||||
var client *localshell.Client
|
||||
client = localshell.NewClient(localshell.Config{
|
||||
Shell: shell,
|
||||
StartDir: startDir,
|
||||
Cols: terminalDimension(cols, 80),
|
||||
Rows: terminalDimension(rows, 24),
|
||||
OnData: func(data []byte) {
|
||||
m.emitEvent("session:data", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"data": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
m.mu.RLock()
|
||||
rec := sess.recorder
|
||||
m.mu.RUnlock()
|
||||
if rec != nil {
|
||||
rec.WriteOutput(data)
|
||||
}
|
||||
},
|
||||
OnError: func(err error) {
|
||||
m.mu.Lock()
|
||||
if sess.localClient != client {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
sess.Info.State = models.SessionError
|
||||
sess.Info.Error = err.Error()
|
||||
m.mu.Unlock()
|
||||
@@ -272,29 +308,85 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
},
|
||||
OnClose: func() {
|
||||
m.handleLocalSessionClosed(sessionID, client)
|
||||
},
|
||||
})
|
||||
|
||||
// Publish the client before Start so a resize arriving during process
|
||||
// creation is retained by Client.Resize and used for the initial PTY size.
|
||||
m.mu.Lock()
|
||||
if sess.Info.State != models.SessionConnecting {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
sess.localClient = client
|
||||
pendingCols, pendingRows := sess.consumePendingResizeLocked()
|
||||
m.mu.Unlock()
|
||||
applyLocalResize(client, pendingCols, pendingRows)
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
m.mu.Lock()
|
||||
emitError := false
|
||||
if sess.localClient == client {
|
||||
sess.localClient = nil
|
||||
sess.Info.State = models.SessionError
|
||||
sess.Info.Error = err.Error()
|
||||
emitError = true
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
sess.localClient = client
|
||||
cols, rows := sess.consumePendingResizeLocked()
|
||||
m.mu.Unlock()
|
||||
applyLocalResize(client, cols, rows)
|
||||
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionConnected
|
||||
sess.Info.ConnectedAt = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionConnected),
|
||||
})
|
||||
if emitError {
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.auditLog.LogConnect("", sessionID, "local", "localhost")
|
||||
}()
|
||||
m.mu.Lock()
|
||||
if sess.localClient != client || sess.Info.State != models.SessionConnecting {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
sess.Info.State = models.SessionConnected
|
||||
sess.Info.ConnectedAt = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
return sessionID, nil
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionConnected),
|
||||
})
|
||||
m.auditLog.LogConnect("", sessionID, "local", "localhost")
|
||||
}
|
||||
|
||||
func (m *Manager) handleLocalSessionClosed(sessionID string, client *localshell.Client) {
|
||||
m.mu.Lock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
if !ok || sess.localClient != client {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
sess.localClient = nil
|
||||
if cwd := client.WorkingDirectory(); cwd != "" {
|
||||
sess.localCwd = cwd
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.handleSessionClosed(sessionID)
|
||||
}
|
||||
|
||||
func terminalDimension(value int, fallback uint16) uint16 {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if value > int(^uint16(0)) {
|
||||
return ^uint16(0)
|
||||
}
|
||||
return uint16(value)
|
||||
}
|
||||
|
||||
// Disconnect closes a specific session.
|
||||
@@ -353,29 +445,31 @@ func (m *Manager) Reconnect(sessionID string, getSecret func(prompt string) (str
|
||||
func (m *Manager) WriteToSession(sessionID string, data []byte) error {
|
||||
m.mu.RLock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
m.mu.RUnlock()
|
||||
return fmt.Errorf("sessions: session %q not found", sessionID)
|
||||
}
|
||||
rec := sess.recorder
|
||||
sshClient := sess.sshClient
|
||||
telnetClient := sess.telnetClient
|
||||
rawClient := sess.rawClient
|
||||
localClient := sess.localClient
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Record input if recording is active.
|
||||
m.mu.RLock()
|
||||
rec := sess.recorder
|
||||
m.mu.RUnlock()
|
||||
if rec != nil {
|
||||
rec.WriteInput(data)
|
||||
}
|
||||
|
||||
switch {
|
||||
case sess.sshClient != nil:
|
||||
return sess.sshClient.Write(data)
|
||||
case sess.telnetClient != nil:
|
||||
return sess.telnetClient.Write(data)
|
||||
case sess.rawClient != nil:
|
||||
return sess.rawClient.Write(data)
|
||||
case sess.localClient != nil:
|
||||
return sess.localClient.Write(data)
|
||||
case sshClient != nil:
|
||||
return sshClient.Write(data)
|
||||
case telnetClient != nil:
|
||||
return telnetClient.Write(data)
|
||||
case rawClient != nil:
|
||||
return rawClient.Write(data)
|
||||
case localClient != nil:
|
||||
return localClient.Write(data)
|
||||
}
|
||||
return fmt.Errorf("sessions: no active client for session %q", sessionID)
|
||||
}
|
||||
@@ -405,7 +499,7 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
|
||||
case telnetClient != nil:
|
||||
return telnetClient.Resize(cols, rows)
|
||||
case localClient != nil:
|
||||
return localClient.Resize(uint16(cols), uint16(rows))
|
||||
return localClient.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24))
|
||||
}
|
||||
// RAW sessions have no concept of terminal size.
|
||||
return nil
|
||||
@@ -439,7 +533,7 @@ func applyLocalResize(client *localshell.Client, cols, rows int) {
|
||||
if cols <= 0 || rows <= 0 {
|
||||
return
|
||||
}
|
||||
_ = client.Resize(uint16(cols), uint16(rows))
|
||||
_ = client.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24))
|
||||
}
|
||||
|
||||
// GetSessionCwd returns a best-effort working directory for SSH sessions.
|
||||
@@ -2021,9 +2115,12 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
sess.ircClient.Close()
|
||||
sess.ircClient = nil
|
||||
}
|
||||
if sess.localClient != nil {
|
||||
sess.localClient.Close()
|
||||
sess.localClient = nil
|
||||
m.mu.Lock()
|
||||
localClient := sess.localClient
|
||||
sess.localClient = nil
|
||||
m.mu.Unlock()
|
||||
if localClient != nil {
|
||||
localClient.Close()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -3,9 +3,16 @@ package sessions
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -154,6 +161,103 @@ func TestCloseAllEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalDimension(t *testing.T) {
|
||||
if got := terminalDimension(0, 80); got != 80 {
|
||||
t.Fatalf("terminalDimension(0, 80) = %d, want 80", got)
|
||||
}
|
||||
if got := terminalDimension(132, 80); got != 132 {
|
||||
t.Fatalf("terminalDimension(132, 80) = %d, want 132", got)
|
||||
}
|
||||
if got := terminalDimension(100000, 80); got != ^uint16(0) {
|
||||
t.Fatalf("terminalDimension(100000, 80) = %d, want %d", got, ^uint16(0))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartLocalReusesSessionAndAppliesRequestedSize(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("local PTY test requires a Unix shell")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "local-shell.sh")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auditStore := &sessionAuditStore{}
|
||||
mgr := NewManager(nil, nil, nil, audit.NewLogger(auditStore), nil)
|
||||
var outputMu sync.Mutex
|
||||
var output strings.Builder
|
||||
disconnected := make(chan string, 2)
|
||||
mgr.SetEventSink(func(name string, payload interface{}) {
|
||||
values, ok := payload.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if name == "session:data" {
|
||||
encoded, _ := values["data"].(string)
|
||||
decoded, _ := base64.StdEncoding.DecodeString(encoded)
|
||||
outputMu.Lock()
|
||||
_, _ = output.Write(decoded)
|
||||
outputMu.Unlock()
|
||||
}
|
||||
if name == "session:state" && values["state"] == string(models.SessionDisconnected) {
|
||||
disconnected <- values["sessionID"].(string)
|
||||
}
|
||||
})
|
||||
|
||||
sessionID, err := mgr.ConnectLocal(script)
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectLocal() error = %v", err)
|
||||
}
|
||||
waitForDisconnectedSession(t, disconnected, sessionID)
|
||||
|
||||
if err := mgr.RestartLocal(sessionID, 132, 43); err != nil {
|
||||
t.Fatalf("RestartLocal() error = %v", err)
|
||||
}
|
||||
waitForDisconnectedSession(t, disconnected, sessionID)
|
||||
|
||||
info := mgr.GetSessionInfo(sessionID)
|
||||
if info == nil || info.ID != sessionID || info.State != models.SessionDisconnected {
|
||||
t.Fatalf("session after restart = %#v, want same disconnected session", info)
|
||||
}
|
||||
outputMu.Lock()
|
||||
got := strings.ReplaceAll(output.String(), "\r", "")
|
||||
outputMu.Unlock()
|
||||
if !strings.Contains(got, "43 132\n") {
|
||||
t.Fatalf("local output %q does not contain restarted size 43 132", got)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDisconnectedSession(t *testing.T, states <-chan string, want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-states:
|
||||
if got != want {
|
||||
t.Fatalf("disconnected session = %q, want %q", got, want)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed out waiting for session %q to disconnect", want)
|
||||
}
|
||||
}
|
||||
|
||||
type sessionAuditStore struct {
|
||||
mu sync.Mutex
|
||||
entries []*models.AuditEntry
|
||||
}
|
||||
|
||||
func (s *sessionAuditStore) AddAuditEntry(entry *models.AuditEntry) error {
|
||||
s.mu.Lock()
|
||||
s.entries = append(s.entries, entry)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sessionAuditStore) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]*models.AuditEntry(nil), s.entries...), nil
|
||||
}
|
||||
|
||||
func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) {
|
||||
store := newSessionTestStore(t)
|
||||
mgr := NewManager(store, nil, nil, nil, nil)
|
||||
|
||||
@@ -306,6 +306,7 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
|
||||
prefs.ConfirmOnClose = true
|
||||
prefs.DefaultFontSize = 16
|
||||
prefs.Theme = "custom-dark"
|
||||
prefs.LeviathanEnabled = true
|
||||
|
||||
if err := store.SavePreferences(&prefs); err != nil {
|
||||
t.Fatalf("SavePreferences() error = %v", err)
|
||||
@@ -327,6 +328,9 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
|
||||
if got.Theme != "custom-dark" {
|
||||
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
|
||||
}
|
||||
if !got.LeviathanEnabled {
|
||||
t.Error("LeviathanEnabled = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user