Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/flue/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,7 @@ func TestStatusReportsAConfiguredRelayWithoutItsSecret(t *testing.T) {
URL: "wss://flue-relay.example",
Secret: secret,
Origin: "https://flue-relay.example",
MachineID: "karns-macbook-pro-a1b2",
MachineID: "karns-macbook-pro-a1b2-0f9a12cd",
MachineName: "Karn's MacBook Pro",
}); err != nil {
t.Fatalf("SaveRelay: %v", err)
Expand Down Expand Up @@ -1304,7 +1304,7 @@ func TestStartRelayDialsAConfiguredRelay(t *testing.T) {
URL: "ws" + strings.TrimPrefix(ts.URL, "http"),
Secret: secret,
Origin: "https://r.example",
MachineID: "karns-macbook-pro-a1b2",
MachineID: "karns-macbook-pro-a1b2-0f9a12cd",
MachineName: "Karn's MacBook Pro",
}); err != nil {
t.Fatalf("SaveRelay: %v", err)
Expand All @@ -1330,7 +1330,7 @@ func TestStartRelayDialsAConfiguredRelay(t *testing.T) {
}
// The machine id from relay.json rides the dial path — it is how the
// Worker knows which machine's hub this socket is.
if got, want := path.Load().(string), "/daemon/karns-macbook-pro-a1b2"; got != want {
if got, want := path.Load().(string), "/daemon/karns-macbook-pro-a1b2-0f9a12cd"; got != want {
t.Errorf("dial path = %q, want %q", got, want)
}
}
Expand Down
13 changes: 10 additions & 3 deletions cmd/flue/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,11 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri
// This machine's identity on the relay: the id is the slot it dials
// (/daemon/<id>) and the name is its human label. Minted fresh on every
// run like the secret — setup is the recovery path, and a stale id would
// resurrect whatever state the old hub was left holding.
machineID := config.MintMachineID(hostname, rand.Reader)
// resurrect whatever state the old hub was left holding. Minted under the
// fresh secret, necessarily: the id carries a MAC tag the Worker verifies
// against DAEMON_SECRET before routing (config.MachineIDTag), so an id and
// the secret it was minted under can only ever travel together.
machineID := config.MintMachineID(hostname, secret, rand.Reader)
machineName := truncateRunes(hostname, machineNameMaxRunes)

// Last, deliberately. relay.json is what makes the daemon dial, and every
Expand Down Expand Up @@ -384,7 +387,11 @@ func runRelayJoin(w io.Writer, args []string) error {
if machineName == "" {
machineName = truncateRunes(hostname, machineNameMaxRunes)
}
machineID := config.MintMachineID(hostname, rand.Reader)
// Minted under the secret from the join line: the id's MAC tag is what the
// Worker checks before routing (config.MachineIDTag), so a join pasted
// with the wrong secret produces an id the relay 404s — the same failure
// the daemon leg's 401 reports, found one dial later.
machineID := config.MintMachineID(hostname, *secret, rand.Reader)

// The same shape setup writes, derived the same way: bare wss:// URL, the
// https origin on the same host. SaveRelay is 0600 — the file holds the
Expand Down
58 changes: 43 additions & 15 deletions cmd/flue/relay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,15 @@ type wireMetadata struct {
MainModule string `json:"main_module"`
CompatibilityDate string `json:"compatibility_date"`
Bindings []struct {
Type string `json:"type"`
Name string `json:"name"`
ClassName string `json:"class_name"`
Text string `json:"text"`
Type string `json:"type"`
Name string `json:"name"`
ClassName string `json:"class_name"`
Text string `json:"text"`
NamespaceID string `json:"namespace_id"`
Simple *struct {
Limit int `json:"limit"`
Period int `json:"period"`
} `json:"simple"`
} `json:"bindings"`
KeepBindings []string `json:"keep_bindings"`
Migrations *struct {
Expand Down Expand Up @@ -357,9 +362,25 @@ func writeAPIErrorCode(w http.ResponseWriter, code int, msg string) {

// --- helpers -----------------------------------------------------------------

// machineIDRe is the relay's id grammar (relay/src/index.ts), which every id
// this command mints has to satisfy or the Worker answers the dial with 404.
var machineIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// machineIDRe is the relay's id grammar (relay/src/index.ts): a slug, then an
// 8-hex MAC tag. Every id this command mints has to satisfy it — and its tag
// has to verify under the secret saved beside it (config.MachineIDTag) — or
// the Worker answers the dial with 404.
var machineIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,53}-[0-9a-f]{8}$`)

// assertMachineIDMinted checks an id the CLI minted: inside the grammar, and
// tagged under the secret it will dial with — the property the Worker's
// verifyMachineId enforces before any id picks a Durable Object.
func assertMachineIDMinted(t *testing.T, id, secret string) {
t.Helper()
if !machineIDRe.MatchString(id) {
t.Fatalf("machine id %q is not inside the relay's grammar", id)
}
slug, tag := id[:len(id)-9], id[len(id)-8:]
if want := config.MachineIDTag(secret, slug); tag != want {
t.Fatalf("machine id %q carries tag %q, want %q — the tag must be the MAC of the slug under the saved secret, or the relay will never route this machine", id, tag, want)
}
}

func oneAccount() []cloudflare.Account {
return []cloudflare.Account{{ID: "acct-0123456789abcdef", Name: "Karn's Account"}}
Expand Down Expand Up @@ -462,7 +483,7 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) {
// running: without the ASSETS binding every /api/* fallthrough calls fetch
// on undefined, and without observability there are no logs to find out
// with. Both are silent in a deploy that otherwise reports success.
var gotAssets, gotHub, gotVersion bool
var gotAssets, gotHub, gotVersion, gotRate bool
for _, b := range f.meta.Bindings {
switch {
case b.Type == "assets" && b.Name == "ASSETS":
Expand All @@ -473,6 +494,14 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) {
// The version stamp: what the Worker reports on /api/health, and
// what lets a daemon see a relay older than itself.
gotVersion = true
case b.Type == "ratelimit" && b.Name == "CLIENT_RATE":
// The per-IP bound on the credential-less routes. The numbers are
// pinned here because they have a dev-only twin in
// relay/wrangler.jsonc, and this deploy is the one users run.
if b.NamespaceID != "1001" || b.Simple == nil || b.Simple.Limit != 300 || b.Simple.Period != 60 {
t.Fatalf("ratelimit binding = %+v, want namespace 1001, 300 per 60s", b)
}
gotRate = true
}
}
if !gotVersion {
Expand All @@ -484,6 +513,9 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) {
if !gotHub {
t.Fatalf("no HUB -> DaemonHub durable object binding in %+v", f.meta.Bindings)
}
if !gotRate {
t.Fatalf("no CLIENT_RATE ratelimit binding in %+v; the deployed relay would serve its credential-less routes unmetered", f.meta.Bindings)
}
if f.meta.Observability == nil || !f.meta.Observability.Enabled {
t.Fatalf("observability = %+v, want enabled", f.meta.Observability)
}
Expand Down Expand Up @@ -557,9 +589,7 @@ func TestRunRelaySetupDeploysTheWorkerAndTheWebApp(t *testing.T) {
if saved.Origin != "https://"+host {
t.Fatalf("relay.json origin = %q", saved.Origin)
}
if !machineIDRe.MatchString(saved.MachineID) {
t.Fatalf("relay.json machine_id = %q, which is not a valid machine id", saved.MachineID)
}
assertMachineIDMinted(t, saved.MachineID, saved.Secret)
hostname, err := os.Hostname()
if err != nil {
t.Fatalf("os.Hostname: %v", err)
Expand Down Expand Up @@ -883,9 +913,7 @@ func TestRunRelayJoinWritesTheRelayConfig(t *testing.T) {
if saved.Secret != "s3cr3t-from-setup" {
t.Fatalf("relay.json secret = %q, want the one given", saved.Secret)
}
if !machineIDRe.MatchString(saved.MachineID) {
t.Fatalf("relay.json machine_id = %q, which is not a valid machine id", saved.MachineID)
}
assertMachineIDMinted(t, saved.MachineID, saved.Secret)
hostname, err := os.Hostname()
if err != nil {
t.Fatalf("os.Hostname: %v", err)
Expand Down Expand Up @@ -1009,7 +1037,7 @@ func TestRunRelayStatusReportsTheConfiguredRelay(t *testing.T) {
URL: "wss://flue-relay.karn.workers.dev",
Secret: "s3cret-value",
Origin: "https://flue-relay.karn.workers.dev",
MachineID: "karns-macbook-pro-a1b2",
MachineID: "karns-macbook-pro-a1b2-0f9a12cd",
MachineName: "Karn's MacBook Pro",
}); err != nil {
t.Fatalf("SaveRelay: %v", err)
Expand Down
5 changes: 3 additions & 2 deletions cmd/flue/relayui.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,9 @@ func (s *relayUIService) Provision(ctx context.Context, req daemon.RelayUIDeploy
}

// The same record `flue relay setup` writes, for the same reasons — see
// runRelaySetup for why the id and secret are fresh and the write is last.
machineID := config.MintMachineID(hostname, rand.Reader)
// runRelaySetup for why the id and secret are fresh, why the id is minted
// under the fresh secret, and why the write is last.
machineID := config.MintMachineID(hostname, secret, rand.Reader)
machineName := truncateRunes(hostname, machineNameMaxRunes)
if err := config.SaveRelay(config.Relay{
URL: "wss://" + host,
Expand Down
66 changes: 47 additions & 19 deletions docs/RELAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ daemon ---- wss /daemon/<id> ----> Worker + one DO per machine <---- wss /cli
```

- **The `<id>` in the path is routing, not identity.** It is the machine id a
daemon joined under (`<hostname>-<4 hex>`, minted by setup and join) and
the Worker turns it into that machine's own Durable Object (`idFromName`),
handing the hub the bare path. One lowercase slug of 1–63 characters;
anything else, including a bare `/daemon` or `/client`, is answered
daemon joined under (`<hostname>-<4 hex>-<8 hex tag>`, minted by setup and
join) and the Worker turns it into that machine's own Durable Object
(`idFromName`), handing the hub the bare path. The tag is a MAC over the
rest of the id under the daemon secret, and the Worker verifies it before
routing — so only ids this relay's own setup or join minted exist, and a
guessed or hand-made id wakes nothing. One lowercase slug of at most 63
characters ending in the 8-hex tag; anything else — a bare `/daemon` or
`/client`, a tampered tag, an id from before tags — is answered
`404 {"error":"no such machine"}` and never with an asset.
- **The daemon leg is outbound.** Nothing on your machine listens for the
relay's sake. Every binary message on it is `[4-byte big-endian channel]` then
Expand Down Expand Up @@ -89,8 +93,11 @@ flue relay join wss://flue-relay.<sub>.workers.dev --secret <...>
Run it there and restart that daemon. That is the whole of adding a machine.
Join never touches the Cloudflare API: the Worker exists and the secret is the
whole credential, so everything it does is local, check the address, mint
this machine a fresh id (`<hostname>-<4 hex>`, its slot on the relay and the
`<id>` in both wss paths), and write the same `relay.json` shape setup writes.
this machine a fresh id (`<hostname>-<4 hex>-<8 hex tag>`, its slot on the
relay and the `<id>` in both wss paths — the tag is a MAC under the secret
from the join line, which is why an id minted with a mistyped secret dials
into `404 no such machine`), and write the same `relay.json` shape setup
writes.
`--name` sets the label the machine picker shows; it defaults to the hostname
and rides the pairing link's query (`n=`) so the pairing browser can write it
down, never a path, and never anything the Worker routes on. The printed line carries the secret (that is the
Expand All @@ -110,7 +117,10 @@ and the secret are upserts) but it is a reset, not a repair: every run mints
a fresh secret *and* a fresh machine id. The fresh secret is deliberate:
setup is the recovery path for a leaked one, and a run that reused the old
could never rotate it, and it means every machine that joined is now
presenting a stale secret and has to run the newly printed join line. The
presenting a stale secret and has to run the newly printed join line — and,
because ids carry a MAC tag minted under the secret, every old id stops
routing at the same moment (the re-join each machine runs anyway mints its
fresh one). The
fresh id means the old hub slot is simply abandoned: a browser that paired
against it is dialling a slot no daemon dials, answered `503 daemon offline`
until it pairs this machine again and forgets the old row in the picker. To
Expand Down Expand Up @@ -298,6 +308,25 @@ machine:
| pairing body cap | 4 KiB | an oversized POST; over it, `413` |
| pairing answer deadline | 10 s | a daemon that never answers; `504` |

Two more run in the Worker itself, before any hub is picked:

- **MAC machine ids.** An id only routes if its 8-hex tag verifies under the
daemon secret (`spec/relay-protocol.md`, Auth), so the whole space of
guessed, scanned or hand-made ids answers `404` without waking a Durable
Object. What used to be "any grammar-valid id wakes an object" is now "only
ids this relay minted exist".
- **A per-IP rate rule.** A Cloudflare rate-limiting binding covers
`/client/*` and `POST /api/pair/*`: 300 requests per minute per IP (per
Cloudflare location), `429 {"error":"rate limited"}` over it. A fleet of
tabs — reconnect storms included — never sees it; spending a free-plan
relay's daily request allowance, or brute-walking the 2^32 tag space,
needs a botnet. The daemon leg is exempt: secret-gated, one socket per
machine. Wired by `flue relay setup`/`update` (internal/relaydeploy) and
by `relay/wrangler.jsonc` for dev, so a deployed relay and the one under
`pnpm dev` carry the same rule. Cloudflare's own WAF rate-limiting rules
(dashboard → Security) remain available on top if your traffic wants a
tighter number.

The message cap is the one whose *number* matters beyond itself: the daemon
reads the socket carrying every browser on your machine with a 2 MiB limit that
kills the connection rather than the message, so the relay's 1 MiB has to stay
Expand All @@ -310,16 +339,14 @@ anyone holding the relay URL can probe which of your machines are up, which
machines exist and when they are online, never what they carry, because
everything a channel forwards is still behind Noise.

**Worth adding yourself: a rate limit on `/api/pair`.** That endpoint carries no
credential by design, and the caps above bound how many attempts one caller can
*hold* rather than how fast they can arrive. A wrong token costs you nothing:
it does not spend your pairing window, so a flood cannot stop you pairing, but
it can spend a free-plan relay's daily request allowance. Cloudflare's own
**Rate Limiting rules** (dashboard → your Worker's route → Security → WAF) are
free, run at the edge before the Durable Object wakes, and are the right place
for a limit that depends on your traffic rather than on this code. Something
like 10 requests per minute per IP on `/api/pair` is far above any human
ceremony (`docs/FOLLOW-UPS.md` item 13).
The rate rule above is the shipped answer to the flood that used to be worth
adding a WAF rule for: a wrong pairing token still costs you nothing (it does
not spend your pairing window), and now the arrival *rate* is bounded too,
not just how many attempts one caller can hold. What the in-code rule cannot
be is traffic-aware — 300/min/IP is a ceiling for abuse, not a fit to your
usage — so a WAF Rate Limiting rule on `/api/pair` (something like 10
requests per minute per IP, far above any human ceremony) remains a sensible
addition on a relay that sees hostile traffic (`docs/FOLLOW-UPS.md` item 13).

**What does not exist yet is an output-rate cap.** A session that streams
continuously (`yes`, `tail -f` on a firehose) pins the object active and floods
Expand Down Expand Up @@ -429,8 +456,9 @@ a dev build carries no Worker to deploy.
- [ ] The isolation check, by hand: stop machine A's daemon, leave B's up, and
`curl -si --http1.1 -H 'Upgrade: websocket' https://<relay>/client/<A's id>`
answers `503` `{"error":"daemon offline"}`. B's daemon being up must
never answer for A. The same command against a bare `/client`, or an id
with a capital in it, answers `404` `{"error":"no such machine"}`.
never answer for A. The same command against a bare `/client`, an id
with a capital in it, or A's id with one tag character changed, answers
`404` `{"error":"no such machine"}`.
- [ ] Re-run `flue relay setup` on the same account. It succeeds (the deploy
and the secret are upserts) and it is a reset: the phone pairs this
machine again (the fresh machine id abandons the slot its old row
Expand Down
43 changes: 43 additions & 0 deletions internal/cloudflare/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,13 @@ type DeployInput struct {
// script in the dashboard — nothing secret may ever ride one.
PlainTextVars map[string]string

// RateLimits become bindings of type "ratelimit" — Cloudflare's Workers
// rate-limiting binding, the same object wrangler's `ratelimits` config
// key produces. The wire shape is
// {"type":"ratelimit","name":…,"namespace_id":…,"simple":{"limit":…,"period":…}},
// pinned by testdata/deploy_metadata.json.
RateLimits []RateLimit

// AssetHeaders is a `_headers` document — response headers for the static
// assets, in the file format Cloudflare's asset router parses. Empty sends
// no header config at all. See assetsConfig.Headers for why this travels in
Expand Down Expand Up @@ -445,6 +452,28 @@ type binding struct {
ClassName string `json:"class_name,omitempty"`
// Text is only present on plain_text bindings; every other type omits it.
Text string `json:"text,omitempty"`
// NamespaceID and Simple are only present on ratelimit bindings. The
// namespace id is a string on the wire — Cloudflare's schema says so,
// wrangler sends it so — even though its content is an integer.
NamespaceID string `json:"namespace_id,omitempty"`
Simple *rateLimitSimple `json:"simple,omitempty"`
}

// RateLimit is one Workers rate-limiting binding: name is what the Worker
// reads it off env as, namespace_id keys the limiter's state (unique per
// limiter within the account), and limit/period are the rule — at most limit
// requests per period seconds per key per Cloudflare location. Cloudflare
// accepts only 10 or 60 for period.
type RateLimit struct {
Name string
NamespaceID string
Limit int
Period int
}

type rateLimitSimple struct {
Limit int `json:"limit"`
Period int `json:"period"`
}

type observability struct {
Expand Down Expand Up @@ -536,6 +565,20 @@ func (c *Client) Deploy(ctx context.Context, in DeployInput) error {
})
}

// Rate limiters after the plain-text vars, sorted by name like the rest:
// deterministic order is what keeps two deploys of the same input the same
// request.
limiters := append([]RateLimit(nil), in.RateLimits...)
sort.Slice(limiters, func(i, j int) bool { return limiters[i].Name < limiters[j].Name })
for _, rl := range limiters {
meta.Bindings = append(meta.Bindings, binding{
Type: "ratelimit",
Name: rl.Name,
NamespaceID: rl.NamespaceID,
Simple: &rateLimitSimple{Limit: rl.Limit, Period: rl.Period},
})
}

// The assets binding goes after the Durable Object ones, which is the order
// wrangler emits, and is only meaningful when there are assets to bind to.
if in.AssetsBinding != "" && len(in.Assets) > 0 {
Expand Down
1 change: 1 addition & 0 deletions internal/cloudflare/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func deployFixture() DeployInput {
AssetsRunWorkerFirst: []string{"/daemon", "/client", "/api/*"},
AssetHeaders: "/*\n X-Fixture: yes\n",
AssetsBinding: "ASSETS",
RateLimits: []RateLimit{{Name: "CLIENT_RATE", NamespaceID: "1001", Limit: 300, Period: 60}},
Observability: true,
}
}
Expand Down
9 changes: 9 additions & 0 deletions internal/cloudflare/testdata/deploy_metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
"name": "HUB",
"class_name": "DaemonHub"
},
{
"type": "ratelimit",
"name": "CLIENT_RATE",
"namespace_id": "1001",
"simple": {
"limit": 300,
"period": 60
}
},
{
"type": "assets",
"name": "ASSETS"
Expand Down
Loading