diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1e3c96..29d837c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} + cache: false - name: Run tests with coverage (Unix) if: runner.os != 'Windows' run: go test -v -count=1 -coverprofile=coverage.out ./... @@ -29,7 +30,6 @@ jobs: if: matrix.os == 'ubuntu-latest' && matrix.go == 'stable' uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} files: coverage.out fail_ci_if_error: false @@ -60,6 +60,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version: 'stable' + cache: false - name: Build CLI env: CGO_ENABLED: '0' @@ -79,6 +80,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version: 'stable' + cache: false - name: Run golangci-lint uses: golangci/golangci-lint-action@v7 with: diff --git a/.gitignore b/.gitignore index 99fa16b..0b02977 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ ROADMAP.md coverage.out dist/ +scripts/smoke/* +VM_SMOKE* \ No newline at end of file diff --git a/README.md b/README.md index fbfe862..02d9ff9 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,10 @@ err := sysproxy.SetPAC("https://config.example.com/proxy.pac", sysproxy.ScopeGlo `SetPACContext` is also available for deadline-aware callers. +Note: `SetPAC` switches the OS into auto-proxy (PAC) mode. In that mode, +`Get` / `GetConfig` report manual proxy state, so they may return +"proxy not set" / "proxy not enabled" even though PAC is active. + ### Temporary proxy `WithProxy` sets the proxy for the duration of `fn` and restores the previous state on return — even if `fn` returns an error. @@ -246,7 +250,28 @@ _ = sysproxy.WriteAppConfig(sysproxy.AppCurl, "http://username:password@proxy.pr | ScopeUser (rc files) | ✓ | ✓ | ✓ | ✓ | | Credential Manager | — | — | — | ✓ | -> **Linux:** `ScopeGlobal` writes `/etc/environment` (requires root) and calls `gsettings`/`kwriteconfig5` for the active desktop session. +> **Linux:** `ScopeGlobal` writes `/etc/environment` (requires root) and calls `gsettings` **and** `kwriteconfig5` if available, so hybrid GNOME/KDE setups are covered without desktop detection. Failure to write `/etc/environment` is returned as a non-critical error — use `sysproxy.IsNonCritical(err)` to distinguish it from a hard failure. + +## Comparison with alternatives + +The table below compares `go-sysproxy` with other Go proxy-management libraries by API surface and behavior. + +| | `mar0ls/go-sysproxy` | [`Jigsaw-Code/outline-sdk/x/sysproxy`](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/x/sysproxy) | +|---|:---:|:---:| +| macOS (`networksetup`) | ✓ | ✓ | +| Linux GNOME (`gsettings`) | ✓ | ✓ | +| Linux KDE (`kwriteconfig5`) | ✓ | — | +| Windows (registry + `cmdkey`) | ✓ | ✓ | +| `Get` / `GetConfig` per protocol | ✓ | partial | +| `SetMulti` (HTTP/HTTPS/SOCKS + NoProxy) | ✓ | — | +| `SetPAC` | ✓ | — | +| `Check` (TCP reachability) | ✓ | — | +| `WithProxy` (temporary, auto-restore) | ✓ | — | +| `WriteAppConfig` (rc files for git/npm/pip/…) | ✓ | — | +| Context-aware API (`*Context`) | ✓ | partial | +| Standalone module, no SDK to import | ✓ | — (part of outline-sdk) | +| Zero external dependencies | ✓ | — | +| CLI (`sysproxy` binary, `--json`) | ✓ | — | ## Security diff --git a/cmd/sysproxy/helpers_test.go b/cmd/sysproxy/helpers_test.go new file mode 100644 index 0000000..d5c2faf --- /dev/null +++ b/cmd/sysproxy/helpers_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "testing" + + sysproxy "github.com/mar0ls/go-sysproxy" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { + os.Stdout = orig + }) + + fn() + _ = w.Close() + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + _ = r.Close() + return buf.String() +} + +func TestParseScope(t *testing.T) { + tests := []struct { + in string + want sysproxy.ProxyScope + wantErr bool + }{ + {in: "shell", want: sysproxy.ScopeShell}, + {in: "user", want: sysproxy.ScopeUser}, + {in: "global", want: sysproxy.ScopeGlobal}, + {in: "bad", wantErr: true}, + } + + for _, tt := range tests { + got, err := parseScope(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("parseScope(%q) expected error", tt.in) + } + continue + } + if err != nil { + t.Fatalf("parseScope(%q) unexpected error: %v", tt.in, err) + } + if got != tt.want { + t.Fatalf("parseScope(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestPrintJSON(t *testing.T) { + out := captureStdout(t, func() { + printJSON(os.Stdout, map[string]any{"ok": true}) + }) + + var v map[string]any + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + if got, ok := v["ok"].(bool); !ok || !got { + t.Fatalf("unexpected JSON content: %v", v) + } +} + +func TestPrintOKTextAndJSON(t *testing.T) { + outText := captureStdout(t, func() { + printOK(false, map[string]any{"x": 1}, os.Stdout) + }) + if strings.TrimSpace(outText) != "ok" { + t.Fatalf("printOK text output = %q, want %q", strings.TrimSpace(outText), "ok") + } + + outJSON := captureStdout(t, func() { + printOK(true, map[string]any{"scope": "global"}, os.Stdout) + }) + var v map[string]any + if err := json.Unmarshal([]byte(outJSON), &v); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + if got, ok := v["ok"].(bool); !ok || !got { + t.Fatalf("expected ok=true in JSON, got %v", v) + } + if v["scope"] != "global" { + t.Fatalf("expected scope=global in JSON, got %v", v) + } +} + +// TestRunUsageAndErrors exercises the argument-parsing and dispatch branches of +// run that return before touching any OS proxy setting. +func TestRunUsageAndErrors(t *testing.T) { + tests := []struct { + name string + args []string + wantCode int + inStdout string + inStderr string + }{ + {name: "no args", args: nil, wantCode: 1, inStderr: "sysproxy"}, + {name: "help", args: []string{"help"}, wantCode: 0, inStdout: "Usage"}, + {name: "help long", args: []string{"--help"}, wantCode: 0, inStdout: "Usage"}, + {name: "help short", args: []string{"-h"}, wantCode: 0, inStdout: "Usage"}, + {name: "version", args: []string{"version"}, wantCode: 0, inStdout: "commit"}, + {name: "unknown command", args: []string{"frobnicate"}, wantCode: 1, inStderr: "frobnicate"}, + {name: "set missing url", args: []string{"set"}, wantCode: 1, inStderr: "usage: sysproxy set"}, + {name: "pac missing url", args: []string{"pac"}, wantCode: 1, inStderr: "usage: sysproxy pac"}, + {name: "check missing url", args: []string{"check"}, wantCode: 1, inStderr: "usage: sysproxy check"}, + {name: "invalid scope", args: []string{"set", "http://127.0.0.1:8080", "--scope", "bogus"}, wantCode: 1, inStderr: "invalid scope"}, + {name: "invalid timeout", args: []string{"check", "http://127.0.0.1:1", "--timeout", "nope"}, wantCode: 1, inStderr: "invalid --timeout"}, + {name: "scope without value", args: []string{"set", "http://127.0.0.1:8080", "--scope"}, wantCode: 1, inStderr: "--scope requires"}, + {name: "timeout without value", args: []string{"check", "http://127.0.0.1:1", "--timeout"}, wantCode: 1, inStderr: "--timeout requires"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(tt.args, &stdout, &stderr) + if code != tt.wantCode { + t.Fatalf("run(%v) = %d, want %d (stderr=%q)", tt.args, code, tt.wantCode, stderr.String()) + } + if tt.inStdout != "" && !strings.Contains(stdout.String(), tt.inStdout) { + t.Errorf("stdout = %q, want substring %q", stdout.String(), tt.inStdout) + } + if tt.inStderr != "" && !strings.Contains(stderr.String(), tt.inStderr) { + t.Errorf("stderr = %q, want substring %q", stderr.String(), tt.inStderr) + } + }) + } +} + +// TestRunCheckUnreachable drives the check command through run for an address +// that cannot be reached, covering cmdCheck's error path without changing any +// OS proxy state. +func TestRunCheckUnreachable(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"check", "http://192.0.2.1:9999", "--timeout", "300ms"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit 1 for unreachable proxy, got %d", code) + } + if !strings.Contains(stderr.String(), "unreachable") { + t.Errorf("expected 'unreachable' on stderr, got %q", stderr.String()) + } +} + +// TestRunCheckUnreachableJSON covers the JSON error branch of cmdCheck via run. +func TestRunCheckUnreachableJSON(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"check", "http://192.0.2.1:9999", "--timeout", "300ms", "--json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + var m map[string]any + if err := json.Unmarshal(stdout.Bytes(), &m); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %q", err, stdout.String()) + } + if _, ok := m["error"]; !ok { + t.Errorf("expected 'error' key in JSON, got %v", m) + } +} diff --git a/cmd/sysproxy/main.go b/cmd/sysproxy/main.go index 9641165..71f1cc2 100644 --- a/cmd/sysproxy/main.go +++ b/cmd/sysproxy/main.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "time" @@ -44,13 +45,20 @@ Examples: ` func main() { - if len(os.Args) < 2 { - fmt.Fprint(os.Stderr, usage) - os.Exit(1) + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +// run parses argv and dispatches to a command, returning the process exit code. +// It is separated from main so it can be exercised in unit tests without +// spawning a subprocess or calling os.Exit. +func run(argv []string, stdout, stderr io.Writer) int { + if len(argv) < 1 { + _, _ = fmt.Fprint(stderr, usage) + return 1 } - cmd := os.Args[1] - args := os.Args[2:] + cmd := argv[0] + args := argv[1:] // shared flags scopeStr := "global" @@ -61,19 +69,19 @@ func main() { 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") + if i+1 >= len(args) { + return die(stderr, "--scope requires a value: shell|user|global") } - scopeStr = args[i] + scopeStr = args[i+1] //nolint:gosec // bounds checked on the line above + i++ case "--json": jsonOut = true case "--timeout": - i++ - if i >= len(args) { - die("--timeout requires a duration value, e.g. 5s") + if i+1 >= len(args) { + return die(stderr, "--timeout requires a duration value, e.g. 5s") } - timeoutStr = args[i] + timeoutStr = args[i+1] //nolint:gosec // bounds checked on the line above + i++ default: positional = append(positional, args[i]) } @@ -81,100 +89,112 @@ func main() { scope, err := parseScope(scopeStr) if err != nil { - die(err.Error()) + return die(stderr, err.Error()) } timeout, err := time.ParseDuration(timeoutStr) if err != nil { - die("invalid --timeout value: " + err.Error()) + return die(stderr, "invalid --timeout value: "+err.Error()) } switch cmd { case "set": if len(positional) < 1 { - die("usage: sysproxy set ") + return die(stderr, "usage: sysproxy set ") } - cmdSet(positional[0], scope, jsonOut) + return cmdSet(positional[0], scope, jsonOut, stdout, stderr) case "get": - cmdGet(jsonOut) + return cmdGet(jsonOut, stdout, stderr) case "unset": - cmdUnset(scope, jsonOut) + return cmdUnset(scope, jsonOut, stdout, stderr) case "pac": if len(positional) < 1 { - die("usage: sysproxy pac ") + return die(stderr, "usage: sysproxy pac ") } - cmdPAC(positional[0], scope, jsonOut) + return cmdPAC(positional[0], scope, jsonOut, stdout, stderr) case "check": if len(positional) < 1 { - die("usage: sysproxy check ") + return die(stderr, "usage: sysproxy check ") } - cmdCheck(positional[0], timeout, jsonOut) + return cmdCheck(positional[0], timeout, jsonOut, stdout, stderr) case "version": - fmt.Println(buildinfo.Summary()) + _, _ = fmt.Fprintln(stdout, buildinfo.Summary()) + return 0 case "--help", "-h", "help": - fmt.Print(usage) + _, _ = fmt.Fprint(stdout, usage) + return 0 default: - fmt.Fprintf(os.Stderr, "unknown command: %q\n\n", cmd) - fmt.Fprint(os.Stderr, usage) - os.Exit(1) + _, _ = fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd) + _, _ = fmt.Fprint(stderr, usage) + return 1 } } -func cmdSet(proxyURL string, scope sysproxy.ProxyScope, jsonOut bool) { +func cmdSet(proxyURL string, scope sysproxy.ProxyScope, jsonOut bool, stdout, stderr io.Writer) int { 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()) + return dieJSON(jsonOut, "set failed: "+err.Error(), stdout, stderr) } - printOK(jsonOut, map[string]any{"proxy": proxyURL, "scope": scope.String()}) + printOK(jsonOut, map[string]any{"proxy": proxyURL, "scope": scope.String()}, stdout) + return 0 } -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") +func cmdGet(jsonOut bool, stdout, stderr io.Writer) int { + if jsonOut { + cfg, err := sysproxy.GetConfig() + if err != nil { + printJSON(stdout, map[string]any{"error": err.Error()}) + return 2 } - os.Exit(2) + if cfg.HTTP == "" && cfg.HTTPS == "" && cfg.SOCKS == "" { + printJSON(stdout, map[string]any{"error": "proxy not set"}) + return 2 + } + printJSON(stdout, cfg) + return 0 } - if jsonOut { - printJSON(map[string]any{"proxy": url}) - } else { - fmt.Println(url) + url, err := sysproxy.Get() + if err != nil { + _, _ = fmt.Fprintln(stderr, "proxy not set") + return 2 } + _, _ = fmt.Fprintln(stdout, url) + return 0 } -func cmdUnset(scope sysproxy.ProxyScope, jsonOut bool) { +func cmdUnset(scope sysproxy.ProxyScope, jsonOut bool, stdout, stderr io.Writer) int { 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()) + return dieJSON(jsonOut, "unset failed: "+err.Error(), stdout, stderr) } - printOK(jsonOut, map[string]any{"scope": scope.String()}) + printOK(jsonOut, map[string]any{"scope": scope.String()}, stdout) + return 0 } -func cmdPAC(pacURL string, scope sysproxy.ProxyScope, jsonOut bool) { +func cmdPAC(pacURL string, scope sysproxy.ProxyScope, jsonOut bool, stdout, stderr io.Writer) int { 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()) + return dieJSON(jsonOut, "pac failed: "+err.Error(), stdout, stderr) } - printOK(jsonOut, map[string]any{"pac": pacURL, "scope": scope.String()}) + printOK(jsonOut, map[string]any{"pac": pacURL, "scope": scope.String()}, stdout) + return 0 } -func cmdCheck(proxyURL string, timeout time.Duration, jsonOut bool) { +func cmdCheck(proxyURL string, timeout time.Duration, jsonOut bool, stdout, stderr io.Writer) int { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := sysproxy.Check(ctx, proxyURL); err != nil { - dieJSON(jsonOut, "unreachable: "+err.Error()) + return dieJSON(jsonOut, "unreachable: "+err.Error(), stdout, stderr) } - printOK(jsonOut, map[string]any{"proxy": proxyURL, "reachable": true}) + printOK(jsonOut, map[string]any{"proxy": proxyURL, "reachable": true}, stdout) + return 0 } func parseScope(s string) (sysproxy.ProxyScope, error) { @@ -190,31 +210,31 @@ func parseScope(s string) (sysproxy.ProxyScope, error) { } } -func printOK(jsonOut bool, fields map[string]any) { +func printOK(jsonOut bool, fields map[string]any, stdout io.Writer) { if jsonOut { fields["ok"] = true - printJSON(fields) + printJSON(stdout, fields) } else { - fmt.Println("ok") + _, _ = fmt.Fprintln(stdout, "ok") } } -func printJSON(v any) { - enc := json.NewEncoder(os.Stdout) +func printJSON(stdout io.Writer, v any) { + enc := json.NewEncoder(stdout) enc.SetIndent("", " ") _ = enc.Encode(v) } -func die(msg string) { - fmt.Fprintln(os.Stderr, "error: "+msg) - os.Exit(1) +func die(stderr io.Writer, msg string) int { + _, _ = fmt.Fprintln(stderr, "error: "+msg) + return 1 } -func dieJSON(jsonOut bool, msg string) { +func dieJSON(jsonOut bool, msg string, stdout, stderr io.Writer) int { if jsonOut { - printJSON(map[string]any{"ok": false, "error": msg}) + printJSON(stdout, map[string]any{"ok": false, "error": msg}) } else { - fmt.Fprintln(os.Stderr, "error: "+msg) + _, _ = fmt.Fprintln(stderr, "error: "+msg) } - os.Exit(1) + return 1 } diff --git a/cmd/sysproxy/main_test.go b/cmd/sysproxy/main_test.go index 4c4cd28..2de85af 100644 --- a/cmd/sysproxy/main_test.go +++ b/cmd/sysproxy/main_test.go @@ -105,7 +105,7 @@ func TestGetNotSet_ExitCode2(t *testing.T) { } } -// TestGetJSON_OutputShape verifies the JSON output is valid JSON with expected key. +// TestGetJSON_OutputShape verifies the JSON output is valid JSON with expected key(s). 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") @@ -119,10 +119,11 @@ func TestGetJSON_OutputShape(t *testing.T) { 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) - } + if _, hasErr := m["error"]; hasErr { + return + } + if _, hasHTTP := m["http"]; !hasHTTP { + t.Errorf("JSON output missing 'http' or 'error' key: %v", m) } } diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..788da5c --- /dev/null +++ b/errors.go @@ -0,0 +1,36 @@ +package sysproxy + +import "errors" + +// nonCriticalError wraps errors from operations that do not invalidate the +// overall request. For example, /etc/environment on Linux requires root; if +// the rest of the configuration (GNOME/KDE/process env) succeeded, the caller +// should be able to distinguish that warning from a hard failure. +type nonCriticalError struct { + err error +} + +func (e *nonCriticalError) Error() string { + if e == nil || e.err == nil { + return "sysproxy: non-critical error" + } + return e.err.Error() +} + +func (e *nonCriticalError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +// IsNonCritical reports whether err is a non-fatal warning emitted by sysproxy +// (for example, failure to write /etc/environment without root privileges). +// When true, the rest of the operation should be considered successful. +func IsNonCritical(err error) bool { + if err == nil { + return false + } + var nc *nonCriticalError + return errors.As(err, &nc) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..5a5f30b --- /dev/null +++ b/errors_test.go @@ -0,0 +1,47 @@ +package sysproxy + +import ( + "errors" + "testing" +) + +func TestIsNonCritical(t *testing.T) { + if IsNonCritical(nil) { + t.Fatal("nil error must not be non-critical") + } + + plain := errors.New("plain") + if IsNonCritical(plain) { + t.Fatal("plain error must not be non-critical") + } + + nc := &nonCriticalError{err: plain} + if !IsNonCritical(nc) { + t.Fatal("nonCriticalError must be detected") + } + + wrapped := errors.New("wrapper: " + nc.Error()) + if IsNonCritical(wrapped) { + t.Fatal("string-wrapped error must not be detected via errors.As") + } +} + +func TestNonCriticalErrorMethods(t *testing.T) { + base := errors.New("permission denied") + nc := &nonCriticalError{err: base} + + if got := nc.Error(); got != "permission denied" { + t.Fatalf("Error() = %q, want %q", got, "permission denied") + } + if !errors.Is(nc, base) { + t.Fatal("Unwrap should expose wrapped error") + } + + var nilNC *nonCriticalError + if got := nilNC.Error(); got == "" { + t.Fatal("nil receiver Error() should return fallback message") + } + if nilNC.Unwrap() != nil { + t.Fatal("nil receiver Unwrap() should return nil") + } +} diff --git a/example_test.go b/example_test.go index e74e140..b901941 100644 --- a/example_test.go +++ b/example_test.go @@ -100,3 +100,30 @@ func ExampleSetLogger() { fmt.Println("logging disabled") // Output: logging disabled } + +func ExampleGet() { + url, err := sysproxy.Get() + if err != nil { + log.Printf("no proxy configured: %v", err) + return + } + fmt.Println("current proxy:", url) +} + +func ExampleGetConfig() { + cfg, err := sysproxy.GetConfig() + if err != nil { + log.Fatal(err) + } + fmt.Println("HTTP:", cfg.HTTP) + fmt.Println("HTTPS:", cfg.HTTPS) + fmt.Println("SOCKS:", cfg.SOCKS) + fmt.Println("NoProxy:", cfg.NoProxy) +} + +func ExampleSetPAC() { + if err := sysproxy.SetPAC("http://wpad.example.com/proxy.pac", sysproxy.ScopeGlobal); err != nil { + log.Fatal(err) + } + fmt.Println("PAC configured") +} diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index db541bf..188de11 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -2,7 +2,7 @@ package buildinfo import "fmt" -// Injected via -ldflags at build time. +// Version, Commit, and BuildDate are injected via -ldflags at build time. var ( Version = "dev" Commit = "unknown" diff --git a/sysproxy.go b/sysproxy.go index 135a20b..221f6b6 100644 --- a/sysproxy.go +++ b/sysproxy.go @@ -50,10 +50,10 @@ func (s ProxyScope) String() string { // ProxyConfig holds per-protocol proxy URLs for SetMulti. // Any field left empty is ignored. type ProxyConfig struct { - HTTP string - HTTPS string - SOCKS string - NoProxy string // comma-separated bypass list, e.g. "localhost,10.0.0.0/8" + HTTP string `json:"http,omitempty"` + HTTPS string `json:"https,omitempty"` + SOCKS string `json:"socks,omitempty"` + NoProxy string `json:"no_proxy,omitempty"` // comma-separated bypass list, e.g. "localhost,10.0.0.0/8" } // Set configures the OS system proxy to proxyURL for the given scope. diff --git a/sysproxy_darwin.go b/sysproxy_darwin.go index d33f584..353e734 100644 --- a/sysproxy_darwin.go +++ b/sysproxy_darwin.go @@ -44,6 +44,10 @@ func unsetGlobal(ctx context.Context) error { _ = runNetworkSetup(ctx, "-setwebproxystate", svc, "off") _ = runNetworkSetup(ctx, "-setsecurewebproxystate", svc, "off") _ = runNetworkSetup(ctx, "-setsocksfirewallproxystate", svc, "off") + // Clear host/port so the previous values do not stay visible in System Settings. + _ = runNetworkSetup(ctx, "-setwebproxy", svc, "", "0") + _ = runNetworkSetup(ctx, "-setsecurewebproxy", svc, "", "0") + _ = runNetworkSetup(ctx, "-setsocksfirewallproxy", svc, "", "0") } return nil } diff --git a/sysproxy_linux.go b/sysproxy_linux.go index 67f41c0..84576dc 100644 --- a/sysproxy_linux.go +++ b/sysproxy_linux.go @@ -19,27 +19,26 @@ func runKwriteconfig5(ctx context.Context, args ...string) error { } func setGlobal(ctx context.Context, p *proxy) error { - switch detectDesktopEnv() { - case "gnome": - if isAvailable("gsettings") { - _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "manual") - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "host", p.host) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "port", p.port) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "host", p.host) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "port", p.port) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "host", p.host) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "port", p.port) - } - case "kde": - if isAvailable("kwriteconfig5") { - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", p.rawURL) - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", p.rawURL) - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ftpProxy", p.rawURL) - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", p.rawURL) - } + if isAvailable("gsettings") { + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "manual") + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "host", p.host) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "port", p.port) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "host", p.host) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "port", p.port) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "host", p.host) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "port", p.port) + } + if isAvailable("kwriteconfig5") { + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", p.rawURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", p.rawURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ftpProxy", p.rawURL) + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", p.rawURL) + } + if err := writeEtcEnvironment("/etc/environment", p.rawURL); err != nil { + return &nonCriticalError{err: err} } - return writeEtcEnvironment("/etc/environment", p.rawURL) + return nil } func unsetGlobal(ctx context.Context) error { @@ -49,7 +48,10 @@ func unsetGlobal(ctx context.Context) error { if isAvailable("kwriteconfig5") { _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") } - return clearEtcEnvironment("/etc/environment") + if err := clearEtcEnvironment("/etc/environment"); err != nil { + return &nonCriticalError{err: err} + } + return nil } func getGlobal(ctx context.Context) (string, error) { @@ -129,54 +131,46 @@ func getGlobalConfig(ctx context.Context) (ProxyConfig, error) { } func setGlobalPAC(ctx context.Context, pacURL string) error { - switch detectDesktopEnv() { - case "gnome": - if isAvailable("gsettings") { - _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "auto") - _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "autoconfig-url", pacURL) - } - case "kde": - if isAvailable("kwriteconfig5") { - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "2") - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Proxy Config Script", pacURL) - } + if isAvailable("gsettings") { + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "auto") + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "autoconfig-url", pacURL) + } + if isAvailable("kwriteconfig5") { + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "2") + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Proxy Config Script", pacURL) } return nil } func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { - switch detectDesktopEnv() { - case "gnome": - if isAvailable("gsettings") { - if cfg.HTTP != "" { - _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "manual") - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "host", hostFromURL(cfg.HTTP)) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "port", portFromURL(cfg.HTTP)) - } - if cfg.HTTPS != "" { - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "host", hostFromURL(cfg.HTTPS)) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "port", portFromURL(cfg.HTTPS)) - } - if cfg.SOCKS != "" { - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "host", hostFromURL(cfg.SOCKS)) - _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "port", portFromURL(cfg.SOCKS)) - } + if isAvailable("gsettings") { + if cfg.HTTP != "" { + _ = runGsettings(ctx, "set", "org.gnome.system.proxy", "mode", "manual") + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "host", hostFromURL(cfg.HTTP)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.http", "port", portFromURL(cfg.HTTP)) + } + if cfg.HTTPS != "" { + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "host", hostFromURL(cfg.HTTPS)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.https", "port", portFromURL(cfg.HTTPS)) + } + if cfg.SOCKS != "" { + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "host", hostFromURL(cfg.SOCKS)) + _ = runGsettings(ctx, "set", "org.gnome.system.proxy.socks", "port", portFromURL(cfg.SOCKS)) + } + } + if isAvailable("kwriteconfig5") { + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") + if cfg.HTTP != "" { + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", cfg.HTTP) + } + if cfg.HTTPS != "" { + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", cfg.HTTPS) } - case "kde": - if isAvailable("kwriteconfig5") { - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") - if cfg.HTTP != "" { - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpProxy", cfg.HTTP) - } - if cfg.HTTPS != "" { - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "httpsProxy", cfg.HTTPS) - } - if cfg.SOCKS != "" { - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", cfg.SOCKS) - } - if cfg.NoProxy != "" { - _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "NoProxyFor", cfg.NoProxy) - } + if cfg.SOCKS != "" { + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", cfg.SOCKS) + } + if cfg.NoProxy != "" { + _ = runKwriteconfig5(ctx, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "NoProxyFor", cfg.NoProxy) } } return nil @@ -198,19 +192,6 @@ func (linuxBackend) SetGlobalMulti(ctx context.Context, c ProxyConfig) error { func init() { activeBackend = linuxBackend{} } -func detectDesktopEnv() string { - for _, env := range []string{"XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "GDMSESSION"} { - v := strings.ToLower(os.Getenv(env)) - switch { - case strings.Contains(v, "gnome"): - return "gnome" - case strings.Contains(v, "kde"): - return "kde" - } - } - return "" -} - func writeEtcEnvironment(path, proxyURL string) error { data, err := os.ReadFile(path) //nolint:gosec if err != nil && !os.IsNotExist(err) { diff --git a/sysproxy_windows.go b/sysproxy_windows.go index f58e922..2784bdb 100644 --- a/sysproxy_windows.go +++ b/sysproxy_windows.go @@ -26,6 +26,7 @@ func runRundll32(ctx context.Context, args ...string) error { } func setGlobal(ctx context.Context, p *proxy) error { + _ = runReg(ctx, "delete", regKey, "/v", "AutoConfigURL", "/f") _ = runReg(ctx, "add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f") _ = runReg(ctx, "add", regKey, "/v", "ProxyServer", "/t", "REG_SZ", "/d", p.host+":"+p.port, "/f") _ = runReg(ctx, "add", regKey, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", "localhost;127.0.0.1;::1", "/f") @@ -40,6 +41,7 @@ func unsetGlobal(ctx context.Context) error { _ = runReg(ctx, "add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f") _ = runReg(ctx, "delete", regKey, "/v", "ProxyServer", "/f") _ = runReg(ctx, "delete", regKey, "/v", "ProxyOverride", "/f") + _ = runReg(ctx, "delete", regKey, "/v", "AutoConfigURL", "/f") if host, err := currentProxyHost(ctx); err == nil && host != "" { _ = runCmdkey(ctx, "/delete:"+host) } @@ -95,6 +97,7 @@ func setGlobalPAC(ctx context.Context, pacURL string) error { } func setGlobalMulti(ctx context.Context, cfg ProxyConfig) error { + _ = runReg(ctx, "delete", regKey, "/v", "AutoConfigURL", "/f") _ = runReg(ctx, "add", regKey, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f") var servers []string if cfg.HTTP != "" {