Skip to content

Commit ce543fb

Browse files
serviceability: make CreateUser naked and idempotent (#4116)
Resolves #4110. Part 2 of #4108. ## Summary The #4111 connect flow needs `CreateUser` to mean "make sure my user exists": create it bare, poll to `Activated`, then join feeds with one `SubscribeFeed`. Today it can't: under an EdgeSeat pass a multicast create fails with `FeedAccountRequired` unless it names a feed and group, and a re-run fails with `AccountAlreadyInitialized`, so `connect` can neither create a user that holds nothing nor call create unconditionally and retry. - The feed gate moved out of `create_user_core` into `CreateSubscribeUser`, its only real caller. A new user starts with no groups, no feeds, and no seat charged; seats are charged by `SubscribeFeed`. Bare multicast users at zero seat cost (bounded by `device.max_users`) are deliberate: they receive nothing. - Re-running `CreateUser` for an existing user matching the requested owner, device, user type, and tenant succeeds without changing state. A mismatch still fails with `AccountAlreadyInitialized`; a banned user fails with `InvalidStatus` so connect fails fast instead of polling forever. Owner is checked because the user PDA carries no owner in its seeds; without it a second buyer with a pass at the same client IP would get success for an account they do not own. - The exists check sits after the pass identity checks and before every counter bump; the tenant, epoch, device-status, and capacity gates stay below it so a retry survives an epoch rollover or a device that filled up. - `CreateSubscribeUser` is unchanged: a duplicate still errors and the gate still applies. ## Testing Verification - Re-run asserts full-struct equality on User, Device, and AccessPass, so any counter bump fails it; a re-run against a device shrunk to `max_users = 1` still succeeds. - Rejections: different device, different tenant, different owner (second buyer, same client IP), and banned user all fail and leave the user untouched; a duplicate `CreateSubscribeUser` errors and ticks no seat. - `test_naked_create_then_subscribe_feed`: a bare user is admitted on an EdgeSeat pass with no seat, then one `SubscribeFeed` joins the feed and charges it. - `feed_metro_gate_test` and `create_subscribe_user_test` cover the gate on the `CreateSubscribeUser` path after the move.
1 parent caa85b0 commit ce543fb

12 files changed

Lines changed: 888 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ All notable changes to this project will be documented in this file.
88

99
### Changes
1010

11+
- Controller
12+
- A multicast user with no publisher role now gets the subscriber ingress ACL and a deny-all announce prefix-list; a user with no roles at all previously fell through to the publisher versions of both. (#4110)
1113
- Serviceability
1214
- New `SubscribeFeed` and `UnsubscribeFeed` instructions join or leave whole feeds on an EdgeSeat access pass in a single atomic transaction, charging one seat per feed rather than per group. A feed is all-or-nothing: the caller names feeds, the processor derives which groups change and rejects a group list that does not match, so two feeds carrying the same group stay unambiguous. `UpdateMulticastGroupRoles` now enforces the multicast-group allowlists for every access-pass type, EdgeSeat included, so purchased groups go through the feed instructions and individually comped groups through the allowlist. `CreateSubscribeUser` skips the allowlist only for the case its feed gate actually covers, closing two paths that could join a group with no check. `MAX_FEED_GROUPS` drops from 64 to 20, bounded by what one transaction can carry: because joining passes every group a feed holds, a larger feed could never be joined. No feed onchain is affected. For the same reason a user may hold at most 6 feeds, and a held feed the pass no longer carries is pruned on leave instead of blocking it. New errors: `EdgeSeatRequired` (101), `UserDeviceMismatch` (102), `UserFeedLimitExceeded` (103), `EdgeSeatIsMulticastOnly` (104). (#4109)
15+
- `CreateUser` can now create a bare multicast user (no group, no feed) under an EdgeSeat pass: the feed gate moved to `CreateSubscribeUser`, its only caller, and feed seats are otherwise charged by `SubscribeFeed`. The pass-level `max_multicast_users` cap is enforced again (it had been vestigial under the feed-seat model) so bare users stay bounded; the oracle already provisions it as the total purchased seats. `CreateUser` is also idempotent: re-running it for an existing user matching the requested owner, device, user type, and tenant succeeds without changing state, so a client can retry safely; a mismatch still fails with `AccountAlreadyInitialized`, and a banned user with `InvalidStatus`. This applies to the current client_ip-keyed user PDA; the legacy index-keyed PDA cannot be re-run, since its index has moved on. Deploy note: `max_multicast_users` becomes load-bearing on existing EdgeSeat passes; before deploying to a cluster, confirm each pass's value covers its feed seats and backfill via `SetAccessPass` where short. Checked 2026-07-30: mainnet-beta and devnet hold no EdgeSeat pass; testnet's single one (`5x9DTsWC…`) has `max_multicast_users` 1, zero users, zero connections. (#4110)
1316

1417
## [v0.32.0](https://github.com/malbeclabs/doublezero/compare/client/v0.31.0...client/v0.32.0) - 2026-07-29
1518

controlplane/controller/internal/controller/fixtures/multicast.tunnel.tmpl

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ mpls icmp ip source-interface Loopback255
7979
default interface Tunnel500
8080
default interface Tunnel501
8181
default interface Tunnel502
82+
default interface Tunnel503
8283
interface Tunnel500
8384
description USER-MCAST-500
8485
ip access-group SEC-USER-SUB-MCAST-IN in
@@ -126,6 +127,21 @@ interface Tunnel502
126127
tunnel ttl 32
127128
no shutdown
128129
!
130+
interface Tunnel503
131+
description USER-MCAST-503
132+
ip access-group SEC-USER-SUB-MCAST-IN in
133+
multicast ipv4 boundary SEC-USER-MCAST-BOUNDARY-503-OUT out
134+
pim ipv4 sparse-mode
135+
pim ipv4 dr-priority 4294967295
136+
mtu 9216
137+
ip address 169.254.0.6/31
138+
tunnel mode gre
139+
tunnel source 8.8.8.8
140+
tunnel destination 9.9.9.9
141+
tunnel path-mtu-discovery
142+
tunnel ttl 32
143+
no shutdown
144+
!
129145
router bgp 65342
130146
router-id 14.14.14.14
131147
timers bgp 1 3
@@ -157,10 +173,20 @@ router bgp 65342
157173
neighbor 169.254.0.5 route-map RM-USER-502-OUT out
158174
neighbor 169.254.0.5 maximum-routes 1
159175
neighbor 169.254.0.5 maximum-accepted-routes 1
176+
no neighbor 169.254.0.7
177+
neighbor 169.254.0.7 remote-as 65000
178+
neighbor 169.254.0.7 local-as 65342 no-prepend replace-as
179+
neighbor 169.254.0.7 passive
180+
neighbor 169.254.0.7 description USER-503
181+
neighbor 169.254.0.7 route-map RM-USER-503-IN in
182+
neighbor 169.254.0.7 route-map RM-USER-503-OUT out
183+
neighbor 169.254.0.7 maximum-routes 1
184+
neighbor 169.254.0.7 maximum-accepted-routes 1
160185
address-family ipv4
161186
neighbor 169.254.0.1 activate
162187
neighbor 169.254.0.3 activate
163188
neighbor 169.254.0.5 activate
189+
neighbor 169.254.0.7 activate
164190
!
165191
address-family vpn-ipv4
166192
!
@@ -203,6 +229,12 @@ route-map RM-USER-502-OUT deny 10
203229
route-map RM-USER-502-OUT permit 20
204230
match community COMM-ALL_MCAST_USERS
205231
!
232+
no route-map RM-USER-503-OUT
233+
route-map RM-USER-503-OUT deny 10
234+
match community COMM-TST_USERS
235+
route-map RM-USER-503-OUT permit 20
236+
match community COMM-ALL_MCAST_USERS
237+
!
206238
no route-map RM-USER-500-IN
207239
route-map RM-USER-500-IN permit 10
208240
match ip address prefix-list PL-USER-500
@@ -221,6 +253,12 @@ route-map RM-USER-502-IN permit 10
221253
match as-path length = 1
222254
set community 21682:1300 21682:10050
223255
!
256+
no route-map RM-USER-503-IN
257+
route-map RM-USER-503-IN permit 10
258+
match ip address prefix-list PL-USER-503
259+
match as-path length = 1
260+
set community 21682:1300 21682:10050
261+
!
224262
no ip prefix-list PL-USER-500
225263
ip prefix-list PL-USER-500 seq 10 deny 0.0.0.0/0 le 32
226264
!
@@ -230,9 +268,13 @@ ip prefix-list PL-USER-501 seq 10 permit 100.0.0.1/32
230268
no ip prefix-list PL-USER-502
231269
ip prefix-list PL-USER-502 seq 10 permit 100.0.0.2/32
232270
!
271+
no ip prefix-list PL-USER-503
272+
ip prefix-list PL-USER-503 seq 10 deny 0.0.0.0/0 le 32
273+
!
233274
no ip access-list SEC-USER-500-IN
234275
no ip access-list SEC-USER-501-IN
235276
no ip access-list SEC-USER-502-IN
277+
no ip access-list SEC-USER-503-IN
236278
no ip access-list standard SEC-USER-MCAST-BOUNDARY-500-OUT
237279
ip access-list standard SEC-USER-MCAST-BOUNDARY-500-OUT
238280
counters per-entry
@@ -254,6 +296,11 @@ ip access-list standard SEC-USER-MCAST-BOUNDARY-502-OUT
254296
permit host 239.0.0.6
255297
deny 224.0.0.0/4
256298
!
299+
no ip access-list standard SEC-USER-MCAST-BOUNDARY-503-OUT
300+
ip access-list standard SEC-USER-MCAST-BOUNDARY-503-OUT
301+
counters per-entry
302+
deny 224.0.0.0/4
303+
!
257304
no ip access-list SEC-USER-PUB-MCAST-IN
258305
ip access-list SEC-USER-PUB-MCAST-IN
259306
counters per-entry

controlplane/controller/internal/controller/render_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,21 @@ func TestRenderConfig(t *testing.T) {
378378
{239, 0, 0, 6},
379379
},
380380
},
381+
// A bare multicast user (no roles yet) must get the subscriber ACL and
382+
// announce nothing, not fall through to the publisher config.
383+
{
384+
Id: 503,
385+
UnderlaySrcIP: net.IP{8, 8, 8, 8},
386+
UnderlayDstIP: net.IP{9, 9, 9, 9},
387+
OverlaySrcIP: net.IP{169, 254, 0, 6},
388+
OverlayDstIP: net.IP{169, 254, 0, 7},
389+
DzIp: net.IP{100, 0, 0, 3},
390+
Allocated: true,
391+
IsMulticast: true,
392+
MulticastBoundaryList: []net.IP{},
393+
MulticastSubscribers: []net.IP{},
394+
MulticastPublishers: []net.IP{},
395+
},
381396
},
382397
},
383398
UnknownBgpPeers: []net.IP{},

controlplane/controller/internal/controller/server_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,21 @@ func TestGetConfig(t *testing.T) {
219219
{239, 0, 0, 6},
220220
},
221221
},
222+
// A bare multicast user (no roles yet) must get the subscriber ACL and
223+
// announce nothing, not fall through to the publisher config.
224+
{
225+
Id: 503,
226+
UnderlaySrcIP: net.IP{8, 8, 8, 8},
227+
UnderlayDstIP: net.IP{9, 9, 9, 9},
228+
OverlaySrcIP: net.IP{169, 254, 0, 6},
229+
OverlayDstIP: net.IP{169, 254, 0, 7},
230+
DzIp: net.IP{100, 0, 0, 3},
231+
Allocated: true,
232+
IsMulticast: true,
233+
MulticastBoundaryList: []net.IP{},
234+
MulticastSubscribers: []net.IP{},
235+
MulticastPublishers: []net.IP{},
236+
},
222237
},
223238
PublicIP: net.IP{7, 7, 7, 7},
224239
Vpn4vLoopbackIP: net.IP{14, 14, 14, 14},

controlplane/controller/internal/controller/templates/tunnel.tmpl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ interface Tunnel{{ .Id }}
171171
{{- if eq true .IsMulticast }}
172172
description USER-MCAST-{{ .Id }}
173173
{{- if not $.NoHardware }}
174-
{{- if and .IsMulticast (gt (len .MulticastSubscribers) 0) (eq (len .MulticastPublishers) 0) }}
174+
{{- /* Only a user holding the publisher role gets the ACL that permits sending into the
175+
multicast group block; subscriber-only and role-less users get the receive-only ACL. */}}
176+
{{- if and .IsMulticast (eq (len .MulticastPublishers) 0) }}
175177
ip access-group SEC-USER-SUB-MCAST-IN in
176178
{{- else }}
177179
ip access-group SEC-USER-PUB-MCAST-IN in
@@ -404,7 +406,9 @@ route-map RM-USER-{{ .Id }}-IN permit 10
404406
{{- range .Device.Tunnels }}
405407
no ip prefix-list PL-USER-{{ .Id }}
406408
{{- if eq true .Allocated }}
407-
{{- if and .IsMulticast (gt (len .MulticastSubscribers) 0) (eq (len .MulticastPublishers) 0) }}
409+
{{- /* Only publishers hold an allocated dz_ip to announce; subscriber-only and role-less
410+
multicast users get deny-all. */}}
411+
{{- if and .IsMulticast (eq (len .MulticastPublishers) 0) }}
408412
ip prefix-list PL-USER-{{ .Id }} seq 10 deny 0.0.0.0/0 le 32
409413
{{- else }}
410414
ip prefix-list PL-USER-{{ .Id }} seq 10 permit {{ .DzIp }}/32

smartcontract/programs/doublezero-serviceability/src/processors/user/create.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ pub fn process_create_user(
106106
permission_account: None,
107107
};
108108

109-
let mut result = create_user_core(
109+
let Some(mut result) = create_user_core(
110110
program_id,
111111
accounts,
112112
&core_accounts,
@@ -116,10 +116,11 @@ pub fn process_create_user(
116116
value.tunnel_endpoint,
117117
false,
118118
None,
119-
// Plain CreateUser is unicast; no multicast group and no feed gate.
120-
None,
121-
None,
122-
)?;
119+
)?
120+
else {
121+
msg!("user already exists; nothing to do");
122+
return Ok(());
123+
};
123124

124125
// Always allocate resources and activate atomically.
125126
resource_onchain_helpers::validate_and_allocate_user_resources(

smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ use solana_program::{
1919
};
2020
use std::net::Ipv4Addr;
2121

22-
use crate::{
23-
processors::{feed::enforce_feed_metro_gate, validation::validate_program_account},
24-
serializer::try_acc_write,
25-
};
22+
use crate::{processors::validation::validate_program_account, serializer::try_acc_write};
2623

2724
#[derive(PartialEq)]
2825
pub enum PDAVersion {
@@ -57,15 +54,18 @@ pub struct CreateUserCoreResult {
5754

5855
/// Shared validation and state setup for CreateUser and CreateSubscribeUser.
5956
///
60-
/// Performs all common checks (payer signer, account emptiness, access pass validation,
61-
/// PDA derivation, device validation, max users checks, epoch check) and sets up the
62-
/// initial User struct with Pending status.
57+
/// Performs all common checks (payer signer, access pass validation, PDA derivation,
58+
/// device validation, max users checks, epoch check) and sets up the initial User struct.
59+
///
60+
/// Returns `Ok(None)` when the user already exists and matches the requested owner, device,
61+
/// user type, and tenant; a mismatch errors with `AccountAlreadyInitialized`, and a banned user
62+
/// with `InvalidStatus`.
6363
///
6464
/// Callers are responsible for:
6565
/// - Parsing the required resource extension accounts
6666
/// - Onchain allocation + try_activate
6767
/// - Account creation (try_acc_create) and write-back
68-
/// - Multicast subscription logic (CreateSubscribeUser only)
68+
/// - Multicast subscription logic and the EdgeSeat feed gate (CreateSubscribeUser only)
6969
#[allow(clippy::too_many_arguments)]
7070
pub fn create_user_core(
7171
program_id: &Pubkey,
@@ -77,17 +77,11 @@ pub fn create_user_core(
7777
tunnel_endpoint: Ipv4Addr,
7878
is_publisher: bool,
7979
owner_override: Option<Pubkey>,
80-
// EdgeSeat multicast metro gate: the multicast group being joined (None for non-multicast
81-
// connects) and the referenced Feed account covering the device's exchange.
82-
target_mgroup: Option<&Pubkey>,
83-
feed_account: Option<&AccountInfo>,
84-
) -> Result<CreateUserCoreResult, ProgramError> {
80+
) -> Result<Option<CreateUserCoreResult>, ProgramError> {
8581
// Check if the payer is a signer
8682
assert!(core.payer_account.is_signer, "Payer must be a signer");
8783

88-
if !core.user_account.data_is_empty() {
89-
return Err(ProgramError::AccountAlreadyInitialized);
90-
}
84+
let already_exists = !core.user_account.data_is_empty();
9185
if core.accesspass_account.data_is_empty() {
9286
return Err(DoubleZeroError::AccessPassNotFound.into());
9387
}
@@ -204,6 +198,43 @@ pub fn create_user_core(
204198
return Err(DoubleZeroError::Unauthorized.into());
205199
}
206200

201+
// Idempotent create: an existing user matching the request is a no-op, so a caller can retry
202+
// safely. Checked after the pass identity checks (PDA, user_payer, client_ip) so a no-op still
203+
// needs the caller's own pass, and before the tenant-allowlist, epoch, device-status, and
204+
// capacity gates: those apply to adding a user, not to retrying one.
205+
if already_exists {
206+
if core.user_account.owner != program_id {
207+
return Err(ProgramError::IncorrectProgramId);
208+
}
209+
let existing = User::try_from(core.user_account)?;
210+
let requested_tenant = core.tenant_account.map(|a| *a.key).unwrap_or_default();
211+
if existing.owner != effective_owner
212+
|| existing.device_pk != *core.device_account.key
213+
|| existing.user_type != user_type
214+
|| existing.tenant_pk != requested_tenant
215+
{
216+
msg!(
217+
"user exists with owner {} device {} type {} tenant {}; requested owner {} device {} type {} tenant {}",
218+
existing.owner,
219+
existing.device_pk,
220+
existing.user_type,
221+
existing.tenant_pk,
222+
effective_owner,
223+
core.device_account.key,
224+
user_type,
225+
requested_tenant
226+
);
227+
return Err(ProgramError::AccountAlreadyInitialized);
228+
}
229+
// A ban is terminal; fail fast instead of leaving the caller polling a user that will
230+
// never activate.
231+
if existing.status == UserStatus::Banned {
232+
msg!("user {} is banned", core.user_account.key);
233+
return Err(DoubleZeroError::InvalidStatus.into());
234+
}
235+
return Ok(None);
236+
}
237+
207238
// Enforce tenant_allowlist for unicast users only. Multicast connections are not
208239
// tenant-scoped, so the access-pass tenant_allowlist does not apply to them.
209240
if user_type != UserType::Multicast {
@@ -317,25 +348,6 @@ pub fn create_user_core(
317348
// returns before any account is written, so no state is persisted.
318349
accesspass.try_add_user(user_type)?;
319350

320-
// EdgeSeat multicast metro gate: the device's exchange must be covered by a feed on the pass,
321-
// the target group must be joinable there, and that feed's seat is ticked. Unicast retains the
322-
// per-category cap above and is not feed-gated. The ticked feed is recorded on the User below so
323-
// delete releases exactly that seat. A user may accumulate more feeds post-activation (multiple
324-
// metros per pass); that re-gating is deferred to doublezero#1699.
325-
let feed_pks = if matches!(accesspass.accesspass_type, AccessPassType::EdgeSeat(_))
326-
&& user_type == UserType::Multicast
327-
{
328-
vec![enforce_feed_metro_gate(
329-
program_id,
330-
&mut accesspass,
331-
&device.exchange_pk,
332-
target_mgroup,
333-
feed_account,
334-
)?]
335-
} else {
336-
vec![]
337-
};
338-
339351
// All validations passed - now update counters
340352
accesspass.connection_count += 1;
341353
accesspass.status = AccessPassStatus::Connected;
@@ -406,16 +418,17 @@ pub fn create_user_core(
406418
last_bgp_up_at: 0,
407419
last_bgp_reported_at: 0,
408420
bgp_rtt_ns: 0,
409-
feed_pks,
421+
// Feeds are joined post-creation via SubscribeFeed, or by CreateSubscribeUser's gate.
422+
feed_pks: vec![],
410423
};
411424

412-
Ok(CreateUserCoreResult {
425+
Ok(Some(CreateUserCoreResult {
413426
user,
414427
device,
415428
accesspass,
416429
globalstate,
417430
pda_ver,
418431
bump_old_seed,
419432
bump_seed,
420-
})
433+
}))
421434
}

0 commit comments

Comments
 (0)