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
63 changes: 56 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,75 @@ on:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go: ['1.22', 'stable']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies
run: go mod tidy
- name: Run tests
run: go test -v ./...
go-version: ${{ matrix.go }}
- name: Run tests with coverage (Unix)
if: runner.os != 'Windows'
run: go test -v -count=1 -coverprofile=coverage.out ./...
- name: Run tests (Windows)
if: runner.os == 'Windows'
run: go test -v -count=1 ./...
- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.go == 'stable'
uses: codecov/codecov-action@v5
with:
files: coverage.out
fail_ci_if_error: false

build-cli:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
goos: linux
goarch: amd64
binary: sysproxy-linux-amd64
- os: ubuntu-latest
goos: linux
goarch: arm64
binary: sysproxy-linux-arm64
- os: macos-latest
goos: darwin
goarch: arm64
binary: sysproxy-darwin-arm64
- os: windows-latest
goos: windows
goarch: amd64
binary: sysproxy-windows-amd64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Build CLI
env:
CGO_ENABLED: '0'
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: go build -o dist/${{ matrix.binary }} ./cmd/sysproxy
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.binary }}
path: dist/${{ matrix.binary }}

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: 'stable'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v7
with:
Expand All @@ -37,6 +86,6 @@ jobs:

ci:
runs-on: ubuntu-latest
needs: [test, lint]
needs: [test, build-cli, lint]
steps:
- run: echo "All checks passed"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
ROADMAP.md
coverage.out
coverage.out
dist/
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ linters:
gosec:
excludes:
- G705
- G101 # false positive on example URLs in usage strings
Comment on lines 16 to +19
revive:
severity: warning
gocritic:
enabled-checks:
- appendAssign
- boolExprSimplify
exclusions:
rules:
Expand Down
49 changes: 49 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
VERSION ?= dev
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GOCACHE ?= $(CURDIR)/.cache/go-build
GOMODCACHE ?= $(CURDIR)/.cache/go-mod
export GOCACHE
export GOMODCACHE

BINARY := sysproxy
MODULE := github.com/mar0ls/go-sysproxy
LDFLAGS := -ldflags="-s -w \
-X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' \
-X '$(MODULE)/internal/buildinfo.Commit=$(COMMIT)' \
-X '$(MODULE)/internal/buildinfo.BuildDate=$(BUILD_DATE)'"

.PHONY: all cli test lint clean tidy dist

all: cli

$(GOCACHE) $(GOMODCACHE):
mkdir -p $@

tidy:
go mod tidy

cli: | $(GOCACHE) $(GOMODCACHE)
CGO_ENABLED=0 go build $(LDFLAGS) -o dist/$(BINARY) ./cmd/sysproxy

# Cross-platform release builds.
dist: | $(GOCACHE) $(GOMODCACHE)
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY)-darwin-amd64 ./cmd/sysproxy
Comment on lines +26 to +31
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/$(BINARY)-darwin-arm64 ./cmd/sysproxy
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY)-linux-amd64 ./cmd/sysproxy
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/$(BINARY)-linux-arm64 ./cmd/sysproxy
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY)-windows-amd64.exe ./cmd/sysproxy
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build $(LDFLAGS) -o dist/$(BINARY)-windows-arm64.exe ./cmd/sysproxy

test: | $(GOCACHE) $(GOMODCACHE)
go test ./... -count=1

cover: | $(GOCACHE) $(GOMODCACHE)
go test ./... -count=1 -coverprofile=coverage.out
go tool cover -func=coverage.out | tail -3

lint:
golangci-lint run ./...

clean:
rm -rf dist/ .cache/
51 changes: 46 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![Go Reference](https://pkg.go.dev/badge/github.com/mar0ls/go-sysproxy.svg)](https://pkg.go.dev/github.com/mar0ls/go-sysproxy)
[![CI](https://github.com/mar0ls/go-sysproxy/actions/workflows/test.yml/badge.svg)](https://github.com/mar0ls/go-sysproxy/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/mar0ls/go-sysproxy/graph/badge.svg)](https://codecov.io/gh/mar0ls/go-sysproxy)
[![Go Report Card](https://goreportcard.com/badge/github.com/mar0ls/go-sysproxy)](https://goreportcard.com/report/github.com/mar0ls/go-sysproxy)

Cross-platform system proxy management for Go — set, clear, and query the OS proxy from your application without shell scripts.
Expand All @@ -20,7 +21,7 @@ defer sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal)

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 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.
`go-sysproxy` wraps the native proxy tools on 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. Zero external dependencies.

## Installation

Expand Down Expand Up @@ -76,10 +77,10 @@ func main() {
err := sysproxy.Set("http://user:pass@proxy.example.com:8080", sysproxy.ScopeGlobal)
err = sysproxy.Unset(sysproxy.ScopeGlobal)

url, err := sysproxy.Get() // reads current system proxy
url, err := sysproxy.Get() // reads current system proxy (HTTP field)
```

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

```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand All @@ -91,6 +92,20 @@ err = sysproxy.UnsetContext(ctx, sysproxy.ScopeGlobal)
url, err := sysproxy.GetContext(ctx)
```

### GetConfig — read per-protocol settings

`GetConfig` returns the full proxy configuration currently active in the OS, with each protocol field populated separately:

```go
cfg, err := sysproxy.GetConfig()
// cfg.HTTP = "http://proxy.example.com:8080"
// cfg.HTTPS = "http://proxy.example.com:8080"
// cfg.SOCKS = "socks5://proxy.example.com:1080"
// cfg.NoProxy = "localhost,10.0.0.0/8"
```

`GetConfigContext` is also available.

### Per-protocol proxy

```go
Expand Down Expand Up @@ -164,6 +179,31 @@ sysproxy.SetLogger(slogAdapter{slog.Default()})
// every Set/Unset/WriteAppConfig now emits a structured log line
```

## CLI

A standalone binary is available at `cmd/sysproxy`:

```sh
# build
make cli # → dist/sysproxy

# or install directly
go install github.com/mar0ls/go-sysproxy/cmd/sysproxy@latest
```

```sh
sysproxy set http://127.0.0.1:8080
sysproxy set http://user:pass@proxy.corp.com:8080 --scope global
sysproxy get
sysproxy get --json
sysproxy unset
sysproxy pac https://config.example.com/proxy.pac
sysproxy check http://proxy.corp.com:8080 --timeout 10s
sysproxy version
```

Exit codes: `0` success · `1` error · `2` proxy not set (only `get`).

## Real-world example — residential proxy

The URL format works with any standard HTTP/SOCKS5 proxy provider:
Expand All @@ -186,7 +226,7 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppGit, "http://username:password@proxy.pr
_ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.provider.com:10000")
```

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

## Notes

Expand All @@ -200,6 +240,7 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.pr
|---|:---:|:---:|:---:|:---:|
| Set / Unset | ✓ | ✓ | ✓ | ✓ |
| Get | ✓ | ✓ | — | ✓ |
| GetConfig | ✓ | ✓ | — | ✓ |
| SetMulti | ✓ | ✓ | ✓ | ✓ |
| SetPAC | ✓ | ✓ | ✓ | ✓ |
| ScopeUser (rc files) | ✓ | ✓ | ✓ | ✓ |
Expand All @@ -211,7 +252,7 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.pr

- **Command allowlist** — `exec.Command` is restricted to a fixed set of permitted binaries. No user-supplied input reaches the shell.
- **Config files** — written with mode `0600`.
- **Static analysis** — code is checked with [golangci-lint](https://golangci-lint.run) (including `gosec`, `errcheck`, `staticcheck`) and [Semgrep](https://semgrep.dev) on every commit.
- **Static analysis** — code is checked with [golangci-lint](https://golangci-lint.run) (including `gosec`, `errcheck`, `staticcheck`) on every push.

## License

Expand Down
2 changes: 2 additions & 0 deletions appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ func WriteAppConfig(app AppName, proxyURL string) error {

// WriteAppConfigContext writes proxy settings to the tool-specific config for
// app. It aborts before side effects if ctx is already canceled.
// Context cancellation is honoured for AppGit and AppNPM (external commands);
// for AppCurl, AppPip and AppWget it is checked once before writing.
func WriteAppConfigContext(ctx context.Context, app AppName, proxyURL string) error {
if err := validateProxyURL(proxyURL); err != nil {
return err
Expand Down
18 changes: 18 additions & 0 deletions backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package sysproxy

import "context"

// globalBackend is the interface for OS-specific proxy operations.
// Platform files register an implementation via init(); tests swap it with a mock.
type globalBackend interface {
SetGlobal(ctx context.Context, p *proxy) error
UnsetGlobal(ctx context.Context) error
GetGlobal(ctx context.Context) (string, error)
GetGlobalConfig(ctx context.Context) (ProxyConfig, error)
SetGlobalPAC(ctx context.Context, pacURL string) error
SetGlobalMulti(ctx context.Context, cfg ProxyConfig) error
}

// activeBackend is the platform implementation used at runtime.
// Tests may replace it with a mock via useMockBackend.
var activeBackend globalBackend
2 changes: 1 addition & 1 deletion check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestCheckInvalidURL(t *testing.T) {
if err == nil {
t.Fatal("expected invalid URL error")
}
if !strings.Contains(err.Error(), "invalid proxy URL") {
if !strings.Contains(err.Error(), "proxy URL") {
t.Fatalf("expected validation error, got %v", err)
}
}
Expand Down
Loading
Loading