edge, host: a sidecar row is refused on kind, at both ends of the fleet chain
4 files changed,
+149
−5
internal/edge/edge.go+17 −0internal/edge/edge_test.go+65 −0internal/host/fleetbind.go+27 −5internal/host/fleetbind_test.go+40 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 151be4f35..c3826cb2e 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -311,6 +311,23 @@ func (e *Edge) Reload() error { |
| // retires activator children for hosts absent from `managed`. |
| probeSpecs := make(map[string]probeSpec) |
| for _, t := range routes { |
| + // A sidecar is a member that serves nothing: it binds no socket, and |
| + // nothing — inside the box or outside it — may ever dial it. Refused |
| + // here on KIND, as the first act of the loop and ahead of every other |
| + // check, rather than left to fall through the dispatch switch for want |
| + // of a Socket/Addr/Tunnel. |
| + // |
| + // That omission is not a guarantee. The fleet arm below binds on |
| + // placement alone (`isFleetPlaced && e.tunnels != nil`, no kind, no |
| + // backend column), and a provisioned Eleven sidecar IS fleet-placed |
| + // (region "fleet/<name>") — so the row this feature creates reaches a |
| + // working internet-facing proxy by shape. This refusal is the |
| + // structural guarantee that survives a future arm, column or |
| + // placement finding the row: no map entry, so no proxy; and no map |
| + // entry means Allowed says no, so no ACME issuance either. |
| + if t.Kind == "sidecar" { |
| + continue |
| + } |
| // Only serve ready routes: a 'creating'/'failed' entry has no working |
| // backend, so proxying it would just stall → 502 — AND, since Allowed |
| // is the autocert HostPolicy, serving it would burn an ACME issuance |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index 157fc7d71..503ed79b9 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -2175,3 +2175,68 @@ func TestServeHTTPMetersRequestsAndBytesExcludingStatus(t *testing.T) { |
| t.Fatalf("status route must not be metered, got %+v", rows["status.example.com"]) |
| } |
| } |
| + |
| +// TestSidecarRouteIsNeverServable is the structural guarantee behind the |
| +// sidecar member kind: a sidecar is a process that serves nothing and that |
| +// nothing may dial, and the edge must refuse it on KIND rather than on the |
| +// accident of it carrying no backend columns. |
| +// |
| +// The row below is adversarial on purpose. It carries a Socket AND an Addr |
| +// AND a Tunnel AND a fleet placement (Region + FleetBox), on an edge with a |
| +// tunnel hub armed and the fleet registered — every column and every |
| +// precondition that any arm of Reload's dispatch switch looks at, all at |
| +// once. Eleven's sidecars are fleet-placed, so the fleet arm (which checks no |
| +// kind at all) is reached by such a row on shape alone. If this test ever |
| +// fails, a sidecar is reachable from the internet. |
| +func TestSidecarRouteIsNeverServable(t *testing.T) { |
| + const host = "bot.eleven.bdm.sidecar.internal" |
| + |
| + reg := openReg(t) |
| + if err := reg.UpsertFleet(registry.Fleet{Name: "eleven", Account: "amadan", Bucket: "eleven-data"}); err != nil { |
| + t.Fatalf("UpsertFleet: %v", err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: "eleven", Label: "box1", TokenHash: sha256Hex("tok"), Status: "active"}); err != nil { |
| + t.Fatalf("AddFleetBox: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, |
| + Kind: "sidecar", |
| + Socket: filepath.Join(t.TempDir(), "whatever.sock"), |
| + Addr: "127.0.0.1:9", |
| + Tunnel: "eleven/box1", |
| + Region: "fleet/eleven", |
| + FleetBox: "box1", |
| + Status: "ready", |
| + App: "eleven", |
| + Account: "bdm", |
| + Platform: true, |
| + }); err != nil { |
| + t.Fatalf("upsert sidecar route: %v", err) |
| + } |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + // Arm the hub, so the Tunnel and fleet arms of the dispatch switch are |
| + // live rather than skipped by their e.tunnels == nil guard. |
| + e.EnableTunnels(NewTunnelHub("tunnel.example.com", nil)) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + |
| + if _, ok := e.current()[host]; ok { |
| + t.Fatalf("a sidecar row entered the routing map — it is reachable from the internet") |
| + } |
| + // Allowed is the autocert HostPolicy: a sidecar host must never cost an |
| + // ACME issuance either. |
| + if e.Allowed(host) { |
| + t.Fatalf("Allowed(%s) = true — a certificate must never be requested for a sidecar host", host) |
| + } |
| + |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + if code, _ := getHost(t, srv.URL, host); code != http.StatusNotFound { |
| + t.Fatalf("GET %s = %d, want 404 — a sidecar must be unreachable", host, code) |
| + } |
| +} |
diff --git a/internal/host/fleetbind.go b/internal/host/fleetbind.go| index adbed21e0..e75fdb298 100644 |
| --- a/internal/host/fleetbind.go |
| +++ b/internal/host/fleetbind.go |
| @@ -289,8 +289,8 @@ func fleetBindingHook(store release.Store, fleet, box string) func(host string) |
| // - an agent restart discards activator.Service's in-memory entries, but |
| // an already-running exec child (or a unit-backed tenant) keeps serving |
| // — its host would otherwise have no fresh binding until its NEXT idle |
| -// wake, which may be a long time coming, or never for an always-awake |
| -// sidecar host; |
| +// wake, which may be a long time coming, or never for a host that is |
| +// always awake; |
| // - a route that isn't activator-managed at all (Hibernate unset, or a |
| // unit-backed Backing) never goes through activator.wake in the first |
| // place, so it would never get a binding published any other way. |
| @@ -307,10 +307,32 @@ func publishResidentFleetBindings(store release.Store, fleet, box string, reg *r |
| ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| defer cancel() |
| now := time.Now().UnixMilli() |
| - for _, rt := range routes { |
| - b := FleetBinding{Host: rt.Host, Fleet: fleet, Box: box, BoundAt: now} |
| + for _, host := range residentBindingHosts(routes) { |
| + b := FleetBinding{Host: host, Fleet: fleet, Box: box, BoundAt: now} |
| if err := WriteFleetBinding(ctx, store, b, false); err != nil { |
| - log.Printf("agent: fleet binding for %s: %v", rt.Host, err) |
| + log.Printf("agent: fleet binding for %s: %v", host, err) |
| + } |
| + } |
| +} |
| + |
| +// residentBindingHosts is the set of hosts this box advertises as served — |
| +// the pure host-selection half of publishResidentFleetBindings, split out so |
| +// the refusal below is directly testable. |
| +// |
| +// Sidecar routes (Kind "sidecar") are excluded. Nothing routes to a sidecar, |
| +// so a binding claiming a box serves it is a lie the platform side then acts |
| +// on: ReconcileFleetBindings stamps SetFleetBox from it, and a fleet-placed |
| +// route is exactly what the edge's fleet dispatch arm turns into a live |
| +// internet-facing proxy. Filtered at the publisher rather than at the |
| +// reconciler because this is the only place that decides what a box CLAIMS — |
| +// the claim is what must never exist. |
| +func residentBindingHosts(routes []registry.Route) []string { |
| + var out []string |
| + for _, rt := range routes { |
| + if rt.Host == "" || rt.Kind == "sidecar" { |
| + continue |
| } |
| + out = append(out, rt.Host) |
| } |
| + return out |
| } |
diff --git a/internal/host/fleetbind_test.go b/internal/host/fleetbind_test.go| index ea8c24e57..7f24c1031 100644 |
| --- a/internal/host/fleetbind_test.go |
| +++ b/internal/host/fleetbind_test.go |
| @@ -591,3 +591,43 @@ func TestFleetBindingHookTakesOverFromAnotherBox(t *testing.T) { |
| t.Fatalf("Box = %q, want new-box (OnWake's hook must take over)", got.Box) |
| } |
| } |
| + |
| +// TestSidecarBindingIsNeverPublished: a fleet binding says "this box serves |
| +// this host". Nothing routes to a sidecar, so publishing one would be a lie |
| +// the platform-side reconciler then acts on — ReconcileFleetBindings stamps |
| +// SetFleetBox from it, and a fleet-placed route is exactly what the edge's |
| +// fleet dispatch arm binds. The filter therefore lives in the publisher's own |
| +// host selection, not in the reconciler that reads it back. |
| +func TestSidecarBindingIsNeverPublished(t *testing.T) { |
| + got := residentBindingHosts([]registry.Route{ |
| + {Host: "app.example.com", Kind: "instance", Socket: "/run/carlos/a.sock"}, |
| + {Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/eleven"}, |
| + }) |
| + if len(got) != 1 || got[0] != "app.example.com" { |
| + t.Fatalf("published %v — a sidecar must never be advertised as served by a box", got) |
| + } |
| +} |
| + |
| +// The end-to-end shape of the same refusal: the startup pass over a registry |
| +// holding both kinds must write a binding object for the instance and none |
| +// at all for the sidecar. |
| +func TestPublishResidentFleetBindingsSkipsSidecars(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert instance: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/eleven"}); err != nil { |
| + t.Fatalf("upsert sidecar: %v", err) |
| + } |
| + |
| + publishResidentFleetBindings(s, "eleven", "box1", reg) |
| + |
| + if _, _, err := s.Get(ctx, fleetBindingKey("eleven", "app.example.com")); err != nil { |
| + t.Fatalf("instance binding missing: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, fleetBindingKey("eleven", "bot.eleven.bdm.sidecar.internal")); err == nil { |
| + t.Fatalf("a sidecar binding was published — a box must never advertise a sidecar as served") |
| + } |
| +} |