diff --git a/.github/workflows/go-releaser.yml b/.github/workflows/go-releaser.yml new file mode 100644 index 0000000..9eb78d2 --- /dev/null +++ b/.github/workflows/go-releaser.yml @@ -0,0 +1,37 @@ +name: goreleaser + +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: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update latest tag + run: | + git tag -f latest + git push -f origin latest \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..e972019 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,106 @@ +# This is a GoReleaser configuration file +# For more information about configuration options, visit: +# https://goreleaser.com/customization/ + +version: 2 + +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}} + mod_timestamp: "{{ .CommitTimestamp }}" + +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: + - repository: + owner: vleeuwenmenno + name: homebrew-tap + token: "{{ .Env.GITHUB_TOKEN }}" + directory: Formula + 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 }}" + id: packages + 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: | + 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 diff --git a/README.md b/README.md index ee73262..6eb69d4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ A Go-based command-line tool to manage SSH tunnels. This tool allows you to: 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: ``` @@ -22,6 +28,8 @@ make sudo make install ``` +The build process will automatically include version information from git tags. + ## Uninstallation For installation using `go install`, run: @@ -38,6 +46,17 @@ sudo make uninstall ## 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 ``` @@ -111,6 +130,58 @@ This command will: 3. Validate all recorded tunnels and their current state 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 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. diff --git a/bin/scripts/build-binary.sh b/bin/scripts/build-binary.sh index b085400..7168eaa 100755 --- a/bin/scripts/build-binary.sh +++ b/bin/scripts/build-binary.sh @@ -28,8 +28,12 @@ if [ -n "$(git status --porcelain)" ]; then POSTFIX=" (dirty)" 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..." -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 printf "\033[0;31m" @@ -39,9 +43,9 @@ if [ $? -ne 0 ]; then fi # 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 printfe "%s\n" "green" "Bash completion script installed to $COMPLETION_SCRIPT." diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..e0b2d39 --- /dev/null +++ b/cmd/version.go @@ -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 +} \ No newline at end of file