Skip to content

Commit 2f4468c

Browse files
committed
feat(wireguard): add shared IP allocator for bulk user creation
- Add `build_wireguard_peer_ip_allocator()` to create a stateful allocator pre-loaded with used networks - Add `prepare_wireguard_proxy_settings_with_allocator()` to allocate IPs against a shared allocator - Add `is_reserved()`, `conflicts()`, and `reserve()` methods to WireGuardPeerIPAllocator for IP validation and blocking - Add `get_wireguard_tags_from_groups()` import to detect WireGuard groups - Update bulk user creation to use shared allocator when WireGuard is enabled, preventing duplicate IP allocation race conditions - Import `wireguard_settings` and new utility functions in user operation module - Validates user-supplied peer IPs and raises descriptive errors for reserved or conflicting addresses
1 parent cda4e72 commit 2f4468c

3 files changed

Lines changed: 110 additions & 7 deletions

File tree

app/operation/user.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,14 @@
9494
from app.utils.jwt import create_subscription_token
9595
from app.utils.logger import get_logger
9696
from app.utils.wireguard import (
97+
build_wireguard_peer_ip_allocator,
9798
bulk_reallocate_wireguard_peer_ips as run_bulk_reallocate_wireguard_peer_ips,
99+
get_wireguard_tags_from_groups,
98100
prepare_wireguard_keys_only,
99101
prepare_wireguard_proxy_settings,
102+
prepare_wireguard_proxy_settings_with_allocator,
100103
)
101-
from config import subscription_env_settings
104+
from config import subscription_env_settings, wireguard_settings
102105

103106
logger = get_logger("user-operation")
104107

@@ -237,12 +240,26 @@ async def _persist_bulk_users(
237240
if not users_to_create:
238241
return []
239242

240-
for user_to_create in users_to_create:
241-
user_to_create.proxy_settings = await self._prepare_user_proxy_settings(
242-
db,
243-
groups,
244-
user_to_create.proxy_settings,
245-
)
243+
wireguard_tags = await get_wireguard_tags_from_groups(groups)
244+
use_shared_allocator = bool(wireguard_tags) and wireguard_settings.enabled
245+
246+
if use_shared_allocator:
247+
allocator = await build_wireguard_peer_ip_allocator(db)
248+
for user_to_create in users_to_create:
249+
try:
250+
user_to_create.proxy_settings = prepare_wireguard_proxy_settings_with_allocator(
251+
user_to_create.proxy_settings,
252+
allocator,
253+
)
254+
except ValueError as exc:
255+
await self.raise_error(message=str(exc), code=400, db=db)
256+
else:
257+
for user_to_create in users_to_create:
258+
user_to_create.proxy_settings = await self._prepare_user_proxy_settings(
259+
db,
260+
groups,
261+
user_to_create.proxy_settings,
262+
)
246263

247264
db_users = await create_users_bulk(db, users_to_create, groups, db_admin)
248265

app/utils/ip_pool.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,39 @@ def allocate(self) -> str | None:
210210
return f"{ip_address(raw_candidate)}/32"
211211
return None
212212

213+
def is_reserved(self, peer_ip: str) -> bool:
214+
"""Whether the given IP/network falls inside the WireGuard reserved ranges."""
215+
try:
216+
candidate = ip_network(peer_ip, strict=False)
217+
except ValueError:
218+
return False
219+
candidate_ip = ip_address(candidate.network_address)
220+
return any(candidate_ip in net for net in WIREGUARD_RESERVED)
221+
222+
def conflicts(self, peer_ip: str) -> bool:
223+
"""Whether the given IP/network overlaps already-blocked addresses (used or reserved)."""
224+
try:
225+
candidate = ip_network(peer_ip, strict=False)
226+
except ValueError:
227+
return False
228+
if candidate.version != 4:
229+
return False
230+
for addr in candidate:
231+
if int(addr) in self._blocked:
232+
return True
233+
return False
234+
235+
def reserve(self, peer_ip: str) -> None:
236+
"""Mark every address in the given IPv4 network as blocked so future allocations skip it."""
237+
try:
238+
candidate = ip_network(peer_ip, strict=False)
239+
except ValueError:
240+
return
241+
if candidate.version != 4:
242+
return
243+
for addr in candidate:
244+
self._blocked.add(int(addr))
245+
213246

214247
async def allocate_from_global_pool(
215248
db: AsyncSession,

app/utils/wireguard.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
WireGuardPeerIPAllocator,
1818
allocate_and_validate_peer_ips,
1919
collect_used_peer_networks_from_proxy_settings_rows,
20+
get_global_used_networks,
2021
peer_ips_outside_global_pool,
22+
validate_peer_ips_within_global_pool,
2123
)
2224
from config import wireguard_settings
2325

@@ -141,6 +143,57 @@ async def prepare_wireguard_proxy_settings(
141143
return proxy_settings
142144

143145

146+
async def build_wireguard_peer_ip_allocator(
147+
db: AsyncSession,
148+
*,
149+
exclude_user_id: int | None = None,
150+
) -> "WireGuardPeerIPAllocator":
151+
"""Build a stateful peer-IP allocator pre-loaded with all currently used peer networks.
152+
153+
Used by bulk-creation flows where many users need IPs allocated within a single
154+
transaction; reusing one allocator avoids the duplicate-allocation bug that occurs
155+
when each user independently re-reads the database before any of the new users have
156+
been committed.
157+
"""
158+
used_networks = await get_global_used_networks(db, exclude_user_id=exclude_user_id)
159+
return WireGuardPeerIPAllocator(used_networks)
160+
161+
162+
def prepare_wireguard_proxy_settings_with_allocator(
163+
proxy_settings: ProxyTable,
164+
allocator: "WireGuardPeerIPAllocator",
165+
) -> ProxyTable:
166+
"""Prepare WireGuard settings for a single user against a shared allocator.
167+
168+
Caller is responsible for confirming the user belongs to a WireGuard group and that
169+
`wireguard_settings.enabled` is true. Validates any user-supplied peer_ips against
170+
the allocator's current blocked set, then either reserves them or allocates a fresh
171+
IP. The allocator is mutated to reflect the new reservation.
172+
"""
173+
prepare_wireguard_keys_for_member(proxy_settings)
174+
175+
peer_ips = list(proxy_settings.wireguard.peer_ips or [])
176+
177+
if peer_ips:
178+
validate_peer_ips_within_global_pool(peer_ips)
179+
for peer_ip in peer_ips:
180+
if allocator.is_reserved(peer_ip):
181+
raise ValueError(f"peer IP '{peer_ip}' is reserved")
182+
if allocator.conflicts(peer_ip):
183+
raise ValueError(
184+
f"peer IP/network '{peer_ip}' is already in use by an existing user's peer network"
185+
)
186+
allocator.reserve(peer_ip)
187+
proxy_settings.wireguard.peer_ips = peer_ips
188+
return proxy_settings
189+
190+
candidate = allocator.allocate()
191+
if candidate is None:
192+
raise ValueError("unable to allocate wireguard peer IP")
193+
proxy_settings.wireguard.peer_ips = [candidate]
194+
return proxy_settings
195+
196+
144197
async def prepare_wireguard_keys_only(
145198
db: AsyncSession,
146199
proxy_settings: ProxyTable,

0 commit comments

Comments
 (0)