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

111 lines
3.7 KiB
Go

package netproxy
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/yetone/magpie/internal/settings"
)
// stubLookup answers names from m for the test, and forgets what earlier
// lookups found.
func stubLookup(t *testing.T, m map[string]string) {
old := lanLookup
lanLookup = func(_ context.Context, host string) ([]net.IPAddr, error) {
ip, ok := m[host]
if !ok {
return nil, errors.New("no such host")
}
return []net.IPAddr{{IP: net.ParseIP(ip)}}, nil
}
clear(lanCache.m)
t.Cleanup(func() { lanLookup = old; clear(lanCache.m) })
}
// #982: with a proxy set, a Remote magpie on the user's network is asked
// directly; a vendor still goes through the proxy, and so does a provider
// with a proxy of its own.
func TestLANGoesAroundTheProxy(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
settings.Save(settings.Settings{Proxy: "127.0.0.1:7890"})
stubLookup(t, map[string]string{"server.lan": "192.168.131.20", "nas.tail1234.ts.net": "100.101.102.103", "api.openai.com": "162.159.140.245"})
req := func(host, choice string) *http.Request {
return (&http.Request{URL: &url.URL{Scheme: "http", Host: host}}).WithContext(With(context.Background(), choice))
}
for _, h := range []string{"192.168.131.20:3425", "10.0.0.5:3425", "172.20.1.1", "100.101.102.103:3425", "[fd7a:115c:a1e0::1]:3425", "server.lan:3425", "nas.tail1234.ts.net:3425", "mac-mini.local:3425"} {
if p, err := Func(req(h, "")); p != nil || err != nil {
t.Errorf("%s: through %v (%v), want direct", h, p, err)
}
}
for _, h := range []string{"api.openai.com", "unresolvable.example", "8.8.8.8"} {
if p, _ := Func(req(h, "")); p == nil || p.Host != "127.0.0.1:7890" {
t.Errorf("%s: %v, want the proxy", h, p)
}
}
if p, _ := Func(req("192.168.131.20:3425", "socks5://127.0.0.1:1080")); p == nil || p.Host != "127.0.0.1:1080" {
t.Errorf("a provider's own proxy: %v", p)
}
}
// The reporter's case end to end: a proxy that answers 502 Bad Gateway to
// whatever it is asked (as Clash does for a host it can't reach), set in
// magpie, and another magpie at this computer's LAN address. The list
// comes from the other magpie, not the proxy's 502.
func TestLANModelListNotThroughProxy(t *testing.T) {
var lan net.IP
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && n.IP.IsPrivate() {
lan = n.IP
break
}
}
if lan == nil {
t.Skip("no private IPv4 address on this computer")
}
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer proxy.Close()
ln, err := net.Listen("tcp", net.JoinHostPort(lan.String(), "0"))
if err != nil {
t.Skip("can't listen on", lan, err)
}
remote := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sk-magpie-key" {
http.Error(w, "no API key was sent", http.StatusUnauthorized)
return
}
io.WriteString(w, `{"object":"list","data":[{"id":"codex/gpt-6.1-sol"}]}`)
}))
remote.Listener = ln
remote.Start()
defer remote.Close()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
settings.Save(settings.Settings{Proxy: proxy.URL})
clear(lanCache.m)
c := &http.Client{Transport: Dispatch(&http.Transport{Proxy: Func})}
req, _ := http.NewRequest("GET", remote.URL+"/v1/models", nil)
req.Header.Set("Authorization", "Bearer sk-magpie-key")
res, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode != 200 {
t.Fatalf("%s: %s %s", remote.URL, res.Status, b)
}
if !strings.Contains(string(b), "codex/gpt-6.1-sol") {
t.Fatalf("not the other magpie's list: %s", b)
}
}