Skip to content

No supported way for a bundle to learn which node it is running on after CELLD_VAR_* removal #219

Description

@samquarm

We run a self-hosted multi-region fleet on v0.4.1 and are blocked from moving to v0.5.0 by the removal of CELLD_VAR_*. I want to describe the use case in case it isn't one you had in view, and be concrete about the cost. I'm not arguing the removal was wrong.

The requirement: one string per node

A bundle needs to know the name of the node it is running on. Nothing else.

Clients connect to a per-region endpoint that terminates on the node for that region. A short-lived credential is minted by our control plane and bound to the region it was minted for, so the serving node can reject one minted elsewhere:

// simplified
const ok = verifyCredential(secret, token, {
  subject,
  region: env.REGION,   // the only per-node value we need
});
if (!ok) return deny();

This is defence in depth on top of the signature and subject checks. It stops a validly minted credential being replayed against a different region.

The shared secret is fleet-wide and moves to vars cleanly. The region name is the problem, because it is the one value that differs per node.

Why the documented replacement doesn't cover it

vars is deployment-wide, and a fleet runs one application with one deploy/current.json, so there is no per-node override. We also can't infer the node from the request: docs/cloudflare-compat.md states the inbound Request has "a cf object without Cloudflare edge fields".

On v0.4.1 there was a second route, globalThis.__cell.node. v0.5.0 closes it deliberately (docs/security.md: "a bundle cannot name a host function, and globalThis carries no __-prefixed property"). We think that hardening is correct and are not asking for it back. We confirmed it is genuinely closed: on v0.5.0, typeof globalThis.__celld is "undefined" from bundle code, both under celld dev and on a real fleet node.

We understand the model behind it. bootstrap.rs says "Every cell scope routes through the host, whichever node owns it", so a bundle asking where it runs in order to make placement decisions is a category error.

Our use is narrower than placement. We do not want node identity to decide where anything goes, since request routing happens entirely in our own control plane before celld is involved. We want it only so a node can refuse a credential issued for somewhere else.

The broader shape: Durable Objects at the edge

Stepping back from our own case, this seems inherent to what celld is for.

The product is self-hosted, distributed Durable Objects. The natural way to deploy that is the way Cloudflare deploys the thing it replicates: many nodes spread across locations, each one a distinct place. Operators adopting celld to get DO semantics without DO billing will tend to scale by adding nodes in more locations, not by making one node bigger. Every node added makes per-node identity more useful, not less.

There is also a capability gap worth naming. On Cloudflare, a Worker can learn something about where it is executing from request.cf. On celld the cf object carries no edge fields, which is entirely reasonable since celld can't prove geolocation it doesn't have. But the combination is what bites: celld provides no edge context on the request, and v0.5.0 removed the last remaining way for a bundle to learn its own node. A self-hosted fleet therefore has strictly less location awareness available to application code than the platform it is replicating, with no supported path to add it back.

We don't think the answer is to synthesise cf fields, which would be claiming knowledge celld doesn't have. A node's own name, which celld does know for certain and already injects into the isolate, seems like the honest primitive: not geolocation, not placement input, just "which of the operator's nodes am I".

The cost of staying on v0.4.1

docs/security.md states that "Security fixes apply to the latest release only, so older alpha builds do not receive fixes". So pinning is not merely forgoing features. It means knowingly forgoing security fixes on a fleet that terminates public traffic. That is what makes this urgent rather than cosmetic for us.

What would unblock us

Any supported way for a bundle to learn its own node name. Roughly in order of how well each seems to fit your model:

  1. Expose the node id on env. You already compute and inject it (bootstrap.rs, inject_routing), so it is close to hand. Read-only, no new configuration surface, and it cannot be used to influence placement.
  2. A ctx field such as ctx.node, if env is meant to stay purely config-derived.
  3. Restore the per-node overlay, but opt-in. Details below.

On option 3, with a working implementation

We prototyped this against v0.5.0 to check it was feasible before suggesting it. Roughly 60 lines across env_vars.rs and fleet.rs, gated behind an explicit CELLD_ALLOW_NODE_VARS, default off.

Opt-in specifically answers the reason you gave for the removal. env_vars.rs explains that a node started with a silently ignored CELLD_VAR_ "would run with no override at all, which reads inside a Worker as a missing secret with nothing in the log", which is a real and nasty failure mode. An operator who had to explicitly enable the feature cannot be surprised by it, so the default stays safe and the sharp edge is only reachable by someone who asked for it.

Verified behaviour of the prototype:

case result
CELLD_VAR_ set, no opt-in hard fail, current message unchanged
CELLD_VAR_ set, opt-in =1 proceeds, value arrives in env
CELLD_VAR_ set, opt-in =0 hard fail
opt-in set to a malformed value loud reject via the existing parse_flag, must be 0 or 1
no CELLD_VAR_, opt-in on unchanged

Two details we found worth getting right, in case they're useful regardless of what you decide:

  • The overlay must read std::env::vars_os(), not vars(). The latter panics if any name or value in the environment is not valid unicode, including variables unrelated to celld. validate() already uses vars_os().
  • The opt-in itself should go through flag() rather than a lenient match, so a typo is rejected loudly rather than silently leaving the feature off.

We deliberately did not restore CELLD_VARS_FILE. One string per node is the need, and leaving the file loader out keeps worker_vars()'s signature and its single caller unchanged.

Happy to share the diff if it is useful, or to test any of the three options on a real multi-region fleet and report back with per-node timing and lease behaviour.

Environment

  • celld v0.4.1 in production, v0.5.0 for the prototype above
  • Multi-region self-hosted fleet, Linux x86_64 and aarch64
  • S3-compatible object store with conditional write support
  • Cell classes holding hibernatable WebSockets (acceptWebSocket, getWebSockets, serializeAttachment); one also uses ctx.storage

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions