Skip to content

Self-certifying machine ids + a per-IP rate rule (fleet-trust stage 1) - #39

Merged
karngyan merged 1 commit into
mainfrom
feat/mac-machine-ids
Aug 9, 2026
Merged

Self-certifying machine ids + a per-IP rate rule (fleet-trust stage 1)#39
karngyan merged 1 commit into
mainfrom
feat/mac-machine-ids

Conversation

@karngyan

@karngyan karngyan commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Implements stage 1 of the fleet-trust spec (spec/fleet-trust.md, riding its own branch in PR #37): the "Self-certifying machine ids" and "Rate rule" sections, and nothing from the fleet-key/certificate/directory sections. The spec file itself is deliberately not in this diff.

What the open id namespace cost

The Worker routes with idFromName(id), and a stateless router cannot know which ids exist — so any grammar-valid id woke a Durable Object. Every scan, typo and probe was a billed DO wake, and the id namespace was effectively "anything lowercase under 64 characters".

The MAC scheme

machine-id  =  <slug> "-" <tag>
slug        =  hostname slug + "-" + 4 random hex   (today's MintMachineID output, verbatim)
tag         =  first 8 lowercase hex of HMAC-SHA256(DAEMON_SECRET, "flue-machine-id/" + slug)
  • Minted where ids were always minted — flue relay setup, flue relay join, and the Remote-screen deploy (all three call config.MintMachineID, which now takes the secret; the signature change is what proves no mint site was missed).
  • The Worker (relay/src/index.ts) verifies the tag statelessly beside the grammar check, before idFromName: a bad tag is the same 404 {"error":"no such machine"} a malformed id gets, and no DO wakes. The HMAC only runs after the cheap regex passes (run_worker_first puts this on every request).
  • On the daemon leg the tag is checked after the Bearer secret, with the reasoning in a comment: that leg is the one route the rate rule doesn't meter, and tag-first would hand it an unthrottled tag oracle (404 for a bad tag vs 401 for a good one) plus an HMAC per anonymous probe — while anyone past the Bearer check already holds the secret tags are minted from, so the check behind it only catches stale ids from a rotated-away secret.
  • Breaking change, no back-compat, per the spec: pre-tag ids are refused by the Worker and by the daemon transport's config check (internal/transport/relay); the sole deployment re-joins. Ids grow nine characters (mac-a1b2-3f9a12cd), and rotating the secret invalidates every id — which re-setup's re-join re-mints anyway.

The rate rule: Cloudflare's rate-limiting binding shipped

The preferred mechanism shipped, not the in-Worker fallback. Verified all three legs can carry it before choosing:

  • API-driven deploy (the one users run): the scripts-upload metadata accepts {"type":"ratelimit","name":…,"namespace_id":…,"simple":{"limit":…,"period":…}} — now emitted by internal/cloudflare (pinned in testdata/deploy_metadata.json and asserted end-to-end through flue relay setup's fake-API test) and wired in internal/relaydeploy.
  • wrangler.jsonc (dev): the modern top-level ratelimits key, same name/namespace/numbers; a comment marks the twin. wrangler 4's config schema carries it and miniflare simulates it — the vitest pool instantiates the binding, and a test asserts env.CLIENT_RATE exists so the two paths can't drift silently.
  • Worker: allowRate keys on CF-Connecting-IP over /client/* and POST /api/pair/*, answers 429 {"error":"rate limited"}, and runs after the grammar check, before the tag HMAC. 300/min per IP per Cloudflare location — order-of-100/min per the spec: fleet tabs (reconnect storms included) never see it; burning quota or walking the 2^32 tag space needs a botnet. Fail-open when the binding is absent (it bounds cost, not access), and the daemon leg is exempt (secret-gated, one socket per machine). GET /api/pair/<id> (a browser following a pairing link, answered from unmetered assets) spends no limiter token.

The fixture

testdata/relay/machine-ids.json pins slug→tag cross-language, like frames.json before it: Go generates (go test ./internal/config/ -update) and re-derives every committed case on ordinary runs; the Worker suite (relay/test/machineid.test.ts) walks the committed file. Cases cover the hostname-fallback slug, the 24-char truncation ceiling, inner double dashes, a hex-shaped slug (the trap for a parser hunting "the hex part" instead of "the last nine characters"), and one slug tagged under two secrets. The test-secret cases are minted under the pool's own DAEMON_SECRET, so one test drives a Go-minted id through the real TS router end to end.

Test ids across both suites now mint through a shared helper (relay/test/harness.ts, machineId()), which is the same HMAC the router verifies.

Docs

spec/relay-protocol.md (Auth grammar + Conformance) and docs/RELAY.md (id shape, join-line failure mode, re-setup consequences, fair-use section — the old "add a WAF rule yourself" paragraph now describes the shipped rule) updated.

Web sources needed no functional change — the browser receives ids from pairing links and never constructs or verifies one; its record grammar is a deliberate superset that tagged ids already satisfy. Only a comment there was corrected (it claimed exactness with the relay grammar), which is why the web suite was run.

Test evidence

  • go vet ./... clean; go test ./... all packages ok (after make web relay)
  • cd relay && pnpm test: 5 files, 126 passed (incl. new fixture walk, tag-routing and rate-rule suites)
  • cd web && pnpm vitest run: 56 files, 998 passed; pnpm run lint clean

🤖 Generated with Claude Code

Machine ids become <slug>-<tag>: the slug is the old mint verbatim
(hostname slug + 4 random hex), the tag the first 8 lowercase hex of
HMAC-SHA256(DAEMON_SECRET, "flue-machine-id/" + slug). Setup, join and
the Remote-screen deploy all hold the secret at mint time and mint
through the same config.MintMachineID; the Worker recomputes the tag
statelessly before idFromName, so a forged or stale id earns the same
404 a malformed one does and no Durable Object wakes. On the daemon
leg the tag is checked after the bearer secret — tag-first would hand
the one unmetered route a tag oracle (404 vs 401) and a free HMAC per
anonymous probe.

The credential-less routes (/client/*, POST /api/pair/*) gain a
Cloudflare rate-limiting binding (CLIENT_RATE, 300 per minute per IP),
carried identically by relay/wrangler.jsonc for dev and by the
API-driven deploy (internal/relaydeploy, a "ratelimit" metadata binding
in internal/cloudflare). The Worker fails open without the binding: the
rule bounds quota burn, not access. The daemon leg stays unmetered —
secret-gated, one socket per machine.

testdata/relay/machine-ids.json pins the tag arithmetic across
languages: internal/config generates it (go test ./internal/config/
-update) and re-derives every case on ordinary runs, and the Worker
suite walks the committed file. Breaking change, no back-compat:
pre-tag ids are refused everywhere, and the sole deployment re-joins.

Implements stage 1 of spec/fleet-trust.md (the spec rides its own
branch and is deliberately not part of this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@karngyan
karngyan merged commit 99d4efc into main Aug 9, 2026
1 check passed
@karngyan
karngyan deleted the feat/mac-machine-ids branch August 9, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant