feat: added a ssh utility that supports smart-aliases and background ssh
Some checks failed
Ansible Lint Check / check-ansible (push) Failing after 8s
Nix Format Check / check-format (push) Failing after 21s
Python Lint Check / check-python (push) Failing after 6s

tunnels
This commit is contained in:
2025-07-25 15:37:55 +02:00
parent f0bf6bc8aa
commit 564e45e099
6 changed files with 191 additions and 37 deletions

View File

@@ -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'

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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