mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
feat: support interactive SSH port forwarding (#67)
This commit is contained in:
@@ -28,6 +28,14 @@ import (
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Event Handlers (handle user input/events)
|
// 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 {
|
func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
|
||||||
// Don't handle global keys when search has focus
|
// Don't handle global keys when search has focus
|
||||||
@@ -40,7 +48,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
|
|||||||
t.handleQuit()
|
t.handleQuit()
|
||||||
return nil
|
return nil
|
||||||
case '/':
|
case '/':
|
||||||
t.handleSearchToggle()
|
t.handleSearchFocus()
|
||||||
return nil
|
return nil
|
||||||
case 'a':
|
case 'a':
|
||||||
t.handleServerAdd()
|
t.handleServerAdd()
|
||||||
@@ -72,6 +80,12 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
|
|||||||
case 't':
|
case 't':
|
||||||
t.handleTagsEdit()
|
t.handleTagsEdit()
|
||||||
return nil
|
return nil
|
||||||
|
case 'f':
|
||||||
|
t.handlePortForward()
|
||||||
|
return nil
|
||||||
|
case 'x':
|
||||||
|
t.handleStopForwarding()
|
||||||
|
return nil
|
||||||
case 'j':
|
case 'j':
|
||||||
t.handleNavigateDown()
|
t.handleNavigateDown()
|
||||||
return nil
|
return nil
|
||||||
@@ -163,8 +177,10 @@ func (t *tui) handleSearchInput(query string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *tui) handleSearchToggle() {
|
func (t *tui) handleSearchFocus() {
|
||||||
t.showSearchBar()
|
if t.app != nil && t.searchBar != nil {
|
||||||
|
t.app.SetFocus(t.searchBar)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *tui) handleServerConnect() {
|
func (t *tui) handleServerConnect() {
|
||||||
@@ -265,7 +281,7 @@ func (t *tui) handleModalClose() {
|
|||||||
func (t *tui) handleRefreshBackground() {
|
func (t *tui) handleRefreshBackground() {
|
||||||
currentIdx := t.serverList.GetCurrentItem()
|
currentIdx := t.serverList.GetCurrentItem()
|
||||||
query := ""
|
query := ""
|
||||||
if t.searchVisible {
|
if t.searchBar != nil {
|
||||||
query = t.searchBar.InputField.GetText()
|
query = t.searchBar.InputField.GetText()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,14 +314,6 @@ func (t *tui) handleRefreshBackground() {
|
|||||||
// UI Display Functions (show UI elements/modals)
|
// 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) {
|
func (t *tui) showDeleteConfirmModal(server domain.Server) {
|
||||||
msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.",
|
msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.",
|
||||||
server.Alias, server.User, server.Host, server.Port)
|
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.AddButton("Cancel", func() { t.returnToMain() })
|
||||||
form.SetCancelFunc(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.SetRoot(form, true)
|
||||||
t.app.SetFocus(form)
|
t.app.SetFocus(form)
|
||||||
}
|
}
|
||||||
@@ -380,12 +525,11 @@ func (t *tui) showEditTagsForm(server domain.Server) {
|
|||||||
// UI State Management (hide UI elements)
|
// UI State Management (hide UI elements)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
func (t *tui) hideSearchBar() {
|
// blurSearchBar moves focus back to the server list without changing layout.
|
||||||
t.left.Clear()
|
func (t *tui) blurSearchBar() {
|
||||||
t.left.AddItem(t.hintBar, 1, 0, false)
|
if t.app != nil && t.serverList != nil {
|
||||||
t.left.AddItem(t.serverList, 0, 1, true)
|
t.app.SetFocus(t.serverList)
|
||||||
t.app.SetFocus(t.serverList)
|
}
|
||||||
t.searchVisible = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -394,7 +538,7 @@ func (t *tui) hideSearchBar() {
|
|||||||
|
|
||||||
func (t *tui) refreshServerList() {
|
func (t *tui) refreshServerList() {
|
||||||
query := ""
|
query := ""
|
||||||
if t.searchVisible {
|
if t.searchBar != nil {
|
||||||
query = t.searchBar.InputField.GetText()
|
query = t.searchBar.InputField.GetText()
|
||||||
}
|
}
|
||||||
filtered, _ := t.serverService.ListServers(query)
|
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()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -213,7 +213,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commands list
|
// 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)
|
sd.TextView.SetText(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func DefaultStatusText() string {
|
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 {
|
func NewStatusBar() *tview.TextView {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ type tui struct {
|
|||||||
|
|
||||||
header *AppHeader
|
header *AppHeader
|
||||||
searchBar *SearchBar
|
searchBar *SearchBar
|
||||||
hintBar *tview.TextView
|
|
||||||
serverList *ServerList
|
serverList *ServerList
|
||||||
details *ServerDetails
|
details *ServerDetails
|
||||||
statusBar *tview.TextView
|
statusBar *tview.TextView
|
||||||
@@ -46,8 +45,7 @@ type tui struct {
|
|||||||
left *tview.Flex
|
left *tview.Flex
|
||||||
content *tview.Flex
|
content *tview.Flex
|
||||||
|
|
||||||
sortMode SortMode
|
sortMode SortMode
|
||||||
searchVisible bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App {
|
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.header = NewAppHeader(t.version, t.commit, RepoURL)
|
||||||
t.searchBar = NewSearchBar().
|
t.searchBar = NewSearchBar().
|
||||||
OnSearch(t.handleSearchInput).
|
OnSearch(t.handleSearchInput).
|
||||||
OnEscape(t.hideSearchBar)
|
OnEscape(t.blurSearchBar)
|
||||||
t.hintBar = NewHintBar()
|
IsForwarding = t.serverService.IsForwarding
|
||||||
|
|
||||||
t.serverList = NewServerList().
|
t.serverList = NewServerList().
|
||||||
OnSelectionChange(t.handleServerSelectionChange)
|
OnSelectionChange(t.handleServerSelectionChange)
|
||||||
t.details = NewServerDetails()
|
t.details = NewServerDetails()
|
||||||
@@ -108,7 +107,7 @@ func (t *tui) buildComponents() *tui {
|
|||||||
|
|
||||||
func (t *tui) buildLayout() *tui {
|
func (t *tui) buildLayout() *tui {
|
||||||
t.left = tview.NewFlex().SetDirection(tview.FlexRow).
|
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)
|
AddItem(t.serverList, 0, 1, true)
|
||||||
|
|
||||||
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import (
|
|||||||
"github.com/mattn/go-runewidth"
|
"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
|
// SSH config value constants
|
||||||
const (
|
const (
|
||||||
sshYes = "yes"
|
sshYes = "yes"
|
||||||
@@ -80,8 +83,18 @@ func pinnedIcon(pinnedAt time.Time) string {
|
|||||||
|
|
||||||
func formatServerLine(s domain.Server) (primary, secondary string) {
|
func formatServerLine(s domain.Server) (primary, secondary string) {
|
||||||
icon := cellPad(pinnedIcon(s.PinnedAt), 2)
|
icon := cellPad(pinnedIcon(s.PinnedAt), 2)
|
||||||
// Use a consistent color for alias; the icon reflects pinning
|
// forwarding column after Host/IP
|
||||||
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))
|
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 = ""
|
secondary = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,5 +27,9 @@ type ServerService interface {
|
|||||||
DeleteServer(server domain.Server) error
|
DeleteServer(server domain.Server) error
|
||||||
SetPinned(alias string, pinned bool) error
|
SetPinned(alias string, pinned bool) error
|
||||||
SSH(alias string) 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)
|
Ping(server domain.Server) (bool, time.Duration, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Adembc/lazyssh/internal/core/domain"
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
@@ -34,6 +36,9 @@ import (
|
|||||||
type serverService struct {
|
type serverService struct {
|
||||||
serverRepository ports.ServerRepository
|
serverRepository ports.ServerRepository
|
||||||
logger *zap.SugaredLogger
|
logger *zap.SugaredLogger
|
||||||
|
|
||||||
|
fwMu sync.Mutex
|
||||||
|
forwards map[string][]*os.Process
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServerService creates a new instance of serverService.
|
// NewServerService creates a new instance of serverService.
|
||||||
@@ -168,6 +173,141 @@ func (s *serverService) SSH(alias string) error {
|
|||||||
return nil
|
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.
|
// Ping checks if the server is reachable on its SSH port.
|
||||||
func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) {
|
func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|||||||
Reference in New Issue
Block a user