Repository navigation
feat(pbs): dial builders outside the config for ePBS requests - #506
JasonVranek wants to merge 14 commits into
Conversation
Pin the lighthouse crates to sigp/lighthouse unstable at 31d8cfd, whose gloas containers hash as EIP-7495 progressive containers, matching the consensus specs. Mirror lighthouse's own [patch.crates-io] entries for the progressive ssz stack (a consumer does not inherit a dependency's patches) and pin the blstrs_plus patch to an explicit rev. lighthouse dropped `TestRandom` for an `arbitrary` generator, so `TestRandomSeed` now draws from `arbitrary::Arbitrary`. Validated BLS points do not randomize under `arbitrary`, so tests that need a real key use `BlsSecretKey::random()`. Also adapt to `ForkName::Heze` and to `ExecutionRequests` no longer implementing `ssz::Decode`.
Tests discovered a free port, dropped the listener and let the server rebind it, leaving a window where another process could take the port. PbsService and SigningService gain `run_with_listener`, the mock SSV servers take a bound listener, and the legacy suites hand their listeners straight to the server. Also add `wait_for_ready`, which polls /status instead of sleeping a fixed 100 ms.
From the Gloas fork the beacon node calls three builder-API endpoints on
Commit-Boost instead of get_header and get_payload:
- POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}
sends the request to the one relay its auth data names and returns that
relay's bid (200), its 400 or 401, or 204 when it has no bid or fails.
Commit-Boost does not validate or rank the bid: the beacon node checks it,
and each request names exactly one builder.
- POST /eth/v1/builder/builder_preferences/{proposer_pubkey} forwards to the
relay the auth data names and returns that builder's answer.
- POST /eth/v1/builder/beacon_blocks forwards the SSZ block bytes unparsed to
every configured relay and answers 202 when one accepts.
Requests must carry Eth-Consensus-Version: gloas. A body without
Content-Type is JSON and a request without Accept gets JSON, as
builder-specs requires. Bid requests need Date-Milliseconds and
X-Timeout-Ms; Commit-Boost clamps the deadline to one slot, keeps
proposer_deadline_buffer_ms back for the return trip and sends the rest to
the builder as its own X-Timeout-Ms, or returns 204 when nothing is left.
Routing follows builder-specs #168: auth data matches a relay when it equals
the lowercase hostname of the relay URL. The first match in config order wins
and unmatched auth data is a 400. Commit-Boost never verifies auth data; the
builder does. Relay-supplied amounts are saturated.
The new operator page (docs/get_started/epbs.md) covers writing each
validator key's builder config through the keymanager API, with a
copy-paste call, plus routing, timing, metrics and troubleshooting.
Unreleased, shipping from v0.12.0-rc1.
condition and lint test
Auth data that is an http(s) URL now also routes to the relay entry with the same scheme, host and port. Either form may end in `?` and parameters for the builder: Commit-Boost routes on the part before `?` and forwards the signed auth, parameters included, unchanged. When no relay entry matches, Commit-Boost dials the builder the auth data names for a bid or preferences request: `https://<hostname>`, or the URL. The target is resolved once, and the lookup and the dial share the request's budget. A name that does not resolve, or any address that is not public unicast, gets 400; a lookup or dial setup that runs out of time counts as a builder that did not answer. At most 32 lookups run at once, since a timed-out lookup keeps its blocking thread. The dial goes only to the checked addresses, with no proxy and no redirects. A request from a Commit-Boost never leads to a dial, so a dial that reaches a Commit-Boost, this one included, goes no further. Auth data, a dialed builder's error body and a dial target come from the request, so they are logged escaped, the body capped at 1 KiB and the target as its origin. The signed block gets 202 once it has been forwarded, whatever the builders answer, since the beacon node gossips it anyway. A block whose bid came from a dialed builder, which is not a relay entry, would otherwise always get 500. When no builder accepts the block, Commit-Boost logs a warning.
proposer_deadline_buffer_ms must now be greater than 0 as well as less than one slot. With 0 the builder gets the whole deadline, so a bid it sends at the deadline reaches the beacon node late. The range is one check in PbsConfig::validate, so the service, a hot reload and a custom PBS module's loader all refuse it. The test fixtures use the default, 50. The metrics catalog documents the ePBS endpoint values, the codes the ePBS endpoints return to the beacon node, the relay_id="dial" label, and that ePBS bids set the relay gauges. The ePBS page corrects two builder config bullets: a top-level min_bid does not floor an entry that sets its own, and the per-client max_execution_payment defaults are dropped. A builder Commit-Boost refuses to dial is not counted under relay_id="dial".
| let mut client = | ||
| reqwest::Client::builder().no_proxy().redirect(reqwest::redirect::Policy::none()); |
There was a problem hiding this comment.
we could reuse a client for all these dials that can't be routed to existing preconfigured relay clients
this optimises repeat dials to same addresses outside of the config + no new client created
A client per dial existed only because resolve_to_addrs pins addresses per client. The shared client's resolver now checks every address it resolves, so a dial still connects only to checked addresses, and a second dial to a builder reuses the first one's connection instead of a new TCP and TLS handshake. Dial hosts are chosen by the request, so the client keeps at most one idle connection per host, for one slot. The check before the dial is unchanged, so a blocked target still gets 400 before anything is sent.
| pub fn decode_auth_data_url(data: &[u8]) -> Option<Url> { | ||
| let url = Url::parse(std::str::from_utf8(data).ok()?).ok()?; | ||
| matches!(url.scheme(), "http" | "https").then_some(url) | ||
| } |
There was a problem hiding this comment.
I guess this means that the auth data must be the builder URL only? I thought I saw somewhere suggested that the auth data would be the builder URL as a prefix followed by arbitrary other data?
There was a problem hiding this comment.
test_get_execution_payload_bid_dial() covers this case
| .or_else(|| { | ||
| let url = | ||
| Url::parse(&format!("https://{}/", std::str::from_utf8(address).ok()?)).ok()?; | ||
| (url.host_str()?.as_bytes() == address).then_some(url) |
There was a problem hiding this comment.
is this round trip check necessary? from_utf8() is loss-less
|
|
||
| /// Run for each new connection to a hostname (an IP address skips it): refuses | ||
| /// it while every lookup slot is taken or if any address is disallowed | ||
| struct DialResolver; |
There was a problem hiding this comment.
is it worth caching resolved addresses here for some interval? also, given that this is all asynchronous a background task could be spawned to recheck already seen addresses - would save the dns resolution on the request path.
decode_auth_data_url only ever gets the auth data address, the part before any `?`, so its parameter and doc now say so. dial_target's comparison of the parsed host with the address passes only a hostname already in the builder-specs default form, since parsing also accepts a port, userinfo, a path, upper case and numeric IPv4.
A dial to a hostname looked the host up twice in a row on the request path: once in dial_relay's check, and again in DialResolver when the connection opened. The resolver now takes the check's addresses while they are under 2 s old, checking them again, and looks the host up itself otherwise, so a later connection still sees a DNS change. The addresses are kept with port 0, as the resolver's own lookup returns them, so a dial to the same host on another port connects to its own.
Builds on #505. There, a bid or preferences request whose auth data matches no relay entry gets
400, so a validator key whose builder config names a builder the operator has not added to Commit-Boost gets no bids from it. This PR has Commit-Boost dial the builder the auth data names, behind an SSRF guard, and routes auth data written as a builder URL. It also answers the signed block with202once it has been forwarded, whatever the builders answer, which addresses a review comment on #505.What it does
httporhttpsURL matches the first relay entry with the same scheme, host and port. The path and the pubkey in the relay URL do not count.?and form-encoded parameters for the builder, such asbuilder-a.example.com?filter=1, a way for a proposer to state a preference like transaction filtering. Commit-Boost routes on the part before?and forwards the signed auth, parameters included, unchanged, so the parameters arrive signed by the proposer. A builder that does not accept them answers400, so a proposer sets them only for builders that accept them. Without a?, routing is unchanged.https://<hostname>on the default port, or the URL's scheme, host and port. There is no setting to turn it off. Dialed requests count underrelay_id="dial", and each dial logs the builder's origin and the addresses it connects to. A key without builder config sends Commit-Boost's own hostname, so Commit-Boost dialshttps://<its hostname>on port 443:400when that hostname resolves to a loopback or private address, as it usually does, and otherwise one request to whatever serves HTTPS on that host, which is Commit-Boost only behind a TLS proxy, where the loop guard below stops it.202once it has been forwarded, whatever the builders answer. This answers a review comment on epbs builder api endpoints #505: relays may do nothing with these requests, so an apparent failure should not be reported as one, and the beacon node gossips the block anyway. The block still goes only to configured relays, since Commit-Boost keeps no auction state; a dialed builder gets it over gossip.The ePBS docs page gets a section on builders outside the config, which also says what a key without builder config gets, the URL form and
?parameters in the routing reference,500in the metrics table for preferences only, and troubleshooting rows for both400s and for that warning.Scope
Commit-Boost now makes outbound requests to hosts named in request data. The proposer signs that data, but Commit-Boost does not verify the signature, so the target is treated as untrusted, and anyone who can reach Commit-Boost's PBS port can choose it:
timeout_register_validator_msfor preferences). If the name does not resolve, the answer is400and nothing is dialed. A lookup or dial setup that runs out of time counts as a builder that did not answer: no bid (204), or500for preferences.resolve_to_addrs), ignoresHTTP(S)_PROXYandALL_PROXY(a proxy would resolve the host again), follows no redirects, and sends none of the operator's relayheaders.X-CommitBoost-Version, which every Commit-Boost dial sends, never leads to a dial, so a dial that reaches a Commit-Boost, this one included, goes no further. Configured relays still serve such a request, so chained Commit-Boosts keep working.user:password@.Testing
cargo test --all-features: 402 passed (392 on part 1). Unit: URL-form auth data decoding (onlyhttpandhttps; opaque data such asbuilder-a:prodandhost:portparse as URLs and are refused), the dial target for each auth data form (URL, hostname, IPv6 hostname, a hostname with?parameters, an internal address, a non-hostname, a non-HTTP scheme), every resolved address checked, the lookup cap refusing a hostname but not an IP address, the dial pinned to the given address with redirects refused, the blocked-address table, and URL-form routing to relay entries and routing on the part before?. End to end: a bid dialed with its auth,?parameters included, unchanged, a bid naming Commit-Boost's own URL dialed once and answered with the second hop's400, a request from a Commit-Boost not dialed but still served by a configured relay, preferences dialed, an unmatchedlocalhostrefused as loopback, and no lookup once the deadline has passed. A builder's400and401reach the beacon node even with an error body over the 1 KiB read cap. The signed block gets202when every builder rejects it, and a pretty-printed JSON bid reaches the beacon node byte for byte.204, putting the500for a block no builder accepted back fails the all-reject test, and removing epbs builder api endpoints #505's passthrough arm fails only the passthrough test.HTTP_PROXYin a process that runs other tests in parallel), the escaping in logs and the 1 KiB cap on a logged error body, the refusal when the dial setup leaves no time, and the lookup's timing: its timeout and the time it leaves the dial (a local lookup finishes inside tokio's 1 ms timer tick, so a name slow enough to matter needs the network).--assert dial; Docker addresses are all private). Each key's auth data washttp://buildoor:8080, which no relay entry matches, so Commit-Boost dialed buildoor 95 times, for bid and preferences requests. All 17 observed slots were built from those bids, no dial was refused, and none looped.