mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
forward() overwrote the Host header with the DIAL ADDRESS on every proxied request. A client dials its own address — HostClient.Addr, zaphttp.Dial(addr) — and never reads this header to find the far end, so that value was not routing anything. It was destroying the one fact a child needs to serve more than one brand: which host was asked. hanzoai/iam is where it showed. It serves every brand from one instance and resolves each brand's OIDC issuer from the request Host, so mounted as a plugin child it was asked as "/var/lib/cloud/run/iam.sock", matched no brand, and fell back to its single default — lux.id and zoolabs.id minting tokens claiming iss=hanzo.id, which their own relying parties reject. Config could not reach it: the brand map was in the child's environment and the Host never arrived. The dial address stays the fallback for a request carrying no Host of its own (the MCP hop builds a fresh one), because HTTP/1.1 requires the header. Read through req.Header.Host() rather than req.Host(): the latter goes through the URI, which parses on first touch — 34 allocations on a hop that is meant to cost none, which the alloc gate catches. And a request that already has a Host is now left ALONE rather than re-set. The test measures the SERIALIZED request. fasthttp writes the Host header from the URI once the URI has been parsed, so reading either field in memory can disagree with what actually goes over the wire; serializing is the only reading that cannot. It fails on the old code and passes on this one. Also: every test that binds a unix socket now does it in a short directory. A socket address is 104 bytes on Darwin and t.TempDir spends most of them on the test's own name, so 9 tests failed with "never began listening" — which reads as a timing bug, is green on Linux, red on every Mac, and breaks when a test is RENAMED. Suite goes 11 red to 2, both of which are pre-existing and platform: SO_PEERCRED (no LOCAL_PEERCRED path on macOS) and the alloc gate, which fails on pristine v1.27.1 when run in isolation. Co-authored-by: Hanzo Dev <[email protected]>
155 lines
6.3 KiB
Go
155 lines
6.3 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"net"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// TestProxy_OverUnixSocket proves the plumbing is read off the address shape
|
|
// rather than off a second scheme: the SAME "zap" transport serves and dials a
|
|
// unix socket when the address is a path. This is the colocated plugin case —
|
|
// no port to allocate, filesystem permissions as the ACL — carrying byte-identical
|
|
// ZAP frames to the tcp case.
|
|
func TestProxy_OverUnixSocket(t *testing.T) {
|
|
sock := filepath.Join(sockDir(t), "billing.sock")
|
|
|
|
plugin := zip.New(zip.Config{AppName: "billing", DisableStartupMessage: true})
|
|
plugin.Get("/v1/billing/invoices", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{"servedBy": "over-uds"})
|
|
})
|
|
go func() { _ = plugin.Listen(sock) }() // bare path => zap scheme, unix network
|
|
defer func() { _ = plugin.Shutdown() }()
|
|
waitSocket(t, sock)
|
|
|
|
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
|
|
core.Use(must(zip.Proxy("/v1/billing", sock)))
|
|
|
|
status, body := call(t, core, "GET", "/v1/billing/invoices", "")
|
|
if status != 200 || !strings.Contains(body, `"servedBy":"over-uds"`) {
|
|
t.Fatalf("through unix mount: status=%d body=%q, want the plugin's JSON", status, body)
|
|
}
|
|
}
|
|
|
|
// waitSocket blocks until path accepts a unix connection.
|
|
func waitSocket(t *testing.T, path string) {
|
|
t.Helper()
|
|
for i := 0; i < 300; i++ {
|
|
if c, err := net.Dial("unix", path); err == nil {
|
|
_ = c.Close()
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("unix socket %s never became reachable", path)
|
|
}
|
|
|
|
// These tests pin the plugin mechanism: Listen serves here, Proxy delegates
|
|
// there, and both read the transport off the address scheme through one
|
|
// registry. The property that matters is that the mounting app holds no
|
|
// reference whatsoever to the mounted app's code — that is what lets a service
|
|
// ship, rebuild, and redeploy as its own binary without relinking its host.
|
|
|
|
// TestProxy_OverZAP is the end-to-end proof. The "plugin" is a real, separate
|
|
// zip.App with its own routes, listening on the real ZAP transport. The "core"
|
|
// mounts it by address alone — a bare address, so DefaultScheme (ZAP) — and
|
|
// serves its routes as if they were local. Nothing is shared but the address.
|
|
func TestProxy_OverZAP(t *testing.T) {
|
|
plugin := zip.New(zip.Config{AppName: "billing", DisableStartupMessage: true})
|
|
plugin.Get("/v1/billing/invoices", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{"servedBy": "billing-plugin"})
|
|
})
|
|
plugin.Post("/v1/billing/charge", func(c *zip.Ctx) error {
|
|
return c.JSON(201, map[string]string{"echo": string(c.Body())})
|
|
})
|
|
|
|
pluginAddr := freeAddr(t)
|
|
go func() { _ = plugin.Listen(pluginAddr) }() // bare addr = ZAP
|
|
defer func() { _ = plugin.Shutdown() }()
|
|
waitReachable(t, pluginAddr)
|
|
|
|
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
|
|
core.Use(must(zip.Proxy("/v1/billing", pluginAddr)))
|
|
|
|
// GET travels core -> ZAP -> plugin and the plugin's body comes back.
|
|
status, body := call(t, core, "GET", "/v1/billing/invoices", "")
|
|
if status != 200 {
|
|
t.Fatalf("GET through mount: status %d, want 200 (body=%s)", status, body)
|
|
}
|
|
if !strings.Contains(body, `"servedBy":"billing-plugin"`) {
|
|
t.Fatalf("GET through mount: body=%q, want the plugin's JSON", body)
|
|
}
|
|
|
|
// POST proves method, request body, and a non-200 status all survive the
|
|
// hop — the request object is handed across, not reconstructed.
|
|
status, body = call(t, core, "POST", "/v1/billing/charge", `{"amount":100}`)
|
|
if status != 201 {
|
|
t.Fatalf("POST through mount: status %d, want 201 (body=%s)", status, body)
|
|
}
|
|
if !strings.Contains(body, `{\"amount\":100}`) && !strings.Contains(body, `amount`) {
|
|
t.Fatalf("POST through mount: body=%q, want the echoed request body", body)
|
|
}
|
|
}
|
|
|
|
// TestProxy_StaticBeatsRemoteProxy pins the same precedence property the local mount
|
|
// path pins: a mount is an ORDINARY wildcard route, so a more specific route
|
|
// registered afterwards still wins. A mount that short-circuited the router
|
|
// would silently shadow the host's own routes.
|
|
func TestProxy_StaticBeatsRemoteProxy(t *testing.T) {
|
|
plugin := zip.New(zip.Config{AppName: "plug", DisableStartupMessage: true})
|
|
plugin.Get("/v1/billing/*", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{"servedBy": "plugin"})
|
|
})
|
|
|
|
pluginAddr := freeAddr(t)
|
|
go func() { _ = plugin.Listen(pluginAddr) }()
|
|
defer func() { _ = plugin.Shutdown() }()
|
|
waitReachable(t, pluginAddr)
|
|
|
|
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
|
|
core.Use(must(zip.Proxy("/v1/billing", pluginAddr)))
|
|
// Registered AFTER the mount, and still wins for its exact path.
|
|
core.Get("/v1/billing/health", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{"servedBy": "core"})
|
|
})
|
|
|
|
if status, body := call(t, core, "GET", "/v1/billing/health", ""); status != 200 ||
|
|
!strings.Contains(body, `"servedBy":"core"`) {
|
|
t.Fatalf("exact route after mount: status=%d body=%q, want the core's own handler", status, body)
|
|
}
|
|
if status, body := call(t, core, "GET", "/v1/billing/anything", ""); status != 200 ||
|
|
!strings.Contains(body, `"servedBy":"plugin"`) {
|
|
t.Fatalf("other subpath: status=%d body=%q, want the mounted plugin", status, body)
|
|
}
|
|
}
|
|
|
|
// TestProxy_UnknownScheme proves Proxy refuses an unregistered scheme loudly
|
|
// rather than silently falling back to a default wire.
|
|
func TestProxy_UnknownScheme(t *testing.T) {
|
|
_, err := zip.Proxy("/v1/x", "carrier-pigeon://somewhere:1")
|
|
if err == nil {
|
|
t.Fatal("Proxy with an unregistered scheme returned nil, want an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "carrier-pigeon") {
|
|
t.Fatalf("error = %v, want it to name the unknown scheme", err)
|
|
}
|
|
}
|
|
|
|
// TestProxy_DialOnlyAndServeOnly proves a transport may implement one
|
|
// direction: Listen reports a dial-only scheme and Proxy reports a serve-only
|
|
// one, each naming which half was missing.
|
|
func TestProxy_ServeOnlyTransportCannotDial(t *testing.T) {
|
|
zip.RegisterTransport("serveonly", zip.Transport{
|
|
Serve: func(addr string, h fasthttp.RequestHandler) zip.Server { return nil },
|
|
})
|
|
_, err := zip.Proxy("/v1/x", "serveonly://host:1")
|
|
if err == nil || !strings.Contains(err.Error(), "cannot dial") {
|
|
t.Fatalf("Proxy on a serve-only transport: err=%v, want a 'cannot dial' error", err)
|
|
}
|
|
}
|