Skip to content

Commit 07a0651

Browse files
committed
fix(wireguard): implement global IP allocation for peer settings and update tests for persisted IPs
1 parent 535987d commit 07a0651

File tree

2 files changed

+90
-41
lines changed

2 files changed

+90
-41
lines changed

app/utils/wireguard.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from app.db.crud.user import get_users_with_proxy_settings
1111
from app.models.proxy import ProxyTable
1212
from app.utils.crypto import generate_wireguard_keypair, get_wireguard_public_key
13-
from app.utils.ip_pool import validate_peer_ips_globally
13+
from app.utils.ip_pool import allocate_from_global_pool, get_global_used_networks, validate_peer_ips_globally
1414

1515

1616
async def get_wireguard_tags(tags: Iterable[str]) -> list[str]:
@@ -69,9 +69,63 @@ async def prepare_wireguard_proxy_settings(
6969
proxy_settings.wireguard.public_key = get_wireguard_public_key(proxy_settings.wireguard.private_key)
7070

7171
peer_ips = list(proxy_settings.wireguard.peer_ips or [])
72+
if not peer_ips:
73+
# Prefer allocating from the WireGuard interface networks (core config),
74+
# so that user addresses stay within the interface subnet (e.g. 10.8.0.0/24).
75+
inbounds_by_tag = await core_manager.get_inbounds_by_tag()
76+
used_networks = await get_global_used_networks(db, exclude_user_id=exclude_user_id)
77+
78+
candidate = None
79+
for tag in wireguard_tags:
80+
inbound = inbounds_by_tag.get(tag) or {}
81+
addresses = inbound.get("address") or []
82+
if not isinstance(addresses, list):
83+
continue
7284

73-
if peer_ips:
74-
await validate_peer_ips_globally(db, peer_ips, exclude_user_id=exclude_user_id)
85+
for cidr in addresses:
86+
if not isinstance(cidr, str) or not cidr.strip():
87+
continue
88+
try:
89+
from ipaddress import ip_interface, ip_network, ip_address
90+
91+
iface = ip_interface(cidr.strip())
92+
network = ip_network(cidr.strip(), strict=False)
93+
server_ip = iface.ip
94+
except Exception:
95+
continue
96+
97+
# Skip networks that are too small to allocate a peer.
98+
if network.num_addresses <= 2:
99+
continue
100+
101+
# Iterate usable hosts. For IPv4, this excludes network/broadcast automatically.
102+
# For IPv6, it excludes only network address; broadcast doesn't exist.
103+
for host_ip in network.hosts():
104+
if host_ip == server_ip:
105+
continue
106+
# Avoid overlaps with existing assigned networks.
107+
if any(host_ip in used for used in used_networks if used.version == host_ip.version):
108+
continue
109+
# Ensure canonical /32 or /128 peer assignment.
110+
prefix = 32 if host_ip.version == 4 else 128
111+
candidate = f"{ip_address(host_ip)}/{prefix}"
112+
break
113+
114+
if candidate:
115+
break
116+
if candidate:
117+
break
118+
119+
if candidate is None:
120+
# Fallback: allocate from global pool if interface networks are missing/invalid/full.
121+
candidate = await allocate_from_global_pool(db, exclude_user_id=exclude_user_id)
122+
123+
if candidate is None:
124+
raise ValueError("unable to allocate wireguard peer IP")
125+
126+
peer_ips = [candidate]
127+
128+
await validate_peer_ips_globally(db, peer_ips, exclude_user_id=exclude_user_id)
75129

76130
proxy_settings.wireguard.peer_ips = peer_ips
77131
return proxy_settings

tests/api/test_user.py

Lines changed: 33 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -748,11 +748,13 @@ def test_user_can_be_assigned_to_multiple_wireguard_interfaces(access_token):
748748
# Get the auto-allocated peer IPs
749749
peer_ips = user["proxy_settings"]["wireguard"]["peer_ips"]
750750

751-
# peer_ips should be empty in user settings (dynamically generated during subscription)
751+
# peer_ips should be persisted in user settings for node sync
752752
assert isinstance(peer_ips, list)
753-
assert len(peer_ips) == 0
753+
assert len(peer_ips) == 1
754+
assert peer_ips[0].startswith("10.")
755+
assert peer_ips[0].endswith("/32")
754756

755-
# Verify that peer IPs are dynamically generated during subscription
757+
# Verify that WireGuard links use the persisted peer IPs
756758
links_response = client.get(f"{user['subscription_url']}/links")
757759
assert links_response.status_code == status.HTTP_200_OK
758760

@@ -763,29 +765,22 @@ def test_user_can_be_assigned_to_multiple_wireguard_interfaces(access_token):
763765
parsed = urlsplit(line.strip())
764766
links_by_endpoint[f"{parsed.hostname}:{parsed.port}"] = parse_qs(parsed.query)
765767

766-
# Both endpoints should have peer IPs generated from interface addresses
767-
# User ID 17 with first_interface address "10.30.10.1/24" should get "10.30.10.18/32"
768-
# User ID 17 with second_interface address "10.40.10.1/24" should get "10.40.10.18/32"
768+
# Both endpoints should have the same persisted peer IPs
769769
first_address = links_by_endpoint[f"{first_endpoint}:51820"]["address"][0]
770770
second_address = links_by_endpoint[f"{second_endpoint}:51821"]["address"][0]
771-
772-
# Verify IPs are from correct ranges
773-
assert first_address.startswith("10.30.10.")
774-
assert second_address.startswith("10.40.10.")
775-
assert first_address.endswith("/32")
776-
assert second_address.endswith("/32")
771+
expected_address = ",".join(peer_ips)
772+
assert first_address == expected_address
773+
assert second_address == expected_address
777774

778775
# Verify WireGuard subscription contains the peer IPs
779776
wireguard_response = client.get(f"{user['subscription_url']}/wireguard")
780777
assert wireguard_response.status_code == status.HTTP_200_OK
781778
config_bodies = extract_wireguard_config_bodies(wireguard_response)
782779
assert len(config_bodies) == 2
783780

784-
# Verify each config has correct Address from respective interface
781+
# Verify each config has the same persisted Address
785782
for body in config_bodies:
786-
# Should have Address from one of the interfaces
787-
assert "Address = 10.30.10." in body or "Address = 10.40.10." in body
788-
assert "/32" in body
783+
assert f"Address = {', '.join(peer_ips)}" in body
789784

790785
expected_endpoints = {f"Endpoint = {first_endpoint}:51820", f"Endpoint = {second_endpoint}:51821"}
791786
actual_endpoints = set()
@@ -797,14 +792,14 @@ def test_user_can_be_assigned_to_multiple_wireguard_interfaces(access_token):
797792

798793
assert actual_endpoints == expected_endpoints
799794

800-
# Test no-op update preserves empty peer_ips
795+
# Test no-op update preserves allocated peer_ips
801796
update_response = client.put(
802797
f"/api/user/{user['username']}",
803798
headers=auth_headers(access_token),
804799
json={"note": "keep existing wireguard allocations"},
805800
)
806801
assert update_response.status_code == status.HTTP_200_OK
807-
assert update_response.json()["proxy_settings"]["wireguard"]["peer_ips"] == []
802+
assert update_response.json()["proxy_settings"]["wireguard"]["peer_ips"] == peer_ips
808803
finally:
809804
delete_user(access_token, user["username"])
810805
delete_group(access_token, group["id"])
@@ -1668,32 +1663,32 @@ def test_wireguard_peer_ip_global_pool_and_validation(access_token):
16681663
assert response.status_code == status.HTTP_400_BAD_REQUEST
16691664
assert "reserved for the server" in response.json()["detail"]
16701665

1671-
# Test 2: Create user without specifying peer IPs - should get IP dynamically during subscription
1666+
# Test 2: Create user without specifying peer IPs - should get persisted auto-allocation
16721667
user1 = create_user(
16731668
access_token,
16741669
group_ids=[group["id"]],
16751670
payload={"username": unique_name("wg_auto_ip_user1")},
16761671
)
1677-
# peer_ips should be empty in user settings
1678-
assert user1["proxy_settings"]["wireguard"]["peer_ips"] == []
1679-
1680-
# But subscription should work with dynamically allocated IP from global pool
1672+
peer_ips1 = user1["proxy_settings"]["wireguard"]["peer_ips"]
1673+
assert isinstance(peer_ips1, list)
1674+
assert len(peer_ips1) == 1
1675+
assert peer_ips1[0].startswith("10.")
1676+
assert peer_ips1[0].endswith("/32")
1677+
assert peer_ips1[0] != "10.0.0.1/32" # Should not be the reserved server IP
1678+
1679+
# Subscription should use persisted allocation
16811680
links_response = client.get(f"{user1['subscription_url']}/links")
16821681
assert links_response.status_code == status.HTTP_200_OK
16831682

1684-
# Should have a wireguard link with an IP from global pool (10.0.0.0/8)
1683+
# Should have a wireguard link with the persisted IP
16851684
link = links_response.text.strip()
16861685
assert link.startswith("wireguard://")
16871686
parsed = urlsplit(link)
16881687
query = parse_qs(parsed.query)
16891688
peer_ip1 = query.get("address", [""])[0]
1690-
# Should be an IP from 10.0.0.0/8 pool
1691-
assert peer_ip1.startswith("10.")
1692-
assert peer_ip1.endswith("/32")
1693-
assert peer_ip1 != "10.0.0.1/32" # Should not be the reserved server IP
1689+
assert peer_ip1 == peer_ips1[0]
16941690

1695-
# Test 3: Manual peer_ip is validated only against stored/manual peer_ips.
1696-
# Dynamic subscription-generated addresses are not persisted in user proxy settings.
1691+
# Test 3: Manual peer_ip is validated against stored peer_ips, including auto-allocated ones.
16971692
response = client.post(
16981693
"/api/user",
16991694
headers=auth_headers(access_token),
@@ -1707,28 +1702,28 @@ def test_wireguard_peer_ip_global_pool_and_validation(access_token):
17071702
"group_ids": [group["id"]],
17081703
},
17091704
)
1710-
assert response.status_code == status.HTTP_201_CREATED
1711-
duplicate_user = response.json()
1705+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1706+
assert "already in use" in response.json()["detail"]
17121707

1713-
# Test 4: Create another user without specifying peer IPs - should get different IP dynamically
1708+
# Test 4: Create another user without specifying peer IPs - should get different persisted IP
17141709
user2 = create_user(
17151710
access_token,
17161711
group_ids=[group["id"]],
17171712
payload={"username": unique_name("wg_auto_ip_user2")},
17181713
)
1719-
# peer_ips should be empty in user settings
1720-
assert user2["proxy_settings"]["wireguard"]["peer_ips"] == []
1714+
peer_ips2 = user2["proxy_settings"]["wireguard"]["peer_ips"]
1715+
assert isinstance(peer_ips2, list)
1716+
assert len(peer_ips2) == 1
17211717

1722-
# Get dynamically allocated IP from subscription
1718+
# Get allocated IP from subscription
17231719
links_response2 = client.get(f"{user2['subscription_url']}/links")
17241720
assert links_response2.status_code == status.HTTP_200_OK
17251721
link2 = links_response2.text.strip()
17261722
assert link2.startswith("wireguard://")
17271723
parsed2 = urlsplit(link2)
17281724
query2 = parse_qs(parsed2.query)
17291725
peer_ip2 = query2.get("address", [""])[0]
1730-
assert peer_ip2.startswith("10.")
1731-
assert peer_ip2.endswith("/32")
1726+
assert peer_ip2 == peer_ips2[0]
17321727
# Different users should get different IPs
17331728
assert peer_ip2 != peer_ip1
17341729

0 commit comments

Comments
 (0)