pull/849/merge
Li Chen 2026-03-14 11:52:21 +00:00 committed by GitHub
commit 365b3bd820
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 659 additions and 2 deletions

View File

@ -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,

View File

@ -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
}

View File

@ -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])
}
}

View File

@ -1,5 +1,5 @@
package main
var (
version = "3.2.6"
version = "3.2.7"
)

238
install-service.ps1 100644
View File

@ -0,0 +1,238 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Builds gost from source and installs it as a Windows service.
.DESCRIPTION
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.
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
# Build, install with defaults, and start immediately
.\install-service.ps1 -Start
.EXAMPLE
# Install with a custom config
.\install-service.ps1 -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]$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"
# Directory containing this script == repo root
$RepoRoot = $PSScriptRoot
# ---------------------------------------------------------------------------
# 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 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/"
}
Write-Step "Building gost from source ($RepoRoot)..."
if (-not (Test-Path $destDir)) {
New-Item -ItemType Directory -Path $destDir -Force | Out-Null
}
$exeDest = Join-Path $destDir "gost.exe"
# 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
}
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) {
$dest = Join-Path $installDir "gost.yml"
if ($userConfig -ne "") {
if (-not (Test-Path $userConfig)) {
throw "Config file not found: $userConfig"
}
$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"
}
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))
}
sc.exe config $ServiceName binPath= $binPath | 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'..."
$startValue = switch ($StartupType) {
"Automatic" { "auto" }
"Manual" { "demand" }
"Disabled" { "disabled" }
}
sc.exe create $ServiceName `
binPath= $binPath `
DisplayName= $DisplayName `
start= $startValue | Out-Null
# 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."
}
sc.exe description $ServiceName "GOST (GO Simple Tunnel) - secure tunnel service" | Out-Null
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
Write-Host ""
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. Build binary from source
$exePath = Build-Binary $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 ""