mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
Every import in this module now resolves to github.com/hanzoai/gateway/v2.
The 34 github.com/krakend/* + github.com/luraproject/lura modules that
supplied the engine and its components are vendored under internal/ and
their 91 packages rewritten onto our own path.
Why fold rather than mirror into 34 hanzoai/* repos:
- It removes the exposure outright. 34 rows leave 34 rows of go.sum to
reason about; zero modules leave nothing. `go list -m all` and go.sum
now contain no krakend/lura entry at all.
- Cross-references cannot go wrong. 28 of the 34 import lura (97 files
inside lura self-reference it). Mirroring means rewriting every one of
those to our lura, and a single miss silently links a SECOND copy of
lura into the binary. With one module that is structurally impossible.
- It creates no duplicates. These are lura-coupled gateway glue with no
consumer outside this binary; publishing 34 repos would be 34 new
things to maintain for zero second caller. internal/ makes that
guarantee mechanical — Go forbids importing it from outside this
module, so none of it can become a competing public package. Verified
no repo outside gateway imports any hanzoai/gateway/v2 subpackage.
Layout mirrors provenance so an upstream re-sync stays a diff:
luraproject/lura/v2 -> internal/lura (the engine, 20 pkgs)
krakend/krakend-<x> -> internal/plugin/<x> (28 components)
krakend/<x> -> internal/pkg/<x> (6 standalone libs)
This commit is PURELY MECHANICAL: 196 of the 197 vendored files are
byte-identical to their upstream original once the import rewrite is
applied. The single exception is bloomfilter's subpackage, which upstream
names `krakend` — the one package name that could not survive as-is; it
becomes internal/pkg/bloomfilter/register. Hand edits land separately in
the next commit so they are readable on their own.
Per `go mod vendor` semantics, dependencies' _test.go files are not
vendored. They test upstream code at a pinned version that we do not
modify, several cannot run at all from a module distribution (they need
TLS certs and compiled .so plugins upstream generates but never ships),
and one is a 100k-request stress test that exhausts the ephemeral port
range and fails every package running beside it. Fold integrity is proven
by the byte-diff above instead, which is the stronger check.
Preserved deliberately — each of these is behaviour, not branding:
- the `github.com/devopsfaith/krakend*` extra_config namespace strings
(35 of them). These are config-surface keys, not import paths;
renaming one silently stops that component finding its config. The
jose validator namespace backs `auth/validator` on 212 endpoints.
- core.KrakendVersion / KrakendUserAgent / the X-KRAKEND consts, whose
VALUES rebrand.go overwrites — that seam is unchanged.
- the KRAKEND_ koanf env prefix (a hardcoded upstream const).
- every vendored module's LICENSE (34 files, Apache-2.0/MIT).
The Makefile/CI -X paths move with the engine. An -X naming a symbol that
does not exist is silently ignored by the linker, so a stale path here
would have shipped an "undefined" version in the backend User-Agent and
X-Api-Version with a perfectly green build. Verified live:
`gateway version` reports 2.13.0, not undefined.
Verification (GOWORK=off CGO_ENABLED=0, both tag modes):
go build ./... OK go build -tags legacy ./... OK
go vet ./... clean go vet -tags legacy ./... clean
go test ./... 6/6 ok go test -tags legacy 5/5 ok
tests/ integration harness 47 fixtures pass
gateway check -c configs/{hanzo,lux}/gateway.json -> Syntax OK!
go list -m all | grep -iE 'krakend|lura' -> empty
go mod verify -> all modules verified
Co-authored-by: Hanzo Dev <[email protected]>
487 lines
14 KiB
Go
487 lines
14 KiB
Go
//go:build legacy
|
|
// +build legacy
|
|
|
|
// Package gateway — base-network backend upstream.
|
|
//
|
|
// Implements `base-network://<service>` routing for base/network-enabled
|
|
// services (ATS, BD, TA, IAM, KMS, AML). See ~/work/hanzo/base/docs/NETWORK.md.
|
|
//
|
|
// Per-backend `extra_config`:
|
|
//
|
|
// "github.com/hanzoai/gateway/base-network": {
|
|
// "shard_key": "user_id",
|
|
// "shard_key_source": "jwt.sub" // | jwt.owner | header:X-Shard | cookie:sid
|
|
// }
|
|
//
|
|
// The enclosing backend's `host` names the headless Service whose members are
|
|
// polled at GET /-/base/members every 5 s. Writes route to a single
|
|
// rendezvous-hash owner pod; reads spread across the shard's member subset.
|
|
// A 307 from a pod we picked means the target isn't caught up — follow once.
|
|
package gateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"hash/crc32"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/gateway/v2/internal/lura/config"
|
|
"github.com/hanzoai/gateway/v2/internal/lura/logging"
|
|
"github.com/hanzoai/gateway/v2/internal/lura/proxy"
|
|
metric "github.com/luxfi/metric"
|
|
)
|
|
|
|
const BaseNetworkNamespace = "github.com/hanzoai/gateway/base-network"
|
|
|
|
type BaseNetworkConfig struct {
|
|
ShardKey string `json:"shard_key"`
|
|
ShardKeySource string `json:"shard_key_source"`
|
|
MemberPollIntervalMS int `json:"member_poll_interval_ms"`
|
|
}
|
|
|
|
type baseMember struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
Shards []string `json:"shards"`
|
|
}
|
|
|
|
type baseMembersResponse struct {
|
|
Members []baseMember `json:"members"`
|
|
}
|
|
|
|
// memberSnapshot is a read-only view used on the hot path. Built once per poll.
|
|
type memberSnapshot struct {
|
|
members []baseMember
|
|
memberIdx map[string]int
|
|
ring []ringPoint // sorted by hash — consistent-hash fallback for N=1
|
|
}
|
|
|
|
type ringPoint struct {
|
|
hash uint32
|
|
memberID string
|
|
}
|
|
|
|
func (s *memberSnapshot) empty() bool { return s == nil || len(s.members) == 0 }
|
|
|
|
// subsetFor returns members whose advertised prefix covers shardID. When no
|
|
// member advertises prefixes (N=1 singleton), the consistent-hash owner stands
|
|
// in so callers still have a target.
|
|
func (s *memberSnapshot) subsetFor(shardID string) []baseMember {
|
|
if s.empty() {
|
|
return nil
|
|
}
|
|
owners := make(map[string]struct{})
|
|
for _, m := range s.members {
|
|
for _, p := range m.Shards {
|
|
if strings.HasPrefix(shardID, p) {
|
|
owners[m.ID] = struct{}{}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(owners) == 0 {
|
|
if owner, ok := s.ownerFor(shardID); ok {
|
|
return []baseMember{s.members[s.memberIdx[owner]]}
|
|
}
|
|
return nil
|
|
}
|
|
out := make([]baseMember, 0, len(owners))
|
|
for id := range owners {
|
|
out = append(out, s.members[s.memberIdx[id]])
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// ownerFor does a standard consistent-hash ring lookup.
|
|
func (s *memberSnapshot) ownerFor(shardID string) (string, bool) {
|
|
if s.empty() || len(s.ring) == 0 {
|
|
return "", false
|
|
}
|
|
h := hashShardID(shardID)
|
|
i := sort.Search(len(s.ring), func(i int) bool { return s.ring[i].hash >= h })
|
|
if i == len(s.ring) {
|
|
i = 0
|
|
}
|
|
return s.ring[i].memberID, true
|
|
}
|
|
|
|
// hashShardID: crc32/Castagnoli. Stdlib, stable, well-distributed.
|
|
func hashShardID(s string) uint32 {
|
|
return crc32.Checksum([]byte(s), crc32.MakeTable(crc32.Castagnoli))
|
|
}
|
|
|
|
func buildSnapshot(members []baseMember) *memberSnapshot {
|
|
snap := &memberSnapshot{
|
|
members: members,
|
|
memberIdx: make(map[string]int, len(members)),
|
|
}
|
|
for i, m := range members {
|
|
snap.memberIdx[m.ID] = i
|
|
}
|
|
for _, m := range members {
|
|
if len(m.Shards) == 0 {
|
|
snap.ring = append(snap.ring, ringPoint{hash: hashShardID(m.ID), memberID: m.ID})
|
|
continue
|
|
}
|
|
for _, p := range m.Shards {
|
|
snap.ring = append(snap.ring, ringPoint{hash: hashShardID(p), memberID: m.ID})
|
|
}
|
|
}
|
|
sort.Slice(snap.ring, func(i, j int) bool { return snap.ring[i].hash < snap.ring[j].hash })
|
|
return snap
|
|
}
|
|
|
|
// memberCache: one poller per distinct service host.
|
|
type memberCache struct {
|
|
host string
|
|
httpClient *http.Client
|
|
interval time.Duration
|
|
logger logging.Logger
|
|
|
|
mu sync.RWMutex
|
|
snap *memberSnapshot
|
|
|
|
stopOnce sync.Once
|
|
stop chan struct{}
|
|
}
|
|
|
|
func newMemberCache(host string, interval time.Duration, logger logging.Logger) *memberCache {
|
|
c := &memberCache{
|
|
host: host,
|
|
interval: interval,
|
|
logger: logger,
|
|
httpClient: &http.Client{Timeout: 3 * time.Second},
|
|
stop: make(chan struct{}),
|
|
}
|
|
c.refresh()
|
|
go c.loop()
|
|
return c
|
|
}
|
|
|
|
func (c *memberCache) loop() {
|
|
t := time.NewTicker(c.interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
c.refresh()
|
|
case <-c.stop:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *memberCache) refresh() {
|
|
metricMemberPolls.Inc()
|
|
req, err := http.NewRequest(http.MethodGet, c.host+"/-/base/members", nil)
|
|
if err != nil {
|
|
c.logger.Error(fmt.Sprintf("[BACKEND: base-network] build members req %s: %v", c.host, err))
|
|
return
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
c.logger.Warning(fmt.Sprintf("[BACKEND: base-network] poll %s: %v", c.host, err))
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
c.logger.Warning(fmt.Sprintf("[BACKEND: base-network] poll %s: status %d", c.host, resp.StatusCode))
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
c.logger.Warning(fmt.Sprintf("[BACKEND: base-network] read: %v", err))
|
|
return
|
|
}
|
|
var parsed baseMembersResponse
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
c.logger.Warning(fmt.Sprintf("[BACKEND: base-network] parse: %v", err))
|
|
return
|
|
}
|
|
clean := parsed.Members[:0]
|
|
for _, m := range parsed.Members {
|
|
if m.ID == "" || m.URL == "" {
|
|
continue
|
|
}
|
|
clean = append(clean, m)
|
|
}
|
|
snap := buildSnapshot(clean)
|
|
c.mu.Lock()
|
|
c.snap = snap
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *memberCache) snapshot() *memberSnapshot {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.snap
|
|
}
|
|
|
|
func (c *memberCache) close() {
|
|
c.stopOnce.Do(func() { close(c.stop) })
|
|
}
|
|
|
|
// memberCachePool singletons caches per service host. Multiple endpoints
|
|
// pointing at the same base-network service share one poller.
|
|
var memberCachePool = struct {
|
|
sync.Mutex
|
|
caches map[string]*memberCache
|
|
}{caches: make(map[string]*memberCache)}
|
|
|
|
func getMemberCache(host string, interval time.Duration, logger logging.Logger) *memberCache {
|
|
memberCachePool.Lock()
|
|
defer memberCachePool.Unlock()
|
|
if c, ok := memberCachePool.caches[host]; ok {
|
|
return c
|
|
}
|
|
c := newMemberCache(host, interval, logger)
|
|
memberCachePool.caches[host] = c
|
|
return c
|
|
}
|
|
|
|
var (
|
|
metricShardMismatches = metric.NewCounter(metric.CounterOpts{
|
|
Name: "gateway_base_network_shard_mismatches_total",
|
|
Help: "Upstream 307s indicating stale shard→owner mapping.",
|
|
})
|
|
metricMemberPolls = metric.NewCounter(metric.CounterOpts{
|
|
Name: "gateway_base_network_member_polls_total",
|
|
Help: "Total member-list poll attempts against base-network services.",
|
|
})
|
|
metric307 = metric.NewCounter(metric.CounterOpts{
|
|
Name: "gateway_base_network_307_total",
|
|
Help: "Total 307 redirects followed once to a peer pod.",
|
|
})
|
|
)
|
|
|
|
func writeMethod(m string) bool {
|
|
switch strings.ToUpper(m) {
|
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// BaseNetworkBackendFactory wraps `next` and intercepts backends carrying a
|
|
// BaseNetworkNamespace extra_config block. All others fall through.
|
|
func BaseNetworkBackendFactory(logger logging.Logger, next proxy.BackendFactory) proxy.BackendFactory {
|
|
return func(remote *config.Backend) proxy.Proxy {
|
|
raw, ok := remote.ExtraConfig[BaseNetworkNamespace]
|
|
if !ok {
|
|
return next(remote)
|
|
}
|
|
b, err := json.Marshal(raw)
|
|
if err != nil {
|
|
logger.Error("[BACKEND: base-network] marshal config:", err.Error())
|
|
return next(remote)
|
|
}
|
|
var cfg BaseNetworkConfig
|
|
if err := json.Unmarshal(b, &cfg); err != nil {
|
|
logger.Error("[BACKEND: base-network] parse config:", err.Error())
|
|
return next(remote)
|
|
}
|
|
if len(remote.Host) == 0 {
|
|
logger.Error("[BACKEND: base-network] no host configured")
|
|
return next(remote)
|
|
}
|
|
if cfg.ShardKeySource == "" {
|
|
logger.Error("[BACKEND: base-network] shard_key_source is required")
|
|
return next(remote)
|
|
}
|
|
interval := 5 * time.Second
|
|
if cfg.MemberPollIntervalMS > 0 {
|
|
interval = time.Duration(cfg.MemberPollIntervalMS) * time.Millisecond
|
|
}
|
|
cache := getMemberCache(remote.Host[0], interval, logger)
|
|
logger.Info(fmt.Sprintf("[BACKEND: base-network] routing %s via %s (key=%s, src=%s)",
|
|
remote.URLPattern, remote.Host[0], cfg.ShardKey, cfg.ShardKeySource))
|
|
return newBaseNetworkProxy(cfg, cache)
|
|
}
|
|
}
|
|
|
|
func newBaseNetworkProxy(cfg BaseNetworkConfig, cache *memberCache) proxy.Proxy {
|
|
httpClient := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
// Never follow redirects automatically; 307 is a routing signal.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
|
|
return func(ctx context.Context, req *proxy.Request) (*proxy.Response, error) {
|
|
shardID := extractShardKey(cfg.ShardKeySource, req)
|
|
if shardID == "" {
|
|
return errorResponse(http.StatusBadRequest, `{"error":"shard_key_missing"}`), nil
|
|
}
|
|
snap := cache.snapshot()
|
|
if snap.empty() {
|
|
return errorResponse(http.StatusBadGateway, `{"error":"no_members"}`), nil
|
|
}
|
|
target, err := pickTarget(snap, shardID, req.Method)
|
|
if err != nil {
|
|
return errorResponse(http.StatusBadGateway, `{"error":"no_members"}`), nil
|
|
}
|
|
|
|
// Body is read once and replayed on 307 retry.
|
|
var bodyBytes []byte
|
|
if req.Body != nil {
|
|
bb, err := io.ReadAll(req.Body)
|
|
_ = req.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("base-network: read body: %w", err)
|
|
}
|
|
bodyBytes = bb
|
|
}
|
|
|
|
resp, err := dispatch(ctx, httpClient, target, shardID, req, bodyBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == http.StatusTemporaryRedirect {
|
|
metric307.Inc()
|
|
if loc := resp.Header.Get("Location"); loc != "" {
|
|
metricShardMismatches.Inc()
|
|
resp.Body.Close()
|
|
resp2, err := dispatch(ctx, httpClient, loc, shardID, req, bodyBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp2.StatusCode == http.StatusTemporaryRedirect {
|
|
resp2.Body.Close()
|
|
return errorResponse(http.StatusBadGateway, `{"error":"redirect_loop"}`), nil
|
|
}
|
|
return responseFromHTTP(ctx, resp2), nil
|
|
}
|
|
}
|
|
return responseFromHTTP(ctx, resp), nil
|
|
}
|
|
}
|
|
|
|
// pickTarget selects the upstream URL. Writes use rendezvous-hash (HRW) within
|
|
// the shard's subset — stable under add/remove of non-winning members. Reads
|
|
// use a different seeded pick for spread while staying pinned per shard.
|
|
func pickTarget(snap *memberSnapshot, shardID, method string) (string, error) {
|
|
subset := snap.subsetFor(shardID)
|
|
if len(subset) == 0 {
|
|
owner, ok := snap.ownerFor(shardID)
|
|
if !ok {
|
|
return "", errors.New("no members")
|
|
}
|
|
return snap.members[snap.memberIdx[owner]].URL, nil
|
|
}
|
|
if writeMethod(method) {
|
|
bestIdx := 0
|
|
bestHash := hashShardID(shardID + "|" + subset[0].ID)
|
|
for i := 1; i < len(subset); i++ {
|
|
h := hashShardID(shardID + "|" + subset[i].ID)
|
|
if h < bestHash {
|
|
bestHash = h
|
|
bestIdx = i
|
|
}
|
|
}
|
|
return subset[bestIdx].URL, nil
|
|
}
|
|
return subset[int(hashShardID(shardID+"|r"))%len(subset)].URL, nil
|
|
}
|
|
|
|
// dispatch issues one upstream request. Target is a fully-qualified pod URL.
|
|
func dispatch(ctx context.Context, c *http.Client, target, shardID string, req *proxy.Request, body []byte) (*http.Response, error) {
|
|
fullURL := strings.TrimRight(target, "/") + req.Path
|
|
if len(req.Query) > 0 {
|
|
fullURL += "?" + req.Query.Encode()
|
|
}
|
|
var reader io.Reader
|
|
if len(body) > 0 {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
hreq, err := http.NewRequestWithContext(ctx, req.Method, fullURL, reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("base-network: build request: %w", err)
|
|
}
|
|
for k, vs := range req.Headers {
|
|
for _, v := range vs {
|
|
hreq.Header.Add(k, v)
|
|
}
|
|
}
|
|
hreq.Header.Set("X-Base-Shard", shardID)
|
|
return c.Do(hreq)
|
|
}
|
|
|
|
// responseFromHTTP adapts an http.Response to a proxy.Response suitable for
|
|
// the no-op render path. Body streams through; headers (inc. Set-Cookie for
|
|
// txseq) are preserved.
|
|
func responseFromHTTP(ctx context.Context, r *http.Response) *proxy.Response {
|
|
return &proxy.Response{
|
|
Data: map[string]interface{}{},
|
|
IsComplete: r.StatusCode < http.StatusInternalServerError,
|
|
Io: proxy.NewReadCloserWrapper(ctx, r.Body),
|
|
Metadata: proxy.Metadata{
|
|
StatusCode: r.StatusCode,
|
|
Headers: r.Header,
|
|
},
|
|
}
|
|
}
|
|
|
|
func errorResponse(status int, body string) *proxy.Response {
|
|
return &proxy.Response{
|
|
Data: map[string]interface{}{},
|
|
IsComplete: status < http.StatusInternalServerError,
|
|
Io: io.NopCloser(strings.NewReader(body)),
|
|
Metadata: proxy.Metadata{
|
|
StatusCode: status,
|
|
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// extractShardKey reads the shard key per source spec. Returns "" when absent.
|
|
//
|
|
// jwt.sub → X-User-Id (injected by auth_middleware)
|
|
// jwt.owner → X-Org-Id (injected by auth_middleware)
|
|
// jwt.<other> → not supported (auth_middleware doesn't propagate)
|
|
// header:<Name> → raw header
|
|
// cookie:<name> → cookie value
|
|
func extractShardKey(src string, req *proxy.Request) string {
|
|
switch {
|
|
case src == "jwt.sub":
|
|
return firstHeader(req.Headers, "X-User-Id")
|
|
case src == "jwt.owner":
|
|
return firstHeader(req.Headers, "X-Org-Id")
|
|
case strings.HasPrefix(src, "jwt."):
|
|
return ""
|
|
case strings.HasPrefix(src, "header:"):
|
|
return firstHeader(req.Headers, strings.TrimPrefix(src, "header:"))
|
|
case strings.HasPrefix(src, "cookie:"):
|
|
return readCookie(req.Headers, strings.TrimPrefix(src, "cookie:"))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstHeader(h map[string][]string, name string) string {
|
|
for _, k := range []string{name, strings.ToLower(name), http.CanonicalHeaderKey(name)} {
|
|
if vs, ok := h[k]; ok && len(vs) > 0 && vs[0] != "" {
|
|
return vs[0]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readCookie(h map[string][]string, name string) string {
|
|
raw := firstHeader(h, "Cookie")
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
req := http.Request{Header: http.Header{"Cookie": []string{raw}}}
|
|
if c, err := req.Cookie(name); err == nil {
|
|
return c.Value
|
|
}
|
|
return ""
|
|
}
|