-
Notifications
You must be signed in to change notification settings - Fork 16
e2e: run an ip-verifier in every devnet and cover the RFC-27 proof outcomes #4232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d616cd1
7322be6
4483270
54442d0
dd530b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| ARG BASE_IMAGE=undefined | ||
| FROM ${BASE_IMAGE} AS base | ||
|
|
||
| FROM ubuntu:24.04 | ||
|
|
||
| ENV DEBIAN_FRONTEND=noninteractive | ||
|
|
||
| RUN apt-get update && \ | ||
| apt-get install -y ca-certificates curl bash | ||
|
|
||
| COPY --from=base /doublezero/bin/doublezero-ip-verifier /usr/local/bin/doublezero-ip-verifier | ||
|
|
||
| ENTRYPOINT ["doublezero-ip-verifier"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| # IP ownership verification in the local devnet | ||
|
|
||
| RFC-27 ([`rfcs/rfc27-ip-verification.md`](../../rfcs/rfc27-ip-verification.md)) has `connect` | ||
| attach a proof, signed by a DoubleZero-operated verifier, that the caller can originate traffic | ||
| from the `client_ip` it is binding. Every devnet runs that verifier — `dev/dzctl` and the Go e2e | ||
| suite alike — so the local flow matches production. | ||
|
|
||
| ## What comes up | ||
|
|
||
| The verifier is on by default: `IPVerifierSpec.Disabled` is the opt-out, so a devnet that says | ||
| nothing about it gets one. `dzctl start` brings up a `dz-local-ip-verifier` container (image | ||
| `dz-local/ip-verifier:dev`) alongside the rest of the stack; an e2e test gets the same container | ||
| named for its own deploy ID: | ||
|
|
||
| - **Keypair**: generated per deploy into `dev/.deploy/dz-local/ip-verifier-keypair.json`. Devnet | ||
| only — nothing is checked in. | ||
| - **Onchain authority**: the keypair's pubkey is written to | ||
| `GlobalState.ip_verifier_authority_pk` before the container starts. The service reads the | ||
| authority from the ledger at startup and exits if it does not name its own key, so the order | ||
| matters. | ||
| - **Networks**: the default network (to reach the ledger) *and* the CYOA network, on host ID 250. | ||
| - **Client wiring**: every client container gets `DZ_IP_VERIFIER_URL` pointing at the verifier's | ||
| **CYOA** address. | ||
|
|
||
| That last pair is the point. The verifier signs the source address it observes the request arrive | ||
| from, and `connect` refuses a proof for any address other than the one it is provisioning. A local | ||
| client provisions its CYOA address, so the request has to reach the verifier over the CYOA network | ||
| for the two to agree — reached over the default network instead, the observed address would be the | ||
| client's default-network address and every connect would hard-fail on the mismatch. This is the | ||
| same class of problem as the proxy handling in production, where the address the service sees is | ||
| the proxy's unless it is configured to read a forwarded one. | ||
|
|
||
| The CYOA subnet is allocated from `9.128.0.0/9`, which is globally routable, so the verifier's | ||
| `not_globally_routable` refusal (which an RFC-1918 source would hit) does not fire. | ||
|
|
||
| ## Enforcement is off by default | ||
|
|
||
| The `require-ip-ownership-proof` feature flag is **clear** in the local `GlobalState`. A proof is | ||
| obtained and attached, but the program accepts a create without one — so a stack where the | ||
| verifier is down, or a client that cannot reach it, still connects. That mirrors an environment | ||
| whose rollout has not flipped the flag yet. | ||
|
|
||
| To exercise the enforcement path, turn it on: | ||
|
|
||
| ```bash | ||
| docker exec dz-local-manager \ | ||
| doublezero global-config feature-flags set --enable require-ip-ownership-proof | ||
| ``` | ||
|
|
||
| and off again: | ||
|
|
||
| ```bash | ||
| docker exec dz-local-manager \ | ||
| doublezero global-config feature-flags set --disable require-ip-ownership-proof | ||
| ``` | ||
|
|
||
| From a Go e2e test, `devnet.SetIPOwnershipProofFeatureFlag(ctx, true)` does the same thing. | ||
|
|
||
| ## From a Go e2e test | ||
|
|
||
| Because the verifier is on by default, an ordinary `connect` in any e2e test already obtains and | ||
| attaches a real proof. Two knobs cover the cases that need something else: | ||
|
|
||
| - `ClientSpec.NoIPVerifier` leaves `DZ_IP_VERIFIER_URL` unset for one client, so its `connect` | ||
| obtains no proof at all — the path an environment takes before its verifier exists. | ||
| - `IPVerifierSpec.AuthorityRefreshSecs` pins how often the service re-reads the onchain authority. | ||
| Set it long and rotate the authority with `devnet.SetIPVerifierAuthority` and the service keeps | ||
| signing with a key `GlobalState` no longer names, which is how a test produces a proof that gets | ||
| refused. | ||
|
|
||
| `e2e/ip_ownership_proof_test.go` uses all three paths. | ||
|
|
||
| ## Poking at it | ||
|
|
||
| ```bash | ||
| # Health: 200 once the cached ledger epoch is fresh and the ledger names this key. | ||
| docker exec dz-local-ip-verifier curl -sS localhost:8080/health | ||
|
|
||
| # What the ledger thinks the authority is. | ||
| docker exec dz-local-manager doublezero global-config authority get | ||
|
|
||
| # A proof, as a client would ask for it. Run through a shell in the container: DZ_IP_VERIFIER_URL | ||
| # is set in the client's environment, and `docker exec curl` would have the host shell expand it. | ||
| docker exec dz-local-client-<pubkey> bash -c \ | ||
| 'curl -sS -X POST "$DZ_IP_VERIFIER_URL/v1/proof" \ | ||
| -H "content-type: application/json" \ | ||
| -d "{\"payer\":\"<pubkey>\",\"user_type\":0}"' | ||
| ``` | ||
|
|
||
| The rate limit is raised well above the production default in the devnet (burst 1000, 6000/min): | ||
| a devnet has one source address per client and a test can reconnect in a tight loop, which the | ||
| production values would turn into `rate_limited` refusals unrelated to what is being tested. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,10 @@ type ClientSpec struct { | |
| // CYOANetworkIPHostID is the offset into the host portion of the subnet (must be < 2^(32 - prefixLen)). | ||
| CYOANetworkIPHostID uint32 | ||
|
|
||
| // NoIPVerifier leaves DZ_IP_VERIFIER_URL unset for this client, so its `connect` obtains no | ||
| // RFC-27 proof even in a devnet running a verifier. | ||
| NoIPVerifier bool | ||
|
|
||
| // EnableQAAgent starts the QA agent inside the client container for local QA testing. | ||
| EnableQAAgent bool | ||
| // QAAgentPort is the port the QA agent listens on inside the container (default: 7009). | ||
|
|
@@ -234,6 +238,17 @@ func (c *Client) Start(ctx context.Context) error { | |
| "DZ_SERVICEABILITY_PROGRAM_ID": c.dn.Manager.ServiceabilityProgramID, | ||
| "DZ_CLIENT_EXTRA_ARGS": strings.Join(extraArgs, " "), | ||
| } | ||
| // Point `connect` at the devnet verifier. The `--env local` config carries no verifier URL, | ||
| // so without this a client obtains no proof at all. The URL is the verifier's CYOA address, | ||
| // so the source address it observes is the same one the client binds its tunnel to — | ||
| // `connect` hard-fails on a proof for any other address. | ||
| // | ||
| // NoIPVerifier leaves it unset, which is how a test covers the no-proof path: the CLI reports | ||
| // nothing to reach and creates the user without a proof, which the program accepts while | ||
| // require-ip-ownership-proof is clear. | ||
| if c.dn.IPVerifier != nil && c.dn.IPVerifier.InternalURL != "" && !c.Spec.NoIPVerifier { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The A test that asserts on the proof would still fail loudly, but every other test would quietly stop covering the proof path, which is exactly the "existing e2e tests exercise this for free" property the PR is built on — and it would fail no test while doing it. Since |
||
| env["DZ_IP_VERIFIER_URL"] = c.dn.IPVerifier.InternalURL | ||
| } | ||
| if c.Spec.EnableQAAgent { | ||
| env["DZ_QAAGENT_ENABLE"] = "true" | ||
| env["DZ_QAAGENT_ADDR"] = fmt.Sprintf("0.0.0.0:%d", qaAgentPort) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The image is built and pushed here, but the shard job's
Pull pre-built imagesstep (~line 439) was not updated --ip-verifierends up the only e2e image missing from that list. SinceIPVerifierSpec.Disableddefaults to false, every shard devnet now needs.../ip-verifier:${DZ_IMAGE_TAG}, and will instead fall back to testcontainers' implicit pull: one unretried pull per devnet, racing at-parallel=12, outside thepull_with_retrywrapper every other image gets. Worth addingpull_with_retry ${{ env.DZ_IMAGE_REPO }}/ip-verifier:${{ env.DZ_IMAGE_TAG }}next to the sentinel line.