Files
zeekayandHanzo Dev 8592e5db04 gateway: every gate is a value, so the default build carries no gin
The last three first-party gin files were transports wrapped around decisions,
and the decisions could not be reached without the framework. That braiding had
already cost this repo a silent hole once (the fiber adaptor restoring headers
the strip had deleted); it was still costing two more, quietly.

widget_security.go and the CORS half of routes.go had NO zip transport at all.
Not a weaker one — none. So on the HIP-0106 mount, the boundary the unified
cloud binary runs, an hz_ widget key — a public credential anyone can read out
of a page's source — arrived with no origin allowlist and no rate limit against
the model budget, and a browser preflight got whatever the router does with an
unrouted OPTIONS. Both gates existed, both were tested, and neither ran, because
each was written inside a gin closure the zip edge could not call. That is the
same defect class as the deleted gin bridge, one level up: the decision was in a
place instead of being a value.

Now each gate is a value and each edge is a few lines over it — widgetGate.admit
in widget_security.go and corsPolicy.admit in cors.go, alongside the
authGate.admit that already was one. Mount installs all three natively, in the
order legacy_engine.go runs them. Every gin transport moved to
legacy_transports.go behind the tag: one file, one reason to exist, deleted whole
with legacy_engine.go at Phase C.

What that buys, measured on the binary rather than on a test:

  go list -deps ./cmd/gateway | grep gin-gonic   6 packages -> 0
  package count                                  2161 -> 2112

And what it does NOT buy, stated because the opposite is the easy thing to
claim: gin CANNOT leave go.mod, and gin is still in the SHIPPED binary. The
Makefile sets BUILD_TAGS ?= legacy, the Dockerfile runs `make build`, and all
five live workloads run `run -c gateway.json` — so ghcr.io/hanzoai/gateway is
the legacy build, and its gin comes from the vendored KrakenD/Lura tree
(internal/lura, internal/plugin), which is not ours to rewrite.
`go list -deps -tags legacy` still reports gin, unchanged at 2680 packages.
First-party gin is gone; the vendored legacy tree keeps it until Phase C.

The shipped edge is proven unmoved, not asserted to be: both binaries booted
against configs/hanzo/gateway.json and probed on fourteen requests covering
every gate. Route table 19 endpoints before and 19 after, and every response —
status line, CORS headers, security posture, BODY — diffs clean against
origin/main.

Tests: transport_parity_test.go drives one request through BOTH edges over 25
cases (auth classes, widget origin+rate limit, CORS preflight) and fails on
disagreement rather than on either edge alone; it was mutation-checked, and a
broken zip transport reports TRANSPORTS DISAGREE on 6 of them.
TestMount_GatesAreOnTheChainMountInstalls drives the app gateway.Mount BUILT,
because a gate that passes its own test while the mount never installs it is
exactly what was wrong here. The gin-driven suites are `legacy`-tagged — that
build is the only one where a gin transport exists, and it is the one that
ships, so nothing stopped running: 215 tests pass in the legacy pass and the
untagged pass is green too, both with -race. Shared JWKS fixtures moved to an
untagged fixtures_test.go so the two halves cannot grow two signing identities.

The routing table is the one gate with no zip twin, and routes.go says why
rather than deferring: its compiled proxies are net/http, so reaching them from
fasthttp would drop WebSocket upgrade and streaming, and the api-host
passthrough rests on cloud being another process — false inside the unified
binary, where the same rule would proxy it to itself.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:35:08 -07:00

283 lines
9.8 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
// routes.go is the gateway's host/path reverse-proxy routing TABLE — stdlib +
// yaml, with ZERO dependency on the upstream Lura SDK and, now, on any HTTP
// framework. It parses the config, compiles one httputil.ReverseProxy per
// prefix, and holds them behind a hot-reloadable lock. Nothing here answers a
// request.
//
// The table's reader is [hostProxyMiddleware] in legacy_transports.go, the
// standalone edge's transport. It stays gin-side deliberately, and this is the
// one gate in this repo that does NOT have a zip twin:
//
// - a compiled httputil.ReverseProxy is net/http, and reaching it from a zip
// handler means the net/http↔fasthttp adaptor, which cannot hijack a
// connection. WebSocket upgrade and streamed responses — which this proxy
// serves today — would silently stop working. Carrying it means a NATIVE
// fasthttp proxy, which is its own reviewable change.
// - the api-host passthrough below rests on a premise that is FALSE
// co-resident: it forwards a whole host to the cloud service because cloud
// is another process. Inside the unified binary that address is the binary
// itself, and the same rule proxies the process to itself. In cloud-mode the
// routing source of truth is cloud's own mount table — stated below, in
// capitals, and true.
//
// The CORS policy that used to live here moved to cors.go, which is what it is;
// this file is the routing table and nothing else.
package gateway
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"os"
"sort"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// RoutesConfig is the YAML structure for gateway routing.
// Loaded from KMS (GATEWAY_ROUTES_KMS_PATH) or local file (GATEWAY_ROUTES_FILE).
type RoutesConfig struct {
Redirects map[string]string `yaml:"redirects" json:"redirects"`
Routes map[string][]RouteEntry `yaml:"routes" json:"routes"`
Subdomains map[string]string `yaml:"subdomains" json:"subdomains"`
}
// RouteEntry maps a path prefix to a backend URL.
type RouteEntry struct {
Prefix string `yaml:"prefix" json:"prefix"`
Backend string `yaml:"backend" json:"backend"`
Rewrite string `yaml:"rewrite,omitempty" json:"rewrite,omitempty"` // optional: rewrite prefix
}
// pathBackend maps a path prefix to a backend URL.
type pathBackend struct {
prefix string
target *url.URL
}
// compiledRoute is a pre-built proxy for a path prefix.
type compiledRoute struct {
prefix string
proxy *httputil.ReverseProxy
}
// routeTable holds the compiled routing state. Thread-safe for hot-reload.
type routeTable struct {
mu sync.RWMutex
exactRoutes map[string][]compiledRoute
redirects map[string]string
subProxies []struct {
pattern string
proxy *httputil.ReverseProxy
}
}
var routes = &routeTable{}
// LoadRoutes loads routing config from YAML. Called at startup and on hot-reload.
func LoadRoutes(cfg *RoutesConfig) error {
exact := make(map[string][]compiledRoute, len(cfg.Routes))
for host, entries := range cfg.Routes {
// Sort by longest prefix first.
sorted := make([]RouteEntry, len(entries))
copy(sorted, entries)
sort.Slice(sorted, func(i, j int) bool {
return len(sorted[i].Prefix) > len(sorted[j].Prefix)
})
compiled := make([]compiledRoute, 0, len(sorted))
for _, e := range sorted {
u, err := url.Parse(e.Backend)
if err != nil {
return fmt.Errorf("bad backend URL %q for %s%s: %w", e.Backend, host, e.Prefix, err)
}
var p *httputil.ReverseProxy
if e.Rewrite != "" {
p = newRewriteProxy(u, e.Prefix, e.Rewrite)
} else {
p = newProxy(u)
}
compiled = append(compiled, compiledRoute{prefix: e.Prefix, proxy: p})
}
exact[host] = compiled
}
type subProxy struct {
pattern string
proxy *httputil.ReverseProxy
}
var subs []struct {
pattern string
proxy *httputil.ReverseProxy
}
for pattern, target := range cfg.Subdomains {
u, err := url.Parse(target)
if err != nil {
return fmt.Errorf("bad subdomain backend %q for %s: %w", target, pattern, err)
}
subs = append(subs, struct {
pattern string
proxy *httputil.ReverseProxy
}{pattern: pattern, proxy: newProxy(u)})
}
routes.mu.Lock()
routes.exactRoutes = exact
routes.redirects = cfg.Redirects
routes.subProxies = subs
routes.mu.Unlock()
return nil
}
// LoadRoutesFromFile loads routes from a YAML file.
func LoadRoutesFromFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read routes file %s: %w", path, err)
}
return loadRoutesFromYAML(data)
}
// loadRoutesFromYAML parses a YAML payload and installs the routes table.
// Shared by LoadRoutesFromFile and the KMS code path so both go through one
// validation/parse step.
func loadRoutesFromYAML(data []byte) error {
var cfg RoutesConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("failed to parse routes yaml: %w", err)
}
return LoadRoutes(&cfg)
}
// loadRoutesFromEnv loads routes from KMS path or local file.
// Priority: GATEWAY_ROUTES_KMS_PATH > GATEWAY_ROUTES_FILE > ./routes.yaml
//
// KMS path is delegated to the package-level kmsResolver. When
// GATEWAY_KMS_ENDPOINT is set (plus IAM_CLIENT_ID/SECRET), the HTTP resolver
// is wired automatically. Tests inject a stub via SetKMSResolver.
func loadRoutesFromEnv() error {
initKMSResolverFromEnv()
if kmsPath := os.Getenv("GATEWAY_ROUTES_KMS_PATH"); kmsPath != "" {
data, err := kmsResolver.FetchRoutes(kmsPath)
if err != nil {
return fmt.Errorf("failed to fetch routes from KMS path %s: %w", kmsPath, err)
}
if err := loadRoutesFromYAML(data); err != nil {
return fmt.Errorf("kms routes payload invalid: %w", err)
}
return nil
}
filePath := os.Getenv("GATEWAY_ROUTES_FILE")
if filePath == "" {
filePath = "routes.yaml"
}
return LoadRoutesFromFile(filePath)
}
// newProxy creates an httputil.ReverseProxy that preserves the original
// Host header and strips backend CORS headers (gateway handles CORS).
func newProxy(target *url.URL) *httputil.ReverseProxy {
p := httputil.NewSingleHostReverseProxy(target)
original := p.Director
p.Director = func(req *http.Request) {
original(req)
req.Host = req.URL.Host
}
p.ModifyResponse = stripBackendCORS
return p
}
// stripBackendCORS removes CORS headers from backend responses.
// The gateway middleware is the single source of CORS headers.
func stripBackendCORS(resp *http.Response) error {
resp.Header.Del("Access-Control-Allow-Origin")
resp.Header.Del("Access-Control-Allow-Methods")
resp.Header.Del("Access-Control-Allow-Headers")
resp.Header.Del("Access-Control-Allow-Credentials")
resp.Header.Del("Access-Control-Max-Age")
resp.Header.Del("Access-Control-Expose-Headers")
return nil
}
// newPassthroughProxy creates an httputil.ReverseProxy that forwards the
// request path unchanged to the target host.
func newPassthroughProxy(target *url.URL) *httputil.ReverseProxy {
p := httputil.NewSingleHostReverseProxy(target)
original := p.Director
p.Director = func(req *http.Request) {
original(req)
req.Host = req.URL.Host
}
p.ModifyResponse = stripBackendCORS
return p
}
// newRewriteProxy creates an httputil.ReverseProxy that rewrites path prefixes.
func newRewriteProxy(target *url.URL, oldPrefix, newPrefix string) *httputil.ReverseProxy {
p := httputil.NewSingleHostReverseProxy(target)
original := p.Director
p.Director = func(req *http.Request) {
if strings.HasPrefix(req.URL.Path, oldPrefix) {
req.URL.Path = newPrefix + req.URL.Path[len(oldPrefix):]
}
original(req)
req.Host = req.URL.Host
}
p.ModifyResponse = stripBackendCORS
return p
}
// defaultCloudAPIURL is the in-cluster cloud (HIP-0106 cloud binary)
// HTTP endpoint that serves /v1/* models traffic natively.
const defaultCloudAPIURL = "http://cloud.hanzo.svc.cluster.local:8000"
// cloudAPIURL resolves the cloud HTTP target. Overridable via
// GATEWAY_CLOUD_API_URL for operators (alternate service DNS) and tests
// (httptest backend). Falls back to defaultCloudAPIURL when unset or
// unparseable so a malformed override can never silently break routing.
func cloudAPIURL() *url.URL {
raw := strings.TrimSpace(os.Getenv("GATEWAY_CLOUD_API_URL"))
if raw == "" {
raw = defaultCloudAPIURL
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
u, _ = url.Parse(defaultCloudAPIURL)
}
return u
}
// apiCloudHosts is the host whose ENTIRE surface is the unified cloud API.
// ONE API host — api.hanzo.ai (no .cloud.). It resolves to the hanzoai/cloud
// binary (HIP-0106), which owns ALL /v1/* routing — AI inference top-level
// (/v1/chat, /v1/messages, …) plus every per-service control plane (/v1/cloud,
// /v1/iam, /v1/kms, /v1/o11y, …). The gateway is a STATELESS edge: auth +
// ratelimit + meter already ran in the upstream middleware, so it forwards "/*"
// straight to cloud with NO per-route allow-list. ONE routing source of truth =
// cloud's mount table, not a second map here.
var apiCloudHosts = map[string]bool{
"api.hanzo.ai": true,
// api.cloud.hanzo.ai is the upstream Host the ingress `console-v1-host` (and
// `cloud-api-host`) middlewares rewrite console.hanzo.ai/v1/* and
// cloud-api.hanzo.ai/v1/* to before this gateway. Without it here those hosts
// fell through to the per-service exactRoutes (which carry /v1/billing,
// /v1/commerce, … but NOT the visor control plane), so /v1/gpus, /v1/machines,
// /v1/clusters, /v1/fleet 404'd — breaking the console GPUs + Machines pages for
// EVERY customer. Treating it as a unified-cloud-API host forwards the WHOLE /v1
// surface to cloud (which owns visor + all control-plane routing), exactly like
// api.hanzo.ai. SECURITY unchanged: NewAuthMiddleware runs BEFORE the host
// passthrough for ALL hosts — it unconditionally strips client identity headers
// and rewrites X-Org-Id from the validated JWT — so this carries a gateway-
// sanitized request, never a client-trusted X-Org-Id.
"api.cloud.hanzo.ai": true,
}