mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
zap_listener.go terminates TLS on a PUBLIC interface. zaphttp_listener.go serves
plaintext ZAP and is the thing the terminator forwards to. They defaulted
independently and both landed on 9999 — which is also what the zap-ingress
LoadBalancer (zap.hanzo.ai) targets, and what k8s/hanzo/deployment.yaml gave
BOTH ZAP_LISTENER_PORT and ZAP_INTERNAL_ADDR. Three spellings of one number
across two files and a manifest, so nothing held the relationship between them,
and the relationship is the entire safety property.
One process, one port, so each boot resolved it by a race:
the terminator binds first — it proxies every accepted connection to itself.
crypto/tls defers the handshake to the first Read, so Accept returns before any
certificate is exchanged: one bare unauthenticated TCP SYN starts an unbounded
accept -> self-dial -> accept loop, two fds and a goroutine a turn, on the pod
that also serves api.hanzo.ai.
ZAP-HTTP binds first — the terminator gets "address already in use", which
InitZapListenerFromEnv only slog.Error()s, and PLAINTEXT ZAP is what answers
the public LoadBalancer. ZAP has no session crypto in any language — not one
crypto/tls import in the zap-proto tree, and zaphttp listens and dials with
bare net.Listen/net.DialTimeout — so that is bearer tokens and prompts in the
clear, whatever the manifest comment says about "TLS 1.3+PQ".
Neither path is fatal. The pod reports Ready in both.
zaplisten.go the topology, decided once: zapPublicPort (9999, the terminator's
and nothing else's) and zapInternalAddr (127.0.0.1:9998), plus isLoopbackHost,
the one predicate that separates public from loopback. Untagged, unlike the
listeners, because zap_listener.go is in both builds and a constant that
exists in one cannot constrain a file that exists in both.
StartZapListener refuses, at startup, both shapes it used to accept: an
InternalAddr on its own port (it would forward to itself) and an InternalAddr
that is not loopback (the inward leg is plaintext, so a routable host puts
the bytes it just decrypted back on the network). A misconfiguration that can
be named at boot should not be discovered one accept at a time.
zapHTTPListenAddr a non-loopback GATEWAY_ZAP_LISTEN override now DISABLES the
plaintext server instead of being honored. Failing closed loses ZAP, which is
recoverable; failing open publishes cleartext, which is not. TestZapHTTPListenAddr
asserted the old permissive contract — ":12345" round-tripping is a wildcard
bind on every interface — and now asserts this one.
the manifest ZAP_INTERNAL_ADDR 127.0.0.1:9999 -> 127.0.0.1:9998. Nothing else
in the fleet dials the gateway's ZAP port: zap.hanzo.ai appears only in this
repo, universe declares no gateway values file, and no in-cluster caller
names gateway:9999. The public port and both Services are unchanged.
Proven, not asserted: with the guards commented out, all three of the new tests
go red with the exact production config; restored, `make test` is green on BOTH
passes it runs (tags=legacy and untagged), 0 failures.
Co-authored-by: Hanzo Dev <[email protected]>
65 lines
2.9 KiB
Go
65 lines
2.9 KiB
Go
package gateway
|
|
|
|
import "net"
|
|
|
|
// The ZAP listen topology, decided ONCE, here.
|
|
//
|
|
// UNTAGGED on purpose, unlike the two listeners it governs. zaphttp_listener.go
|
|
// is `legacy` and executor.go — the only thing that starts either listener — is
|
|
// `legacy` too, but zap_listener.go, which holds the terminator and its
|
|
// self-dial guard, is in BOTH builds. A constant that only exists in one build
|
|
// cannot constrain a file that exists in both, and these are facts about how the
|
|
// fleet is wired, not about which entrypoint was compiled.
|
|
//
|
|
// Two listeners live in this process and they are not peers:
|
|
//
|
|
// zap_listener.go TLS 1.3+PQ terminator. The ONLY thing on a public
|
|
// interface. Terminates, then forwards inward.
|
|
// zaphttp_listener.go the plaintext ZAP server that actually serves the
|
|
// composed gateway handler. LOOPBACK, always.
|
|
//
|
|
// They had a port each, defaulted independently, and both landed on 9999 —
|
|
// which is also the port the PUBLIC zap-ingress LoadBalancer targets
|
|
// (zap.hanzo.ai). Three spellings of one number across two files and a
|
|
// manifest, so nothing could hold the relationship between them, and the
|
|
// relationship is the whole safety property. Hence one file, two constants, and
|
|
// the one predicate that separates "public" from "loopback".
|
|
//
|
|
// Why loopback is not merely tidy: ZAP has NO session crypto, in any language.
|
|
// There is not a single crypto/tls import anywhere in the zap-proto tree, and
|
|
// zap-proto/http listens and dials with bare net.Listen / net.DialTimeout. So
|
|
// "plaintext ZAP on a routable interface" is literally cleartext on the wire —
|
|
// bearer tokens, prompts, completions — regardless of what a manifest comment
|
|
// claims about "TLS 1.3+PQ". The terminator in zap_listener.go is the only
|
|
// crypto on this path, and it is inbound-only: there is no outbound TLS ZAP
|
|
// dialer in the fleet at all.
|
|
const (
|
|
// zapPublicPort is THE public ZAP port: what k8s/hanzo/service.yaml's
|
|
// zap-ingress LoadBalancer forwards, and therefore what the TLS terminator
|
|
// binds. Nothing else may bind it.
|
|
zapPublicPort = 9999
|
|
|
|
// zapInternalAddr is where the plaintext ZAP server binds. Loopback, and a
|
|
// DIFFERENT port from zapPublicPort — the terminator forwards here after
|
|
// decrypting. Same-port is not a tuning mistake, it is a self-dial loop:
|
|
// see StartZapListener's guard.
|
|
zapInternalAddr = "127.0.0.1:9998"
|
|
)
|
|
|
|
// isLoopbackHost reports whether a bind host reaches only this host.
|
|
//
|
|
// The empty host is the case that matters and the one that reads as harmless:
|
|
// ":9998" is not "localhost:9998", it is EVERY interface, which is exactly how
|
|
// the plaintext server ended up on a public LoadBalancer. A wildcard is not
|
|
// loopback.
|
|
func isLoopbackHost(host string) bool {
|
|
switch host {
|
|
case "":
|
|
return false // ":9998" — every interface, not loopback
|
|
case "localhost":
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|