mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
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]>
283 lines
9.8 KiB
Go
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,
|
|
}
|