diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 4125fc7..26e4420 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -ce0d75b28075a7688ba4535c94b4784c \ No newline at end of file +99c6bbb0236daee684251a5f62bbd974 \ No newline at end of file diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css index f971625..d82fc47 100644 --- a/frontend/src/css/forms.css +++ b/frontend/src/css/forms.css @@ -467,6 +467,16 @@ color: var(--danger); } +.settings-shell-row { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.settings-shell-row .form-input { + flex: 1; +} + .form-success { font-size: var(--text-xs); color: var(--success); diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css index e4d5b26..33e5ed1 100644 --- a/frontend/src/css/sidebar.css +++ b/frontend/src/css/sidebar.css @@ -253,6 +253,12 @@ .sidebar-item .protocol-indicator.proto-github::before { content: "GH"; } +.sidebar-item .protocol-indicator.proto-vnc::before { + content: "⊞"; +} +.sidebar-item .protocol-indicator.proto-local::before { + content: "›_"; +} .sidebar-item .protocol-indicator.proto-ssh { background: rgba(78, 205, 196, 0.18); @@ -270,6 +276,21 @@ background: rgba(124, 138, 255, 0.18); color: var(--protocol-github); } +.sidebar-item .protocol-indicator.proto-vnc { + background: rgba(232, 93, 74, 0.18); + color: var(--protocol-vnc); +} +.sidebar-item .protocol-indicator.proto-local { + background: rgba(139, 195, 74, 0.18); + color: var(--protocol-local); +} + +/* Local machine item separator */ +.sidebar-item-local { + border-bottom: 1px solid var(--border-subtle); + margin: var(--space-2) var(--space-2) var(--space-2); + padding-bottom: var(--space-2); +} /* Text block (name + host) */ .sidebar-item-text { diff --git a/frontend/src/css/tabs.css b/frontend/src/css/tabs.css index f79bc35..563633d 100644 --- a/frontend/src/css/tabs.css +++ b/frontend/src/css/tabs.css @@ -114,6 +114,14 @@ background: rgba(124, 138, 255, 0.15); color: var(--protocol-github); } +.tab-badge.badge-vnc { + background: rgba(232, 93, 74, 0.15); + color: var(--protocol-vnc); +} +.tab-badge.badge-local { + background: rgba(139, 195, 74, 0.15); + color: var(--protocol-local); +} /* ----------------------------------------------------------------------- Status dot (connection state) diff --git a/frontend/src/css/tokens.css b/frontend/src/css/tokens.css index fedfee4..b2ca5e0 100644 --- a/frontend/src/css/tokens.css +++ b/frontend/src/css/tokens.css @@ -58,6 +58,7 @@ --protocol-raw: #ab47bc; --protocol-github: #7c8aff; --protocol-vnc: #e85d4a; + --protocol-local: #8bc34a; /* ----------------------------------------------------------------------- Spacing scale (4 px base unit) diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index d2d508e..544a5eb 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -541,6 +541,9 @@ this._bindClick("btn-new-tab", () => this.profileEditor.openNew()); this._bindClick("btn-broadcast", () => this.toggleBroadcast()); + // Wire the "Local Machine" sidebar button + this.sidebar.onConnectLocal = () => this.connectLocal(); + // Right-click on broadcast button opens target selector const broadcastBtn = document.getElementById("btn-broadcast"); if (broadcastBtn) { @@ -809,6 +812,30 @@ } } + /** + * Opens a local terminal session. + */ + async connectLocal() { + try { + const sessionInfo = await window.go.api.Service.ConnectLocal(); + this.state.addSession(sessionInfo); + this.tabs.addTab(sessionInfo); + + const panelId = `panel-${sessionInfo.id}`; + const prefs = this.state.preferences || {}; + this.terminals.createTerminal(sessionInfo.id, panelId, { + terminalFontFamily: prefs.defaultFontFamily, + terminalFontSize: prefs.defaultFontSize, + terminalScrollback: prefs.defaultScrollback, + terminalCursorStyle: prefs.defaultCursorStyle, + }); + + this.notifications.info("Opening local terminal…"); + } catch (err) { + this.notifications.error("Local terminal failed: " + err); + } + } + /** * Opens a quick connect dialog for ad-hoc connections without saving a profile. */ diff --git a/frontend/src/js/commands.js b/frontend/src/js/commands.js index b1ec44e..9b328e6 100644 --- a/frontend/src/js/commands.js +++ b/frontend/src/js/commands.js @@ -107,6 +107,14 @@ action: () => this.app.quickConnect(), }); + // Local terminal + cmds.push({ + id: "local-terminal", + label: "Open Local Terminal", + section: "Actions", + action: () => this.app.connectLocal(), + }); + // Tab actions cmds.push({ id: "close-tab", diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index fc328a6..d58a421 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -53,6 +53,12 @@ this._knownHosts = []; } + try { + this._defaultShell = await window.go.api.Service.GetDefaultShell(); + } catch (_) { + this._defaultShell = ""; + } + this._render(); } @@ -281,6 +287,45 @@ { min: 180, max: 500 }, ), ); + + // Local shell path with browse button + const shellGroup = createElement("div", "form-group settings-field"); + const shellLabel = createElement("label", "form-label", { + textContent: "Local shell", + for: "setting-localShell", + }); + shellGroup.appendChild(shellLabel); + + const shellRow = createElement("div", "settings-shell-row"); + const shellInput = createElement("input", "form-input", { + type: "text", + id: "setting-localShell", + name: "setting-localShell", + value: p.localShell || "", + placeholder: this._defaultShell || "/bin/zsh", + }); + shellRow.appendChild(shellInput); + + const browseBtn = createElement("button", "btn btn-sm btn-secondary", { + textContent: "Browse…", + type: "button", + }); + browseBtn.addEventListener("click", async () => { + try { + const path = await window.go.api.Service.BrowseForShell(); + if (path) { + shellInput.value = path; + } + } catch (_) {} + }); + shellRow.appendChild(browseBtn); + shellGroup.appendChild(shellRow); + + const shellHint = createElement("span", "form-hint", { + textContent: "Leave empty to use system default", + }); + shellGroup.appendChild(shellHint); + body.appendChild(shellGroup); } /** @@ -1050,6 +1095,7 @@ "defaultScrollback", "defaultCursorStyle", "useNativePaste", + "localShell", ]; for (const field of fields) { diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js index 199a015..78c962e 100644 --- a/frontend/src/js/sidebar.js +++ b/frontend/src/js/sidebar.js @@ -68,6 +68,9 @@ this.el.appendChild(this._renderBulkActions()); } + // Local Machine quick-connect button + this.el.appendChild(this._renderLocalMachineItem()); + // Favourites const favourites = profiles.filter((p) => p.favourite); if (favourites.length > 0) { @@ -531,6 +534,36 @@ ); } + /** @private */ + _renderLocalMachineItem() { + const item = createElement("div", "sidebar-item sidebar-item-local"); + item.addEventListener("mousedown", (e) => e.preventDefault()); + + const indicator = createElement( + "span", + "protocol-indicator proto-local", + { + title: "Local", + }, + ); + item.appendChild(indicator); + + const textWrapper = createElement("div", "sidebar-item-text"); + const nameEl = createElement("span", "sidebar-item-name", { + textContent: "Local Machine", + }); + textWrapper.appendChild(nameEl); + item.appendChild(textWrapper); + + item.addEventListener("click", () => { + if (this.onConnectLocal) { + this.onConnectLocal(); + } + }); + + return item; + } + /** @private */ _renderBulkActions() { const bar = createElement("div", "sidebar-bulk-actions"); diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index 6c82759..237c5e1 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -480,7 +480,9 @@ const protoLabel = sessionInfo.protocol === "github" ? "GitHub" - : sessionInfo.protocol.toUpperCase(); + : sessionInfo.protocol === "local" + ? "Local" + : sessionInfo.protocol.toUpperCase(); const badge = createElement( "span", `tab-badge badge-${sessionInfo.protocol}`, diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js index 8e94e65..0c66aa7 100644 --- a/frontend/src/js/utils.js +++ b/frontend/src/js/utils.js @@ -229,6 +229,8 @@ return "proto-github"; case "vnc": return "proto-vnc"; + case "local": + return "proto-local"; default: return ""; } diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index 7fe3def..7067e3b 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -1,8 +1,11 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT import {models} from '../models'; +import {api} from '../models'; import {context} from '../models'; +export function BrowseForShell():Promise; + export function CancelReconnect(arg1:string):Promise; export function CheckForUpdates():Promise>; @@ -11,6 +14,8 @@ export function ClearSessionHistory(arg1:string):Promise; export function CloseAllSessions():Promise; +export function ConnectLocal():Promise; + export function ConnectQuick(arg1:models.Profile):Promise; export function ConnectSession(arg1:string):Promise; @@ -27,6 +32,8 @@ export function DeleteProfileSecrets(arg1:string):Promise; export function DeleteRecording(arg1:string):Promise; +export function DeleteSSHKey(arg1:string):Promise; + export function DeleteSnippet(arg1:string):Promise; export function DeleteTOTPSecret(arg1:string):Promise; @@ -45,20 +52,28 @@ export function FileExists(arg1:string):Promise; export function FileIsPrivateKey(arg1:string):Promise; +export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise; + export function GetAppInfo():Promise>; +export function GetDefaultShell():Promise; + export function GetPreferences():Promise; export function GetProfile(arg1:string):Promise; export function GetRecording(arg1:string):Promise>; +export function GetSSHPublicKey(arg1:string):Promise; + export function GetSessionCwd(arg1:string):Promise; export function GetSessionHistory(arg1:string):Promise>>; export function GetTOTPCode(arg1:string):Promise; +export function GetVNCWebSocketURL(arg1:string):Promise; + export function HasTOTPSecret(arg1:string):Promise; export function ImportProfiles(arg1:string,arg2:string):Promise; @@ -93,6 +108,8 @@ export function ListProfilesByTag(arg1:string):Promise>; export function ListRecordings():Promise>>; +export function ListSSHKeyPairs():Promise>; + export function ListSSHKeys():Promise>; export function ListSessions():Promise>; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index 9bdd955..6b150ac 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function BrowseForShell() { + return window['go']['api']['Service']['BrowseForShell'](); +} + export function CancelReconnect(arg1) { return window['go']['api']['Service']['CancelReconnect'](arg1); } @@ -18,6 +22,10 @@ export function CloseAllSessions() { return window['go']['api']['Service']['CloseAllSessions'](); } +export function ConnectLocal() { + return window['go']['api']['Service']['ConnectLocal'](); +} + export function ConnectQuick(arg1) { return window['go']['api']['Service']['ConnectQuick'](arg1); } @@ -50,6 +58,10 @@ export function DeleteRecording(arg1) { return window['go']['api']['Service']['DeleteRecording'](arg1); } +export function DeleteSSHKey(arg1) { + return window['go']['api']['Service']['DeleteSSHKey'](arg1); +} + export function DeleteSnippet(arg1) { return window['go']['api']['Service']['DeleteSnippet'](arg1); } @@ -86,10 +98,18 @@ export function FileIsPrivateKey(arg1) { return window['go']['api']['Service']['FileIsPrivateKey'](arg1); } +export function GenerateSSHKey(arg1, arg2, arg3, arg4) { + return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4); +} + export function GetAppInfo() { return window['go']['api']['Service']['GetAppInfo'](); } +export function GetDefaultShell() { + return window['go']['api']['Service']['GetDefaultShell'](); +} + export function GetPreferences() { return window['go']['api']['Service']['GetPreferences'](); } @@ -102,6 +122,10 @@ export function GetRecording(arg1) { return window['go']['api']['Service']['GetRecording'](arg1); } +export function GetSSHPublicKey(arg1) { + return window['go']['api']['Service']['GetSSHPublicKey'](arg1); +} + export function GetSessionCwd(arg1) { return window['go']['api']['Service']['GetSessionCwd'](arg1); } @@ -114,6 +138,10 @@ export function GetTOTPCode(arg1) { return window['go']['api']['Service']['GetTOTPCode'](arg1); } +export function GetVNCWebSocketURL(arg1) { + return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1); +} + export function HasTOTPSecret(arg1) { return window['go']['api']['Service']['HasTOTPSecret'](arg1); } @@ -182,6 +210,10 @@ export function ListRecordings() { return window['go']['api']['Service']['ListRecordings'](); } +export function ListSSHKeyPairs() { + return window['go']['api']['Service']['ListSSHKeyPairs'](); +} + export function ListSSHKeys() { return window['go']['api']['Service']['ListSSHKeys'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 68b7707..66d2e19 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,3 +1,28 @@ +export namespace api { + + export class SSHKeyInfo { + name: string; + type: string; + bits: string; + publicKey: string; + privateKeyPath: string; + + static createFrom(source: any = {}) { + return new SSHKeyInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.type = source["type"]; + this.bits = source["bits"]; + this.publicKey = source["publicKey"]; + this.privateKeyPath = source["privateKeyPath"]; + } + } + +} + export namespace models { export class AppPreferences { @@ -13,6 +38,7 @@ export namespace models { defaultScrollback: number; defaultCursorStyle: string; useNativePaste: boolean; + localShell: string; static createFrom(source: any = {}) { return new AppPreferences(source); @@ -32,6 +58,7 @@ export namespace models { this.defaultScrollback = source["defaultScrollback"]; this.defaultCursorStyle = source["defaultCursorStyle"]; this.useNativePaste = source["useNativePaste"]; + this.localShell = source["localShell"]; } } export class AuditEntry { @@ -222,6 +249,46 @@ export namespace models { this.label = source["label"]; } } + export class VNCConfig { + vncHost: string; + vncPort: number; + secretMode: string; + viewOnly: boolean; + scaleToFit: boolean; + quality: number; + sharedSession: boolean; + sshTunnel: boolean; + sshHost: string; + sshPort: number; + sshUsername: string; + sshAuthMethod: string; + sshSecretMode: string; + sshPrivateKeyPath: string; + sshAgentAuth: boolean; + + static createFrom(source: any = {}) { + return new VNCConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.vncHost = source["vncHost"]; + this.vncPort = source["vncPort"]; + this.secretMode = source["secretMode"]; + this.viewOnly = source["viewOnly"]; + this.scaleToFit = source["scaleToFit"]; + this.quality = source["quality"]; + this.sharedSession = source["sharedSession"]; + this.sshTunnel = source["sshTunnel"]; + this.sshHost = source["sshHost"]; + this.sshPort = source["sshPort"]; + this.sshUsername = source["sshUsername"]; + this.sshAuthMethod = source["sshAuthMethod"]; + this.sshSecretMode = source["sshSecretMode"]; + this.sshPrivateKeyPath = source["sshPrivateKeyPath"]; + this.sshAgentAuth = source["sshAgentAuth"]; + } + } export class RAWConfig { autoReconnect: boolean; lineEnding: string; @@ -410,6 +477,7 @@ export namespace models { telnet?: TelnetConfig; raw?: RAWConfig; github?: GitHubConfig; + vnc?: VNCConfig; static createFrom(source: any = {}) { return new Profile(source); @@ -442,6 +510,7 @@ export namespace models { this.telnet = this.convertValues(source["telnet"], TelnetConfig); this.raw = this.convertValues(source["raw"], RAWConfig); this.github = this.convertValues(source["github"], GitHubConfig); + this.vnc = this.convertValues(source["vnc"], VNCConfig); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -522,6 +591,7 @@ export namespace models { this.updatedAt = source["updatedAt"]; } } + } diff --git a/go.mod b/go.mod index 28a02d8..101abd7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/andydixon/nexus go 1.25.0 require ( + github.com/creack/pty v1.1.24 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/mattn/go-sqlite3 v1.14.22 github.com/pkg/sftp v1.13.10 github.com/pquerna/otp v1.5.0 @@ -22,7 +24,6 @@ require ( github.com/danieljoos/wincred v1.2.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/kr/fs v0.1.0 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect diff --git a/go.sum b/go.sum index 6f7dc2e..e5f290c 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/api/api.go b/internal/api/api.go index 0ab330d..9b76711 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 // --------------------------------------------------------------------------- diff --git a/internal/localshell/localshell.go b/internal/localshell/localshell.go new file mode 100644 index 0000000..d9a1542 --- /dev/null +++ b/internal/localshell/localshell.go @@ -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 + } + } +} diff --git a/internal/models/defaults.go b/internal/models/defaults.go index 346b62c..df77c9c 100644 --- a/internal/models/defaults.go +++ b/internal/models/defaults.go @@ -51,6 +51,7 @@ func DefaultAppPreferences() AppPreferences { DefaultScrollback: DefaultScrollback, DefaultCursorStyle: DefaultCursorStyle, UseNativePaste: false, + LocalShell: "", // empty means use system default } } diff --git a/internal/models/models.go b/internal/models/models.go index 95532b6..647b7b8 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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. diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 3281f4b..88a97d1 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -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