Skip to content
Merged
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
22 changes: 19 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,35 @@ on:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies
run: go mod tidy
- name: Run tests
run: go test -v ./...

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.11.4
args: --timeout=5m

ci:
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- run: echo "All checks passed"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
ROADMAP.md
coverage.out
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
Cross-platform system proxy management for Go — set, clear, and query the OS proxy from your application without shell scripts.

```go
sysproxy.Set("socks5://user:pass@proxy.example.com:1080", sysproxy.ScopeGlobal)
sysproxy.Unset(sysproxy.ScopeGlobal)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := sysproxy.SetContext(ctx, "socks5://user:pass@proxy.example.com:1080", sysproxy.ScopeGlobal); err != nil {
log.Fatal(err)
}
defer sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal)
```

## Why

Proxy-switching tools, VPN clients, and network-aware CLIs built in Go often need to set the OS system proxy — not just read it. The existing options are either buried inside a large SDK ([outline-sdk/x/sysproxy](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/x/sysproxy)), Windows-only, or rely on shipping pre-built binaries.

`go-sysproxy` is a focused, standalone package: macOS (`networksetup`), Linux (GNOME + KDE + `/etc/environment`), and Windows (registry + Credential Manager), with health checking, per-app config, and temporary proxy restore — zero external dependencies.
`go-sysproxy` is a focused package for macOS (`networksetup`), Linux (GNOME + KDE + `/etc/environment`), and Windows (registry + Credential Manager). It covers system proxy changes, health checks, per-app config, and temporary proxy restore without external dependencies.

## Installation

Expand All @@ -39,18 +44,19 @@ import (
)

func main() {
// Verify the proxy is reachable before committing
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Verify the proxy is reachable before committing
if err := sysproxy.Check(ctx, "http://proxy.example.com:8080"); err != nil {
log.Fatal(err)
}

// Set system proxy globally
if err := sysproxy.Set("http://proxy.example.com:8080", sysproxy.ScopeGlobal); err != nil {
// Apply the system proxy and restore it on exit
if err := sysproxy.SetContext(ctx, "http://proxy.example.com:8080", sysproxy.ScopeGlobal); err != nil {
log.Fatal(err)
}
defer sysproxy.Unset(sysproxy.ScopeGlobal)
defer sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal)
}
```

Expand All @@ -73,6 +79,18 @@ err = sysproxy.Unset(sysproxy.ScopeGlobal)
url, err := sysproxy.Get() // reads current system proxy
```

The plain wrappers stay available for backward compatibility. If you want cancellation and deadlines, use the context-aware variants:

```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

err := sysproxy.SetContext(ctx, "http://user:pass@proxy.example.com:8080", sysproxy.ScopeGlobal)
err = sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal)

url, err := sysproxy.GetContext(ctx)
```

### Per-protocol proxy

```go
Expand All @@ -84,12 +102,16 @@ err := sysproxy.SetMulti(sysproxy.ProxyConfig{
}, sysproxy.ScopeGlobal)
```

`SetMultiContext` is also available when you want the same API with cancellation support.

### PAC file

```go
err := sysproxy.SetPAC("https://config.example.com/proxy.pac", sysproxy.ScopeGlobal)
```

`SetPACContext` is also available for deadline-aware callers.

### Temporary proxy

`WithProxy` sets the proxy for the duration of `fn` and restores the previous state on return — even if `fn` returns an error.
Expand Down Expand Up @@ -130,6 +152,8 @@ sysproxy.WriteAppConfig(sysproxy.AppWget, "http://proxy.example.com:8080") // ~/
sysproxy.ClearAppConfig(sysproxy.AppGit)
```

`WriteAppConfigContext` and `ClearAppConfigContext` are available for `git` and `npm`, where configuration is applied through external commands.

### Logging / auditing

```go
Expand Down Expand Up @@ -164,6 +188,12 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.pr

> Credentials in proxy URLs are handled by the OS — on Windows they are stored in Credential Manager, not written to disk in plaintext.

## Notes

- `Check` verifies TCP reachability of the proxy endpoint. It does not validate credentials or perform a protocol-level handshake.
- Context-aware APIs abort before starting side effects when the context is already canceled, and command-backed operations use `exec.CommandContext`.
- `ScopeGlobal` may still require elevated permissions depending on the platform and the target settings store.

## Platform support

| Feature | macOS | Linux (GNOME) | Linux (KDE) | Windows |
Expand Down
61 changes: 41 additions & 20 deletions appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sysproxy

import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
Expand All @@ -26,17 +27,27 @@ const (
//
// err := sysproxy.WriteAppConfig(sysproxy.AppGit, "http://proxy.example.com:8080")
func WriteAppConfig(app AppName, proxyURL string) error {
return WriteAppConfigContext(context.Background(), app, proxyURL)
}

// WriteAppConfigContext writes proxy settings to the tool-specific config for
// app. It aborts before side effects if ctx is already canceled.
func WriteAppConfigContext(ctx context.Context, app AppName, proxyURL string) error {
if err := validateProxyURL(proxyURL); err != nil {
return err
}
ctx = normalizeContext(ctx)
if err := ctx.Err(); err != nil {
return err
}
var err error
switch app {
case AppCurl:
err = writeCurlRC(proxyURL)
case AppGit:
err = writeGitProxy(proxyURL)
err = writeGitProxy(ctx, proxyURL)
case AppNPM:
err = writeNPMProxy(proxyURL)
err = writeNPMProxy(ctx, proxyURL)
case AppPip:
err = writePipConf(proxyURL)
case AppWget:
Expand All @@ -50,14 +61,24 @@ func WriteAppConfig(app AppName, proxyURL string) error {

// ClearAppConfig removes proxy settings from the tool-specific config for app.
func ClearAppConfig(app AppName) error {
return ClearAppConfigContext(context.Background(), app)
}

// ClearAppConfigContext removes proxy settings from the tool-specific config
// for app. It aborts before side effects if ctx is already canceled.
func ClearAppConfigContext(ctx context.Context, app AppName) error {
ctx = normalizeContext(ctx)
if err := ctx.Err(); err != nil {
return err
}
var err error
switch app {
case AppCurl:
err = clearCurlRC()
case AppGit:
err = clearGitProxy()
err = clearGitProxy(ctx)
case AppNPM:
err = clearNPMProxy()
err = clearNPMProxy(ctx)
case AppPip:
err = clearPipConf()
case AppWget:
Expand Down Expand Up @@ -89,57 +110,57 @@ func clearCurlRC() error {

// ── git (git config --global) ─────────────────────────────────────────────────

func runGit(args ...string) error {
return exec.Command("git", args...).Run() //nolint:noctx,gosec
func runGit(ctx context.Context, args ...string) error {
return exec.CommandContext(normalizeContext(ctx), "git", args...).Run() //nolint:gosec
}

func writeGitProxy(proxyURL string) error {
func writeGitProxy(ctx context.Context, proxyURL string) error {
if !isAvailable("git") {
return fmt.Errorf("sysproxy: git not found in PATH")
}
if err := runGit("config", "--global", "http.proxy", proxyURL); err != nil {
if err := runGit(ctx, "config", "--global", "http.proxy", proxyURL); err != nil {
return fmt.Errorf("sysproxy: git config http.proxy: %w", err)
}
if err := runGit("config", "--global", "https.proxy", proxyURL); err != nil {
if err := runGit(ctx, "config", "--global", "https.proxy", proxyURL); err != nil {
return fmt.Errorf("sysproxy: git config https.proxy: %w", err)
}
return nil
}

func clearGitProxy() error {
func clearGitProxy(ctx context.Context) error {
if !isAvailable("git") {
return fmt.Errorf("sysproxy: git not found in PATH")
}
_ = runGit("config", "--global", "--unset", "http.proxy")
_ = runGit("config", "--global", "--unset", "https.proxy")
_ = runGit(ctx, "config", "--global", "--unset", "http.proxy")
_ = runGit(ctx, "config", "--global", "--unset", "https.proxy")
return nil
}

// ── npm (npm config set) ──────────────────────────────────────────────────────

func runNPM(args ...string) error {
return exec.Command("npm", args...).Run() //nolint:noctx,gosec
func runNPM(ctx context.Context, args ...string) error {
return exec.CommandContext(normalizeContext(ctx), "npm", args...).Run() //nolint:gosec
}

func writeNPMProxy(proxyURL string) error {
func writeNPMProxy(ctx context.Context, proxyURL string) error {
if !isAvailable("npm") {
return fmt.Errorf("sysproxy: npm not found in PATH")
}
if err := runNPM("config", "set", "proxy", proxyURL); err != nil {
if err := runNPM(ctx, "config", "set", "proxy", proxyURL); err != nil {
return fmt.Errorf("sysproxy: npm config set proxy: %w", err)
}
if err := runNPM("config", "set", "https-proxy", proxyURL); err != nil {
if err := runNPM(ctx, "config", "set", "https-proxy", proxyURL); err != nil {
return fmt.Errorf("sysproxy: npm config set https-proxy: %w", err)
}
return nil
}

func clearNPMProxy() error {
func clearNPMProxy(ctx context.Context) error {
if !isAvailable("npm") {
return fmt.Errorf("sysproxy: npm not found in PATH")
}
_ = runNPM("config", "delete", "proxy")
_ = runNPM("config", "delete", "https-proxy")
_ = runNPM(ctx, "config", "delete", "proxy")
_ = runNPM(ctx, "config", "delete", "https-proxy")
return nil
}

Expand Down
Loading
Loading