8 Commits

Author SHA1 Message Date
265107b66b Replace GoReleaser workflow with custom Release pipeline
Some checks failed
Release / goreleaser (push) Failing after 5m10s
2025-05-23 15:55:01 +02:00
a806b97b9b Add GoReleaser configuration and version command
Some checks failed
goreleaser / goreleaser (push) Failing after 5m46s
2025-05-23 15:41:54 +02:00
e1f7604439 Update README.md 2025-05-23 15:26:19 +02:00
1e195a313c Improve version tag fetching and handling in release script 2025-05-23 15:24:09 +02:00
f33de18007 Fix import path for stats package 2025-05-23 15:20:30 +02:00
e136c30266 Fix module import path and installation instructions 2025-05-23 15:19:59 +02:00
ee9c0edf8a Add release script for automatic version tagging 2025-05-23 15:16:59 +02:00
d9ec7efaae Update CLI flags and repository URLs 2025-05-23 15:16:47 +02:00
12 changed files with 715 additions and 123 deletions

98
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Set up Git for Gitea
run: |
git config --global url."https://\token:${{ secrets.API_TOKEN }}@git.mvl.sh/".insteadOf "https://git.mvl.sh/"
- name: Build binaries
id: build
run: |
# Get version from tag
VERSION=${GITHUB_REF#refs/tags/}
echo "Building version ${VERSION}..."
# Set up directories
mkdir -p dist/linux_amd64
mkdir -p dist/linux_arm64
mkdir -p dist/darwin_amd64
mkdir -p dist/darwin_arm64
# Build for different platforms
LDFLAGS="-s -w -X git.mvl.sh/vleeuwenmenno/sshtunnel/cmd.Version=${VERSION} -X git.mvl.sh/vleeuwenmenno/sshtunnel/cmd.BuildDate=$(date -u '+%Y-%m-%d %H:%M:%S')"
# Linux amd64
GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/linux_amd64/sshtunnel
tar -czf dist/sshtunnel_Linux_x86_64.tar.gz -C dist/linux_amd64 sshtunnel
# Linux arm64
GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/linux_arm64/sshtunnel
tar -czf dist/sshtunnel_Linux_arm64.tar.gz -C dist/linux_arm64 sshtunnel
# macOS amd64
GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/darwin_amd64/sshtunnel
tar -czf dist/sshtunnel_Darwin_x86_64.tar.gz -C dist/darwin_amd64 sshtunnel
# macOS arm64
GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/darwin_arm64/sshtunnel
tar -czf dist/sshtunnel_Darwin_arm64.tar.gz -C dist/darwin_arm64 sshtunnel
# Generate checksums
cd dist && sha256sum *.tar.gz > checksums.txt
- name: Generate completion scripts
run: |
mkdir -p dist/completions
# Generate completion scripts for different shells
dist/linux_amd64/sshtunnel completion bash > dist/completions/sshtunnel.bash
dist/linux_amd64/sshtunnel completion zsh > dist/completions/sshtunnel.zsh
dist/linux_amd64/sshtunnel completion fish > dist/completions/sshtunnel.fish
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.API_TOKEN }}
run: |
# Get version from tag
VERSION=${GITHUB_REF#refs/tags/}
# Create release on Gitea
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d "{\"tag_name\": \"$VERSION\", \"name\": \"$VERSION\", \"body\": \"Release $VERSION\"}" \
"https://git.mvl.sh/api/v1/repos/vleeuwenmenno/sshtunnel/releases"
# Upload release assets
for file in dist/*.tar.gz dist/checksums.txt; do
echo "Uploading $file..."
curl -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$file" \
"https://git.mvl.sh/api/v1/repos/vleeuwenmenno/sshtunnel/releases/$VERSION/assets"
done
- name: Update latest tag
run: |
git tag -f latest
git push -f origin latest

100
.goreleaser.yml Normal file
View File

@@ -0,0 +1,100 @@
# This is a GoReleaser configuration file for sshtunnel
# Compatible with GoReleaser v1.26.2
# https://goreleaser.com/customization/
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
flags:
- -trimpath
ldflags:
- -s -w
- -X git.mvl.sh/vleeuwenmenno/sshtunnel/cmd.Version={{.Version}}
- -X git.mvl.sh/vleeuwenmenno/sshtunnel/cmd.BuildDate={{.Date}}
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
# Homebrew formula
brews:
- name: sshtunnel
homepage: "https://git.mvl.sh/vleeuwenmenno/sshtunnel"
description: "SSH tunnel manager CLI tool"
license: "MIT"
commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
install: |
bin.install "sshtunnel"
# Generate shell completions
output = Utils.safe_popen_read("#{bin}/sshtunnel", "completion", "bash")
(bash_completion/"sshtunnel").write output
output = Utils.safe_popen_read("#{bin}/sshtunnel", "completion", "zsh")
(zsh_completion/"_sshtunnel").write output
output = Utils.safe_popen_read("#{bin}/sshtunnel", "completion", "fish")
(fish_completion/"sshtunnel.fish").write output
test: |
system "#{bin}/sshtunnel", "version"
# .deb and .rpm packages
nfpms:
- file_name_template: "{{ .ProjectName }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
homepage: https://git.mvl.sh/vleeuwenmenno/sshtunnel
description: "SSH tunnel manager CLI tool"
maintainer: Menno van Leeuwen
license: MIT
vendor: vleeuwenmenno
formats:
- deb
- rpm
dependencies:
- openssh-client
recommends:
- bash-completion
scripts:
postinstall: |
mkdir -p /usr/share/bash-completion/completions/
mkdir -p /usr/share/zsh/site-functions/
mkdir -p /usr/share/fish/vendor_completions.d/
sshtunnel completion bash > /usr/share/bash-completion/completions/sshtunnel
sshtunnel completion zsh > /usr/share/zsh/site-functions/_sshtunnel
sshtunnel completion fish > /usr/share/fish/vendor_completions.d/sshtunnel.fish
chmod 644 /usr/share/bash-completion/completions/sshtunnel || true
chmod 644 /usr/share/zsh/site-functions/_sshtunnel || true
chmod 644 /usr/share/fish/vendor_completions.d/sshtunnel.fish || true

118
README.md
View File

@@ -10,19 +10,53 @@ A Go-based command-line tool to manage SSH tunnels. This tool allows you to:
## Installation ## Installation
``` ```
go install github.com/yourusername/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:
``` ```
git clone https://github.com/yourusername/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
``` ```
@@ -34,31 +68,31 @@ This will display all active SSH tunnels with their IDs, local ports, remote end
### Starting a new tunnel ### Starting a new tunnel
``` ```
sshtunnel start -local 8080 -remote 80 -host example.com -server user@ssh-server.com sshtunnel start -l 8080 -r 80 -H example.com -s user@ssh-server.com
``` ```
Options: Options:
- `-local`: Local port to forward (required) - `-l`: Local port to forward (required)
- `-remote`: Remote port to forward to (required) - `-r`: Remote port to forward to (required)
- `-host`: Remote host to forward to (default: "localhost") - `-H`: Remote host to forward to (default: "localhost")
- `-server`: SSH server address in the format user@host (required) - `-s`: SSH server address in the format user@host (required)
- `-identity`: Path to SSH identity file (optional) - `-i`: Path to SSH identity file (optional)
### Stopping tunnels ### Stopping tunnels
Stop a specific tunnel by ID: Stop a specific tunnel by ID:
``` ```
sshtunnel stop -id 1 sshtunnel stop -i 1
``` ```
Stop all active tunnels: Stop all active tunnels:
``` ```
sshtunnel stop -all sshtunnel stop --all
``` ```
Options: Options:
- `-id`: ID of the tunnel to stop - `-i`: ID of the tunnel to stop
- `-all`: Stop all tunnels - `--all`: Stop all tunnels
### Viewing traffic statistics ### Viewing traffic statistics
@@ -69,12 +103,12 @@ sshtunnel stats --all
View statistics for a specific tunnel: View statistics for a specific tunnel:
``` ```
sshtunnel stats --id 1 sshtunnel stats -i 1
``` ```
Monitor tunnel traffic in real-time: Monitor tunnel traffic in real-time:
``` ```
sshtunnel stats --id 1 --watch sshtunnel stats -i 1 --watch
``` ```
Options: Options:
@@ -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.
@@ -107,4 +193,4 @@ The tool creates SSH tunnels using the system's SSH client and manages them by t
## License ## License
MIT MIT

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."

88
bin/scripts/release.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
source bin/helpers/func.sh
printfe "%s\n" "green" "SSH Tunnel Manager Release Script"
printfe "%s\n" "normal" "==============================="
# Check if git is installed
if ! command -v git &> /dev/null; then
printfe "%s\n" "red" "Error: git is not installed"
exit 1
fi
# Check if we're in a git repository
if ! git rev-parse --is-inside-work-tree &> /dev/null; then
printfe "%s\n" "red" "Error: Not in a git repository"
exit 1
fi
# Fetch all tags first to ensure we have the latest
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
if [[ $LATEST_TAG =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
else
printfe "%s\n" "red" "Error: Could not parse latest tag: $LATEST_TAG"
exit 1
fi
# Calculate the next patch version
NEXT_PATCH=$((PATCH + 1))
NEXT_VERSION="v$MAJOR.$MINOR.$NEXT_PATCH"
# Display current version and suggested next version
printfe "%s" "cyan" "Current version: "
printfe "%s\n" "yellow" "$LATEST_TAG" false
printfe "%s" "cyan" "Suggested next version: "
printfe "%s\n" "yellow" "$NEXT_VERSION" false
# Ask for confirmation or custom version
read -p "Accept suggested version? (y/n) " ACCEPT
if [[ $ACCEPT != "y" && $ACCEPT != "Y" ]]; then
read -p "Enter custom version (format vX.Y.Z): " CUSTOM_VERSION
if [[ $CUSTOM_VERSION =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
NEXT_VERSION=$CUSTOM_VERSION
else
printfe "%s\n" "red" "Error: Invalid version format. Must be vX.Y.Z where X, Y, Z are numbers."
exit 1
fi
fi
printfe "%s" "cyan" "Creating and pushing tag: "
printfe "%s\n" "yellow" "$NEXT_VERSION" false
# Make sure we have the latest changes
# We've already fetched tags at the beginning
# Create the new tag
printfe "%s\n" "cyan" "Creating new tag..."
log_and_run git tag "$NEXT_VERSION"
# Push the new tag
printfe "%s\n" "cyan" "Pushing new tag..."
log_and_run git push origin "$NEXT_VERSION"
# Update the latest tag
printfe "%s\n" "cyan" "Updating 'latest' tag..."
log_and_run git tag -f latest "$NEXT_VERSION"
log_and_run git push -f origin latest
printfe "%s\n" "green" "Successfully tagged and pushed version $NEXT_VERSION"
printfe "%s\n" "cyan" "Also updated 'latest' tag to point to $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 actualPID > 0 { if !active {
pid = actualPID status = "UNKNOWN"
} }
tunnel := Tunnel{
ID: id,
LocalPort: localPort,
RemotePort: remotePort,
RemoteHost: remoteHost,
SSHServer: sshServer,
PID: pid,
}
if err := saveTunnel(tunnel); err != nil { fmt.Printf("Started SSH tunnel (ID: %d): localhost:%d -> %s:%d (%s) [PID: %d] [Status: %s]\n",
fmt.Printf("Error saving tunnel information: %v\n", err) id, localPort, remoteHost, remotePort, sshServer, pid, status)
if pid > 0 { },
// Try to kill the process }
process, _ := os.FindProcess(pid)
if process != nil {
process.Kill()
}
}
os.Exit(1)
}
// Initialize statistics for this tunnel
statsManager, err := stats.NewStatsManager()
if err == nil {
err = statsManager.InitStats(id, localPort)
if err != nil {
fmt.Printf("Warning: Failed to initialize statistics: %v\n", err)
}
}
// Verify tunnel is actually working func init() {
time.Sleep(500 * time.Millisecond) rootCmd.AddCommand(startCmd)
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", // Add flags for the start command
id, localPort, remoteHost, remotePort, sshServer, pid, status) 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")
func init() { // Mark required flags
rootCmd.AddCommand(startCmd) startCmd.MarkFlagRequired("local")
startCmd.MarkFlagRequired("remote")
// Add flags for the start command startCmd.MarkFlagRequired("server")
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 {
@@ -138,4 +138,4 @@ func verifyTunnelActive(port int) bool {
} }
conn.Close() conn.Close()
return true return true
} }

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"
) )
@@ -181,7 +181,7 @@ func watchTunnelStats(tunnelID int, interval int) {
if err != nil { if err != nil {
fmt.Printf("Warning: Tunnel #%d may not be active: %v\n", tunnelID, err) fmt.Printf("Warning: Tunnel #%d may not be active: %v\n", tunnelID, err)
fmt.Println("Attempting to monitor anyway...") fmt.Println("Attempting to monitor anyway...")
// Try to get port from stats // Try to get port from stats
statsManager, _ := stats.NewStatsManager() statsManager, _ := stats.NewStatsManager()
s, err := statsManager.GetStats(tunnelID) s, err := statsManager.GetStats(tunnelID)
@@ -226,7 +226,7 @@ func watchTunnelStats(tunnelID int, interval int) {
if err != nil { if err != nil {
fmt.Printf("Warning: %v\n", err) fmt.Printf("Warning: %v\n", err)
fmt.Println("Displaying minimal statistics...") fmt.Println("Displaying minimal statistics...")
statsStr = fmt.Sprintf("Monitoring tunnel #%d on port %d...", statsStr = fmt.Sprintf("Monitoring tunnel #%d on port %d...",
tunnelID, tunnel.LocalPort) tunnelID, tunnel.LocalPort)
} }
fmt.Println(statsStr) fmt.Println(statsStr)
@@ -241,7 +241,7 @@ func watchTunnelStats(tunnelID int, interval int) {
statsStr, err := mon.FormatStats() statsStr, err := mon.FormatStats()
if err != nil { if err != nil {
statsStr = fmt.Sprintf("Warning: %v\n\nStill monitoring tunnel #%d on port %d...", statsStr = fmt.Sprintf("Warning: %v\n\nStill monitoring tunnel #%d on port %d...",
err, tunnelID, tunnel.LocalPort) err, tunnelID, tunnel.LocalPort)
} }

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 (
@@ -86,7 +86,7 @@ func killTunnel(t Tunnel) {
removeFile(t.ID) removeFile(t.ID)
cleanupStats(t.ID) cleanupStats(t.ID)
fmt.Printf("Stopped SSH tunnel (ID: %d): localhost:%d -> %s:%d\n", fmt.Printf("Stopped SSH tunnel (ID: %d): localhost:%d -> %s:%d\n",
t.ID, t.LocalPort, t.RemoteHost, t.RemotePort) t.ID, t.LocalPort, t.RemoteHost, t.RemotePort)
} }
@@ -102,4 +102,4 @@ func cleanupStats(tunnelID int) {
if err != nil { if err != nil {
fmt.Printf("Warning: Failed to delete statistics for tunnel %d: %v\n", tunnelID, err) fmt.Printf("Warning: Failed to delete statistics for tunnel %d: %v\n", tunnelID, err)
} }
} }

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