8 Commits

10 changed files with 434 additions and 114 deletions

View File

@@ -10,7 +10,13 @@ A Go-based command-line tool to manage SSH tunnels. This tool allows you to:
## Installation ## Installation
``` ```
go install git.mvl.sh/vleeuwenmenno/sshtunnel/cmd@latest go install git.mvl.sh/vleeuwenmenno/sshtunnel@latest
```
You can also install a specific version:
```
go install git.mvl.sh/vleeuwenmenno/sshtunnel@v1.0.0
``` ```
Or clone this repository and build it yourself: Or clone this repository and build it yourself:
@@ -18,11 +24,39 @@ Or clone this repository and build it yourself:
``` ```
git clone https://git.mvl.sh/vleeuwenmenno/sshtunnel.git git clone https://git.mvl.sh/vleeuwenmenno/sshtunnel.git
cd sshtunnel cd sshtunnel
go build -o sshtunnel ./cmd make
sudo make install
```
The build process will automatically include version information from git tags.
## Uninstallation
For installation using `go install`, run:
```
go clean -i git.mvl.sh/vleeuwenmenno/sshtunnel
```
For manual installation, run:
```
sudo make uninstall
``` ```
## Usage ## Usage
### Showing version information
```
sshtunnel version
```
This will display the current version of sshtunnel along with build information.
Options:
- `-c, --check-updates`: Check for updates against the latest release
### Listing active tunnels ### Listing active tunnels
``` ```
@@ -96,6 +130,58 @@ This command will:
3. Validate all recorded tunnels and their current state 3. Validate all recorded tunnels and their current state
4. Show active SSH tunnel processes and their status 4. Show active SSH tunnel processes and their status
### Version information
```
sshtunnel version --check-updates
```
This displays version information and checks for updates to the tool.
When a new version is available, you'll get instructions on how to update.
### Shell completion
Generate shell completion scripts:
```
# Bash
sshtunnel completion bash > ~/.bash_completion.d/sshtunnel
source ~/.bash_completion.d/sshtunnel
# Zsh
sshtunnel completion zsh > "${fpath[1]}/_sshtunnel"
# Fish
sshtunnel completion fish > ~/.config/fish/completions/sshtunnel.fish
```
For system-wide installation:
```
# Bash (Linux)
sudo sshtunnel completion bash > /etc/bash_completion.d/sshtunnel
# Bash (macOS with Homebrew)
sshtunnel completion bash > $(brew --prefix)/etc/bash_completion.d/sshtunnel
```
## Development
### Release Process
The project includes a release script to help manage version tags:
```
./bin/scripts/release.sh
```
This script will:
1. Find the latest version tag
2. Suggest the next patch version (e.g., v1.0.0 → v1.0.1)
3. Allow you to accept this suggestion or enter a custom version
4. Create and push the new tag
5. Update the "latest" tag to point to the new version
## How it works ## How it works
The tool creates SSH tunnels using the system's SSH client and manages them by tracking their process IDs in a hidden directory (`~/.sshtunnels/`). Each tunnel is assigned a unique ID for easy management. Traffic statistics are collected and stored to help you monitor data transfer through your tunnels. The tool creates SSH tunnels using the system's SSH client and manages them by tracking their process IDs in a hidden directory (`~/.sshtunnels/`). Each tunnel is assigned a unique ID for easy management. Traffic statistics are collected and stored to help you monitor data transfer through your tunnels.

View File

@@ -28,8 +28,12 @@ if [ -n "$(git status --porcelain)" ]; then
POSTFIX=" (dirty)" POSTFIX=" (dirty)"
fi fi
# Format a clean version string for the build
VERSION=$(echo "$BRANCH" | sed 's/^v//')
BUILD_DATE=$(date -u '+%Y-%m-%d %H:%M:%S')
printfe "%s\n" "cyan" "Building $BINARY_NAME binary for $BRANCH ($LATEST_COMMIT_HASH)$POSTFIX..." printfe "%s\n" "cyan" "Building $BINARY_NAME binary for $BRANCH ($LATEST_COMMIT_HASH)$POSTFIX..."
go build -o $BINARY_PATH go build -ldflags="-X 'git.mvl.sh/vleeuwenmenno/sshtunnel/cmd.Version=$BRANCH' -X 'git.mvl.sh/vleeuwenmenno/sshtunnel/cmd.BuildDate=$BUILD_DATE'" -o $BINARY_PATH
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
printf "\033[0;31m" printf "\033[0;31m"
@@ -39,9 +43,9 @@ if [ $? -ne 0 ]; then
fi fi
# Put tag and hash in .sshtunnel_version file # Put tag and hash in .sshtunnel_version file
echo "$BRANCH ($LATEST_COMMIT_HASH)$POSTFIX" > $BINARY_PATH_VERSION echo "$BRANCH" > $BINARY_PATH_VERSION
printfe "%s\n" "cyan" "Generating Bash completion script..." printfe "%s\n" "cyan" "Generating completion scripts..."
$BINARY_PATH completion bash > $COMPLETION_SCRIPT $BINARY_PATH completion bash > $COMPLETION_SCRIPT
printfe "%s\n" "green" "Bash completion script installed to $COMPLETION_SCRIPT." printfe "%s\n" "green" "Bash completion script installed to $COMPLETION_SCRIPT."

View File

@@ -17,8 +17,21 @@ if ! git rev-parse --is-inside-work-tree &> /dev/null; then
exit 1 exit 1
fi fi
# Get the latest tag or default to v0.0.0 if no tags exist # Fetch all tags first to ensure we have the latest
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") printfe "%s\n" "cyan" "Fetching all tags..."
log_and_run git fetch --tags
# Get the latest version tag, excluding 'latest' tag, or default to v0.0.0 if no tags exist
VERSION_TAGS=$(git tag -l 'v*.*.*' | grep -v 'latest' | sort -V)
if [ -z "$VERSION_TAGS" ]; then
printfe "%s\n" "yellow" "No version tags found. Starting with v0.0.0."
LATEST_TAG="v0.0.0"
else
# Get the latest version tag
LATEST_TAG=$(echo "$VERSION_TAGS" | tail -n 1)
printfe "%s" "cyan" "Found latest version tag: "
printfe "%s\n" "yellow" "$LATEST_TAG" false
fi
# Parse the version number # Parse the version number
if [[ $LATEST_TAG =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then if [[ $LATEST_TAG =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
@@ -56,8 +69,7 @@ printfe "%s" "cyan" "Creating and pushing tag: "
printfe "%s\n" "yellow" "$NEXT_VERSION" false printfe "%s\n" "yellow" "$NEXT_VERSION" false
# Make sure we have the latest changes # Make sure we have the latest changes
printfe "%s\n" "cyan" "Fetching latest tags..." # We've already fetched tags at the beginning
log_and_run git fetch --tags
# Create the new tag # Create the new tag
printfe "%s\n" "cyan" "Creating new tag..." printfe "%s\n" "cyan" "Creating new tag..."
@@ -69,7 +81,9 @@ log_and_run git push origin "$NEXT_VERSION"
# Update the latest tag # Update the latest tag
printfe "%s\n" "cyan" "Updating 'latest' tag..." printfe "%s\n" "cyan" "Updating 'latest' tag..."
log_and_run git tag -f latest "$NEXT_VERSION" # Get the commit that the new version tag points to
COMMIT_HASH=$(git rev-parse "$NEXT_VERSION")
log_and_run git tag -f latest "$COMMIT_HASH"
log_and_run git push -f origin latest log_and_run git push -f origin latest
printfe "%s\n" "green" "Successfully tagged and pushed version $NEXT_VERSION" printfe "%s\n" "green" "Successfully tagged and pushed version $NEXT_VERSION"

View File

@@ -8,8 +8,8 @@ import (
"strings" "strings"
"time" "time"
"git.mvl.sh/vleeuwenmenno/sshtunnel/pkg/stats"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sshtunnel/pkg/stats"
) )
var ( var (
@@ -28,106 +28,106 @@ var startCmd = &cobra.Command{
The tunnel will run in the background and can be managed using the list and stop commands.`, The tunnel will run in the background and can be managed using the list and stop commands.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Check required flags // Check required flags
// Generate the SSH command with appropriate flags for reliable background operation // Generate the SSH command with appropriate flags for reliable background operation
sshArgs := []string{ sshArgs := []string{
"-N", // Don't execute remote command "-N", // Don't execute remote command
"-f", // Run in background "-f", // Run in background
"-L", fmt.Sprintf("%d:%s:%d", localPort, remoteHost, remotePort), "-L", fmt.Sprintf("%d:%s:%d", localPort, remoteHost, remotePort),
}
if identity != "" {
sshArgs = append(sshArgs, "-i", identity)
}
sshArgs = append(sshArgs, sshServer)
sshCmd := exec.Command("ssh", sshArgs...)
// Capture output for debugging
var outputBuffer strings.Builder
sshCmd.Stdout = &outputBuffer
sshCmd.Stderr = &outputBuffer
// Run the command (not just Start) - the -f flag means it will return immediately
// after going to the background
if err := sshCmd.Run(); err != nil {
fmt.Printf("Error starting SSH tunnel: %v\n", err)
fmt.Printf("SSH output: %s\n", outputBuffer.String())
os.Exit(1)
}
// The PID from cmd.Process is no longer valid since ssh -f forks
// We need to find the actual SSH process PID
actualPID, err := findSSHTunnelPID(localPort)
if err != nil {
fmt.Printf("Warning: Could not determine tunnel PID: %v\n", err)
}
// Store tunnel information
id := generateTunnelID()
pid := 0
if actualPID > 0 {
pid = actualPID
}
tunnel := Tunnel{
ID: id,
LocalPort: localPort,
RemotePort: remotePort,
RemoteHost: remoteHost,
SSHServer: sshServer,
PID: pid,
}
if err := saveTunnel(tunnel); err != nil {
fmt.Printf("Error saving tunnel information: %v\n", err)
if pid > 0 {
// Try to kill the process
process, _ := os.FindProcess(pid)
if process != nil {
process.Kill()
}
} }
os.Exit(1)
}
if identity != "" { // Initialize statistics for this tunnel
sshArgs = append(sshArgs, "-i", identity) statsManager, err := stats.NewStatsManager()
} if err == nil {
err = statsManager.InitStats(id, localPort)
sshArgs = append(sshArgs, sshServer)
sshCmd := exec.Command("ssh", sshArgs...)
// Capture output for debugging
var outputBuffer strings.Builder
sshCmd.Stdout = &outputBuffer
sshCmd.Stderr = &outputBuffer
// Run the command (not just Start) - the -f flag means it will return immediately
// after going to the background
if err := sshCmd.Run(); err != nil {
fmt.Printf("Error starting SSH tunnel: %v\n", err)
fmt.Printf("SSH output: %s\n", outputBuffer.String())
os.Exit(1)
}
// The PID from cmd.Process is no longer valid since ssh -f forks
// We need to find the actual SSH process PID
actualPID, err := findSSHTunnelPID(localPort)
if err != nil { if err != nil {
fmt.Printf("Warning: Could not determine tunnel PID: %v\n", err) fmt.Printf("Warning: Failed to initialize statistics: %v\n", err)
} }
}
// Store tunnel information // Verify tunnel is actually working
id := generateTunnelID() time.Sleep(500 * time.Millisecond)
pid := 0 active := verifyTunnelActive(localPort)
status := "ACTIVE"
if !active {
status = "UNKNOWN"
}
if actualPID > 0 { fmt.Printf("Started SSH tunnel (ID: %d): localhost:%d -> %s:%d (%s) [PID: %d] [Status: %s]\n",
pid = actualPID id, localPort, remoteHost, remotePort, sshServer, pid, status)
} },
}
tunnel := Tunnel{ func init() {
ID: id, rootCmd.AddCommand(startCmd)
LocalPort: localPort,
RemotePort: remotePort,
RemoteHost: remoteHost,
SSHServer: sshServer,
PID: pid,
}
if err := saveTunnel(tunnel); err != nil { // Add flags for the start command
fmt.Printf("Error saving tunnel information: %v\n", err) startCmd.Flags().IntVarP(&localPort, "local", "l", 0, "Local port to forward")
if pid > 0 { startCmd.Flags().IntVarP(&remotePort, "remote", "r", 0, "Remote port to forward to")
// Try to kill the process startCmd.Flags().StringVarP(&remoteHost, "host", "H", "localhost", "Remote host to forward to")
process, _ := os.FindProcess(pid) startCmd.Flags().StringVarP(&sshServer, "server", "s", "", "SSH server address (user@host)")
if process != nil { startCmd.Flags().StringVarP(&identity, "identity", "i", "", "Path to SSH identity file")
process.Kill()
}
}
os.Exit(1)
}
// Initialize statistics for this tunnel // Mark required flags
statsManager, err := stats.NewStatsManager() startCmd.MarkFlagRequired("local")
if err == nil { startCmd.MarkFlagRequired("remote")
err = statsManager.InitStats(id, localPort) startCmd.MarkFlagRequired("server")
if err != nil { }
fmt.Printf("Warning: Failed to initialize statistics: %v\n", err)
}
}
// Verify tunnel is actually working
time.Sleep(500 * time.Millisecond)
active := verifyTunnelActive(localPort)
status := "ACTIVE"
if !active {
status = "UNKNOWN"
}
fmt.Printf("Started SSH tunnel (ID: %d): localhost:%d -> %s:%d (%s) [PID: %d] [Status: %s]\n",
id, localPort, remoteHost, remotePort, sshServer, pid, status)
},
}
func init() {
rootCmd.AddCommand(startCmd)
// Add flags for the start command
startCmd.Flags().IntVarP(&localPort, "local", "l", 0, "Local port to forward")
startCmd.Flags().IntVarP(&remotePort, "remote", "r", 0, "Remote port to forward to")
startCmd.Flags().StringVarP(&remoteHost, "host", "H", "localhost", "Remote host to forward to")
startCmd.Flags().StringVarP(&sshServer, "server", "s", "", "SSH server address (user@host)")
startCmd.Flags().StringVarP(&identity, "identity", "i", "", "Path to SSH identity file")
// Mark required flags
startCmd.MarkFlagRequired("local")
startCmd.MarkFlagRequired("remote")
startCmd.MarkFlagRequired("server")
}
// verifyTunnelActive checks if the tunnel is actually working // verifyTunnelActive checks if the tunnel is actually working
func verifyTunnelActive(port int) bool { func verifyTunnelActive(port int) bool {

View File

@@ -5,8 +5,8 @@ import (
"os" "os"
"time" "time"
"sshtunnel/pkg/monitor" "git.mvl.sh/vleeuwenmenno/sshtunnel/pkg/monitor"
"sshtunnel/pkg/stats" "git.mvl.sh/vleeuwenmenno/sshtunnel/pkg/stats"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View File

@@ -5,8 +5,8 @@ import (
"os" "os"
"syscall" "syscall"
"git.mvl.sh/vleeuwenmenno/sshtunnel/pkg/stats"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sshtunnel/pkg/stats"
) )
var ( var (

216
cmd/version.go Normal file
View File

@@ -0,0 +1,216 @@
package cmd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
)
var (
// Version is set during build
Version = "dev"
// BuildDate is set during build
BuildDate = ""
// For go install builds, this will be the go.mod version
versionFromMod = "$GOFLAGS: -ldflags=-X git.mvl.sh/vleeuwenmenno/sshtunnel/cmd.Version=${VERSION}"
checkForUpdates bool
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show sshtunnel version information",
Long: `Display the current version of sshtunnel and check for updates.`,
Run: func(cmd *cobra.Command, args []string) {
// Display current version
showVersion()
// Check for updates if requested
if checkForUpdates {
checkUpdate()
}
},
}
func init() {
rootCmd.AddCommand(versionCmd)
versionCmd.Flags().BoolVarP(&checkForUpdates, "check-updates", "c", false, "Check for updates")
// If version is still "dev", try to read from other sources
if Version == "dev" {
// Check if version was injected via go install's version info
if strings.Contains(versionFromMod, "${VERSION}") {
// Not replaced, try to read from the installed version file
Version = readInstalledVersion()
} else {
// Extract version from the injected string
re := regexp.MustCompile(`Version=(.+)$`)
matches := re.FindStringSubmatch(versionFromMod)
if len(matches) > 1 {
Version = matches[1]
} else {
// Fall back to reading from installed file
Version = readInstalledVersion()
}
}
}
}
func showVersion() {
fmt.Printf("sshtunnel %s\n", Version)
if BuildDate != "" {
fmt.Printf("Build date: %s\n", BuildDate)
}
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
}
func readInstalledVersion() string {
// Check system-installed version file first
versionFile := "/usr/local/share/sshtunnel/sshtunnel.version"
if _, err := os.Stat(versionFile); err == nil {
content, err := os.ReadFile(versionFile)
if err == nil {
return strings.TrimSpace(string(content))
}
}
// Try user's home directory for go install version
homeDir, err := os.UserHomeDir()
if err == nil {
goPath := filepath.Join(homeDir, "go", "pkg", "mod", "git.mvl.sh", "vleeuwenmenno", "sshtunnel@*")
matches, err := filepath.Glob(goPath)
if err == nil && len(matches) > 0 {
// Sort matches to get the latest version
// Example: .../sshtunnel@v1.0.0 -> extract v1.0.0
latestMatch := matches[len(matches)-1]
parts := strings.Split(filepath.Base(latestMatch), "@")
if len(parts) > 1 {
return parts[1]
}
}
}
// Check if executable name has version info
execPath, err := os.Executable()
if err == nil {
execName := filepath.Base(execPath)
if strings.Contains(execName, "sshtunnel-v") {
parts := strings.Split(execName, "-v")
if len(parts) > 1 {
return "v" + parts[1]
}
}
// Check if it's in GOBIN with a version suffix
re := regexp.MustCompile(`sshtunnel@(v[0-9]+\.[0-9]+\.[0-9]+)$`)
matches := re.FindStringSubmatch(execName)
if len(matches) > 1 {
return matches[1]
}
}
// Default version if we couldn't find any version information
return "dev"
}
func checkUpdate() {
fmt.Println("\nChecking for updates...")
client := http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get("https://git.mvl.sh/api/v1/repos/vleeuwenmenno/sshtunnel/tags")
if err != nil {
fmt.Printf("Error checking for updates: %s\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %s\n", err)
return
}
var tags []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(body, &tags); err != nil {
fmt.Printf("Error parsing response: %s\n", err)
return
}
// Find the latest version tag
var latestTag string
for _, tag := range tags {
if strings.HasPrefix(tag.Name, "v") && (latestTag == "" || compareVersions(tag.Name, latestTag) > 0) {
latestTag = tag.Name
}
}
if latestTag == "" {
fmt.Println("No version tags found in the repository")
return
}
// Compare with current version
if compareVersions(latestTag, Version) > 0 {
fmt.Printf("A newer version is available: %s (you have %s)\n", latestTag, Version)
fmt.Println("To update, run: go install git.mvl.sh/vleeuwenmenno/sshtunnel@latest")
} else {
fmt.Println("You are running the latest version")
}
}
// compareVersions compares two semantic version strings (v1.2.3)
// returns: 1 if v1 > v2
// -1 if v1 < v2
// 0 if v1 == v2
func compareVersions(v1, v2 string) int {
// Remove 'v' prefix if any
v1 = strings.TrimPrefix(v1, "v")
v2 = strings.TrimPrefix(v2, "v")
// Split into parts
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
// Compare each part
for i := 0; i < len(parts1) && i < len(parts2); i++ {
// Skip non-numeric parts (like pre-release suffixes)
num1 := 0
fmt.Sscanf(parts1[i], "%d", &num1)
num2 := 0
fmt.Sscanf(parts2[i], "%d", &num2)
if num1 > num2 {
return 1
}
if num1 < num2 {
return -1
}
}
// If we get here, the common parts are equal
if len(parts1) > len(parts2) {
return 1
}
if len(parts1) < len(parts2) {
return -1
}
return 0
}

2
go.mod
View File

@@ -1,4 +1,4 @@
module sshtunnel module git.mvl.sh/vleeuwenmenno/sshtunnel
go 1.22.2 go 1.22.2

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"sshtunnel/cmd" "git.mvl.sh/vleeuwenmenno/sshtunnel/cmd"
) )
func main() { func main() {

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"sshtunnel/pkg/stats" "git.mvl.sh/vleeuwenmenno/sshtunnel/pkg/stats"
) )
// Monitor represents a SSH tunnel traffic monitor // Monitor represents a SSH tunnel traffic monitor