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)
|
||||
// =============================================================================
|
||||
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()
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user