gateway: sharing keeps the host MAGPIE_ADDR names (127.0.0.1 behind Tailscale Serve, one interface's address) instead of listening on every interface, and still asks what reaches it from elsewhere for a gateway key (#1112)

listenAddr turned sharing into 0.0.0.0:<port> and dropped the host of an
explicit MAGPIE_ADDR, so requiring gateway keys from remote callers also
widened the listener. A host MAGPIE_ADDR names is now kept while shared;
0.0.0.0, :: or no host still means every interface, and an unset
MAGPIE_ADDR still shares on every interface. LANURLs offers the pinned
address alone, or none on loopback (MAGPIE_PUBLIC_URL says the proxy's).

Verified: TestSharedKeepsPinnedAddr fails without the lan.go change
("shared, MAGPIE_ADDR on loopback, listens on 0.0.0.0:4555") and passes
with it, -race -count=20. go test -race ./internal/gateway ./internal/gui
green under a temp HOME.
This commit is contained in:
yetone committed 2026-10-07 16:47:41 +08:00
1 parent 6b6113aa7a
commit c83c0d5fef
3 files changed
+97 -2

No files matched your search

+4
View File
@@ -341,6 +341,10 @@ permissive: any token works, including a stale or disabled gateway key.
Only a valid, enabled key is attributed to its named identity.
Without sharing, an explicitly exposed `MAGPIE_ADDR` keeps its original open
access, including old `sk-magpie-…` tokens, without key authentication.
Sharing listens on every interface, unless `MAGPIE_ADDR` names a host of its
own: `MAGPIE_ADDR=127.0.0.1:3425` behind Tailscale Serve, or one interface's
address, stays where it is while shared, and what reaches it from elsewhere
still needs an enabled gateway key (#1112).
A request that reaches loopback through a proxy or tunnel on this computer
(Cloudflare Tunnel's `cloudflared`, ngrok, Tailscale serve or funnel, frp's
+30 -2
View File
@@ -24,14 +24,34 @@ import (
// Loopback clients may still use any token, including a stale named key.
// listenAddr is where the gateway listens: every interface while it is
// shared, on its port, else its address.
// shared, on its port, else its address. A host MAGPIE_ADDR names itself
// (127.0.0.1 behind Tailscale Serve, one interface's address) is kept
// while shared too: sharing asks remote callers for a gateway key, and
// never widens the listener past the address the user chose (#1112).
func listenAddr() string {
if s := settings.Load(); s.LAN {
if s := settings.Load(); s.LAN && pinnedHost() == "" {
return "0.0.0.0:" + Port()
}
return Addr()
}
// pinnedHost is the host MAGPIE_ADDR names, "" when it isn't set or names
// every interface (0.0.0.0, ::, or no host).
func pinnedHost() string {
a := os.Getenv("MAGPIE_ADDR")
if a == "" {
return ""
}
h, _, err := net.SplitHostPort(a)
if err != nil || h == "" {
return ""
}
if ip := net.ParseIP(h); ip != nil && ip.IsUnspecified() {
return ""
}
return h
}
// Port is the gateway's port.
func Port() string {
_, p, err := net.SplitHostPort(Addr())
@@ -129,6 +149,14 @@ func LANURLs() []string {
if u := publicURL(); u != "" {
return []string{u}
}
// a gateway MAGPIE_ADDR keeps on one host is reached there alone; on
// loopback, only through a proxy, whose address MAGPIE_PUBLIC_URL says
if h := pinnedHost(); h != "" {
if ip := net.ParseIP(h); h == "localhost" || ip != nil && ip.IsLoopback() {
return nil
}
return []string{"http://" + net.JoinHostPort(h, Port())}
}
var out []string
ifs, _ := net.Interfaces()
for _, i := range ifs {
+63
View File
@@ -140,3 +140,66 @@ func TestProxiedLoopbackRoutes(t *testing.T) {
t.Fatal("MCP through a tunnel without the key:", c)
}
}
// Sharing keeps a host MAGPIE_ADDR names (#1112): a gateway kept on
// loopback behind Tailscale Serve asks what Serve forwards for a gateway
// key once shared, and still listens on loopback alone; the addresses it
// offers other machines are the one it listens on, or none on loopback.
func TestSharedKeepsPinnedAddr(t *testing.T) {
fresh(t)
t.Setenv("MAGPIE_TRUST_PROXY", "")
t.Setenv("MAGPIE_PUBLIC_URL", "")
t.Setenv("MAGPIE_ADDR", "127.0.0.1:4555")
reached := false
h := lanGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
call := func(hdr ...string) int {
reached = false
r := httptest.NewRequest("GET", "/v1/models", nil)
r.RemoteAddr = "127.0.0.1:51234"
for i := 0; i+1 < len(hdr); i += 2 {
r.Header.Set(hdr[i], hdr[i+1])
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
serve := []string{"Tailscale-User-Login", "someone@example.com", "X-Forwarded-For", "100.64.0.7"}
_, secrets := newCaller(t, "Tailnet laptop") // shares magpie
if a := listenAddr(); a != "127.0.0.1:4555" {
t.Fatal("shared, MAGPIE_ADDR on loopback, listens on", a)
}
if u := LANURLs(); len(u) != 0 {
t.Fatal("shared on loopback offers", u)
}
for _, bad := range [][]string{nil, {"Authorization", "Bearer magpie"}, {"x-api-key", "sk-magpie-wrong"}} {
if c := call(append(append([]string{}, bad...), serve...)...); c != http.StatusUnauthorized || reached {
t.Fatalf("through Serve with %v: %d reached=%v", bad, c, reached)
}
}
if c := call(append([]string{"Authorization", "Bearer " + secrets[0]}, serve...)...); c != 200 || !reached {
t.Fatal("through Serve with the key:", c)
}
if c := call("Authorization", "Bearer anything"); c != 200 {
t.Fatal("an agent on loopback:", c)
}
t.Setenv("MAGPIE_ADDR", "100.64.0.1:4555")
if a := listenAddr(); a != "100.64.0.1:4555" {
t.Fatal("shared, MAGPIE_ADDR on one address, listens on", a)
}
if u := LANURLs(); len(u) != 1 || u[0] != "http://100.64.0.1:4555" {
t.Fatal("shared on one address offers", u)
}
// every interface, said or left to Settings, is every interface still
for _, a := range []string{"0.0.0.0:4555", "[::]:4555", ":4555"} {
t.Setenv("MAGPIE_ADDR", a)
if got := listenAddr(); got != "0.0.0.0:4555" {
t.Fatal("shared, MAGPIE_ADDR", a, "listens on", got)
}
}
t.Setenv("MAGPIE_ADDR", "")
if got := listenAddr(); got != "0.0.0.0:"+Port() {
t.Fatal("shared from Settings listens on", got)
}
}