A route says the largest body its proxy may carry, and both proxies honour it

The registry's public name is served by the predecessor with a twenty-gigabyte buffering
middleware, because a registry takes image layers in single requests of gigabytes and a
proxy's default turns every push into a 413 the registry never sees. A route contribution
had no way to say so, so the mesh could not take the name over without losing what made it
usable.

The contribution now carries `max-request-body`, a whole positive number of bytes, and the
catalogue holds every route to an agreed vocabulary — label or name, port, and the limit —
refusing a key no proxy reads (a field that parses cleanly and does nothing is a promise
nobody keeps) and a route with no port (unreachable by the proxy it just asked for, found at
parse time rather than in a proxy's log). The mesh's own proxy reads the limit as written,
refuses a body past it as 413 rather than the 502 the transport would have reported, and
skips a route whose limit it cannot read rather than carrying what the module said not to.

The registry's hand-over itself is read from the catalogue beside this checkout: the store
still resolves with no proxy, the gate beside it pulls the store in, contributes the
predecessor's name on the port the node gave it, and locks only the door that faces the
world.

hq ADR 0082/0104, the registry hand-over.
This commit is contained in:
2026-09-23 23:19:12 +02:00
parent 8fb32d7ee0
commit 01d57b629f
6 changed files with 695 additions and 28 deletions
+106 -17
View File
@@ -12,7 +12,8 @@
//
// What it is given, written by the host from an ordinary declaration:
//
// $ROUTES every consumer, the name it asked for, and where the mesh says that machine is
// $ROUTES every consumer, the name it asked for, where the mesh says that machine is, and
// the largest request body it will take (`max-request-body`, bytes; absent is no limit)
//
// It re-reads on change rather than being restarted, for the same reason the provisioner does:
// a route arriving or leaving is an ordinary event and must not drop the connections of every
@@ -27,8 +28,10 @@ import (
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net"
"net/http"
"net/http/httputil"
@@ -95,6 +98,23 @@ type contribution struct {
Values map[string]any `json:"values"`
}
// route is one name the proxy serves: where it goes, and what it will not carry there.
type route struct {
// Target is the workload, as a URL.
Target string
// MaxRequestBody is the largest request body, in bytes, this route accepts — the
// contribution's `max-request-body`. Zero is no limit, which is what a route that said nothing
// gets: the mesh's own proxy has never limited a body, and a default that appeared with the
// field would have broken every route that did not ask for one.
MaxRequestBody int64
}
// served is a route the proxy has built: the reverse proxy, and the limit it enforces in front.
type served struct {
proxy *httputil.ReverseProxy
limit int64
}
// table is what the proxy is currently serving, replaced whole whenever the file changes.
//
// Replaced rather than merged: the file is the whole truth about who has a route, so merging
@@ -102,26 +122,28 @@ type contribution struct {
// 08-connectivity lists as open, reintroduced one level down.
type table struct {
mu sync.RWMutex
to map[string]*httputil.ReverseProxy
targets map[string]string
to map[string]served
targets map[string]route
}
func (t *table) set(routes map[string]string) {
made := map[string]*httputil.ReverseProxy{}
for name, target := range routes {
where, err := url.Parse(target)
func (t *table) set(routes map[string]route) {
made := map[string]served{}
for name, r := range routes {
where, err := url.Parse(r.Target)
if err != nil {
log.Printf("route %s points at %q, which is not a URL: %v", name, target, err)
log.Printf("route %s points at %q, which is not a URL: %v", name, r.Target, err)
continue
}
made[name] = httputil.NewSingleHostReverseProxy(where)
proxy := httputil.NewSingleHostReverseProxy(where)
proxy.ErrorHandler = tooLargeOrBadGateway
made[name] = served{proxy: proxy, limit: r.MaxRequestBody}
}
t.mu.Lock()
t.to, t.targets = made, routes
t.mu.Unlock()
}
func (t *table) find(host string) (*httputil.ReverseProxy, bool) {
func (t *table) find(host string) (served, bool) {
// The port is not part of the name. A request to app.example:8080 is for app.example.
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
@@ -132,6 +154,24 @@ func (t *table) find(host string) (*httputil.ReverseProxy, bool) {
return p, ok
}
// tooLargeOrBadGateway is what the proxy answers when the workload could not be reached — or when
// it was the request that stopped, because its body ran past the route's limit.
//
// A body read that hits the limit surfaces as the transport's error, which the reverse proxy
// would report as 502 — the workload's fault, in the client's eyes, for a limit the client hit.
// Named as what it was: 413, so a push that is too large is told so rather than told the registry
// is down.
func tooLargeOrBadGateway(w http.ResponseWriter, r *http.Request, err error) {
var exceeded *http.MaxBytesError
if errors.As(err, &exceeded) {
http.Error(w, fmt.Sprintf("the request body for %q is larger than the %d bytes this route "+
"accepts", r.Host, exceeded.Limit), http.StatusRequestEntityTooLarge)
return
}
log.Printf("http: proxy error: %v", err)
w.WriteHeader(http.StatusBadGateway)
}
func (t *table) names() []string {
t.mu.RLock()
defer t.mu.RUnlock()
@@ -285,13 +325,13 @@ func forThisAuthority(cache, directory string, root []byte) string {
// newTable is an empty routing table.
func newTable() *table {
return &table{to: map[string]*httputil.ReverseProxy{}, targets: map[string]string{}}
return &table{to: map[string]served{}, targets: map[string]route{}}
}
// handler is the proxy itself, separated so it can be driven by a test without a listener.
func handler(held *table) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxy, known := held.find(r.Host)
route, known := held.find(r.Host)
if !known {
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
// are different things, and a proxy that says only "not found" makes an operator go
@@ -302,12 +342,26 @@ func handler(held *table) http.Handler {
r.Host, strings.Join(held.names(), ", "))
return
}
proxy.ServeHTTP(w, r)
if route.limit > 0 {
// **The limit is the route's, enforced here rather than by the workload** — the
// contribution says what the proxy may carry to it, which is the one thing the
// workload cannot say for itself once a proxy stands in front. A body whose length is
// declared and too large is refused before a byte of it is read; one whose length is
// not declared is read up to the limit and refused at the byte past it.
if r.ContentLength > route.limit {
http.Error(w, fmt.Sprintf("the request body for %q is %d bytes, and this route "+
"accepts at most %d", r.Host, r.ContentLength, route.limit),
http.StatusRequestEntityTooLarge)
return
}
r.Body = http.MaxBytesReader(w, r.Body, route.limit)
}
route.proxy.ServeHTTP(w, r)
})
}
// routesFrom reads what the mesh wrote and turns it into name → target.
func routesFrom(path string) (map[string]string, error) {
// routesFrom reads what the mesh wrote and turns it into name → route.
func routesFrom(path string) (map[string]route, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
@@ -317,7 +371,7 @@ func routesFrom(path string) (map[string]string, error) {
return nil, err
}
out := map[string]string{}
out := map[string]route{}
for _, c := range said.Given {
name, _ := c.Values["name"].(string)
if name == "" {
@@ -330,6 +384,16 @@ func routesFrom(path string) (map[string]string, error) {
c.From, c.Node, name)
continue
}
// A limit it cannot honour is a route it does not serve — skipped and named, like a
// port that is not one. Serving the route with no limit instead would carry exactly what
// the module said not to carry, and report success.
limit, ok := bodyLimit(c.Values["max-request-body"])
if !ok {
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is not "+
"a whole positive number of bytes; skipped", c.From, c.Node, name,
c.Values["max-request-body"])
continue
}
// Where the mesh says that machine is. Empty means it is this one — a workload beside the
// proxy is ordinary, and reaching it over loopback is both correct and the only thing
// that works when there is no private network.
@@ -337,11 +401,36 @@ func routesFrom(path string) (map[string]string, error) {
if at == "" {
at = "127.0.0.1"
}
out[strings.ToLower(name)] = fmt.Sprintf("http://%s:%d", at, port)
out[strings.ToLower(name)] = route{
Target: fmt.Sprintf("http://%s:%d", at, port),
MaxRequestBody: limit,
}
}
return out, nil
}
// bodyLimit reads a contribution's `max-request-body`: absent is no limit, and anything present
// must be a whole positive number of bytes — the same rule the control plane's catalogue applies
// when it parses the manifest, so a limit that reaches here has already passed it once.
func bodyLimit(v any) (int64, bool) {
if v == nil {
return 0, true
}
var n float64
switch x := v.(type) {
case float64:
n = x
case int:
n = float64(x)
default:
return 0, false
}
if n < 1 || n != math.Trunc(n) || n > math.MaxInt64 {
return 0, false
}
return int64(n), true
}
// asPort accepts what JSON makes of a number, which is a float even when it was written 8080.
func asPort(v any) (int, bool) {
switch n := v.(type) {