feat: independent bandwidth throughput measurement (ADR-015, part of #73) - #90
Conversation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds ProviderAgentService.MeasureBandwidth and its request/response messages exactly as specified in ADR-015 sec1. Regenerated Go bindings via make proto. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements agent-api's measure_bandwidth handler: validates probe_id
(MAX_CHALLENGE_ID) and payload/requested-size bounds (new
MAX_BANDWIDTH_PROBE_BYTES = 8 MiB), generates a random (not zeroed)
download_payload, computes SHA256(upload_payload), times
server_processing_ms after into_inner() and before building the
response, and signs a domain-separated construction consistent with
solve_challenge's existing signing style:
BANDWIDTH_PROBE_DOMAIN ++ be_u32(len(probe_id)) ++ probe_id
++ upload_payload_hash (32 bytes, fixed)
++ be_u32(len(download_payload)) ++ download_payload
++ be_u32(server_processing_ms)
Adds a per-caller rate limiter (BandwidthRateLimiter, fixed window,
10 calls/60s) scoped to just this RPC, keyed by the caller's raw
Ed25519 public key extracted from its mTLS leaf certificate via
tonic::Request::peer_certs() -- real per-caller extraction, not the
coarser global fallback ADR-015 allowed, mirroring agent-cli's
mtls.rs allowlist verifier's identical extraction logic (duplicated,
not shared, since agent-api cannot depend on agent-cli). Callers this
handler cannot identify share one fallback bucket rather than
bypassing the limiter.
Threads a new bandwidth_rate_limiter field through AgentGrpcServer's
construction in agent-cli/src/main.rs.
Tests: valid probe succeeds and its hash/signature verify against a
real keypair; oversized upload_payload / requested_download_bytes
rejected; empty probe_id rejected; download_payload length exactly
matches the request; two concurrent calls sharing one probe_id do not
cross-talk; the rate limiter blocks a caller once its window budget is
exhausted and a distinct caller has an independent budget; the raw-
key extraction helper is verified against a real rcgen-generated
self-signed certificate.
cargo fmt --check, cargo clippy --workspace --all-targets -D warnings,
and cargo test --workspace all pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… loop (ADR-015) Adds ChallengeClient.MeasureBandwidth (internal/networkvalidator/ bandwidth.go): sends an 8 MiB random upload payload and requests an 8 MiB download payload in one round trip, verifies the Agent's signature/hash (bandwidthSignedBytes reproduces agent-api's exact signed-byte construction), estimates ingress/egress Mbps by subtracting the Agent's reported server_processing_ms from the validator's own wall-clock round trip and splitting the remainder proportionally by direction's byte count (estimateThroughputMbps -- a documented approximation, per ADR-015 sec2's own accepted coarseness), and scores pass/fail (10_000 or 0 bps) against the provider's declared ResourceCapability.Bandwidth with a 70% tolerance (bandwidthToleranceBps, ADR-015 sec5's own example value). Wires this into run.go: the Network dimension now calls MeasureBandwidth instead of the generic SolveChallenge-based Challenge() every other dimension still uses -- a deliberate, documented behavior change to already-shipped code (#85). Threads declared bandwidth capacity through: the dashboard's GET /api/v1/agent-endpoint/{provider_id} response (internal/ dashboard/agentendpoint.go) now includes bandwidth_ingress_mbps/ bandwidth_egress_mbps, read from the same Redis heartbeat cache (openinfra:heartbeat:<provider_id>) the overview endpoint's capability display already reads -- the same live directory data the scheduler uses, per ADR-015 sec5. Omitted (not zero-valued) when no fresh heartbeat capability data exists yet, so "nothing declared" is distinguishable from "declared 0 Mbps"; MeasureBandwidth's tolerance check treats both as "nothing to verify against" and passes trivially either way. AgentEndpoint (endpoint.go) carries the decoded fields through to the challenge loop. Also fixes a real bug caught by the new tests: gRPC's default 4 MiB per-message limit (both grpc-go and tonic) rejects an 8 MiB probe outright. Raises the limit on both sides: agent-cli's tonic server (ProviderAgentServiceServer::max_decoding/encoding_message_size, sized off agent-api's newly-public MAX_BANDWIDTH_PROBE_BYTES) and this package's grpc-go dial options (bandwidthMessageSizeLimit). Tests: a fake Agent (extended fakeAgentServer, shared with challenge_test.go) proves score 10_000 when measured throughput comfortably exceeds a low declared tolerance and 0 against an unreachably high declared figure; tampered upload_payload_hash and tampered signature both score 0; an unreachable Agent scores 0 with a reason, never a Go error; passesBandwidthTolerance/ estimateThroughputMbps are unit-tested directly; the end-to-end loop test (run_test.go) now asserts exactly one MeasureBandwidth call and confirms SolveChallenge is still called exactly once for each of the other four dimensions and never for TYPE_NETWORK. Dashboard tests cover the new field being omitted without a Redis client and populated from a real heartbeat cache entry (Postgres/Redis-backed, gated on OPENINFRA_TEST_DATABASE_URL/OPENINFRA_TEST_REDIS_URL per this package's existing convention). gofmt -l ., go build ./..., go vet ./..., and go test ./... all pass (both with and without the Postgres/Redis-backed integration tests enabled, against this session's running dev-stack containers). blockchain/ is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Independently reviewed and re-verified before merging (this session's established standard for Network Validator PRs — not taking the implementation's own report on faith): Byte-layout cross-check. Compared Independently re-ran everything, not just read the PR's own report:
Security properties checked directly, not assumed:
One non-blocking design note for future hardening, not a defect: Merging. |
Summary
Implements ADR-015 (added in this PR,
docs/adr/015-bandwidth-throughput-measurement.md), the core "independent bandwidth measurement" slice of #73: a real, independently-verified bandwidth throughput probe for the Network Validator's challenge loop, replacing the genericSolveChallenge-based liveness check that dimension previously used.This is a substantial slice of #73, not the whole thing — leaves #73 open for its explicitly-out-of-scope remainder: WireGuard overhead accounting, regional endpoint selection, workload-level rate limit enforcement, and the adversarial test suite (congestion, asymmetric links, spoofed results, partitions).
What changed
Protocol (
protocol/proto/openinfra/agent/v1/agent.proto): newMeasureBandwidthRPC andMeasureBandwidthRequest/MeasureBandwidthResponsemessages, exactly as specified in ADR-015 §1. Regenerated viamake proto; generated Go committed.Agent (
provider-agent/crates/agent-api): implementsmeasure_bandwidth— validatesprobe_id/payload bounds (MAX_BANDWIDTH_PROBE_BYTES = 8 MiB, distinct from and larger thanMAX_CHALLENGE_PAYLOAD), generates a random (not zeroed)download_payload, computesSHA256(upload_payload), timesserver_processing_msfrom right afterinto_inner()to just before building the response, and signs a domain-separated construction consistent withsolve_challenge's existing signing style:Rate limiting: implemented real per-caller extraction (not the coarser global fallback ADR-015 allowed as a documented alternative).
BandwidthRateLimiteris a fixed-window counter (10 calls/60s) keyed by the caller's raw Ed25519 public key, extracted from its mTLS leaf certificate viatonic::Request::peer_certs()— the same identityagent-cli'smtls.rsallowlist verifier already establishes trust on, reused here purely as a rate-limit key. The extraction logic is duplicated (not shared) frommtls.rssinceagent-apicannot depend onagent-cli. Callers this handler can't identify share one fallback bucket rather than bypassing the limiter.Validator (
control-plane/internal/networkvalidator):ChallengeClient.MeasureBandwidth(newbandwidth.go) sends/requests an 8 MiB payload each way in one round trip, verifies the Agent's signature and hash (re-deriving the exact signed-byte construction from the Rust side), estimates ingress/egress Mbps by subtracting the Agent'sserver_processing_msfrom the validator's own wall-clock round trip and splitting the remainder proportionally by each direction's byte count (a documented approximation — a single unary gRPC call doesn't expose separate write/read timing), and scores pass/fail (10,000 or 0 bps) against the provider's declared bandwidth with a 70% tolerance (ADR-015 §5's own suggested value, adopted as the actual threshold).Declared bandwidth is threaded through the existing agent-endpoint discovery path: the dashboard's
GET /api/v1/agent-endpoint/{provider_id}response now includesbandwidth_ingress_mbps/bandwidth_egress_mbps, read from the same Redis heartbeat cache the overview endpoint's capability display already reads (the same live directory data the scheduler uses, per ADR-015 §5). Fields are omitted (not zero-valued) when no fresh heartbeat data exists, so "nothing declared" is distinguishable from "declared 0 Mbps"; the tolerance check treats both as "nothing to verify against" and passes trivially either way.run.gois wired so the Network dimension now callsMeasureBandwidthinstead of (not in addition to) the genericChallenge()/SolveChallengepath every other dimension still uses — a deliberate, documented behavior change to already-shipped code (#85), confirmed by a loop-level test.A real bug caught by testing: gRPC's default 4 MiB per-message limit (both grpc-go and tonic) rejected an 8 MiB probe outright. Fixed by raising the limit on both sides —
agent-cli's tonic server (.max_decoding_message_size/.max_encoding_message_size, sized off agent-api's newly-publicMAX_BANDWIDTH_PROBE_BYTES) and this package's grpc-go dial options (bandwidthMessageSizeLimit).Key implementation decisions
bandwidth_signed_bytes, mirrored exactly by the Go side'sbandwidthSignedBytes):BANDWIDTH_PROBE_DOMAIN ++ be_u32(len(probe_id)) ++ probe_id ++ upload_payload_hash (32 bytes, fixed) ++ be_u32(len(download_payload)) ++ download_payload ++ be_u32(server_processing_ms). Mirrorssolve_challenge's existing convention (domain constant, then length-prefixed/fixed-width fields) so both RPCs share one consistent signing style.tonic::Request::peer_certs()is already reachable from anagent-apihandler once compiled into theagent-clibinary (feature unification supplies tonic's "tls" feature).MAX_BANDWIDTH_PROBE_BYTES = 8 MiB(ADR-015 §3's own example), and the validator always sends/requests the full 8 MiB in both directions (maximizes timing resolution). Tolerance is 70% in both directions (ADR-015 §5's example, adopted as the actual threshold) — generous enough to absorb this measurement's documented coarseness (one HTTP/2 stream, no multi-connection saturation, no slow-start removal, proportional time-split approximation) while still catching a materially false declaration.Explicit scope boundaries
blockchain/is untouched (git diff --stat -- blockchain/is empty) — evidence submission's on-chain shape is unchanged, only how Network-dimension evidence is produced.Verification
make proto: clean, generated Go bindings committed and matching (git diff --exit-code -- protocol/generatedpasses).provider-agent/:cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace— all genuinely run, all clean (13 new tests inagent-api, all passing).control-plane/:gofmt -l .(clean),go build ./...,go vet ./...,go test ./...— all genuinely run and clean, including against this session's real running dev-stack Postgres/Redis containers (OPENINFRA_TEST_DATABASE_URL/OPENINFRA_TEST_REDIS_URL) for the Postgres/Redis-gated dashboard tests.blockchain/: untouched, confirmed viagit diff --stat -- blockchain/(empty).blockchain's owncargo clippycould not be run in this sandbox at all (missinglibclang/llvm-config, a pre-existing, already-documented environment limitation unrelated to this change).provider-agentcontainer against the running dev stack) was not attempted: noprovider-agentcontainer was running in this session's stack to begin with, and standing one up plus a matchingcontrol-planerebuild for the new dashboard field was judged a significant detour beyond this task's scope, per the task's own "don't block on this" guidance.🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com