Implement SSH port forwarding and improve jump host UI

Port Forwarding (new):
- Add PortForward model with local/remote direction, bind/dest addresses
- Implement ForwardManager in sshclient/portforward.go with full
  bidirectional tunnel support for both local and remote forwarding
- Integrate ForwardManager into SSH client lifecycle (auto-start on
  connect, clean shutdown on close)
- Add session manager methods: ListPortForwards, StartPortForward,
  StopPortForward with event emission
- Add API surface: ListPortForwards, StartPortForward, StopPortForward
- Add validation for port-forward rules in ValidateSSHConfig
- Add port forwarding section to profile editor with direction, bind/dest
  host/port, label, and enable toggle
- Add frontend event handling for forward:started/stopped/error
- Style port-forward rows with charcoal card design matching jump hosts

Jump Host UI improvements:
- Add private key path field to jump host editor rows
- Collect privateKeyPath in form value collection
- Restyle jump host rows as two-line cards with clear field grouping
This commit is contained in:
2026-04-16 00:00:00 +01:00
parent 5e397195b9
commit 6d300f7dba
13 changed files with 664 additions and 8 deletions
+109
View File
@@ -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
// ---------------------------------------------------------------------------