Skip to content

client: honor connect --client-ip for ip-bound access passes - #4341

Open
juan-malbeclabs wants to merge 18 commits into
mainfrom
jo/connect-client-ip-ip-bound-accesspass
Open

juan-malbeclabs wants to merge 18 commits into
mainfrom
jo/connect-client-ip-ip-bound-accesspass

Conversation

@juan-malbeclabs

@juan-malbeclabs juan-malbeclabs commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Resolves: #4333

Two halves, both of #4333. The client half honors connect --client-ip; the onchain half waives the RFC-27 proof requirement for a pass whose address an authority pinned. They ship together because either alone leaves the flag unusable for the host that asked for it: without the waiver a pinned host still fails when a verifier is configured, and without the client half there is no way to ask for the pinned address.

Summary of Changes

  • connect --client-ip is honored again instead of being parsed, warned about and discarded. It is accepted only when the payer holds an AccessPass at the exact (client_ip, user_payer) PDA and that pass is not flagged allow_multiple_ip: a pass that authorizes any address does not authorize a caller-chosen one, which is the squatting hole RFC-27 exists to close. This is the same predicate create_user applies, so a refusal arrives up front rather than as a late IpOwnershipProofRequired.
  • Onchain: with RequireIpOwnershipProof set, a creation carrying no proof is now accepted when the AccessPass is bound to the address being claimed — stored at that client_ip and not flagged allow_multiple_ip. Such a pass is itself an attestation: SetAccessPass is permissioned, so a registrant cannot self-issue one. The waiver covers absence only; a supplied proof is still validated in full. validate_ip_ownership_proof moves below the access-pass checks in create_user_core, which is what establishes that the pass names this address and this owner before the waiver can rely on it. RFC-27 is amended to match.
  • New GetExactAccessPassCommand in the Rust SDK does the CLI's lookup. GetAccessPassCommand resolves the wildcard pass first and falls back to the exact one, so a payer holding both would have been wrongly admitted.
  • The pinned address reaches the reconciler, which is what made the flag a no-op before: it matches onchain users on the client IP and, for plain IBRL, uses it verbatim as the GRE tunnel source. POST /enable now takes an optional client_ip, reconciles even when already enabled, and clears the tunnel-src cache on a change.
  • The CLI verifies the address is globally routable and assigned to an interface that is up and running on this host; the daemon repeats the local-assignment half. Possession is not ownership — RFC-27 proofs remain the answer to that — but it stops a caller naming a third party's address, and an address the kernel does not hold cannot source a tunnel. Globality is a property of the address rather than of the host, so it cannot change between the two checks, and create_user enforces it onchain for every path in. The CLI check is best-effort (an enumeration failure defers to the daemon); the daemon's is fatal, since nothing downstream is left to catch it.
  • The pin is persisted with the reconciler-enabled state, so a restart does not silently revert the host to its discovered address and tear the tunnel down. Every state write persists the pin in effect rather than the request being served, so the running daemon and the state file cannot disagree: an enable without the flag leaves the pin alone, and so does a disable. The pin is host configuration rather than session state — only a new pin replaces it. Precedence at startup is the daemon's own -client-ip flag, then a persisted pin, then discovery.
  • A restored pin is re-checked against this host's interfaces before it is used. The check /enable made says nothing about now — an address can leave with a DHCP lease, a NIC swap or a re-addressing while the daemon is down — and a host pinned to an address the kernel no longer holds would match no onchain user and build no tunnel, with no un-pin path short of editing the state file. A pin that no longer holds falls back to discovery with a warning and stays on disk, so it applies again once the address returns.
  • A daemon that refuses the pin fails the connect loudly. An ordinary enable is a no-op when the reconciler is already on, but a pin is not, so a refusal is no longer written off as a redundant call — it would otherwise leave the operator waiting on a tunnel that could never be built for a user already created at that address. The bare path sends the pin with its up-front enable, so a refusal stops the run before anything is created onchain.
  • The flag now parses as Ipv4Addr rather than String, so a malformed value is rejected instead of silently accepted.

Security decision this PR makes

The waiver moves the trust boundary from "the verification service observed this address" to "every ACCESS_PASS_ADMIN and every tenant administrator names addresses correctly".

set.rs authorizes SetAccessPass on is_privileged || is_tenant_admin, where is_tenant_admin is just tenant.administrators.contains(payer), and nothing constrains value.client_ip. So a tenant administrator can issue a pass at (someone_else's_ip, their_own_payer) and then create a user at that address with no proof — a path RFC-27 previously closed for them.

This is accepted deliberately, as the price of making pinned passes usable, and it is called out here rather than left in a code comment because it wants explicit sign-off. A per-pass waiver flag set by a narrower authority is the lever if it needs tightening; see Follow-up.

Diff Breakdown

Category Files Lines (+/-) Net
Core logic 13 +768 / -162 +606
Tests 8 +1536 / -97 +1439
Scaffolding 4 +45 / -2 +43
Docs 2 +51 / -11 +40
Config/build 2 +4 / -0 +4
Generated 1 +1 / -0 +1
Total 26 +2405 / -272 +2133

Test-heavy: roughly two thirds of the diff is tests. Core logic is +768 new lines, above the ~500-line guideline; the onchain half (ip_proof.rs, create_core.rs, the RFC amendment and user_ip_proof_test.rs, ~+270 including its tests) is the clean split if a reviewer would rather take it separately. The Rust files appear in both Core logic and Tests, since their tests are inline; the totals row counts each file once.

Key files (click to expand)

Testing Verification

  • Onchain, the waiver: a creation with no proof is accepted on a pass pinned to exactly this address, with the flag on — on the IBRL path and on create_subscribe_user, since both share create_user_core. It stays rejected for a pass at the wildcard PDA and for a pinned pass flagged allow_multiple_ip, on first creation and on the idempotent rerun path alike.
  • Onchain, the waiver's boundary: a pass pinned to this address does not excuse a proof that fails validation — one naming a different address, payer, user type or epoch is still rejected. The waiver covers absence only.
  • Rust: the flag is honored for an exactly pinned pass and refused when only a dynamic pass exists (the case get_accesspass would have admitted), refused for an exact pass flagged allow_multiple_ip (the case that would otherwise fail late onchain), refused for an address this host does not hold and for a non-global one, and ignored entirely when absent — the ledger is never consulted about a pin in that case.
  • Rust, end-to-end through connect: three combinations that must all create a user — dynamic pass with a discovered and certified address, pinned pass with a discovered address and no proof, and pinned pass with --client-ip and no proof. The last asserts the pinned address is what reaches provisioning, and that every downstream pass lookup is keyed on it rather than the discovered one.
  • Rust, refused pins: a refusal is not excused by an already-enabled reconciler, is excused when the daemon already reports that exact address, and leaves the unpinned lenient path untouched. A bare connect --client-ip against a daemon that refuses the pin stops with no create_user call at all.
  • Go: /enable pins and reports the address on /v2/status immediately; a body-less request leaves both the address in use and the persisted pin alone; a malformed or non-local address changes nothing (no state write, no pin); an interface-enumeration failure fails the request; a pin on an already-enabled daemon is still delivered; the daemon and the state file agree at every step of pin → disconnect → reconnect-without-the-flag; State.ClientIP round-trips through the state file.
  • Go, restart: a restored pin the host still holds is used; one it no longer holds, or that is unparseable or IPv6, falls back to discovery; an enumeration failure keeps the pin rather than dropping it, since it is a failure to check rather than a failed check.
  • E2E not run locally. Several e2e tests already pass --client-ip to connect (e2e/device_maxusers_rollover_test.go, e2e/multi_client_ibrl_allocated_ip_test.go), where it was previously ignored and now takes effect. They pin the AccessPass to the exact CYOA address and that address is assigned to the client container, so both gates should pass, but this wants a CI run to confirm.

Follow-up

  • A narrower waiver. The exemption is available to any issuer SetAccessPass authorizes, tenant administrators included (see Security decision above). Recording at issuance whether the issuer held ACCESS_PASS_ADMIN — a bit in the existing flags byte, so no layout change — and waiving only for those passes is the contained way to tighten it. Deliberately not in this PR: it would make tenant-issued pinned passes require a proof, which is a product call about how tenants provision rather than a correctness fix.
  • No explicit un-pin. Returning a host to discovery means pinning a different address, or clearing client_ip from the state file and restarting; an --client-ip=-style clear would need the daemon to re-run discovery on demand, which is worth doing separately if operators ask for it.
  • --client-ip is refused while the payer holds a dynamic pass. User creation attaches the dynamic pass, so the flag cannot be honored against the pinned one without changing which pass is charged for the seat — a larger change than this flag should make. The gate says so up front instead of promising something the transaction does not deliver.
  • The client-side mismatch abort is unchanged. When a verifier is configured and the address it observes disagrees with the one being claimed, connect still treats that as fatal, before the onchain waiver is ever reached. A host that needs the waiver gets there through the address the daemon is configured with or discovers.

@juan-malbeclabs
juan-malbeclabs requested review from a team and a lite review from Copilot September 15, 2026 17:28

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 901aa53. Configure here.

Comment thread client/doublezerod/internal/manager/http.go
Comment thread crates/doublezero-daemon-cli/src/connect.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

`connect --client-ip` was accepted and silently discarded, so a host whose
discovered address differs from the one its AccessPass names could only connect
by editing the packaged doublezerod unit and restarting the daemon as root.

The flag is honored again, but only when the payer holds an AccessPass at the
exact (client_ip, user_payer) PDA and the host actually holds the address. A
dynamic pass is deliberately not enough: it authorizes any globally routable
address, so accepting a caller-chosen one against it is the squatting hole
RFC-27 exists to close. The lookup goes through a new GetExactAccessPassCommand
rather than GetAccessPassCommand, whose wildcard-first resolution would answer
such a query with the dynamic pass.

The pin also has to reach the reconciler, which matches onchain users on the
client IP and uses it verbatim as the IBRL tunnel source: POST /enable takes an
optional client_ip, re-verifies local assignment itself, reconciles even when
already enabled, and persists the pin so a restart does not revert the host to
its discovered address.
Two ways the pin could go missing without the operator being told.

A body-less /enable — an ordinary `connect` with no flag, or `doublezero
enable` — persisted an empty client_ip while leaving the running daemon on the
pinned address. The divergence surfaced only at the next restart, as a tunnel
torn down because discovery had taken over. Every state write now persists the
pin in effect rather than the request being served, and the pin outlives a
disable: it describes which address the host presents to DoubleZero, not
anything about one session.

A refused pin was also excused whenever the reconciler happened to be on
already, because a plain enable is a no-op in that state. A pin is not: the
daemon rejects an address the host does not hold, so the connect went on to
wait for a tunnel that would never be built for a user it had just created.
`connect` now requires the daemon to report the pinned address, and the bare
path sends the pin with its up-front enable so a refusal stops the run before
anything is created onchain.
@juan-malbeclabs
juan-malbeclabs force-pushed the jo/connect-client-ip-ip-bound-accesspass branch from 58bdb13 to 6b6fb0a Compare September 15, 2026 18:28
… pass

With require-ip-ownership-proof set, a creation carrying no proof was rejected
for every access pass alike. That left a host whose pass names an address it
cannot originate from — asymmetric routing, NAT, a verifier unreachable from
that source — with no path to a connection at all: no proof for the address is
obtainable, and the only workaround was reissuing the pass as a wildcard one,
which trades the attestation away for the shape RFC-27 treats as weaker.

A pass stored at its own client_ip PDA and not flagged allow_multiple_ip now
waives the requirement. Such a pass is already an attestation: SetAccessPass is
permissioned, so a privileged party asserted that this payer may use this
address, which is the same reasoning by which RFC-27 scopes its threat to
wildcard passes. The waiver covers a missing proof only; a supplied one is
still validated in full.

The trust boundary moves onto issuance with it. A tenant administrator may
issue passes, so one could pin a third party's address to their own payer and
create a user there with no proof; that is accepted deliberately, with a
per-pass waiver flag as the lever if it needs tightening.

The proof check moves below the access-pass checks, which is what makes the
pass shape available and already matched against the address being created. It
stays above the already_exists return, so a rerun is still covered.

Four tests asserted rejection on what is now an IP-bound pass; they move to
wildcard passes, the shape where the requirement still bites.

@elitegreg elitegreg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff. Code quality is high — the comments explain why rather than
what, the concurrency work in manager.go is disciplined, and onchain test coverage is
strong. My concerns are mostly about scope and disclosure rather than craft.

1. The description says the onchain waiver is not in this PR, but it is (High)

The Follow-up section states Part 2 of #4333 "is not in this PR. It is an onchain change
in validate_ip_ownership_proof plus an amendment to RFC-27, and the issue's open
question ... is undecided."

That change is here: ip_proof.rs, create_core.rs, user_ip_proof_test.rs (+167)
and a 34-line RFC-27 amendment. Neither Summary of Changes nor Testing Verification
mentions it. The highest-risk change in the PR is described as absent in the text a
reviewer reads first. It also makes the Diff Breakdown table stale (19 files / +1588 vs.
an actual 24 / +1845).

Could you update the description, or split the onchain half out?

2. The waiver is reachable by any tenant administrator (High)

The logic itself is sound — I checked both ways it could break and neither does.
accesspass_is_ip_bound is only reachable with accesspass.client_ip == client_ip,
because the guard above rejects a mismatch, and the client_ip field cannot diverge from
the PDA seed (set.rs:116 derives from value.client_ip, :192/:253 store it).
Moving the validate_ip_ownership_proof call below the access-pass checks is what makes
that hold, and the tests cover allow_multiple_ip and wildcard-PDA passes still
requiring a proof, on both first creation and rerun.

The issue is the trust boundary. In set.rs:142-163 a caller is authorized if
is_privileged or is_tenant_admin (just tenant.administrators.contains(payer)),
and nothing constrains value.client_ip. So any tenant administrator can issue a pass at
(someone_elses_ip, their_own_payer) and then create a user at that address with no
proof — the squatting RFC-27 exists to close.

The doc comment and RFC amendment both acknowledge this and accept it, pointing at a
per-pass waiver flag as the future lever. Given the PR body says the waiver option is
still an open question, this reads like a design decision that got settled in-flight.
Worth an explicit call rather than a code comment — and the narrower ACCESS_PASS_ADMIN-only
waiver flag may be worth doing now rather than later.

3. A restored pin is never re-validated (Medium)

/enable gates a pin on IPv4 parse plus isLocallyAssigned. The restart path applies
neither: run.go passes the persisted pin into DiscoverClientIP, whose explicit branch
(runtime/clientip.go:84-96) only parses, with no local-assignment check and no fallback
to discovery.

So a host whose pinned address goes away (DHCP change, NIC swap, re-addressing) comes back
up pinned to an address the kernel does not hold, matches no onchain user, and builds no
tunnel. With no un-pin path, recovery is hand-editing the state file and restarting.
Re-running IsLocallyAssigned on the restored pin and falling back to discovery with a
warning would close it.

4. The waiver's justification contradicts the client gate (Medium)

The RFC amendment justifies the waiver because an attested address "is not always one the
host can originate from — under asymmetric routing, behind NAT". But the client gate this
PR adds requires the address be assigned to a local up+running interface. Behind NAT the
host does not hold its public address, so connect --client-ip rejects exactly the hosts
the onchain waiver exists to serve. The two halves address disjoint sets of hosts; the
amendment should probably say so.

5. The CLI and program gates test different predicates (Low)

The CLI checks "an exact-PDA pass exists"; the program checks
"exact PDA and !allow_multiple_ip()". A pass at PDA(X, payer) with
allow_multiple_ip set passes the CLI gate, so connect --client-ip X proceeds, but
onchain accesspass_is_ip_bound is false and a proof is still required. For the flag's
target case (no obtainable proof) that fails late at create_user with
IpOwnershipProofRequired instead of early with the clear "No AccessPass is pinned to X"
message. Fails safe, so UX rather than a hole, but the predicates should match.

6. The daemon does not check globally-routable (Low)

The description says "Both sides verify the address is globally routable and assigned to
an interface that is up and running." IsLocallyAssigned is possession-only and there is
no is_global equivalent in http.go. The CLI and the program both catch it, so this is
just a description inaccuracy.

Things I specifically liked

  • GetExactAccessPassCommand — the pre-existing wildcard-first resolution in
    GetAccessPassCommand would have admitted a payer holding both passes on the strength
    of the dynamic one. Returning Ok(None) for an UNSPECIFIED query rather than
    resolving it is a good call.
  • Adopting the address synchronously in SetReconcilerState and sending only
    clientIPChanged over the channel, so tunnelSrcCache stays owned by the reconciler
    goroutine while /v2/status can report the pin the moment /enable returns.
  • Snapshotting clientIP := n.ClientIP() once per reconcile() pass, so a concurrent
    pin cannot match a user against one address and provision against another.
  • "Persist the pin in effect, not the request being served" — the subtlest invariant
    here, and correct. A body-less /enable blanking a live pin would only surface at the
    next reboot.

A pin restored from the state file went straight into DiscoverClientIP's
explicit branch, which only parses it. An address that left while the daemon
was down — DHCP change, NIC swap, re-addressing — brought the host back up
pinned to something the kernel does not hold, matching no onchain user and
building no tunnel, with no un-pin path short of editing the state file.

The check /enable makes is now repeated at startup. A pin the host no longer
holds falls back to discovery with a warning and is kept on disk, so it applies
again once the address returns and a late interface does not cost the operator
their configuration. An enumeration failure keeps the pin: it is a failure to
check, not a failed check.
The --client-ip gate asked only whether a pass existed at the exact PDA, while
create_user additionally requires !allow_multiple_ip() before it treats the pass
as attesting the address. A pass at (X, payer) with the flag set cleared the CLI
and then failed onchain with IpOwnershipProofRequired — late, and precisely for
the host the flag exists to serve, one with no obtainable proof. The two
predicates now match.
The amendment justified the waiver with hosts behind NAT, but behind plain NAT
the service observes the same address discovery finds, so a proof is obtainable.
The cases that actually need it are a host reaching the service from an address
other than the attested one, or unable to reach it from that source at all.

Also records that the waiver is reached through the daemon's configured or
discovered address rather than through connect --client-ip, which independently
requires local possession, and says which of the CLI's two checks the daemon
repeats and why it does not repeat the other.
…iver

The merge of main resolved CHANGELOG.md by keeping this branch's Unreleased
section wholesale, which dropped four entries main had added: the feed lifecycle
SDK commands and three RFC-28 serviceability bullets. Main has since cut v0.41.0
and moved them there, so this takes main's file and adds this branch's entries
under Unreleased, where they belong now that v0.41.0 is out.

The connect bullet no longer says the onchain waiver is separate work — it is in
this branch — and the waiver gets a Serviceability entry of its own.
@juan-malbeclabs

Copy link
Copy Markdown
Contributor Author

Thanks — all six are addressed. Point by point:

1. Description claimed the onchain waiver was absent (High). Correct, and the worst of the six. The description is rewritten: the waiver is in the opening paragraph, the Summary, Testing Verification and Key files, and the Follow-up section no longer claims it is separate work. The table is recomputed from the real diff (25 files, +1988/−219). I kept the two halves together — either alone leaves the flag unusable for the host that asked for it — but the size note now names the onchain half as the clean split if you would rather take it separately. The CHANGELOG carried the same stale sentence and is fixed too.

2. Waiver reachable by any tenant administrator (High). Accepted, and now an explicit call rather than a code comment: the description has a "Security decision this PR makes" section naming set.rs's is_privileged || is_tenant_admin authorization, the unconstrained value.client_ip, and the resulting (someone_else's_ip, their_own_payer) path. It wants a conscious sign-off, not a silent merge.

The narrower lever is recorded in Follow-up with the shape it would take: stamp a bit in the existing flags byte at issuance when the issuer held ACCESS_PASS_ADMIN, and waive only for stamped passes — no account layout change. Deliberately not in this PR, because it would make tenant-issued pinned passes require a proof, which is a product call about how tenants provision rather than a correctness fix. Happy to land it first if you would rather not merge the wider boundary at all.

3. Restored pin never re-validated (Medium). Fixed — restoreClientIPPin in run.go repeats the local-assignment check at startup. Two choices worth flagging, both slightly different from your suggestion:

  • A rejected pin is not erased. It stays in the state file, so it applies again once the address returns and an interface that is merely late coming up does not cost the operator their configuration.
  • An enumeration failure keeps the pin rather than dropping it: that is a failure to check, not a failed check. Only a definite "this host does not hold it" falls back to discovery, with a warning.

Worth noting this was inherited rather than introduced — the daemon's own -client-ip flag has never been locally validated either — but the pin is machine-written, so it is the one that deserves the fallback.

4. Justification contradicts the client gate (Medium). Right, and the NAT example was weaker than either of us said: behind plain NAT the service observes the same address discovery finds, so a proof is obtainable. The amendment is rewritten around the cases that actually hold — a host reaching the service from an address other than the attested one, or unable to reach it from that source at all — and now states the thing that resolves the contradiction: the waiver is reached through the address the daemon is configured with or discovers, never through connect --client-ip, which independently requires local possession. The same wording in ip_proof.rs and in the test doc comment is fixed.

5. CLI and program test different predicates (Low). Fixed — the gate filters on !accesspass.allow_multiple_ip(), matching accesspass_is_ip_bound, and the error names the flag. New test refuses_an_exact_pass_flagged_allow_multiple_ip.

6. Daemon does not check globally-routable (Low). Fixed as the description inaccuracy it was, rather than by adding the check: globality is a property of the address rather than of the host, so it cannot change between the CLI's check and the daemon's, and User::validate enforces it onchain for every path in. The /enable comment now says which of the CLI's two checks it repeats and why it does not repeat the other.

One thing outside the review. The merge of main at 63ee88c17 resolved CHANGELOG.md by keeping this branch's Unreleased section wholesale, which dropped four entries main had added — the feed-lifecycle SDK commands and three RFC-28 serviceability bullets. Main has since cut v0.41.0 and moved them there, so a later merge would have conflicted or lost them again. Rebuilt from main's file with this branch's entries under Unreleased; the diff against main is now purely additive.

The waiver read the stored `client_ip` field, which is not on its own an
attestation. Between #1608 and #3859 `create_user` wrote the first address a
dynamic pass connected from into that field and serialized it back, and #3859
removed the lock-in without migrating the accounts. Such a pass still sits at
the UNSPECIFIED PDA while its field names an address the user picked, so the
waiver accepted an address no authority ever vouched for — the squatting RFC-27
exists to close, reached through exactly the wildcard passes it names as its
threat.

The PDA seed is what an issuing authority chose, so the waiver is keyed on it.
A legacy pass still authorizes the creation; it only stops waiving the proof.

Also gives the sentinel test its teeth back: it provisioned a pinned pass, which
the waiver now satisfies on its own, so it passed with the sentinel exemption
deleted entirely. It uses an allow_multiple_ip pass instead.
The gate asked `get_accesspass_exact`, but user creation resolves the account
with `GetAccessPassCommand`, which prefers the dynamic (0.0.0.0) PDA whenever
one exists. A payer holding both passes cleared the gate on the exact one and
then had the dynamic one sent, where the pass does not attest the address and
the proof is required after all — the late IpOwnershipProofRequired the gate
exists to turn into an early refusal. It now refuses up front and says why.

Two diagnostics alongside it. GetExactAccessPassCommand folded every error into
Ok(None), so an unreachable or wrong-cluster ledger read as "this payer holds no
pass" and sent an operator to have a live one reissued; only AccountNotFound is
absence now. And the daemon client's enable/disable discarded the response body,
which for /enable is the only place the reason exists — four distinct rejections
all surfaced as "400 Bad Request".
reconcile() reads the address once per pass under a comment saying a concurrent
pin must not split it between matching one user and provisioning against
another, but the snapshot was a local that stopped there: buildProvisionRequest
re-read n.ClientIP(), and for plain IBRL that value becomes the GRE tunnel
source verbatim. A pin landing between the two would source the tunnel from an
address the ledger and the device both bind elsewhere, with the service marked
up. The snapshot is now passed down like devicesByPK and allPrefixes already
are.
…lost

Two holes on either side of the pin's lifetime.

/enable checked only possession. create_user is not the backstop for the rest:
it rejects the onchain user, while what /enable writes is the daemon's pin, and
a pin no user can exist at is the worst shape to hold — it matches nothing,
tears down the services the host had, persists, and survives every restart with
no un-pin path short of editing the state file. IsPublicIPv4 moves to the
manager package so the discovered address and the pinned one share one
definition, and the pin is now checked against it.

A restored pin the host no longer holds fell back to discovery but stayed in
memory as the pin in effect, so the next state write put it back and a later
restart could apply it once the address happened to return — a silent address
migration, and a state file disagreeing with the daemon in the meantime. It is
dropped instead, and the warning says to re-pin.
It filtered the host's own addresses to the globally routable ones, and CI and
developer machines are NAT'd, so the loop had no iterations: a test structurally
incapable of failing where it runs. It now covers every enumerated address and
asserts that a private one is refused for not being global rather than for not
being assigned, which is the agreement it exists to guard.
Follows the program: the seed is what an issuing authority chose, and a pass
predating the RFC may carry an address its own holder first connected from. Also
records the two client-side refusals added alongside it.
@juan-malbeclabs

Copy link
Copy Markdown
Contributor Author

A second self-review pass turned up six more, five of which I'd rank above some of the first round. All fixed and pushed; each has a regression test that fails without the fix.

1. The waiver was reachable by a legacy wildcard pass. accesspass_is_ip_bound read the stored accesspass.client_ip field, which is not on its own an attestation. Between #1608 (3b13effb5) and #3859 (2550270fa), create_user wrote the first address a dynamic pass connected from into that field and serialized it back, and #3859 removed the lock-in without migrating the accounts. Such a pass still sits at the UNSPECIFIED PDA while its field names an address its own holder picked — so the waiver accepted an address no authority ever vouched for, reached through exactly the wildcard passes RFC-27 names as its threat. Latent until RequireIpOwnershipProof is set, which is the moment it matters.

The waiver is now keyed on the PDA seed, which is what an issuing authority chose. A legacy pass still authorizes the creation; it only stops waiving the proof. test_missing_proof_is_rejected_for_a_legacy_self_bound_wildcard_pass fabricates that account shape directly, since no instruction can produce it today, and fails without the fix.

Worth a check against live ledger state to see whether the population is non-empty — I could not verify that from here.

2. The --client-ip gate did not predict what the transaction evaluates. It asked get_accesspass_exact, but CreateUserCommand resolves the account with GetAccessPassCommand, which prefers the dynamic PDA whenever one exists. A payer holding both passes cleared the gate on the exact one and then had the dynamic one sent, failing with IpOwnershipProofRequired — the late failure the gate exists to prevent. It now refuses up front and says why. Letting the pin choose which pass is charged for the seat is a bigger change than this flag should make, so that is stated rather than done.

3. The single-read invariant stopped halfway. reconcile snapshots clientIP under a comment saying a concurrent pin must not split the pass between matching one user and provisioning against another — but buildProvisionRequest re-read n.ClientIP(), and for plain IBRL that value becomes the GRE tunnel source verbatim. Now passed down like devicesByPK and allPrefixes already are.

4. /enable accepted a non-routable pin. I argued last round that the daemon needn't repeat the CLI's is_global check because create_user enforces it onchain. That was wrong: the program rejects the onchain user, while what /enable writes is the daemon's pin, and a pin no user can exist at is the worst shape to hold — it matches nothing, tears down the host's services, persists, and survives every restart. IsPublicIPv4 moved to the manager package so the discovered address and the pinned one share one definition.

5. A restored pin the host had lost was still held as the pin in effect. Falling back to discovery but keeping it in memory meant the next state write put it back, and a later restart could apply it once the address happened to return. That is a silent address migration, and it contradicts the invariant this PR claims — that the daemon and the state file cannot disagree. Dropped instead, with the warning saying to re-pin. @elitegreg, this is your original suggestion; I traded it away last round for boot-race tolerance and the trade was wrong.

6. Two tests were vacuous, one newly so. test_sentinel_payer_may_create_without_a_proof_while_the_flag_is_set provisioned a pinned pass, which the new waiver satisfies on its own — I confirmed it passed with the payer_is_sentinel branch deleted entirely. It uses an allow_multiple_ip pass now and fails without the exemption. And accepts_addresses_this_host_actually_holds filtered to globally routable addresses, so on any NAT'd host — every CI runner — the loop had no iterations.

Two diagnostics went with them: GetExactAccessPassCommand folded every error into Ok(None), so an unreachable or wrong-cluster ledger read as "this payer holds no pass"; and the daemon client discarded the /enable response body, collapsing four distinct rejections into "400 Bad Request".

Still open and unfixed, flagged rather than silently carried: version skew (a daemon predating this PR 200s the pin and discards it, so a CLI upgrade without a daemon restart exits 0 with an orphan onchain user); poll_for_daemon_provisioned matching on user_type alone, so a stale service can satisfy the wait; the pin reaching the daemon before any onchain write only on the bare connect path; a read-modify-write window in ServeEnable; flag-versus-pin precedence differing between boot and runtime; and IFF_RUNNING rejecting addresses on a carrier-less bridge or bond. Happy to take any of them here or in a follow-up — say which.

@ben-malbeclabs ben-malbeclabs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 important, 7 minor. The two important ones want addressing before merge.

Reviewed at 8f8d28c. CI is green on that commit including all six e2e shards, so the connect --client-ip invocations the description calls out as unverified do pass: the e2e CYOA subnet is 9.128.0.0/9, which is_global accepts, and the passes are issued at the exact PDA with no dynamic pass alongside.

Findings

# Sev Location
1 Important runtime/run.go:131 — a runtime /enable pin loses to -client-ip on the next restart
2 Important CHANGELOG.md:12 — behaviour change is breaking, filed under Changes
3 Minor CHANGELOG.md:18 — entry length and PR IDs
4 Minor manager/manager.go:523 — tunnel-src cache clear has no reachable effect
5 Minor runtime/run.go:51 — restored pin skips the globality check
6 Minor connect.rs:232 — rejection names the wrong condition
7 Minor rfc27-ip-verification.md:363 — states a program property the program does not enforce
8 Minor create_core.rs:234allow_multiple_ip rationale is false against the assert above it
9 Minor accesspass/get.rs:81 — absence decided by matching error text

Rationale without a single anchor

The onchain waiver itself checks out. accesspass_is_ip_bound keys on accesspass_account.key == &accesspass_pda, which is seeded on the client_ip being created, so a legacy pass whose stored field its own holder chose cannot buy the waiver, and the test for that shape is present. Moving validate_ip_ownership_proof below the access-pass checks weakens no other path: every check that ran before still runs, only the error a bad caller sees changes.

The tenant-administrator escalation is accurately described. add_administrator is foundation-gated so administrators are appointed, but they hold no ACCESS_PASS_ADMIN and nothing constrains value.client_ip in set.rs. One property the description omits: the waiver is unconditional once RequireIpOwnershipProof is set, so no configuration enforces RFC-27 strictly any more. If the follow-up per-pass flag slips, that is what was lost.

Finding 1 is the one worth blocking on. The changelog states the previous workaround was -client-ip in the packaged unit, so the hosts carrying that flag are exactly the population this feature targets; on them the pin works until the next restart and then reverts silently. /v2/status reports the flag address while PinnedClientIP() reports the dormant pin, and restoreClientIPPin is skipped in that branch, so the dormant pin is never validated either.

// host to its discovered address and tearing the tunnel down.
effectiveClientIP := clientIP
pinnedClientIP := state.ClientIP
if clientIP == "" && state.ClientIP != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important — the persisted pin is only consulted when -client-ip is unset, and the manager is never told the flag was set, so /enable accepts and persists a pin that the next restart overrides with the flag's address, leaving the host on an address no onchain user matches. Pass the flag's presence through to the manager and have ServeEnable refuse a differing pin, naming the flag.

Comment thread CHANGELOG.md
### Changes

- CLI
- `doublezero connect --client-ip <ip>` is honored again instead of being accepted, warned about and discarded. A host whose discovered address differs from the one its AccessPass names could previously only connect by adding the flag to the packaged `doublezerod` unit and restarting the daemon as root. The flag is honored only when the payer holds an AccessPass at the exact `(client_ip, user_payer)` PDA: a dynamic pass authorizes any globally routable address, so accepting a caller-chosen one against it is the address squatting RFC-27 exists to prevent. The flag is also refused while the payer holds a dynamic pass, because user creation attaches that one and the gate would otherwise promise something the transaction does not deliver. The address must also be globally routable and assigned to an interface that is up on this host — possession, not ownership, which is what the local configuration needs and all the CLI can check; RFC-27 proofs remain the answer to who holds an address on the public internet. The value now parses as an IPv4 address rather than a string, so a malformed one is refused instead of silently ignored. A daemon that refuses the pin fails the connect: on the bare path the refusal stops the run before anything is created onchain, and elsewhere it is reported rather than written off as a redundant enable, which would otherwise leave the operator waiting on a tunnel that could never be built for a user already created at that address. A pass flagged `allow_multiple_ip` is refused for the same reason a dynamic one is, and for the same predicate `create_user` applies, so the refusal arrives up front rather than as a late `IpOwnershipProofRequired`. (#4333)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important — --client-ip goes from accepted-and-ignored to a hard failure, so an existing invocation by a payer holding a dynamic pass now aborts the connect. This belongs under the empty ### Breaking heading above.

Comment thread CHANGELOG.md
- SDK
- `GetExactAccessPassCommand` in the Rust SDK reads the AccessPass at an exact `(client_ip, user_payer)` PDA. `GetAccessPassCommand` resolves the dynamic `0.0.0.0` pass first and falls back to the exact one, so a payer holding both is answered with the dynamic pass — right for a connect that takes the address it is given, wrong for authorizing a caller-chosen one, where the distinction is the whole check.
- Serviceability
- With `RequireIpOwnershipProof` set, a user creation carrying no RFC-27 proof is accepted when its AccessPass is bound to the address being claimed — stored at that address's PDA and not flagged `allow_multiple_ip`. The PDA seed is what an issuing authority chose, so the waiver is keyed on it rather than on the stored `client_ip` field: a pass issued between #1608 and #3859 can carry the first address its own holder connected from, which attests nothing. Such a pass is itself an attestation: `SetAccessPass` is permissioned, so a registrant cannot self-issue one, and a pass at `(client_ip, user_payer)` means an issuing authority asserted that this payer may use this address. The flag and the pass together left a host unable to connect at all when no proof was obtainable for its pinned address — the service signs the source it observes, so a host reaching it from some other address gets a proof it cannot use, and one that cannot reach it from that source gets none. The waiver covers *absence* only; a supplied proof is still validated in full, and a proof naming another address, payer, user type or epoch is rejected as before. Wildcard passes and passes flagged `allow_multiple_ip` still require one, including a legacy wildcard pass whose stored field names an address: neither has an authority's seed behind the address being claimed, which is the squatting RFC-27 exists to close. The trust boundary moves with it, onto issuance: `SetAccessPass` authorizes tenant administrators as well as `ACCESS_PASS_ADMIN` holders, so a tenant administrator can pin a third party's address to their own payer and create a user there with no proof. Accepted deliberately; a per-pass waiver flag set by a narrower authority is the lever if it needs tightening. RFC-27 is amended to match. (#4333)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — these four entries run 200-400 words each and carry (#4333). The convention here is a TL;DR with no issue or PR IDs.

// resolved for the old source. Cleared here because the cache belongs to
// this goroutine.
if cmd.clientIPChanged {
n.tunnelSrcCache = make(map[string]net.IP)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — entries are ResolveTunnelSrc(tunnelDst), a function of the routing table alone, and are read only for IBRLWithAllocatedIP and Multicast, where clientIP is not the source; a pin change neither invalidates them nor is described by the comment above. Drop the clear, or state what it actually protects.

//
// An enumeration failure keeps the pin: that is a failure to check, not a failed check.
func restoreClientIPPin(pinned string, isAssigned func(net.IP) (bool, error)) string {
ip := net.ParseIP(pinned)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — a restored pin is re-checked for parseability, IPv4-ness and local assignment but not IsPublicIPv4, which /enable applies, so a non-global pin is adopted and matches no onchain user: the lockout this function exists to prevent.

if pinned.is_none() {
writeln!(
out,
"❌ No AccessPass is pinned to {client_ip} for UserPayer: {}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — an exact pass flagged allow_multiple_ip reaches this line through the filter above, and the message says no pass is pinned to the address when one is.

host unable to connect. It waives the *requirement* only; a supplied proof is still validated in
full.

This exemption is reached through the address the client daemon is configured with or discovers,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — nothing onchain enforces the local-interface check, so doublezero user create --client-ip and any SDK caller reach the waiver without it; this states a program property that only the daemon CLI provides.

// is keyed on (`accesspass.user_payer` above). On the ordinary path the two are the same
// account.
//
// A pass at the UNSPECIFIED PDA authorizes any address, and `allow_multiple_ip` says the same

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — the assert! above restricts accesspass_account to PDA(client_ip) or PDA(0.0.0.0), so a pass stored at a specific address can only ever be presented for that address and allow_multiple_ip cannot make it authorize another; keep the clause as an issuer opt-out, but the stated reason is wrong. Same claim in ip_proof.rs, tests/user_ip_proof_test.rs:236 and connect.rs:191.

// behaviour this replaces, and the transport errors worth retrying have already
// been retried by the client.
Err(err) => {
if format!("{err:#}").contains("AccountNotFound") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — absence is decided by matching upstream error text, so a message change turns the ordinary no-pinned-pass case into a hard error and breaks connect --client-ip. DoubleZeroClient::get_multiple_accounts already returns Vec<Option<Account>> for typed absence.

@elitegreg

Copy link
Copy Markdown
Contributor

Re-reviewed at 8f8d28c. Thanks for turning the earlier round around so thoroughly — the
rewritten description, the explicit security-decision section, and restoreClientIPPin all
address what I raised, and the second self-review pass caught real problems, the legacy
wildcard-pass bypass in particular.

I agree with Ben's review. Both of his important findings match what I see, and I traced
run.go:131 independently: on a host whose unit file carries -client-ip, the whole
restore branch is skipped, so a runtime pin is neither used nor validated on restart, and
/v2/status and PinnedClientIP() then disagree about which address the host presents.
That is the population the CHANGELOG names as the one the feature is for. I'd also not
discount his RFC point (#7) just because it is filed as minor — the amendment leans on
connect --client-ip's local-assignment check in a section that otherwise speaks only in
program MUSTs, and the program enforces nothing of the kind.

I'm satisfied with the onchain waiver as scoped. The predicate keying on the PDA seed rather
than the stored client_ip field is the right call, moving validate_ip_ownership_proof
below the access-pass checks is what makes it hold, and the absence-only boundary is well
covered by tests. Signing off on the wider trust boundary knowingly; the per-pass flag stays
the lever if we need it.

One nit nobody has mentioned: nix is inserted between libc and log in the workspace
Cargo.toml, out of alphabetical order with the rest of the list.

Leaving this as comments rather than a verdict — Ben's two important items are the ones
worth resolving before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client: restore --client-ip on connect for IP-bound access passes

4 participants