mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
the brand Host reaches a mounted child, end to end
Two halves of one path, and fixing only the first is why the last attempt looked right and changed nothing. forward() overwrote the Host header with the child's dial address. A client dials its own address and never reads that header to find the far end, so the value was routing nothing — it was destroying the one fact a callee needs to serve more than one brand. zap-proto/http then dropped it again: the encoder excluded Host from the headers slot as "frame-owned" while nothing in the frame carried a host, and the decoder reconstructed it from the very header the encoder had removed. Every request over the ZAP wire arrived with an empty Host. Fixed in v0.3.2, required here. TestMountedChildSeesTheCallersHost goes through a REAL mount rather than a stubbed client, which is what the earlier unit test missed: it built its own request, so it never met the serialization the server path takes. It fails on either half alone. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -47,7 +47,7 @@ require (
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/zap-proto/http v0.3.1
|
||||
github.com/zap-proto/http v0.3.2
|
||||
github.com/zap-proto/mcp v1.0.5
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
|
||||
@@ -89,6 +89,8 @@ github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
|
||||
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
|
||||
github.com/zap-proto/http v0.3.1 h1:A2rCPWYCX866eAsdiWuns0dvWnBmViZtGm4pwX7jwlY=
|
||||
github.com/zap-proto/http v0.3.1/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
|
||||
github.com/zap-proto/http v0.3.2 h1:4VCAoGUQWNXuO3cXl20rYi7D+GoCObKy1BoienVXam8=
|
||||
github.com/zap-proto/http v0.3.2/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
|
||||
github.com/zap-proto/mcp v1.0.5 h1:1Hi0KNi4YlWW2zkQUMDK9n3jP3wZaRJ1rseMW9p9F0c=
|
||||
github.com/zap-proto/mcp v1.0.5/go.mod h1:cYOsq+33NyW4X8nUVtIs/qtezVovYazw1s0V7mrQBYA=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// THE HOST A CHILD SEES IS THE HOST THE CALLER ASKED FOR — through a REAL
|
||||
// mount, not a stubbed client. The unit test for forward() passes a request it
|
||||
// built itself; this one goes through the server path, where fasthttp has
|
||||
// already parsed the URI in order to route, which is the condition under which
|
||||
// Request.Write re-derives the Host header from the URI.
|
||||
func TestMountedChildSeesTheCallersHost(t *testing.T) {
|
||||
seen := make(chan string, 4)
|
||||
|
||||
child := zip.New(zip.Config{AppName: "child", DisableStartupMessage: true})
|
||||
child.Get("/v1/child/who", func(c *zip.Ctx) error {
|
||||
seen <- c.Host()
|
||||
return c.String(200, "ok")
|
||||
})
|
||||
dir := sockDir(t)
|
||||
sock := dir + "/child.sock"
|
||||
go func() { _ = child.Listen(sock) }()
|
||||
waitSock(t, sock)
|
||||
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
loaded, err := zip.Load(zip.Plugin{Name: "child", Addr: sock}, "/v1/child")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
host.Use(loaded)
|
||||
|
||||
// First establish what the HOST sees: if the harness does not deliver a Host,
|
||||
// the child cannot be blamed for not receiving one.
|
||||
host.Get("/echo-host", func(c *zip.Ctx) error { return c.String(200, "["+c.Host()+"]") })
|
||||
er := httptest.NewRequest(http.MethodGet, "/echo-host", nil)
|
||||
er.Host = "lux.id"
|
||||
eresp, eerr := host.Test(er)
|
||||
if eerr != nil {
|
||||
t.Fatalf("echo: %v", eerr)
|
||||
}
|
||||
ebuf := make([]byte, 64)
|
||||
en, _ := eresp.Body.Read(ebuf)
|
||||
_ = eresp.Body.Close()
|
||||
t.Logf("HOST saw: %s", string(ebuf[:en]))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/child/who", nil)
|
||||
req.Host = "lux.id"
|
||||
resp, err := host.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
select {
|
||||
case got := <-seen:
|
||||
if got != "lux.id" {
|
||||
t.Fatalf("child saw Host %q, want \"lux.id\" — a brand-resolving child cannot answer as the brand it was asked as", got)
|
||||
}
|
||||
default:
|
||||
t.Fatal("the child never served the request")
|
||||
}
|
||||
}
|
||||
+12
-25
@@ -319,33 +319,20 @@ func forward(req *fasthttp.Request, resp *fasthttp.Response, client Client, host
|
||||
if client == nil {
|
||||
return Errorf(503, "%s: no instance running", what)
|
||||
}
|
||||
// THE CALLER'S HOST SURVIVES THE HOP. A client dials its own address
|
||||
// (HostClient.Addr, zaphttp.Dial(addr)) and never reads this header to find
|
||||
// the far end, so Host here is not routing — it is the question "who was
|
||||
// asked", and the child needs it to answer as that brand.
|
||||
// The caller's Host survives the hop. A client dials its own address and never
|
||||
// reads this header to find the far end, so overwriting it with the dial
|
||||
// address destroyed the one fact a callee needs to serve more than one brand:
|
||||
// which host was asked for. The dial address remains the fallback for a request
|
||||
// carrying no Host of its own — a freshly built one rather than a proxied
|
||||
// inbound — because HTTP/1.1 requires the header.
|
||||
//
|
||||
// It used to be overwritten with the dial address unconditionally, which
|
||||
// made every multi-brand behaviour collapse behind a plugin. Identity is
|
||||
// where that showed: hanzoai/iam serves every brand from one instance and
|
||||
// resolves each brand's OIDC issuer from the Host, so a child 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 that claim iss=hanzo.id,
|
||||
// which their own relying parties reject. Config could not fix it: the brand
|
||||
// map was in the child's environment, and the Host never arrived.
|
||||
//
|
||||
// The dial address remains the fallback for a request that carries no Host
|
||||
// of its own — a freshly built one (the MCP hop) rather than a proxied
|
||||
// inbound — because HTTP/1.1 requires the header to be present.
|
||||
// req.Header.Host(), not req.Host(): the latter reads through the URI, which
|
||||
// parses it on first touch — 34 allocations a request where this hop is
|
||||
// meant to cost none, and the alloc gate catches exactly that. The header is
|
||||
// the raw inbound bytes, and it is also the value that MEANS "who was asked".
|
||||
//
|
||||
// A request that already carries one is left ALONE rather than re-set: the
|
||||
// cheapest correct move here is no move at all.
|
||||
if len(req.Header.Host()) == 0 {
|
||||
req.SetHost(host)
|
||||
// This is only half the path. zap-proto/http dropped Host on the wire as well
|
||||
// (it excluded the header as frame-owned while nothing in the frame carried a
|
||||
// host); fixed in v0.3.2, which this module requires.
|
||||
if inbound := req.Header.Host(); len(inbound) > 0 {
|
||||
host = string(inbound)
|
||||
}
|
||||
req.SetHost(host)
|
||||
if path != "" {
|
||||
req.URI().SetPath(path)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user