sshkeeper: v0.2.0 — Phase 4: CLI route/forward/tunnel commands
This commit is contained in:
parent
912b17e1f1
commit
c2edaa4224
|
|
@ -0,0 +1,118 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/mirivlad/sshkeeper/internal/model"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// --- Forward commands ---
|
||||
|
||||
var forwardCmd = &cobra.Command{
|
||||
Use: "forward",
|
||||
Short: "Manage port forwards",
|
||||
}
|
||||
|
||||
var forwardListCmd = &cobra.Command{
|
||||
Use: "list <alias>",
|
||||
Short: "List port forwards for a server",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
alias := args[0]
|
||||
server, err := appDB.GetServer(alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %s", alias)
|
||||
}
|
||||
forwards, err := appDB.GetForwards(server.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list forwards: %w", err)
|
||||
}
|
||||
if len(forwards) == 0 {
|
||||
fmt.Println("No port forwards configured.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Port forwards for %s:\n", alias)
|
||||
for _, f := range forwards {
|
||||
switch f.Type {
|
||||
case model.ForwardLocal:
|
||||
fmt.Printf(" [%d] -L %s:%d:%s:%d\n", f.ID, f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
|
||||
case model.ForwardRemote:
|
||||
fmt.Printf(" [%d] -R %s:%d:%s:%d\n", f.ID, f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
|
||||
case model.ForwardDynamic:
|
||||
fmt.Printf(" [%d] -D %s:%d\n", f.ID, f.LocalAddr, f.LocalPort)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var forwardAddCmd = &cobra.Command{
|
||||
Use: "add <alias>",
|
||||
Short: "Add a port forward",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
alias := args[0]
|
||||
server, err := appDB.GetServer(alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %s", alias)
|
||||
}
|
||||
|
||||
fwdType, _ := cmd.Flags().GetString("type")
|
||||
localAddr, _ := cmd.Flags().GetString("local-addr")
|
||||
localPort, _ := cmd.Flags().GetInt("local-port")
|
||||
remoteAddr, _ := cmd.Flags().GetString("remote-addr")
|
||||
remotePort, _ := cmd.Flags().GetInt("remote-port")
|
||||
|
||||
fwd := &model.Forward{
|
||||
ServerID: server.ID,
|
||||
Type: model.ForwardType(fwdType),
|
||||
LocalAddr: localAddr,
|
||||
LocalPort: localPort,
|
||||
RemoteAddr: remoteAddr,
|
||||
RemotePort: remotePort,
|
||||
}
|
||||
|
||||
if err := appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort); err != nil {
|
||||
return fmt.Errorf("add forward: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Forward added [%d]\n", fwd.ID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var forwardDeleteCmd = &cobra.Command{
|
||||
Use: "delete <alias> <id>",
|
||||
Short: "Delete a port forward",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
alias := args[0]
|
||||
id, err := strconv.ParseInt(args[1], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid forward ID: %s", args[1])
|
||||
}
|
||||
// Verify server exists
|
||||
if _, err := appDB.GetServer(alias); err != nil {
|
||||
return fmt.Errorf("server not found: %s", alias)
|
||||
}
|
||||
if err := appDB.DeleteForward(id); err != nil {
|
||||
return fmt.Errorf("delete forward: %w", err)
|
||||
}
|
||||
fmt.Println("✓ Forward deleted")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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.MarkFlagRequired("local-port")
|
||||
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
|
||||
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")
|
||||
|
||||
forwardCmd.AddCommand(forwardListCmd)
|
||||
forwardCmd.AddCommand(forwardAddCmd)
|
||||
forwardCmd.AddCommand(forwardDeleteCmd)
|
||||
}
|
||||
|
|
@ -55,6 +55,9 @@ func init() {
|
|||
rootCmd.AddCommand(groupCmd)
|
||||
rootCmd.AddCommand(templateCmd)
|
||||
rootCmd.AddCommand(runTemplateCmd)
|
||||
rootCmd.AddCommand(routeCmd)
|
||||
rootCmd.AddCommand(forwardCmd)
|
||||
rootCmd.AddCommand(tunnelCmd)
|
||||
}
|
||||
|
||||
func initApp() {
|
||||
|
|
@ -172,7 +175,7 @@ func commandRequiresStartupVaultUnlock(args []string) bool {
|
|||
}
|
||||
|
||||
switch args[0] {
|
||||
case "connect", "c", "run", "run-template", "test", "edit", "delete":
|
||||
case "connect", "c", "run", "run-template", "test", "edit", "delete", "tunnel":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mirivlad/sshkeeper/internal/model"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// --- Route commands ---
|
||||
|
||||
var routeCmd = &cobra.Command{
|
||||
Use: "route",
|
||||
Short: "Manage server routes (ProxyJump)",
|
||||
}
|
||||
|
||||
var routeShowCmd = &cobra.Command{
|
||||
Use: "show <alias>",
|
||||
Short: "Show route for a server",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
alias := args[0]
|
||||
server, err := appDB.GetServer(alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %s", alias)
|
||||
}
|
||||
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
||||
if len(server.Route.Hops) > 0 {
|
||||
fmt.Printf("Route: %s\n", server.Route.DisplaySummary(target))
|
||||
fmt.Printf("Mode: %s\n", server.Route.RouteMode())
|
||||
fmt.Printf("ProxyJump: %s\n", server.Route.ProxyJumpString())
|
||||
if server.Route.HasProfileLinks() {
|
||||
fmt.Println("Hops:")
|
||||
for _, h := range server.Route.Hops {
|
||||
if h.IsProfile {
|
||||
fmt.Printf(" - %s (profile)\n", h.Alias)
|
||||
} else {
|
||||
fmt.Printf(" - %s (raw)\n", h.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if server.ProxyJump != "" {
|
||||
fmt.Printf("ProxyJump: %s\n", server.ProxyJump)
|
||||
} else {
|
||||
fmt.Println("Direct connection (no route)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var routeSetCmd = &cobra.Command{
|
||||
Use: "set <alias>",
|
||||
Short: "Set route for a server",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
alias := args[0]
|
||||
server, err := appDB.GetServer(alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %s", alias)
|
||||
}
|
||||
|
||||
mode, _ := cmd.Flags().GetString("mode")
|
||||
jumps, _ := cmd.Flags().GetString("jumps")
|
||||
|
||||
if mode == "clear" || jumps == "" {
|
||||
server.Route = model.Route{}
|
||||
server.ProxyJump = ""
|
||||
} else {
|
||||
parts := strings.Split(jumps, ",")
|
||||
hops := make([]model.RouteHop, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(p, "@") || strings.Contains(p, ":") {
|
||||
hops = append(hops, model.RouteHop{Raw: p, IsProfile: false})
|
||||
} else {
|
||||
hops = append(hops, model.RouteHop{Alias: p, IsProfile: true})
|
||||
}
|
||||
}
|
||||
server.Route = model.Route{Hops: hops}
|
||||
server.ProxyJump = server.Route.ProxyJumpString()
|
||||
}
|
||||
|
||||
if err := appDB.UpdateServer(server); err != nil {
|
||||
return fmt.Errorf("update route: %w", err)
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
|
||||
if len(server.Route.Hops) > 0 {
|
||||
fmt.Printf("✓ Route set: %s\n", server.Route.DisplaySummary(target))
|
||||
} else {
|
||||
fmt.Println("✓ Route cleared (direct connection)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var routeClearCmd = &cobra.Command{
|
||||
Use: "clear <alias>",
|
||||
Short: "Clear route for a server (set direct)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
alias := args[0]
|
||||
server, err := appDB.GetServer(alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %s", alias)
|
||||
}
|
||||
server.Route = model.Route{}
|
||||
server.ProxyJump = ""
|
||||
if err := appDB.UpdateServer(server); err != nil {
|
||||
return fmt.Errorf("clear route: %w", err)
|
||||
}
|
||||
fmt.Println("✓ Route cleared (direct connection)")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
routeSetCmd.Flags().String("mode", "via", "Route mode: direct, via, chain, or clear")
|
||||
routeSetCmd.Flags().String("jumps", "", "Comma-separated jump hosts (aliases or raw addresses)")
|
||||
|
||||
routeCmd.AddCommand(routeShowCmd)
|
||||
routeCmd.AddCommand(routeSetCmd)
|
||||
routeCmd.AddCommand(routeClearCmd)
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mirivlad/sshkeeper/internal/ssh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var tunnelCmd = &cobra.Command{
|
||||
Use: "tunnel <alias>",
|
||||
Short: "Start SSH session with port forwards",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
alias := args[0]
|
||||
server, err := appDB.GetServer(alias)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %s", alias)
|
||||
}
|
||||
|
||||
forwardsOnly, _ := cmd.Flags().GetBool("forward-only")
|
||||
|
||||
v := getOrCreateVault()
|
||||
vaultFunc := func(serverAlias string, secretType string) (string, error) {
|
||||
if !v.IsUnlocked() {
|
||||
return "", fmt.Errorf("%s", vaultLockedProcessMessage())
|
||||
}
|
||||
key := fmt.Sprintf("server:%s:%s", serverAlias, secretType)
|
||||
data, err := v.Get(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Load forwards
|
||||
forwards, err := appDB.GetForwards(server.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load forwards: %w", err)
|
||||
}
|
||||
|
||||
if len(forwards) == 0 && forwardsOnly {
|
||||
return fmt.Errorf("no forwards configured for %s", alias)
|
||||
}
|
||||
|
||||
if len(forwards) > 0 {
|
||||
fmt.Printf("Starting tunnel to %s with %d forward(s)...\n", alias, len(forwards))
|
||||
} else {
|
||||
fmt.Printf("Starting session to %s...\n", alias)
|
||||
}
|
||||
|
||||
sshArgs := ssh.BuildSSHArgs(server, forwards, forwardsOnly)
|
||||
if forwardsOnly {
|
||||
fmt.Printf("Tunnel mode (ssh -N). Press Ctrl+C to exit.\n")
|
||||
}
|
||||
|
||||
return ssh.ConnectWithArgs(cfg, sshArgs, vaultFunc, server)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
tunnelCmd.Flags().Bool("forward-only", false, "Start tunnel only (ssh -N)")
|
||||
}
|
||||
|
|
@ -178,7 +178,33 @@ func testWithPassword(cfg *config.Config, args []string, password string) (bool,
|
|||
return false, result
|
||||
}
|
||||
|
||||
// BuildForwardArgs builds SSH port forwarding arguments.
|
||||
func ConnectWithArgs(cfg *config.Config, args []string, vaultFunc VaultFunc, server *model.Server) error {
|
||||
switch server.AuthMethod {
|
||||
case model.AuthPassword:
|
||||
password, err := vaultFunc(server.Alias, "ssh_password")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get password from vault: %w", err)
|
||||
}
|
||||
return ConnectWithPassword(cfg.SSH.Binary, args, password)
|
||||
|
||||
case model.AuthKeyPassphrase:
|
||||
passphrase, err := vaultFunc(server.Alias, "key_passphrase")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get key passphrase from vault: %w", err)
|
||||
}
|
||||
return ConnectWithPassword(cfg.SSH.Binary, args, passphrase)
|
||||
|
||||
default:
|
||||
cmd := exec.Command(cfg.SSH.Binary, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start ssh: %w", err)
|
||||
}
|
||||
return cmd.Wait()
|
||||
}
|
||||
}
|
||||
func BuildForwardArgs(forwards []*model.Forward, exitOnForwardFailure bool) []string {
|
||||
var args []string
|
||||
for _, f := range forwards {
|
||||
|
|
|
|||
Loading…
Reference in New Issue