diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 567fc5e..1bf5f3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,18 +9,67 @@ 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 @@ -28,7 +77,7 @@ jobs: - 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: @@ -37,6 +86,6 @@ jobs: ci: runs-on: ubuntu-latest - needs: [test, lint] + needs: [test, build-cli, lint] steps: - run: echo "All checks passed" diff --git a/.gitignore b/.gitignore index 59f3c67..99fa16b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ ROADMAP.md -coverage.out \ No newline at end of file +coverage.out +dist/ diff --git a/.golangci.yml b/.golangci.yml index c6cce79..4e4b6a2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -16,11 +16,11 @@ linters: gosec: excludes: - G705 + - G101 # false positive on example URLs in usage strings revive: severity: warning gocritic: enabled-checks: - - appendAssign - boolExprSimplify exclusions: rules: diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a2dc8c --- /dev/null +++ b/Makefile @@ -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 + 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/ diff --git a/README.md b/README.md index 4e3f069..fbfe862 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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) @@ -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 @@ -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: @@ -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 @@ -200,6 +240,7 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.pr |---|:---:|:---:|:---:|:---:| | Set / Unset | ✓ | ✓ | ✓ | ✓ | | Get | ✓ | ✓ | — | ✓ | +| GetConfig | ✓ | ✓ | — | ✓ | | SetMulti | ✓ | ✓ | ✓ | ✓ | | SetPAC | ✓ | ✓ | ✓ | ✓ | | ScopeUser (rc files) | ✓ | ✓ | ✓ | ✓ | @@ -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 diff --git a/appconfig.go b/appconfig.go index c27ac80..8d47618 100644 --- a/appconfig.go +++ b/appconfig.go @@ -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 diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..d6ca7c0 --- /dev/null +++ b/backend.go @@ -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 diff --git a/check_test.go b/check_test.go index 3c8cc45..1ff2dd1 100644 --- a/check_test.go +++ b/check_test.go @@ -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) } } diff --git a/cmd/sysproxy/main.go b/cmd/sysproxy/main.go new file mode 100644 index 0000000..9641165 --- /dev/null +++ b/cmd/sysproxy/main.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + sysproxy "github.com/mar0ls/go-sysproxy" + "github.com/mar0ls/go-sysproxy/internal/buildinfo" +) + +const usage = `sysproxy — cross-platform system proxy manager + +Usage: + sysproxy [flags] + +Commands: + set Set system proxy (e.g. http://127.0.0.1:8080) + get Print current system proxy URL + unset Clear system proxy + pac Set PAC (Proxy Auto-Config) URL + check Test TCP reachability of a proxy endpoint + version Show build information + +Flags: + --scope shell|user|global Scope for set/unset/pac (default: global) + --json Output in JSON format + --timeout duration Timeout for check (default: 5s) + +Exit codes: + 0 success + 1 error + 2 proxy not set (get) + +Examples: + sysproxy set http://127.0.0.1:8080 + sysproxy set http://user:pass@proxy.corp.com:8080 --scope global + sysproxy get --json + sysproxy unset + sysproxy pac https://config.example.com/proxy.pac + sysproxy check http://proxy.corp.com:8080 --timeout 10s +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usage) + os.Exit(1) + } + + cmd := os.Args[1] + args := os.Args[2:] + + // shared flags + scopeStr := "global" + jsonOut := false + timeoutStr := "5s" + var positional []string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--scope": + i++ + if i >= len(args) { + die("--scope requires a value: shell|user|global") + } + scopeStr = args[i] + case "--json": + jsonOut = true + case "--timeout": + i++ + if i >= len(args) { + die("--timeout requires a duration value, e.g. 5s") + } + timeoutStr = args[i] + default: + positional = append(positional, args[i]) + } + } + + scope, err := parseScope(scopeStr) + if err != nil { + die(err.Error()) + } + + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + die("invalid --timeout value: " + err.Error()) + } + + switch cmd { + case "set": + if len(positional) < 1 { + die("usage: sysproxy set ") + } + cmdSet(positional[0], scope, jsonOut) + case "get": + cmdGet(jsonOut) + case "unset": + cmdUnset(scope, jsonOut) + case "pac": + if len(positional) < 1 { + die("usage: sysproxy pac ") + } + cmdPAC(positional[0], scope, jsonOut) + case "check": + if len(positional) < 1 { + die("usage: sysproxy check ") + } + cmdCheck(positional[0], timeout, jsonOut) + case "version": + fmt.Println(buildinfo.Summary()) + case "--help", "-h", "help": + fmt.Print(usage) + default: + fmt.Fprintf(os.Stderr, "unknown command: %q\n\n", cmd) + fmt.Fprint(os.Stderr, usage) + os.Exit(1) + } +} + +func cmdSet(proxyURL string, scope sysproxy.ProxyScope, jsonOut bool) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := sysproxy.SetContext(ctx, proxyURL, scope); err != nil { + dieJSON(jsonOut, "set failed: "+err.Error()) + } + printOK(jsonOut, map[string]any{"proxy": proxyURL, "scope": scope.String()}) +} + +func cmdGet(jsonOut bool) { + url, err := sysproxy.Get() + if err != nil { + if jsonOut { + printJSON(map[string]any{"error": "proxy not set"}) + } else { + fmt.Fprintln(os.Stderr, "proxy not set") + } + os.Exit(2) + } + if jsonOut { + printJSON(map[string]any{"proxy": url}) + } else { + fmt.Println(url) + } +} + +func cmdUnset(scope sysproxy.ProxyScope, jsonOut bool) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := sysproxy.UnsetContext(ctx, scope); err != nil { + dieJSON(jsonOut, "unset failed: "+err.Error()) + } + printOK(jsonOut, map[string]any{"scope": scope.String()}) +} + +func cmdPAC(pacURL string, scope sysproxy.ProxyScope, jsonOut bool) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := sysproxy.SetPACContext(ctx, pacURL, scope); err != nil { + dieJSON(jsonOut, "pac failed: "+err.Error()) + } + printOK(jsonOut, map[string]any{"pac": pacURL, "scope": scope.String()}) +} + +func cmdCheck(proxyURL string, timeout time.Duration, jsonOut bool) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := sysproxy.Check(ctx, proxyURL); err != nil { + dieJSON(jsonOut, "unreachable: "+err.Error()) + } + printOK(jsonOut, map[string]any{"proxy": proxyURL, "reachable": true}) +} + +func parseScope(s string) (sysproxy.ProxyScope, error) { + switch s { + case "shell": + return sysproxy.ScopeShell, nil + case "user": + return sysproxy.ScopeUser, nil + case "global": + return sysproxy.ScopeGlobal, nil + default: + return 0, fmt.Errorf("invalid scope %q — use shell, user or global", s) + } +} + +func printOK(jsonOut bool, fields map[string]any) { + if jsonOut { + fields["ok"] = true + printJSON(fields) + } else { + fmt.Println("ok") + } +} + +func printJSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func die(msg string) { + fmt.Fprintln(os.Stderr, "error: "+msg) + os.Exit(1) +} + +func dieJSON(jsonOut bool, msg string) { + if jsonOut { + printJSON(map[string]any{"ok": false, "error": msg}) + } else { + fmt.Fprintln(os.Stderr, "error: "+msg) + } + os.Exit(1) +} diff --git a/cmd/sysproxy/main_test.go b/cmd/sysproxy/main_test.go new file mode 100644 index 0000000..4c4cd28 --- /dev/null +++ b/cmd/sysproxy/main_test.go @@ -0,0 +1,188 @@ +package main_test + +import ( + "encoding/json" + "os" + "os/exec" + "runtime" + "strings" + "testing" +) + +// testBinary returns the path to a freshly built sysproxy binary. +// The binary is built once per test run and removed when the test suite exits. +func testBinary(t *testing.T) string { + t.Helper() + bin := t.TempDir() + "/sysproxy" + if runtime.GOOS == "windows" { + bin += ".exe" + } + cmd := exec.Command("go", "build", "-o", bin, ".") //nolint:gosec + cmd.Dir = "." + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build sysproxy: %v\n%s", err, out) + } + return bin +} + +func run(t *testing.T, bin string, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + cmd := exec.Command(bin, args...) //nolint:gosec + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err := cmd.Run() + if err != nil { + if ex, ok := err.(*exec.ExitError); ok { + exitCode = ex.ExitCode() + } else { + t.Fatalf("exec error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +// TestVersion checks that the version command prints something sensible. +func TestVersion(t *testing.T) { + bin := testBinary(t) + out, _, code := run(t, bin, "version") + if code != 0 { + t.Fatalf("exit code %d", code) + } + if !strings.Contains(out, "dev") && !strings.Contains(out, ".") { + t.Errorf("unexpected version output: %q", out) + } +} + +// TestHelp checks that the help text is printed and exit 0. +func TestHelp(t *testing.T) { + bin := testBinary(t) + for _, arg := range []string{"help", "--help", "-h"} { + out, _, code := run(t, bin, arg) + if code != 0 { + t.Errorf("%s: exit code %d", arg, code) + } + if !strings.Contains(out, "sysproxy") { + t.Errorf("%s: expected usage text, got %q", arg, out) + } + } +} + +// TestNoArgs checks that running without arguments exits 1 and prints usage. +func TestNoArgs(t *testing.T) { + bin := testBinary(t) + _, stderr, code := run(t, bin) + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + if !strings.Contains(stderr, "sysproxy") { + t.Errorf("expected usage on stderr, got %q", stderr) + } +} + +// TestUnknownCommand exits 1 and prints something useful. +func TestUnknownCommand(t *testing.T) { + bin := testBinary(t) + _, stderr, code := run(t, bin, "foobar") + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + if !strings.Contains(stderr, "foobar") { + t.Errorf("expected command name in error, got %q", stderr) + } +} + +// TestVersionJSON checks --json flag on version (unsupported; just ensure no panic). +func TestGetNotSet_ExitCode2(t *testing.T) { + if os.Getenv("CI") == "" && os.Getenv("SYSPROXY_INTEGRATION") == "" { + t.Skip("skipping get test outside CI/SYSPROXY_INTEGRATION to avoid touching OS settings") + } + bin := testBinary(t) + _, _, code := run(t, bin, "get") + // exit 2 = not set, 0 = set — both are valid outcomes + if code != 0 && code != 2 { + t.Fatalf("expected exit 0 or 2, got %d", code) + } +} + +// TestGetJSON_OutputShape verifies the JSON output is valid JSON with expected key. +func TestGetJSON_OutputShape(t *testing.T) { + if os.Getenv("CI") == "" && os.Getenv("SYSPROXY_INTEGRATION") == "" { + t.Skip("skipping get test outside CI/SYSPROXY_INTEGRATION to avoid touching OS settings") + } + bin := testBinary(t) + out, stderr, code := run(t, bin, "get", "--json") + if code != 0 && code != 2 { + t.Fatalf("unexpected exit %d: %s", code, stderr) + } + var m map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &m); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %q", err, out) + } + if _, hasProxy := m["proxy"]; !hasProxy { + if _, hasErr := m["error"]; !hasErr { + t.Errorf("JSON output missing 'proxy' or 'error' key: %v", m) + } + } +} + +// TestCheck_InvalidURL exits 1 for a clearly unreachable host. +func TestCheck_InvalidURL(t *testing.T) { + bin := testBinary(t) + _, _, code := run(t, bin, "check", "http://192.0.2.1:9999", "--timeout", "500ms") + if code != 1 { + t.Fatalf("expected exit 1 for unreachable proxy, got %d", code) + } +} + +// TestCheck_JSONError verifies --json on failure produces valid JSON. +func TestCheck_JSONError(t *testing.T) { + bin := testBinary(t) + out, _, code := run(t, bin, "check", "http://192.0.2.1:9999", "--timeout", "500ms", "--json") + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + var m map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &m); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %q", err, out) + } + if _, ok := m["error"]; !ok { + t.Errorf("expected 'error' key in JSON, got %v", m) + } +} + +// TestSetMissingURL exits 1 with helpful message. +func TestSetMissingURL(t *testing.T) { + bin := testBinary(t) + _, stderr, code := run(t, bin, "set") + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + if !strings.Contains(stderr, "set") { + t.Errorf("expected usage hint, got %q", stderr) + } +} + +// TestInvalidScope exits 1 with a clear message. +func TestInvalidScope(t *testing.T) { + bin := testBinary(t) + _, stderr, code := run(t, bin, "set", "http://127.0.0.1:8080", "--scope", "badscope") + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + if !strings.Contains(stderr, "badscope") && !strings.Contains(stderr, "scope") { + t.Errorf("expected scope error, got %q", stderr) + } +} + +// TestInvalidTimeout exits 1 with a clear message. +func TestInvalidTimeout(t *testing.T) { + bin := testBinary(t) + _, stderr, code := run(t, bin, "check", "http://127.0.0.1:9999", "--timeout", "notaduration") + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + if !strings.Contains(stderr, "timeout") { + t.Errorf("expected timeout error, got %q", stderr) + } +} diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..db541bf --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,15 @@ +package buildinfo + +import "fmt" + +// Injected via -ldflags at build time. +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) + +// Summary returns a one-line build description for CLI output and logs. +func Summary() string { + return fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, BuildDate) +} diff --git a/logger.go b/logger.go index 6e67e08..11fd1bf 100644 --- a/logger.go +++ b/logger.go @@ -22,7 +22,7 @@ var ( ) // SetLogger installs l as the global logger for all sysproxy operations. -// Pass nil to disable logging (the default). +// Pass nil to disable logging (the default). Safe for concurrent use. func SetLogger(l Logger) { logMu.Lock() globalLog = l diff --git a/mock_test.go b/mock_test.go new file mode 100644 index 0000000..29fbd1d --- /dev/null +++ b/mock_test.go @@ -0,0 +1,68 @@ +package sysproxy + +import ( + "context" + "testing" +) + +// mockBackend is a test double for globalBackend. +// Any nil field falls back to a no-op that returns nil. +type mockBackend struct { + setGlobalFn func(ctx context.Context, p *proxy) error + unsetGlobalFn func(ctx context.Context) error + getGlobalFn func(ctx context.Context) (string, error) + getGlobalConfigFn func(ctx context.Context) (ProxyConfig, error) + setGlobalPACFn func(ctx context.Context, pacURL string) error + setGlobalMultiFn func(ctx context.Context, cfg ProxyConfig) error +} + +func (m *mockBackend) SetGlobal(ctx context.Context, p *proxy) error { + if m.setGlobalFn != nil { + return m.setGlobalFn(ctx, p) + } + return nil +} + +func (m *mockBackend) UnsetGlobal(ctx context.Context) error { + if m.unsetGlobalFn != nil { + return m.unsetGlobalFn(ctx) + } + return nil +} + +func (m *mockBackend) GetGlobal(ctx context.Context) (string, error) { + if m.getGlobalFn != nil { + return m.getGlobalFn(ctx) + } + return "", nil +} + +func (m *mockBackend) GetGlobalConfig(ctx context.Context) (ProxyConfig, error) { + if m.getGlobalConfigFn != nil { + return m.getGlobalConfigFn(ctx) + } + return ProxyConfig{}, nil +} + +func (m *mockBackend) SetGlobalPAC(ctx context.Context, pacURL string) error { + if m.setGlobalPACFn != nil { + return m.setGlobalPACFn(ctx, pacURL) + } + return nil +} + +func (m *mockBackend) SetGlobalMulti(ctx context.Context, cfg ProxyConfig) error { + if m.setGlobalMultiFn != nil { + return m.setGlobalMultiFn(ctx, cfg) + } + return nil +} + +// useMockBackend replaces activeBackend for the duration of test t and +// restores the original on cleanup. +func useMockBackend(t *testing.T, m *mockBackend) { + t.Helper() + orig := activeBackend + activeBackend = m + t.Cleanup(func() { activeBackend = orig }) +} diff --git a/rcfile_unix_test.go b/rcfile_unix_test.go new file mode 100644 index 0000000..8af7a45 --- /dev/null +++ b/rcfile_unix_test.go @@ -0,0 +1,179 @@ +//go:build linux || darwin + +package sysproxy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSetUser_WritesRCFiles(t *testing.T) { + home := setTestHome(t) + + if err := setUser("http://proxy.example.com:8080"); err != nil { + t.Fatal(err) + } + + for _, rc := range unixRCFiles { + data, err := os.ReadFile(filepath.Join(home, rc)) //nolint:gosec + if err != nil { + t.Fatalf("rc file %s missing: %v", rc, err) + } + content := string(data) + if !strings.Contains(content, "export http_proxy='http://proxy.example.com:8080'") { + t.Errorf("%s: missing http_proxy export, got:\n%s", rc, content) + } + if !strings.Contains(content, "export HTTP_PROXY='http://proxy.example.com:8080'") { + t.Errorf("%s: missing HTTP_PROXY export", rc) + } + if !strings.Contains(content, "export no_proxy=") { + t.Errorf("%s: missing no_proxy export", rc) + } + } +} + +func TestUnsetUser_RemovesProxyLines(t *testing.T) { + home := setTestHome(t) + + if err := setUser("http://proxy.example.com:8080"); err != nil { + t.Fatal(err) + } + if err := unsetUser(); err != nil { + t.Fatal(err) + } + + for _, rc := range unixRCFiles { + data, _ := os.ReadFile(filepath.Join(home, rc)) //nolint:gosec + if strings.Contains(string(data), "_proxy=") || strings.Contains(string(data), "_PROXY=") { + t.Errorf("%s: proxy lines not removed:\n%s", rc, data) + } + } +} + +func TestSetUserPAC_WritesAUTOPROXY(t *testing.T) { + home := setTestHome(t) + + if err := setUserPAC("http://config.example.com/proxy.pac"); err != nil { + t.Fatal(err) + } + + for _, rc := range unixRCFiles { + data, err := os.ReadFile(filepath.Join(home, rc)) //nolint:gosec + if err != nil { + t.Fatalf("rc file %s missing: %v", rc, err) + } + if !strings.Contains(string(data), "export AUTOPROXY='http://config.example.com/proxy.pac'") { + t.Errorf("%s: missing AUTOPROXY export, got:\n%s", rc, data) + } + } +} + +func TestSetUserMulti_WritesPerProtocol(t *testing.T) { + home := setTestHome(t) + + cfg := ProxyConfig{ + HTTP: "http://http.example.com:8080", + HTTPS: "http://https.example.com:8080", + SOCKS: "socks5://socks.example.com:1080", + NoProxy: "localhost,10.0.0.0/8", + } + if err := setUserMulti(cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(home, ".bashrc")) //nolint:gosec + if err != nil { + t.Fatal(err) + } + content := string(data) + + for _, want := range []string{ + "export http_proxy='" + cfg.HTTP + "'", + "export https_proxy='" + cfg.HTTPS + "'", + "export all_proxy='" + cfg.SOCKS + "'", + "export no_proxy='" + cfg.NoProxy + "'", + } { + if !strings.Contains(content, want) { + t.Errorf(".bashrc missing %q", want) + } + } +} + +func TestSetUserMulti_EmptyFieldsSkipped(t *testing.T) { + home := setTestHome(t) + + if err := setUserMulti(ProxyConfig{HTTP: "http://proxy.example.com:8080"}); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(filepath.Join(home, ".bashrc")) //nolint:gosec + content := string(data) + if strings.Contains(content, "https_proxy") || strings.Contains(content, "all_proxy") { + t.Errorf(".bashrc should not contain https/socks entries, got:\n%s", content) + } +} + +// ── ScopeUser in public API ─────────────────────────────────────────────────── + +func TestSetContext_ScopeUser(t *testing.T) { + home := setTestHome(t) + t.Cleanup(unsetEnvVars) + + if err := Set("http://proxy.example.com:8080", ScopeUser); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(home, ".bashrc")) //nolint:gosec + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "http_proxy") { + t.Error(".bashrc missing proxy after ScopeUser Set") + } +} + +func TestUnsetContext_ScopeUser(t *testing.T) { + home := setTestHome(t) + t.Cleanup(unsetEnvVars) + + _ = Set("http://proxy.example.com:8080", ScopeUser) + if err := Unset(ScopeUser); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(filepath.Join(home, ".bashrc")) //nolint:gosec + if strings.Contains(string(data), "_proxy=") { + t.Error(".bashrc still has proxy after ScopeUser Unset") + } +} + +func TestSetMultiContext_ScopeUser(t *testing.T) { + home := setTestHome(t) + t.Cleanup(unsetEnvVars) + + cfg := ProxyConfig{HTTP: "http://proxy.example.com:8080", HTTPS: "http://proxy.example.com:8080"} + if err := SetMulti(cfg, ScopeUser); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(filepath.Join(home, ".bashrc")) //nolint:gosec + if !strings.Contains(string(data), "http_proxy") { + t.Error(".bashrc missing proxy after SetMulti ScopeUser") + } +} + +func TestSetPACContext_ScopeUser(t *testing.T) { + home := setTestHome(t) + t.Cleanup(func() { _ = os.Unsetenv("AUTOPROXY") }) + + if err := SetPAC("http://config.example.com/proxy.pac", ScopeUser); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(filepath.Join(home, ".bashrc")) //nolint:gosec + if !strings.Contains(string(data), "AUTOPROXY") { + t.Error(".bashrc missing AUTOPROXY after SetPAC ScopeUser") + } +} diff --git a/sysproxy.go b/sysproxy.go index 8f2d626..135a20b 100644 --- a/sysproxy.go +++ b/sysproxy.go @@ -33,7 +33,22 @@ const ( ScopeGlobal ) -// ProxyConfig allows configuring a different proxy URL per protocol. +// String returns a human-readable name for the scope. +func (s ProxyScope) String() string { + switch s { + case ScopeShell: + return "shell" + case ScopeUser: + return "user" + case ScopeGlobal: + return "global" + default: + return "unknown" + } +} + +// ProxyConfig holds per-protocol proxy URLs for SetMulti. +// Any field left empty is ignored. type ProxyConfig struct { HTTP string HTTPS string @@ -74,7 +89,7 @@ func SetContext(ctx context.Context, proxyURL string, scope ProxyScope) error { return err case ScopeGlobal: setEnvVars(proxyURL) - err = setGlobal(ctx, p) + err = activeBackend.SetGlobal(ctx, p) logf("set proxy scope=global url=%s err=%v", proxyURL, err) return err default: @@ -108,7 +123,7 @@ func UnsetContext(ctx context.Context, scope ProxyScope) error { return err case ScopeGlobal: unsetEnvVars() - err := unsetGlobal(ctx) + err := activeBackend.UnsetGlobal(ctx) logf("unset proxy scope=global err=%v", err) return err default: @@ -128,7 +143,23 @@ func GetContext(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err } - return getGlobal(ctx) + return activeBackend.GetGlobal(ctx) +} + +// GetConfig returns the per-protocol proxy configuration currently active in +// the OS system settings. Fields are empty when that protocol has no proxy set. +// Only ScopeGlobal is supported; read os.Getenv for shell-scope values. +func GetConfig() (ProxyConfig, error) { + return GetConfigContext(context.Background()) +} + +// GetConfigContext is like GetConfig but respects context cancellation. +func GetConfigContext(ctx context.Context) (ProxyConfig, error) { + ctx = normalizeContext(ctx) + if err := ctx.Err(); err != nil { + return ProxyConfig{}, err + } + return activeBackend.GetGlobalConfig(ctx) } // SetMulti configures per-protocol proxies. Any field left empty is not changed. @@ -159,7 +190,7 @@ func SetMultiContext(ctx context.Context, cfg ProxyConfig, scope ProxyScope) err return setUserMulti(cfg) case ScopeGlobal: setEnvVarsMulti(cfg) - return setGlobalMulti(ctx, cfg) + return activeBackend.SetGlobalMulti(ctx, cfg) default: return fmt.Errorf("sysproxy: invalid scope %d", scope) } @@ -190,7 +221,7 @@ func SetPACContext(ctx context.Context, pacURL string, scope ProxyScope) error { return setUserPAC(pacURL) case ScopeGlobal: setEnvVarsPAC(pacURL) - return setGlobalPAC(ctx, pacURL) + return activeBackend.SetGlobalPAC(ctx, pacURL) default: return fmt.Errorf("sysproxy: invalid scope %d", scope) } diff --git a/sysproxy_darwin.go b/sysproxy_darwin.go index 7243611..d33f584 100644 --- a/sysproxy_darwin.go +++ b/sysproxy_darwin.go @@ -48,6 +48,21 @@ func unsetGlobal(ctx context.Context) error { return nil } +// parseNSProxyOutput extracts host, port and enabled state from networksetup output. +func parseNSProxyOutput(output string) (host, port string, enabled bool) { + for _, line := range strings.Split(output, "\n") { + switch { + case strings.HasPrefix(line, "Enabled: Yes"): + enabled = true + case strings.HasPrefix(line, "Server:"): + host = strings.TrimSpace(strings.TrimPrefix(line, "Server:")) + case strings.HasPrefix(line, "Port:"): + port = strings.TrimSpace(strings.TrimPrefix(line, "Port:")) + } + } + return +} + func getGlobal(ctx context.Context) (string, error) { services, err := macOSNetworkServices(ctx) if err != nil || len(services) == 0 { @@ -57,22 +72,54 @@ func getGlobal(ctx context.Context) (string, error) { if err != nil { return "", err } - var host, port string - var enabled bool - for _, line := range strings.Split(string(out), "\n") { - switch { - case strings.HasPrefix(line, "Enabled: Yes"): - enabled = true - case strings.HasPrefix(line, "Server:"): - host = strings.TrimSpace(strings.TrimPrefix(line, "Server:")) - case strings.HasPrefix(line, "Port:"): - port = strings.TrimSpace(strings.TrimPrefix(line, "Port:")) + h, p, ok := parseNSProxyOutput(string(out)) + if ok && h != "" && p != "0" { + return "http://" + h + ":" + p, nil + } + return "", fmt.Errorf("sysproxy: proxy not set") +} + +func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { + services, err := macOSNetworkServices(ctx) + if err != nil || len(services) == 0 { + return ProxyConfig{}, fmt.Errorf("sysproxy: no network services found") + } + svc := services[0] + ctx = normalizeContext(ctx) + + var cfg ProxyConfig + for _, q := range []struct { + flag string + dest *string + }{ + {"-getwebproxy", &cfg.HTTP}, + {"-getsecurewebproxy", &cfg.HTTPS}, + {"-getsocksfirewallproxy", &cfg.SOCKS}, + } { + out, err := exec.CommandContext(ctx, "networksetup", q.flag, svc).Output() //nolint:gosec + if err == nil { + h, p, ok := parseNSProxyOutput(string(out)) + if ok && h != "" && p != "0" { + *q.dest = "http://" + h + ":" + p + } + } + } + + out, err := exec.CommandContext(ctx, "networksetup", "-getproxybypassdomains", svc).Output() //nolint:gosec + if err == nil { + var parts []string + for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if l = strings.TrimSpace(l); l != "" { + parts = append(parts, l) + } } + cfg.NoProxy = strings.Join(parts, ",") } - if enabled && host != "" && port != "0" { - return "http://" + host + ":" + port, nil + + if cfg.HTTP == "" && cfg.HTTPS == "" && cfg.SOCKS == "" { + return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") } - return "", fmt.Errorf("sysproxy: proxy not set") + return cfg, nil } func setGlobalPAC(ctx context.Context, pacURL string) error { @@ -112,6 +159,22 @@ func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { return nil } +// darwinBackend implements globalBackend using macOS networksetup. +type darwinBackend struct{} + +func (darwinBackend) SetGlobal(ctx context.Context, p *proxy) error { return setGlobal(ctx, p) } +func (darwinBackend) UnsetGlobal(ctx context.Context) error { return unsetGlobal(ctx) } +func (darwinBackend) GetGlobal(ctx context.Context) (string, error) { return getGlobal(ctx) } +func (darwinBackend) GetGlobalConfig(ctx context.Context) (ProxyConfig, error) { + return getGlobalConfig(ctx) +} +func (darwinBackend) SetGlobalPAC(ctx context.Context, u string) error { return setGlobalPAC(ctx, u) } +func (darwinBackend) SetGlobalMulti(ctx context.Context, c ProxyConfig) error { + return setGlobalMulti(ctx, c) +} + +func init() { activeBackend = darwinBackend{} } + func macOSNetworkServices(ctx context.Context) ([]string, error) { out, err := exec.CommandContext(normalizeContext(ctx), "networksetup", "-listallnetworkservices").Output() if err != nil { diff --git a/sysproxy_darwin_test.go b/sysproxy_darwin_test.go new file mode 100644 index 0000000..6abe467 --- /dev/null +++ b/sysproxy_darwin_test.go @@ -0,0 +1,49 @@ +//go:build darwin + +package sysproxy + +import "testing" + +func TestParseNSProxyOutput(t *testing.T) { + cases := []struct { + name string + input string + wantHost string + wantPort string + wantEnabled bool + }{ + { + name: "enabled with host and port", + input: "Enabled: Yes\nServer: proxy.example.com\nPort: 8080\n", + wantHost: "proxy.example.com", wantPort: "8080", wantEnabled: true, + }, + { + name: "disabled", + input: "Enabled: No\nServer: proxy.example.com\nPort: 8080\n", + wantHost: "proxy.example.com", wantPort: "8080", wantEnabled: false, + }, + { + name: "empty output", + input: "", + wantHost: "", + wantPort: "", + wantEnabled: false, + }, + { + name: "no server line", + input: "Enabled: Yes\nPort: 8080\n", + wantHost: "", + wantPort: "8080", + wantEnabled: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h, p, ok := parseNSProxyOutput(c.input) + if h != c.wantHost || p != c.wantPort || ok != c.wantEnabled { + t.Errorf("parseNSProxyOutput(%q) = (%q, %q, %v), want (%q, %q, %v)", + c.input, h, p, ok, c.wantHost, c.wantPort, c.wantEnabled) + } + }) + } +} diff --git a/sysproxy_linux.go b/sysproxy_linux.go index ea8e855..67f41c0 100644 --- a/sysproxy_linux.go +++ b/sysproxy_linux.go @@ -73,6 +73,61 @@ func getGlobal(ctx context.Context) (string, error) { return "http://" + h + ":" + p, nil } +// gsettingsField reads a single string field from a gsettings schema. +func gsettingsField(ctx context.Context, schema, key string) string { + out, err := exec.CommandContext(normalizeContext(ctx), "gsettings", "get", schema, key).Output() //nolint:gosec + if err != nil { + return "" + } + return strings.Trim(strings.TrimSpace(string(out)), "'") +} + +// parseGsettingsArray converts a GLib array string like ['a', 'b'] to "a,b". +func parseGsettingsArray(raw string) string { + s := strings.Trim(strings.TrimSpace(raw), "[]") + var parts []string + for _, item := range strings.Split(s, ",") { + if v := strings.Trim(strings.TrimSpace(item), "'"); v != "" { + parts = append(parts, v) + } + } + return strings.Join(parts, ",") +} + +func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { + if !isAvailable("gsettings") { + return ProxyConfig{}, fmt.Errorf("sysproxy: gsettings not available") + } + if gsettingsField(ctx, "org.gnome.system.proxy", "mode") != "manual" { + return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") + } + + var cfg ProxyConfig + for _, q := range []struct { + schema string + scheme string + dest *string + }{ + {"org.gnome.system.proxy.http", "http", &cfg.HTTP}, + {"org.gnome.system.proxy.https", "http", &cfg.HTTPS}, + {"org.gnome.system.proxy.socks", "socks5", &cfg.SOCKS}, + } { + h := gsettingsField(ctx, q.schema, "host") + p := gsettingsField(ctx, q.schema, "port") + if h != "" && p != "0" { + *q.dest = q.scheme + "://" + h + ":" + p + } + } + + raw, _ := exec.CommandContext(normalizeContext(ctx), "gsettings", "get", "org.gnome.system.proxy", "ignore-hosts").Output() + cfg.NoProxy = parseGsettingsArray(string(raw)) + + if cfg.HTTP == "" && cfg.HTTPS == "" && cfg.SOCKS == "" { + return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") + } + return cfg, nil +} + func setGlobalPAC(ctx context.Context, pacURL string) error { switch detectDesktopEnv() { case "gnome": @@ -127,6 +182,22 @@ func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { return nil } +// linuxBackend implements globalBackend using gsettings/kwriteconfig5 and /etc/environment. +type linuxBackend struct{} + +func (linuxBackend) SetGlobal(ctx context.Context, p *proxy) error { return setGlobal(ctx, p) } +func (linuxBackend) UnsetGlobal(ctx context.Context) error { return unsetGlobal(ctx) } +func (linuxBackend) GetGlobal(ctx context.Context) (string, error) { return getGlobal(ctx) } +func (linuxBackend) GetGlobalConfig(ctx context.Context) (ProxyConfig, error) { + return getGlobalConfig(ctx) +} +func (linuxBackend) SetGlobalPAC(ctx context.Context, u string) error { return setGlobalPAC(ctx, u) } +func (linuxBackend) SetGlobalMulti(ctx context.Context, c ProxyConfig) error { + return setGlobalMulti(ctx, c) +} + +func init() { activeBackend = linuxBackend{} } + func detectDesktopEnv() string { for _, env := range []string{"XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "GDMSESSION"} { v := strings.ToLower(os.Getenv(env)) diff --git a/sysproxy_other.go b/sysproxy_other.go index ac14e72..6c635ee 100644 --- a/sysproxy_other.go +++ b/sysproxy_other.go @@ -8,18 +8,25 @@ import ( "runtime" ) -func setGlobal(_ context.Context, _ *proxy) error { return errUnsupported() } -func unsetGlobal(_ context.Context) error { return errUnsupported() } -func getGlobal(_ context.Context) (string, error) { return "", errUnsupported() } -func setGlobalPAC(_ context.Context, _ string) error { return errUnsupported() } -func setGlobalMulti(_ context.Context, _ ProxyConfig) error { - return errUnsupported() -} func setUser(_ string) error { return errUnsupported() } func unsetUser() error { return errUnsupported() } func setUserPAC(_ string) error { return errUnsupported() } func setUserMulti(_ ProxyConfig) error { return errUnsupported() } +// otherBackend implements globalBackend for unsupported operating systems. +type otherBackend struct{} + +func (otherBackend) SetGlobal(_ context.Context, _ *proxy) error { return errUnsupported() } +func (otherBackend) UnsetGlobal(_ context.Context) error { return errUnsupported() } +func (otherBackend) GetGlobal(_ context.Context) (string, error) { return "", errUnsupported() } +func (otherBackend) GetGlobalConfig(_ context.Context) (ProxyConfig, error) { + return ProxyConfig{}, errUnsupported() +} +func (otherBackend) SetGlobalPAC(_ context.Context, _ string) error { return errUnsupported() } +func (otherBackend) SetGlobalMulti(_ context.Context, _ ProxyConfig) error { return errUnsupported() } + +func init() { activeBackend = otherBackend{} } + func errUnsupported() error { return fmt.Errorf("sysproxy: unsupported OS %q", runtime.GOOS) } diff --git a/sysproxy_test.go b/sysproxy_test.go index 95ebef4..76a3375 100644 --- a/sysproxy_test.go +++ b/sysproxy_test.go @@ -1,6 +1,9 @@ package sysproxy import ( + "context" + "errors" + "fmt" "os" "path/filepath" "strings" @@ -10,9 +13,30 @@ import ( // ── Get ─────────────────────────────────────────────────────────────────────── func TestGet_NotSet(t *testing.T) { - proxy, err := Get() - if err == nil && proxy != "" { - t.Error("expected error or empty proxy when not set") + useMockBackend(t, &mockBackend{ + getGlobalFn: func(_ context.Context) (string, error) { + return "", fmt.Errorf("sysproxy: proxy not set") + }, + }) + _, err := Get() + if err == nil { + t.Error("expected error when proxy not set") + } +} + +func TestGet_Set(t *testing.T) { + const want = "http://proxy.example.com:8080" + useMockBackend(t, &mockBackend{ + getGlobalFn: func(_ context.Context) (string, error) { + return want, nil + }, + }) + got, err := Get() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("Get() = %q, want %q", got, want) } } @@ -81,23 +105,29 @@ func TestParseSocks5(t *testing.T) { func TestValidateProxyURL(t *testing.T) { cases := []struct { - url string - want bool // true = valid + url string + want bool // true = valid + wantErrFrag string // non-empty: substring expected in error message }{ - {"http://proxy.example.com:8080", true}, - {"https://proxy.example.com:8080", true}, - {"socks5://proxy.example.com:1080", true}, - {"http://user:pass@proxy.example.com:8080", true}, - {"http://localhost:8080", true}, // localhost allowed — library should not restrict this - {"://bad url", false}, - {"http://proxy.example.com:99999", false}, - {"http://proxy.example.com:0", false}, - {"", false}, + {"http://proxy.example.com:8080", true, ""}, + {"https://proxy.example.com:8080", true, ""}, + {"socks5://proxy.example.com:1080", true, ""}, + {"http://user:pass@proxy.example.com:8080", true, ""}, + {"http://localhost:8080", true, ""}, + {"://bad url", false, "scheme"}, + {"http://", false, "missing host"}, + {"http://proxy.example.com:99999", false, "out of range"}, + {"http://proxy.example.com:0", false, "out of range"}, + {"", false, "missing scheme"}, } for _, c := range cases { err := validateProxyURL(c.url) if (err == nil) != c.want { t.Errorf("validateProxyURL(%q): got err=%v, want valid=%v", c.url, err, c.want) + continue + } + if !c.want && c.wantErrFrag != "" && !strings.Contains(err.Error(), c.wantErrFrag) { + t.Errorf("validateProxyURL(%q): error = %q, want to contain %q", c.url, err.Error(), c.wantErrFrag) } } } @@ -273,3 +303,301 @@ func TestWriteAppConfigUnsupported(t *testing.T) { t.Error("expected error for unsupported app") } } + +// ── ScopeGlobal via mock backend ────────────────────────────────────────────── + +func TestSetGlobal_CallsBackend(t *testing.T) { + var called bool + useMockBackend(t, &mockBackend{ + setGlobalFn: func(_ context.Context, p *proxy) error { + called = true + if p.host != "proxy.example.com" || p.port != "8080" { + t.Errorf("unexpected proxy: host=%q port=%q", p.host, p.port) + } + return nil + }, + }) + t.Cleanup(unsetEnvVars) + + if err := Set("http://proxy.example.com:8080", ScopeGlobal); err != nil { + t.Fatal(err) + } + if !called { + t.Error("backend.SetGlobal was not called") + } +} + +func TestSetGlobal_BackendError(t *testing.T) { + useMockBackend(t, &mockBackend{ + setGlobalFn: func(_ context.Context, _ *proxy) error { + return errors.New("backend error") + }, + }) + t.Cleanup(unsetEnvVars) + + if err := Set("http://proxy.example.com:8080", ScopeGlobal); err == nil { + t.Error("expected error from backend") + } +} + +func TestUnsetGlobal_CallsBackend(t *testing.T) { + var called bool + useMockBackend(t, &mockBackend{ + unsetGlobalFn: func(_ context.Context) error { + called = true + return nil + }, + }) + t.Cleanup(unsetEnvVars) + + if err := Unset(ScopeGlobal); err != nil { + t.Fatal(err) + } + if !called { + t.Error("backend.UnsetGlobal was not called") + } +} + +func TestGetContext_PropagatesURL(t *testing.T) { + const want = "http://proxy.example.com:9090" + useMockBackend(t, &mockBackend{ + getGlobalFn: func(_ context.Context) (string, error) { return want, nil }, + }) + + got, err := Get() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("Get() = %q, want %q", got, want) + } +} + +func TestSetMultiGlobal_CallsBackend(t *testing.T) { + var got ProxyConfig + useMockBackend(t, &mockBackend{ + setGlobalMultiFn: func(_ context.Context, cfg ProxyConfig) error { + got = cfg + return nil + }, + }) + t.Cleanup(unsetEnvVars) + + want := ProxyConfig{ + HTTP: "http://http.example.com:8080", + HTTPS: "http://https.example.com:8080", + } + if err := SetMulti(want, ScopeGlobal); err != nil { + t.Fatal(err) + } + if got.HTTP != want.HTTP || got.HTTPS != want.HTTPS { + t.Errorf("SetMulti passed %+v, want %+v", got, want) + } +} + +func TestSetPACGlobal_CallsBackend(t *testing.T) { + const pacURL = "http://config.example.com/proxy.pac" + var called bool + useMockBackend(t, &mockBackend{ + setGlobalPACFn: func(_ context.Context, u string) error { + called = true + if u != pacURL { + t.Errorf("SetGlobalPAC got %q, want %q", u, pacURL) + } + return nil + }, + }) + t.Cleanup(unsetEnvVars) + + if err := SetPAC(pacURL, ScopeGlobal); err != nil { + t.Fatal(err) + } + if !called { + t.Error("backend.SetGlobalPAC was not called") + } +} + +func TestWithProxy_RestoresPrevious(t *testing.T) { + const prev = "http://prev.example.com:8080" + const next = "http://next.example.com:9090" + + setLog := []string{} + useMockBackend(t, &mockBackend{ + setGlobalFn: func(_ context.Context, p *proxy) error { + setLog = append(setLog, p.rawURL) + return nil + }, + unsetGlobalFn: func(_ context.Context) error { return nil }, + getGlobalFn: func(_ context.Context) (string, error) { return prev, nil }, + }) + t.Cleanup(unsetEnvVars) + + err := WithProxy(context.Background(), next, ScopeGlobal, func(_ context.Context) error { + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(setLog) < 2 { + t.Fatalf("expected at least 2 Set calls, got %d", len(setLog)) + } + // last Set call should restore the previous proxy + if setLog[len(setLog)-1] != prev { + t.Errorf("last Set = %q, want %q", setLog[len(setLog)-1], prev) + } +} + +// ── SetMultiContext / SetPACContext – ScopeShell ────────────────────────────── + +func TestSetMultiContext_ScopeShell(t *testing.T) { + t.Cleanup(unsetEnvVars) + + cfg := ProxyConfig{HTTP: "http://proxy.example.com:8080", HTTPS: "http://proxy.example.com:8080"} + if err := SetMulti(cfg, ScopeShell); err != nil { + t.Fatal(err) + } + if got := os.Getenv("http_proxy"); got != cfg.HTTP { + t.Errorf("http_proxy = %q, want %q", got, cfg.HTTP) + } +} + +func TestSetPACContext_ScopeShell(t *testing.T) { + t.Cleanup(func() { _ = os.Unsetenv("AUTOPROXY") }) + + if err := SetPAC("http://config.example.com/proxy.pac", ScopeShell); err != nil { + t.Fatal(err) + } + if got := os.Getenv("AUTOPROXY"); got != "http://config.example.com/proxy.pac" { + t.Errorf("AUTOPROXY = %q", got) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func TestHostFromURL(t *testing.T) { + cases := []struct{ url, want string }{ + {"http://proxy.example.com:8080", "proxy.example.com"}, + {"socks5://user:pass@proxy.example.com:1080", "proxy.example.com"}, + {"://bad", ""}, + } + for _, c := range cases { + if got := hostFromURL(c.url); got != c.want { + t.Errorf("hostFromURL(%q) = %q, want %q", c.url, got, c.want) + } + } +} + +func TestPortFromURL(t *testing.T) { + cases := []struct{ url, want string }{ + {"http://proxy.example.com:8080", "8080"}, + {"socks5://proxy.example.com:1080", "1080"}, + {"://bad", ""}, + } + for _, c := range cases { + if got := portFromURL(c.url); got != c.want { + t.Errorf("portFromURL(%q) = %q, want %q", c.url, got, c.want) + } + } +} + +// ── normalizeContext ────────────────────────────────────────────────────────── + +func TestNormalizeContext_Nil(t *testing.T) { + var nilCtx context.Context // typed nil, avoids SA1012 on literal nil + ctx := normalizeContext(nilCtx) + if ctx == nil { + t.Error("normalizeContext(nil) returned nil") + } +} + +func TestNormalizeContext_NonNil(t *testing.T) { + orig := context.Background() + if got := normalizeContext(orig); got != orig { + t.Error("normalizeContext should return the same non-nil context") + } +} + +// ── validatePACURL ──────────────────────────────────────────────────────────── + +func TestValidatePACURL(t *testing.T) { + cases := []struct { + url string + want bool + }{ + {"http://config.example.com/proxy.pac", true}, + {"https://config.example.com/proxy.pac", true}, + {"file:///etc/proxy.pac", true}, + {"ftp://bad.example.com/proxy.pac", false}, + {"", false}, + } + for _, c := range cases { + err := validatePACURL(c.url) + if (err == nil) != c.want { + t.Errorf("validatePACURL(%q): got err=%v, want valid=%v", c.url, err, c.want) + } + } +} + +// ── GetConfig via mock backend ──────────────────────────────────────────────── + +func TestGetConfig_ReturnsFull(t *testing.T) { + want := ProxyConfig{ + HTTP: "http://http.example.com:8080", + HTTPS: "http://https.example.com:8080", + SOCKS: "socks5://socks.example.com:1080", + NoProxy: "localhost,10.0.0.0/8", + } + useMockBackend(t, &mockBackend{ + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { return want, nil }, + }) + + got, err := GetConfig() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("GetConfig() = %+v, want %+v", got, want) + } +} + +func TestGetConfig_BackendError(t *testing.T) { + useMockBackend(t, &mockBackend{ + getGlobalConfigFn: func(_ context.Context) (ProxyConfig, error) { + return ProxyConfig{}, errors.New("proxy not set") + }, + }) + + _, err := GetConfig() + if err == nil { + t.Error("expected error from backend") + } +} + +func TestGetConfigContext_CanceledCtx(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := GetConfigContext(ctx) + if err == nil { + t.Error("expected context cancellation error") + } +} + +// ── ProxyScope.String ───────────────────────────────────────────────────────── + +func TestProxyScopeString(t *testing.T) { + cases := []struct { + scope ProxyScope + want string + }{ + {ScopeShell, "shell"}, + {ScopeUser, "user"}, + {ScopeGlobal, "global"}, + {ProxyScope(99), "unknown"}, + } + for _, c := range cases { + if got := c.scope.String(); got != c.want { + t.Errorf("ProxyScope(%d).String() = %q, want %q", c.scope, got, c.want) + } + } +} diff --git a/sysproxy_windows.go b/sysproxy_windows.go index 56bbe47..f58e922 100644 --- a/sysproxy_windows.go +++ b/sysproxy_windows.go @@ -115,6 +115,70 @@ func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { return nil } +// parseWindowsProxyServer converts a ProxyServer registry value to ProxyConfig. +// The value is either "host:port" (single proxy for all protocols) or +// "http=h:p;https=h:p;socks=h:p" (per-protocol). +func parseWindowsProxyServer(server string) ProxyConfig { + var cfg ProxyConfig + if !strings.Contains(server, "=") { + cfg.HTTP = "http://" + server + cfg.HTTPS = "http://" + server + cfg.SOCKS = "socks5://" + server + return cfg + } + for _, part := range strings.Split(server, ";") { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + switch k { + case "http": + cfg.HTTP = "http://" + v + case "https": + cfg.HTTPS = "http://" + v + case "socks": + cfg.SOCKS = "socks5://" + v + } + } + return cfg +} + +// extractRegValue returns the last whitespace-separated field on the line +// containing key in reg.exe output. +func extractRegValue(output, key string) string { + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, key) { + parts := strings.Fields(line) + if len(parts) > 0 { + return strings.TrimSpace(parts[len(parts)-1]) + } + } + } + return "" +} + +func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { + out, err := exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyEnable").Output() + if err != nil || !strings.Contains(string(out), "0x1") { + return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not enabled") + } + out, err = exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyServer").Output() + if err != nil { + return ProxyConfig{}, fmt.Errorf("sysproxy: cannot read ProxyServer") + } + server := extractRegValue(string(out), "ProxyServer") + if server == "" { + return ProxyConfig{}, fmt.Errorf("sysproxy: proxy not set") + } + cfg := parseWindowsProxyServer(server) + + out, _ = exec.CommandContext(normalizeContext(ctx), "reg", "query", regKey, "/v", "ProxyOverride").Output() + cfg.NoProxy = extractRegValue(string(out), "ProxyOverride") + + return cfg, nil +} + func setUser(proxyURL string) error { psProfile, err := powershellProfile() if err != nil { @@ -207,6 +271,22 @@ func setUserMulti(cfg ProxyConfig) error { return nil } +// windowsBackend implements globalBackend using the Windows registry and WinINET. +type windowsBackend struct{} + +func (windowsBackend) SetGlobal(ctx context.Context, p *proxy) error { return setGlobal(ctx, p) } +func (windowsBackend) UnsetGlobal(ctx context.Context) error { return unsetGlobal(ctx) } +func (windowsBackend) GetGlobal(ctx context.Context) (string, error) { return getGlobal(ctx) } +func (windowsBackend) GetGlobalConfig(ctx context.Context) (ProxyConfig, error) { + return getGlobalConfig(ctx) +} +func (windowsBackend) SetGlobalPAC(ctx context.Context, u string) error { return setGlobalPAC(ctx, u) } +func (windowsBackend) SetGlobalMulti(ctx context.Context, c ProxyConfig) error { + return setGlobalMulti(ctx, c) +} + +func init() { activeBackend = windowsBackend{} } + func hostPortFromURL(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { diff --git a/validate.go b/validate.go index b8d572a..25bf936 100644 --- a/validate.go +++ b/validate.go @@ -34,13 +34,19 @@ func parse(rawURL string) (*proxy, error) { func validateProxyURL(rawURL string) error { u, err := url.Parse(rawURL) - if err != nil || u.Scheme == "" || u.Hostname() == "" { - return fmt.Errorf("sysproxy: invalid proxy URL %q", rawURL) + if err != nil { + return fmt.Errorf("sysproxy: cannot parse proxy URL %q: %w", rawURL, err) + } + if u.Scheme == "" { + return fmt.Errorf("sysproxy: proxy URL %q missing scheme (e.g. http://host:port)", rawURL) + } + if u.Hostname() == "" { + return fmt.Errorf("sysproxy: proxy URL %q missing host", rawURL) } if port := u.Port(); port != "" { var n int if _, err := fmt.Sscanf(port, "%d", &n); err != nil || n < 1 || n > 65535 { - return fmt.Errorf("sysproxy: invalid port %q in proxy URL", port) + return fmt.Errorf("sysproxy: port %q out of range 1–65535 in proxy URL %q", port, rawURL) } } return nil @@ -50,7 +56,7 @@ func validatePACURL(pacURL string) error { if !strings.HasPrefix(pacURL, "http://") && !strings.HasPrefix(pacURL, "https://") && !strings.HasPrefix(pacURL, "file://") { - return fmt.Errorf("sysproxy: PAC URL must start with http://, https://, or file://") + return fmt.Errorf("sysproxy: PAC URL must use http, https, or file scheme (got %q)", pacURL) } return nil }