Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ https://github.com/user-attachments/assets/11dc8d14-59f1-4bdd-9503-b70f8a0d2db1
- [Session spawn errors (0xc0000142)](#session-spawn-errors-0xc0000142)
- [Server started with `--insecure` still has `Failed to handshake`](#server-started-with---insecure-still-has-failed-to-handshake)
- [Foreground vs Background](#foreground-vs-background)
- [Auto-Proxy Detection (Windows)](#auto-proxy-detection-windows)
- [Donations, Support, or Giving Back](#donations-support-or-giving-back)

## TL;DR
Expand Down Expand Up @@ -221,6 +222,7 @@ This requires the web server component has been enabled.
--ntlm-proxy-creds Set NTLM proxy credentials in format DOMAIN\\USER:PASS
--owners Set owners of client, if unset client is public all users. E.g --owners jsmith,ldavidson
--proxy Set connect proxy address to bake it
--auto-proxy Instruct client to auto-detect proxy from system settings (Windows: HKCU WinINET)
--raw-download Download over raw TCP, outputs bash downloader rather than http
--shared-object Generate shared object file
--sni When TLS is in use, set a custom SNI for the client to connect with
Expand Down Expand Up @@ -494,6 +496,10 @@ You can also generate clients with `link --fingerprint <fingerprint here>` to sp

By default, clients will run in the background then the parent process will exit, the child process will be given the parent processes stdout/stderr so you will be able to see output. If you need to debug your client, use the `--foreground` flag.

## Auto-Proxy Detection (Windows)

Standalone clients support `--auto-proxy` to auto-detect proxy settings from the Windows registry (HKCU WinINET `ProxyServer`). This can also be baked in at build time via `link --auto-proxy`. When multiple WinINET entries are configured, the client prefers the entry matching the target transport (`http`/`ws` use the HTTP proxy first; `https`/`tls`/`wss` use the Secure/HTTPS proxy first; raw SSH uses generic entries first) and then falls back through the remaining detected proxies before standard `*_PROXY` environment variables. If WinINET proxying is disabled (`ProxyEnable=0`) but `ProxyServer` still contains entries, those entries are not used as the primary proxy; they are only tried as fallbacks after the direct or explicitly configured connection fails. Only manual proxy settings are supported (no PAC/WPAD).

# Donations, Support, or Giving Back

The easiest way to give back to the RSSH project is by finding bugs, opening feature requests and word-of-mouth advertising it to people you think will find it useful!
Expand Down
7 changes: 7 additions & 0 deletions cmd/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ var (
customSNI string
// golang can only embed strings using the compile time linker
useHostKerberos string
autoProxy string
logLevel string

ntlmProxyCreds string
Expand All @@ -61,6 +62,7 @@ func printHelp() {
fmt.Println("\t\t--fingerprint\tServer public key SHA256 hex fingerprint for auth")
fmt.Println("\t\t--fingerprint-file\tRead server public key SHA256 hex fingerprint from file path")
fmt.Println("\t\t--proxy\tLocation of HTTP connect proxy to use")
fmt.Println("\t\t--auto-proxy\tAuto-detect proxy from system settings (Windows: HKCU WinINET)")
fmt.Println("\t\t--ntlm-proxy-creds\tNTLM proxy credentials in format DOMAIN\\USER:PASS")
fmt.Println("\t\t--process_name\tProcess name shown in tasklist/process list")
fmt.Println("\t\t--sni\tWhen using TLS set the clients requested SNI to this value")
Expand All @@ -81,6 +83,7 @@ func makeInitialSettings() (*client.Settings, error) {
ProxyAddr: proxy,
Addr: destination,
ProxyUseHostKerberos: useHostKerberos == "true",
ProxyAutoDetect: autoProxy == "true",
SNI: customSNI,
VersionString: versionString,
}
Expand Down Expand Up @@ -130,6 +133,10 @@ func main() {
settings.ProxyAddr = proxyaddress
}

if line.IsSet("auto-proxy") {
settings.ProxyAutoDetect = true
}

userSpecifiedFingerprint, err := line.GetArgString("fingerprint")
if err == nil {
settings.Fingerprint = userSpecifiedFingerprint
Expand Down
38 changes: 32 additions & 6 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ type Settings struct {
SNI string

ProxyUseHostKerberos bool
ProxyAutoDetect bool

VersionString string

Expand Down Expand Up @@ -344,6 +345,33 @@ func Run(settings *Settings) {
l := logger.NewLog("client")

var err error
realAddr, scheme := determineConnectionType(settings.Addr)

var autoDetectedProxies []string
if settings.ProxyAutoDetect {
proxyConfig, derr := detectSystemProxyConfig()
selection := selectAutoDetectedProxies(proxyConfig, scheme, settings.ProxyAddr)
switch {
case derr != nil:
log.Printf("auto-proxy: detection failed: %v", derr)
case len(selection.orderedProxies) == 0:
log.Println("auto-proxy: no system proxy configured")
case proxyConfig.enabled && settings.ProxyAddr == "":
settings.ProxyAddr = selection.proxyAddr
autoDetectedProxies = selection.fallbackProxies
log.Printf("auto-proxy: using detected proxy %s for %s transport", settings.ProxyAddr, scheme)
case proxyConfig.enabled:
autoDetectedProxies = selection.fallbackProxies
log.Printf("auto-proxy: detected %v for %s transport; --proxy %s takes precedence, detected proxies will be used as fallback", autoDetectedProxies, scheme, settings.ProxyAddr)
case settings.ProxyAddr == "":
autoDetectedProxies = selection.fallbackProxies
log.Printf("auto-proxy: WinINET proxy is disabled; detected %v for %s transport will be used as fallback after direct connection fails", autoDetectedProxies, scheme)
default:
autoDetectedProxies = selection.fallbackProxies
log.Printf("auto-proxy: WinINET proxy is disabled; detected %v for %s transport will be used as fallback after --proxy %s", autoDetectedProxies, scheme, settings.ProxyAddr)
}
}

settings.ProxyAddr, err = GetProxyDetails(settings.ProxyAddr)
if err != nil {
log.Fatal("Invalid proxy details", settings.ProxyAddr, ":", err)
Expand Down Expand Up @@ -389,10 +417,8 @@ func Run(settings *Settings) {
config.ClientVersion = "SSH-" + settings.VersionString
}

realAddr, scheme := determineConnectionType(settings.Addr)

// fetch the environment variables, but the first proxy is done from the supplied proxyAddr arg
potentialProxies := getCaseInsensitiveEnv("http_proxy", "https_proxy")
// fetch fallback proxies, but the first proxy is done from the supplied proxyAddr arg
potentialProxies := dedupeProxyFallbacks(settings.ProxyAddr, append(autoDetectedProxies, getCaseInsensitiveEnv("http_proxy", "https_proxy")...))
triedProxyIndex := 0
initialProxyAddr := settings.ProxyAddr
for {
Expand All @@ -412,15 +438,15 @@ func Run(settings *Settings) {

if len(potentialProxies) > 0 {
if len(potentialProxies) <= triedProxyIndex {
log.Printf("Unable to connect via proxies (from env), retrying with proxy as %q: %v", potentialProxies, initialProxyAddr)
log.Printf("Unable to connect via fallback proxies, retrying with proxy as %q: %v", initialProxyAddr, err)
triedProxyIndex = 0
settings.ProxyAddr = initialProxyAddr
continue
}
proxy := potentialProxies[triedProxyIndex]
triedProxyIndex++

log.Println("Trying to proxy via env variable (", proxy, ")")
log.Println("Trying fallback proxy (", proxy, ")")

settings.ProxyAddr, err = GetProxyDetails(proxy)
if err != nil {
Expand Down
191 changes: 191 additions & 0 deletions internal/client/proxy_autodetect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package client

import (
"strings"
"unicode"
)

type proxyCandidate struct {
forScheme string
proxyURL string
}

type systemProxyConfig struct {
enabled bool
candidates []proxyCandidate
}

type autoProxySelection struct {
proxyAddr string
fallbackProxies []string
orderedProxies []string
}

// parseWinINETProxyString parses the value of the WinINET ProxyServer
// registry value. Common formats:
//
// "host:port" — single proxy for all schemes
// "http=h:p;https=h:p;ftp=h:p;socks=h:p" — per-scheme list
// "http=http://h:p https=socks5://s:p" — explicit proxy schemes
//
// Entries can be separated by semicolons or whitespace. ftp= entries are
// ignored because the client never makes FTP requests. If a per-scheme entry
// omits an explicit proxy URL scheme, Windows' HTTP/Secure fields are treated
// as HTTP proxy endpoints, while the SOCKS field is treated as SOCKS5.
func parseWinINETProxyString(raw string) []proxyCandidate {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}

var candidates []proxyCandidate
for _, part := range splitWinINETProxyList(raw) {
forScheme, proxySpec := splitWinINETProxyEntry(part)
proxyURL := normalizeWinINETProxySpec(forScheme, proxySpec)
if proxyURL == "" {
continue
}

if forScheme == "ftp" {
continue
}

candidates = append(candidates, proxyCandidate{
forScheme: forScheme,
proxyURL: proxyURL,
})
}

return candidates
}

func splitWinINETProxyList(raw string) []string {
return strings.FieldsFunc(raw, func(r rune) bool {
return r == ';' || unicode.IsSpace(r)
})
}

func splitWinINETProxyEntry(entry string) (forScheme, proxySpec string) {
kv := strings.SplitN(strings.TrimSpace(entry), "=", 2)
if len(kv) != 2 {
return "", strings.TrimSpace(entry)
}

forScheme = strings.ToLower(strings.TrimSpace(kv[0]))
if forScheme == "secure" {
forScheme = "https"
}

return forScheme, strings.TrimSpace(kv[1])
}

func normalizeWinINETProxySpec(forScheme, proxySpec string) string {
proxySpec = strings.TrimSpace(proxySpec)
if proxySpec == "" {
return ""
}

lowerSpec := strings.ToLower(proxySpec)
if lowerSpec == "direct" || lowerSpec == "direct://" {
return ""
}

if scheme, ok := explicitProxyScheme(proxySpec); ok {
switch scheme {
case "http", "https", "socks", "socks5":
return proxySpec
default:
return ""
}
}

if forScheme == "socks" {
return "socks5://" + proxySpec
}

return "http://" + proxySpec
}

func explicitProxyScheme(proxySpec string) (string, bool) {
index := strings.Index(proxySpec, "://")
if index <= 0 {
return "", false
}

return strings.ToLower(proxySpec[:index]), true
}

func orderProxyCandidatesForTransport(candidates []proxyCandidate, transport string) []string {
var preference []string

switch transport {
case "http", "ws":
preference = []string{"http", "", "https", "socks"}
case "https", "tls", "wss":
preference = []string{"https", "", "http", "socks"}
default:
preference = []string{"", "https", "http", "socks"}
}

var ordered []string
seen := map[string]bool{}

for _, scheme := range preference {
for _, candidate := range candidates {
if candidate.forScheme != scheme || seen[candidate.proxyURL] {
continue
}

ordered = append(ordered, candidate.proxyURL)
seen[candidate.proxyURL] = true
}
}

return ordered
}

func selectAutoDetectedProxies(config systemProxyConfig, transport, configuredProxy string) autoProxySelection {
ordered := orderProxyCandidatesForTransport(config.candidates, transport)
selection := autoProxySelection{
proxyAddr: configuredProxy,
orderedProxies: ordered,
}

if config.enabled && configuredProxy == "" && len(ordered) > 0 {
selection.proxyAddr = ordered[0]
selection.fallbackProxies = ordered[1:]
return selection
}

selection.fallbackProxies = ordered
return selection
}

func dedupeProxyFallbacks(primary string, fallbacks []string) []string {
seen := map[string]bool{}
if key := proxyDedupeKey(primary); key != "" {
seen[key] = true
}

var deduped []string
for _, fallback := range fallbacks {
key := proxyDedupeKey(fallback)
if key == "" || seen[key] {
continue
}

deduped = append(deduped, fallback)
seen[key] = true
}

return deduped
}

func proxyDedupeKey(proxy string) string {
normalized, err := GetProxyDetails(proxy)
if err == nil {
return normalized
}

return strings.TrimSpace(proxy)
}
14 changes: 14 additions & 0 deletions internal/client/proxy_autodetect_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !windows

package client

// DetectSystemProxy is a no-op on non-Windows platforms. WinINET-style proxy
// auto-detection is not available; users on other platforms should rely on
// the standard *_PROXY environment variables, which Run() already consults.
func DetectSystemProxy() (string, error) {
return "", nil
}

func detectSystemProxyConfig() (systemProxyConfig, error) {
return systemProxyConfig{}, nil
}
Loading