Files
magpie/internal/netproxy/netproxy.go
T
yetone 125da38943 providers: each provider, a signed-in account like Codex's among them, goes through its own proxy — the one in Settings, none, or its own address (#237 xosi: 目前似乎是全局设置代理的,codex和国模供应商无法单独区分设置代理) — magpie had one proxy, Settings' (or the environment's, or the system's), for every request it made, so Codex, which needs a proxy from China, and a vendor at home, which is slower or refused through one, couldn't both be right. A provider now has a proxy, "" (following the global one, as before and the default), "direct" (none, whatever Settings says) or an http://, https:// or socks5:// address (host:port meaning http), kept as proxy in providers.json only when set, and for an account provider in its picks entry beside its models. It is checked on save by the same rule Settings' proxy is (settings.CheckProxy, now shared), so ftp:// and the like are refused. netproxy.With puts a provider's choice on a request's context and netproxy.Func honours it; netproxy.Dispatch sends such a request through a clone of the transport kept for that proxy alone (at most 32, cleared past that), since a transport shares an HTTP/2 connection by host whatever proxy dialled it; loopback is never proxied. The gateway's client and http.DefaultClient go through Dispatch, and every request made on a provider's behalf carries its choice: the gateway's turns (attempt, forward, draw, decide, the Codex backend), and the model lists, balances, plan and subscription usage, tests, sign-in, Codex's warm-up and reset, while the Claude subscription's and warm-up's CLI runs get the account's proxy in their *_PROXY (netproxy.EnvWith). The provider editor, a key provider's and a signed-in account's alike, has a Proxy row after Fallback: Global proxy | Direct | Custom (全局代理 | 直连 | 自定义), the address field beside the options only for Custom, its room kept otherwise so a pick never changes the centred dialog's height and moves nothing, and a line under it saying what the pick does; Save posts proxy ("" , "direct" or the trimmed address), a save without it keeps the old one, and Custom with no address is refused before anything is posted ("Proxy: type its address, like http://127.0.0.1:7890"). No border stripes; en and zh strings together. Tested with a new internal/gateway/proxy_test.go in a sandbox HOME: with a global proxy set, provider A's turns and model list go through its own fake proxy, B's (direct) straight to the vendor, C's (following) through the global one, none through another, and A set back to following goes through the global one; ftp:// is refused (fails with With neutered). netproxy_test.go's TestWith covers Func, Dispatch's per-proxy transports and EnvWith. A new internal/gui/tests/provider-proxy.test.cjs in Chromium and WebKit, en and zh: a key provider opens on Global proxy with no address, Custom unfilled is refused and nothing posted, a padded socks5 address saved trimmed, Direct saved as "direct" and Global as ""; Codex's account opens on Custom with its address and saves Direct with its models; every pick leaves the option within 1px; no left border. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./..., provider-proxy, click-scroll, old-webkit, balance-fix and add-sheet pass (click-scroll's WebKit case failed once alongside the others and passes alone, twice). Not tried against a real proxy or the real Wails window.
2026-09-30 02:15:50 +08:00

316 lines
8.4 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)
}
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
}