Compare commits
8 Commits
e602d503e8
...
v0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
265107b66b
|
|||
|
a806b97b9b
|
|||
|
e1f7604439
|
|||
|
1e195a313c
|
|||
|
f33de18007
|
|||
|
e136c30266
|
|||
|
ee9c0edf8a
|
|||
|
d9ec7efaae
|
98
.github/workflows/release.yml
vendored
Normal file
98
.github/workflows/release.yml
vendored
Normal 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
100
.goreleaser.yml
Normal 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
|
||||||
116
README.md
116
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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
88
bin/scripts/release.sh
Executable 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"
|
||||||
184
cmd/start.go
184
cmd/start.go
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
216
cmd/version.go
Normal 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
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module sshtunnel
|
module git.mvl.sh/vleeuwenmenno/sshtunnel
|
||||||
|
|
||||||
go 1.22.2
|
go 1.22.2
|
||||||
|
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"sshtunnel/cmd"
|
"git.mvl.sh/vleeuwenmenno/sshtunnel/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user