mirror of
https://github.com/hanzoai/gateway.git
synced 2026-11-02 15:19:24 +00:00
Since the HIP-0110 ZAP-relay rearchitecture, cmd/gateway/main's
dialBackends eagerly dialed cloud:9090 / base:9091 at startup and
os.Exit(1)'d on any failure. When those backends are not deployed (the
steady state in prod), every gateway image past v2.14.1 crashlooped —
freezing the gateway on v2.14.1 and blocking the merged CORS fix and the
billing gate. HTTP serving (what api.hanzo.ai actually needs) worked fine.
Decomplect boot from backend reachability:
- startNode (was dialBackends) only starts the ZAP node. The single
fatal case left is a genuine local error — failure to bind the
ephemeral port. No dialing at startup.
- peerResolver (new, peer_resolver.go) lazily resolves a backend
address to its handshake-learned peerID, dialing on first use with
single-flight, caching once connected. ConnectDirectID is idempotent,
so a healthy backend is dialed exactly once and ZAP is unchanged.
- NewLazyPicker best-effort warms both backends (failure -> WARN, not
fatal) and returns the forward.PeerPicker. A dead backend makes the
picker return "" -> node.Call yields a per-request "peer not found"
error that forward.Relay surfaces as an HTTP error, never a crash.
The dial is retried on the next matching request (heals with no
restart once the backend appears).
- isBasePath is the single definition of the /v1/base/* split, shared
by the static pickPeer (tests) and the lazy picker (prod).
- RelayDeps.Pick lets main pass the lazy picker; nil falls back to the
static pickPeer for the pre-existing e2e test.
Tests (peer_resolver_test.go): boot succeeds with backends absent (no
hang/crash), healthz still serves 200, picker returns "" for dead
backends, ZAP routes+caches correctly when backends exist, and recovers
when a backend comes up. All pass under -race.
134 lines
4.7 KiB
Go
134 lines
4.7 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
// build_app.go wires the gateway edge per HIP-0110.
|
|
//
|
|
// The gateway is a pure ZAP→ZAP relay: ingress terminates TLS+HTTP and
|
|
// speaks ZAP to the gateway; the gateway authenticates on the Forward
|
|
// ENVELOPE, injects identity, and forwards to cloud/base over ZAP. The
|
|
// relay therefore lives on the ZAP node (RegisterRelay), not on an HTTP
|
|
// router — there is no per-request HTTP handling at the gateway anymore.
|
|
//
|
|
// BuildApp still returns a tiny *zip.App so cmd/gateway/main can keep its
|
|
// :8080 listener as a process/liveness surface (ingress owns real client
|
|
// HTTP). It carries no forwarder and no auth middleware — those moved onto
|
|
// the node. RegisterRelay is the real entrypoint.
|
|
package gateway
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
zaplib "github.com/luxfi/zap"
|
|
"github.com/luxfi/zap/forward"
|
|
|
|
"github.com/hanzoai/zip"
|
|
"github.com/hanzoai/zip/middleware"
|
|
)
|
|
|
|
// jwksTTL is the signing-key cache lifetime for the relay gate. Matches the
|
|
// edge cache used elsewhere; keys refresh past this, stale-on-error.
|
|
const jwksTTL = 5 * time.Minute
|
|
|
|
// RouterDeps is the set of dependencies BuildApp needs.
|
|
type RouterDeps struct {
|
|
Logger luxlog.Logger
|
|
ZAPNode *zaplib.Node
|
|
CloudAddr string
|
|
BaseAddr string
|
|
}
|
|
|
|
// RelayDeps is what RegisterRelay needs to stand up the ZAP relay on a node.
|
|
type RelayDeps struct {
|
|
Logger luxlog.Logger
|
|
Node *zaplib.Node
|
|
// Pick chooses the backend peer for a request path. Production passes a
|
|
// lazy resolver-backed picker (peerResolver.picker) that dials backends
|
|
// on first use, so the gateway boots even when the backends are absent.
|
|
// When nil, RegisterRelay falls back to the static pickPeer over the
|
|
// pre-resolved BasePeerID / CloudPeerID below.
|
|
Pick forward.PeerPicker
|
|
// BasePeerID / CloudPeerID are the handshake-learned NodeIDs of the
|
|
// already-connected base and cloud peers (from ConnectDirectID at dial
|
|
// time). Used only when Pick is nil (e.g. tests that dial up front).
|
|
BasePeerID string
|
|
CloudPeerID string
|
|
// Auth is the IAM JWT validation config for the relay gate. Use
|
|
// DefaultAuthConfig() to read it from the environment.
|
|
Auth AuthConfig
|
|
}
|
|
|
|
// RegisterRelay installs the canonical HIP-0110 relay on deps.Node:
|
|
//
|
|
// - forward.Relay(node, gate, pick): decodes each inbound Forward
|
|
// envelope, runs the auth gate on it (JWT validate + identity inject,
|
|
// NO body read, NO billing — cloud-api bills the bridged request),
|
|
// re-emits the Forward with identity, and forwards to the picked peer,
|
|
// streaming the backend's Response back verbatim.
|
|
// - forward.RegisterReversePushHandler(node): routes backend→gateway
|
|
// Push frames (SSE / WebSocket) back to the originating client conn.
|
|
//
|
|
// The gate reuses package iamauth + gateway's permission bit-field math;
|
|
// there is exactly one auth implementation. pick routes /v1/base/* to the
|
|
// base peer, everything else to cloud.
|
|
func RegisterRelay(deps RelayDeps) error {
|
|
if deps.Logger == nil {
|
|
return errors.New("gateway.RegisterRelay: nil Logger")
|
|
}
|
|
if deps.Node == nil {
|
|
return errors.New("gateway.RegisterRelay: nil Node")
|
|
}
|
|
|
|
gate := newGate(deps.Auth)
|
|
pick := deps.Pick
|
|
if pick == nil {
|
|
pick = pickPeer(deps.BasePeerID, deps.CloudPeerID)
|
|
}
|
|
|
|
forward.Relay(deps.Node, gate, pick)
|
|
forward.RegisterReversePushHandler(deps.Node)
|
|
|
|
deps.Logger.Info("gateway relay registered",
|
|
"lazy_picker", deps.Pick != nil,
|
|
"base_peer", deps.BasePeerID,
|
|
"cloud_peer", deps.CloudPeerID,
|
|
"auth_enabled", deps.Auth.Enabled,
|
|
"require_auth", deps.Auth.RequireAuth,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// BuildApp returns the gateway's tiny HTTP surface for cmd/gateway/main's
|
|
// :8080 listener. Real client traffic arrives over ZAP via RegisterRelay;
|
|
// this app exists only so the process exposes a liveness/readiness HTTP
|
|
// endpoint on the public port. It carries no forwarder.
|
|
func BuildApp(deps RouterDeps) (*zip.App, error) {
|
|
if deps.Logger == nil {
|
|
return nil, errors.New("gateway.BuildApp: nil Logger")
|
|
}
|
|
if deps.ZAPNode == nil {
|
|
return nil, errors.New("gateway.BuildApp: nil ZAPNode")
|
|
}
|
|
app := zip.New(zip.Config{
|
|
Logger: deps.Logger,
|
|
ServerHeader: "hanzoai",
|
|
DisableStartupMessage: true,
|
|
AppName: "gateway",
|
|
})
|
|
app.Use(
|
|
middleware.Recover(),
|
|
middleware.RequestID(),
|
|
middleware.Logger(deps.Logger),
|
|
)
|
|
app.Get("/healthz", func(c *zip.Ctx) error {
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
app.Get("/readyz", func(c *zip.Ctx) error {
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ready"})
|
|
})
|
|
deps.Logger.Info("gateway.BuildApp ready (relay on ZAP node; HTTP is liveness-only)",
|
|
"cloud_zap", deps.CloudAddr, "base_zap", deps.BaseAddr)
|
|
return app, nil
|
|
}
|