diff --git a/.bashrc b/.bashrc index 32e8bf2..aa9dd29 100644 --- a/.bashrc +++ b/.bashrc @@ -69,6 +69,7 @@ alias gcb='git checkout -b' # Kubernetes aliases (Minikube) alias kubectl="minikube kubectl --" alias zed=zeditor +alias ssh="~/.local/bin/smart-ssh" # random string (Syntax: random ) alias random='openssl rand -base64' diff --git a/config/ansible/tasks/global/utils/ssh/README.md b/config/ansible/tasks/global/utils/smart-ssh/README.md similarity index 100% rename from config/ansible/tasks/global/utils/ssh/README.md rename to config/ansible/tasks/global/utils/smart-ssh/README.md diff --git a/config/ansible/tasks/global/utils/ssh/config.yaml b/config/ansible/tasks/global/utils/smart-ssh/config.yaml similarity index 93% rename from config/ansible/tasks/global/utils/ssh/config.yaml rename to config/ansible/tasks/global/utils/smart-ssh/config.yaml index 9fcc7ae..f664684 100644 --- a/config/ansible/tasks/global/utils/ssh/config.yaml +++ b/config/ansible/tasks/global/utils/smart-ssh/config.yaml @@ -40,6 +40,14 @@ tunnels: type: dynamic local_port: 1080 ssh_host: bastion + + # Modem web interface tunnel + modem-web: + type: local + local_port: 8443 + remote_host: 192.168.1.1 + remote_port: 443 + ssh_host: desktop # Tunnel Management Commands: # ssh --tunnel --open desktop-database (or ssh -TO desktop-database) # ssh --tunnel --close desktop-database (or ssh -TC desktop-database) diff --git a/config/ansible/tasks/global/utils/ssh/go.mod b/config/ansible/tasks/global/utils/smart-ssh/go.mod similarity index 73% rename from config/ansible/tasks/global/utils/ssh/go.mod rename to config/ansible/tasks/global/utils/smart-ssh/go.mod index 3301abb..62f7f21 100644 --- a/config/ansible/tasks/global/utils/ssh/go.mod +++ b/config/ansible/tasks/global/utils/smart-ssh/go.mod @@ -3,6 +3,7 @@ module ssh-util go 1.21 require ( + github.com/jedib0t/go-pretty/v6 v6.4.9 github.com/rs/zerolog v1.31.0 github.com/spf13/cobra v1.8.0 gopkg.in/yaml.v3 v3.0.1 @@ -12,6 +13,8 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sys v0.12.0 // indirect ) diff --git a/config/ansible/tasks/global/utils/ssh/go.sum b/config/ansible/tasks/global/utils/smart-ssh/go.sum similarity index 58% rename from config/ansible/tasks/global/utils/ssh/go.sum rename to config/ansible/tasks/global/utils/smart-ssh/go.sum index 630d9de..7bd2646 100644 --- a/config/ansible/tasks/global/utils/ssh/go.sum +++ b/config/ansible/tasks/global/utils/smart-ssh/go.sum @@ -1,14 +1,26 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.4.9 h1:vZ6bjGg2eBSrJn365qlxGcaWu09Id+LHtrfDWlB2Usc= +github.com/jedib0t/go-pretty/v6 v6.4.9/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= @@ -17,11 +29,18 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM= +github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/config/ansible/tasks/global/utils/ssh/ssh.go b/config/ansible/tasks/global/utils/smart-ssh/smart-ssh.go similarity index 85% rename from config/ansible/tasks/global/utils/ssh/ssh.go rename to config/ansible/tasks/global/utils/smart-ssh/smart-ssh.go index 6395949..2abbb47 100644 --- a/config/ansible/tasks/global/utils/ssh/ssh.go +++ b/config/ansible/tasks/global/utils/smart-ssh/smart-ssh.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "encoding/json" "fmt" "net" @@ -13,6 +14,7 @@ import ( "syscall" "time" + "github.com/jedib0t/go-pretty/v6/table" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -184,11 +186,8 @@ func main() { } if isTunnelCommand { - // Use Cobra for tunnel commands - if err := rootCmd.Execute(); err != nil { - log.Error().Err(err).Msg("Command execution failed") - os.Exit(1) - } + // Bypass Cobra for tunnel commands too - handle directly + handleTunnelManual(args) } else { // Bypass Cobra for regular SSH commands (smart alias resolution) handleSSHDirect(args) @@ -219,8 +218,8 @@ func handleCombinedFlags(cmd *cobra.Command, args []string) error { } func handleSSH(cmd *cobra.Command, args []string) { - // This handles tunnel commands via Cobra - handleTunnelManual(os.Args[1:]) + // This function is no longer used since we bypass Cobra + handleSSHDirect(args) } func handleSSHDirect(args []string) { @@ -282,10 +281,17 @@ func handleTunnelManual(args []string) { var action string var localForward, remoteForward, via string var dynamicPort int + var showAll bool + skipNext := false for i, arg := range args { + if skipNext { + skipNext = false + continue + } + switch arg { - case "--tunnel", "-T": + case "--tunnel", "-T", "tunnel": continue case "--open", "-O": action = "open" @@ -293,21 +299,27 @@ func handleTunnelManual(args []string) { action = "close" case "--list", "-L": action = "list" + case "--all", "-A": + showAll = true case "--local": if i+1 < len(args) { localForward = args[i+1] + skipNext = true } case "--remote": if i+1 < len(args) { remoteForward = args[i+1] + skipNext = true } case "--via": if i+1 < len(args) { via = args[i+1] + skipNext = true } case "--dynamic": if i+1 < len(args) { fmt.Sscanf(args[i+1], "%d", &dynamicPort) + skipNext = true } default: if strings.HasPrefix(arg, "-T") && len(arg) > 2 { @@ -321,15 +333,20 @@ func handleTunnelManual(args []string) { if strings.Contains(suffix, "L") { action = "list" } + if strings.Contains(suffix, "A") { + showAll = true + } } else if !strings.HasPrefix(arg, "-") && tunnelName == "" { tunnelName = arg } } } + log.Debug().Str("action", action).Str("tunnel_name", tunnelName).Str("via", via).Str("local", localForward).Msg("Parsed tunnel arguments") + // Handle tunnel commands if action == "list" { - listTunnels() + listTunnels(showAll) return } @@ -405,22 +422,15 @@ func validateTunnelStates() error { return nil } -func listTunnels() { +func listTunnels(showAll bool) { files, err := os.ReadDir(tunnelsDir) if err != nil { fmt.Fprintf(os.Stderr, "Error: Failed to read tunnels directory: %v\n", err) return } - if len(files) == 0 { - fmt.Println("No active tunnels") - return - } - - fmt.Printf("%-20s %-8s %-8s %-25s %-12s %-8s %s\n", - "NAME", "TYPE", "LOCAL", "REMOTE", "HOST", "PID", "UPTIME") - fmt.Println(strings.Repeat("-", 80)) - + // Get running tunnels + runningTunnels := make(map[string]TunnelState) for _, file := range files { if !strings.HasSuffix(file.Name(), ".json") { continue @@ -431,19 +441,87 @@ func listTunnels() { if err != nil { continue } - - uptime := time.Since(state.StartedAt).Truncate(time.Second) - remote := "" - if state.Type == "local" || state.Type == "remote" { - remote = fmt.Sprintf("%s:%d", state.RemoteHost, state.RemotePort) - } else if state.Type == "dynamic" { - remote = "SOCKS" - } - - fmt.Printf("%-20s %-8s %-8d %-25s %-12s %-8d %s\n", - state.Name, state.Type, state.LocalPort, remote, - state.SSHHostResolved, state.PID, uptime) + runningTunnels[state.Name] = state } + + // If showing all, include config tunnels + allTunnels := make(map[string]interface{}) + + // Add running tunnels + for name, state := range runningTunnels { + allTunnels[name] = state + } + + // Add config tunnels if --all flag is used + if showAll { + for name, tunnel := range config.Tunnels { + if _, isRunning := runningTunnels[name]; !isRunning { + allTunnels[name] = tunnel + } + } + } + + if len(allTunnels) == 0 { + if showAll { + fmt.Println("No tunnels defined or running") + } else { + fmt.Println("No active tunnels") + } + return + } + + // Create pretty table + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.SetStyle(table.StyleLight) + t.AppendHeader(table.Row{"NAME", "TYPE", "LOCAL", "REMOTE", "HOST", "STATUS", "PID", "UPTIME"}) + + for name, tunnelData := range allTunnels { + switch tunnel := tunnelData.(type) { + case TunnelState: + // Running tunnel + uptime := time.Since(tunnel.StartedAt).Truncate(time.Second) + remote := "" + if tunnel.Type == "local" || tunnel.Type == "remote" { + remote = fmt.Sprintf("%s:%d", tunnel.RemoteHost, tunnel.RemotePort) + } else if tunnel.Type == "dynamic" { + remote = "SOCKS" + } + + t.AppendRow(table.Row{ + tunnel.Name, + tunnel.Type, + tunnel.LocalPort, + remote, + tunnel.SSHHostResolved, + "🟢 ACTIVE", + tunnel.PID, + uptime.String(), + }) + + case TunnelDefinition: + // Available tunnel from config + remote := "" + if tunnel.Type == "local" || tunnel.Type == "remote" { + remote = fmt.Sprintf("%s:%d", tunnel.RemoteHost, tunnel.RemotePort) + } else if tunnel.Type == "dynamic" { + remote = "SOCKS" + } + + t.AppendRow(table.Row{ + name, + tunnel.Type, + tunnel.LocalPort, + remote, + tunnel.SSHHost, + "⚪ AVAILABLE", + "-", + "-", + }) + } + } + + t.Render() } func openTunnel(name string) error { @@ -485,17 +563,35 @@ func openTunnel(name string) error { log.Debug().Strs("command", cmdArgs).Msg("Starting SSH tunnel") // Start SSH process - cmd := &exec.Cmd{ - Path: realSSHPath, - Args: cmdArgs, - } + cmd := exec.Command(realSSHPath, cmdArgs[1:]...) + + // Capture stderr to see any SSH errors + var stderr bytes.Buffer + cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start SSH tunnel: %w", err) } - pid := cmd.Process.Pid - log.Info().Str("tunnel", name).Int("pid", pid).Msg("SSH tunnel started") + // Wait for the process to complete (with -f, it should fork and exit quickly) + err := cmd.Wait() + if err != nil { + stderrOutput := stderr.String() + log.Error().Str("stderr", stderrOutput).Err(err).Msg("SSH tunnel command failed") + return fmt.Errorf("SSH tunnel failed: %s", stderrOutput) + } + + // The actual tunnel process is now running in background due to -f flag + // We need to find the backgrounded process PID + time.Sleep(200 * time.Millisecond) // Give SSH time to establish tunnel + + // Find the SSH process with our tunnel parameters + pid := findSSHProcessByPort(tunnel.LocalPort) + if pid == 0 { + return fmt.Errorf("failed to find backgrounded SSH tunnel process") + } + + log.Info().Str("tunnel", name).Int("pid", pid).Str("command", strings.Join(cmdArgs, " ")).Msg("SSH tunnel started") // Create tunnel state state := TunnelState{ @@ -928,6 +1024,33 @@ func parseSSHConfigFile(configFile, hostname string) int { return 22 // default port } +// findSSHProcessByPort finds the SSH process that's using a specific local port +func findSSHProcessByPort(port int) int { + // Use netstat or ss to find the process using the port + cmd := exec.Command("ss", "-tlnp", fmt.Sprintf("sport = :%d", port)) + output, err := cmd.Output() + if err != nil { + return 0 + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, fmt.Sprintf(":%d ", port)) && strings.Contains(line, "ssh") { + // Extract PID from ss output format + parts := strings.Fields(line) + for _, part := range parts { + if strings.Contains(part, "pid=") { + pidStr := strings.Split(strings.Split(part, "pid=")[1], ",")[0] + if pid, err := strconv.Atoi(pidStr); err == nil { + return pid + } + } + } + } + } + return 0 +} + // executeRealSSH executes the real SSH binary with given arguments func executeRealSSH(args []string) { // Check if real SSH exists