feat: added a ssh utility that supports smart-aliases and background ssh
tunnels
This commit is contained in:
1
.bashrc
1
.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 <length>)
|
||||
alias random='openssl rand -base64'
|
||||
|
@@ -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)
|
@@ -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
|
||||
)
|
@@ -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=
|
@@ -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
|
Reference in New Issue
Block a user