diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css index d5ed6fe..b787e29 100644 --- a/frontend/src/css/forms.css +++ b/frontend/src/css/forms.css @@ -486,12 +486,66 @@ } .jump-host-row { - display: grid; - grid-template-columns: 1fr 80px 1fr auto; + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2); + background: var(--surface-2); + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +.jump-host-fields { + display: flex; gap: var(--space-2); align-items: center; } +/* ----------------------------------------------------------------------- + Port-forwarding rows + ----------------------------------------------------------------------- */ +.port-forwards-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.port-forward-row { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2); + background: var(--surface-2); + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +.port-forward-fields { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.port-forward-arrow { + color: var(--text-muted); + font-size: var(--text-sm); + flex-shrink: 0; +} + +.port-forward-enabled-label { + display: flex; + gap: var(--space-1); + align-items: center; + font-size: var(--text-xs); + color: var(--text-secondary); + white-space: nowrap; + cursor: pointer; +} + +.port-forward-enabled-label input[type="checkbox"] { + accent-color: var(--accent); +} + /* ----------------------------------------------------------------------- Payload preset rows ----------------------------------------------------------------------- */ diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index ea6c8d5..e862cd5 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -180,6 +180,21 @@ class App { ); }); + // Port-forwarding events + window.runtime.EventsOn('session:forward:started', (payload) => { + const fwd = payload.forward || {}; + const label = fwd.label || `${fwd.bindHost || ''}:${fwd.bindPort} → ${fwd.destHost}:${fwd.destPort}`; + this.notifications.success(`Forward started: ${label}`); + }); + + window.runtime.EventsOn('session:forward:stopped', (payload) => { + this.notifications.info(`Forward stopped: ${payload.forwardID}`); + }); + + window.runtime.EventsOn('session:forward:error', (payload) => { + this.notifications.error(`Forward error: ${payload.error}`); + }); + // Telnet negotiation debug messages window.runtime.EventsOn('session:debug', (payload) => { console.debug('[session:debug]', payload); diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 2bbfb14..4bc153b 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -24,6 +24,7 @@ const DEFAULTS = { keepaliveCountMax: 3, compression: false, jumpHosts: [], + portForwards: [], startupCommand: '', envVars: {}, }, @@ -373,6 +374,11 @@ class ProfileEditor { this._renderJumpHostsList(ssh.jumpHosts || []), ], true)); + // Port Forwarding + sections.push(this._renderSection('Port Forwarding', [ + this._renderPortForwardsList(ssh.portForwards || []), + ], true)); + // Startup sections.push(this._renderSection('Startup', [ this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, { @@ -712,10 +718,15 @@ class ProfileEditor { const jh = jumpHosts[i]; const row = createElement('div', 'jump-host-row'); row.innerHTML = ` - - - - +
+ + + +
+
+ + +
`; row.querySelector('.jump-host-remove').addEventListener('click', () => { jumpHosts.splice(i, 1); @@ -734,6 +745,65 @@ class ProfileEditor { return wrapper; } + /** + * Renders the port-forwarding rules list editor. + * @param {Array} portForwards + * @returns {HTMLElement} + * @private + */ + _renderPortForwardsList(portForwards) { + const wrapper = createElement('div', 'port-forwards-list'); + + for (let i = 0; i < portForwards.length; i++) { + const pf = portForwards[i]; + const row = createElement('div', 'port-forward-row'); + row.innerHTML = ` +
+ + + + ⟶ + + +
+
+ + + +
+ `; + row.querySelector('.port-forward-remove').addEventListener('click', () => { + portForwards.splice(i, 1); + this.render(this._profile, this._isNew); + }); + wrapper.appendChild(row); + } + + const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Port Forward' }); + addBtn.addEventListener('click', () => { + portForwards.push({ + id: generateId(), + direction: 'local', + bindHost: '127.0.0.1', + bindPort: 0, + destHost: '', + destPort: 0, + enabled: true, + label: '', + }); + this.render(this._profile, this._isNew); + }); + wrapper.appendChild(addBtn); + + return wrapper; + } + /** * Renders the payload-presets list editor. * @param {Array} presets @@ -855,11 +925,29 @@ class ProfileEditor { username: val(`jumpHost-${ji}-username`) || '', authMethod: 'publickey', secretMode: 'ask-every-time', - privateKeyPath: '', + privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || '', }); ji++; } p.ssh.jumpHosts = jh; + + // Port forwards + const pfs = []; + let fi = 0; + while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) { + pfs.push({ + id: (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || generateId(), + direction: val(`portFwd-${fi}-direction`) || 'local', + bindHost: val(`portFwd-${fi}-bindHost`) || '127.0.0.1', + bindPort: val(`portFwd-${fi}-bindPort`) || 0, + destHost: val(`portFwd-${fi}-destHost`) || '', + destPort: val(`portFwd-${fi}-destPort`) || 0, + enabled: val(`portFwd-${fi}-enabled`) !== false, + label: val(`portFwd-${fi}-label`) || '', + }); + fi++; + } + p.ssh.portForwards = pfs; } // Telnet-specific diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index e119c55..ac42e42 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -47,6 +47,8 @@ export function ListFavourites():Promise>; export function ListKnownHosts():Promise>; +export function ListPortForwards(arg1:string):Promise>; + export function ListProfiles():Promise>; export function ListProfilesByFolder(arg1:string):Promise>; @@ -91,6 +93,10 @@ export function SetFavourite(arg1:string,arg2:boolean):Promise; export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise; +export function StartPortForward(arg1:string,arg2:models.PortForward):Promise; + +export function StopPortForward(arg1:string,arg2:string):Promise; + export function TestConnection(arg1:string,arg2:number):Promise>; export function UnlockSecretStore(arg1:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index 20b5570..b80377d 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -90,6 +90,10 @@ export function ListKnownHosts() { return window['go']['api']['Service']['ListKnownHosts'](); } +export function ListPortForwards(arg1) { + return window['go']['api']['Service']['ListPortForwards'](arg1); +} + export function ListProfiles() { return window['go']['api']['Service']['ListProfiles'](); } @@ -178,6 +182,14 @@ export function SetProfileSecret(arg1, arg2, arg3) { return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3); } +export function StartPortForward(arg1, arg2) { + return window['go']['api']['Service']['StartPortForward'](arg1, arg2); +} + +export function StopPortForward(arg1, arg2) { + return window['go']['api']['Service']['StopPortForward'](arg1, arg2); +} + export function TestConnection(arg1, arg2) { return window['go']['api']['Service']['TestConnection'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b4a2b11..681ccb2 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -209,6 +209,32 @@ export namespace models { this.format = source["format"]; } } + export class PortForward { + id: string; + direction: string; + bindHost: string; + bindPort: number; + destHost: string; + destPort: number; + enabled: boolean; + label: string; + + static createFrom(source: any = {}) { + return new PortForward(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.direction = source["direction"]; + this.bindHost = source["bindHost"]; + this.bindPort = source["bindPort"]; + this.destHost = source["destHost"]; + this.destPort = source["destPort"]; + this.enabled = source["enabled"]; + this.label = source["label"]; + } + } export class RAWConfig { autoReconnect: boolean; lineEnding: string; @@ -282,6 +308,7 @@ export namespace models { keepaliveCountMax: number; compression: boolean; jumpHosts: JumpHost[]; + portForwards: PortForward[]; startupCommand: string; envVars: Record; @@ -301,6 +328,7 @@ export namespace models { this.keepaliveCountMax = source["keepaliveCountMax"]; this.compression = source["compression"]; this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost); + this.portForwards = this.convertValues(source["portForwards"], PortForward); this.startupCommand = source["startupCommand"]; this.envVars = source["envVars"]; } diff --git a/internal/api/api.go b/internal/api/api.go index f91d2f7..bf4736c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -624,6 +624,25 @@ func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, err return s.sessions.FetchGitHubRepos(sessionID) } +// --------------------------------------------------------------------------- +// Port forwarding +// --------------------------------------------------------------------------- + +// ListPortForwards returns the active port-forwarding tunnels for an SSH session. +func (s *Service) ListPortForwards(sessionID string) ([]models.PortForward, error) { + return s.sessions.ListPortForwards(sessionID) +} + +// StartPortForward activates a new forwarding tunnel on a live SSH session. +func (s *Service) StartPortForward(sessionID string, rule models.PortForward) error { + return s.sessions.StartPortForward(sessionID, rule) +} + +// StopPortForward terminates an active forwarding tunnel. +func (s *Service) StopPortForward(sessionID string, forwardID string) error { + return s.sessions.StopPortForward(sessionID, forwardID) +} + // TestConnection performs a basic TCP reachability check to the given host and port. // This verifies the network path without establishing a full protocol session. func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) { diff --git a/internal/models/defaults.go b/internal/models/defaults.go index d86d504..72263b1 100644 --- a/internal/models/defaults.go +++ b/internal/models/defaults.go @@ -80,6 +80,7 @@ func NewProfile(protocol Protocol) Profile { KeepaliveInterval: DefaultKeepaliveInterval, KeepaliveCountMax: DefaultKeepaliveCountMax, JumpHosts: []JumpHost{}, + PortForwards: []PortForward{}, EnvVars: map[string]string{}, } case ProtocolTelnet: diff --git a/internal/models/models.go b/internal/models/models.go index 6d33ab4..c0f5ca7 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -94,6 +94,7 @@ type SSHConfig struct { KeepaliveCountMax int `json:"keepaliveCountMax"` Compression bool `json:"compression"` JumpHosts []JumpHost `json:"jumpHosts"` + PortForwards []PortForward `json:"portForwards"` StartupCommand string `json:"startupCommand"` EnvVars map[string]string `json:"envVars"` } @@ -108,6 +109,26 @@ type JumpHost struct { PrivateKeyPath string `json:"privateKeyPath"` } +// PortForwardDirection indicates the tunnel direction. +type PortForwardDirection string + +const ( + PortForwardLocal PortForwardDirection = "local" + PortForwardRemote PortForwardDirection = "remote" +) + +// PortForward defines a single SSH port-forwarding rule. +type PortForward struct { + ID string `json:"id"` + Direction PortForwardDirection `json:"direction"` + BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote) + BindPort int `json:"bindPort"` + DestHost string `json:"destHost"` // target address to forward to + DestPort int `json:"destPort"` + Enabled bool `json:"enabled"` // whether to activate on connect + Label string `json:"label"` // optional descriptive label +} + // TelnetConfig holds Telnet-specific settings for a connection profile. type TelnetConfig struct { LocalEcho bool `json:"localEcho"` diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index e7e099f..cc5b616 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -470,6 +470,33 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin } sess.sshClient = client + + // Start any port-forwarding rules configured in the profile. + if sshConf != nil && len(sshConf.PortForwards) > 0 { + fwdMgr := client.Forwards() + if fwdMgr != nil { + for _, pf := range sshConf.PortForwards { + if !pf.Enabled { + continue + } + if err := fwdMgr.StartForward(pf); err != nil { + // Non-fatal — log and notify but don't abort the session. + m.emitEvent("session:forward:error", map[string]interface{}{ + "sessionID": sess.Info.ID, + "forwardID": pf.ID, + "label": pf.Label, + "error": err.Error(), + }) + } else { + m.emitEvent("session:forward:started", map[string]interface{}{ + "sessionID": sess.Info.ID, + "forward": pf, + }) + } + } + } + } + return nil } @@ -668,6 +695,88 @@ func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error return repos, nil } +// --------------------------------------------------------------------------- +// Port-forwarding management +// --------------------------------------------------------------------------- + +// ListPortForwards returns the active port-forwarding rules for a session. +func (m *Manager) ListPortForwards(sessionID string) ([]models.PortForward, error) { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return nil, fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.sshClient == nil { + return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) + } + + fwdMgr := sess.sshClient.Forwards() + if fwdMgr == nil { + return []models.PortForward{}, nil + } + return fwdMgr.ActiveForwards(), nil +} + +// StartPortForward activates a new forwarding tunnel on a live SSH session. +func (m *Manager) StartPortForward(sessionID string, rule models.PortForward) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.sshClient == nil { + return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) + } + + fwdMgr := sess.sshClient.Forwards() + if fwdMgr == nil { + return fmt.Errorf("sessions: forward manager not initialised") + } + + if err := fwdMgr.StartForward(rule); err != nil { + return err + } + + m.emitEvent("session:forward:started", map[string]interface{}{ + "sessionID": sessionID, + "forward": rule, + }) + return nil +} + +// StopPortForward terminates an active forwarding tunnel. +func (m *Manager) StopPortForward(sessionID string, forwardID string) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.sshClient == nil { + return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) + } + + fwdMgr := sess.sshClient.Forwards() + if fwdMgr == nil { + return fmt.Errorf("sessions: forward manager not initialised") + } + + if err := fwdMgr.StopForward(forwardID); err != nil { + return err + } + + m.emitEvent("session:forward:stopped", map[string]interface{}{ + "sessionID": sessionID, + "forwardID": forwardID, + }) + return nil +} + // --------------------------------------------------------------------------- // Internal: helpers // --------------------------------------------------------------------------- diff --git a/internal/sshclient/portforward.go b/internal/sshclient/portforward.go new file mode 100644 index 0000000..bb4272b --- /dev/null +++ b/internal/sshclient/portforward.go @@ -0,0 +1,269 @@ +// Package sshclient — port forwarding support. +// +// This file implements local and remote SSH port forwarding (tunnelling). +// Local forwarding listens on a local address and pipes connections through +// the SSH channel to a remote destination. Remote forwarding requests the +// SSH server to listen on a remote address and pipes accepted connections +// back to a local destination. +package sshclient + +import ( + "fmt" + "io" + "net" + "sync" + + "github.com/copilot-ssh-client/app/internal/models" + "golang.org/x/crypto/ssh" +) + +// ActiveForward tracks a running port-forwarding tunnel. +type ActiveForward struct { + Rule models.PortForward `json:"rule"` + Listener net.Listener `json:"-"` + done chan struct{} + mu sync.Mutex + conns []net.Conn // tracked connections for clean shutdown +} + +// ForwardManager manages active port-forwarding tunnels for a single SSH connection. +type ForwardManager struct { + mu sync.Mutex + forwards map[string]*ActiveForward + client *ssh.Client +} + +// NewForwardManager creates a ForwardManager bound to the given SSH client. +func NewForwardManager(client *ssh.Client) *ForwardManager { + return &ForwardManager{ + forwards: make(map[string]*ActiveForward), + client: client, + } +} + +// StartForward activates a port-forwarding rule. Returns an error if the +// rule's ID is already active or the listener cannot be established. +func (fm *ForwardManager) StartForward(rule models.PortForward) error { + fm.mu.Lock() + if _, exists := fm.forwards[rule.ID]; exists { + fm.mu.Unlock() + return fmt.Errorf("forward %q is already active", rule.ID) + } + fm.mu.Unlock() + + switch rule.Direction { + case models.PortForwardLocal: + return fm.startLocalForward(rule) + case models.PortForwardRemote: + return fm.startRemoteForward(rule) + default: + return fmt.Errorf("unsupported forward direction %q", rule.Direction) + } +} + +// StopForward terminates a specific active forward. +func (fm *ForwardManager) StopForward(forwardID string) error { + fm.mu.Lock() + af, ok := fm.forwards[forwardID] + if !ok { + fm.mu.Unlock() + return fmt.Errorf("forward %q not found", forwardID) + } + delete(fm.forwards, forwardID) + fm.mu.Unlock() + + return af.shutdown() +} + +// StopAll terminates every active forward. Called on session close. +func (fm *ForwardManager) StopAll() { + fm.mu.Lock() + all := make([]*ActiveForward, 0, len(fm.forwards)) + for id, af := range fm.forwards { + all = append(all, af) + delete(fm.forwards, id) + } + fm.mu.Unlock() + + for _, af := range all { + _ = af.shutdown() + } +} + +// ActiveForwards returns the list of currently active forwarding rules. +func (fm *ForwardManager) ActiveForwards() []models.PortForward { + fm.mu.Lock() + defer fm.mu.Unlock() + + result := make([]models.PortForward, 0, len(fm.forwards)) + for _, af := range fm.forwards { + result = append(result, af.Rule) + } + return result +} + +// --------------------------------------------------------------------------- +// Local forwarding: listen locally → tunnel to remote via SSH +// --------------------------------------------------------------------------- + +func (fm *ForwardManager) startLocalForward(rule models.PortForward) error { + bindAddr := rule.BindHost + if bindAddr == "" { + bindAddr = "127.0.0.1" + } + listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort)) + + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return fmt.Errorf("local forward: listen on %s failed: %w", listenAddr, err) + } + + af := &ActiveForward{ + Rule: rule, + Listener: listener, + done: make(chan struct{}), + } + + fm.mu.Lock() + fm.forwards[rule.ID] = af + fm.mu.Unlock() + + destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort)) + + go func() { + defer close(af.done) + for { + localConn, acceptErr := listener.Accept() + if acceptErr != nil { + // Listener closed — normal shutdown path. + return + } + + af.mu.Lock() + af.conns = append(af.conns, localConn) + af.mu.Unlock() + + go func(lc net.Conn) { + defer lc.Close() + + remoteConn, dialErr := fm.client.Dial("tcp", destAddr) + if dialErr != nil { + return + } + defer remoteConn.Close() + + af.mu.Lock() + af.conns = append(af.conns, remoteConn) + af.mu.Unlock() + + pipeBidirectional(lc, remoteConn) + }(localConn) + } + }() + + return nil +} + +// --------------------------------------------------------------------------- +// Remote forwarding: ask server to listen → tunnel back to local destination +// --------------------------------------------------------------------------- + +func (fm *ForwardManager) startRemoteForward(rule models.PortForward) error { + bindAddr := rule.BindHost + if bindAddr == "" { + bindAddr = "0.0.0.0" + } + listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort)) + + listener, err := fm.client.Listen("tcp", listenAddr) + if err != nil { + return fmt.Errorf("remote forward: server listen on %s failed: %w", listenAddr, err) + } + + af := &ActiveForward{ + Rule: rule, + Listener: listener, + done: make(chan struct{}), + } + + fm.mu.Lock() + fm.forwards[rule.ID] = af + fm.mu.Unlock() + + destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort)) + + go func() { + defer close(af.done) + for { + remoteConn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + + af.mu.Lock() + af.conns = append(af.conns, remoteConn) + af.mu.Unlock() + + go func(rc net.Conn) { + defer rc.Close() + + localConn, dialErr := net.Dial("tcp", destAddr) + if dialErr != nil { + return + } + defer localConn.Close() + + af.mu.Lock() + af.conns = append(af.conns, localConn) + af.mu.Unlock() + + pipeBidirectional(rc, localConn) + }(remoteConn) + } + }() + + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// shutdown closes the listener and all tracked connections. +func (af *ActiveForward) shutdown() error { + var firstErr error + if af.Listener != nil { + if err := af.Listener.Close(); err != nil { + firstErr = err + } + } + + af.mu.Lock() + conns := af.conns + af.conns = nil + af.mu.Unlock() + + for _, c := range conns { + _ = c.Close() + } + return firstErr +} + +// pipeBidirectional copies data between two connections until either side closes. +func pipeBidirectional(a, b net.Conn) { + var wg sync.WaitGroup + wg.Add(2) + + copy := func(dst, src net.Conn) { + defer wg.Done() + _, _ = io.Copy(dst, src) + // Signal the other direction to stop by closing the write-half. + if tc, ok := dst.(*net.TCPConn); ok { + _ = tc.CloseWrite() + } + } + + go copy(a, b) + go copy(b, a) + wg.Wait() +} diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index a4b6789..be9efaf 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -15,7 +15,7 @@ import ( // Client manages a single SSH session lifecycle. // It handles connection establishment, authentication, PTY allocation, -// terminal resize propagation, and clean shutdown. +// terminal resize propagation, port forwarding, and clean shutdown. type Client struct { mu sync.Mutex config *models.Profile @@ -30,6 +30,7 @@ type Client struct { onClose func() ctx context.Context cancel context.CancelFunc + forwards *ForwardManager // port forwarding tunnels } // NewClient creates a new SSH client bound to the given profile. @@ -193,6 +194,7 @@ func (c *Client) connectInternal( c.stdout = stdoutPipe c.stderr = stderrPipe c.connected = true + c.forwards = NewForwardManager(sshConn) c.mu.Unlock() go c.readLoop() @@ -242,11 +244,17 @@ func (c *Client) Close() error { c.connected = false session := c.session sshClient := c.sshClient + fwdMgr := c.forwards onClose := c.onClose c.mu.Unlock() c.cancel() + // Shut down any active port forwards first. + if fwdMgr != nil { + fwdMgr.StopAll() + } + var firstErr error if session != nil { if err := session.Close(); err != nil && firstErr == nil { @@ -272,6 +280,13 @@ func (c *Client) IsConnected() bool { return c.connected } +// Forwards returns the ForwardManager for this client, or nil if not connected. +func (c *Client) Forwards() *ForwardManager { + c.mu.Lock() + defer c.mu.Unlock() + return c.forwards +} + // buildAuthMethods constructs the SSH authentication method list from the // profile's SSHConfig and the provided credential callbacks. func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) { diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 2f7982a..7a0c363 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -93,6 +93,8 @@ func ValidateProfile(p *models.Profile) error { errs.Errors = append(errs.Errors, ve.Errors...) } } + case models.ProtocolGitHub: + // GitHub profiles require no host-level validation beyond the common checks. default: errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol)) } @@ -217,6 +219,23 @@ func ValidateSSHConfig(cfg *models.SSHConfig) error { } } + // Validate each port-forwarding rule. + for i, pf := range cfg.PortForwards { + prefix := fmt.Sprintf("ssh.portForwards[%d]", i) + if pf.Direction != models.PortForwardLocal && pf.Direction != models.PortForwardRemote { + errs.Add(prefix+".direction", fmt.Sprintf("unsupported forwarding direction %q", pf.Direction)) + } + if pf.BindPort < 1 || pf.BindPort > 65535 { + errs.Add(prefix+".bindPort", "bind port must be between 1 and 65535") + } + if pf.DestPort < 1 || pf.DestPort > 65535 { + errs.Add(prefix+".destPort", "destination port must be between 1 and 65535") + } + if strings.TrimSpace(pf.DestHost) == "" { + errs.Add(prefix+".destHost", "destination host must not be empty") + } + } + if errs.HasErrors() { return errs }