Skip to content

feat(relay): add per-day egress budget (Denial-of-Wallet cap) - #101

Merged
polius merged 9 commits into
mainfrom
feat/relay-daily-budget
Jul 1, 2026
Merged

polius merged 9 commits into
mainfrom
feat/relay-daily-budget

Conversation

@polius

@polius polius commented Jul 1, 2026 •

Copy link
Copy Markdown
Owner

What

Adds FSEND_RELAY_MAX_BYTES_PER_DAY: a server-wide cap on relay bytes per UTC day.

Default 0 = unlimited. Once spent, the relay refuses new transfers and drops in-flight ones until 00:00 UTC.

Why

Per-IP and per-session limits can't bound total cost:

  • A distributed abuser sidesteps per-IP caps.
  • A per-session cap only limits one transfer.

A daily budget is the only knob that bounds cumulative egress spend. This is the Denial-of-Wallet backstop.

How it works

Two enforcement paths:

  • New transfer, budget spent — fails fast at allocate time. Deterministic. Both peers get the reason.
  • Budget trips mid-transfer — evicts via the same path as the per-session cap.

Both surface as E037 with a descriptive message, on sender and receiver.

Descriptive errors

Every rate-limit error now ends with a self-hosting link so users know they can run their own server:

  • E017 (per-IP session limits)
  • E023 (per-session byte cap)
  • E037 (daily budget)

Renames (breaking, pre-1.0)

  • FSEND_PAIRING_ADDR -> FSEND_SERVER_ADDR
  • FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MIN -> ..._PER_MINUTE

Metrics

One new field on /v1/metrics:

  • budget_bytes_today — bytes forwarded so far in the current UTC day. Approaching your configured FSEND_RELAY_MAX_BYTES_PER_DAY is the early warning; reaching it means the breaker tripped.

The configured limit is deliberately not exposed: it's the operator's own config, and on an open server it would hand a Denial-of-Wallet abuser the exact target.

Docs

Reordered the config (table, startup summary, CLI help) by plane: server-wide, pairing/control plane, relay/data plane.

Validation

Unit tests, -race, and gofmt/vet all clean.

Also validated end-to-end with the real binary, driving two processes through the relay (--mode relay):

Scenario Sender Receiver
Per-session cap, oversized transfer E023 E023
Daily budget trips mid-transfer E037 E037
Daily budget already spent, new transfer E037 E037
No caps (control) success success

Notes

  • Defaults stay 0 (unlimited), as requested.
  • On budget-exhaustion the sender leaves the pairing session to expire via TTL, so the receiver can also reach the relay and get the same E037.

🤖 Generated with Claude Code

Pol Alzina and others added 9 commits July 1, 2026 18:18
Add FSEND_RELAY_MAX_BYTES_PER_DAY: a server-wide cap on relay bytes
per UTC day. Default 0 = unlimited. Once spent, the relay refuses new
transfers and drops in-flight ones until 00:00 UTC.

Bounds total egress cost, which per-IP and per-session limits cannot: a
distributed abuser sidesteps per-IP caps, and a per-session cap only
limits one transfer.

Enforcement has two paths. New transfers fail fast at allocate time
(deterministic, both peers get the reason). A budget that trips
mid-transfer evicts via the same path as the per-session cap.

Both surface as E037 with a descriptive message on sender and receiver.
Every rate-limit error (E017/E023/E037) now ends with a self-hosting
link so users know they can run their own server.

Rename two env vars for consistency (breaking, pre-1.0):
- FSEND_PAIRING_ADDR -> FSEND_SERVER_ADDR
- FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MIN -> ..._PER_MINUTE

Metrics gain budget_max_bytes_per_day, budget_bytes_today, and
transfers_budget_capped_total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two budget gauges already report the actionable state:
budget_bytes_today >= budget_max_bytes_per_day means the breaker
tripped. The counter only tallied mid-flight evictions (not up-front
refusals), so it was a partial, easily-misread signal on top of gauges
that answer the question exactly. Keep it simple.

The per-session transfers_capped_total stays: that cap has no gauge, so
its counter is the only signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop budget_max_bytes_per_day from /v1/metrics. It's the operator's own
config, it's the only limit value the metrics exposed (per-IP and
per-session caps expose only counts), and on an open server it handed a
Denial-of-Wallet abuser the exact target. Keep budget_bytes_today —
live usage, the operator's early warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group the config table, startup summary, and CLI help the same way:
server-wide (log, auth), then the pairing control plane (its listener +
session limits), then the relay data plane (its listener + byte limits).
Previously FSEND_SERVER_ADDR sat after the session limits, so the table
didn't read plane-by-plane. Also fix the startup-example alignment to
match the actual output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The env-var rename missed scripts/preview/demo.sh, which set a custom
port via FSEND_PAIRING_ADDR — now ignored, so the server bound :8080
while the client dialed :8799 and the demo broke. Point it at
FSEND_SERVER_ADDR.

Also add comments: the both-peers allocate asymmetry (only the first
caller sees the budget refusal; the second converges via the in-flight
breaker) and the per-IP-count retention when a budget-exhausted session
is left to PairedTTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prove the lazy-once contract: the relay token is minted on the first
peer's allocate and reused for the second, so the second peer never
re-invokes Allocate. The fake relay is rigged to report the budget spent
on any second Allocate; the test asserts it never fires (allocCalls==1,
no budget_exhausted, shared token). This is the asymmetry that lets a
budget spent mid-pairing converge via the in-flight breaker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spell out what FSEND_RELAY_MAX_BYTES_PER_DAY meters: every forwarded
datagram, once, in both directions (payload one way, ACKs/control the
other) = outbound bytes, matching how egress is billed. Note STUN and
direct/LAN transfers aren't counted, and that a host billing inbound too
sees ~2x. Applied to the config table, the budget_bytes_today metric row,
and the CLI help.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previous wording over-explained "both directions" and a 2x inbound
caveat, which read as if the budget double-counts. It doesn't: each byte
is counted once when forwarded, so a 1 MB relayed file ≈ 1 MB of budget.
State that plainly and drop the confusing bits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@polius
polius merged commit 6e998b7 into main Jul 1, 2026
20 checks passed
@polius
polius deleted the feat/relay-daily-budget branch July 1, 2026 19:08
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