diff --git a/README.md b/README.md index fc26266..10dc38c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -494,6 +496,10 @@ You can also generate clients with `link --fingerprint ` 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! diff --git a/cmd/client/main.go b/cmd/client/main.go index 4f5fbd4..aa208a2 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -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 @@ -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") @@ -81,6 +83,7 @@ func makeInitialSettings() (*client.Settings, error) { ProxyAddr: proxy, Addr: destination, ProxyUseHostKerberos: useHostKerberos == "true", + ProxyAutoDetect: autoProxy == "true", SNI: customSNI, VersionString: versionString, } @@ -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 diff --git a/internal/client/client.go b/internal/client/client.go index 641404b..33f8e2c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -307,6 +307,7 @@ type Settings struct { SNI string ProxyUseHostKerberos bool + ProxyAutoDetect bool VersionString string @@ -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) @@ -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 { @@ -412,7 +438,7 @@ 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 @@ -420,7 +446,7 @@ func Run(settings *Settings) { 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 { diff --git a/internal/client/proxy_autodetect.go b/internal/client/proxy_autodetect.go new file mode 100644 index 0000000..955bd1c --- /dev/null +++ b/internal/client/proxy_autodetect.go @@ -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) +} diff --git a/internal/client/proxy_autodetect_other.go b/internal/client/proxy_autodetect_other.go new file mode 100644 index 0000000..120f5b5 --- /dev/null +++ b/internal/client/proxy_autodetect_other.go @@ -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 +} diff --git a/internal/client/proxy_autodetect_test.go b/internal/client/proxy_autodetect_test.go new file mode 100644 index 0000000..d176003 --- /dev/null +++ b/internal/client/proxy_autodetect_test.go @@ -0,0 +1,237 @@ +package client + +import ( + "reflect" + "testing" +) + +func TestParseWinINETProxyString(t *testing.T) { + cases := []struct { + name string + in string + wantURLs []string + wantFor []string + }{ + {"empty", "", nil, nil}, + {"whitespace only", " ", nil, nil}, + {"single host:port", "127.0.0.1:8080", []string{"http://127.0.0.1:8080"}, []string{""}}, + {"single with whitespace", " proxy.local:3128 ", []string{"http://proxy.local:3128"}, []string{""}}, + {"per-scheme http and https", "http=10.0.0.1:8080;https=10.0.0.1:8443", []string{"http://10.0.0.1:8080", "http://10.0.0.1:8443"}, []string{"http", "https"}}, + {"per-scheme socks", "socks=10.0.0.1:1080", []string{"socks5://10.0.0.1:1080"}, []string{"socks"}}, + {"per-scheme socks fallback", "ftp=10.0.0.1:21;socks=10.0.0.1:1080", []string{"socks5://10.0.0.1:1080"}, []string{"socks"}}, + {"per-scheme only ftp ignored", "ftp=10.0.0.1:21", nil, nil}, + {"per-scheme malformed segment", "http=;https=10.0.0.1:8443", []string{"http://10.0.0.1:8443"}, []string{"https"}}, + {"per-scheme uppercase keys", "HTTP=10.0.0.1:8080", []string{"http://10.0.0.1:8080"}, []string{"http"}}, + {"secure alias", "secure=10.0.0.1:8443", []string{"http://10.0.0.1:8443"}, []string{"https"}}, + {"per-scheme stray semicolons", ";;http=10.0.0.1:8080;;", []string{"http://10.0.0.1:8080"}, []string{"http"}}, + {"explicit http url is preserved", "http=http://proxy.local:8080", []string{"http://proxy.local:8080"}, []string{"http"}}, + {"explicit https proxy url is preserved", "https=https://secure-proxy.local:8443", []string{"https://secure-proxy.local:8443"}, []string{"https"}}, + {"explicit socks5 url is preserved", "socks=socks5://socks.local:1080", []string{"socks5://socks.local:1080"}, []string{"socks"}}, + {"whitespace separated entries", "http=10.0.0.1:8080 https=10.0.0.2:8443", []string{"http://10.0.0.1:8080", "http://10.0.0.2:8443"}, []string{"http", "https"}}, + {"ordered generic fallback list", "proxy1.local:8080;proxy2.local:8080", []string{"http://proxy1.local:8080", "http://proxy2.local:8080"}, []string{"", ""}}, + {"duplicate scheme entries are preserved", "http=proxy1.local:8080;http=proxy2.local:8080", []string{"http://proxy1.local:8080", "http://proxy2.local:8080"}, []string{"http", "http"}}, + {"direct entries ignored", "DIRECT;http=proxy.local:8080", []string{"http://proxy.local:8080"}, []string{"http"}}, + {"unsupported explicit socks4 ignored", "socks=socks4://socks.local:1080;http=proxy.local:8080", []string{"http://proxy.local:8080"}, []string{"http"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseWinINETProxyString(tc.in) + if gotURLs := candidateURLs(got); !reflect.DeepEqual(gotURLs, tc.wantURLs) { + t.Errorf("urls = %#v, want %#v", gotURLs, tc.wantURLs) + } + if gotFor := candidateSchemes(got); !reflect.DeepEqual(gotFor, tc.wantFor) { + t.Errorf("schemes = %#v, want %#v", gotFor, tc.wantFor) + } + }) + } +} + +func TestOrderProxyCandidatesForTransport(t *testing.T) { + candidates := parseWinINETProxyString("generic.local:8080;http=http.local:8080;https=https.local:8443;socks=socks.local:1080") + + cases := []struct { + name string + transport string + want []string + }{ + { + name: "http transport prefers http entry", + transport: "http", + want: []string{"http://http.local:8080", "http://generic.local:8080", "http://https.local:8443", "socks5://socks.local:1080"}, + }, + { + name: "ws transport prefers http entry", + transport: "ws", + want: []string{"http://http.local:8080", "http://generic.local:8080", "http://https.local:8443", "socks5://socks.local:1080"}, + }, + { + name: "https transport prefers secure entry", + transport: "https", + want: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + }, + { + name: "wss transport prefers secure entry", + transport: "wss", + want: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + }, + { + name: "raw ssh transport prefers generic entry", + transport: "ssh", + want: []string{"http://generic.local:8080", "http://https.local:8443", "http://http.local:8080", "socks5://socks.local:1080"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := orderProxyCandidatesForTransport(candidates, tc.transport) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("orderProxyCandidatesForTransport(..., %q) = %#v, want %#v", tc.transport, got, tc.want) + } + }) + } +} + +func TestOrderProxyCandidatesDeduplicatesURLs(t *testing.T) { + candidates := parseWinINETProxyString("http=proxy.local:8080;https=proxy.local:8080;socks=socks.local:1080") + got := orderProxyCandidatesForTransport(candidates, "https") + want := []string{"http://proxy.local:8080", "socks5://socks.local:1080"} + if !reflect.DeepEqual(got, want) { + t.Errorf("orderProxyCandidatesForTransport dedupe = %#v, want %#v", got, want) + } +} + +func TestSelectAutoDetectedProxies(t *testing.T) { + candidates := parseWinINETProxyString("generic.local:8080;http=http.local:8080;https=https.local:8443;socks=socks.local:1080") + + cases := []struct { + name string + config systemProxyConfig + configured string + wantProxy string + wantFallbacks []string + wantOrderedAll []string + }{ + { + name: "enabled system proxy becomes primary when no proxy is configured", + config: systemProxyConfig{ + enabled: true, + candidates: candidates, + }, + wantProxy: "http://https.local:8443", + wantFallbacks: []string{"http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + wantOrderedAll: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + }, + { + name: "disabled system proxy remains fallback after direct connection", + config: systemProxyConfig{ + enabled: false, + candidates: candidates, + }, + wantProxy: "", + wantFallbacks: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + wantOrderedAll: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + }, + { + name: "explicit proxy wins over enabled system proxy", + config: systemProxyConfig{ + enabled: true, + candidates: candidates, + }, + configured: "manual.local:8080", + wantProxy: "manual.local:8080", + wantFallbacks: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + wantOrderedAll: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + }, + { + name: "disabled system proxy remains fallback after explicit proxy", + config: systemProxyConfig{ + enabled: false, + candidates: candidates, + }, + configured: "manual.local:8080", + wantProxy: "manual.local:8080", + wantFallbacks: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + wantOrderedAll: []string{"http://https.local:8443", "http://generic.local:8080", "http://http.local:8080", "socks5://socks.local:1080"}, + }, + { + name: "no candidates keeps direct connection without fallbacks", + config: systemProxyConfig{ + enabled: true, + }, + wantProxy: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := selectAutoDetectedProxies(tc.config, "https", tc.configured) + if got.proxyAddr != tc.wantProxy { + t.Errorf("proxyAddr = %q, want %q", got.proxyAddr, tc.wantProxy) + } + if !reflect.DeepEqual(got.fallbackProxies, tc.wantFallbacks) { + t.Errorf("fallbackProxies = %#v, want %#v", got.fallbackProxies, tc.wantFallbacks) + } + if !reflect.DeepEqual(got.orderedProxies, tc.wantOrderedAll) { + t.Errorf("orderedProxies = %#v, want %#v", got.orderedProxies, tc.wantOrderedAll) + } + }) + } +} + +func TestDedupeProxyFallbacks(t *testing.T) { + cases := []struct { + name string + primary string + fallbacks []string + want []string + }{ + { + name: "drops configured proxy duplicate after normalization", + primary: "http://proxy.local:8080", + fallbacks: []string{"proxy.local:8080", "http://other.local:8080"}, + want: []string{"http://other.local:8080"}, + }, + { + name: "drops duplicate default port forms", + primary: "http://proxy.local", + fallbacks: []string{"proxy.local:80", "http://other.local"}, + want: []string{"http://other.local"}, + }, + { + name: "deduplicates fallback list without primary proxy", + fallbacks: []string{"proxy.local:8080", "http://proxy.local:8080", "socks5://socks.local:1080"}, + want: []string{"proxy.local:8080", "socks5://socks.local:1080"}, + }, + { + name: "keeps invalid proxies once so caller can log parse error", + fallbacks: []string{"://bad", "://bad", "http://proxy.local:8080"}, + want: []string{"://bad", "http://proxy.local:8080"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := dedupeProxyFallbacks(tc.primary, tc.fallbacks) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("dedupeProxyFallbacks(%q, %#v) = %#v, want %#v", tc.primary, tc.fallbacks, got, tc.want) + } + }) + } +} + +func candidateURLs(candidates []proxyCandidate) []string { + var urls []string + for _, candidate := range candidates { + urls = append(urls, candidate.proxyURL) + } + return urls +} + +func candidateSchemes(candidates []proxyCandidate) []string { + var schemes []string + for _, candidate := range candidates { + schemes = append(schemes, candidate.forScheme) + } + return schemes +} diff --git a/internal/client/proxy_autodetect_windows.go b/internal/client/proxy_autodetect_windows.go new file mode 100644 index 0000000..16d4298 --- /dev/null +++ b/internal/client/proxy_autodetect_windows.go @@ -0,0 +1,68 @@ +//go:build windows + +package client + +import ( + "fmt" + + "golang.org/x/sys/windows/registry" +) + +const winINETInternetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings` + +// detectSystemProxyConfig reads the WinINET proxy configuration from the +// current user's registry hive (HKCU\...\Internet Settings) and returns proxy +// candidates usable by Connect/GetProxyDetails. +// +// Only the manual ProxyServer value is honoured. ProxyEnable=0 or an absent +// ProxyEnable disables primary proxy selection, but any ProxyServer entries +// are still returned so Run() can try them as fallbacks after a failed direct +// connection. AutoConfigURL (PAC) and AutoDetect (WPAD) are intentionally not +// consulted in this implementation. +// +// An empty candidate list with nil error means "no proxy configured". +func detectSystemProxyConfig() (systemProxyConfig, error) { + k, err := registry.OpenKey(registry.CURRENT_USER, winINETInternetSettingsKey, registry.QUERY_VALUE) + if err != nil { + return systemProxyConfig{}, fmt.Errorf("open HKCU\\%s: %w", winINETInternetSettingsKey, err) + } + defer k.Close() + + enable, _, err := k.GetIntegerValue("ProxyEnable") + if err != nil && err != registry.ErrNotExist { + return systemProxyConfig{}, fmt.Errorf("read ProxyEnable: %w", err) + } + + server, _, err := k.GetStringValue("ProxyServer") + if err == registry.ErrNotExist { + return systemProxyConfig{}, nil + } + if err != nil { + return systemProxyConfig{}, fmt.Errorf("read ProxyServer: %w", err) + } + + return systemProxyConfig{ + enabled: enable == 1, + candidates: parseWinINETProxyString(server), + }, nil +} + +// DetectSystemProxy returns the first usable system proxy for raw SSH-style +// connections. New code should use detectSystemProxyConfig and choose an +// ordering based on the target transport. +func DetectSystemProxy() (string, error) { + config, err := detectSystemProxyConfig() + if err != nil { + return "", err + } + if !config.enabled { + return "", nil + } + + ordered := orderProxyCandidatesForTransport(config.candidates, "ssh") + if len(ordered) == 0 { + return "", nil + } + + return ordered[0], nil +} diff --git a/internal/server/commands/link.go b/internal/server/commands/link.go index 85c36a5..33b84f7 100644 --- a/internal/server/commands/link.go +++ b/internal/server/commands/link.go @@ -52,6 +52,7 @@ func (l *link) ValidArgs() map[string]string { "working-directory": "Set download/working directory for automatic script (i.e doing curl https://.sh)", "raw-download": "Download over raw TCP, outputs bash downloader rather than http", "use-kerberos": "Instruct client to try and use kerberos ticket when using a proxy", + "auto-proxy": "Instruct client to auto-detect proxy from system settings (Windows: HKCU WinINET)", "log-level": "Set default output logging levels, [INFO,WARNING,ERROR,FATAL,DISABLED]", "ntlm-proxy-creds": "Set NTLM proxy credentials in format DOMAIN\\USER:PASS", "version-string": "Set the SSH version string the client uses, will always be prefixed with SSH-", @@ -128,6 +129,7 @@ func (l *link) Run(user *users.User, tty io.ReadWriter, line terminal.ParsedLine Garble: line.IsSet("garble"), DisableLibC: line.IsSet("no-lib-c"), UseKerberosAuth: line.IsSet("use-kerberos"), + AutoProxy: line.IsSet("auto-proxy"), RawDownload: line.IsSet("raw-download"), } diff --git a/internal/server/webserver/buildmanager.go b/internal/server/webserver/buildmanager.go index 42cf307..f28508b 100644 --- a/internal/server/webserver/buildmanager.go +++ b/internal/server/webserver/buildmanager.go @@ -48,6 +48,7 @@ type BuildConfig struct { Proxy, SNI, LogLevel string UseKerberosAuth bool + AutoProxy bool SharedLibrary bool UPX bool @@ -183,7 +184,7 @@ func Build(config BuildConfig) (string, error) { return "", err } - buildArguments = append(buildArguments, fmt.Sprintf("-ldflags=-s -w -X main.logLevel=%s -X main.destination=%s -X main.fingerprint=%s -X main.proxy=%s -X main.customSNI=%s -X main.useHostKerberos=%t -X main.ntlmProxyCreds=%s -X main.versionString=%s -X github.com/NHAS/reverse_ssh/internal.Version=%s", config.LogLevel, config.ConnectBackAdress, config.Fingerprint, config.Proxy, config.SNI, config.UseKerberosAuth, config.NTLMProxyCreds, strings.TrimSpace(config.VersionString), strings.TrimSpace(f.Version))) + buildArguments = append(buildArguments, fmt.Sprintf("-ldflags=-s -w -X main.logLevel=%s -X main.destination=%s -X main.fingerprint=%s -X main.proxy=%s -X main.customSNI=%s -X main.useHostKerberos=%t -X main.autoProxy=%t -X main.ntlmProxyCreds=%s -X main.versionString=%s -X github.com/NHAS/reverse_ssh/internal.Version=%s", config.LogLevel, config.ConnectBackAdress, config.Fingerprint, config.Proxy, config.SNI, config.UseKerberosAuth, config.AutoProxy, config.NTLMProxyCreds, strings.TrimSpace(config.VersionString), strings.TrimSpace(f.Version))) buildArguments = append(buildArguments, "-o", f.FilePath, filepath.Join(projectRoot, "/cmd/client")) cmd := exec.Command(buildTool, buildArguments...)