Skip to content

Commit 3740095

Browse files
committed
dev/dzctl: run an ip-verifier in the local devnet
RFC-27 has connect obtain an IP ownership proof, so the local devnet needs a verifier to reach or the flow diverges from production. dzctl start now brings up a dz-local-ip-verifier container with a keypair generated per deploy, writes its pubkey to GlobalState.ip_verifier_authority_pk before the container starts (the service exits if the ledger names another key), and points every client at it. The container sits on the CYOA network, not only the default network, and clients are pointed at its CYOA address. The service signs the source address it observes and connect refuses a proof for any other address than the one it is provisioning, which for a local client is its CYOA address; reached over the default network the two would never agree. Same class of problem as the proxy handling in production. Enforcement stays off: the require-ip-ownership-proof feature flag is clear by default locally, so a proof is attached but not demanded.
1 parent dbd91c3 commit 3740095

13 files changed

Lines changed: 499 additions & 0 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ env:
4343
DZ_DEVICE_HEALTH_ORACLE_IMAGE: ghcr.io/malbeclabs/dz-e2e/device-health-oracle:${{ github.event.inputs.image_tag || github.sha }}
4444
DZ_GEOPROBE_IMAGE: ghcr.io/malbeclabs/dz-e2e/geoprobe:${{ github.event.inputs.image_tag || github.sha }}
4545
DZ_SENTINEL_IMAGE: ghcr.io/malbeclabs/dz-e2e/sentinel:${{ github.event.inputs.image_tag || github.sha }}
46+
DZ_IP_VERIFIER_IMAGE: ghcr.io/malbeclabs/dz-e2e/ip-verifier:${{ github.event.inputs.image_tag || github.sha }}
4647
DZ_VALIDATOR_METADATA_SERVICE_MOCK_IMAGE: ghcr.io/malbeclabs/dz-e2e/validator-metadata-service-mock:${{ github.event.inputs.image_tag || github.sha }}
4748

4849
jobs:
@@ -291,6 +292,7 @@ jobs:
291292
docker push ${{ env.DZ_IMAGE_REPO }}/device-health-oracle:${{ env.DZ_IMAGE_TAG }}
292293
docker push ${{ env.DZ_IMAGE_REPO }}/geoprobe:${{ env.DZ_IMAGE_TAG }}
293294
docker push ${{ env.DZ_IMAGE_REPO }}/sentinel:${{ env.DZ_IMAGE_TAG }}
295+
docker push ${{ env.DZ_IMAGE_REPO }}/ip-verifier:${{ env.DZ_IMAGE_TAG }}
294296
docker push ${{ env.DZ_IMAGE_REPO }}/validator-metadata-service-mock:${{ env.DZ_IMAGE_TAG }}
295297
- name: Discover tests and distribute across shards
296298
if: steps.gate.outputs.run-e2e == 'true'

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ The local devnet runs in Docker containers with the naming convention `dz-local-
147147
- **Clients**: `dz-local-client-{pubkey}` - Client containers running doublezerod
148148
- **Manager**: `dz-local-manager` - Runs the doublezero CLI for admin operations
149149
- **Controller**: `dz-local-controller` - Pushes configs to devices
150+
- **IP verifier**: `dz-local-ip-verifier` - Signs RFC-27 IP ownership proofs for `connect` (see `e2e/docs/IP_VERIFIER_LOCAL_DEVNET.md`)
150151

151152
### Arista Device Interaction
152153

e2e/.env.local

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ DZ_DEVICE_HEALTH_ORACLE_IMAGE=${DZ_IMAGE_REPO}/device-health-oracle:${DZ_IMAGE_T
1313
DZ_GEOPROBE_IMAGE=${DZ_IMAGE_REPO}/geoprobe:${DZ_IMAGE_TAG}
1414
DZ_SENTINEL_IMAGE=${DZ_IMAGE_REPO}/sentinel:${DZ_IMAGE_TAG}
1515
DZ_VALIDATOR_METADATA_SERVICE_MOCK_IMAGE=${DZ_IMAGE_REPO}/validator-metadata-service-mock:${DZ_IMAGE_TAG}
16+
DZ_IP_VERIFIER_IMAGE=${DZ_IMAGE_REPO}/ip-verifier:${DZ_IMAGE_TAG}

e2e/docker/base.dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ RUN --mount=type=cache,id=cargo-${CARGO_LOCK_HASH},target=/cargo \
122122
RUSTFLAGS="-C link-arg=-fuse-ld=mold" cargo build --workspace --release --exclude doublezero-serviceability --exclude doublezero-telemetry && \
123123
cp /target/release/doublezero ${BIN_DIR}/ && \
124124
cp /target/release/doublezero-sentinel ${BIN_DIR}/ && \
125+
cp /target/release/doublezero-ip-verifier ${BIN_DIR}/ && \
125126
cp /target/release/fork-accounts ${BIN_DIR}/
126127

127128
# Force COPY in later stages to always copy the binaries, even if they appear to be the same.

e2e/docker/ip-verifier/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
ARG BASE_IMAGE=undefined
2+
FROM ${BASE_IMAGE} AS base
3+
4+
FROM ubuntu:24.04
5+
6+
ENV DEBIAN_FRONTEND=noninteractive
7+
8+
RUN apt-get update && \
9+
apt-get install -y ca-certificates curl bash
10+
11+
COPY --from=base /doublezero/bin/doublezero-ip-verifier /usr/local/bin/doublezero-ip-verifier
12+
13+
ENTRYPOINT ["doublezero-ip-verifier"]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# IP ownership verification in the local devnet
2+
3+
RFC-27 ([`rfcs/rfc27-ip-verification.md`](../../rfcs/rfc27-ip-verification.md)) has `connect`
4+
attach a proof, signed by a DoubleZero-operated verifier, that the caller can originate traffic
5+
from the `client_ip` it is binding. `dev/dzctl` runs that verifier so the local flow matches
6+
production.
7+
8+
## What comes up
9+
10+
`dzctl start` brings up a `dz-local-ip-verifier` container (image `dz-local/ip-verifier:dev`)
11+
alongside the rest of the stack:
12+
13+
- **Keypair**: generated per deploy into `dev/.deploy/dz-local/ip-verifier-keypair.json`. Devnet
14+
only — nothing is checked in.
15+
- **Onchain authority**: the keypair's pubkey is written to
16+
`GlobalState.ip_verifier_authority_pk` before the container starts. The service reads the
17+
authority from the ledger at startup and exits if it does not name its own key, so the order
18+
matters.
19+
- **Networks**: the default network (to reach the ledger) *and* the CYOA network, on host ID 250.
20+
- **Client wiring**: every client container gets `DZ_IP_VERIFIER_URL` pointing at the verifier's
21+
**CYOA** address.
22+
23+
That last pair is the point. The verifier signs the source address it observes the request arrive
24+
from, and `connect` refuses a proof for any address other than the one it is provisioning. A local
25+
client provisions its CYOA address, so the request has to reach the verifier over the CYOA network
26+
for the two to agree — reached over the default network instead, the observed address would be the
27+
client's default-network address and every connect would hard-fail on the mismatch. This is the
28+
same class of problem as the proxy handling in production, where the address the service sees is
29+
the proxy's unless it is configured to read a forwarded one.
30+
31+
The CYOA subnet is allocated from `9.128.0.0/9`, which is globally routable, so the verifier's
32+
`not_globally_routable` refusal (which an RFC-1918 source would hit) does not fire.
33+
34+
## Enforcement is off by default
35+
36+
The `require-ip-ownership-proof` feature flag is **clear** in the local `GlobalState`. A proof is
37+
obtained and attached, but the program accepts a create without one — so a stack where the
38+
verifier is down, or a client that cannot reach it, still connects. That mirrors an environment
39+
whose rollout has not flipped the flag yet.
40+
41+
To exercise the enforcement path, turn it on:
42+
43+
```bash
44+
docker exec dz-local-manager \
45+
doublezero global-config feature-flags set --enable require-ip-ownership-proof
46+
```
47+
48+
and off again:
49+
50+
```bash
51+
docker exec dz-local-manager \
52+
doublezero global-config feature-flags set --disable require-ip-ownership-proof
53+
```
54+
55+
From a Go e2e test, `devnet.SetIPOwnershipProofFeatureFlag(ctx, true)` does the same thing.
56+
57+
## Poking at it
58+
59+
```bash
60+
# Health: 200 once the cached ledger epoch is fresh and the ledger names this key.
61+
docker exec dz-local-ip-verifier curl -sS localhost:8080/health
62+
63+
# What the ledger thinks the authority is.
64+
docker exec dz-local-manager doublezero global-config authority get
65+
66+
# A proof, as a client would ask for it.
67+
docker exec dz-local-client-<pubkey> \
68+
curl -sS -X POST "$DZ_IP_VERIFIER_URL/v1/proof" \
69+
-H 'content-type: application/json' \
70+
-d '{"payer":"<pubkey>","user_type":0}'
71+
```
72+
73+
The rate limit is raised well above the production default in the devnet (burst 1000, 6000/min):
74+
a devnet has one source address per client and a test can reconnect in a tight loop, which the
75+
production values would turn into `rate_limited` refusals unrelated to what is being tested.

e2e/internal/devnet/builder.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ func BuildContainerImages(ctx context.Context, log *slog.Logger, workspaceDir st
118118
dockerfile: filepath.Join(dockerfilesDir, "sentinel", "Dockerfile"),
119119
args: append([]string{"--build-arg", baseImageArg}, extraArgs...),
120120
},
121+
{
122+
name: "ip-verifier",
123+
image: os.Getenv("DZ_IP_VERIFIER_IMAGE"),
124+
dockerfile: filepath.Join(dockerfilesDir, "ip-verifier", "Dockerfile"),
125+
args: append([]string{"--build-arg", baseImageArg}, extraArgs...),
126+
},
121127
{
122128
name: "validator-metadata-service-mock",
123129
image: os.Getenv("DZ_VALIDATOR_METADATA_SERVICE_MOCK_IMAGE"),

e2e/internal/devnet/client.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,13 @@ func (c *Client) Start(ctx context.Context) error {
234234
"DZ_SERVICEABILITY_PROGRAM_ID": c.dn.Manager.ServiceabilityProgramID,
235235
"DZ_CLIENT_EXTRA_ARGS": strings.Join(extraArgs, " "),
236236
}
237+
// Point `connect` at the devnet verifier. Without this the `--env local` default
238+
// (http://localhost:8080) is used, which resolves to nothing inside the client container.
239+
// The URL is the verifier's CYOA address, so the source address it observes is the same one
240+
// the client binds its tunnel to — `connect` hard-fails on a proof for any other address.
241+
if c.dn.IPVerifier != nil && c.dn.IPVerifier.InternalURL != "" {
242+
env["DZ_IP_VERIFIER_URL"] = c.dn.IPVerifier.InternalURL
243+
}
237244
if c.Spec.EnableQAAgent {
238245
env["DZ_QAAGENT_ENABLE"] = "true"
239246
env["DZ_QAAGENT_ADDR"] = fmt.Sprintf("0.0.0.0:%d", qaAgentPort)

e2e/internal/devnet/cmd/devnet.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ func NewLocalDevnet(log *slog.Logger, deployID string) (*LocalDevnet, error) {
9090
Verbose: true,
9191
Interval: 10 * time.Second,
9292
},
93+
// The RFC-27 verifier runs in the local devnet so `connect` always has one to reach.
94+
// Enforcement is a separate switch: the require-ip-ownership-proof feature flag stays
95+
// clear by default, so a proof is attached but not demanded. See e2e/docs/IP_VERIFIER_LOCAL_DEVNET.md.
96+
IPVerifier: devnet.IPVerifierSpec{
97+
Enabled: true,
98+
},
9399
}, log, dockerClient, subnetAllocator)
94100
if err != nil {
95101
return nil, fmt.Errorf("failed to create devnet: %w", err)

e2e/internal/devnet/devnet.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const (
4141

4242
containerDoublezeroKeypairPath = "/root/.config/doublezero/id.json"
4343
containerSolanaKeypairPath = "/root/.config/solana/id.json"
44+
containerIPVerifierKeypairPath = "/etc/doublezero/ip-verifier.json"
4445

4546
// defaultNetworkBaseCIDR is the address range the devnet's default network is allocated from.
4647
// It is kept separate from the CYOA network range (9.128.0.0/9) so tests that detect interfaces
@@ -94,6 +95,7 @@ type DevnetSpec struct {
9495
InfluxDB InfluxDBSpec
9596
Prometheus PrometheusSpec
9697
Sentinel SentinelSpec
98+
IPVerifier IPVerifierSpec
9799
ValidatorMetadataServiceMock ValidatorMetadataServiceMockSpec
98100
Devices map[string]DeviceSpec
99101
Clients map[string]ClientSpec
@@ -129,6 +131,7 @@ type Devnet struct {
129131
InfluxDB *InfluxDB
130132
Prometheus *Prometheus
131133
Sentinel *Sentinel
134+
IPVerifier *IPVerifier
132135
ValidatorMetadataServiceMock *ValidatorMetadataServiceMock
133136
Devices map[string]*Device
134137
Clients map[string]*Client
@@ -180,6 +183,10 @@ func (s *DevnetSpec) Validate() error {
180183
return fmt.Errorf("prometheus: %w", err)
181184
}
182185

186+
if err := s.IPVerifier.Validate(s.CYOANetwork); err != nil {
187+
return fmt.Errorf("ip-verifier: %w", err)
188+
}
189+
183190
if s.Devices == nil {
184191
s.Devices = make(map[string]DeviceSpec)
185192
}
@@ -295,6 +302,24 @@ func New(spec DevnetSpec, log *slog.Logger, dockerClient *client.Client, subnetA
295302
}
296303
}
297304

305+
// If the ip-verifier keypair path is not provided, generate a new keypair or use an existing
306+
// one in the deploy directory if it exists. Devnet-only: it is written to the deploy
307+
// directory rather than checked in, and its pubkey is what the local GlobalState names as the
308+
// verifier authority.
309+
if spec.IPVerifier.Enabled && spec.IPVerifier.KeypairPath == "" {
310+
ipVerifierKeypairPath := filepath.Join(spec.DeployDir, "ip-verifier-keypair.json")
311+
generated, err := generateKeypairIfNotExists(ipVerifierKeypairPath)
312+
if err != nil {
313+
return nil, fmt.Errorf("failed to generate ip-verifier keypair: %w", err)
314+
}
315+
spec.IPVerifier.KeypairPath = ipVerifierKeypairPath
316+
if generated {
317+
log.Debug("--> Generated ip-verifier keypair", "path", ipVerifierKeypairPath)
318+
} else {
319+
log.Debug("--> Using existing ip-verifier keypair", "path", ipVerifierKeypairPath)
320+
}
321+
}
322+
298323
// Validate the spec.
299324
if err := spec.Validate(); err != nil {
300325
return nil, fmt.Errorf("failed to validate spec: %w", err)
@@ -376,6 +401,12 @@ func New(spec DevnetSpec, log *slog.Logger, dockerClient *client.Client, subnetA
376401
dn: dn,
377402
log: log.With("component", "sentinel"),
378403
}
404+
if spec.IPVerifier.Enabled {
405+
dn.IPVerifier = &IPVerifier{
406+
dn: dn,
407+
log: log.With("component", "ip-verifier"),
408+
}
409+
}
379410
dn.ValidatorMetadataServiceMock = &ValidatorMetadataServiceMock{
380411
dn: dn,
381412
log: log.With("component", "validator-metadata-service-mock"),
@@ -535,6 +566,27 @@ func (d *Devnet) Start(ctx context.Context, buildConfig *BuildConfig) error {
535566
return fmt.Errorf("failed to create CYOA network: %w", err)
536567
}
537568

569+
// Start the ip-verifier if it's not already running. It comes after the CYOA network, which
570+
// it attaches to so that a client's proof request is observed arriving from the same address
571+
// the client binds its tunnel to. Its authority has to be onchain before it starts: the
572+
// service reads GlobalState at startup and exits if the authority is not its own key.
573+
if d.IPVerifier != nil {
574+
if err := d.IPVerifier.Prepare(); err != nil {
575+
return fmt.Errorf("failed to prepare ip-verifier: %w", err)
576+
}
577+
// Skipped for a stack running cloned state: its GlobalState came from a remote cluster
578+
// and the local manager is not its authority, so the write would fail. Such a stack has
579+
// to name a verifier the cloned state already trusts.
580+
if !d.Spec.SkipProgramDeploy {
581+
if err := d.SetIPVerifierAuthority(ctx, d.IPVerifier.Pubkey); err != nil {
582+
return fmt.Errorf("failed to set ip-verifier authority: %w", err)
583+
}
584+
}
585+
if _, err := d.IPVerifier.StartIfNotRunning(ctx); err != nil {
586+
return fmt.Errorf("failed to start ip-verifier: %w", err)
587+
}
588+
}
589+
538590
// We don't support starting with devices yet.
539591
// The AddDevice method can be used to add devices after the devnet is started.
540592
if len(d.Spec.Devices) > 0 {

0 commit comments

Comments
 (0)