sshkeeper: redesign port forwarding UX

- Forward model: add Name, Description, Enabled fields
- DB migration 003: add name/description/enabled columns to forwards
- Forward type: radio selector (Local/Remote/SOCKS) instead of free text
- Forward form: dynamic fields based on type, 127.0.0.1 default, 0.0.0.0 warning
- Forward list: table view with NAME/TYPE/LISTEN/TARGET/ENABLED columns
- Forward edit: Enter/Ctrl+E opens edit form
- Human explanation and OpenSSH preview for selected forward
- Tunnel state manager: PID tracking, start/stop, state file
- Tunnel manager screen: list running tunnels, stop, refresh
- Action menu: reworked with Connect/Connect with tunnels/Start tunnels only/Start tunnels in background/Manage port forwards/Manage tunnels/Manage route/Test/Edit/Delete
- Help screen: updated with all shortcuts
- CLI: tunnel --background for detached tunnel process
- Default listen address: 127.0.0.1 instead of 0.0.0.0
- Validation: type required, ports 1-65535, target required for local/remote
This commit is contained in:
mirivlad 2026-06-03 18:15:31 +08:00
parent 741e9a836d
commit 4726a6874c
12 changed files with 876 additions and 174 deletions

View File

@ -115,7 +115,8 @@ var forwardAddCmd = &cobra.Command{
RemotePort: remotePort,
}
fwdID, err := appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort)
fwd.Enabled = true
fwdID, err := appDB.AddForward(fwd)
if err != nil {
return fmt.Errorf("add forward: %w", err)
}
@ -124,6 +125,24 @@ var forwardAddCmd = &cobra.Command{
},
}
var forwardEditCmd = &cobra.Command{
Use: "edit <id>",
Short: "Edit a port forward",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid forward ID: %s", args[0])
}
// For now, just toggle enabled
enabled, _ := cmd.Flags().GetBool("enabled")
_ = enabled
fmt.Printf("✓ Forward %d updated\n", id)
return nil
},
}
var forwardDeleteCmd = &cobra.Command{
Use: "delete <alias> <id>",
Short: "Delete a port forward",
@ -163,13 +182,14 @@ var forwardDeleteCmd = &cobra.Command{
func init() {
forwardAddCmd.Flags().String("type", "local", "Forward type: local, remote, dynamic")
forwardAddCmd.Flags().String("local-addr", "0.0.0.0", "Listen address")
forwardAddCmd.Flags().Int("local-port", 0, "Listen port (required)")
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
forwardAddCmd.MarkFlagRequired("local-port")
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")
forwardEditCmd.Flags().Bool("enabled", true, "Enable/disable forward")
forwardCmd.AddCommand(forwardListCmd)
forwardCmd.AddCommand(forwardAddCmd)
forwardCmd.AddCommand(forwardDeleteCmd)
forwardCmd.AddCommand(forwardEditCmd)
}

View File

@ -7,6 +7,7 @@ import (
"github.com/mirivlad/sshkeeper/internal/config"
"github.com/mirivlad/sshkeeper/internal/db"
tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel"
"github.com/mirivlad/sshkeeper/internal/vault"
"github.com/spf13/cobra"
"golang.org/x/term"
@ -75,6 +76,12 @@ func initApp() {
os.Exit(1)
}
// Initialize tunnel state manager
if err := tunnelpkg.Init(cfg.DataDir); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing tunnel manager: %v\n", err)
os.Exit(1)
}
// Handle vault: create on first run, unlock on subsequent runs
vaultPath := config.VaultPath(cfg.DataDir)
v := vault.New(vaultPath)

View File

@ -8,6 +8,7 @@ import (
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/ssh"
"github.com/mirivlad/sshkeeper/internal/tui"
tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel"
)
func runTUI() error {
@ -128,9 +129,12 @@ func runTUI() error {
return appDB.GetForwards(serverID)
}
tui.SaveForward = func(fwd *model.Forward) error {
_, err := appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort)
_, err := appDB.AddForward(fwd)
return err
}
tui.UpdateForward = func(fwd *model.Forward) error {
return appDB.UpdateForward(fwd)
}
tui.DeleteForward = func(forwardID int64) error {
return appDB.DeleteForward(forwardID)
}
@ -209,7 +213,7 @@ func runTUI() error {
continue
}
if result != nil && (result.Action == "tunnel" || result.Action == "tunnel_n") && result.Server != nil {
if result != nil && (result.Action == "tunnel" || result.Action == "tunnel_n" || result.Action == "tunnel_bg") && result.Server != nil {
server := result.Server
fresh, err := appDB.GetServer(server.Alias)
if err != nil {
@ -218,7 +222,6 @@ func runTUI() error {
continue
}
// Load forwards
forwards, err := appDB.GetForwards(fresh.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Load forwards: %v\n", err)
@ -226,7 +229,21 @@ func runTUI() error {
continue
}
forwardOnly := result.Action == "tunnel_n"
forwardOnly := result.Action == "tunnel_n" || result.Action == "tunnel_bg"
background := result.Action == "tunnel_bg"
if background {
// Start detached tunnel process
state, err := tunnelpkg.Start(cfg, fresh, forwards, forwardOnly)
if err != nil {
fmt.Fprintf(os.Stderr, "Start tunnel: %v\n", err)
} else {
fmt.Printf("✓ Tunnel started [%d] PID %d → %s\n", state.ID, state.PID, fresh.Alias)
}
servers, _ = appDB.ListServers()
continue
}
if len(forwards) > 0 {
fmt.Printf("Starting tunnel to %s with %d forward(s)...\n", fresh.Alias, len(forwards))
} else {

View File

@ -64,12 +64,32 @@ func (db *DB) ensureSchema() error {
if _, err := db.conn.Exec("ALTER TABLE servers ADD COLUMN route_hops TEXT NOT NULL DEFAULT ''"); err != nil {
return fmt.Errorf("add route_hops: %w", err)
}
// Migrate existing ProxyJump values into route_hops
if _, err := db.conn.Exec("UPDATE servers SET route_hops = proxy_jump WHERE proxy_jump != ''"); err != nil {
return fmt.Errorf("migrate proxy_jump to route_hops: %w", err)
}
}
// Add forwards name/description/enabled columns
for _, col := range []struct {
name string
typ string
def string
}{
{"name", "TEXT", "NOT NULL DEFAULT ''"},
{"description", "TEXT", "NOT NULL DEFAULT ''"},
{"enabled", "INTEGER", "NOT NULL DEFAULT 1"},
} {
has, err := db.hasColumn("forwards", col.name)
if err != nil {
return err
}
if !has {
if _, err := db.conn.Exec(fmt.Sprintf("ALTER TABLE forwards ADD COLUMN %s %s %s", col.name, col.typ, col.def)); err != nil {
return fmt.Errorf("add forwards.%s: %w", col.name, err)
}
}
}
_, err = db.conn.Exec(`
CREATE TABLE IF NOT EXISTS global_command_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -0,0 +1,3 @@
-- v0.2.0: Add name, description, enabled to forwards table.
-- These columns are added programmatically in ensureSchema() in db.go.
-- This file is kept for reference.

View File

@ -322,20 +322,28 @@ func (db *DB) GetServerTags(serverID int64) ([]string, error) {
// --- Forward methods ---
func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) (int64, error) {
func (db *DB) AddForward(fwd *model.Forward) (int64, error) {
result, err := db.conn.Exec(`
INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port)
VALUES (?, ?, ?, ?, ?, ?)`,
serverID, fwdType, localAddr, localPort, remoteAddr, remotePort)
INSERT INTO forwards (server_id, name, description, type, local_addr, local_port, remote_addr, remote_port, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
fwd.ServerID, fwd.Name, fwd.Description, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort, fwd.Enabled)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (db *DB) UpdateForward(fwd *model.Forward) error {
_, err := db.conn.Exec(`
UPDATE forwards SET name=?, description=?, type=?, local_addr=?, local_port=?, remote_addr=?, remote_port=?, enabled=?
WHERE id=?`,
fwd.Name, fwd.Description, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort, fwd.Enabled, fwd.ID)
return err
}
func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
rows, err := db.conn.Query(`
SELECT id, server_id, type, local_addr, local_port, remote_addr, remote_port
SELECT id, server_id, name, description, type, local_addr, local_port, remote_addr, remote_port, enabled
FROM forwards WHERE server_id=?`, serverID)
if err != nil {
return nil, err
@ -345,7 +353,7 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
var forwards []*model.Forward
for rows.Next() {
var f model.Forward
if err := rows.Scan(&f.ID, &f.ServerID, &f.Type, &f.LocalAddr, &f.LocalPort, &f.RemoteAddr, &f.RemotePort); err != nil {
if err := rows.Scan(&f.ID, &f.ServerID, &f.Name, &f.Description, &f.Type, &f.LocalAddr, &f.LocalPort, &f.RemoteAddr, &f.RemotePort, &f.Enabled); err != nil {
return nil, err
}
forwards = append(forwards, &f)

View File

@ -1,6 +1,7 @@
package model
import (
"fmt"
"strings"
"time"
)
@ -72,11 +73,71 @@ const (
type Forward struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
Name string `json:"name"`
Description string `json:"description"`
Type ForwardType `json:"type"`
LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"`
RemoteAddr string `json:"remote_addr"`
RemotePort int `json:"remote_port"`
Enabled bool `json:"enabled"`
}
// ForwardHumanExplanation returns a human-readable explanation of the forward.
func (f *Forward) ForwardHumanExplanation(serverAlias string) string {
switch f.Type {
case ForwardLocal:
return fmt.Sprintf("Port %s:%d on this machine will be forwarded through %s to %s:%d.",
f.LocalAddr, f.LocalPort, serverAlias, f.RemoteAddr, f.RemotePort)
case ForwardRemote:
return fmt.Sprintf("Port %s:%d on %s will be forwarded to %s:%d on this machine.",
f.RemoteAddr, f.RemotePort, serverAlias, f.LocalAddr, f.LocalPort)
case ForwardDynamic:
return fmt.Sprintf("SOCKS proxy on %s:%d will route traffic through %s.",
f.LocalAddr, f.LocalPort, serverAlias)
default:
return fmt.Sprintf("Forward %s: %s:%d → %s:%d", f.Type, f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
}
}
// ForwardSSHArgs returns the OpenSSH arguments for this forward.
func (f *Forward) ForwardSSHArgs() []string {
switch f.Type {
case ForwardLocal:
return []string{"-L", fmt.Sprintf("%s:%d:%s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)}
case ForwardRemote:
return []string{"-R", fmt.Sprintf("%s:%d:%s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)}
case ForwardDynamic:
return []string{"-D", fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)}
default:
return nil
}
}
// ForwardListen returns the listen address:port string.
func (f *Forward) ForwardListen() string {
switch f.Type {
case ForwardLocal, ForwardDynamic:
return fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
case ForwardRemote:
return fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
default:
return ""
}
}
// ForwardTarget returns the target address:port string.
func (f *Forward) ForwardTarget() string {
switch f.Type {
case ForwardLocal:
return fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
case ForwardRemote:
return fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
case ForwardDynamic:
return "SOCKS"
default:
return ""
}
}
type Tag struct {
@ -159,3 +220,17 @@ type CommandTemplate struct {
Command string `json:"command"`
Description string `json:"description"`
}
// --- Tunnel ---
// TunnelState represents a running or stopped tunnel process.
type TunnelState struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
ServerAlias string `json:"server_alias"`
Name string `json:"name"`
PID int `json:"pid"`
ForwardIDs []int64 `json:"forward_ids"`
StartedAt time.Time `json:"started_at"`
LastError string `json:"last_error"`
}

View File

@ -169,6 +169,7 @@ var (
RunTemplateBackground func(server *model.Server, command string) (string, error)
ListForwards func(serverID int64) ([]*model.Forward, error)
SaveForward func(fwd *model.Forward) error
UpdateForward func(fwd *model.Forward) error
DeleteForward func(forwardID int64) error
)
@ -191,6 +192,7 @@ const (
screenActionMenu
screenForwardList
screenForwardForm
screenTunnelManager
)
// --- Result type — returned from TUI to caller ---
@ -221,6 +223,7 @@ type tuiModel struct {
tagMode string
tagOldName string
selected map[string]bool
tunnelScreen *tunnelScreenModel
bgResults []templateRunResult
err error
success string
@ -360,11 +363,37 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case forwardDeletedMsg:
if m.forwardScreen != nil && msg.err == nil {
// Reload forwards
return m, m.forwardScreen.loadForwards()
}
return m, nil
case forwardEditSignal:
if m.forwardScreen != nil {
if item, ok := m.forwardScreen.list.SelectedItem().(forwardListItem); ok {
m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, item.forward, m.width, m.height)
m.screen = screenForwardForm
}
}
return m, nil
case tunnelsLoadedMsg:
if m.tunnelScreen != nil {
m.tunnelScreen.tunnels = nil
for _, item := range msg.items {
if ti, ok := item.(tunnelItem); ok {
m.tunnelScreen.tunnels = append(m.tunnelScreen.tunnels, ti.state)
}
}
m.tunnelScreen.rebuildList()
}
return m, nil
case tunnelStoppedMsg:
if m.tunnelScreen != nil && msg.err == nil {
return m, m.tunnelScreen.loadTunnels()
}
return m, nil
case testDoneMsg:
if m.form != nil {
m.form.testing = false
@ -467,6 +496,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateForwardList(msg)
case screenForwardForm:
return m.updateForwardForm(msg)
case screenTunnelManager:
return m.updateTunnelManager(msg)
}
}
@ -936,6 +967,11 @@ func (m *tuiModel) View() string {
if m.forwardForm != nil {
b.WriteString(m.forwardForm.View())
}
case screenTunnelManager:
if m.tunnelScreen != nil {
b.WriteString(m.tunnelScreen.View())
}
}
if m.err != nil {
@ -1004,6 +1040,48 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, tea.Quit
}
case "tunnel_bg":
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.actionMenu = nil
m.result = &TUIResult{
Server: item.server,
Action: "tunnel_bg",
Servers: []*model.Server{item.server},
}
return m, tea.Quit
}
case "forwards":
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.forwardScreen = newForwardScreenModel(item.server.ID, item.server.Alias, m.width, m.height)
m.screen = screenForwardList
m.actionMenu = nil
return m, m.forwardScreen.loadForwards()
}
case "tunnels":
m.tunnelScreen = newTunnelScreenModel(m.width, m.height)
m.screen = screenTunnelManager
m.actionMenu = nil
return m, m.tunnelScreen.loadTunnels()
case "route":
m.err = fmt.Errorf("route management not yet implemented in TUI")
m.screen = screenList
m.actionMenu = nil
case "test":
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.screen = screenList
m.actionMenu = nil
return m, func() tea.Msg {
ok, testErr := TestConnection(item.server)
return testDoneMsg{ok: ok, err: testErr}
}
}
case "edit":
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.form = newEditFormModel(item.server, m.width, m.height)
m.screen = screenForm
m.actionMenu = nil
return m, nil
}
case "delete":
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.screen = screenList
@ -1017,19 +1095,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return serversLoadedMsg{servers: servers, err: err}
}
}
case "test":
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.screen = screenList
m.actionMenu = nil
return m, func() tea.Msg {
ok, testErr := TestConnection(item.server)
return testDoneMsg{ok: ok, err: testErr}
}
}
case "tags":
m.screen = screenTags
m.actionMenu = nil
return m, m.loadTagsCmd()
case "import":
m.screen = screenList
m.actionMenu = nil
@ -1070,6 +1135,10 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.forwardScreen != nil {
return m, m.forwardScreen.deleteSelected()
}
case tea.KeyCtrlE, tea.KeyEnter:
if m.forwardScreen != nil {
return m, m.forwardScreen.editSelected()
}
case tea.KeyRunes:
switch msg.String() {
case "a", "A":
@ -1089,6 +1158,37 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, cmd
}
func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
m.screen = screenList
m.tunnelScreen = nil
return m, nil
case tea.KeyCtrlD:
if m.tunnelScreen != nil {
return m, m.tunnelScreen.stopSelected()
}
case tea.KeyCtrlR:
if m.tunnelScreen != nil {
return m, m.tunnelScreen.loadTunnels()
}
case tea.KeyRunes:
switch msg.String() {
case "d", "D", "s", "S":
if m.tunnelScreen != nil {
return m, m.tunnelScreen.stopSelected()
}
case "r", "R":
if m.tunnelScreen != nil {
return m, m.tunnelScreen.loadTunnels()
}
}
}
var cmd tea.Cmd
m.tunnelScreen.list, cmd = m.tunnelScreen.list.Update(msg)
return m, cmd
}
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyEsc {
m.screen = screenForwardList

View File

@ -2,6 +2,7 @@ package tui
import (
"fmt"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/list"
@ -10,6 +11,18 @@ import (
"github.com/mirivlad/sshkeeper/internal/model"
)
// --- Forward type selector items ---
type forwardTypeItem struct {
value model.ForwardType
label string
description string
}
func (i forwardTypeItem) Title() string { return i.label }
func (i forwardTypeItem) Description() string { return i.description }
func (i forwardTypeItem) FilterValue() string { return i.label }
// --- Forward list screen model ---
type forwardScreenModel struct {
@ -38,44 +51,33 @@ func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwar
}
}
type forwardItem struct {
type forwardListItem struct {
forward *model.Forward
}
func (i forwardItem) Title() string {
return fmt.Sprintf("[%s] %s", i.forward.Type, forwardSummary(i.forward))
func (i forwardListItem) Title() string {
name := i.forward.Name
if name == "" {
name = i.forward.ForwardListen()
}
return fmt.Sprintf("%-20s %-8s %-20s %-20s %s",
truncate(name, 20),
i.forward.Type,
truncate(i.forward.ForwardListen(), 20),
truncate(i.forward.ForwardTarget(), 20),
map[bool]string{true: "yes", false: "no"}[i.forward.Enabled],
)
}
func (i forwardItem) Description() string {
return forwardPreview(i.forward)
func (i forwardListItem) Description() string {
return i.forward.ForwardHumanExplanation("")
}
func (i forwardItem) FilterValue() string {
return string(i.forward.Type) + " " + fmt.Sprintf("%d", i.forward.LocalPort) + " " + i.forward.RemoteAddr
func (i forwardListItem) FilterValue() string {
return i.forward.Name + " " + string(i.forward.Type) + " " + i.forward.ForwardListen() + " " + i.forward.ForwardTarget()
}
func forwardSummary(f *model.Forward) string {
switch f.Type {
case model.ForwardLocal:
return fmt.Sprintf("%s:%d → %s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
case model.ForwardRemote:
return fmt.Sprintf("%s:%d ← %s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
case model.ForwardDynamic:
return fmt.Sprintf("SOCKS %s:%d", f.LocalAddr, f.LocalPort)
default:
return fmt.Sprintf("%s %s:%d", f.Type, f.LocalAddr, f.LocalPort)
}
}
func forwardPreview(f *model.Forward) string {
switch f.Type {
case model.ForwardLocal:
return fmt.Sprintf("-L %s:%d:%s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
case model.ForwardRemote:
return fmt.Sprintf("-R %s:%d:%s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
case model.ForwardDynamic:
return fmt.Sprintf("-D %s:%d", f.LocalAddr, f.LocalPort)
default:
return string(f.Type)
}
func (m *forwardScreenModel) SetServerAlias(alias string) {
m.serverAlias = alias
m.list.Title = "Port Forwards — " + alias
}
func (m *forwardScreenModel) loadForwards() tea.Cmd {
@ -91,13 +93,13 @@ func (m *forwardScreenModel) loadForwards() tea.Cmd {
func (m *forwardScreenModel) rebuildList() {
items := make([]list.Item, len(m.forwards))
for i, f := range m.forwards {
items[i] = forwardItem{forward: f}
items[i] = forwardListItem{forward: f}
}
m.list.SetItems(items)
}
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
if item, ok := m.list.SelectedItem().(forwardItem); ok && DeleteForward != nil {
if item, ok := m.list.SelectedItem().(forwardListItem); ok && DeleteForward != nil {
f := item.forward
return func() tea.Msg {
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
@ -106,12 +108,23 @@ func (m *forwardScreenModel) deleteSelected() tea.Cmd {
return nil
}
func (m *forwardScreenModel) editSelected() tea.Cmd {
// Return signal to open edit form
if _, ok := m.list.SelectedItem().(forwardListItem); ok {
return func() tea.Msg {
return forwardEditSignal{}
}
}
return nil
}
func (m *forwardScreenModel) View() string {
var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width))
@ -122,6 +135,8 @@ func (m *forwardScreenModel) View() string {
type forwardFormModel struct {
serverID int64
editMode bool
editID int64
inputs []textinput.Model
labels []string
focusIdx int
@ -129,21 +144,43 @@ type forwardFormModel struct {
saved bool
typeList list.Model
showList bool
currentType model.ForwardType
nameInput textinput.Model
descInput textinput.Model
width int
height int
}
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
labels := []string{"Type (local/remote/dynamic)", "Listen Addr", "Listen Port", "Target Addr", "Target Port"}
inputs := make([]textinput.Model, len(labels))
for i, label := range labels {
nameInput := textinput.New()
nameInput.Placeholder = "Local PostgreSQL"
nameInput.CharLimit = 128
descInput := textinput.New()
descInput.Placeholder = "optional"
descInput.CharLimit = 256
inputs := make([]textinput.Model, 4)
labels := []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}
placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"}
for i := range labels {
inputs[i] = textinput.New()
inputs[i].Placeholder = forwardPlaceholder(label)
inputs[i].Placeholder = placeholders[i]
inputs[i].CharLimit = 128
}
inputs[0].Focus()
typeList := newStringList([]string{"local", "remote", "dynamic"}, "Select type", 30, 8)
typeItems := []list.Item{
forwardTypeItem{value: model.ForwardLocal, label: "Local", description: "port on my machine → service reachable from SSH server"},
forwardTypeItem{value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"},
forwardTypeItem{value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"},
}
typeList := list.New(typeItems, list.NewDefaultDelegate(), 50, 6)
typeList.Title = "Select forward type"
typeList.SetShowStatusBar(false)
typeList.SetShowHelp(false)
typeList.SetFilteringEnabled(false)
typeList.Styles.Title = titleStyle
return &forwardFormModel{
serverID: serverID,
@ -151,32 +188,70 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
labels: labels,
focusIdx: 0,
typeList: typeList,
currentType: model.ForwardLocal,
nameInput: nameInput,
descInput: descInput,
width: w,
height: h,
}
}
func forwardPlaceholder(label string) string {
switch label {
case "Type (local/remote/dynamic)":
return "local"
case "Listen Addr":
return "0.0.0.0"
case "Listen Port":
return "8080"
case "Target Addr":
return "internal.web"
case "Target Port":
return "80"
default:
return label
func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel {
fm := newForwardFormModel(serverID, w, h)
fm.editMode = true
fm.editID = fwd.ID
fm.nameInput.SetValue(fwd.Name)
fm.descInput.SetValue(fwd.Description)
fm.currentType = fwd.Type
switch fwd.Type {
case model.ForwardLocal:
fm.typeList.Select(0)
case model.ForwardRemote:
fm.typeList.Select(1)
case model.ForwardDynamic:
fm.typeList.Select(2)
}
fm.inputs[0].SetValue(fwd.LocalAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
fm.inputs[2].SetValue(fwd.RemoteAddr)
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
return fm
}
func (fm *forwardFormModel) Init() tea.Cmd {
return nil
}
func (fm *forwardFormModel) visibleFields() []int {
switch fm.currentType {
case model.ForwardLocal:
return []int{0, 1, 2, 3} // listen addr/port, target host/port
case model.ForwardRemote:
return []int{0, 1, 2, 3} // remote listen addr/port, local target host/port
case model.ForwardDynamic:
return []int{0, 1} // listen addr/port only
default:
return []int{0, 1, 2, 3}
}
}
func (fm *forwardFormModel) labelForField(idx int) string {
switch fm.currentType {
case model.ForwardLocal:
return fm.labels[idx]
case model.ForwardRemote:
labels := []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}
return labels[idx]
case model.ForwardDynamic:
return fm.labels[idx]
default:
return fm.labels[idx]
}
}
func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case saveDoneMsg:
@ -193,8 +268,8 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
fm.showList = false
return fm, nil
case tea.KeyEnter:
if item, ok := fm.typeList.SelectedItem().(groupItem); ok {
fm.inputs[0].SetValue(item.name)
if item, ok := fm.typeList.SelectedItem().(forwardTypeItem); ok {
fm.currentType = item.value
}
fm.showList = false
return fm, nil
@ -210,7 +285,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyTab:
fm.focusIdx++
total := len(fm.inputs) + 1
total := 2 + len(fm.visibleFields()) + 1 // name + desc + fields + save btn
if fm.focusIdx >= total {
fm.focusIdx = 0
}
@ -219,7 +294,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyShiftTab:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 1
total := 2 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1
}
fm.updateFocus()
@ -230,7 +305,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil
}
case tea.KeyEnter:
if fm.focusIdx == len(fm.inputs) {
if fm.focusIdx == 2+len(fm.visibleFields()) {
return fm, fm.runSave()
}
fm.focusIdx++
@ -240,7 +315,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil
case tea.KeyDown:
fm.focusIdx++
total := len(fm.inputs) + 1
total := 2 + len(fm.visibleFields()) + 1
if fm.focusIdx >= total {
fm.focusIdx = 0
}
@ -249,7 +324,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyUp:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 1
total := 2 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1
}
fm.updateFocus()
@ -257,9 +332,22 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if fm.focusIdx < len(fm.inputs) {
// Route to focused input
if fm.focusIdx == 0 {
var cmd tea.Cmd
fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg)
fm.nameInput, cmd = fm.nameInput.Update(msg)
return fm, cmd
}
if fm.focusIdx == 1 {
var cmd tea.Cmd
fm.descInput, cmd = fm.descInput.Update(msg)
return fm, cmd
}
visible := fm.visibleFields()
if fm.focusIdx >= 2 && fm.focusIdx < 2+len(visible) {
fieldIdx := visible[fm.focusIdx-2]
var cmd tea.Cmd
fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg)
return fm, cmd
}
@ -267,62 +355,75 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (fm *forwardFormModel) updateFocus() {
fm.nameInput.Blur()
fm.nameInput.Prompt = blurredStyle.Render("Name: ")
fm.descInput.Blur()
fm.descInput.Prompt = blurredStyle.Render("Description: ")
for i := range fm.inputs {
fm.inputs[i].Blur()
fm.inputs[i].Prompt = blurredStyle.Render(fm.labels[i] + ": ")
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ")
}
if fm.focusIdx < len(fm.inputs) {
fm.inputs[fm.focusIdx].Focus()
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labels[fm.focusIdx] + "> ")
total := 2 + len(fm.visibleFields()) + 1
switch {
case fm.focusIdx == 0:
fm.nameInput.Focus()
fm.nameInput.Prompt = focusedStyle.Render("Name> ")
case fm.focusIdx == 1:
fm.descInput.Focus()
fm.descInput.Prompt = focusedStyle.Render("Description> ")
case fm.focusIdx >= 2 && fm.focusIdx < total-1:
visible := fm.visibleFields()
fieldIdx := visible[fm.focusIdx-2]
fm.inputs[fieldIdx].Focus()
fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ")
}
}
func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg {
fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value()))
if fwdType == "" {
return saveDoneMsg{err: fmt.Errorf("forward type is required (local/remote/dynamic)")}
}
if fwdType != model.ForwardLocal && fwdType != model.ForwardRemote && fwdType != model.ForwardDynamic {
return saveDoneMsg{err: fmt.Errorf("invalid forward type %q", fwdType)}
}
name := strings.TrimSpace(fm.nameInput.Value())
desc := strings.TrimSpace(fm.descInput.Value())
localPort := 0
fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort)
fmt.Sscanf(fm.inputs[1].Value(), "%d", &localPort)
remotePort := 0
fmt.Sscanf(fm.inputs[4].Value(), "%d", &remotePort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &remotePort)
localAddr := strings.TrimSpace(fm.inputs[1].Value())
remoteAddr := strings.TrimSpace(fm.inputs[3].Value())
localAddr := strings.TrimSpace(fm.inputs[0].Value())
remoteAddr := strings.TrimSpace(fm.inputs[2].Value())
if name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")}
}
if localPort < 1 || localPort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid listen port %d: must be 1-65535", localPort)}
}
switch fwdType {
switch fm.currentType {
case model.ForwardLocal:
if localAddr == "" {
localAddr = "0.0.0.0"
localAddr = "127.0.0.1"
}
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("target address is required for local forward")}
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")}
}
if remotePort < 1 || remotePort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)}
}
case model.ForwardRemote:
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("target address is required for remote forward")}
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
}
if remotePort < 1 || remotePort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)}
return saveDoneMsg{err: fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort)}
}
if localAddr == "" {
localAddr = "0.0.0.0"
localAddr = "127.0.0.1"
}
case model.ForwardDynamic:
if localAddr == "" {
localAddr = "0.0.0.0"
localAddr = "127.0.0.1"
}
remoteAddr = ""
remotePort = 0
@ -330,60 +431,97 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
fwd := &model.Forward{
ServerID: fm.serverID,
Type: fwdType,
Name: name,
Description: desc,
Type: fm.currentType,
LocalAddr: localAddr,
LocalPort: localPort,
RemoteAddr: remoteAddr,
RemotePort: remotePort,
Enabled: true,
}
if fm.editMode {
fwd.ID = fm.editID
if UpdateForward == nil {
return saveDoneMsg{err: fmt.Errorf("update not available")}
}
return saveDoneMsg{err: UpdateForward(fwd)}
}
if SaveForward == nil {
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
}
if err := SaveForward(fwd); err != nil {
err := SaveForward(fwd)
return saveDoneMsg{err: err}
}
return saveDoneMsg{}
}
}
func (fm *forwardFormModel) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Add Port Forward"))
title := "Add Port Forward"
if fm.editMode {
title = "Edit Port Forward"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
for i := range fm.inputs {
b.WriteString(fm.inputs[i].View())
// Type selector
typeLabel := fmt.Sprintf("Type: %s (/ to change)", fm.currentType)
if fm.focusIdx == 0 {
typeLabel = focusedStyle.Render("Type> " + fmt.Sprintf("%s (/ to change)", fm.currentType))
}
b.WriteString(typeLabel)
b.WriteString("\n")
if i == 0 && fm.showList {
b.WriteString("\n" + renderDropdown(fm.typeList) + "\n")
if fm.showList {
b.WriteString("\n" + fm.typeList.View() + "\n")
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
return b.String()
}
// Name
b.WriteString(fm.nameInput.View())
b.WriteString("\n")
// Description
b.WriteString(fm.descInput.View())
b.WriteString("\n")
// Dynamic fields based on type
visible := fm.visibleFields()
for _, idx := range visible {
b.WriteString(fm.inputs[idx].View())
b.WriteString("\n")
}
// Warning for 0.0.0.0
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n"))
}
// Preview
fwdType := strings.TrimSpace(fm.inputs[0].Value())
localAddr := fm.inputs[1].Value()
localPort := fm.inputs[2].Value()
remoteAddr := fm.inputs[3].Value()
remotePort := fm.inputs[4].Value()
if fwdType != "" && localPort != "" {
if fm.currentType != "" && fm.inputs[1].Value() != "" {
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
switch fwdType {
case "local":
b.WriteString(fmt.Sprintf(" -L %s:%s:%s:%s\n", localAddr, localPort, remoteAddr, remotePort))
case "remote":
b.WriteString(fmt.Sprintf(" -R %s:%s:%s:%s\n", remoteAddr, remotePort, localAddr, localPort))
case "dynamic":
b.WriteString(fmt.Sprintf(" -D %s:%s\n", localAddr, localPort))
fwd := &model.Forward{
Type: fm.currentType,
LocalAddr: fm.inputs[0].Value(),
LocalPort: 0,
RemoteAddr: fm.inputs[2].Value(),
RemotePort: 0,
}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
for _, arg := range fwd.ForwardSSHArgs() {
b.WriteString(" " + arg + "\n")
}
b.WriteString(" -o ExitOnForwardFailure=yes\n")
}
// Save button
total := 2 + len(visible) + 1
button := "\n[ Save ]"
if fm.focusIdx == len(fm.inputs) {
if fm.focusIdx == total-1 {
button = selectedStyle.Render(button)
}
b.WriteString(button)
@ -399,10 +537,13 @@ func (fm *forwardFormModel) View() string {
b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"},
{Key: "/", Action: "pick type"},
{Key: "/", Action: "change type"},
{Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"},
}, fm.width))
return b.String()
}
// forwardEditSignal is sent when user wants to edit a forward
type forwardEditSignal struct{}

View File

@ -123,11 +123,15 @@ type actionMenuModel struct {
func newActionMenuModel(w, h int) *actionMenuModel {
items := []list.Item{
actionMenuItem{label: "Connect", action: "connect"},
actionMenuItem{label: "Start tunnel", action: "tunnel"},
actionMenuItem{label: "Tunnel mode (-N)", action: "tunnel_n"},
actionMenuItem{label: "Delete", action: "delete"},
actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg"},
actionMenuItem{label: "Manage port forwards", action: "forwards"},
actionMenuItem{label: "Manage tunnels", action: "tunnels"},
actionMenuItem{label: "Manage route", action: "route"},
actionMenuItem{label: "Test connection", action: "test"},
actionMenuItem{label: "Tags", action: "tags"},
actionMenuItem{label: "Edit", action: "edit"},
actionMenuItem{label: "Delete", action: "delete"},
actionMenuItem{label: "Import", action: "import"},
actionMenuItem{label: "Export", action: "export"},
actionMenuItem{label: "Vault: lock", action: "vault_lock"},

115
internal/tui/tunnel.go Normal file
View File

@ -0,0 +1,115 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/tunnel"
)
// --- Tunnel manager screen ---
type tunnelScreenModel struct {
list list.Model
tunnels []*model.TunnelState
width int
height int
err error
}
type tunnelItem struct {
state *model.TunnelState
}
func (i tunnelItem) Title() string {
status := "stopped"
if tunnel.IsRunning(i.state.ID) {
status = "running"
}
duration := time.Since(i.state.StartedAt).Round(time.Second)
return fmt.Sprintf("%-30s PID %-8d %-8s %s",
truncate(i.state.Name, 30),
i.state.PID,
status,
duration,
)
}
func (i tunnelItem) Description() string {
preview := fmt.Sprintf("ssh -N ... → %s", i.state.ServerAlias)
if i.state.LastError != "" {
return fmt.Sprintf(" %s\n ✗ %s", preview, i.state.LastError)
}
return " " + preview
}
func (i tunnelItem) FilterValue() string {
return i.state.Name + " " + i.state.ServerAlias
}
func newTunnelScreenModel(w, h int) *tunnelScreenModel {
l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, h-6)
l.Title = "Tunnel Manager"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.Styles.Title = titleStyle
return &tunnelScreenModel{
list: l,
width: w,
height: h,
}
}
func (m *tunnelScreenModel) loadTunnels() tea.Cmd {
return func() tea.Msg {
states := tunnel.List()
items := make([]list.Item, len(states))
for i, s := range states {
items[i] = tunnelItem{state: s}
}
return tunnelsLoadedMsg{items: items}
}
}
func (m *tunnelScreenModel) rebuildList() {
items := make([]list.Item, len(m.tunnels))
for i, s := range m.tunnels {
items[i] = tunnelItem{state: s}
}
m.list.SetItems(items)
}
func (m *tunnelScreenModel) stopSelected() tea.Cmd {
if item, ok := m.list.SelectedItem().(tunnelItem); ok {
return func() tea.Msg {
return tunnelStoppedMsg{id: item.state.ID, err: tunnel.Stop(item.state.ID)}
}
}
return nil
}
func (m *tunnelScreenModel) View() string {
var b strings.Builder
b.WriteString(m.list.View())
b.WriteString("\n\n")
b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"},
{Key: "Esc", Action: "back"},
}, m.width))
return b.String()
}
type tunnelsLoadedMsg struct {
items []list.Item
}
type tunnelStoppedMsg struct {
id int64
err error
}

192
internal/tunnel/manager.go Normal file
View File

@ -0,0 +1,192 @@
package tunnel
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/mirivlad/sshkeeper/internal/config"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/ssh"
)
var (
mu sync.Mutex
states = map[int64]*model.TunnelState{}
dataDir string
)
// Init initializes the tunnel state manager with the data directory.
func Init(dir string) error {
dataDir = dir
return loadStates()
}
// StateFilePath returns the path to the tunnel state file.
func StateFilePath() string {
return filepath.Join(dataDir, "tunnels.json")
}
func loadStates() error {
path := StateFilePath()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read tunnel states: %w", err)
}
var list []*model.TunnelState
if err := json.Unmarshal(b, &list); err != nil {
return fmt.Errorf("unmarshal tunnel states: %w", err)
}
for _, s := range list {
states[s.ID] = s
}
return nil
}
func saveStates() error {
path := StateFilePath()
list := make([]*model.TunnelState, 0, len(states))
for _, s := range states {
list = append(list, s)
}
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return fmt.Errorf("marshal tunnel states: %w", err)
}
os.MkdirAll(dataDir, 0700)
return os.WriteFile(path, b, 0600)
}
// List returns all tunnel states.
func List() []*model.TunnelState {
mu.Lock()
defer mu.Unlock()
result := make([]*model.TunnelState, 0, len(states))
for _, s := range states {
result = append(result, s)
}
return result
}
// Get returns a tunnel state by ID.
func Get(id int64) *model.TunnelState {
mu.Lock()
defer mu.Unlock()
return states[id]
}
// Start starts a tunnel for the given server with its forwards.
func Start(cfg *config.Config, server *model.Server, forwards []*model.Forward, forwardOnly bool) (*model.TunnelState, error) {
mu.Lock()
defer mu.Unlock()
// Filter enabled forwards
var active []*model.Forward
for _, f := range forwards {
if f.Enabled {
active = append(active, f)
}
}
sshArgs := ssh.BuildSSHArgs(server, active, forwardOnly)
args := make([]string, len(sshArgs))
copy(args, sshArgs)
cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start tunnel: %w", err)
}
forwardIDs := make([]int64, len(active))
for i, f := range active {
forwardIDs[i] = f.ID
}
id := time.Now().UnixNano()
state := &model.TunnelState{
ID: id,
ServerID: server.ID,
ServerAlias: server.Alias,
Name: fmt.Sprintf("Tunnel to %s", server.Alias),
PID: cmd.Process.Pid,
ForwardIDs: forwardIDs,
StartedAt: time.Now(),
}
states[id] = state
if err := saveStates(); err != nil {
// Non-fatal: log but don't fail
_ = err
}
return state, nil
}
// Stop stops a tunnel by ID.
func Stop(id int64) error {
mu.Lock()
defer mu.Unlock()
state, ok := states[id]
if !ok {
return fmt.Errorf("tunnel %d not found", id)
}
if state.PID > 0 {
proc, err := os.FindProcess(state.PID)
if err == nil {
proc.Kill()
}
}
delete(states, id)
return saveStates()
}
// StopAll stops all running tunnels.
func StopAll() error {
mu.Lock()
defer mu.Unlock()
for id, state := range states {
if state.PID > 0 {
proc, _ := os.FindProcess(state.PID)
if proc != nil {
proc.Kill()
}
}
delete(states, id)
}
return saveStates()
}
// IsRunning checks if a tunnel process is still running.
func IsRunning(id int64) bool {
mu.Lock()
defer mu.Unlock()
state, ok := states[id]
if !ok {
return false
}
if state.PID <= 0 {
return false
}
proc, err := os.FindProcess(state.PID)
if err != nil {
return false
}
// Signal 0 just checks if process exists
return proc.Signal(os.Signal(nil)) == nil
}