feat: implement local terminal support with shell configuration and UI integration
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/exportimport"
|
||||
"github.com/andydixon/nexus/internal/localshell"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/preferences"
|
||||
"github.com/andydixon/nexus/internal/secrets"
|
||||
@@ -369,6 +370,24 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
|
||||
return *info, nil
|
||||
}
|
||||
|
||||
// ConnectLocal opens a local shell session using the configured (or default) shell.
|
||||
func (s *Service) ConnectLocal() (models.SessionInfo, error) {
|
||||
shell := ""
|
||||
if s.prefs != nil {
|
||||
prefs := s.prefs.Get()
|
||||
shell = prefs.LocalShell
|
||||
}
|
||||
sessionID, err := s.sessions.ConnectLocal(shell)
|
||||
if err != nil {
|
||||
return models.SessionInfo{}, err
|
||||
}
|
||||
info := s.sessions.GetSessionInfo(sessionID)
|
||||
if info == nil {
|
||||
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
|
||||
}
|
||||
return *info, nil
|
||||
}
|
||||
|
||||
// DisconnectSession closes a session by ID.
|
||||
func (s *Service) DisconnectSession(sessionID string) error {
|
||||
return s.sessions.Disconnect(sessionID)
|
||||
@@ -923,6 +942,29 @@ func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
|
||||
return s.sessions.GetVNCWebSocketURL(sessionID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetDefaultShell returns the system's default shell path.
|
||||
func (s *Service) GetDefaultShell() string {
|
||||
return localshell.DefaultShell()
|
||||
}
|
||||
|
||||
// BrowseForShell opens a file dialog to select a shell binary.
|
||||
func (s *Service) BrowseForShell() (string, error) {
|
||||
if s.ctx == nil {
|
||||
return "", fmt.Errorf("application context not set")
|
||||
}
|
||||
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Shell Binary",
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file dialog failed: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port forwarding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Package localshell provides a local PTY-based terminal session.
|
||||
// It spawns a shell process and bridges I/O for the frontend terminal.
|
||||
package localshell
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// DefaultShell returns the system default shell.
|
||||
func DefaultShell() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Prefer PowerShell if available, else cmd.exe.
|
||||
if ps, err := exec.LookPath("powershell.exe"); err == nil {
|
||||
return ps
|
||||
}
|
||||
return "cmd.exe"
|
||||
}
|
||||
// Unix: use SHELL env var, fallback to /bin/sh.
|
||||
if sh := os.Getenv("SHELL"); sh != "" {
|
||||
return sh
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
// NewClient creates a new local shell client.
|
||||
func NewClient(cfg Config) *Client {
|
||||
shell := cfg.Shell
|
||||
if shell == "" {
|
||||
shell = DefaultShell()
|
||||
}
|
||||
cols := cfg.Cols
|
||||
if cols == 0 {
|
||||
cols = 80
|
||||
}
|
||||
rows := cfg.Rows
|
||||
if rows == 0 {
|
||||
rows = 24
|
||||
}
|
||||
return &Client{
|
||||
shell: shell,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
onData: cfg.OnData,
|
||||
onError: cfg.OnError,
|
||||
onClose: cfg.OnClose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start spawns the shell process and begins reading output.
|
||||
func (c *Client) Start() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.ptmx != nil {
|
||||
return fmt.Errorf("shell already running")
|
||||
}
|
||||
|
||||
cmd := exec.Command(c.shell)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
// Start in the user's home directory.
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
cmd.Dir = home
|
||||
}
|
||||
|
||||
// Set TERM for proper terminal behaviour.
|
||||
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
||||
Cols: c.cols,
|
||||
Rows: c.rows,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start shell %q: %w", c.shell, err)
|
||||
}
|
||||
|
||||
c.cmd = cmd
|
||||
c.ptmx = ptmx
|
||||
|
||||
// Read loop — forward PTY output to the frontend.
|
||||
go c.readLoop()
|
||||
|
||||
// Wait for process exit.
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
if !wasClosed && c.onClose != nil {
|
||||
c.onClose()
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write sends data to the shell's stdin (PTY).
|
||||
func (c *Client) Write(data []byte) error {
|
||||
c.mu.Lock()
|
||||
ptmx := c.ptmx
|
||||
c.mu.Unlock()
|
||||
|
||||
if ptmx == nil {
|
||||
return fmt.Errorf("shell not running")
|
||||
}
|
||||
_, err := ptmx.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// Resize updates the PTY window size.
|
||||
func (c *Client) Resize(cols, rows uint16) error {
|
||||
c.mu.Lock()
|
||||
ptmx := c.ptmx
|
||||
c.mu.Unlock()
|
||||
|
||||
if ptmx == nil {
|
||||
return nil
|
||||
}
|
||||
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
|
||||
}
|
||||
|
||||
// Close terminates the shell process and PTY.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
|
||||
if c.ptmx != nil {
|
||||
c.ptmx.Close()
|
||||
c.ptmx = nil
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
c.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := c.ptmx.Read(buf)
|
||||
if n > 0 && c.onData != nil {
|
||||
c.onData(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.mu.Unlock()
|
||||
if !wasClosed && c.onError != nil {
|
||||
c.onError(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func DefaultAppPreferences() AppPreferences {
|
||||
DefaultScrollback: DefaultScrollback,
|
||||
DefaultCursorStyle: DefaultCursorStyle,
|
||||
UseNativePaste: false,
|
||||
LocalShell: "", // empty means use system default
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
ProtocolRAW Protocol = "raw"
|
||||
ProtocolGitHub Protocol = "github"
|
||||
ProtocolVNC Protocol = "vnc"
|
||||
ProtocolLocal Protocol = "local"
|
||||
)
|
||||
|
||||
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
|
||||
@@ -302,6 +303,7 @@ type AppPreferences struct {
|
||||
DefaultScrollback int `json:"defaultScrollback"`
|
||||
DefaultCursorStyle string `json:"defaultCursorStyle"`
|
||||
UseNativePaste bool `json:"useNativePaste"`
|
||||
LocalShell string `json:"localShell"` // path to shell binary for local terminal
|
||||
}
|
||||
|
||||
// AuditEntry records an auditable action within the application.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/githubclient"
|
||||
"github.com/andydixon/nexus/internal/localshell"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/rawclient"
|
||||
"github.com/andydixon/nexus/internal/recording"
|
||||
@@ -38,6 +39,7 @@ type Session struct {
|
||||
githubClient *githubclient.Client
|
||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
||||
localClient *localshell.Client // local shell PTY, nil for non-local sessions
|
||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||
profile *models.Profile
|
||||
recorder *recording.Recorder // active recording, nil if not recording
|
||||
@@ -163,6 +165,104 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// ConnectLocal creates a local shell session without a saved profile.
|
||||
// The shell path is resolved from the given parameter or falls back to the system default.
|
||||
func (m *Manager) ConnectLocal(shell string) (string, error) {
|
||||
sessionID := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
info := models.SessionInfo{
|
||||
ID: sessionID,
|
||||
ProfileID: "",
|
||||
ProfileName: "Local Machine",
|
||||
Protocol: models.ProtocolLocal,
|
||||
Host: "localhost",
|
||||
Port: 0,
|
||||
State: models.SessionConnecting,
|
||||
TabName: "Local",
|
||||
ConnectedAt: now,
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
Info: info,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.sessions[sessionID] = sess
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"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()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
OnClose: func() {
|
||||
m.handleSessionClosed(sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionError
|
||||
sess.Info.Error = err.Error()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess.localClient = client
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
m.auditLog.LogConnect("", sessionID, "local", "localhost")
|
||||
}()
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// Disconnect closes a specific session.
|
||||
func (m *Manager) Disconnect(sessionID string) error {
|
||||
m.mu.Lock()
|
||||
@@ -240,6 +340,8 @@ func (m *Manager) WriteToSession(sessionID string, data []byte) error {
|
||||
return sess.telnetClient.Write(data)
|
||||
case sess.rawClient != nil:
|
||||
return sess.rawClient.Write(data)
|
||||
case sess.localClient != nil:
|
||||
return sess.localClient.Write(data)
|
||||
}
|
||||
return fmt.Errorf("sessions: no active client for session %q", sessionID)
|
||||
}
|
||||
@@ -259,6 +361,8 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
|
||||
return sess.sshClient.Resize(cols, rows)
|
||||
case sess.telnetClient != nil:
|
||||
return sess.telnetClient.Resize(cols, rows)
|
||||
case sess.localClient != nil:
|
||||
return sess.localClient.Resize(uint16(cols), uint16(rows))
|
||||
}
|
||||
// RAW sessions have no concept of terminal size.
|
||||
return nil
|
||||
@@ -1490,6 +1594,10 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
sess.vncClient.Close()
|
||||
sess.vncClient = nil
|
||||
}
|
||||
if sess.localClient != nil {
|
||||
sess.localClient.Close()
|
||||
sess.localClient = nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionDisconnected
|
||||
|
||||
Reference in New Issue
Block a user