Files
magpie/internal/netproxy/netproxy.go
T
yetone 3dc8f00f06 providers: each account of a subscription holding several can go through its own proxy — the provider's, none, or its own address (Discord, gakki: 是否可以为不同的codex账号设置不用的代理,现在是同一个供应商下使用相同的代理) — the per-provider proxy (#237) was one for all of Codex's signed-in accounts. A subscription's entry in providers.json now keeps accountProxies, by the account's name in lower case, each checked by settings.CheckProxy on save (ftp:// refused), addresses trimmed, accounts following the provider's proxy left out. Provider.ProxyChoice picks an account's own proxy, else the provider's, else the global one, so everything that already went through Provider.Via follows the account: gateway turns and fallback across accounts, usage and quota, token refresh, Codex's warm-up, reset and saved accounts' model lists, Claude's warm-up and the *_PROXY of Claude Code runs (ViaLogin / ProxyOfLogin), and Codex's own sign-in relayed as it came (ViaSignedIn). Extra accounts of Copilot, ZCode, Kiro and the like, made afresh without the provider's settings, now read them from providers.json, so they honour the provider's proxy too. A save without accountProxies keeps them; {} clears them. The provider editor, for a subscription with two or more accounts, lists each under the Proxy row: Provider's proxy | Direct | Custom, the address field's room kept so a pick moves nothing; Custom with no address is refused before anything is posted, its field focused without scrolling. en and zh together; no border stripes. Tested with internal/gateway/account_proxy_test.go in a sandbox HOME against fake proxies and a fake ChatGPT backend: three Codex accounts (own proxy, direct, following the provider) each reach the backend their own way in one turn and for usage; clearing an account's proxy sends it through the provider's, clearing the provider's through the global one, direct staying direct; ftp:// refused; TestClaudeAccountProxyEnv covers each Claude account's HTTPS_PROXY. Both fail with the per-account lookup off. account-proxy.test.cjs, Chromium and WebKit, en and zh (new), provider-proxy, click-scroll and old-webkit pass. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against a real proxy, real accounts or the real Wails window; Grok's per-account usage and Kiro's usage on a key still use the provider's proxy.
2026-09-30 04:52:03 +08:00

320 lines
8.6 KiB
Go

// Package netproxy decides which proxy magpie's own requests to vendors go
// through. An app started from the Dock or the Start menu has no
// HTTPS_PROXY in its environment, so without this every call to a vendor
// that is only reachable through a proxy hangs until the agent gives up.
//
// In order: the proxy set in magpie's settings (or "direct" for none), the
// usual *_PROXY variables, then the system's proxy (macOS network
// settings, Windows Internet Options). Loopback is never proxied.
package netproxy
import (
"context"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/settings"
)
// Install routes http.DefaultTransport (and so http.DefaultClient) through
// Func, and http.DefaultClient's requests made on a provider's behalf
// (With) through a transport of that provider's proxy (Dispatch).
func Install() {
if t, ok := http.DefaultTransport.(*http.Transport); ok {
t.Proxy = Func
http.DefaultClient.Transport = Dispatch(t)
}
}
// Func is an http.Transport Proxy function: the proxy the request's
// context names (With), or else the global one.
func Func(req *http.Request) (*url.URL, error) {
if loopback(req.URL.Hostname()) {
return nil, nil
}
if c := choiceOf(req.Context()); c != "" {
return forChoice(c)
}
return For(req.URL)
}
type choiceKey struct{}
// With is ctx for requests made on behalf of one provider, whose own proxy
// is choice (issue #237: Codex through a proxy, a vendor at home without):
// "" follows the global one (ctx is returned as it is), "direct" goes
// through none, anything else is the proxy's address (http://, https://,
// socks5://; host:port means http). Requests made with it through Func,
// or through a transport Dispatch made, take that proxy.
func With(ctx context.Context, choice string) context.Context {
if choice = strings.TrimSpace(choice); choice == "" {
return ctx
}
return context.WithValue(ctx, choiceKey{}, choice)
}
// Choice is the proxy ctx names (With), "" when it names none — for a CLI
// run on a provider's behalf, whose *_PROXY is set from it (EnvWith).
func Choice(ctx context.Context) string { return choiceOf(ctx) }
func choiceOf(ctx context.Context) string {
if ctx == nil {
return ""
}
c, _ := ctx.Value(choiceKey{}).(string)
return c
}
// forChoice is the proxy a provider's own choice names; nil for "direct".
func forChoice(c string) (*url.URL, error) {
if c == "direct" {
return nil, nil
}
return Parse(c)
}
// Dispatch is a RoundTripper sending a request through base, unless its
// context names a provider's own proxy (With): then through a clone of
// base kept for that proxy alone. Connections are so reused among the
// requests going through one proxy and never carried to another — HTTP/2
// ones, which a transport shares by host whatever proxy they were dialled
// through, among them.
func Dispatch(base *http.Transport) http.RoundTripper {
return &dispatch{base: base, own: map[string]*http.Transport{}}
}
type dispatch struct {
base *http.Transport
mu sync.Mutex
own map[string]*http.Transport
}
func (d *dispatch) RoundTrip(req *http.Request) (*http.Response, error) {
c := choiceOf(req.Context())
if c == "" {
return d.base.RoundTrip(req)
}
return d.transport(c).RoundTrip(req)
}
func (d *dispatch) transport(c string) *http.Transport {
d.mu.Lock()
defer d.mu.Unlock()
if t, ok := d.own[c]; ok {
return t
}
if len(d.own) >= 32 { // choices come and go as they are edited
for k, t := range d.own {
t.CloseIdleConnections()
delete(d.own, k)
}
}
t := d.base.Clone()
t.Proxy = func(req *http.Request) (*url.URL, error) {
if loopback(req.URL.Hostname()) {
return nil, nil
}
return forChoice(c)
}
d.own[c] = t
return t
}
// CloseIdleConnections closes base's and every proxy's own.
func (d *dispatch) CloseIdleConnections() {
d.base.CloseIdleConnections()
d.mu.Lock()
defer d.mu.Unlock()
for _, t := range d.own {
t.CloseIdleConnections()
}
}
// For is the proxy a request to u goes through; nil means direct.
func For(u *url.URL) (*url.URL, error) {
switch s := strings.TrimSpace(settings.Load().Proxy); s {
case "direct":
return nil, nil
case "":
default:
return Parse(s)
}
if p, err := fromEnv(u); p != nil || err != nil {
return p, err
}
sys := System()
if sys.URL == "" || bypassed(u.Hostname(), sys.Bypass) {
return nil, nil
}
return Parse(sys.URL)
}
// Env is env (the process's own when nil) for an agent's CLI that magpie
// starts — a sign-in, or a subscription's run — told to use the proxy
// magpie's own requests would: the CLI reads only *_PROXY, which an app
// started from the Dock or the Start menu doesn't have, so a proxy set in
// magpie or the system's alone left it trying the vendor directly. A
// proxy set in magpie replaces any in env and "direct" drops them; the
// system's is only added where env names none. Loopback never goes
// through it, since a CLI calls back to magpie there.
func Env(env []string) []string { return EnvWith("", env) }
// EnvWith is Env for a CLI run on one provider's behalf, whose own proxy
// is choice (see With): "" is Env's.
func EnvWith(choice string, env []string) []string {
if env == nil {
env = os.Environ()
}
var p string
var bypass []string
s := strings.TrimSpace(choice)
if s == "" {
s = strings.TrimSpace(settings.Load().Proxy)
}
switch s {
case "direct":
return withProxy(env, "", nil)
case "":
for _, e := range env {
k, v, _ := strings.Cut(e, "=")
if proxyVar(k) && !strings.EqualFold(k, "NO_PROXY") && strings.TrimSpace(v) != "" {
return env
}
}
sys := System()
if sys.URL == "" {
return env
}
p, bypass = sys.URL, sys.Bypass
default:
u, err := Parse(s)
if err != nil {
return env
}
p = u.String()
}
return withProxy(env, p, bypass)
}
func proxyVar(k string) bool {
switch strings.ToUpper(k) {
case "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY":
return true
}
return false
}
// withProxy is env with its *_PROXY replaced by p ("" for none), and
// NO_PROXY naming loopback and bypass.
func withProxy(env []string, p string, bypass []string) []string {
out := make([]string, 0, len(env)+8)
for _, e := range env {
if k, _, _ := strings.Cut(e, "="); !proxyVar(k) {
out = append(out, e)
}
}
if p == "" {
return out
}
no := []string{"localhost", "127.0.0.1", "::1"}
for _, b := range bypass {
switch b = strings.TrimPrefix(strings.TrimSpace(b), "*"); b {
case "", "<local>":
default:
no = append(no, b)
}
}
nos := strings.Join(no, ",")
return append(out, "HTTPS_PROXY="+p, "https_proxy="+p, "HTTP_PROXY="+p, "http_proxy="+p,
"ALL_PROXY="+p, "all_proxy="+p, "NO_PROXY="+nos, "no_proxy="+nos)
}
// Parse reads a proxy address; a bare host:port is an HTTP proxy.
func Parse(s string) (*url.URL, error) {
s = strings.TrimSpace(s)
if !strings.Contains(s, "://") {
s = "http://" + s
}
return url.Parse(s)
}
// Describe says, for the settings page, what a request to u would use.
func Describe() (proxy, source string) {
switch s := strings.TrimSpace(settings.Load().Proxy); s {
case "direct":
return "", "off"
case "":
default:
return s, "settings"
}
u, _ := url.Parse("https://chatgpt.com")
if p, _ := fromEnv(u); p != nil {
return p.String(), "environment"
}
if sys := System(); sys.URL != "" {
return sys.URL, "system"
}
return "", "none"
}
func fromEnv(u *url.URL) (*url.URL, error) { return http.ProxyFromEnvironment(&http.Request{URL: u}) }
// Proxy is the system's proxy: one address, and the hosts that skip it.
type Proxy struct {
URL string
Bypass []string
}
var sysCache struct {
sync.Mutex
at time.Time
p Proxy
}
// System reads the system proxy, at most every 15 seconds.
func System() Proxy {
sysCache.Lock()
defer sysCache.Unlock()
if time.Since(sysCache.at) > 15*time.Second {
sysCache.p, sysCache.at = system(), time.Now()
}
return sysCache.p
}
func loopback(host string) bool {
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// bypassed matches the system's exception list: exact hosts, "*.example.com"
// or ".example.com" suffixes, and "<local>" for dotless names.
func bypassed(host string, list []string) bool {
host = strings.ToLower(host)
for _, b := range list {
b = strings.ToLower(strings.TrimSpace(b))
switch {
case b == "":
case b == "<local>":
if !strings.Contains(host, ".") {
return true
}
case strings.HasPrefix(b, "*."), strings.HasPrefix(b, "."):
suf := strings.TrimPrefix(b, "*")
if strings.HasSuffix(host, suf) || host == suf[1:] {
return true
}
case host == b:
return true
}
}
return false
}