sshkeeper: v0.2.0 — Phase 2: Route / ProxyJump UX (model, migration, DB, SSH args, TUI)
This commit is contained in:
parent
446f55f740
commit
700724e93b
|
|
@ -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 = '';
|
||||||
|
|
@ -2,6 +2,7 @@ package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -9,11 +10,42 @@ import (
|
||||||
"github.com/mirivlad/sshkeeper/internal/model"
|
"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 {
|
func (db *DB) CreateServer(s *model.Server) error {
|
||||||
result, err := db.conn.Exec(`
|
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)
|
INSERT INTO servers (alias, display_name, host, port, user, auth_method, identity_file, proxy_jump, route_hops, group_name, notes, startup_command)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod, s.IdentityFile, s.ProxyJump, s.GroupName, s.Notes, s.StartupCommand)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -25,10 +57,10 @@ func (db *DB) UpdateServer(s *model.Server) error {
|
||||||
_, err := db.conn.Exec(`
|
_, err := db.conn.Exec(`
|
||||||
UPDATE servers SET
|
UPDATE servers SET
|
||||||
display_name=?, host=?, port=?, user=?, auth_method=?,
|
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=?`,
|
WHERE alias=?`,
|
||||||
s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,10 +68,10 @@ func (db *DB) UpdateServerByAlias(oldAlias string, s *model.Server) error {
|
||||||
_, err := db.conn.Exec(`
|
_, err := db.conn.Exec(`
|
||||||
UPDATE servers SET
|
UPDATE servers SET
|
||||||
alias=?, display_name=?, host=?, port=?, user=?, auth_method=?,
|
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=?`,
|
WHERE alias=?`,
|
||||||
s.Alias, s.DisplayName, s.Host, s.Port, s.User, s.AuthMethod,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,14 +83,15 @@ func (db *DB) DeleteServer(alias string) error {
|
||||||
func (db *DB) GetServer(alias string) (*model.Server, error) {
|
func (db *DB) GetServer(alias string) (*model.Server, error) {
|
||||||
var s model.Server
|
var s model.Server
|
||||||
var lastConnected, lastTest sql.NullTime
|
var lastConnected, lastTest sql.NullTime
|
||||||
|
var routeHops sql.NullString
|
||||||
err := db.conn.QueryRow(`
|
err := db.conn.QueryRow(`
|
||||||
SELECT id, alias, display_name, host, port, user, auth_method,
|
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,
|
created_at, updated_at, last_connected_at,
|
||||||
last_test_at, last_test_status, last_test_error
|
last_test_at, last_test_status, last_test_error
|
||||||
FROM servers WHERE alias=?`, alias).Scan(
|
FROM servers WHERE alias=?`, alias).Scan(
|
||||||
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
&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,
|
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||||
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -70,6 +103,12 @@ func (db *DB) GetServer(alias string) (*model.Server, error) {
|
||||||
if lastTest.Valid {
|
if lastTest.Valid {
|
||||||
s.LastTestAt = &lastTest.Time
|
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)
|
tags, err := db.GetServerTags(s.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -81,7 +120,7 @@ func (db *DB) GetServer(alias string) (*model.Server, error) {
|
||||||
func (db *DB) ListServers() ([]*model.Server, error) {
|
func (db *DB) ListServers() ([]*model.Server, error) {
|
||||||
rows, err := db.conn.Query(`
|
rows, err := db.conn.Query(`
|
||||||
SELECT id, alias, display_name, host, port, user, auth_method,
|
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,
|
created_at, updated_at, last_connected_at,
|
||||||
last_test_at, last_test_status, last_test_error
|
last_test_at, last_test_status, last_test_error
|
||||||
FROM servers ORDER BY alias`)
|
FROM servers ORDER BY alias`)
|
||||||
|
|
@ -94,9 +133,10 @@ func (db *DB) ListServers() ([]*model.Server, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s model.Server
|
var s model.Server
|
||||||
var lastConnected, lastTest sql.NullTime
|
var lastConnected, lastTest sql.NullTime
|
||||||
|
var routeHops sql.NullString
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
&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,
|
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||||
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -108,6 +148,12 @@ func (db *DB) ListServers() ([]*model.Server, error) {
|
||||||
if lastTest.Valid {
|
if lastTest.Valid {
|
||||||
s.LastTestAt = &lastTest.Time
|
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)
|
tags, err := db.GetServerTags(s.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -122,7 +168,7 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) {
|
||||||
pattern := "%" + query + "%"
|
pattern := "%" + query + "%"
|
||||||
rows, err := db.conn.Query(`
|
rows, err := db.conn.Query(`
|
||||||
SELECT id, alias, display_name, host, port, user, auth_method,
|
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,
|
created_at, updated_at, last_connected_at,
|
||||||
last_test_at, last_test_status, last_test_error
|
last_test_at, last_test_status, last_test_error
|
||||||
FROM servers
|
FROM servers
|
||||||
|
|
@ -137,9 +183,10 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s model.Server
|
var s model.Server
|
||||||
var lastConnected, lastTest sql.NullTime
|
var lastConnected, lastTest sql.NullTime
|
||||||
|
var routeHops sql.NullString
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&s.ID, &s.Alias, &s.DisplayName, &s.Host, &s.Port, &s.User, &s.AuthMethod,
|
&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,
|
&s.CreatedAt, &s.UpdatedAt, &lastConnected,
|
||||||
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
&lastTest, &s.LastTestStatus, &s.LastTestError)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -151,6 +198,12 @@ func (db *DB) SearchServers(query string) ([]*model.Server, error) {
|
||||||
if lastTest.Valid {
|
if lastTest.Valid {
|
||||||
s.LastTestAt = &lastTest.Time
|
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)
|
tags, err := db.GetServerTags(s.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -173,7 +226,8 @@ func (db *DB) UpdateLastConnected(alias string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag methods
|
// --- Tag methods ---
|
||||||
|
|
||||||
func (db *DB) AddTagToServer(serverID int64, tagName string) error {
|
func (db *DB) AddTagToServer(serverID int64, tagName string) error {
|
||||||
tagName = strings.TrimSpace(tagName)
|
tagName = strings.TrimSpace(tagName)
|
||||||
if tagName == "" {
|
if tagName == "" {
|
||||||
|
|
@ -265,7 +319,8 @@ func (db *DB) GetServerTags(serverID int64) ([]string, error) {
|
||||||
return tags, rows.Err()
|
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 {
|
func (db *DB) AddForward(serverID int64, fwdType model.ForwardType, localAddr string, localPort int, remoteAddr string, remotePort int) error {
|
||||||
_, err := db.conn.Exec(`
|
_, err := db.conn.Exec(`
|
||||||
INSERT INTO forwards (server_id, type, local_addr, local_port, remote_addr, remote_port)
|
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
|
// Ensure time import is used
|
||||||
var _ time.Time
|
var _ time.Time
|
||||||
|
|
||||||
|
// --- Command template methods ---
|
||||||
|
|
||||||
func (db *DB) CreateCommandTemplate(t *model.CommandTemplate) error {
|
func (db *DB) CreateCommandTemplate(t *model.CommandTemplate) error {
|
||||||
result, err := db.conn.Exec(
|
result, err := db.conn.Exec(
|
||||||
"INSERT INTO global_command_templates (name, command, description) VALUES (?, ?, ?)",
|
"INSERT INTO global_command_templates (name, command, description) VALUES (?, ?, ?)",
|
||||||
|
|
@ -368,7 +425,8 @@ func uniqueCleanStrings(values []string) []string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGroups returns all unique group names with server count
|
// --- Group methods ---
|
||||||
|
|
||||||
func (db *DB) GetGroups() ([]string, error) {
|
func (db *DB) GetGroups() ([]string, error) {
|
||||||
rows, err := db.conn.Query(`
|
rows, err := db.conn.Query(`
|
||||||
SELECT group_name FROM servers
|
SELECT group_name FROM servers
|
||||||
|
|
@ -391,7 +449,6 @@ func (db *DB) GetGroups() ([]string, error) {
|
||||||
return groups, rows.Err()
|
return groups, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameGroup renames a group for all servers in it
|
|
||||||
func (db *DB) RenameGroup(oldName, newName string) error {
|
func (db *DB) RenameGroup(oldName, newName string) error {
|
||||||
_, err := db.conn.Exec(
|
_, err := db.conn.Exec(
|
||||||
"UPDATE servers SET group_name = ?, updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
|
"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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteGroup removes group assignment from all servers
|
|
||||||
func (db *DB) DeleteGroup(name string) error {
|
func (db *DB) DeleteGroup(name string) error {
|
||||||
_, err := db.conn.Exec(
|
_, err := db.conn.Exec(
|
||||||
"UPDATE servers SET group_name = '', updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
|
"UPDATE servers SET group_name = '', updated_at = CURRENT_TIMESTAMP WHERE group_name = ?",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type AuthMethod string
|
type AuthMethod string
|
||||||
|
|
||||||
|
|
@ -29,6 +32,7 @@ type Server struct {
|
||||||
AuthMethod AuthMethod `json:"auth_method"`
|
AuthMethod AuthMethod `json:"auth_method"`
|
||||||
IdentityFile string `json:"identity_file"`
|
IdentityFile string `json:"identity_file"`
|
||||||
ProxyJump string `json:"proxy_jump"`
|
ProxyJump string `json:"proxy_jump"`
|
||||||
|
Route Route `json:"route"`
|
||||||
GroupName string `json:"group_name"`
|
GroupName string `json:"group_name"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
StartupCommand string `json:"startup_command"`
|
StartupCommand string `json:"startup_command"`
|
||||||
|
|
@ -80,6 +84,74 @@ type Tag struct {
|
||||||
Name string `json:"name"`
|
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 {
|
type CommandTemplate struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ServerID int64 `json:"server_id"`
|
ServerID int64 `json:"server_id"`
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,11 @@ func BuildSSHArgs(server *model.Server) []string {
|
||||||
args = append(args, "-i", server.IdentityFile)
|
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)
|
args = append(args, "-J", server.ProxyJump)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -92,7 +92,9 @@ type serverItem struct {
|
||||||
|
|
||||||
func (i serverItem) Title() string { return i.server.Alias }
|
func (i serverItem) Title() string { return i.server.Alias }
|
||||||
func (i serverItem) Description() string {
|
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 {
|
func (i serverItem) FilterValue() string {
|
||||||
return i.server.Alias + " " + i.server.DisplayName + " " + i.server.Host + " " + i.server.User
|
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(" Port: %d\n", server.Port))
|
||||||
b.WriteString(fmt.Sprintf(" User: %s\n", server.User))
|
b.WriteString(fmt.Sprintf(" User: %s\n", server.User))
|
||||||
b.WriteString(fmt.Sprintf(" Auth: %s\n", authLabel(server.AuthMethod)))
|
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))
|
b.WriteString(fmt.Sprintf(" Group: %s\n", group))
|
||||||
if len(server.Tags) > 0 {
|
if len(server.Tags) > 0 {
|
||||||
b.WriteString(fmt.Sprintf(" Tags: %s\n", strings.Join(server.Tags, ", ")))
|
b.WriteString(fmt.Sprintf(" Tags: %s\n", strings.Join(server.Tags, ", ")))
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ func newFormModel(w, h int) *formModel {
|
||||||
"User",
|
"User",
|
||||||
"Auth Method (password/key/key_passphrase/agent)",
|
"Auth Method (password/key/key_passphrase/agent)",
|
||||||
"Identity File",
|
"Identity File",
|
||||||
"ProxyJump",
|
"Route hops (comma-separated, or pick from profiles)",
|
||||||
"Group (type new or pick from list)",
|
"Group (type new or pick from list)",
|
||||||
"Notes",
|
"Notes",
|
||||||
"Startup Command",
|
"Startup Command",
|
||||||
|
|
@ -114,7 +114,6 @@ func newFormModel(w, h int) *formModel {
|
||||||
string(model.AuthAgent),
|
string(model.AuthAgent),
|
||||||
}, "Select auth method", 34, 16)
|
}, "Select auth method", 34, 16)
|
||||||
|
|
||||||
// Load existing groups
|
|
||||||
if GetGroups != nil {
|
if GetGroups != nil {
|
||||||
if groups, err := GetGroups(); err == nil && len(groups) > 0 {
|
if groups, err := GetGroups(); err == nil && len(groups) > 0 {
|
||||||
fm.groups = groups
|
fm.groups = groups
|
||||||
|
|
@ -142,8 +141,8 @@ func placeholderForLabel(label string) string {
|
||||||
return "key"
|
return "key"
|
||||||
case "Identity File":
|
case "Identity File":
|
||||||
return "~/.ssh/id_ed25519"
|
return "~/.ssh/id_ed25519"
|
||||||
case "ProxyJump":
|
case "Route hops (comma-separated, or pick from profiles)":
|
||||||
return "optional"
|
return "bastion, dmz-gw"
|
||||||
case "Group (type new or pick from list)":
|
case "Group (type new or pick from list)":
|
||||||
return "KP"
|
return "KP"
|
||||||
case "Notes":
|
case "Notes":
|
||||||
|
|
@ -168,7 +167,22 @@ func newEditFormModel(s *model.Server, w, h int) *formModel {
|
||||||
fm.inputs[4].SetValue(s.User)
|
fm.inputs[4].SetValue(s.User)
|
||||||
fm.inputs[5].SetValue(string(s.AuthMethod))
|
fm.inputs[5].SetValue(string(s.AuthMethod))
|
||||||
fm.inputs[6].SetValue(s.IdentityFile)
|
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[8].SetValue(s.GroupName)
|
||||||
fm.inputs[9].SetValue(s.Notes)
|
fm.inputs[9].SetValue(s.Notes)
|
||||||
fm.inputs[10].SetValue(s.StartupCommand)
|
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) {
|
func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
// Handle test/save completion
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case testDoneMsg:
|
case testDoneMsg:
|
||||||
fm.testing = false
|
fm.testing = false
|
||||||
|
|
@ -223,7 +236,6 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle spinner tick while testing/saving
|
|
||||||
if fm.testing || fm.saving {
|
if fm.testing || fm.saving {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
fm.spinner, cmd = fm.spinner.Update(msg)
|
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
|
return fm, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle group dropdown
|
|
||||||
if fm.showGroupList {
|
if fm.showGroupList {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
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 {
|
func (fm *formModel) buildServer() *model.Server {
|
||||||
port := 22
|
port := 22
|
||||||
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
|
fmt.Sscanf(fm.inputs[3].Value(), "%d", &port)
|
||||||
|
|
@ -452,6 +488,9 @@ func (fm *formModel) buildServer() *model.Server {
|
||||||
if authMethod == "" {
|
if authMethod == "" {
|
||||||
authMethod = model.AuthKey
|
authMethod = model.AuthKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
route := parseRouteHops(fm.inputs[7].Value())
|
||||||
|
|
||||||
return &model.Server{
|
return &model.Server{
|
||||||
Alias: fm.inputs[0].Value(),
|
Alias: fm.inputs[0].Value(),
|
||||||
DisplayName: fm.inputs[1].Value(),
|
DisplayName: fm.inputs[1].Value(),
|
||||||
|
|
@ -460,7 +499,8 @@ func (fm *formModel) buildServer() *model.Server {
|
||||||
User: fm.inputs[4].Value(),
|
User: fm.inputs[4].Value(),
|
||||||
AuthMethod: authMethod,
|
AuthMethod: authMethod,
|
||||||
IdentityFile: fm.inputs[6].Value(),
|
IdentityFile: fm.inputs[6].Value(),
|
||||||
ProxyJump: fm.inputs[7].Value(),
|
ProxyJump: route.ProxyJumpString(),
|
||||||
|
Route: route,
|
||||||
GroupName: fm.inputs[8].Value(),
|
GroupName: fm.inputs[8].Value(),
|
||||||
Notes: fm.inputs[9].Value(),
|
Notes: fm.inputs[9].Value(),
|
||||||
StartupCommand: fm.inputs[10].Value(),
|
StartupCommand: fm.inputs[10].Value(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue