Files
zeekayandHanzo Dev 3868e17b6a gateway: two ZAP listeners, two ports, and a refusal when they are one
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]>
2026-08-02 10:59:09 -07:00

125 lines
4.6 KiB
YAML

apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
namespace: hanzo
labels:
app: gateway
app.kubernetes.io/name: gateway
app.kubernetes.io/part-of: hanzo-platform
spec:
replicas: 2
selector:
matchLabels:
app: gateway
template:
metadata:
labels:
app: gateway
spec:
enableServiceLinks: false
imagePullSecrets:
- name: registry-credentials
containers:
- name: gateway
image: ghcr.io/hanzoai/gateway:v2.14.10
imagePullPolicy: Always
command: ["/usr/bin/gateway"]
args: ["run", "-c", "/etc/gateway/gateway.json"]
env:
- name: GATEWAY_PORT
value: "8080"
# -- Unified Auth Middleware --
- name: AUTH_JWKS_URL
value: "https://hanzo.id/v1/iam/.well-known/jwks"
- name: AUTH_ISSUER
value: "https://hanzo.id"
- name: AUTH_PUBLIC_PATHS
value: "/healthz,/__stats,/.well-known/,/favicon"
- name: AUTH_PUBLIC_HOSTS
value: "hanzo.id,lux.id,pars.id,id.bootno.de,id.zoo.network,iam.hanzo.ai"
# Set to "true" to require auth on all non-public endpoints
- name: AUTH_REQUIRE
value: "false"
# -- ZAP native binary listener (TLS 1.3+PQ) --
#
# TWO listeners, and they are not peers. ZAP_LISTENER_PORT is the
# PUBLIC one: the TLS terminator, which is what zap-ingress
# (zap.hanzo.ai) forwards to and the only ZAP thing on an interface
# this pod shares with anyone. ZAP_INTERNAL_ADDR is where it forwards
# AFTER decrypting — the plaintext ZAP server, on LOOPBACK, on a
# DIFFERENT port.
#
# These both said 9999. Same process, same port, so one of two things
# happened on every boot depending on which bound first: the
# terminator proxied every accepted connection back into itself
# (unbounded accept loop off one unauthenticated SYN), or it failed
# with "address already in use" — logged, never fatal — and PLAINTEXT
# ZAP served the public LoadBalancer. ZAP has no session crypto in any
# language, so that second one is bearer tokens and prompts in
# cleartext. The pod reports Ready either way.
#
# The binary now refuses both shapes at startup rather than picking
# one, so this file and zaplisten.go have to agree; they do.
- name: ZAP_LISTENER_ENABLED
value: "true"
- name: ZAP_LISTENER_PORT
value: "9999"
- name: ZAP_TLS_CERT
value: "/etc/tls/tls.crt"
- name: ZAP_TLS_KEY
value: "/etc/tls/tls.key"
- name: ZAP_INTERNAL_ADDR
value: "127.0.0.1:9998"
# -- Billing enforcement via Commerce API --
- name: AUTH_BILLING_URL
value: "http://commerce.hanzo.svc.cluster.local:8001"
# Admin-scoped S2S token the balance gate uses to call Commerce.
# REQUIRED from KMS (gateway-secrets) whenever BILLING_ENABLED=true:
# the gateway refuses to start without it (AuthConfig.Validate). It
# stays optional here only because billing is OFF (BILLING_ENABLED
# unset); populate it from KMS before flipping billing on.
- name: COMMERCE_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: gateway-secrets
key: COMMERCE_SERVICE_TOKEN
optional: true
ports:
- containerPort: 8080
name: http
- containerPort: 9999
name: zap
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: config
mountPath: /etc/gateway
- name: tls
mountPath: /etc/tls
readOnly: true
volumes:
- name: config
configMap:
name: gateway-config
- name: tls
secret:
secretName: api-hanzo-ai-tls