From 700724e93b400102347085cf9a3aa674a1293a10 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 3 Jun 2026 10:00:12 +0800 Subject: [PATCH] =?UTF-8?q?sshkeeper:=20v0.2.0=20=E2=80=94=20Phase=202:=20?= =?UTF-8?q?Route=20/=20ProxyJump=20UX=20(model,=20migration,=20DB,=20SSH?= =?UTF-8?q?=20args,=20TUI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/migrations/002_routes.sql | 10 ++ internal/db/servers.go | 98 ++++++++++++++---- internal/model/server.go | 74 +++++++++++++- internal/ssh/command.go | 6 +- internal/ssh/route.go | 12 +++ internal/ssh/route_test.go | 140 ++++++++++++++++++++++++++ internal/tui/app.go | 10 +- internal/tui/form.go | 58 +++++++++-- 8 files changed, 375 insertions(+), 33 deletions(-) create mode 100644 internal/db/migrations/002_routes.sql create mode 100644 internal/ssh/route.go create mode 100644 internal/ssh/route_test.go diff --git a/internal/db/migrations/002_routes.sql b/internal/db/migrations/002_routes.sql new file mode 100644 index 0000000..2ea8b91 --- /dev/null +++ b/internal/db/migrations/002_routes.sql @@ -0,0 +1,10 @@ +-- v0.2.0: Add route support +-- Adds route columns to servers table and migrates existing ProxyJump data. + +ALTER TABLE servers ADD COLUMN route_hops TEXT NOT NULL DEFAULT ''; + +-- Migrate existing ProxyJump values into Route.Hops (all as raw addresses). +-- ProxyJump format: "host1,host2,host3" → each becomes a RouteHop with IsProfile=false. +-- We store as a simple comma-separated list of raw addresses in route_hops for now. +-- The application layer will parse this into Route.Hops on read. +UPDATE servers SET route_hops = proxy_jump WHERE proxy_jump != '' AND route_hops = ''; diff --git a/internal/db/servers.go b/internal/db/servers.go index 34356de..bb26cc2 100644 --- a/internal/db/servers.go +++ b/internal/db/servers.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "encoding/json" "sort" "strings" "time" @@ -9,11 +10,42 @@ import ( "github.com/mirivlad/sshkeeper/internal/model" ) +// --- Route marshaling helpers --- + +func marshalRoute(route model.Route) string { + if len(route.Hops) == 0 { + return "" + } + b, _ := json.Marshal(route.Hops) + return string(b) +} + +func unmarshalRoute(s string) model.Route { + s = strings.TrimSpace(s) + if s == "" { + return model.Route{} + } + var hops []model.RouteHop + if err := json.Unmarshal([]byte(s), &hops); err != nil { + parts := strings.Split(s, ",") + hops = make([]model.RouteHop, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + hops = append(hops, model.RouteHop{Raw: p, IsProfile: false}) + } + } + } + return model.Route{Hops: hops} +} + +// --- Server CRUD --- + func (db *DB) CreateServer(s *model.Server) error { result, err := db.conn.Exec(` - INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, group_name, notes, startup_command) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.StartupCommand) + INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, route_hops, group_name, notes, startup_command) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand) if err != nil { return err } @@ -25,10 +57,10 @@ func (db *DB) UpdateServer(s *model.Server) error { _, err := db.conn.Exec(` UPDATE servers SET display_name=?, host=?, port=?, user=?, auth_method=?, - identity_file=?, proxy_jump=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP + identity_file=?, proxy_jump=?, route_hops=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP WHERE alias=?`, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, - s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.StartupCommand, s.Alias) + s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand, s.Alias) return err } @@ -36,10 +68,10 @@ func (db *DB) UpdateServerByAlias(oldAlias string, s *model.Server) error { _, err := db.conn.Exec(` UPDATE servers SET alias=?, display_name=?, host=?, port=?, user=?, auth_method=?, - identity_file=?, proxy_jump=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP + identity_file=?, proxy_jump=?, route_hops=?, group_name=?, notes=?, startup_command=?, updated_at=CURRENT_TIMESTAMP WHERE alias=?`, s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, - s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.StartupCommand, oldAlias) + s.IdentityFile, s.ProxyJump, marshalRoute(s.Route), s.GroupName, s.Notes, s.StartupCommand, oldAlias) return err } @@ -51,14 +83,15 @@ func (db *DB) DeleteServer(alias string) error { func (db *DB) GetServer(alias string) (*model.Server, error) { var s model.Server var lastConnected, lastTest sql.NullTime + var routeHops sql.NullString err := db.conn.QueryRow(` SELECT id, alias, display_name, host, port, user, auth_method, - identity_file, proxy_jump, group_name, notes, startup_command, + identity_file, proxy_jump, route_hops, group_name, notes, startup_command, created_at, updated_at, last_connected_at, last_test_at, last_test_status, last_test_error FROM servers WHERE alias=?`, alias).Scan( &s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod, - &s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes, &s.StartupCommand, + &s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand, &s.CreatedAt, &s.UpdatedAt, &lastConnected, &lastTest, &s.LastTestStatus, &s.LastTestError) if err != nil { @@ -70,6 +103,12 @@ func (db *DB) GetServer(alias string) (*model.Server, error) { if lastTest.Valid { s.LastTestAt = &lastTest.Time } + if routeHops.Valid && routeHops.String != "" { + s.Route = unmarshalRoute(routeHops.String) + } + if len(s.Route.Hops) == 0 && s.ProxyJump != "" { + s.Route = unmarshalRoute(s.ProxyJump) + } tags, err := db.GetServerTags(s.ID) if err != nil { return nil, err @@ -81,7 +120,7 @@ func (db *DB) GetServer(alias string) (*model.Server, error) { func (db *DB) ListServers() ([]*model.Server, error) { rows, err := db.conn.Query(` SELECT id, alias, display_name, host, port, user, auth_method, - identity_file, proxy_jump, group_name, notes, startup_command, + identity_file, proxy_jump, route_hops, group_name, notes, startup_command, created_at, updated_at, last_connected_at, last_test_at, last_test_status, last_test_error FROM servers ORDER BY alias`) @@ -94,9 +133,10 @@ func (db *DB) ListServers() ([]*model.Server, error) { for rows.Next() { var s model.Server var lastConnected, lastTest sql.NullTime + var routeHops sql.NullString err := rows.Scan( &s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod, - &s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes, &s.StartupCommand, + &s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand, &s.CreatedAt, &s.UpdatedAt, &lastConnected, &lastTest, &s.LastTestStatus, &s.LastTestError) if err != nil { @@ -108,6 +148,12 @@ func (db *DB) ListServers() ([]*model.Server, error) { if lastTest.Valid { s.LastTestAt = &lastTest.Time } + if routeHops.Valid && routeHops.String != "" { + s.Route = unmarshalRoute(routeHops.String) + } + if len(s.Route.Hops) == 0 && s.ProxyJump != "" { + s.Route = unmarshalRoute(s.ProxyJump) + } tags, err := db.GetServerTags(s.ID) if err != nil { return nil, err @@ -122,7 +168,7 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) { pattern := "%" + query + "%" rows, err := db.conn.Query(` SELECT id, alias, display_name, host, port, user, auth_method, - identity_file, proxy_jump, group_name, notes, startup_command, + identity_file, proxy_jump, route_hops, group_name, notes, startup_command, created_at, updated_at, last_connected_at, last_test_at, last_test_status, last_test_error FROM servers @@ -137,9 +183,10 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) { for rows.Next() { var s model.Server var lastConnected, lastTest sql.NullTime + var routeHops sql.NullString err := rows.Scan( &s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod, - &s.IdentityFile, &s.ProxyJump, &s.GroupName, &s.Notes, &s.StartupCommand, + &s.IdentityFile, &s.ProxyJump, &routeHops, &s.GroupName, &s.Notes, &s.StartupCommand, &s.CreatedAt, &s.UpdatedAt, &lastConnected, &lastTest, &s.LastTestStatus, &s.LastTestError) if err != nil { @@ -151,6 +198,12 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) { if lastTest.Valid { s.LastTestAt = &lastTest.Time } + if routeHops.Valid && routeHops.String != "" { + s.Route = unmarshalRoute(routeHops.String) + } + if len(s.Route.Hops) == 0 && s.ProxyJump != "" { + s.Route = unmarshalRoute(s.ProxyJump) + } tags, err := db.GetServerTags(s.ID) if err != nil { return nil, err @@ -173,7 +226,8 @@ func (db *DB) UpdateLastConnected(alias string) error { return err } -// Tag methods +// --- Tag methods --- + func (db *DB) AddTagToServer(serverID int64, tagName string) error { tagName = strings.TrimSpace(tagName) if tagName == "" { @@ -265,7 +319,8 @@ func (db *DB) GetServerTags(serverID int64) ([]string, error) { return tags, rows.Err() } -// Forward methods +// --- Forward methods --- + func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) error { _, err := db.conn.Exec(` INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port) @@ -297,6 +352,8 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) { // Ensure time import is used var _ time.Time +// --- Command template methods --- + func (db *DB) CreateCommandTemplate(t *model.CommandTemplate) error { result, err := db.conn.Exec( "INSERT INTO global_command_templates (name, command, description) VALUES (?, ?, ?)", @@ -368,12 +425,13 @@ func uniqueCleanStrings(values []string) []string { return result } -// GetGroups returns all unique group names with server count +// --- Group methods --- + func (db *DB) GetGroups() ([]string, error) { rows, err := db.conn.Query(` - SELECT group_name FROM servers - WHERE group_name != '' - GROUP BY group_name + SELECT group_name FROM servers + WHERE group_name != '' + GROUP BY group_name ORDER BY group_name`) if err != nil { return nil, err @@ -391,7 +449,6 @@ func (db *DB) GetGroups() ([]string, error) { return groups, rows.Err() } -// RenameGroup renames a group for all servers in it func (db *DB) RenameGroup(oldName, newName string) error { _, err := db.conn.Exec( "UPDATE servers SET group_name = ?, updated_at = CURRENT_TIMESTAMP WHERE group_name = ?", @@ -399,7 +456,6 @@ func (db *DB) RenameGroup(oldName, newName string) error { return err } -// DeleteGroup removes group assignment from all servers func (db *DB) DeleteGroup(name string) error { _, err := db.conn.Exec( "UPDATE servers SET group_name = '', updated_at = CURRENT_TIMESTAMP WHERE group_name = ?", diff --git a/internal/model/server.go b/internal/model/server.go index 73c8e88..45b3858 100644 --- a/internal/model/server.go +++ b/internal/model/server.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "strings" + "time" +) type AuthMethod string @@ -29,6 +32,7 @@ type Server struct { AuthMethod AuthMethod `json:"auth_method"` IdentityFile string `json:"identity_file"` ProxyJump string `json:"proxy_jump"` + Route Route `json:"route"` GroupName string `json:"group_name"` Notes string `json:"notes"` StartupCommand string `json:"startup_command"` @@ -80,6 +84,74 @@ type Tag struct { Name string `json:"name"` } +// --- Route --- + +// RouteHop represents a single jump host in a route. +// IsProfile: true = use Alias (references a sshkeeper profile), false = use Raw (literal address). +type RouteHop struct { + Alias string `json:"alias"` + Raw string `json:"raw"` + IsProfile bool `json:"is_profile"` +} + +// Route represents the SSH jump route for a server. +// Mode is computed from Hops length: 0=direct, 1=via, 2+=chain +type Route struct { + Hops []RouteHop `json:"hops"` +} + +// RouteMode returns the computed route mode. +func (r Route) RouteMode() string { + switch len(r.Hops) { + case 0: + return "direct" + case 1: + return "via" + default: + return "chain" + } +} + +// ProxyJumpString builds the -J argument value from hops. +func (r Route) ProxyJumpString() string { + parts := make([]string, len(r.Hops)) + for i, h := range r.Hops { + if h.IsProfile { + parts[i] = h.Alias + } else { + parts[i] = h.Raw + } + } + return strings.Join(parts, ",") +} + +// DisplaySummary returns a human-readable route summary. +// direct → target / bastion → target / bastion → dmz-gw → target +func (r Route) DisplaySummary(target string) string { + if len(r.Hops) == 0 { + return "direct → " + target + } + names := make([]string, len(r.Hops)) + for i, h := range r.Hops { + if h.IsProfile { + names[i] = h.Alias + } else { + names[i] = h.Raw + } + } + return strings.Join(names, " → ") + " → " + target +} + +// HasProfileLinks returns true if any hop references a known profile. +func (r Route) HasProfileLinks() bool { + for _, h := range r.Hops { + if h.IsProfile { + return true + } + } + return false +} + type CommandTemplate struct { ID int64 `json:"id"` ServerID int64 `json:"server_id"` diff --git a/internal/ssh/command.go b/internal/ssh/command.go index 3ab2ad0..bf49344 100644 --- a/internal/ssh/command.go +++ b/internal/ssh/command.go @@ -188,7 +188,11 @@ func BuildSSHArgs(server *model.Server) []string { args = append(args, "-i", server.IdentityFile) } - if server.ProxyJump != "" { + // Use Route if available, fall back to raw ProxyJump for backward compatibility + routeArgs := BuildRouteArgs(server.Route) + if len(routeArgs) > 0 { + args = append(args, routeArgs...) + } else if server.ProxyJump != "" { args = append(args, "-J", server.ProxyJump) } diff --git a/internal/ssh/route.go b/internal/ssh/route.go new file mode 100644 index 0000000..d9f6483 --- /dev/null +++ b/internal/ssh/route.go @@ -0,0 +1,12 @@ +package ssh + +import "github.com/mirivlad/sshkeeper/internal/model" + +// BuildRouteArgs builds SSH arguments for a route. +// Returns -J flag with the full ProxyJump chain if route has hops. +func BuildRouteArgs(route model.Route) []string { + if len(route.Hops) == 0 { + return nil + } + return []string{"-J", route.ProxyJumpString()} +} diff --git a/internal/ssh/route_test.go b/internal/ssh/route_test.go new file mode 100644 index 0000000..1e98b21 --- /dev/null +++ b/internal/ssh/route_test.go @@ -0,0 +1,140 @@ +package ssh + +import ( + "testing" + + "github.com/mirivlad/sshkeeper/internal/model" +) + +func TestBuildRouteArgs_Direct(t *testing.T) { + route := model.Route{Hops: []model.RouteHop{}} + args := BuildRouteArgs(route) + if len(args) != 0 { + t.Fatalf("expected no args for direct route, got %v", args) + } +} + +func TestBuildRouteArgs_Via(t *testing.T) { + route := model.Route{Hops: []model.RouteHop{ + {Raw: "bastion.example.com", IsProfile: false}, + }} + args := BuildRouteArgs(route) + if len(args) != 2 || args[0] != "-J" || args[1] != "bastion.example.com" { + t.Fatalf("expected [-J bastion.example.com], got %v", args) + } +} + +func TestBuildRouteArgs_Chain(t *testing.T) { + route := model.Route{Hops: []model.RouteHop{ + {Alias: "bastion", IsProfile: true}, + {Raw: "dmz-gw.internal", IsProfile: false}, + }} + args := BuildRouteArgs(route) + if len(args) != 2 || args[0] != "-J" || args[1] != "bastion,dmz-gw.internal" { + t.Fatalf("expected [-J bastion,dmz-gw.internal], got %v", args) + } +} + +func TestBuildRouteArgs_ProfileHop(t *testing.T) { + route := model.Route{Hops: []model.RouteHop{ + {Alias: "my-bastion", IsProfile: true}, + }} + args := BuildRouteArgs(route) + if len(args) != 2 || args[1] != "my-bastion" { + t.Fatalf("expected profile alias in -J, got %v", args) + } +} + +func TestRouteProxyJumpString(t *testing.T) { + route := model.Route{Hops: []model.RouteHop{ + {Alias: "bastion", IsProfile: true}, + {Raw: "10.0.0.1", IsProfile: false}, + }} + got := route.ProxyJumpString() + if got != "bastion,10.0.0.1" { + t.Fatalf("expected 'bastion,10.0.0.1', got %q", got) + } +} + +func TestRouteDisplaySummary(t *testing.T) { + tests := []struct { + route model.Route + target string + want string + }{ + {model.Route{}, "target", "direct → target"}, + {model.Route{Hops: []model.RouteHop{{Alias: "bastion", IsProfile: true}}}, "target", "bastion → target"}, + {model.Route{Hops: []model.RouteHop{ + {Alias: "bastion", IsProfile: true}, + {Raw: "dmz-gw", IsProfile: false}, + }}, "target", "bastion → dmz-gw → target"}, + } + for _, tt := range tests { + got := tt.route.DisplaySummary(tt.target) + if got != tt.want { + t.Fatalf("DisplaySummary() = %q, want %q", got, tt.want) + } + } +} + +func TestRouteMode(t *testing.T) { + tests := []struct { + hops int + want string + }{ + {0, "direct"}, + {1, "via"}, + {2, "chain"}, + {3, "chain"}, + } + for _, tt := range tests { + route := model.Route{Hops: make([]model.RouteHop, tt.hops)} + got := route.RouteMode() + if got != tt.want { + t.Fatalf("RouteMode() = %q, want %q", got, tt.want) + } + } +} + +func TestBuildSSHArgs_WithRoute(t *testing.T) { + server := &model.Server{ + Host: "target.internal", + Port: 22, + User: "root", + Route: model.Route{Hops: []model.RouteHop{ + {Alias: "bastion", IsProfile: true}, + }}, + } + args := BuildSSHArgs(server) + // Should contain -J bastion + found := false + for i, a := range args { + if a == "-J" && i+1 < len(args) && args[i+1] == "bastion" { + found = true + break + } + } + if !found { + t.Fatalf("expected -J bastion in args, got %v", args) + } +} + +func TestBuildSSHArgs_FallbackToProxyJump(t *testing.T) { + server := &model.Server{ + Host: "target.internal", + Port: 22, + User: "root", + ProxyJump: "old-bastion", + } + args := BuildSSHArgs(server) + found := false + for i, a := range args { + if a == "-J" && i+1 < len(args) && args[i+1] == "old-bastion" { + found = true + break + } + } + if !found { + t.Fatalf("expected -J old-bastion in args, got %v", args) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 3b23794..0840f9a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -92,7 +92,9 @@ type serverItem struct { func (i serverItem) Title() string { return i.server.Alias } func (i serverItem) Description() string { - return fmt.Sprintf("%s@%s:%d %s", i.server.User, i.server.Host, i.server.Port, i.server.AuthMethod) + target := fmt.Sprintf("%s@%s:%d", i.server.User, i.server.Host, i.server.Port) + routeStr := i.server.Route.DisplaySummary(target) + return fmt.Sprintf("%s %s", routeStr, i.server.AuthMethod) } func (i serverItem) FilterValue() string { return i.server.Alias + " " + i.server.DisplayName + " " + i.server.Host + " " + i.server.User @@ -1108,6 +1110,12 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string { b.WriteString(fmt.Sprintf(" Port: %d\n", server.Port)) b.WriteString(fmt.Sprintf(" User: %s\n", server.User)) b.WriteString(fmt.Sprintf(" Auth: %s\n", authLabel(server.AuthMethod))) + if len(server.Route.Hops) > 0 { + target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) + b.WriteString(fmt.Sprintf(" Route: %s\n", server.Route.DisplaySummary(target))) + } else if server.ProxyJump != "" { + b.WriteString(fmt.Sprintf(" ProxyJump: %s\n", server.ProxyJump)) + } b.WriteString(fmt.Sprintf(" Group: %s\n", group)) if len(server.Tags) > 0 { b.WriteString(fmt.Sprintf(" Tags: %s\n", strings.Join(server.Tags, ", "))) diff --git a/internal/tui/form.go b/internal/tui/form.go index ea6da0d..5414a32 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -74,7 +74,7 @@ func newFormModel(w, h int) *formModel { "User", "Auth Method (password/key/key_passphrase/agent)", "Identity File", - "ProxyJump", + "Route hops (comma-separated, or pick from profiles)", "Group (type new or pick from list)", "Notes", "Startup Command", @@ -114,7 +114,6 @@ func newFormModel(w, h int) *formModel { string(model.AuthAgent), }, "Select auth method", 34, 16) - // Load existing groups if GetGroups != nil { if groups, err := GetGroups(); err == nil && len(groups) > 0 { fm.groups = groups @@ -142,8 +141,8 @@ func placeholderForLabel(label string) string { return "key" case "Identity File": return "~/.ssh/id_ed25519" - case "ProxyJump": - return "optional" + case "Route hops (comma-separated, or pick from profiles)": + return "bastion, dmz-gw" case "Group (type new or pick from list)": return "KP" case "Notes": @@ -168,7 +167,22 @@ func newEditFormModel(s *model.Server, w, h int) *formModel { fm.inputs[4].SetValue(s.User) fm.inputs[5].SetValue(string(s.AuthMethod)) fm.inputs[6].SetValue(s.IdentityFile) - fm.inputs[7].SetValue(s.ProxyJump) + + // Populate Route hops + if len(s.Route.Hops) > 0 { + hopStrs := make([]string, len(s.Route.Hops)) + for i, h := range s.Route.Hops { + if h.IsProfile { + hopStrs[i] = h.Alias + } else { + hopStrs[i] = h.Raw + } + } + fm.inputs[7].SetValue(strings.Join(hopStrs, ", ")) + } else if s.ProxyJump != "" { + fm.inputs[7].SetValue(s.ProxyJump) + } + fm.inputs[8].SetValue(s.GroupName) fm.inputs[9].SetValue(s.Notes) fm.inputs[10].SetValue(s.StartupCommand) @@ -196,7 +210,6 @@ func (fm *formModel) Init() tea.Cmd { } func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Handle test/save completion switch msg := msg.(type) { case testDoneMsg: fm.testing = false @@ -223,7 +236,6 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return fm, nil } - // Handle spinner tick while testing/saving if fm.testing || fm.saving { var cmd tea.Cmd fm.spinner, cmd = fm.spinner.Update(msg) @@ -233,7 +245,6 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return fm, cmd } - // Handle group dropdown if fm.showGroupList { switch msg := msg.(type) { case tea.KeyMsg: @@ -445,6 +456,31 @@ func (fm *formModel) runSave() tea.Cmd { ) } +// parseRouteHops parses the route hops input string into a model.Route. +// Format: comma-separated list of aliases or raw addresses. +func parseRouteHops(input string) model.Route { + input = strings.TrimSpace(input) + if input == "" { + return model.Route{} + } + parts := strings.Split(input, ",") + hops := make([]model.RouteHop, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + // Heuristic: if it contains @ or :, treat as raw address + if strings.Contains(p, "@") || strings.Contains(p, ":") { + hops = append(hops, model.RouteHop{Raw: p, IsProfile: false}) + } else { + // Treat as profile alias + hops = append(hops, model.RouteHop{Alias: p, IsProfile: true}) + } + } + return model.Route{Hops: hops} +} + func (fm *formModel) buildServer() *model.Server { port := 22 fmt.Sscanf(fm.inputs[3].Value(), "%d", &port) @@ -452,6 +488,9 @@ func (fm *formModel) buildServer() *model.Server { if authMethod == "" { authMethod = model.AuthKey } + + route := parseRouteHops(fm.inputs[7].Value()) + return &model.Server{ Alias: fm.inputs[0].Value(), DisplayName: fm.inputs[1].Value(), @@ -460,7 +499,8 @@ func (fm *formModel) buildServer() *model.Server { User: fm.inputs[4].Value(), AuthMethod: authMethod, IdentityFile: fm.inputs[6].Value(), - ProxyJump: fm.inputs[7].Value(), + ProxyJump: route.ProxyJumpString(), + Route: route, GroupName: fm.inputs[8].Value(), Notes: fm.inputs[9].Value(), StartupCommand: fm.inputs[10].Value(),