Files
yetone fcebf8fac0 netproxy: a host on the user's own network goes around the proxy (#982, jiz4oh: a Remote magpie's model list failed with "no model list: http://server…:3425/v1/models: 502 Bad Gateway", and Test with "no model to try", while curl with the key got the list) — the 502 had no body, which magpie's own errors always carry, and the other magpie logged nothing for the refresh: the request went to the proxy magpie takes from its settings, the environment or the system (Clash and the like), which couldn't reach the LAN name and answered 502. netproxy.Func now sends a request directly when the global proxy would take it and the host is on the user's network: a private, link-local, loopback or 100.64/10 (Tailscale) address, a .local name, or a name whose every address is one of those (looked up for at most 400ms, kept a minute; a name that doesn't resolve still goes through the proxy). A provider's own proxy is still taken as it is. Models, chat, count_tokens and the shared quota cards to a Remote magpie all go through Func (http.DefaultClient and the gateway's transport), so all are covered; so is any other provider on the LAN (an Ollama on a NAS). TestLANModelListNotThroughProxy (a proxy that answers 502 to everything, set in magpie, and a fake remote magpie on this computer's LAN address) fails without the change with the reporter's bare "502 Bad Gateway" and passes; TestLANGoesAroundTheProxy (IPs, LAN/Tailscale names, a vendor, a provider's own proxy) fails without it. Not tried on the reporter's network; they're asked to confirm. go test ./internal/netproxy/ ./internal/catalog/ ./internal/provider/ ./internal/gateway/ ./internal/update/ ./internal/usage/ pass; -race -count=5 on the new tests; go vet, darwin/linux(nogui)/windows builds pass.
2026-10-06 13:35:32 +08:00

377 lines
10 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, nor,
// unless a provider has a proxy of its own, a host on the user's own
// network (lan.go).
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, which a host on the
// user's own network goes around (onLAN).
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)
}
p, err := For(req.URL)
if p != nil && onLAN(req.URL.Hostname()) {
return nil, nil
}
return p, err
}
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. A host whose IPv6 is taken but goes nowhere is
// dialed over IPv4 (v4Fallback).
func Dispatch(base *http.Transport) http.RoundTripper {
v4Fallback(base)
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 v4Retry(d.base, req)
}
return v4Retry(d.transport(c), 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"
}
// fromEnv is the proxy the environment names for u: HTTPS_PROXY or
// HTTP_PROXY as Go reads them, else ALL_PROXY (curl's, which Go doesn't
// read: a shell with only ALL_PROXY=socks5://… set has curl go through it,
// and magpie, its updates among its requests, too). NO_PROXY is kept for
// it as well.
func fromEnv(u *url.URL) (*url.URL, error) {
if p, err := http.ProxyFromEnvironment(&http.Request{URL: u}); p != nil || err != nil {
return p, err
}
all := envOf("ALL_PROXY", "all_proxy")
if all == "" || noProxy(u.Hostname(), envOf("NO_PROXY", "no_proxy")) {
return nil, nil
}
return Parse(all)
}
func envOf(keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
}
return ""
}
// noProxy matches host against NO_PROXY as curl does: "*" for every host,
// else entries apart by commas, each the host or a domain it is under
// (example.com and .example.com both take api.example.com); a port on an
// entry is ignored. Loopback is never proxied anyway.
func noProxy(host, list string) bool {
host = strings.ToLower(strings.TrimSuffix(host, "."))
if host == "" || loopback(host) {
return true
}
for _, e := range strings.Split(list, ",") {
e = strings.ToLower(strings.TrimSpace(e))
if e == "*" {
return true
}
if h, _, err := net.SplitHostPort(e); err == nil {
e = h
}
e = strings.TrimPrefix(strings.TrimPrefix(e, "*"), ".")
if e != "" && (host == e || strings.HasSuffix(host, "."+e)) {
return true
}
}
return false
}
// 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
}