From bb043572d3ef59e11497e4ae1833ca6001d9c6e6 Mon Sep 17 00:00:00 2001 From: CHEN Li Date: Fri, 13 Mar 2026 11:36:00 +0800 Subject: [PATCH 1/3] enhancement for supporting local ssh config, so you can use alias in ssh:// --- cmd/gost/program.go | 2 +- cmd/gost/sshconfig.go | 193 +++++++++++++++++++++++++++++++ cmd/gost/sshconfig_test.go | 226 +++++++++++++++++++++++++++++++++++++ cmd/gost/version.go | 2 +- 4 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 cmd/gost/sshconfig.go create mode 100644 cmd/gost/sshconfig_test.go diff --git a/cmd/gost/program.go b/cmd/gost/program.go index 7ce007e..6a9f9fe 100644 --- a/cmd/gost/program.go +++ b/cmd/gost/program.go @@ -36,7 +36,7 @@ func (p *program) Init(env svc.Environment) error { parser.Init(parser.Args{ CfgFile: cfgFile, Services: services, - Nodes: nodes, + Nodes: expandSSHNodes(nodes), Debug: debug, Trace: trace, ApiAddr: apiAddr, diff --git a/cmd/gost/sshconfig.go b/cmd/gost/sshconfig.go new file mode 100644 index 0000000..072fdb1 --- /dev/null +++ b/cmd/gost/sshconfig.go @@ -0,0 +1,193 @@ +package main + +import ( + "bufio" + "net" + "net/url" + "os" + "path/filepath" + "strings" +) + +// sshHostEntry holds the resolved settings for a single SSH host alias. +type sshHostEntry struct { + hostname string // HostName directive + port string // Port directive + user string // User directive + identityFile string // first IdentityFile directive +} + +// parseSSHConfigLine splits an SSH config line into (key, value). +// Handles both "Key Value" and "Key=Value" (with optional spaces around =). +func parseSSHConfigLine(line string) (key, value string, ok bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false + } + // Find first whitespace or '=' + i := 0 + for i < len(line) && line[i] != ' ' && line[i] != '\t' && line[i] != '=' { + i++ + } + if i == len(line) { + return "", "", false + } + key = line[:i] + rest := strings.TrimLeft(line[i:], " \t=") + // Strip trailing inline comment (must be preceded by whitespace) + if idx := strings.Index(rest, " #"); idx >= 0 { + rest = strings.TrimSpace(rest[:idx]) + } + value = strings.Trim(rest, `"'`) + return key, value, value != "" +} + +// readSSHConfig parses ~/.ssh/config and returns a map of host alias/pattern +// to resolved entry. Only exact-match patterns (no wildcards) are indexed so +// they can be looked up directly; wildcard patterns are skipped for now. +func readSSHConfig() map[string]*sshHostEntry { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + path := filepath.Join(home, ".ssh", "config") + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + entries := make(map[string]*sshHostEntry) + var current *sshHostEntry + var currentPatterns []string + + commit := func() { + if current == nil { + return + } + for _, pat := range currentPatterns { + // Skip wildcard patterns — they can't be used for exact lookup. + if strings.ContainsAny(pat, "*?") { + continue + } + if _, exists := entries[pat]; !exists { + entries[pat] = current + } + } + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + key, value, ok := parseSSHConfigLine(scanner.Text()) + if !ok { + continue + } + switch strings.ToLower(key) { + case "host": + commit() + currentPatterns = strings.Fields(value) + current = &sshHostEntry{} + case "hostname": + if current != nil && current.hostname == "" { + current.hostname = value + } + case "port": + if current != nil && current.port == "" { + current.port = value + } + case "user": + if current != nil && current.user == "" { + current.user = value + } + case "identityfile": + if current != nil && current.identityFile == "" { + if strings.HasPrefix(value, "~/") { + home, _ := os.UserHomeDir() + value = filepath.Join(home, value[2:]) + } + current.identityFile = value + } + } + } + commit() + + return entries +} + +// expandSSHNode rewrites a single node URL string using ~/.ssh/config when the +// scheme is "ssh" and the host matches a Host entry. Fields already present in +// the URL are never overridden. +func expandSSHNode(raw string, cfg map[string]*sshHostEntry) string { + if cfg == nil || !strings.HasPrefix(raw, "ssh://") { + return raw + } + + u, err := url.Parse(raw) + if err != nil || u.Scheme != "ssh" { + return raw + } + + host := u.Hostname() + port := u.Port() + + entry, ok := cfg[host] + if !ok { + return raw + } + + // Resolve hostname. + newHost := host + if entry.hostname != "" { + newHost = entry.hostname + } + + // Resolve port: only substitute when the URL carries no explicit port or + // carries the SSH default (22) and the config specifies a different one. + newPort := port + if newPort == "" || newPort == "22" { + if entry.port != "" && entry.port != "22" { + newPort = entry.port + } + } + + if newPort != "" && newPort != "22" { + u.Host = net.JoinHostPort(newHost, newPort) + } else { + u.Host = newHost + } + + // Apply user if not already present in the URL. + if entry.user != "" && (u.User == nil || u.User.Username() == "") { + var pw string + if u.User != nil { + pw, _ = u.User.Password() + } + if pw != "" { + u.User = url.UserPassword(entry.user, pw) + } else { + u.User = url.User(entry.user) + } + } + + // Apply identity file as the "key" query parameter when not already set. + if entry.identityFile != "" { + q := u.Query() + if q.Get("privateKeyFile") == "" { + q.Set("privateKeyFile", entry.identityFile) + u.RawQuery = q.Encode() + } + } + + return u.String() +} + +// expandSSHNodes applies expandSSHNode to every element of the node list and +// returns the result as a new slice. The original slice is not modified. +func expandSSHNodes(nodes []string) []string { + cfg := readSSHConfig() + result := make([]string, len(nodes)) + for i, n := range nodes { + result[i] = expandSSHNode(n, cfg) + } + return result +} diff --git a/cmd/gost/sshconfig_test.go b/cmd/gost/sshconfig_test.go new file mode 100644 index 0000000..ba78885 --- /dev/null +++ b/cmd/gost/sshconfig_test.go @@ -0,0 +1,226 @@ +package main + +import ( + "net/url" + "os" + "path/filepath" + "testing" +) + +func writeSSHConfig(t *testing.T, content string) (cleanup func()) { + t.Helper() + dir := t.TempDir() + sshDir := filepath.Join(dir, ".ssh") + if err := os.Mkdir(sshDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(content), 0600); err != nil { + t.Fatal(err) + } + old := os.Getenv("HOME") + os.Setenv("HOME", dir) + return func() { os.Setenv("HOME", old) } +} + +func TestParseSSHConfigLine(t *testing.T) { + cases := []struct { + line, wantKey, wantVal string + wantOk bool + }{ + {" HostName example.com", "HostName", "example.com", true}, + {"HostName=example.com", "HostName", "example.com", true}, + {"HostName = example.com", "HostName", "example.com", true}, + {"# comment", "", "", false}, + {"", "", "", false}, + {`IdentityFile "~/.ssh/id_rsa"`, "IdentityFile", "~/.ssh/id_rsa", true}, + {"Host myalias", "Host", "myalias", true}, + {"Host alias1 alias2", "Host", "alias1 alias2", true}, + } + for _, c := range cases { + k, v, ok := parseSSHConfigLine(c.line) + if ok != c.wantOk || k != c.wantKey || v != c.wantVal { + t.Errorf("parseSSHConfigLine(%q) = (%q, %q, %v), want (%q, %q, %v)", + c.line, k, v, ok, c.wantKey, c.wantVal, c.wantOk) + } + } +} + +func TestExpandSSHNode_NoMatch(t *testing.T) { + cfg := map[string]*sshHostEntry{ + "myalias": {hostname: "real.host.com", port: "2222", user: "alice"}, + } + if got := expandSSHNode("http://myalias:8080", cfg); got != "http://myalias:8080" { + t.Errorf("non-SSH URL was modified: %q", got) + } + if got := expandSSHNode("ssh://other:22", cfg); got != "ssh://other:22" { + t.Errorf("unknown SSH host was modified: %q", got) + } +} + +func TestExpandSSHNode_FullExpansion(t *testing.T) { + keyPath := "/home/alice/.ssh/id_ed25519" + cfg := map[string]*sshHostEntry{ + "myalias": { + hostname: "real.host.com", + port: "2222", + user: "alice", + identityFile: keyPath, + }, + } + + got := expandSSHNode("ssh://myalias", cfg) + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parse result URL: %v", err) + } + if u.Hostname() != "real.host.com" { + t.Errorf("hostname: got %q, want %q", u.Hostname(), "real.host.com") + } + if u.Port() != "2222" { + t.Errorf("port: got %q, want %q", u.Port(), "2222") + } + if u.User.Username() != "alice" { + t.Errorf("user: got %q, want %q", u.User.Username(), "alice") + } + if q := u.Query().Get("privateKeyFile"); q != keyPath { + t.Errorf("privateKeyFile param: got %q, want %q", q, keyPath) + } +} + +func TestExpandSSHNode_PreserveExisting(t *testing.T) { + cfg := map[string]*sshHostEntry{ + "myalias": { + hostname: "real.host.com", + port: "2222", + user: "alice", + identityFile: "/home/alice/.ssh/id_ed25519", + }, + } + + got := expandSSHNode("ssh://bob@myalias:22?key=/tmp/other_key", cfg) + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parse result URL: %v", err) + } + if u.Hostname() != "real.host.com" { + t.Errorf("hostname: got %q, want %q", u.Hostname(), "real.host.com") + } + // Port 22 is default; config has 2222 → should be substituted. + if u.Port() != "2222" { + t.Errorf("port: got %q, want %q", u.Port(), "2222") + } + // Existing user in URL must be preserved. + if u.User.Username() != "bob" { + t.Errorf("user: got %q, want %q", u.User.Username(), "bob") + } + // Existing TLS key param must be preserved unchanged. + if q := u.Query().Get("key"); q != "/tmp/other_key" { + t.Errorf("key param: got %q, want %q", q, "/tmp/other_key") + } + // privateKeyFile from config must be added (key is TLS, not SSH). + if q := u.Query().Get("privateKeyFile"); q != "/home/alice/.ssh/id_ed25519" { + t.Errorf("privateKeyFile param: got %q, want %q", q, "/home/alice/.ssh/id_ed25519") + } +} + +func TestExpandSSHNode_PrivateKeyFileInURL_Preserved(t *testing.T) { + cfg := map[string]*sshHostEntry{ + "srv": {hostname: "1.2.3.4", identityFile: "/home/user/.ssh/id_rsa"}, + } + got := expandSSHNode("ssh://srv?privateKeyFile=/custom/key", cfg) + u, _ := url.Parse(got) + // privateKeyFile already set → identityFile from config should NOT override. + if q := u.Query().Get("privateKeyFile"); q != "/custom/key" { + t.Errorf("privateKeyFile param: got %q, want %q", q, "/custom/key") + } +} + +func TestReadSSHConfig(t *testing.T) { + cleanup := writeSSHConfig(t, ` +# global defaults +Host * + ServerAliveInterval 60 + +Host bastion + HostName bastion.example.com + Port 2222 + User deploy + IdentityFile ~/.ssh/bastion_key + +Host dev + HostName 10.0.0.5 + User dev +`) + defer cleanup() + + cfg := readSSHConfig() + if cfg == nil { + t.Fatal("readSSHConfig returned nil") + } + + e, ok := cfg["bastion"] + if !ok { + t.Fatal("expected 'bastion' entry") + } + if e.hostname != "bastion.example.com" { + t.Errorf("hostname: got %q", e.hostname) + } + if e.port != "2222" { + t.Errorf("port: got %q", e.port) + } + if e.user != "deploy" { + t.Errorf("user: got %q", e.user) + } + if filepath.Base(e.identityFile) != "bastion_key" { + t.Errorf("identityFile: got %q", e.identityFile) + } + + dev, ok := cfg["dev"] + if !ok { + t.Fatal("expected 'dev' entry") + } + if dev.hostname != "10.0.0.5" { + t.Errorf("dev hostname: got %q", dev.hostname) + } + + // Wildcard Host * must not be indexed by exact key. + if _, ok := cfg["*"]; ok { + t.Error("wildcard Host * should not appear in entries map") + } +} + +func TestExpandSSHNodes_Integration(t *testing.T) { + cleanup := writeSSHConfig(t, ` +Host jump + HostName jump.corp.example.com + Port 2200 + User ops + IdentityFile ~/.ssh/jump_key +`) + defer cleanup() + + nodes := []string{ + "ssh://jump", + "http://proxy:8080", + "ssh://realhost:22", + } + got := expandSSHNodes(nodes) + + u0, _ := url.Parse(got[0]) + if u0.Hostname() != "jump.corp.example.com" { + t.Errorf("[0] hostname: got %q", u0.Hostname()) + } + if u0.Port() != "2200" { + t.Errorf("[0] port: got %q", u0.Port()) + } + if u0.User.Username() != "ops" { + t.Errorf("[0] user: got %q", u0.User.Username()) + } + if got[1] != "http://proxy:8080" { + t.Errorf("[1] non-SSH URL changed: %q", got[1]) + } + // ssh://realhost:22 — not in config, unchanged. + if got[2] != "ssh://realhost:22" { + t.Errorf("[2] unknown host changed: %q", got[2]) + } +} diff --git a/cmd/gost/version.go b/cmd/gost/version.go index b5a6251..2c633c8 100644 --- a/cmd/gost/version.go +++ b/cmd/gost/version.go @@ -1,5 +1,5 @@ package main var ( - version = "3.2.6" + version = "3.2.7" ) From 3f45651bf1fc5c0a88a935aa921297bc1c0daab8 Mon Sep 17 00:00:00 2001 From: CHEN Li Date: Sat, 14 Mar 2026 19:50:00 +0800 Subject: [PATCH 2/3] a windows service setup script that uses downloaded binary from github. --- install-service.ps1 | 267 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 install-service.ps1 diff --git a/install-service.ps1 b/install-service.ps1 new file mode 100644 index 0000000..e270de9 --- /dev/null +++ b/install-service.ps1 @@ -0,0 +1,267 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Installs gost as a Windows service. + +.DESCRIPTION + Downloads the latest (or specified) gost release from GitHub, installs it to + a target directory, and registers it as a Windows service using the native + Windows Service Control Manager. gost is built with go-svc and runs as a + proper Windows service without any wrapper. + +.PARAMETER Version + The release tag to install, e.g. "v3.2.6". Defaults to the latest release. + +.PARAMETER InstallDir + Directory where gost.exe and gost.yml are placed. + Default: C:\Program Files\gost + +.PARAMETER ConfigFile + Path to an existing gost config file to use. If omitted and no config + exists in InstallDir, a minimal placeholder is created. + +.PARAMETER ServiceName + Windows service name. Default: gost + +.PARAMETER DisplayName + Windows service display name. Default: GOST Tunnel + +.PARAMETER ExtraArgs + Additional arguments passed to gost.exe, e.g. "-L :8080 -D". + The -C flag pointing to the config file is always added automatically. + +.PARAMETER StartupType + Service start type: Automatic, Manual, or Disabled. Default: Automatic + +.PARAMETER Start + Start the service immediately after installation. + +.EXAMPLE + # Install latest release with defaults and start immediately + .\install-service.ps1 -Start + +.EXAMPLE + # Install a specific version with a custom config + .\install-service.ps1 -Version v3.2.6 -ConfigFile C:\etc\gost.yml -Start + +.EXAMPLE + # Install with inline service definition (no config file) + .\install-service.ps1 -ExtraArgs "-L socks5://:1080 -L http://:8080" -Start +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$Version = "", + [string]$InstallDir = "C:\Program Files\gost", + [string]$ConfigFile = "", + [string]$ServiceName = "gost", + [string]$DisplayName = "GOST Tunnel", + [string]$ExtraArgs = "", + [ValidateSet("Automatic","Manual","Disabled")] + [string]$StartupType = "Automatic", + [switch]$Start +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } +function Write-Ok([string]$msg) { Write-Host " OK $msg" -ForegroundColor Green } +function Write-Warn([string]$msg) { Write-Host " WARN $msg" -ForegroundColor Yellow } + +function Get-Architecture { + switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { return "amd64" } + "ARM64" { return "arm64" } + "x86" { return "386" } + default { + # Also check PROCESSOR_ARCHITEW6432 for WoW64 processes + if ($env:PROCESSOR_ARCHITEW6432 -eq "AMD64") { return "amd64" } + throw "Unsupported architecture: $($env:PROCESSOR_ARCHITECTURE)" + } + } +} + +function Get-LatestVersion { + Write-Step "Fetching latest gost release from GitHub..." + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/go-gost/gost/releases/latest" ` + -Headers @{ "User-Agent" = "gost-install-script" } + return $release.tag_name +} + +function Get-DownloadUrl([string]$tag, [string]$arch) { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/go-gost/gost/releases/tags/$tag" ` + -Headers @{ "User-Agent" = "gost-install-script" } + $pattern = "windows.*$arch" + $asset = $release.assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 + if (-not $asset) { + throw "No Windows/$arch asset found for release $tag. Available: $($release.assets.name -join ', ')" + } + return $asset.browser_download_url +} + +function Install-Binary([string]$url, [string]$destDir) { + $zipPath = Join-Path $env:TEMP "gost-install.zip" + Write-Step "Downloading $url ..." + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + + Write-Step "Extracting to $destDir ..." + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + foreach ($entry in $zip.Entries) { + if ($entry.Name -eq "gost.exe") { + $dest = Join-Path $destDir "gost.exe" + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) + Write-Ok "gost.exe extracted to $dest" + break + } + } + } finally { + $zip.Dispose() + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + } + + $exePath = Join-Path $destDir "gost.exe" + if (-not (Test-Path $exePath)) { + throw "gost.exe not found in the downloaded archive." + } + return $exePath +} + +function Ensure-Config([string]$installDir, [string]$userConfig) { + $dest = Join-Path $installDir "gost.yml" + + if ($userConfig -ne "") { + if (-not (Test-Path $userConfig)) { + throw "Config file not found: $userConfig" + } + if ((Resolve-Path $userConfig).Path -ne (Resolve-Path $dest -ErrorAction SilentlyContinue)?.Path) { + Copy-Item $userConfig $dest -Force + Write-Ok "Config copied from $userConfig" + } + return $dest + } + + if (Test-Path $dest) { + Write-Ok "Using existing config: $dest" + return $dest + } + + # Create a minimal placeholder config + $placeholder = @" +# gost configuration file +# Documentation: https://gost.run/ +# +# Example: HTTP proxy on port 8080 +# services: +# - name: http-proxy +# addr: ":8080" +# handler: +# type: http +# listener: +# type: tcp + +log: + level: info + format: json +"@ + Set-Content -Path $dest -Value $placeholder -Encoding UTF8 + Write-Warn "A placeholder config was created at $dest" + Write-Warn "Edit it before starting the service, or pass -ExtraArgs with -L/-F flags." + return $dest +} + +function Register-GostService([string]$exePath, [string]$cfgPath, [string]$extraArgs) { + $binPath = "`"$exePath`" -C `"$cfgPath`"" + if ($extraArgs -ne "") { + $binPath += " $extraArgs" + } + + $existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + + if ($existing) { + Write-Step "Service '$ServiceName' already exists — updating..." + if ($existing.Status -eq "Running") { + Write-Step "Stopping existing service..." + Stop-Service -Name $ServiceName -Force + $existing.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(30)) + } + # Update the binary path + sc.exe config $ServiceName binPath= $binPath | Out-Null + sc.exe config $ServiceName start= $(if ($StartupType -eq "Automatic") { "auto" } elseif ($StartupType -eq "Manual") { "demand" } else { "disabled" }) | Out-Null + Write-Ok "Service updated." + } else { + Write-Step "Registering service '$ServiceName'..." + $startValue = switch ($StartupType) { + "Automatic" { "auto" } + "Manual" { "demand" } + "Disabled" { "disabled" } + } + sc.exe create $ServiceName ` + binPath= $binPath ` + DisplayName= $DisplayName ` + start= $startValue | Out-Null + + # Configure failure recovery: restart on first/second failure, reset after 1 day + sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000 | Out-Null + Write-Ok "Service registered." + } + + # Set description + sc.exe description $ServiceName "GOST (GO Simple Tunnel) - secure tunnel service" | Out-Null +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +$arch = Get-Architecture +$version = if ($Version -ne "") { $Version } else { Get-LatestVersion } + +Write-Host "" +Write-Host " gost Windows Service Installer" -ForegroundColor White +Write-Host " ================================" -ForegroundColor White +Write-Host " Version : $version" +Write-Host " Arch : $version / windows-$arch" +Write-Host " Install dir: $InstallDir" +Write-Host " Service : $ServiceName ($StartupType)" +Write-Host "" + +# 1. Download & install binary +$url = Get-DownloadUrl $version $arch +$exePath = Install-Binary $url $InstallDir + +# 2. Ensure config exists +$cfgPath = Ensure-Config $InstallDir $ConfigFile + +# 3. Register Windows service +Register-GostService $exePath $cfgPath $ExtraArgs + +# 4. Optionally start +if ($Start) { + Write-Step "Starting service '$ServiceName'..." + Start-Service -Name $ServiceName + $svc = Get-Service -Name $ServiceName + $svc.WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) + Write-Ok "Service is running." +} + +Write-Host "" +Write-Host " Done!" -ForegroundColor Green +Write-Host "" +Write-Host " Useful commands:" -ForegroundColor White +Write-Host " Start : Start-Service $ServiceName" +Write-Host " Stop : Stop-Service $ServiceName" +Write-Host " Status : Get-Service $ServiceName" +Write-Host " Logs : Get-EventLog -LogName Application -Source $ServiceName -Newest 20" +Write-Host " Uninstall: sc.exe delete $ServiceName" +Write-Host "" From 3f82cdb2f2cef0c84521ea725da5ddaec8c4d64f Mon Sep 17 00:00:00 2001 From: CHEN Li Date: Sat, 14 Mar 2026 19:52:12 +0800 Subject: [PATCH 3/3] using binary from local build output. --- install-service.ps1 | 157 ++++++++++++++++++-------------------------- 1 file changed, 64 insertions(+), 93 deletions(-) diff --git a/install-service.ps1 b/install-service.ps1 index e270de9..b1bd833 100644 --- a/install-service.ps1 +++ b/install-service.ps1 @@ -1,16 +1,13 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - Installs gost as a Windows service. + Builds gost from source and installs it as a Windows service. .DESCRIPTION - Downloads the latest (or specified) gost release from GitHub, installs it to - a target directory, and registers it as a Windows service using the native - Windows Service Control Manager. gost is built with go-svc and runs as a - proper Windows service without any wrapper. - -.PARAMETER Version - The release tag to install, e.g. "v3.2.6". Defaults to the latest release. + Runs "go build" against the local source tree, copies the resulting binary + to a target directory, and registers it as a Windows service using the + native Windows Service Control Manager. gost is built with go-svc and + runs as a proper Windows service without any wrapper. .PARAMETER InstallDir Directory where gost.exe and gost.yml are placed. @@ -37,12 +34,12 @@ Start the service immediately after installation. .EXAMPLE - # Install latest release with defaults and start immediately + # Build, install with defaults, and start immediately .\install-service.ps1 -Start .EXAMPLE - # Install a specific version with a custom config - .\install-service.ps1 -Version v3.2.6 -ConfigFile C:\etc\gost.yml -Start + # Install with a custom config + .\install-service.ps1 -ConfigFile C:\etc\gost.yml -Start .EXAMPLE # Install with inline service definition (no config file) @@ -51,20 +48,22 @@ [CmdletBinding(SupportsShouldProcess)] param( - [string]$Version = "", - [string]$InstallDir = "C:\Program Files\gost", - [string]$ConfigFile = "", - [string]$ServiceName = "gost", - [string]$DisplayName = "GOST Tunnel", - [string]$ExtraArgs = "", + [string]$InstallDir = "C:\Program Files\gost", + [string]$ConfigFile = "", + [string]$ServiceName = "gost", + [string]$DisplayName = "GOST Tunnel", + [string]$ExtraArgs = "", [ValidateSet("Automatic","Manual","Disabled")] - [string]$StartupType = "Automatic", + [string]$StartupType = "Automatic", [switch]$Start ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +# Directory containing this script == repo root +$RepoRoot = $PSScriptRoot + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -73,68 +72,42 @@ function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cya function Write-Ok([string]$msg) { Write-Host " OK $msg" -ForegroundColor Green } function Write-Warn([string]$msg) { Write-Host " WARN $msg" -ForegroundColor Yellow } -function Get-Architecture { - switch ($env:PROCESSOR_ARCHITECTURE) { - "AMD64" { return "amd64" } - "ARM64" { return "arm64" } - "x86" { return "386" } - default { - # Also check PROCESSOR_ARCHITEW6432 for WoW64 processes - if ($env:PROCESSOR_ARCHITEW6432 -eq "AMD64") { return "amd64" } - throw "Unsupported architecture: $($env:PROCESSOR_ARCHITECTURE)" - } +function Build-Binary([string]$destDir) { + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "go not found in PATH. Please install Go from https://go.dev/dl/" } -} -function Get-LatestVersion { - Write-Step "Fetching latest gost release from GitHub..." - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/go-gost/gost/releases/latest" ` - -Headers @{ "User-Agent" = "gost-install-script" } - return $release.tag_name -} + Write-Step "Building gost from source ($RepoRoot)..." -function Get-DownloadUrl([string]$tag, [string]$arch) { - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/go-gost/gost/releases/tags/$tag" ` - -Headers @{ "User-Agent" = "gost-install-script" } - $pattern = "windows.*$arch" - $asset = $release.assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 - if (-not $asset) { - throw "No Windows/$arch asset found for release $tag. Available: $($release.assets.name -join ', ')" - } - return $asset.browser_download_url -} - -function Install-Binary([string]$url, [string]$destDir) { - $zipPath = Join-Path $env:TEMP "gost-install.zip" - Write-Step "Downloading $url ..." - Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing - - Write-Step "Extracting to $destDir ..." if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } - Add-Type -AssemblyName System.IO.Compression.FileSystem - $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) - try { - foreach ($entry in $zip.Entries) { - if ($entry.Name -eq "gost.exe") { - $dest = Join-Path $destDir "gost.exe" - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) - Write-Ok "gost.exe extracted to $dest" - break - } - } - } finally { - $zip.Dispose() - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - } + $exeDest = Join-Path $destDir "gost.exe" - $exePath = Join-Path $destDir "gost.exe" - if (-not (Test-Path $exePath)) { - throw "gost.exe not found in the downloaded archive." + # Embed version: prefer git tag, fall back to version.go + $ldflags = "-s -w" + $version = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + $version = git -C $RepoRoot describe --tags --abbrev=0 2>$null } - return $exePath + if (-not $version) { + $verFile = Join-Path $RepoRoot "cmd\gost\version.go" + if (Test-Path $verFile) { + $match = Select-String -Path $verFile -Pattern 'version\s*=\s*"([^"]+)"' + if ($match) { $version = $match.Matches[0].Groups[1].Value } + } + } + if ($version) { $ldflags = "-s -w -X 'main.version=$version'" } + + $goArgs = @("build", "-ldflags", $ldflags, "-o", $exeDest, "./cmd/gost") + Write-Host " go $($goArgs -join ' ')" -ForegroundColor DarkGray + + & go @goArgs 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + if ($LASTEXITCODE -ne 0) { throw "go build failed (exit $LASTEXITCODE)" } + + Write-Ok "Built: $exeDest" + return $exeDest } function Ensure-Config([string]$installDir, [string]$userConfig) { @@ -144,7 +117,9 @@ function Ensure-Config([string]$installDir, [string]$userConfig) { if (-not (Test-Path $userConfig)) { throw "Config file not found: $userConfig" } - if ((Resolve-Path $userConfig).Path -ne (Resolve-Path $dest -ErrorAction SilentlyContinue)?.Path) { + $resolvedSrc = (Resolve-Path $userConfig).Path + $resolvedDest = if (Test-Path $dest) { (Resolve-Path $dest).Path } else { "" } + if ($resolvedSrc -ne $resolvedDest) { Copy-Item $userConfig $dest -Force Write-Ok "Config copied from $userConfig" } @@ -182,9 +157,7 @@ log: function Register-GostService([string]$exePath, [string]$cfgPath, [string]$extraArgs) { $binPath = "`"$exePath`" -C `"$cfgPath`"" - if ($extraArgs -ne "") { - $binPath += " $extraArgs" - } + if ($extraArgs -ne "") { $binPath += " $extraArgs" } $existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue @@ -195,9 +168,13 @@ function Register-GostService([string]$exePath, [string]$cfgPath, [string]$extra Stop-Service -Name $ServiceName -Force $existing.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(30)) } - # Update the binary path sc.exe config $ServiceName binPath= $binPath | Out-Null - sc.exe config $ServiceName start= $(if ($StartupType -eq "Automatic") { "auto" } elseif ($StartupType -eq "Manual") { "demand" } else { "disabled" }) | Out-Null + $startValue = switch ($StartupType) { + "Automatic" { "auto" } + "Manual" { "demand" } + "Disabled" { "disabled" } + } + sc.exe config $ServiceName start= $startValue | Out-Null Write-Ok "Service updated." } else { Write-Step "Registering service '$ServiceName'..." @@ -211,12 +188,11 @@ function Register-GostService([string]$exePath, [string]$cfgPath, [string]$extra DisplayName= $DisplayName ` start= $startValue | Out-Null - # Configure failure recovery: restart on first/second failure, reset after 1 day + # Restart on failure: 5 s / 10 s / 30 s, reset counter after 1 day sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000 | Out-Null Write-Ok "Service registered." } - # Set description sc.exe description $ServiceName "GOST (GO Simple Tunnel) - secure tunnel service" | Out-Null } @@ -224,21 +200,16 @@ function Register-GostService([string]$exePath, [string]$cfgPath, [string]$extra # Main # --------------------------------------------------------------------------- -$arch = Get-Architecture -$version = if ($Version -ne "") { $Version } else { Get-LatestVersion } - Write-Host "" -Write-Host " gost Windows Service Installer" -ForegroundColor White -Write-Host " ================================" -ForegroundColor White -Write-Host " Version : $version" -Write-Host " Arch : $version / windows-$arch" +Write-Host " gost Windows Service Installer (build from source)" -ForegroundColor White +Write-Host " ===================================================" -ForegroundColor White +Write-Host " Repo : $RepoRoot" Write-Host " Install dir: $InstallDir" Write-Host " Service : $ServiceName ($StartupType)" Write-Host "" -# 1. Download & install binary -$url = Get-DownloadUrl $version $arch -$exePath = Install-Binary $url $InstallDir +# 1. Build binary from source +$exePath = Build-Binary $InstallDir # 2. Ensure config exists $cfgPath = Ensure-Config $InstallDir $ConfigFile @@ -259,9 +230,9 @@ Write-Host "" Write-Host " Done!" -ForegroundColor Green Write-Host "" Write-Host " Useful commands:" -ForegroundColor White -Write-Host " Start : Start-Service $ServiceName" -Write-Host " Stop : Stop-Service $ServiceName" -Write-Host " Status : Get-Service $ServiceName" -Write-Host " Logs : Get-EventLog -LogName Application -Source $ServiceName -Newest 20" +Write-Host " Start : Start-Service $ServiceName" +Write-Host " Stop : Stop-Service $ServiceName" +Write-Host " Status : Get-Service $ServiceName" +Write-Host " Logs : Get-EventLog -LogName Application -Source $ServiceName -Newest 20" Write-Host " Uninstall: sc.exe delete $ServiceName" Write-Host ""