server: replicate mesh-eligible routes to every live mesh peer (#821)
Traced the actual request path before relying on domain scoping alone: router.go's ServeHTTP looks up a request's host in that box's OWN routes SQLite and 404s on a miss, before the mesh/activator logic ever runs. Registries are per-box with no sync (confirmed by TestMeshProxyToOwner's own test setup, which only passes because its helper seeds the route on both boxes) — so even with mesh placement scoped to in-scope domains, a box with no row for a host would 404 it the moment HRW assigned it there. Adds the fan-out this needs: - POST /api/mesh/route (meshreplicate.go), wired into the router's HTTPS mux only when the mesh is on, bearer-authenticated with a new required LCHAT_MESH_TOKEN shared secret. The wire payload is narrower than Route — Socket/DBPath are always derived locally by the receiver, never trusted from a peer. - registerInstance fans a new hibernating, in-scope route out to every live mesh peer right after marking it ready (best-effort, logged on a peer failure). - `lchat mesh-seed <peer>` bulk-replicates every existing in-scope route once, for a freshly joined box's initial catch-up. A peer's own LCHAT_MESH_SELF address was never issued a cert of its own (only registered wildcard-covered instance hostnames get one), so meshReplicateRoute dials the peer's raw address but performs TLS (SNI) against the route's own hostname being replicated — the same trick mesh.go's newPeerProxy already uses for the reverse-proxy hop. Deliberately deferred, not silently dropped: route mutations after creation (rename, remove, a later hibernate flip) aren't replicated yet, only the initial fan-out and one-time seed.
Co-authored-by: Claude <noreply@anthropic.com>
7 files changed,
+497
−8
control.go+12 −0main.go+3 −0mesh.go+22 −5mesh_test.go+26 −2meshreplicate.go+274 −0meshreplicate_test.go+148 −0router.go+12 −1
diff --git a/control.go b/control.go| index e962630..9054101 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -287,6 +287,18 @@ func materializeInstance(reg *Registry, route Route, token string, opts addInsta |
| if err := reg.upsert(route); err != nil { |
| return nil, fmt.Errorf("mark ready: %v", err) |
| } |
| + if route.Hibernate { |
| + // Best-effort: give any live mesh peer this route before HRW could ever |
| + // place it there (meshreplicate.go). A no-op when the mesh is off or |
| + // this host is out of LCHAT_MESH_DOMAINS scope — self-hosters and |
| + // non-mesh boxes are unaffected. Never fails materialization: the |
| + // route and admin link stand regardless of replication's outcome. |
| + if hc, hcErr := hibernateConfigFromEnv(); hcErr != nil { |
| + log.Printf("mesh: skipping route replication for %s: %v", host, hcErr) |
| + } else { |
| + fanOutMeshRoute(hc, route) |
| + } |
| + } |
| if err := systemctl("reload", routerUnit); err != nil { |
| return nil, fmt.Errorf("reload %s: %v (route added; run: systemctl reload %s)", routerUnit, err, routerUnit) |
| } |
diff --git a/main.go b/main.go| index fd6f5d0..4697956 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -143,6 +143,9 @@ func main() { |
| case "add-instance": |
| runAddInstance(os.Args[2:]) |
| return |
| + case "mesh-seed": |
| + runMeshSeed(os.Args[2:]) |
| + return |
| case "deploy": |
| runDeploy(os.Args[2:]) |
| return |
diff --git a/mesh.go b/mesh.go| index b27ff0f..12197c8 100644 |
| --- a/mesh.go |
| +++ b/mesh.go |
| @@ -99,6 +99,7 @@ type meshSvc struct { |
| self string // this box's peer base URL — its mesh identity |
| store meshStore |
| domains []string // LCHAT_MESH_DOMAINS, lowercased — see inScope. Empty = unrestricted (test convenience only; meshFromEnv always supplies a non-empty list) |
| + token string // LCHAT_MESH_TOKEN — shared secret authenticating box-to-box route replication (meshreplicate.go); empty = unrestricted (test convenience only) |
| interval time.Duration |
| staleAfter time.Duration |
| now func() time.Time // a seam for the unit tests' clock |
| @@ -135,7 +136,10 @@ func newMesh(self string, store meshStore, domains []string) *meshSvc { |
| // comma-list shape as LCHAT_WILDCARD_DOMAINS, parsed by the same |
| // splitDomains): failing loudly here beats silently letting HRW "place" a |
| // host onto a box that has no registry row for it (a 404, not a graceful |
| -// wake) or that could never get it a matching cert — see inScope. |
| +// wake) or that could never get it a matching cert — see inScope. And |
| +// LCHAT_MESH_TOKEN, the shared secret every mesh member authenticates |
| +// box-to-box route replication with (meshreplicate.go) — without it any box |
| +// could hand any other box arbitrary routes. |
| func meshFromEnv(hc *hibernateConfig) (*meshSvc, error) { |
| self := strings.TrimSpace(os.Getenv("LCHAT_MESH_SELF")) |
| if self == "" { |
| @@ -148,6 +152,10 @@ func meshFromEnv(hc *hibernateConfig) (*meshSvc, error) { |
| if len(domains) == 0 { |
| return nil, errors.New("LCHAT_MESH_SELF set but LCHAT_MESH_DOMAINS missing — mesh placement needs an explicit domain allowlist so HRW never assigns a host to a box that has no registry row for it or can't obtain it a matching cert; set it to the domains every mesh member can serve (typically the same value as LCHAT_WILDCARD_DOMAINS)") |
| } |
| + token := strings.TrimSpace(os.Getenv("LCHAT_MESH_TOKEN")) |
| + if token == "" { |
| + return nil, errors.New("LCHAT_MESH_SELF set but LCHAT_MESH_TOKEN missing — route replication between mesh members needs a shared secret; generate one and set it identically on every mesh box") |
| + } |
| self = strings.TrimRight(self, "/") |
| u, err := url.Parse(self) |
| if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "" { |
| @@ -157,7 +165,9 @@ func meshFromEnv(hc *hibernateConfig) (*meshSvc, error) { |
| if err != nil { |
| return nil, fmt.Errorf("mesh heartbeat store: %w", err) |
| } |
| - return newMesh(self, store, domains), nil |
| + m := newMesh(self, store, domains) |
| + m.token = token |
| + return m, nil |
| } |
| // start registers this box (one synchronous beat, so the first sweep and the |
| @@ -262,17 +272,24 @@ func (m *meshSvc) liveBoxes() []string { |
| // hosts under a configured LCHAT_MESH_DOMAINS domain are ever hashed across |
| // boxes; everything else always resolves to whichever box is asked — correct |
| // because registerInstance only ever replicates a route to the boxes covered |
| -// by those same domains (control.go), so no other box has a row for it |
| +// by those same domains (meshreplicate.go), so no other box has a row for it |
| // anyway, and this box (which was asked) is the only one that could possibly |
| // serve it. An empty domain list is unrestricted — a test-construction |
| // convenience (direct newMesh callers exercising bare HRW); meshFromEnv |
| // always supplies a non-empty list in production. |
| func (m *meshSvc) inScope(host string) bool { |
| - if len(m.domains) == 0 { |
| + return hostInDomains(host, m.domains) |
| +} |
| + |
| +// hostInDomains is inScope's matching rule, factored out so `lchat mesh-seed` |
| +// (meshreplicate.go) can filter routes to replicate without needing a full |
| +// meshSvc: host equals a configured domain, or is a subdomain of one. |
| +func hostInDomains(host string, domains []string) bool { |
| + if len(domains) == 0 { |
| return true |
| } |
| host = strings.ToLower(host) |
| - for _, d := range m.domains { |
| + for _, d := range domains { |
| if host == d || strings.HasSuffix(host, "."+d) { |
| return true |
| } |
diff --git a/mesh_test.go b/mesh_test.go| index eaf6d8f..95df128 100644 |
| --- a/mesh_test.go |
| +++ b/mesh_test.go |
| @@ -424,6 +424,7 @@ func TestMeshDomainScopingBypassesHRW(t *testing.T) { |
| // no registry row for it (see the inScope doc comment). |
| func TestMeshFromEnvRequiresDomains(t *testing.T) { |
| t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal") |
| + t.Setenv("LCHAT_MESH_TOKEN", "shared-secret") |
| t.Setenv("LCHAT_MESH_DOMAINS", "") |
| if _, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}); err == nil { |
| t.Fatal("mesh without LCHAT_MESH_DOMAINS should be a config error") |
| @@ -439,6 +440,27 @@ func TestMeshFromEnvRequiresDomains(t *testing.T) { |
| } |
| } |
| +// meshFromEnv refuses to start the mesh without a shared route-replication |
| +// secret — an unauthenticated /api/mesh/route would let any caller hand this |
| +// box arbitrary routes (see meshreplicate.go). |
| +func TestMeshFromEnvRequiresToken(t *testing.T) { |
| + t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal") |
| + t.Setenv("LCHAT_MESH_DOMAINS", "11ch.at") |
| + t.Setenv("LCHAT_MESH_TOKEN", "") |
| + if _, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}); err == nil { |
| + t.Fatal("mesh without LCHAT_MESH_TOKEN should be a config error") |
| + } |
| + |
| + t.Setenv("LCHAT_MESH_TOKEN", "shared-secret") |
| + m, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}) |
| + if err != nil || m == nil { |
| + t.Fatalf("valid token: %v, %v", m, err) |
| + } |
| + if m.token != "shared-secret" { |
| + t.Fatalf("token = %q, want it read through", m.token) |
| + } |
| +} |
| + |
| // --- the proxy hop ------------------------------------------------------------- |
| // Scheme/Host/SNI semantics of the peer hop: the instance hostname survives |
| @@ -616,9 +638,11 @@ func TestMeshOffTripwire(t *testing.T) { |
| // meshFromEnv: unset = off (nil, nil — the zero-change default); set without |
| // the hibernation bucket = a loud config error; malformed URLs rejected. |
| func TestMeshFromEnv(t *testing.T) { |
| - // A valid domain allowlist throughout — TestMeshFromEnvRequiresDomains |
| - // covers the missing-domains case on its own. |
| + // A valid domain allowlist and token throughout — |
| + // TestMeshFromEnvRequiresDomains/TestMeshFromEnvRequiresToken cover their |
| + // missing cases on their own. |
| t.Setenv("LCHAT_MESH_DOMAINS", "11ch.at") |
| + t.Setenv("LCHAT_MESH_TOKEN", "shared-secret") |
| t.Setenv("LCHAT_MESH_SELF", "") |
| if m, err := meshFromEnv(&hibernateConfig{Bucket: "b"}); err != nil || m != nil { |
diff --git a/meshreplicate.go b/meshreplicate.go| new file mode 100644 |
| index 0000000..ab697af |
| --- /dev/null |
| +++ b/meshreplicate.go |
| @@ -0,0 +1,274 @@ |
| +package main |
| + |
| +// Route replication across the activator mesh (mesh.go). registerInstance's |
| +// reg.upsert only ever writes ONE box's registry — but a host under |
| +// LCHAT_MESH_DOMAINS needs a routes row on every live mesh member before HRW |
| +// can honestly place it there (mesh.go's inScope doc comment: a box with no |
| +// row for a host 404s it, never wakes it). This file is the fan-out: |
| +// |
| +// - An admin RPC, POST /api/mesh/route (meshRouteHandler, wired by runRouter |
| +// only when the mesh is on), inserts or updates one route. |
| +// - registerInstance calls fanOutMeshRoute right after its local upsert, |
| +// best-effort against whichever peers are live right now. |
| +// - `lchat mesh-seed <peer>` (runMeshSeed) bulk-replicates every existing |
| +// in-scope route once — the catch-up a freshly joined box needs before it |
| +// can serve anything, since it joins with an empty registry. |
| +// |
| +// Both paths send only the fields a route's CONTENT actually needs (host, |
| +// kind, hibernate flag, per-instance config) — never Socket/DBPath, which are |
| +// box-local filesystem layout the receiving box must derive itself exactly as |
| +// registerInstance does for a route created locally, and never |
| +// Status/NextDue/CreatedAt, which are receiver-local lifecycle state. |
| +// |
| +// Honest gap: route MUTATIONS after creation (rename, remove, a hibernate |
| +// flip) are not replicated — only the initial registerInstance fan-out and |
| +// the one-time seed. A box whose peer never saw a later change either serves |
| +// stale config (rare per-instance fields) or, for a removed host, still has a |
| +// row an out-of-scope hash never assigns it anyway (mesh.go's inScope). Worth |
| +// closing before mesh placement is load-bearing for more than the initial |
| +// auto-provisioned space pool. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/subtle" |
| + "crypto/tls" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// meshRoutePayload is the wire shape for one replicated route — deliberately |
| +// narrower than Route (see file doc comment). |
| +type meshRoutePayload struct { |
| + Host string |
| + Kind string |
| + Hibernate bool |
| + AppStoreURL string |
| + HomeOrigin string |
| + PushRelayURL string |
| + PushRelayToken string |
| + HomeRegion string |
| + Provider string |
| + Version string |
| +} |
| + |
| +func routeToPayload(r Route) meshRoutePayload { |
| + return meshRoutePayload{ |
| + Host: r.Host, Kind: r.Kind, Hibernate: r.Hibernate, |
| + AppStoreURL: r.AppStoreURL, HomeOrigin: r.HomeOrigin, |
| + PushRelayURL: r.PushRelayURL, PushRelayToken: r.PushRelayToken, |
| + HomeRegion: r.HomeRegion, Provider: r.Provider, Version: r.Version, |
| + } |
| +} |
| + |
| +// toLocalRoute rebuilds a full Route from a peer's payload, deriving the |
| +// box-local Socket/DBPath itself — never trusting a peer's filesystem layout |
| +// — exactly as registerInstance does for a route created locally. |
| +func (p meshRoutePayload) toLocalRoute() (Route, error) { |
| + host := strings.ToLower(strings.TrimSpace(p.Host)) |
| + if !validHost(host) { |
| + return Route{}, fmt.Errorf("%w: %q", errInvalidHost, host) |
| + } |
| + socket := filepath.Join(instanceSocketDir, host+".sock") |
| + if err := checkSocketLen(socket); err != nil { |
| + return Route{}, err |
| + } |
| + return Route{ |
| + Host: host, Kind: p.Kind, Socket: socket, |
| + DBPath: filepath.Join(instanceDataDir, host+".db"), |
| + AppStoreURL: p.AppStoreURL, |
| + HomeOrigin: p.HomeOrigin, |
| + PushRelayURL: p.PushRelayURL, |
| + PushRelayToken: p.PushRelayToken, |
| + HomeRegion: p.HomeRegion, |
| + Provider: p.Provider, |
| + Hibernate: p.Hibernate, |
| + Version: p.Version, |
| + }, nil |
| +} |
| + |
| +// meshReplicateRoute POSTs one route to peer's /api/mesh/route, authenticated |
| +// with the mesh's shared token. Used both by fanOutMeshRoute and by |
| +// runMeshSeed's bulk seed. |
| +// The peer's own base URL (LCHAT_MESH_SELF, typically a bare address like |
| +// https://10.0.0.5) was never issued a certificate of its own — only |
| +// registered, wildcard-covered instance hostnames get one. So — exactly like |
| +// mesh.go's own newPeerProxy authenticates a proxied hop by the INSTANCE |
| +// hostname, never the peer's bare identity — this dials the peer's raw |
| +// address but performs TLS (and sends Host) as route.Host: a wildcard-covered |
| +// hostname the receiving box's wildcard manager hands back a valid cert for |
| +// regardless of whether that host is registered THERE yet (the "unknown |
| +// subdomain still completes TLS" property wildcard.go relies on). Callers |
| +// only ever pass an in-scope route (fanOutMeshRoute/runMeshSeed both check |
| +// first), so this is never asked to authenticate as an out-of-scope host. |
| +func meshReplicateRoute(ctx context.Context, peer, token string, route Route) error { |
| + peerURL, err := url.Parse(peer) |
| + if err != nil || peerURL.Host == "" { |
| + return fmt.Errorf("bad peer URL %q: %w", peer, err) |
| + } |
| + body, err := json.Marshal(routeToPayload(route)) |
| + if err != nil { |
| + return err |
| + } |
| + reqURL := url.URL{Scheme: peerURL.Scheme, Host: route.Host, Path: "/api/mesh/route"} |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL.String(), bytes.NewReader(body)) |
| + if err != nil { |
| + return err |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + |
| + dialAddr := peerDialAddr(peerURL) |
| + transport := &http.Transport{ |
| + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { |
| + return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, dialAddr) |
| + }, |
| + } |
| + if peerURL.Scheme == "https" { |
| + transport.DialTLSContext = func(ctx context.Context, network, _ string) (net.Conn, error) { |
| + conn, err := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, dialAddr) |
| + if err != nil { |
| + return nil, err |
| + } |
| + tlsConn := tls.Client(conn, &tls.Config{ServerName: route.Host}) |
| + if err := tlsConn.HandshakeContext(ctx); err != nil { |
| + conn.Close() |
| + return nil, err |
| + } |
| + return tlsConn, nil |
| + } |
| + } |
| + client := &http.Client{Transport: transport} |
| + |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { |
| + return fmt.Errorf("peer %s: %s", peer, resp.Status) |
| + } |
| + return nil |
| +} |
| + |
| +// fanOutMeshRoute replicates route to every OTHER live mesh member, |
| +// best-effort: a peer that's down or errors just can't yet serve this host |
| +// (see the file doc comment's honest gap on mutations — creation retries only |
| +// via a later add-instance or an operator's mesh-seed rerun). A no-op — byte- |
| +// identical to today — whenever the mesh is off or the host is out of |
| +// LCHAT_MESH_DOMAINS scope, so self-hosters and non-mesh boxes are untouched. |
| +// Called right after registerInstance's local reg.upsert. |
| +func fanOutMeshRoute(hc *hibernateConfig, route Route) { |
| + mesh, err := meshFromEnv(hc) |
| + if err != nil || mesh == nil || !mesh.inScope(route.Host) { |
| + return |
| + } |
| + mesh.beat() // one synchronous PUT+LIST for a fresh membership view |
| + for _, peer := range mesh.liveBoxes() { |
| + if peer == mesh.self { |
| + continue |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + err := meshReplicateRoute(ctx, peer, mesh.token, route) |
| + cancel() |
| + if err != nil { |
| + log.Printf("mesh: replicate %s to %s failed: %v — it can't serve this host until a later replication succeeds", route.Host, peer, err) |
| + } |
| + } |
| +} |
| + |
| +// meshRouteHandler is the receiving side: POST /api/mesh/route, wired by |
| +// runRouter only when the mesh is on. Bearer-authenticated with the same |
| +// shared token every mesh member is configured with — without this, any |
| +// caller could hand this box arbitrary routes. |
| +func meshRouteHandler(reg *Registry, rt *router, token string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + const prefix = "Bearer " |
| + auth := r.Header.Get("Authorization") |
| + if !strings.HasPrefix(auth, prefix) || |
| + subtle.ConstantTimeCompare([]byte(strings.TrimPrefix(auth, prefix)), []byte(token)) != 1 { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + var payload meshRoutePayload |
| + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&payload); err != nil { |
| + http.Error(w, "bad request", http.StatusBadRequest) |
| + return |
| + } |
| + route, err := payload.toLocalRoute() |
| + if err != nil { |
| + http.Error(w, err.Error(), http.StatusBadRequest) |
| + return |
| + } |
| + if err := reg.upsert(route); err != nil { |
| + log.Printf("mesh: route replication upsert %s failed: %v", route.Host, err) |
| + http.Error(w, "internal error", http.StatusInternalServerError) |
| + return |
| + } |
| + if err := rt.reload(); err != nil { |
| + log.Printf("mesh: route replication reload after %s failed: %v", route.Host, err) |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| + } |
| +} |
| + |
| +// runMeshSeed is `lchat mesh-seed <peer-base-url>`: bulk-replicates every |
| +// existing in-scope, hibernating route to one peer — the one-time catch-up a |
| +// freshly joined box needs (it starts with an empty registry) before HRW can |
| +// honestly place any host there. Reads LCHAT_MESH_DOMAINS/LCHAT_MESH_TOKEN |
| +// directly rather than through meshFromEnv: the CALLER (the already-running |
| +// box doing the seeding) need not have LCHAT_MESH_SELF set yet — seeding is |
| +// an operator step that runs before the mesh itself is flipped on (see |
| +// docs/instance-router.md). |
| +func runMeshSeed(args []string) { |
| + fs := flag.NewFlagSet("mesh-seed", flag.ExitOnError) |
| + registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") |
| + _ = fs.Parse(args) |
| + peer := fs.Arg(0) |
| + if peer == "" { |
| + die("mesh-seed: usage: lchat mesh-seed <peer-base-url>") |
| + } |
| + domains := splitDomains(os.Getenv("LCHAT_MESH_DOMAINS")) |
| + if len(domains) == 0 { |
| + die("mesh-seed: LCHAT_MESH_DOMAINS must be set to the domains this seed covers") |
| + } |
| + token := strings.TrimSpace(os.Getenv("LCHAT_MESH_TOKEN")) |
| + if token == "" { |
| + die("mesh-seed: LCHAT_MESH_TOKEN must be set") |
| + } |
| + |
| + reg, err := openRegistry(*registryPath) |
| + if err != nil { |
| + die("mesh-seed: open registry: %v", err) |
| + } |
| + routes, err := reg.list() |
| + if err != nil { |
| + die("mesh-seed: list routes: %v", err) |
| + } |
| + seeded := 0 |
| + for _, route := range routes { |
| + if !route.Hibernate || !hostInDomains(route.Host, domains) { |
| + continue |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + err := meshReplicateRoute(ctx, peer, token, route) |
| + cancel() |
| + if err != nil { |
| + fmt.Printf(" %s: FAILED (%v)\n", route.Host, err) |
| + continue |
| + } |
| + fmt.Printf(" %s: seeded\n", route.Host) |
| + seeded++ |
| + } |
| + fmt.Printf("seeded %d/%d in-scope hibernating route(s) to %s\n", seeded, len(routes), peer) |
| +} |
diff --git a/meshreplicate_test.go b/meshreplicate_test.go| new file mode 100644 |
| index 0000000..f918699 |
| --- /dev/null |
| +++ b/meshreplicate_test.go |
| @@ -0,0 +1,148 @@ |
| +package main |
| + |
| +// Tests for meshreplicate.go: the trust boundary in toLocalRoute (only |
| +// content fields survive a peer's payload; Socket/DBPath are always derived |
| +// locally), the receiving handler's bearer auth, and the full round trip over |
| +// a plain HTTP peer (meshReplicateRoute's TLS-SNI-pinned dial for https peers |
| +// mirrors mesh.go's own newPeerProxy, already exercised by TestPeerProxyRewrite |
| +// — this file proves the request/response/auth/upsert plumbing, not the TLS |
| +// mechanics a second time). |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// toLocalRoute must never trust a peer's Socket/DBPath (box-local filesystem |
| +// layout) — it derives both itself from Host, exactly like registerInstance |
| +// does for a route created locally. |
| +func TestMeshRoutePayloadTrustBoundary(t *testing.T) { |
| + route := Route{ |
| + Host: "space1.11ch.at", Kind: "instance", Socket: "/peer/only/space1.sock", |
| + DBPath: "/peer/only/space1.db", Hibernate: true, Version: "v1", |
| + AppStoreURL: "https://apps.example.com", HomeRegion: "eu", |
| + } |
| + payload := routeToPayload(route) |
| + |
| + // Round-trip through JSON, as it travels over the wire. |
| + body, err := json.Marshal(payload) |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + var decoded meshRoutePayload |
| + if err := json.Unmarshal(body, &decoded); err != nil { |
| + t.Fatalf("unmarshal: %v", err) |
| + } |
| + |
| + local, err := decoded.toLocalRoute() |
| + if err != nil { |
| + t.Fatalf("toLocalRoute: %v", err) |
| + } |
| + if local.Socket == route.Socket || local.DBPath == route.DBPath { |
| + t.Fatalf("peer's Socket/DBPath leaked through: got %q / %q", local.Socket, local.DBPath) |
| + } |
| + if want := filepath.Join(instanceSocketDir, "space1.11ch.at.sock"); local.Socket != want { |
| + t.Fatalf("Socket = %q, want locally-derived %q", local.Socket, want) |
| + } |
| + if want := filepath.Join(instanceDataDir, "space1.11ch.at.db"); local.DBPath != want { |
| + t.Fatalf("DBPath = %q, want locally-derived %q", local.DBPath, want) |
| + } |
| + if local.Host != route.Host || local.Version != route.Version || local.AppStoreURL != route.AppStoreURL || local.HomeRegion != route.HomeRegion || !local.Hibernate { |
| + t.Fatalf("content fields did not survive: %+v", local) |
| + } |
| + if local.Status != "" || local.NextDue != 0 || local.CreatedAt != 0 { |
| + t.Fatalf("receiver-local lifecycle fields should be zero, got %+v", local) |
| + } |
| +} |
| + |
| +// An invalid host in the payload must be rejected, not silently coerced into |
| +// a filename/socket path. |
| +func TestMeshRoutePayloadRejectsBadHost(t *testing.T) { |
| + if _, err := (meshRoutePayload{Host: "not a host!!"}).toLocalRoute(); err == nil { |
| + t.Fatal("bad host should be rejected") |
| + } |
| +} |
| + |
| +// meshRouteHandler: wrong or missing bearer is 401 and never touches the |
| +// registry; a correctly authenticated request upserts the route and reloads |
| +// the router so it's immediately servable. |
| +func TestMeshRouteHandlerAuth(t *testing.T) { |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + rt, err := newRouter(reg, nil) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + srv := httptest.NewServer(meshRouteHandler(reg, rt, "correct-token")) |
| + defer srv.Close() |
| + |
| + route := Route{Host: "space2.11ch.at", Kind: "instance", Hibernate: true} |
| + body, _ := json.Marshal(routeToPayload(route)) |
| + |
| + post := func(bearer string) int { |
| + req, _ := http.NewRequest(http.MethodPost, srv.URL, bytes.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + |
| + if code := post(""); code != http.StatusUnauthorized { |
| + t.Fatalf("no bearer: got %d, want 401", code) |
| + } |
| + if code := post("wrong-token"); code != http.StatusUnauthorized { |
| + t.Fatalf("wrong bearer: got %d, want 401", code) |
| + } |
| + if rt.allowed(route.Host) { |
| + t.Fatal("route must not exist after unauthorized attempts") |
| + } |
| + |
| + if code := post("correct-token"); code != http.StatusNoContent { |
| + t.Fatalf("correct bearer: got %d, want 204", code) |
| + } |
| + if !rt.allowed(route.Host) { |
| + t.Fatal("route should be servable immediately after a successful replication (reload)") |
| + } |
| +} |
| + |
| +// The full round trip over a plain HTTP peer: meshReplicateRoute dials the |
| +// peer's raw address and the handler on the other end upserts + reloads. |
| +func TestMeshReplicateRouteOverHTTP(t *testing.T) { |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + rt, err := newRouter(reg, nil) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + mux := http.NewServeMux() |
| + mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, "shared-secret")) |
| + srv := httptest.NewServer(mux) |
| + defer srv.Close() |
| + |
| + route := testRoute("space3.11ch.at") |
| + if err := meshReplicateRoute(context.Background(), srv.URL, "shared-secret", route); err != nil { |
| + t.Fatalf("meshReplicateRoute: %v", err) |
| + } |
| + if !rt.allowed(route.Host) { |
| + t.Fatal("route was not replicated") |
| + } |
| + |
| + if err := meshReplicateRoute(context.Background(), srv.URL, "wrong-secret", route); err == nil { |
| + t.Fatal("wrong token should be rejected") |
| + } |
| +} |
diff --git a/router.go b/router.go| index 76a8173..89556b3 100644 |
| --- a/router.go |
| +++ b/router.go |
| @@ -405,11 +405,22 @@ func runRouter(args []string) { |
| http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusFound) |
| }) |
| + // The mesh route-replication endpoint (meshreplicate.go) only exists when |
| + // the mesh is on — everything else keeps proxying straight through rt, so |
| + // a non-mesh box's HTTPS handler is byte-identical to before this existed. |
| + var httpsHandler http.Handler = rt |
| + if mesh != nil { |
| + mux := http.NewServeMux() |
| + mux.Handle("POST /api/mesh/route", meshRouteHandler(reg, rt, mesh.token)) |
| + mux.Handle("/", rt) |
| + httpsHandler = mux |
| + } |
| + |
| // ReadHeaderTimeout guards the public front door against Slowloris-style slow- |
| // header connection exhaustion. No ReadTimeout/WriteTimeout — those would break |
| // long-lived proxied WebSockets. |
| httpSrv := &http.Server{Addr: *httpAddr, Handler: m.HTTPHandler(httpFallback), ReadHeaderTimeout: 10 * time.Second} |
| - httpsSrv := &http.Server{Addr: *httpsAddr, Handler: rt, TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second} |
| + httpsSrv := &http.Server{Addr: *httpsAddr, Handler: httpsHandler, TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second} |
| // :80 serves the ACME HTTP-01 challenge and redirects everything else to https. |
| go func() { |