diff --git a/cmd/forward.go b/cmd/forward.go index 70102ce..a825de9 100644 --- a/cmd/forward.go +++ b/cmd/forward.go @@ -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 ", + 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 ", 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) } diff --git a/cmd/root.go b/cmd/root.go index 28a6466..9604d1e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) diff --git a/cmd/tui.go b/cmd/tui.go index 9d8a466..263c03a 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -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 { diff --git a/internal/db/db.go b/internal/db/db.go index bb2d857..be85e0f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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, diff --git a/internal/db/migrations/003_forwards_name.sql b/internal/db/migrations/003_forwards_name.sql new file mode 100644 index 0000000..78627d7 --- /dev/null +++ b/internal/db/migrations/003_forwards_name.sql @@ -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. \ No newline at end of file diff --git a/internal/db/servers.go b/internal/db/servers.go index 2bd5fbd..421d170 100644 --- a/internal/db/servers.go +++ b/internal/db/servers.go @@ -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) diff --git a/internal/model/server.go b/internal/model/server.go index eb0a19a..95c7ace 100644 --- a/internal/model/server.go +++ b/internal/model/server.go @@ -1,6 +1,7 @@ package model import ( + "fmt" "strings" "time" ) @@ -70,13 +71,73 @@ const ( ) type Forward struct { - ID int64 `json:"id"` - ServerID int64 `json:"server_id"` - Type ForwardType `json:"type"` - LocalAddr string `json:"local_addr"` - LocalPort int `json:"local_port"` - RemoteAddr string `json:"remote_addr"` - RemotePort int `json:"remote_port"` + 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"` +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 352e411..df54b1d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 diff --git a/internal/tui/forward.go b/internal/tui/forward.go index c48849f..75bb6ef 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -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)) @@ -121,62 +134,124 @@ func (m *forwardScreenModel) View() string { // --- Forward form screen model --- type forwardFormModel struct { - serverID int64 - inputs []textinput.Model - labels []string - focusIdx int - err error - saved bool - typeList list.Model - showList bool - width int - height int + serverID int64 + editMode bool + editID int64 + inputs []textinput.Model + labels []string + focusIdx int + err error + 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, - inputs: inputs, - labels: labels, - focusIdx: 0, - typeList: typeList, - width: w, - height: h, + serverID: serverID, + inputs: inputs, + 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,123 +355,173 @@ 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 } fwd := &model.Forward{ - ServerID: fm.serverID, - Type: fwdType, - LocalAddr: localAddr, - LocalPort: localPort, - RemoteAddr: remoteAddr, - RemotePort: remotePort, + ServerID: fm.serverID, + 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 { - return saveDoneMsg{err: err} - } - return saveDoneMsg{} + err := SaveForward(fwd) + return saveDoneMsg{err: err} } } 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 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") - if i == 0 && fm.showList { - b.WriteString("\n" + renderDropdown(fm.typeList) + "\n") - b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) - return b.String() - } + } + + // 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{} diff --git a/internal/tui/help_screen.go b/internal/tui/help_screen.go index f81a6b9..cea28cf 100644 --- a/internal/tui/help_screen.go +++ b/internal/tui/help_screen.go @@ -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"}, diff --git a/internal/tui/tunnel.go b/internal/tui/tunnel.go new file mode 100644 index 0000000..d00f9cf --- /dev/null +++ b/internal/tui/tunnel.go @@ -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 +} diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go new file mode 100644 index 0000000..a23e747 --- /dev/null +++ b/internal/tunnel/manager.go @@ -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 +}