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