feat: support interactive SSH port forwarding (#67)

This commit is contained in:
Adem Baccara
2025-10-08 20:38:00 +01:00
committed by GitHub
parent 6ad07193ff
commit d4be74bf83
8 changed files with 347 additions and 56 deletions
+181 -19
View File
@@ -28,6 +28,14 @@ import (
// =============================================================================
// Event Handlers (handle user input/events)
// =============================================================================
const (
ForwardTypeLocal = "Local"
ForwardTypeRemote = "Remote"
ForwardTypeDynamic = "Dynamic"
ForwardModeOnlyForward = "Only forward"
ForwardModeForwardSSH = "Forward + SSH"
)
func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
// Don't handle global keys when search has focus
@@ -40,7 +48,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
t.handleQuit()
return nil
case '/':
t.handleSearchToggle()
t.handleSearchFocus()
return nil
case 'a':
t.handleServerAdd()
@@ -72,6 +80,12 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
case 't':
t.handleTagsEdit()
return nil
case 'f':
t.handlePortForward()
return nil
case 'x':
t.handleStopForwarding()
return nil
case 'j':
t.handleNavigateDown()
return nil
@@ -163,8 +177,10 @@ func (t *tui) handleSearchInput(query string) {
}
}
func (t *tui) handleSearchToggle() {
t.showSearchBar()
func (t *tui) handleSearchFocus() {
if t.app != nil && t.searchBar != nil {
t.app.SetFocus(t.searchBar)
}
}
func (t *tui) handleServerConnect() {
@@ -265,7 +281,7 @@ func (t *tui) handleModalClose() {
func (t *tui) handleRefreshBackground() {
currentIdx := t.serverList.GetCurrentItem()
query := ""
if t.searchVisible {
if t.searchBar != nil {
query = t.searchBar.InputField.GetText()
}
@@ -298,14 +314,6 @@ func (t *tui) handleRefreshBackground() {
// UI Display Functions (show UI elements/modals)
// =============================================================================
func (t *tui) showSearchBar() {
t.left.Clear()
t.left.AddItem(t.searchBar, 3, 0, true)
t.left.AddItem(t.serverList, 0, 1, false)
t.app.SetFocus(t.searchBar)
t.searchVisible = true
}
func (t *tui) showDeleteConfirmModal(server domain.Server) {
msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.",
server.Alias, server.User, server.Host, server.Port)
@@ -372,6 +380,143 @@ func (t *tui) showEditTagsForm(server domain.Server) {
form.AddButton("Cancel", func() { t.returnToMain() })
form.SetCancelFunc(func() { t.returnToMain() })
t.app.SetRoot(form, true)
toFocus := form
t.app.SetFocus(toFocus)
}
func (t *tui) handlePortForward() {
if server, ok := t.serverList.GetSelectedServer(); ok {
t.showPortForwardForm(server)
}
}
func (t *tui) showPortForwardForm(server domain.Server) {
typeChoices := []string{ForwardTypeLocal, ForwardTypeRemote, ForwardTypeDynamic}
modeChoices := []string{ForwardModeOnlyForward, ForwardModeForwardSSH}
currentTypeIdx := 0
currentModeIdx := 0
portVal := ""
hostVal := "localhost"
hostPortVal := ""
bindAddrVal := ""
form := tview.NewForm()
form.SetBorder(true).
SetTitle(fmt.Sprintf(" Port Forwarding: %s ", server.Alias)).
SetTitleAlign(tview.AlignCenter)
dd := tview.NewDropDown()
hostField := tview.NewInputField()
hostPortField := tview.NewInputField()
portField := tview.NewInputField()
bindAddrField := tview.NewInputField()
dd.SetOptions(typeChoices, func(text string, index int) {
currentTypeIdx = index
// Toggle fields when switching type
isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic
if isDynamic {
hostField.SetText("").SetDisabled(true)
hostPortField.SetText("").SetDisabled(true)
} else {
hostField.SetDisabled(false)
hostPortField.SetDisabled(false)
}
})
dd.SetCurrentOption(currentTypeIdx)
form.AddFormItem(dd.SetLabel("Type"))
portField.SetLabel("Port").SetText(portVal).SetFieldWidth(8).SetChangedFunc(func(text string) { portVal = strings.TrimSpace(text) })
form.AddFormItem(portField)
hostField.SetLabel("Host").SetText(hostVal).SetFieldWidth(40).SetChangedFunc(func(text string) { hostVal = strings.TrimSpace(text) })
form.AddFormItem(hostField)
hostPortField.SetLabel("Host Port").SetText(hostPortVal).SetFieldWidth(8).SetChangedFunc(func(text string) { hostPortVal = strings.TrimSpace(text) })
form.AddFormItem(hostPortField)
bindAddrField.SetLabel("Bind Address (optional)").SetText(bindAddrVal).SetFieldWidth(40).SetChangedFunc(func(text string) { bindAddrVal = strings.TrimSpace(text) })
form.AddFormItem(bindAddrField)
mode := tview.NewDropDown().SetOptions(modeChoices, func(text string, index int) { currentModeIdx = index })
mode.SetCurrentOption(currentModeIdx)
form.AddFormItem(mode.SetLabel("Mode"))
isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic
if isDynamic {
hostField.SetText("").SetDisabled(true)
hostPortField.SetText("").SetDisabled(true)
}
form.AddButton("Start", func() {
if err := validatePort(portVal); err != nil {
t.showStatusTempColor("Invalid port: "+err.Error(), "#FF6B6B")
return
}
if bindAddrVal != "" {
if err := validateBindAddress(bindAddrVal); err != nil {
t.showStatusTempColor("Invalid bind address: "+err.Error(), "#FF6B6B")
return
}
}
ft := typeChoices[currentTypeIdx]
var args []string
if ft == ForwardTypeDynamic {
spec := portVal
if bindAddrVal != "" {
spec = bindAddrVal + ":" + portVal
}
args = append(args, "-D", spec)
} else {
if err := validateHost(hostVal); err != nil {
t.showStatusTempColor("Invalid host: "+err.Error(), "#FF6B6B")
return
}
if err := validatePort(hostPortVal); err != nil {
t.showStatusTempColor("Invalid host port: "+err.Error(), "#FF6B6B")
return
}
spec := portVal + ":" + hostVal + ":" + hostPortVal
if bindAddrVal != "" {
spec = bindAddrVal + ":" + spec
}
if ft == ForwardTypeLocal {
args = append(args, "-L", spec)
} else {
args = append(args, "-R", spec)
}
}
onlyForward := modeChoices[currentModeIdx] == ForwardModeOnlyForward
alias := server.Alias
if onlyForward {
t.returnToMain()
t.showStatusTemp("Starting port forward…")
go func() {
pid, err := t.serverService.StartForward(alias, args)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor("Forward failed: "+err.Error(), "#FF6B6B")
} else {
t.refreshServerList()
t.showStatusTemp(fmt.Sprintf("Port forwarding started (pid %d)", pid))
}
})
}()
return
}
t.app.Suspend(func() {
_ = t.serverService.SSHWithArgs(alias, args)
})
t.returnToMain()
})
form.AddButton("Cancel", func() { t.returnToMain() })
form.SetCancelFunc(func() { t.returnToMain() })
t.app.SetRoot(form, true)
t.app.SetFocus(form)
}
@@ -380,12 +525,11 @@ func (t *tui) showEditTagsForm(server domain.Server) {
// UI State Management (hide UI elements)
// =============================================================================
func (t *tui) hideSearchBar() {
t.left.Clear()
t.left.AddItem(t.hintBar, 1, 0, false)
t.left.AddItem(t.serverList, 0, 1, true)
t.app.SetFocus(t.serverList)
t.searchVisible = false
// blurSearchBar moves focus back to the server list without changing layout.
func (t *tui) blurSearchBar() {
if t.app != nil && t.serverList != nil {
t.app.SetFocus(t.serverList)
}
}
// =============================================================================
@@ -394,7 +538,7 @@ func (t *tui) hideSearchBar() {
func (t *tui) refreshServerList() {
query := ""
if t.searchVisible {
if t.searchBar != nil {
query = t.searchBar.InputField.GetText()
}
filtered, _ := t.serverService.ListServers(query)
@@ -430,3 +574,21 @@ func (t *tui) showStatusTempColor(msg string, color string) {
}
})
}
// Stop any active port forwarding for the selected server.
func (t *tui) handleStopForwarding() {
if server, ok := t.serverList.GetSelectedServer(); ok {
alias := server.Alias
go func() {
err := t.serverService.StopForwarding(alias)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), "#FF6B6B")
} else {
t.showStatusTemp("Stopped forwarding for " + alias)
}
t.refreshServerList()
})
}()
}
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func NewHintBar() *tview.TextView {
hint := tview.NewTextView().SetDynamicColors(true)
hint.SetBackgroundColor(tcell.Color233)
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]")
return hint
}
+1 -1
View File
@@ -213,7 +213,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
}
// Commands list
text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin"
text += "\n[::b]Commands:[-]\n Enter: SSH connect\n f: Port forward\n x: Stop forwarding\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin"
sd.TextView.SetText(text)
}
+1 -1
View File
@@ -20,7 +20,7 @@ import (
)
func DefaultStatusText() string {
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop Forward • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
}
func NewStatusBar() *tview.TextView {
+5 -6
View File
@@ -37,7 +37,6 @@ type tui struct {
header *AppHeader
searchBar *SearchBar
hintBar *tview.TextView
serverList *ServerList
details *ServerDetails
statusBar *tview.TextView
@@ -46,8 +45,7 @@ type tui struct {
left *tview.Flex
content *tview.Flex
sortMode SortMode
searchVisible bool
sortMode SortMode
}
func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App {
@@ -93,8 +91,9 @@ func (t *tui) buildComponents() *tui {
t.header = NewAppHeader(t.version, t.commit, RepoURL)
t.searchBar = NewSearchBar().
OnSearch(t.handleSearchInput).
OnEscape(t.hideSearchBar)
t.hintBar = NewHintBar()
OnEscape(t.blurSearchBar)
IsForwarding = t.serverService.IsForwarding
t.serverList = NewServerList().
OnSelectionChange(t.handleServerSelectionChange)
t.details = NewServerDetails()
@@ -108,7 +107,7 @@ func (t *tui) buildComponents() *tui {
func (t *tui) buildLayout() *tui {
t.left = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(t.hintBar, 1, 0, false).
AddItem(t.searchBar, 3, 0, false).
AddItem(t.serverList, 0, 1, true)
right := tview.NewFlex().SetDirection(tview.FlexRow).
+15 -2
View File
@@ -25,6 +25,9 @@ import (
"github.com/mattn/go-runewidth"
)
// IsForwarding is an optional hook supplied by TUI to indicate active forwarding per alias.
var IsForwarding func(alias string) bool
// SSH config value constants
const (
sshYes = "yes"
@@ -80,8 +83,18 @@ func pinnedIcon(pinnedAt time.Time) string {
func formatServerLine(s domain.Server) (primary, secondary string) {
icon := cellPad(pinnedIcon(s.PinnedAt), 2)
// Use a consistent color for alias; the icon reflects pinning
primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
// forwarding column after Host/IP
fGlyph := ""
isFwd := IsForwarding != nil && IsForwarding(s.Alias)
if isFwd {
fGlyph = "Ⓕ"
}
fCol := cellPad(fGlyph, 2)
if isFwd {
fCol = "[#A0FFA0]" + fCol + "[-]"
}
// Use a consistent color for alias; host/IP fixed width; then forwarding column
primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] %s [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, fCol, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
secondary = ""
return
}
+4
View File
@@ -27,5 +27,9 @@ type ServerService interface {
DeleteServer(server domain.Server) error
SetPinned(alias string, pinned bool) error
SSH(alias string) error
SSHWithArgs(alias string, extraArgs []string) error
StartForward(alias string, extraArgs []string) (int, error)
StopForwarding(alias string) error
IsForwarding(alias string) bool
Ping(server domain.Server) (bool, time.Duration, error)
}
+140
View File
@@ -24,6 +24,8 @@ import (
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/Adembc/lazyssh/internal/core/domain"
@@ -34,6 +36,9 @@ import (
type serverService struct {
serverRepository ports.ServerRepository
logger *zap.SugaredLogger
fwMu sync.Mutex
forwards map[string][]*os.Process
}
// NewServerService creates a new instance of serverService.
@@ -168,6 +173,141 @@ func (s *serverService) SSH(alias string) error {
return nil
}
// SSHWithArgs runs system ssh with provided extra args (e.g., -L/-R/-D) for the given alias.
func (s *serverService) SSHWithArgs(alias string, extraArgs []string) error {
s.logger.Infow("ssh start (with args)", "alias", alias, "args", extraArgs)
args := append([]string{}, extraArgs...)
args = append(args, alias)
// #nosec G204
cmd := exec.Command("ssh", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
s.logger.Errorw("ssh (with args) failed", "alias", alias, "error", err)
return err
}
if err := s.serverRepository.RecordSSH(alias); err != nil {
s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err)
}
s.logger.Infow("ssh end (with args)", "alias", alias)
return nil
}
// StartForward starts ssh port forwarding in the background and tracks the process.
func (s *serverService) StartForward(alias string, extraArgs []string) (int, error) {
s.fwMu.Lock()
if s.forwards == nil {
s.forwards = make(map[string][]*os.Process)
}
s.fwMu.Unlock()
extraArgs = append(extraArgs, "-N", alias)
// #nosec G204
cmd := exec.Command("ssh", extraArgs...)
// Detach from TTY: discard stdio
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err != nil {
return 0, fmt.Errorf("failed to open devnull: %w", err)
}
defer func() {
if devNull != nil {
_ = devNull.Close()
}
}()
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true, // Create new session to fully detach
}
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("failed to start ssh: %w", err)
}
proc := cmd.Process
if proc == nil {
return 0, fmt.Errorf("process is nil after start")
}
pid := proc.Pid
// Track process
s.fwMu.Lock()
s.forwards[alias] = append(s.forwards[alias], proc)
s.fwMu.Unlock()
// Cleanup on exit
go func(a string, c *exec.Cmd, dn *os.File) {
_ = c.Wait()
_ = dn.Close()
s.fwMu.Lock()
defer s.fwMu.Unlock()
procs := s.forwards[a]
if len(procs) == 0 {
return
}
filtered := make([]*os.Process, 0, len(procs))
for _, p := range procs {
if p != nil && p.Pid != pid {
filtered = append(filtered, p)
}
}
if len(filtered) == 0 {
delete(s.forwards, a)
} else {
s.forwards[a] = filtered
}
}(alias, cmd, devNull)
devNull = nil // Prevent defer from closing it
return pid, nil
}
// StopForwarding kills all active forward processes for the alias.
func (s *serverService) StopForwarding(alias string) error {
s.fwMu.Lock()
procs := s.forwards[alias]
delete(s.forwards, alias)
s.fwMu.Unlock()
if len(procs) == 0 {
return nil
}
var errs []error
for _, p := range procs {
if p != nil {
if err := p.Signal(syscall.SIGTERM); err != nil {
// If SIGTERM fails, try SIGKILL
if killErr := p.Kill(); killErr != nil {
errs = append(errs, fmt.Errorf("failed to kill pid %d: %w", p.Pid, killErr))
}
}
}
}
if len(errs) > 0 {
return fmt.Errorf("errors stopping forwards: %v", errs)
}
return nil
}
// IsForwarding reports whether there is at least one active forward for alias.
func (s *serverService) IsForwarding(alias string) bool {
s.fwMu.Lock()
defer s.fwMu.Unlock()
return len(s.forwards[alias]) > 0
}
// Ping checks if the server is reachable on its SSH port.
func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) {
start := time.Now()