Skip to content

serviceability/client: recycled client IPs strand user accounts and misprovision the new host #4193

Description

@elitegreg

A validator is decommissioned and its hosting provider reassigns the public IP to a different customer. This happens routinely, and it reliably breaks the new tenant. Worse, in the most common variant it does not break loudly: the new machine comes up connected on the previous tenant's onchain identity.

This issue records the analysis and tracks the work. Each proposed change is a sub-task.

Root cause: two identities, keyed differently

Account PDA seeds Keyed on
AccessPass ["accesspass", client_ip, user_payer] (pda.rs:90) IP and keypair
User (V2) ["user", client_ip, user_type] (pda.rs:58) IP and type, no owner

That asymmetry is the whole problem. Two keypairs at the same address get two distinct access passes and never collide. They map to the same user account address. A user account is therefore a global, first-come lease on (client_ip, user_type), and the only parties who can release it are the incumbent owner and a USER_ADMIN holder (.../processors/user/delete.rs:131-139).

Scenario A: the daemon was shut down and the user account survives

This is the common case and the damaging one.

The new operator runs doublezero connect. Their own access pass checks out. The CLI then looks for an existing user by client_ip and user_type, ignoring the owner:

// crates/doublezero-daemon-cli/src/connect.rs:1281-1284
let matched_user = users
    .iter()
    .find(|(_, u)| u.client_ip == *client_ip && u.user_type == user_type);

It reports An account already exists with Pubkey: X and adopts it. The daemon reconciler independently filters onchain users by client IP alone (client/doublezerod/internal/manager/manager.go:507-512). The controller has already programmed the device tunnel with UnderlayDstIP = <client_ip> (controlplane/controller/internal/controller/server.go:616), and that address now belongs to the new host, so packets flow and the session comes up.

The new validator is genuinely connected, on the old validator's account: its dz_ip, its device seat, its access pass, its multicast allowlists, its billing attribution. Nothing onchain reflects that the machine changed hands.

doublezero disconnect then refuses, because it skips any user whose owner is not the local payer (.../disconnect.rs:147-165) and reports the account as "managed by an external service". This is the "my doublezero address doesn't match what's onchain" report we keep receiving. The mismatch is on the user account, not the access pass — the operator's own pass is present and correct.

Two follow-on hazards in this state:

  • Connecting with a different mode (IBRLWithAllocatedIP rather than IBRL) derives a different PDA, so a second user account is created for the same address. The reconciler logs multiple activated unicast users for this client IP, ignoring extra and takes whichever it encounters first out of a map, which is not stable across restarts.
  • The previous operator can still delete their user at any time, which silently tears the tunnel out from under the new one.

Scenario B: the previous operator ran doublezero disconnect

This case largely works. The PDA is released and the new operator creates their own user under their own pass. What remains is the previous access pass at (client_ip, old_key) with connection_count = 0 and status = Disconnected, still carrying epoch entitlement and multicast allowlists for a keypair that no longer has the address. Nothing collects it.

Why a wildcard access pass is not the fix on its own

It addresses half. GetAccessPassCommand already prefers the dynamic pass, and both processors accept either PDA, so a 0.0.0.0 pass does stop the pass from being IP-bound. The user account is still keyed on the address, so the collision is untouched.

It also weakens the position. A wildcard pass asserts nothing about which address the holder may use, and the program only checks that a supplied client_ip is globally routable. A holder can bind an address they do not control, squatting the PDA and denying service to whoever actually has it. Per-IP control for that path is the subject of RFC-27, "IP Ownership Verification Service for user connection" (#2304), which this work depends on but does not duplicate: RFC-27 governs which address may be bound at user creation, while this issue is about releasing an address whose account is already bound.

Two defects found along the way

Independent of the above, and reachable outside this scenario. Both produce states no existing instruction can clean up: a closed access pass makes its user account permanently undeletable, and RequestBan never decrements connection_count, so a banned user leaves an access pass that can never be closed. Detail in the sub-task.

Proposed changes

Dependencies

RFC-27, "IP Ownership Verification Service for user connection" (#2304), tracked separately. #4190 needs it implemented.

Suggested sequencing

#4188 and #4191 are small, self-contained, and unblock everything else. #4189 removes most of the operational pain without a program change. #4190 follows RFC-27.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions