eleven / messenger Public

server: replicate mesh-eligible routes to every live mesh peer

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>
James Adam c2caf045a0c149e82d80ef459fe35fb4c338ba05 parent 32ce9d6
7 files changed, +497 −8
  • control.go +12 −0
  • main.go +3 −0
  • mesh.go +22 −5
  • mesh_test.go +26 −2
  • meshreplicate.go +274 −0
  • meshreplicate_test.go +148 −0
  • router.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 03f04fe..c31f9c6 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() {