Revert "server: replicate mesh-eligible routes to every live mesh peer"
This reverts commit c2caf045a0c149e82d80ef459fe35fb4c338ba05.
7 files changed,
+8
−497
control.go+0 −12main.go+0 −3mesh.go+5 −22mesh_test.go+2 −26meshreplicate.go+0 −274meshreplicate_test.go+0 −148router.go+1 −12
diff --git a/control.go b/control.go| index 9054101..e962630 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -287,18 +287,6 @@ 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 c31f9c6..03f04fe 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -143,9 +143,6 @@ 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 12197c8..b27ff0f 100644 |
| --- a/mesh.go |
| +++ b/mesh.go |
| @@ -99,7 +99,6 @@ 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 |
| @@ -136,10 +135,7 @@ 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. 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. |
| +// wake) or that could never get it a matching cert — see inScope. |
| func meshFromEnv(hc *hibernateConfig) (*meshSvc, error) { |
| self := strings.TrimSpace(os.Getenv("LCHAT_MESH_SELF")) |
| if self == "" { |
| @@ -152,10 +148,6 @@ 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 != "" { |
| @@ -165,9 +157,7 @@ func meshFromEnv(hc *hibernateConfig) (*meshSvc, error) { |
| if err != nil { |
| return nil, fmt.Errorf("mesh heartbeat store: %w", err) |
| } |
| - m := newMesh(self, store, domains) |
| - m.token = token |
| - return m, nil |
| + return newMesh(self, store, domains), nil |
| } |
| // start registers this box (one synchronous beat, so the first sweep and the |
| @@ -272,24 +262,17 @@ 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 (meshreplicate.go), so no other box has a row for it |
| +// by those same domains (control.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 { |
| - 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 { |
| + if len(m.domains) == 0 { |
| return true |
| } |
| host = strings.ToLower(host) |
| - for _, d := range domains { |
| + for _, d := range m.domains { |
| if host == d || strings.HasSuffix(host, "."+d) { |
| return true |
| } |
diff --git a/mesh_test.go b/mesh_test.go| index 95df128..eaf6d8f 100644 |
| --- a/mesh_test.go |
| +++ b/mesh_test.go |
| @@ -424,7 +424,6 @@ 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") |
| @@ -440,27 +439,6 @@ 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 |
| @@ -638,11 +616,9 @@ 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 and token throughout — |
| - // TestMeshFromEnvRequiresDomains/TestMeshFromEnvRequiresToken cover their |
| - // missing cases on their own. |
| + // A valid domain allowlist throughout — TestMeshFromEnvRequiresDomains |
| + // covers the missing-domains case on its 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| deleted file mode 100644 |
| index ab697af..0000000 |
| --- a/meshreplicate.go |
| +++ /dev/null |
| @@ -1,274 +0,0 @@ |
| -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| deleted file mode 100644 |
| index f918699..0000000 |
| --- a/meshreplicate_test.go |
| +++ /dev/null |
| @@ -1,148 +0,0 @@ |
| -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 89556b3..76a8173 100644 |
| --- a/router.go |
| +++ b/router.go |
| @@ -405,22 +405,11 @@ 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: httpsHandler, TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second} |
| + httpsSrv := &http.Server{Addr: *httpsAddr, Handler: rt, TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second} |
| // :80 serves the ACME HTTP-01 challenge and redirects everything else to https. |
| go func() { |