Picture this: you're writing a Go integration-test harness that must route all traffic through a corporate proxy during the test run, then leave the developer's machine exactly as it found it. You reach for HTTP_PROXY — and quickly discover that system-level proxy configuration is a different problem entirely.
On macOS you need networksetup. GNOME uses gsettings. KDE has its own tooling. Windows stores proxy settings in the registry. Add authentication, PAC files, cleanup, and error handling, and what looked like a one-afternoon feature becomes a collection of platform-specific scripts that nobody wants to maintain.
go-sysproxy wraps those native mechanisms in a single Go API. The rest of this article uses the test-harness scenario to walk through the library feature by feature — from a first proxy to full multi-protocol configuration, safe restore, and the CLI.
Verification scope: this article was checked against the tagged
v0.5.2source at commit14e44f6. All public API examples were verified against the implementation, andgo test ./...passes with Go 1.26.2 ondarwin/arm64. Platform descriptions come from inspection of platform-specific source and unit tests; they are not presented as live integration-test results for every supported desktop and Windows version.
Installation
go-sysproxy requires Go 1.22 or newer and has no external Go module dependencies:
go get github.com/mar0ls/go-sysproxy
import sysproxy "github.com/mar0ls/go-sysproxy"
Your first system proxy
The shortest example sets a proxy, runs some code, then clears it:
package main
import (
"log"
sysproxy "github.com/mar0ls/go-sysproxy"
)
func main() {
if err := sysproxy.Set("http://proxy.example.com:8080", sysproxy.ScopeGlobal); err != nil {
log.Fatal(err)
}
defer func() {
if err := sysproxy.Unset(sysproxy.ScopeGlobal); err != nil {
log.Printf("could not clear proxy: %v", err)
}
}()
// Run your network-aware code here.
}
Set accepts URLs with credentials:
http://username:password@proxy.example.com:8080
socks5://username:password@proxy.example.com:1080
For the test harness — where you want a hard deadline on how long a proxy change can take — the context-aware variant is the right choice:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := sysproxy.SetContext(ctx, "http://proxy.example.com:8080", sysproxy.ScopeGlobal)
The implementation checks an already-cancelled context before side effects. Command-backed operations use exec.CommandContext; this does not make a multi-step OS update transactional. The same pattern is available through UnsetContext, GetContext, GetConfigContext, SetMultiContext, and SetPACContext.
Choose the right scope
The Set call above used ScopeGlobal. The scope controls how far the change reaches:
| Scope | What it changes | Typical use |
|---|---|---|
ScopeShell |
Proxy environment variables in the current process | One-off commands, child processes |
ScopeUser |
Current process plus shell startup files or the PowerShell profile | Persistent per-user development setup |
ScopeGlobal |
Current process plus the native OS proxy store | Desktop apps, VPN clients, proxy switchers |
One nuance: ScopeGlobal is an API-level label, not a machine-wide guarantee. On Windows, the backend writes HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings, which is scoped to the current Windows user. On Linux, writing /etc/environment can require root. Get and GetConfig read the OS-level configuration, so they are intended for global proxy state, not shell or user-profile values.
For the integration-test scenario, ScopeGlobal is the right choice: it reaches desktop apps and other processes that read the native proxy store, which is exactly what you want when testing through a corporate gateway.
The safer pattern: apply, run, restore
The naive approach — Set at the start, Unset at the end — breaks the moment a developer already has a proxy configured. Your harness would clobber their setup and fail to restore it.
WithProxy solves this properly. It reads the current ProxyConfig, applies the temporary proxy, runs a callback, and then restores the previous state — regardless of whether the callback succeeds or fails:
err := sysproxy.WithProxy(
ctx,
"socks5://proxy.example.com:1080",
sysproxy.ScopeGlobal,
func(ctx context.Context) error {
return runIntegrationTests(ctx)
},
)
For ScopeGlobal, the snapshot is a full ProxyConfig containing HTTP, HTTPS, SOCKS, the bypass list, and PAC fields. The restore path reapplies PAC when PAC is non-empty; otherwise it reapplies the manual protocol fields, or calls Unset if none were set.
Two things to know about restore:
- Restore errors are not returned to the caller. Critical failures go to the optional package logger instead. If your harness needs to detect a failed restore, install a logger and verify state with
GetConfigafterward. -
ScopeShellandScopeUserdo not have a read-back snapshot path. With those scopes,WithProxycallsUnsetafter the callback — it does not restore pre-existing environment or profile values.
Configure each protocol separately
A single proxy URL works for many cases. Corporate environments often require separate endpoints for HTTP, HTTPS, and SOCKS. SetMulti handles that:
err := sysproxy.SetMulti(sysproxy.ProxyConfig{
HTTP: "http://http-proxy.example.com:8080",
HTTPS: "http://https-proxy.example.com:8443",
SOCKS: "socks5://socks-proxy.example.com:1080",
NoProxy: "localhost,127.0.0.1,10.0.0.0/8",
}, sysproxy.ScopeGlobal)
The implementation validates non-empty HTTP, HTTPS, and SOCKS URLs before starting side effects. Note that SetMulti does not apply the PAC field — use SetPAC for PAC mode. Backend behavior is not fully transactional: the Windows path, for example, enables ProxyEnable before writing the individual protocol entries.
There is a temporary multi-protocol variant too, with the same read-back restore semantics as WithProxy:
err := sysproxy.WithProxyMulti(
ctx,
sysproxy.ProxyConfig{
HTTP: "http://proxy.example.com:8080",
HTTPS: "http://proxy.example.com:8080",
},
sysproxy.ScopeGlobal,
func(ctx context.Context) error {
return runIntegrationTests(ctx)
},
)
Work with PAC files
Some networks distribute proxy configuration via Proxy Auto-Config files. To request PAC mode from the selected backend:
err := sysproxy.SetPAC(
"https://config.example.com/proxy.pac",
sysproxy.ScopeGlobal,
)
The validator accepts http://, https://, and file:// URLs. To inspect what is currently active:
cfg, err := sysproxy.GetConfig()
if err != nil {
log.Fatal(err)
}
fmt.Printf("HTTP: %s\n", cfg.HTTP)
fmt.Printf("HTTPS: %s\n", cfg.HTTPS)
fmt.Printf("SOCKS: %s\n", cfg.SOCKS)
fmt.Printf("NoProxy: %s\n", cfg.NoProxy)
fmt.Printf("PAC: %s\n", cfg.PAC)
When automatic proxy configuration is active, PAC holds the URL. For manual mode, the individual protocol fields hold the configured endpoints.
Handle errors without parsing strings
System proxy configuration can be in several distinct states: no proxy, a disabled proxy, an unsupported target, or a non-critical persistence error. The package exposes sentinel errors and helper functions instead of requiring string parsing:
cfg, err := sysproxy.GetConfig()
switch {
case err == nil:
fmt.Printf("current proxy: %+v\n", cfg)
case errors.Is(err, sysproxy.ErrProxyNotSet):
fmt.Println("no proxy is configured")
case errors.Is(err, sysproxy.ErrProxyNotEnabled):
fmt.Println("the manual proxy is not enabled")
case errors.Is(err, sysproxy.ErrUnsupportedPlatform):
fmt.Println("this operating system is not supported")
default:
return err
}
ErrToolMissing is returned when a required binary is absent — currently for the per-application Git and npm paths:
err := sysproxy.WriteAppConfig(sysproxy.AppGit, proxyURL)
if errors.Is(err, sysproxy.ErrToolMissing) {
log.Println("git is not available in PATH")
}
On Linux, failing to update /etc/environment is wrapped as a non-critical error. A permission failure is additionally classified as requiring elevation:
if err := sysproxy.Set(proxyURL, sysproxy.ScopeGlobal); err != nil {
switch {
case sysproxy.RequiresElevation(err):
log.Println("writing /etc/environment requires elevated permissions")
case sysproxy.IsNonCritical(err):
log.Printf("the /etc/environment step failed: %v", err)
default:
return err
}
}
These helpers classify that persistence error. They do not independently prove that every preceding GNOME or KDE command succeeded, so verify the state with GetConfig when confirmation matters.
One broader point that applies across all platforms in v0.5.2: several platform setters intentionally ignore errors from individual native commands. A nil result therefore does not prove that every sub-step was accepted by the OS. Read state back when your application needs confirmation. On macOS specifically, Get and GetConfig inspect the first service returned by networksetup -listallnetworkservices, while setters iterate over all returned services.
Verify the endpoint before changing the system
Before your test harness reconfigures the OS, it is worth confirming the proxy is actually reachable. Check opens a TCP connection to the proxy endpoint:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sysproxy.Check(ctx, "http://proxy.example.com:8080"); err != nil {
log.Fatalf("proxy is unreachable: %v", err)
}
This is a reachability check, not a full proxy handshake. It does not authenticate, perform an HTTP CONNECT, or complete a SOCKS handshake. A successful result means only that the host and port accepted a TCP connection.
Configure developer tools too
Routing OS traffic through a proxy does not automatically route git clone or pip install. The package ships explicit configuration writers for Git, curl, npm, pip, and wget:
proxyURL := "http://proxy.example.com:8080"
if err := sysproxy.WriteAppConfig(sysproxy.AppGit, proxyURL); err != nil {
log.Fatal(err)
}
if err := sysproxy.WriteAppConfig(sysproxy.AppCurl, proxyURL); err != nil {
log.Fatal(err)
}
// Later:
_ = sysproxy.ClearAppConfig(sysproxy.AppGit)
_ = sysproxy.ClearAppConfig(sysproxy.AppCurl)
These functions update application-specific configuration independently of the OS-level proxy functions.
Use it without writing Go
The repository includes a standalone CLI:
go install github.com/mar0ls/go-sysproxy/cmd/sysproxy@latest
sysproxy set http://127.0.0.1:8080
sysproxy set http://proxy.example.com:8080 --scope global
sysproxy get
sysproxy get --json
sysproxy check http://proxy.example.com:8080 --timeout 10s
sysproxy pac https://config.example.com/proxy.pac
sysproxy unset
One exit-code quirk to be aware of: get returns exit code 2 for any error, including the normal "proxy not set" case. Other command failures return 1. If you are scripting around this, check for both codes.
What happens under the hood
The public API stays the same across platforms; only the backend changes:
| Platform | Native mechanism |
|---|---|
| macOS | networksetup |
| Linux / GNOME | gsettings |
| Linux / KDE |
kwriteconfig5 for writes; kreadconfig5 or kreadconfig6 for reads |
| Linux system environment |
Set/Unset also edits /etc/environment
|
| Windows | Current-user Internet Settings registry keys; Set calls cmdkey when URL credentials are present |
All native commands go through exec.CommandContext with explicit argument lists, not shell construction. When the library creates Unix shell or application configuration files, it requests mode 0600; writing an existing file does not necessarily tighten its previous permissions.
Practical cautions before you ship
-
ScopeGlobalwrites outside your process. Which applications read that store depends on the platform and the application. -
/etc/environmenton Linux may require root. Other native commands have their own permission models. - Some native setter subcommands are best-effort in v0.5.2. Verify important changes by reading state back.
-
Checkproves TCP reachability only. It is not a proxy handshake. - Credentials in proxy URLs are handled differently by each backend. Avoid hard-coding them, and inspect where the selected OS or application persists them.
-
The package logger emits proxy URLs. If you install a logger with
SetLogger, the audit messages fromSetandWriteAppConfiginclude the supplied URL. Redact credentials in your adapter before forwarding to a shared system. -
WithProxywithScopeGlobalrestores previous state; the other scopes do not. Shell and user-profile values are not preserved forScopeShellorScopeUser. -
ScopeUseron Unix appends exports to.bashrc,.zshrc,.profile, and.bash_profile. Repeated calls can append duplicate lines. Inspect that behavior before using it as a configuration reconciler.
Final thoughts
go-sysproxy trades platform-specific scripts for a single Go API. In v0.5.2 that API covers contexts, sentinel errors, per-protocol settings, PAC support, TCP endpoint checks, a global-state restore helper, per-application configuration, and a CLI.
The scenario that opened this article — a test harness that applies a proxy, runs tests, and restores the previous state without touching the developer's setup — is four lines with WithProxy. The edge cases (multi-protocol corporate proxies, PAC mode, already-disabled proxies, missing tools) are handled by the same API with explicit error types rather than string matching.
Relevant source links for those who want to verify the implementation details:
- Public API and restore logic
- macOS backend
- Linux and KDE backends
- Windows backend
- TCP check
- CLI implementation
The project is open source under the MIT license:
Top comments (0)