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, 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 { if err != nil {
return fmt.Errorf("add forward: %w", err) 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{ var forwardDeleteCmd = &cobra.Command{
Use: "delete <alias> <id>", Use: "delete <alias> <id>",
Short: "Delete a port forward", Short: "Delete a port forward",
@ -163,13 +182,14 @@ var forwardDeleteCmd = &cobra.Command{
func init() { func init() {
forwardAddCmd.Flags().String("type", "local", "Forward type: local, remote, dynamic") forwardAddCmd.Flags().String("type", "local", "Forward type: local, remote, dynamic")
forwardAddCmd.Flags().String("local-addr", "0.0.0.0", "Listen address") forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
forwardAddCmd.Flags().Int("local-port", 0, "Listen port (required)")
forwardAddCmd.MarkFlagRequired("local-port") forwardAddCmd.MarkFlagRequired("local-port")
forwardAddCmd.Flags().String("remote-addr", "", "Target address") forwardAddCmd.Flags().String("remote-addr", "", "Target address")
forwardAddCmd.Flags().Int("remote-port", 0, "Target port") forwardAddCmd.Flags().Int("remote-port", 0, "Target port")
forwardEditCmd.Flags().Bool("enabled", true, "Enable/disable forward")
forwardCmd.AddCommand(forwardListCmd) forwardCmd.AddCommand(forwardListCmd)
forwardCmd.AddCommand(forwardAddCmd) forwardCmd.AddCommand(forwardAddCmd)
forwardCmd.AddCommand(forwardDeleteCmd) forwardCmd.AddCommand(forwardDeleteCmd)
forwardCmd.AddCommand(forwardEditCmd)
} }

View File

@ -7,6 +7,7 @@ import (
"github.com/mirivlad/sshkeeper/internal/config" "github.com/mirivlad/sshkeeper/internal/config"
"github.com/mirivlad/sshkeeper/internal/db" "github.com/mirivlad/sshkeeper/internal/db"
tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel"
"github.com/mirivlad/sshkeeper/internal/vault" "github.com/mirivlad/sshkeeper/internal/vault"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term" "golang.org/x/term"
@ -75,6 +76,12 @@ func initApp() {
os.Exit(1) 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 // Handle vault: create on first run, unlock on subsequent runs
vaultPath := config.VaultPath(cfg.DataDir) vaultPath := config.VaultPath(cfg.DataDir)
v := vault.New(vaultPath) v := vault.New(vaultPath)

View File

@ -8,6 +8,7 @@ import (
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
"github.com/mirivlad/sshkeeper/internal/ssh" "github.com/mirivlad/sshkeeper/internal/ssh"
"github.com/mirivlad/sshkeeper/internal/tui" "github.com/mirivlad/sshkeeper/internal/tui"
tunnelpkg "github.com/mirivlad/sshkeeper/internal/tunnel"
) )
func runTUI() error { func runTUI() error {
@ -128,9 +129,12 @@ func runTUI() error {
return appDB.GetForwards(serverID) return appDB.GetForwards(serverID)
} }
tui.SaveForward = func(fwd *model.Forward) error { 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 return err
} }
tui.UpdateForward = func(fwd *model.Forward) error {
return appDB.UpdateForward(fwd)
}
tui.DeleteForward = func(forwardID int64) error { tui.DeleteForward = func(forwardID int64) error {
return appDB.DeleteForward(forwardID) return appDB.DeleteForward(forwardID)
} }
@ -209,7 +213,7 @@ func runTUI() error {
continue 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 server := result.Server
fresh, err := appDB.GetServer(server.Alias) fresh, err := appDB.GetServer(server.Alias)
if err != nil { if err != nil {
@ -218,7 +222,6 @@ func runTUI() error {
continue continue
} }
// Load forwards
forwards, err := appDB.GetForwards(fresh.ID) forwards, err := appDB.GetForwards(fresh.ID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Load forwards: %v\n", err) fmt.Fprintf(os.Stderr, "Load forwards: %v\n", err)
@ -226,7 +229,21 @@ func runTUI() error {
continue 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 { if len(forwards) > 0 {
fmt.Printf("Starting tunnel to %s with %d forward(s)...\n", fresh.Alias, len(forwards)) fmt.Printf("Starting tunnel to %s with %d forward(s)...\n", fresh.Alias, len(forwards))
} else { } 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 { 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) 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 { 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) 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(` _, err = db.conn.Exec(`
CREATE TABLE IF NOT EXISTS global_command_templates ( CREATE TABLE IF NOT EXISTS global_command_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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 --- // --- 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(` result, err := db.conn.Exec(`
INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port) INSERT INTO forwards (server_id, name, description, type, local_addr, local_port, remote_addr, remote_port, enabled)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
serverID, fwdType, localAddr, localPort, remoteAddr, remotePort) fwd.ServerID, fwd.Name, fwd.Description, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort, fwd.Enabled)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return result.LastInsertId() 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) { func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
rows, err := db.conn.Query(` 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) FROM forwards WHERE server_id=?`, serverID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -345,7 +353,7 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
var forwards []*model.Forward var forwards []*model.Forward
for rows.Next() { for rows.Next() {
var f model.Forward 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 return nil, err
} }
forwards = append(forwards, &f) forwards = append(forwards, &f)

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"fmt"
"strings" "strings"
"time" "time"
) )
@ -70,13 +71,73 @@ const (
) )
type Forward struct { type Forward struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ServerID int64 `json:"server_id"` ServerID int64 `json:"server_id"`
Type ForwardType `json:"type"` Name string `json:"name"`
LocalAddr string `json:"local_addr"` Description string `json:"description"`
LocalPort int `json:"local_port"` Type ForwardType `json:"type"`
RemoteAddr string `json:"remote_addr"` LocalAddr string `json:"local_addr"`
RemotePort int `json:"remote_port"` 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 { type Tag struct {
@ -159,3 +220,17 @@ type CommandTemplate struct {
Command string `json:"command"` Command string `json:"command"`
Description string `json:"description"` 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) RunTemplateBackground func(server *model.Server, command string) (string, error)
ListForwards func(serverID int64) ([]*model.Forward, error) ListForwards func(serverID int64) ([]*model.Forward, error)
SaveForward func(fwd *model.Forward) error SaveForward func(fwd *model.Forward) error
UpdateForward func(fwd *model.Forward) error
DeleteForward func(forwardID int64) error DeleteForward func(forwardID int64) error
) )
@ -191,6 +192,7 @@ const (
screenActionMenu screenActionMenu
screenForwardList screenForwardList
screenForwardForm screenForwardForm
screenTunnelManager
) )
// --- Result type — returned from TUI to caller --- // --- Result type — returned from TUI to caller ---
@ -221,6 +223,7 @@ type tuiModel struct {
tagMode string tagMode string
tagOldName string tagOldName string
selected map[string]bool selected map[string]bool
tunnelScreen *tunnelScreenModel
bgResults []templateRunResult bgResults []templateRunResult
err error err error
success string success string
@ -360,11 +363,37 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case forwardDeletedMsg: case forwardDeletedMsg:
if m.forwardScreen != nil && msg.err == nil { if m.forwardScreen != nil && msg.err == nil {
// Reload forwards
return m, m.forwardScreen.loadForwards() return m, m.forwardScreen.loadForwards()
} }
return m, nil 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: case testDoneMsg:
if m.form != nil { if m.form != nil {
m.form.testing = false m.form.testing = false
@ -467,6 +496,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateForwardList(msg) return m.updateForwardList(msg)
case screenForwardForm: case screenForwardForm:
return m.updateForwardForm(msg) return m.updateForwardForm(msg)
case screenTunnelManager:
return m.updateTunnelManager(msg)
} }
} }
@ -936,6 +967,11 @@ func (m *tuiModel) View() string {
if m.forwardForm != nil { if m.forwardForm != nil {
b.WriteString(m.forwardForm.View()) b.WriteString(m.forwardForm.View())
} }
case screenTunnelManager:
if m.tunnelScreen != nil {
b.WriteString(m.tunnelScreen.View())
}
} }
if m.err != nil { if m.err != nil {
@ -1004,6 +1040,48 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, tea.Quit 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": case "delete":
if item, ok := m.list.SelectedItem().(serverItem); ok { if item, ok := m.list.SelectedItem().(serverItem); ok {
m.screen = screenList m.screen = screenList
@ -1017,19 +1095,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return serversLoadedMsg{servers: servers, err: err} 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": case "import":
m.screen = screenList m.screen = screenList
m.actionMenu = nil m.actionMenu = nil
@ -1070,6 +1135,10 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.forwardScreen != nil { if m.forwardScreen != nil {
return m, m.forwardScreen.deleteSelected() return m, m.forwardScreen.deleteSelected()
} }
case tea.KeyCtrlE, tea.KeyEnter:
if m.forwardScreen != nil {
return m, m.forwardScreen.editSelected()
}
case tea.KeyRunes: case tea.KeyRunes:
switch msg.String() { switch msg.String() {
case "a", "A": case "a", "A":
@ -1089,6 +1158,37 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, 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) { func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyEsc { if msg.Type == tea.KeyEsc {
m.screen = screenForwardList m.screen = screenForwardList

View File

@ -2,6 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@ -10,6 +11,18 @@ import (
"github.com/mirivlad/sshkeeper/internal/model" "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 --- // --- Forward list screen model ---
type forwardScreenModel struct { 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 forward *model.Forward
} }
func (i forwardItem) Title() string { func (i forwardListItem) Title() string {
return fmt.Sprintf("[%s] %s", i.forward.Type, forwardSummary(i.forward)) 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 { func (i forwardListItem) Description() string {
return forwardPreview(i.forward) return i.forward.ForwardHumanExplanation("")
} }
func (i forwardItem) FilterValue() string { func (i forwardListItem) FilterValue() string {
return string(i.forward.Type) + " " + fmt.Sprintf("%d", i.forward.LocalPort) + " " + i.forward.RemoteAddr return i.forward.Name + " " + string(i.forward.Type) + " " + i.forward.ForwardListen() + " " + i.forward.ForwardTarget()
} }
func forwardSummary(f *model.Forward) string { func (m *forwardScreenModel) SetServerAlias(alias string) {
switch f.Type { m.serverAlias = alias
case model.ForwardLocal: m.list.Title = "Port Forwards — " + alias
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) loadForwards() tea.Cmd { func (m *forwardScreenModel) loadForwards() tea.Cmd {
@ -91,13 +93,13 @@ func (m *forwardScreenModel) loadForwards() tea.Cmd {
func (m *forwardScreenModel) rebuildList() { func (m *forwardScreenModel) rebuildList() {
items := make([]list.Item, len(m.forwards)) items := make([]list.Item, len(m.forwards))
for i, f := range m.forwards { for i, f := range m.forwards {
items[i] = forwardItem{forward: f} items[i] = forwardListItem{forward: f}
} }
m.list.SetItems(items) m.list.SetItems(items)
} }
func (m *forwardScreenModel) deleteSelected() tea.Cmd { 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 f := item.forward
return func() tea.Msg { return func() tea.Msg {
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)} return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
@ -106,12 +108,23 @@ func (m *forwardScreenModel) deleteSelected() tea.Cmd {
return nil 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 { func (m *forwardScreenModel) View() string {
var b strings.Builder var b strings.Builder
b.WriteString(m.list.View()) b.WriteString(m.list.View())
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(renderHelp([]helpItem{ b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"}, {Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, m.width)) }, m.width))
@ -121,62 +134,124 @@ func (m *forwardScreenModel) View() string {
// --- Forward form screen model --- // --- Forward form screen model ---
type forwardFormModel struct { type forwardFormModel struct {
serverID int64 serverID int64
inputs []textinput.Model editMode bool
labels []string editID int64
focusIdx int inputs []textinput.Model
err error labels []string
saved bool focusIdx int
typeList list.Model err error
showList bool saved bool
width int typeList list.Model
height int showList bool
currentType model.ForwardType
nameInput textinput.Model
descInput textinput.Model
width int
height int
} }
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel { func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
labels := []string{"Type (local/remote/dynamic)", "Listen Addr", "Listen Port", "Target Addr", "Target Port"} nameInput := textinput.New()
inputs := make([]textinput.Model, len(labels)) nameInput.Placeholder = "Local PostgreSQL"
for i, label := range labels { 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] = textinput.New()
inputs[i].Placeholder = forwardPlaceholder(label) inputs[i].Placeholder = placeholders[i]
inputs[i].CharLimit = 128 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{ return &forwardFormModel{
serverID: serverID, serverID: serverID,
inputs: inputs, inputs: inputs,
labels: labels, labels: labels,
focusIdx: 0, focusIdx: 0,
typeList: typeList, typeList: typeList,
width: w, currentType: model.ForwardLocal,
height: h, nameInput: nameInput,
descInput: descInput,
width: w,
height: h,
} }
} }
func forwardPlaceholder(label string) string { func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel {
switch label { fm := newForwardFormModel(serverID, w, h)
case "Type (local/remote/dynamic)": fm.editMode = true
return "local" fm.editID = fwd.ID
case "Listen Addr": fm.nameInput.SetValue(fwd.Name)
return "0.0.0.0" fm.descInput.SetValue(fwd.Description)
case "Listen Port": fm.currentType = fwd.Type
return "8080"
case "Target Addr": switch fwd.Type {
return "internal.web" case model.ForwardLocal:
case "Target Port": fm.typeList.Select(0)
return "80" case model.ForwardRemote:
default: fm.typeList.Select(1)
return label 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 { func (fm *forwardFormModel) Init() tea.Cmd {
return nil 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) { func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case saveDoneMsg: case saveDoneMsg:
@ -193,8 +268,8 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
fm.showList = false fm.showList = false
return fm, nil return fm, nil
case tea.KeyEnter: case tea.KeyEnter:
if item, ok := fm.typeList.SelectedItem().(groupItem); ok { if item, ok := fm.typeList.SelectedItem().(forwardTypeItem); ok {
fm.inputs[0].SetValue(item.name) fm.currentType = item.value
} }
fm.showList = false fm.showList = false
return fm, nil return fm, nil
@ -210,7 +285,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.Type { switch msg.Type {
case tea.KeyTab: case tea.KeyTab:
fm.focusIdx++ fm.focusIdx++
total := len(fm.inputs) + 1 total := 2 + len(fm.visibleFields()) + 1 // name + desc + fields + save btn
if fm.focusIdx >= total { if fm.focusIdx >= total {
fm.focusIdx = 0 fm.focusIdx = 0
} }
@ -219,7 +294,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyShiftTab: case tea.KeyShiftTab:
fm.focusIdx-- fm.focusIdx--
if fm.focusIdx < 0 { if fm.focusIdx < 0 {
total := len(fm.inputs) + 1 total := 2 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1 fm.focusIdx = total - 1
} }
fm.updateFocus() fm.updateFocus()
@ -230,7 +305,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil return fm, nil
} }
case tea.KeyEnter: case tea.KeyEnter:
if fm.focusIdx == len(fm.inputs) { if fm.focusIdx == 2+len(fm.visibleFields()) {
return fm, fm.runSave() return fm, fm.runSave()
} }
fm.focusIdx++ fm.focusIdx++
@ -240,7 +315,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil return fm, nil
case tea.KeyDown: case tea.KeyDown:
fm.focusIdx++ fm.focusIdx++
total := len(fm.inputs) + 1 total := 2 + len(fm.visibleFields()) + 1
if fm.focusIdx >= total { if fm.focusIdx >= total {
fm.focusIdx = 0 fm.focusIdx = 0
} }
@ -249,7 +324,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyUp: case tea.KeyUp:
fm.focusIdx-- fm.focusIdx--
if fm.focusIdx < 0 { if fm.focusIdx < 0 {
total := len(fm.inputs) + 1 total := 2 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1 fm.focusIdx = total - 1
} }
fm.updateFocus() 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 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 return fm, cmd
} }
@ -267,123 +355,173 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (fm *forwardFormModel) updateFocus() { 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 { for i := range fm.inputs {
fm.inputs[i].Blur() 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() total := 2 + len(fm.visibleFields()) + 1
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labels[fm.focusIdx] + "> ") 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 { func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value())) name := strings.TrimSpace(fm.nameInput.Value())
if fwdType == "" { desc := strings.TrimSpace(fm.descInput.Value())
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)}
}
localPort := 0 localPort := 0
fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort) fmt.Sscanf(fm.inputs[1].Value(), "%d", &localPort)
remotePort := 0 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()) localAddr := strings.TrimSpace(fm.inputs[0].Value())
remoteAddr := strings.TrimSpace(fm.inputs[3].Value()) remoteAddr := strings.TrimSpace(fm.inputs[2].Value())
if name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")}
}
if localPort < 1 || localPort > 65535 { if localPort < 1 || localPort > 65535 {
return saveDoneMsg{err: fmt.Errorf("invalid listen port %d: must be 1-65535", localPort)} return saveDoneMsg{err: fmt.Errorf("invalid listen port %d: must be 1-65535", localPort)}
} }
switch fwdType { switch fm.currentType {
case model.ForwardLocal: case model.ForwardLocal:
if localAddr == "" { if localAddr == "" {
localAddr = "0.0.0.0" localAddr = "127.0.0.1"
} }
if remoteAddr == "" { 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 { 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 target port %d: must be 1-65535", remotePort)}
} }
case model.ForwardRemote: case model.ForwardRemote:
if remoteAddr == "" { 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 { 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 == "" { if localAddr == "" {
localAddr = "0.0.0.0" localAddr = "127.0.0.1"
} }
case model.ForwardDynamic: case model.ForwardDynamic:
if localAddr == "" { if localAddr == "" {
localAddr = "0.0.0.0" localAddr = "127.0.0.1"
} }
remoteAddr = "" remoteAddr = ""
remotePort = 0 remotePort = 0
} }
fwd := &model.Forward{ fwd := &model.Forward{
ServerID: fm.serverID, ServerID: fm.serverID,
Type: fwdType, Name: name,
LocalAddr: localAddr, Description: desc,
LocalPort: localPort, Type: fm.currentType,
RemoteAddr: remoteAddr, LocalAddr: localAddr,
RemotePort: remotePort, 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 { if SaveForward == nil {
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")} return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
} }
if err := SaveForward(fwd); err != nil { err := SaveForward(fwd)
return saveDoneMsg{err: err} return saveDoneMsg{err: err}
}
return saveDoneMsg{}
} }
} }
func (fm *forwardFormModel) View() string { func (fm *forwardFormModel) View() string {
var b strings.Builder 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") b.WriteString("\n\n")
for i := range fm.inputs { // Type selector
b.WriteString(fm.inputs[i].View()) 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") 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)) // Warning for 0.0.0.0
return b.String() 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 // Preview
fwdType := strings.TrimSpace(fm.inputs[0].Value()) if fm.currentType != "" && fm.inputs[1].Value() != "" {
localAddr := fm.inputs[1].Value()
localPort := fm.inputs[2].Value()
remoteAddr := fm.inputs[3].Value()
remotePort := fm.inputs[4].Value()
if fwdType != "" && localPort != "" {
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n") b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
switch fwdType { fwd := &model.Forward{
case "local": Type: fm.currentType,
b.WriteString(fmt.Sprintf(" -L %s:%s:%s:%s\n", localAddr, localPort, remoteAddr, remotePort)) LocalAddr: fm.inputs[0].Value(),
case "remote": LocalPort: 0,
b.WriteString(fmt.Sprintf(" -R %s:%s:%s:%s\n", remoteAddr, remotePort, localAddr, localPort)) RemoteAddr: fm.inputs[2].Value(),
case "dynamic": RemotePort: 0,
b.WriteString(fmt.Sprintf(" -D %s:%s\n", localAddr, localPort)) }
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") b.WriteString(" -o ExitOnForwardFailure=yes\n")
} }
// Save button
total := 2 + len(visible) + 1
button := "\n[ Save ]" button := "\n[ Save ]"
if fm.focusIdx == len(fm.inputs) { if fm.focusIdx == total-1 {
button = selectedStyle.Render(button) button = selectedStyle.Render(button)
} }
b.WriteString(button) b.WriteString(button)
@ -399,10 +537,13 @@ func (fm *forwardFormModel) View() string {
b.WriteString(renderHelp([]helpItem{ b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"}, {Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"}, {Key: "↑", Action: "prev"},
{Key: "/", Action: "pick type"}, {Key: "/", Action: "change type"},
{Key: "Enter", Action: "save"}, {Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, fm.width)) }, fm.width))
return b.String() 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 { func newActionMenuModel(w, h int) *actionMenuModel {
items := []list.Item{ items := []list.Item{
actionMenuItem{label: "Connect", action: "connect"}, actionMenuItem{label: "Connect", action: "connect"},
actionMenuItem{label: "Start tunnel", action: "tunnel"}, actionMenuItem{label: "Connect with tunnels", action: "tunnel"},
actionMenuItem{label: "Tunnel mode (-N)", action: "tunnel_n"}, actionMenuItem{label: "Start tunnels only", action: "tunnel_n"},
actionMenuItem{label: "Delete", action: "delete"}, 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: "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: "Import", action: "import"},
actionMenuItem{label: "Export", action: "export"}, actionMenuItem{label: "Export", action: "export"},
actionMenuItem{label: "Vault: lock", action: "vault_lock"}, 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
}