@@ -977,19 +900,9 @@ export default function BulkFlow({ operationType }: BulkFlowProps) {
{operationType === 'data' && t('bulk.dataLimit')}
{operationType === 'expire' && t('bulk.expireDate')}
{operationType === 'groups' && t('bulk.groups')}
- {operationType === 'wireguard' && t('bulk.wireguardPeerIps')}
- {operationType === 'wireguard' && (
-
{t('status', { defaultValue: 'Status' })}:
{selectedStatuses.map(status => t(`status.${status}`, { defaultValue: status.replace(/_/g, ' ') })).join(', ')}
@@ -1157,11 +1070,9 @@ export default function BulkFlow({ operationType }: BulkFlowProps) {
{t('cancel', { defaultValue: 'Cancel' })}
- {proxyMutation.isPending || dataMutation.isPending || expireMutation.isPending || addGroupsMutation.isPending || removeGroupsMutation.isPending || wireguardPeerIpsMutation.isPending
+ {proxyMutation.isPending || dataMutation.isPending || expireMutation.isPending || addGroupsMutation.isPending || removeGroupsMutation.isPending
? t('applying', { defaultValue: 'Applying...' })
: t('confirm', { defaultValue: 'Confirm' })}
diff --git a/dashboard/src/features/users/dialogs/user-modal.tsx b/dashboard/src/features/users/dialogs/user-modal.tsx
index ea8120029..cab52c3b7 100644
--- a/dashboard/src/features/users/dialogs/user-modal.tsx
+++ b/dashboard/src/features/users/dialogs/user-modal.tsx
@@ -1231,7 +1231,6 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
wireguard: {
private_key: keyPair.privateKey,
public_key: keyPair.publicKey,
- peer_ips: form.getValues('proxy_settings.wireguard.peer_ips') ?? [],
},
}
form.setValue('proxy_settings', newSettings as any, { shouldDirty: true, shouldValidate: true })
@@ -1258,10 +1257,6 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
[form, handleFieldChange],
)
- const parseWireGuardPeerIps = React.useCallback((value: string) => {
- return value.split('\n')
- }, [])
-
const hasMeaningfulProxyValue = React.useCallback((value: unknown): boolean => {
if (Array.isArray(value)) {
return value.some(item => hasMeaningfulProxyValue(item))
@@ -1285,18 +1280,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
const cleanedProtocolSettings = Object.entries(settings as Record
).reduce(
(protocolAcc, [key, value]) => {
if (Array.isArray(value)) {
- const cleanedList = value
- .flatMap(item => {
- if (typeof item !== 'string') {
- return [item]
- }
- if (protocol === 'wireguard' && key === 'peer_ips') {
- return item.split(',')
- }
- return [item]
- })
- .map(item => (typeof item === 'string' ? item.trim() : item))
- .filter(item => hasMeaningfulProxyValue(item))
+ const cleanedList = value.map(item => (typeof item === 'string' ? item.trim() : item)).filter(item => hasMeaningfulProxyValue(item))
if (cleanedList.length > 0) {
protocolAcc[key] = cleanedList
@@ -2135,34 +2119,6 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
)}
/>
- (
-
- {t('userDialog.proxySettings.wireguardPeerIps', { defaultValue: 'WireGuard Peer IPs' })}
-
-
-
- {t('userDialog.proxySettings.peerIpsHint', {
- defaultValue: 'Leave empty to auto-assign from the global WireGuard peer pool. For manual entries, enter one CIDR per line, and keep each value within that pool.',
- })}
-
-
-
- )}
- />
diff --git a/dashboard/src/features/users/forms/user-form.ts b/dashboard/src/features/users/forms/user-form.ts
index 6bb606689..d81099412 100644
--- a/dashboard/src/features/users/forms/user-form.ts
+++ b/dashboard/src/features/users/forms/user-form.ts
@@ -26,7 +26,6 @@ export const hysteriaSettingsSchema = z.object({
export const wireguardSettingsSchema = z.object({
private_key: z.string().nullable().optional(),
public_key: z.string().nullable().optional(),
- peer_ips: z.array(z.string()).optional(),
})
export const proxyTableInputSchema = z.object({
vmess: vMessSettingsSchema.optional(),
@@ -121,7 +120,6 @@ export const getDefaultUserForm = async () => {
wireguard: {
private_key: undefined,
public_key: undefined,
- peer_ips: [],
},
hysteria: {
auth: undefined,
diff --git a/dashboard/src/pages/_dashboard.bulk.tsx b/dashboard/src/pages/_dashboard.bulk.tsx
index ec18fae8c..953596b15 100644
--- a/dashboard/src/pages/_dashboard.bulk.tsx
+++ b/dashboard/src/pages/_dashboard.bulk.tsx
@@ -2,7 +2,7 @@ import PageHeader from '@/components/layout/page-header'
import { useAdmin } from '@/hooks/use-admin'
import PageTransition from '@/components/layout/page-transition'
import { getDocsUrl } from '@/utils/docs-url'
-import { ArrowUpDown, Calendar, Lock, Group, Network, UserPlus } from 'lucide-react'
+import { ArrowUpDown, Calendar, Lock, Group, UserPlus } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useLocation, useNavigate } from 'react-router'
@@ -14,7 +14,6 @@ const allTabs = [
{ id: 'expire', label: 'bulk.expireDate', icon: Calendar, url: '/bulk/expire' },
{ id: 'data', label: 'bulk.dataLimit', icon: ArrowUpDown, url: '/bulk/data' },
{ id: 'proxy', label: 'bulk.proxySettings', icon: Lock, url: '/bulk/proxy' },
- { id: 'wireguard', label: 'bulk.wireguardPeerIps', icon: Network, url: '/bulk/wireguard' },
]
const BulkPage = () => {
@@ -59,7 +58,6 @@ const BulkPage = () => {
'/bulk/expire': { title: 'bulk.expireDate', description: 'bulk.expireDateDesc' },
'/bulk/data': { title: 'bulk.dataLimit', description: 'bulk.dataLimitDesc' },
'/bulk/proxy': { title: 'bulk.proxySettings', description: 'bulk.proxySettingsDesc' },
- '/bulk/wireguard': { title: 'bulk.wireguardPeerIps', description: 'bulk.wireguardPeerIpsDesc' },
}
const header = pathToHeader[location.pathname] || pathToHeader['/bulk']
diff --git a/dashboard/src/pages/_dashboard.bulk.wireguard.tsx b/dashboard/src/pages/_dashboard.bulk.wireguard.tsx
deleted file mode 100644
index b3ee3003d..000000000
--- a/dashboard/src/pages/_dashboard.bulk.wireguard.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-'use client'
-
-import BulkFlow from '@/features/bulk/components/bulk-flow'
-
-export default function BulkWireguardPage() {
- return
-}
diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts
index 2cd64af54..1ed4dd61c 100644
--- a/dashboard/src/service/api/index.ts
+++ b/dashboard/src/service/api/index.ts
@@ -578,15 +578,6 @@ export interface WireGuardSettings {
peer_ips?: string[]
}
-export interface WireGuardPeerIPsReallocateResponse {
- wireguard_inbound_tags: number
- candidates: number
- updated: number
- dry_run: boolean
- sample_usernames: string[]
- affected_users: number
-}
-
export type WireGuardHostOverridesDns = string[] | null
export type WireGuardHostOverridesKeepaliveSeconds = number | null
@@ -3036,25 +3027,6 @@ export type CRUDPermissionsCreateAnyOf = { [key: string]: PermissionScope | numb
export type CRUDPermissionsCreate = boolean | CRUDPermissionsCreateAnyOf | null
-export type BulkWireGuardPeerIPsExpireBefore = string | null
-
-export type BulkWireGuardPeerIPsExpireAfter = string | null
-
-/**
- * Re-seat WireGuard peer IPs (same scoping as BulkUser: users, admins, group_ids, status).
- */
-export interface BulkWireGuardPeerIPs {
- dry_run?: boolean
- group_ids?: number[]
- admins?: number[]
- users?: number[]
- status?: UserStatus[]
- expire_after?: BulkWireGuardPeerIPsExpireAfter
- expire_before?: BulkWireGuardPeerIPsExpireBefore
- confirm?: boolean
- replace_all?: boolean
-}
-
export interface BulkUsersSetOwner {
ids?: number[]
admin_username: string
@@ -13481,62 +13453,6 @@ export const useBulkModifyUsersProxySettings = <
return useMutation(mutationOptions)
}
-/**
- * Same scoping as other bulk user actions (users, admins, group_ids, optional status filter). non-owner admins only affect their own users.
- * @summary Bulk reallocate WireGuard peer IPs
- */
-export const bulkReallocateWireguardPeerIps = (bulkWireGuardPeerIPs: BodyType, signal?: AbortSignal) => {
- return orvalFetcher({
- url: `/api/users/bulk/wireguard/reallocate-peer-ips`,
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- data: bulkWireGuardPeerIPs,
- signal,
- })
-}
-
-export const getBulkReallocateWireguardPeerIpsMutationOptions = <
- TData = Awaited>,
- TError = ErrorType,
- TContext = unknown,
->(options?: {
- mutation?: UseMutationOptions }, TContext>
-}) => {
- const mutationKey = ['bulkReallocateWireguardPeerIps']
- const { mutation: mutationOptions } = options
- ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
- ? options
- : { ...options, mutation: { ...options.mutation, mutationKey } }
- : { mutation: { mutationKey } }
-
- const mutationFn: MutationFunction>, { data: BodyType }> = props => {
- const { data } = props ?? {}
-
- return bulkReallocateWireguardPeerIps(data)
- }
-
- return { mutationFn, ...mutationOptions } as UseMutationOptions }, TContext>
-}
-
-export type BulkReallocateWireguardPeerIpsMutationResult = NonNullable>>
-export type BulkReallocateWireguardPeerIpsMutationBody = BodyType
-export type BulkReallocateWireguardPeerIpsMutationError = ErrorType
-
-/**
- * @summary Bulk reallocate WireGuard peer IPs
- */
-export const useBulkReallocateWireguardPeerIps = <
- TData = Awaited>,
- TError = ErrorType,
- TContext = unknown,
->(options?: {
- mutation?: UseMutationOptions }, TContext>
-}): UseMutationResult }, TContext> => {
- const mutationOptions = getBulkReallocateWireguardPeerIpsMutationOptions(options)
-
- return useMutation(mutationOptions)
-}
-
/**
* Provides a subscription link based on the user agent (Clash, V2Ray, etc.).
* @summary User Subscription
diff --git a/dashboard/src/utils/rbac.ts b/dashboard/src/utils/rbac.ts
index 19013d6a7..d066f20f0 100644
--- a/dashboard/src/utils/rbac.ts
+++ b/dashboard/src/utils/rbac.ts
@@ -95,7 +95,6 @@ export const canAccessRoute = (admin: AdminDetails | null | undefined, pathname:
}
if (pathname.startsWith('/bulk/create') || pathname === '/bulk') return hasPermission(admin, 'users', 'create') && canReadResourcePage(admin, 'templates')
if (pathname.startsWith('/bulk/groups')) return hasScopeAll(admin, 'users', 'update') && hasPermission(admin, 'groups', 'read')
- if (pathname.startsWith('/bulk/expire') || pathname.startsWith('/bulk/data') || pathname.startsWith('/bulk/proxy') || pathname.startsWith('/bulk/wireguard'))
- return hasScopeAll(admin, 'users', 'update')
+ if (pathname.startsWith('/bulk/expire') || pathname.startsWith('/bulk/data') || pathname.startsWith('/bulk/proxy')) return hasScopeAll(admin, 'users', 'update')
return true
}
diff --git a/tests/api/test_bulk.py b/tests/api/test_bulk.py
index 52c5242c4..c91fa2496 100644
--- a/tests/api/test_bulk.py
+++ b/tests/api/test_bulk.py
@@ -46,21 +46,6 @@ async def _set_usage():
asyncio.run(_set_usage())
-def set_user_wireguard_peer_ips(username: str, peer_ips: list[str]) -> None:
- async def _set_peer_ips():
- async with TestSession() as session:
- result = await session.execute(select(User).where(User.username == username))
- db_user = result.scalar_one()
- proxy_settings = dict(db_user.proxy_settings or {})
- wireguard_settings = dict(proxy_settings.get("wireguard") or {})
- wireguard_settings["peer_ips"] = peer_ips
- proxy_settings["wireguard"] = wireguard_settings
- db_user.proxy_settings = proxy_settings
- await session.commit()
-
- asyncio.run(_set_peer_ips())
-
-
def set_user_wireguard_keys(username: str, private_key: str, public_key: str) -> None:
async def _set_keys():
async with TestSession() as session:
@@ -118,7 +103,7 @@ def test_bulk_add_wireguard_group_allocates_existing_user_peer_ips(access_token)
"interface_name": interface_name,
"private_key": interface_private_key,
"listen_port": 51820,
- "address": [],
+ "address": ["10.61.0.1/24"],
},
type="wg",
fallbacks=[],
@@ -688,239 +673,3 @@ def test_bulk_set_owner_by_ids(access_token):
finally:
cleanup(access_token, core, groups, users)
delete_admin(access_token, new_owner["username"])
-
-
-def test_bulk_wireguard_reallocate_peer_ips_accepts_status_filter(access_token):
- """Dry-run accepts optional status filter like other bulk user actions."""
- response = client.post(
- "/api/users/bulk/wireguard/reallocate-peer-ips",
- headers={"Authorization": f"Bearer {access_token}"},
- json={
- "dry_run": True,
- "confirm": False,
- "status": ["active", "disabled"],
- },
- )
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["dry_run"] is True
- assert "candidates" in data
- assert "affected_users" in data
-
-
-def test_bulk_wireguard_reallocate_peer_ips_repairs_duplicates(access_token):
- interface_private_key, _ = generate_wireguard_keypair()
- interface_name = unique_name("wg_bulk_dup")
- core = create_core(
- access_token,
- name=unique_name("wireguard_bulk_dup_core"),
- config={
- "interface_name": interface_name,
- "private_key": interface_private_key,
- "listen_port": 51820,
- "address": [],
- },
- type="wg",
- fallbacks=[],
- )
- group = create_group(access_token, name=unique_name("wg_bulk_dup_group"), inbound_tags=[interface_name])
-
- users: list[dict] = []
- try:
- first_user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_dup_keep")},
- )
- second_user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_dup_realloc")},
- )
- users = [first_user, second_user]
-
- duplicate_peer_ip = first_user["proxy_settings"]["wireguard"]["peer_ips"][0]
- set_user_wireguard_peer_ips(second_user["username"], [duplicate_peer_ip])
-
- dry_run_response = client.post(
- "/api/users/bulk/wireguard/reallocate-peer-ips",
- headers={"Authorization": f"Bearer {access_token}"},
- json={"dry_run": True, "confirm": False, "users": [second_user["id"]]},
- )
- assert dry_run_response.status_code == status.HTTP_200_OK
- assert dry_run_response.json()["candidates"] == 1
- assert second_user["username"] in dry_run_response.json()["sample_usernames"]
-
- response = client.post(
- "/api/users/bulk/wireguard/reallocate-peer-ips",
- headers={"Authorization": f"Bearer {access_token}"},
- json={"confirm": True, "users": [second_user["id"]]},
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.json()["updated"] == 1
-
- first_response = client.get(
- f"/api/user/{first_user['username']}",
- headers={"Authorization": f"Bearer {access_token}"},
- )
- second_response = client.get(
- f"/api/user/{second_user['username']}",
- headers={"Authorization": f"Bearer {access_token}"},
- )
- assert first_response.status_code == status.HTTP_200_OK
- assert second_response.status_code == status.HTTP_200_OK
-
- first_peer_ips = first_response.json()["proxy_settings"]["wireguard"]["peer_ips"]
- second_peer_ips = second_response.json()["proxy_settings"]["wireguard"]["peer_ips"]
- assert first_peer_ips == [duplicate_peer_ip]
- assert len(second_peer_ips) == 1
- assert second_peer_ips[0] != duplicate_peer_ip
- assert second_peer_ips[0].startswith("10.")
- assert second_peer_ips[0].endswith("/32")
- finally:
- cleanup(access_token, core, [group], users)
-
-
-def test_bulk_wireguard_reallocate_peer_ips_updates_multiple_selected_users(access_token):
- interface_private_key, _ = generate_wireguard_keypair()
- interface_name = unique_name("wg_bulk_multi")
- core = create_core(
- access_token,
- name=unique_name("wireguard_bulk_multi_core"),
- config={
- "interface_name": interface_name,
- "private_key": interface_private_key,
- "listen_port": 51820,
- "address": [],
- },
- type="wg",
- fallbacks=[],
- )
- group = create_group(access_token, name=unique_name("wg_bulk_multi_group"), inbound_tags=[interface_name])
-
- users: list[dict] = []
- try:
- keep_user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_multi_keep")},
- )
- duplicate_user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_multi_duplicate")},
- )
- missing_user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_multi_missing")},
- )
- users = [keep_user, duplicate_user, missing_user]
-
- duplicate_peer_ip = keep_user["proxy_settings"]["wireguard"]["peer_ips"][0]
- set_user_wireguard_peer_ips(duplicate_user["username"], [duplicate_peer_ip])
- set_user_wireguard_peer_ips(missing_user["username"], [])
-
- selected_user_ids = [duplicate_user["id"], missing_user["id"]]
- dry_run_response = client.post(
- "/api/users/bulk/wireguard/reallocate-peer-ips",
- headers={"Authorization": f"Bearer {access_token}"},
- json={"dry_run": True, "confirm": False, "users": selected_user_ids},
- )
- assert dry_run_response.status_code == status.HTTP_200_OK
- assert dry_run_response.json()["candidates"] == 2
-
- response = client.post(
- "/api/users/bulk/wireguard/reallocate-peer-ips",
- headers={"Authorization": f"Bearer {access_token}"},
- json={"confirm": True, "users": selected_user_ids},
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.json()["updated"] == 2
-
- peer_ips_by_username = {}
- for user in users:
- user_response = client.get(
- f"/api/user/{user['username']}",
- headers={"Authorization": f"Bearer {access_token}"},
- )
- assert user_response.status_code == status.HTTP_200_OK
- peer_ips = user_response.json()["proxy_settings"]["wireguard"]["peer_ips"]
- assert len(peer_ips) == 1
- peer_ips_by_username[user["username"]] = peer_ips[0]
-
- assert peer_ips_by_username[keep_user["username"]] == duplicate_peer_ip
- assert peer_ips_by_username[duplicate_user["username"]] != duplicate_peer_ip
- assert len(set(peer_ips_by_username.values())) == 3
- finally:
- cleanup(access_token, core, [group], users)
-
-
-def test_bulk_wireguard_reallocate_peer_ips_repairs_duplicate_keys(access_token):
- interface_private_key, _ = generate_wireguard_keypair()
- duplicate_private_key, duplicate_public_key = generate_wireguard_keypair()
- interface_name = unique_name("wg_bulk_key_dup")
- core = create_core(
- access_token,
- name=unique_name("wireguard_bulk_key_dup_core"),
- config={
- "interface_name": interface_name,
- "private_key": interface_private_key,
- "listen_port": 51820,
- "address": [],
- },
- type="wg",
- fallbacks=[],
- )
- group = create_group(access_token, name=unique_name("wg_bulk_key_dup_group"), inbound_tags=[interface_name])
-
- users: list[dict] = []
- try:
- first_user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_key_dup_keep")},
- )
- second_user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_key_dup_rekey")},
- )
- users = [first_user, second_user]
- set_user_wireguard_keys(first_user["username"], duplicate_private_key, duplicate_public_key)
- set_user_wireguard_keys(second_user["username"], duplicate_private_key, duplicate_public_key)
-
- dry_run_response = client.post(
- "/api/users/bulk/wireguard/reallocate-peer-ips",
- headers={"Authorization": f"Bearer {access_token}"},
- json={"dry_run": True, "confirm": False, "users": [second_user["id"]]},
- )
- assert dry_run_response.status_code == status.HTTP_200_OK
- assert dry_run_response.json()["candidates"] == 1
-
- response = client.post(
- "/api/users/bulk/wireguard/reallocate-peer-ips",
- headers={"Authorization": f"Bearer {access_token}"},
- json={"confirm": True, "users": [second_user["id"]]},
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.json()["updated"] == 1
-
- first_response = client.get(
- f"/api/user/{first_user['username']}",
- headers={"Authorization": f"Bearer {access_token}"},
- )
- second_response = client.get(
- f"/api/user/{second_user['username']}",
- headers={"Authorization": f"Bearer {access_token}"},
- )
- assert first_response.status_code == status.HTTP_200_OK
- assert second_response.status_code == status.HTTP_200_OK
-
- first_wireguard = first_response.json()["proxy_settings"]["wireguard"]
- second_wireguard = second_response.json()["proxy_settings"]["wireguard"]
- assert first_wireguard["public_key"] == duplicate_public_key
- assert second_wireguard["public_key"] != duplicate_public_key
- assert second_wireguard["private_key"] != duplicate_private_key
- finally:
- cleanup(access_token, core, [group], users)
diff --git a/tests/api/test_user.py b/tests/api/test_user.py
index cd39f47c3..e9e078875 100644
--- a/tests/api/test_user.py
+++ b/tests/api/test_user.py
@@ -1237,57 +1237,6 @@ def test_wireguard_subscription_outputs_are_consistent(access_token):
delete_core(access_token, core["id"])
-def test_wireguard_disabled_skips_peer_ip_allocation(access_token, monkeypatch):
- monkeypatch.setattr("config.wireguard_settings.enabled", False)
-
- interface_private_key, _ = generate_wireguard_keypair()
- interface_name = unique_name("wg_disabled")
- endpoint = "198.51.100.20"
-
- core = create_core(
- access_token,
- name=unique_name("wireguard_disabled_core"),
- config={
- "interface_name": interface_name,
- "private_key": interface_private_key,
- "listen_port": 51820,
- "address": ["10.40.0.1/24"],
- },
- type="wg",
- fallbacks=[],
- )
- host_response = client.post(
- "/api/host",
- headers=auth_headers(access_token),
- json={
- "remark": "Disabled WG {USERNAME}",
- "address": [endpoint],
- "port": 51820,
- "inbound_tag": interface_name,
- "priority": 1,
- },
- )
- assert host_response.status_code == status.HTTP_201_CREATED
- host_id = host_response.json()["id"]
- group = create_group(access_token, name=unique_name("wg_disabled_group"), inbound_tags=[interface_name])
- user = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_disabled_user")})
-
- try:
- assert user["proxy_settings"]["wireguard"]["private_key"] is None
- assert user["proxy_settings"]["wireguard"]["public_key"] is None
- assert user["proxy_settings"]["wireguard"]["peer_ips"] == []
-
- links_response = client.get(f"{user['subscription_url']}/links")
-
- assert links_response.status_code == status.HTTP_200_OK
- assert "wireguard://" not in links_response.text
- finally:
- delete_user(access_token, user["username"])
- delete_group(access_token, group["id"])
- client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token))
- delete_core(access_token, core["id"])
-
-
def test_xray_subscription_includes_wireguard_outbound(access_token):
interface_private_key, _ = generate_wireguard_keypair()
interface_public_key = get_wireguard_public_key(interface_private_key)
@@ -1691,13 +1640,12 @@ def test_user_can_be_assigned_to_multiple_wireguard_interfaces(access_token):
user = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_multi_user")})
try:
- # Single global-pool allocation; same peer address on every WG inbound
+ # One allocation per subnet: a distinct peer address on each WG interface
peer_ips = user["proxy_settings"]["wireguard"]["peer_ips"]
assert isinstance(peer_ips, list)
- assert len(peer_ips) == 1
- assert peer_ips[0].startswith("10.")
- assert peer_ips[0].endswith("/32")
+ # .1 is the server on both interfaces, so the first user gets .2 in each subnet
+ assert sorted(peer_ips) == ["10.30.10.2/32", "10.40.10.2/32"]
links_response = client.get(f"{user['subscription_url']}/links")
assert links_response.status_code == status.HTTP_200_OK
@@ -1709,20 +1657,22 @@ def test_user_can_be_assigned_to_multiple_wireguard_interfaces(access_token):
parsed = urlsplit(line.strip())
links_by_endpoint[f"{parsed.hostname}:{parsed.port}"] = parse_qs(parsed.query)
- expected_addr = ",".join(peer_ips)
- assert links_by_endpoint[f"{first_endpoint}:51820"]["address"] == [expected_addr]
- assert links_by_endpoint[f"{second_endpoint}:51821"]["address"] == [expected_addr]
+ # each interface's config only carries the peer IP from its own subnet
+ assert links_by_endpoint[f"{first_endpoint}:51820"]["address"] == ["10.30.10.2/32"]
+ assert links_by_endpoint[f"{second_endpoint}:51821"]["address"] == ["10.40.10.2/32"]
wireguard_response = client.get(f"{user['subscription_url']}/wireguard")
assert wireguard_response.status_code == status.HTTP_200_OK
config_bodies = extract_wireguard_config_bodies(wireguard_response)
assert len(config_bodies) == 2
- addr_line = f"Address = {', '.join(peer_ips)}"
expected_endpoints = {f"Endpoint = {first_endpoint}:51820", f"Endpoint = {second_endpoint}:51821"}
actual_endpoints = set()
for body in config_bodies:
- assert addr_line in body
+ if f"Endpoint = {first_endpoint}:51820" in body:
+ assert "Address = 10.30.10.2/32" in body
+ else:
+ assert "Address = 10.40.10.2/32" in body
for endpoint in expected_endpoints:
if endpoint in body:
actual_endpoints.add(endpoint)
@@ -1745,165 +1695,6 @@ def test_user_can_be_assigned_to_multiple_wireguard_interfaces(access_token):
delete_core(access_token, second_core["id"])
-def test_shared_wireguard_peer_ips_can_be_applied_to_multiple_interfaces(access_token):
- first_private_key, _ = generate_wireguard_keypair()
- second_private_key, _ = generate_wireguard_keypair()
- first_interface = unique_name("wg_multi_explicit_a")
- second_interface = unique_name("wg_multi_explicit_b")
- first_endpoint = "198.51.100.23"
- second_endpoint = "198.51.100.24"
- shared_peer_ips = ["10.30.20.9/32"]
- updated_shared_peer_ips = ["10.30.20.10/32"]
-
- first_core = create_core(
- access_token,
- name=unique_name("wireguard_multi_explicit_core_a"),
- config={
- "interface_name": first_interface,
- "private_key": first_private_key,
- "listen_port": 51820,
- "address": ["10.30.20.1/24"],
- },
- type="wg",
- fallbacks=[],
- )
- second_core = create_core(
- access_token,
- name=unique_name("wireguard_multi_explicit_core_b"),
- config={
- "interface_name": second_interface,
- "private_key": second_private_key,
- "listen_port": 51821,
- "address": ["10.40.20.1/24"],
- },
- type="wg",
- fallbacks=[],
- )
-
- first_host_response = client.post(
- "/api/host",
- headers=auth_headers(access_token),
- json={
- "remark": "WG Multi Shared A {USERNAME}",
- "address": [first_endpoint],
- "port": 51820,
- "inbound_tag": first_interface,
- "priority": 1,
- },
- )
- assert first_host_response.status_code == status.HTTP_201_CREATED
- first_host_id = first_host_response.json()["id"]
-
- second_host_response = client.post(
- "/api/host",
- headers=auth_headers(access_token),
- json={
- "remark": "WG Multi Shared B {USERNAME}",
- "address": [second_endpoint],
- "port": 51821,
- "inbound_tag": second_interface,
- "priority": 2,
- },
- )
- assert second_host_response.status_code == status.HTTP_201_CREATED
- second_host_id = second_host_response.json()["id"]
-
- group = create_group(
- access_token,
- name=unique_name("wg_multi_explicit_group"),
- inbound_tags=[first_interface, second_interface],
- )
- user = None
-
- try:
- user = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={
- "username": unique_name("wg_multi_shared_user"),
- "proxy_settings": {
- "wireguard": {
- "peer_ips": shared_peer_ips,
- }
- },
- },
- )
-
- # With simplified model, peer_ips are stored directly
- wireguard_settings = user["proxy_settings"]["wireguard"]
- assert wireguard_settings["peer_ips"] == shared_peer_ips
-
- # Verify WireGuard links use the shared peer IPs
- links_response = client.get(f"{user['subscription_url']}/links")
- assert links_response.status_code == status.HTTP_200_OK
-
- links_by_endpoint: dict[str, dict[str, list[str]]] = {}
- for line in links_response.text.splitlines():
- if not line.startswith("wireguard://"):
- continue
- parsed = urlsplit(line.strip())
- links_by_endpoint[f"{parsed.hostname}:{parsed.port}"] = parse_qs(parsed.query)
-
- # Both endpoints should have the same peer IPs
- expected_address = ",".join(shared_peer_ips)
- assert links_by_endpoint[f"{first_endpoint}:51820"]["address"] == [expected_address]
- assert links_by_endpoint[f"{second_endpoint}:51821"]["address"] == [expected_address]
-
- # Verify WireGuard subscription contains the shared peer IPs
- wireguard_response = client.get(f"{user['subscription_url']}/wireguard")
- assert wireguard_response.status_code == status.HTTP_200_OK
- config_bodies = extract_wireguard_config_bodies(wireguard_response)
- assert len(config_bodies) == 2
-
- expected_address = f"Address = {', '.join(shared_peer_ips)}"
- expected_endpoints = {f"Endpoint = {first_endpoint}:51820", f"Endpoint = {second_endpoint}:51821"}
- actual_endpoints = set()
-
- for body in config_bodies:
- assert expected_address in body
- for endpoint in expected_endpoints:
- if endpoint in body:
- actual_endpoints.add(endpoint)
-
- assert actual_endpoints == expected_endpoints
-
- # Test updating with new peer_ips
- updated_proxy_settings = deepcopy(user["proxy_settings"])
- updated_proxy_settings["wireguard"]["peer_ips"] = updated_shared_peer_ips
- update_response = client.put(
- f"/api/user/{user['username']}",
- headers=auth_headers(access_token),
- json={"proxy_settings": updated_proxy_settings},
- )
- assert update_response.status_code == status.HTTP_200_OK
-
- updated_wireguard = update_response.json()["proxy_settings"]["wireguard"]
- assert updated_wireguard["peer_ips"] == updated_shared_peer_ips
-
- # Verify the updated peer IPs are used in subscription links
- links_response = client.get(f"{user['subscription_url']}/links")
- assert links_response.status_code == status.HTTP_200_OK
-
- links_by_endpoint = {}
- for line in links_response.text.splitlines():
- if not line.startswith("wireguard://"):
- continue
- parsed = urlsplit(line.strip())
- links_by_endpoint[f"{parsed.hostname}:{parsed.port}"] = parse_qs(parsed.query)
-
- expected_updated_address = ",".join(updated_shared_peer_ips)
- assert links_by_endpoint[f"{first_endpoint}:51820"]["address"] == [expected_updated_address]
- assert links_by_endpoint[f"{second_endpoint}:51821"]["address"] == [expected_updated_address]
- finally:
- if user:
- delete_user(access_token, user["username"])
- delete_group(access_token, group["id"])
- client.delete(f"/api/host/{first_host_id}", headers=auth_headers(access_token))
- client.delete(f"/api/host/{second_host_id}", headers=auth_headers(access_token))
- delete_core(access_token, first_core["id"])
- delete_core(access_token, second_core["id"])
-
-
def test_format_rule_response_headers_supports_strings_and_json():
rule = SubRule(
pattern=r"^TestClient$",
@@ -2987,193 +2778,289 @@ def test_get_users_simple_search_and_sort(access_token):
delete_user(access_token, username)
-def test_wireguard_peer_ip_global_pool_and_validation(access_token):
- """Test that peer IPs are allocated from global pool and server IP is rejected."""
- interface_private_key, _ = generate_wireguard_keypair()
- interface_name = unique_name("wg_global_pool")
- endpoint = "198.51.100.30"
+def _wg_config(interface_name: str, address: list[str], *, listen_port: int = 51820) -> dict:
+ private_key, _ = generate_wireguard_keypair()
+ return {
+ "interface_name": interface_name,
+ "private_key": private_key,
+ "listen_port": listen_port,
+ "address": address,
+ }
- core = create_core(
+
+def _wg_core_payload(interface_name: str, address: list[str], *, listen_port: int = 51820) -> dict:
+ """Raw API payload shape, for client.post/put calls that assert failures."""
+ return {
+ "name": unique_name("wg_core"),
+ "type": "wg",
+ "exclude_inbound_tags": [],
+ "fallbacks_inbound_tags": [],
+ "config": _wg_config(interface_name, address, listen_port=listen_port),
+ }
+
+
+def _create_wg_core(access_token, interface_name: str, address: list[str], *, listen_port: int = 51820) -> dict:
+ return create_core(
access_token,
- name=unique_name("wireguard_global_pool_core"),
- config={
- "interface_name": interface_name,
- "private_key": interface_private_key,
- "listen_port": 51820,
- "address": [],
- },
+ name=unique_name("wg_core"),
+ config=_wg_config(interface_name, address, listen_port=listen_port),
type="wg",
fallbacks=[],
)
- host_response = client.post(
+
+def _wg_host(access_token, interface_name: str, endpoint: str, port: int = 51820) -> int:
+ response = client.post(
"/api/host",
headers=auth_headers(access_token),
json={
- "remark": "WG Global Pool {USERNAME}",
+ "remark": "WG {USERNAME}",
"address": [endpoint],
- "port": 51820,
+ "port": port,
"inbound_tag": interface_name,
"priority": 1,
},
)
- assert host_response.status_code == status.HTTP_201_CREATED
- host_id = host_response.json()["id"]
+ assert response.status_code == status.HTTP_201_CREATED
+ return response.json()["id"]
- group = create_group(access_token, name=unique_name("wg_global_pool_group"), inbound_tags=[interface_name])
- user1 = None
- user2 = None
- duplicate_user = None
+def _subnet_usage(access_token, subnet: str) -> dict:
+ response = client.get("/api/wireguard/subnets", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ return next(row for row in response.json() if row["subnet"] == subnet)
- try:
- # Test 1: Try to create user with server IP (10.0.0.1) - should fail
- response = client.post(
- "/api/user",
- headers=auth_headers(access_token),
- json={
- "username": unique_name("wg_server_ip_user"),
- "proxy_settings": {
- "wireguard": {
- "peer_ips": ["10.0.0.1/32"],
- }
- },
- "group_ids": [group["id"]],
- },
- )
- assert response.status_code == status.HTTP_400_BAD_REQUEST
- assert "reserved" in response.json()["detail"]
- # Test 2: Create user without specifying peer IPs - should get persisted auto-allocation
+def test_wireguard_pool_allocation_ignores_manual_input(access_token):
+ """Peer IPs come from the per-subnet pool: manual input is ignored, the server IP
+ is reserved, users get distinct sequential IPs, and freed IPs are visible and reused."""
+ interface_name = unique_name("wg_pool")
+ core = _create_wg_core(access_token, interface_name, ["10.88.0.1/24"])
+ host_id = _wg_host(access_token, interface_name, "198.51.100.30")
+ group = create_group(access_token, name=unique_name("wg_pool_group"), inbound_tags=[interface_name])
+
+ user1 = user2 = user3 = None
+ try:
+ # manual peer_ips (even outside the pool) are ignored; .1 is the server, so .2 is first
user1 = create_user(
access_token,
group_ids=[group["id"]],
- payload={"username": unique_name("wg_auto_ip_user1")},
+ payload={
+ "username": unique_name("wg_manual_user"),
+ "proxy_settings": {"wireguard": {"peer_ips": ["172.16.0.50/32"]}},
+ },
)
- peer_ips1 = user1["proxy_settings"]["wireguard"]["peer_ips"]
- assert isinstance(peer_ips1, list)
- assert len(peer_ips1) == 1
- assert peer_ips1[0].startswith("10.")
- assert peer_ips1[0].endswith("/32")
- assert peer_ips1[0] != "10.0.0.1/32" # Should not be the reserved server IP
+ assert user1["proxy_settings"]["wireguard"]["peer_ips"] == ["10.88.0.2/32"]
- # Subscription should use persisted allocation
links_response = client.get(f"{user1['subscription_url']}/links")
assert links_response.status_code == status.HTTP_200_OK
-
- # Should have a wireguard link with the persisted IP
link = links_response.text.strip()
assert link.startswith("wireguard://")
- parsed = urlsplit(link)
- query = parse_qs(parsed.query)
- peer_ip1 = query.get("address", [""])[0]
- assert peer_ip1 == peer_ips1[0]
+ assert parse_qs(urlsplit(link).query)["address"] == ["10.88.0.2/32"]
- # Test 3: Manual peer_ip is validated against stored peer_ips, including auto-allocated ones.
- response = client.post(
- "/api/user",
- headers=auth_headers(access_token),
- json={
- "username": unique_name("wg_duplicate_ip_user"),
- "proxy_settings": {
- "wireguard": {
- "peer_ips": [peer_ip1],
- }
- },
- "group_ids": [group["id"]],
- },
- )
- assert response.status_code == status.HTTP_400_BAD_REQUEST
- assert "already in use" in response.json()["detail"]
-
- # Test 4: Create another user without specifying peer IPs - should get different persisted IP
user2 = create_user(
- access_token,
- group_ids=[group["id"]],
- payload={"username": unique_name("wg_auto_ip_user2")},
+ access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_pool_user2")}
)
- peer_ips2 = user2["proxy_settings"]["wireguard"]["peer_ips"]
- assert isinstance(peer_ips2, list)
- assert len(peer_ips2) == 1
+ assert user2["proxy_settings"]["wireguard"]["peer_ips"] == ["10.88.0.3/32"]
- # Get allocated IP from subscription
- links_response2 = client.get(f"{user2['subscription_url']}/links")
- assert links_response2.status_code == status.HTTP_200_OK
- link2 = links_response2.text.strip()
- assert link2.startswith("wireguard://")
- parsed2 = urlsplit(link2)
- query2 = parse_qs(parsed2.query)
- peer_ip2 = query2.get("address", [""])[0]
- assert peer_ip2 == peer_ips2[0]
- # Different users should get different IPs
- assert peer_ip2 != peer_ip1
+ usage = _subnet_usage(access_token, "10.88.0.0/24")
+ assert usage["interface_tags"] == [interface_name]
+ assert usage["capacity"] == 253 # 254 hosts minus the server IP
+ assert usage["used"] == 2
+ assert usage["free"] == 251
+ assert "10.88.0.2" not in usage["free_ips"]
+ # deleting a user frees its IP immediately...
+ delete_user(access_token, user1["username"])
+ user1 = None
+ usage = _subnet_usage(access_token, "10.88.0.0/24")
+ assert usage["used"] == 1
+ assert "10.88.0.2" in usage["free_ips"]
+
+ # ...and the freed IP is handed out again
+ user3 = create_user(
+ access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_pool_user3")}
+ )
+ assert user3["proxy_settings"]["wireguard"]["peer_ips"] == ["10.88.0.2/32"]
finally:
- if user1:
- delete_user(access_token, user1["username"])
- if user2:
- delete_user(access_token, user2["username"])
- if duplicate_user:
- delete_user(access_token, duplicate_user["username"])
+ for user in (user1, user2, user3):
+ if user:
+ delete_user(access_token, user["username"])
delete_group(access_token, group["id"])
client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token))
delete_core(access_token, core["id"])
-def test_wireguard_rejects_manual_peer_ip_outside_global_pool(access_token):
- """Manual peer IPv4 must fall within WIREGUARD_GLOBAL_POOL."""
- interface_private_key, _ = generate_wireguard_keypair()
- interface_name = unique_name("wg_subnet_val")
- endpoint = "198.51.100.40"
+def test_wireguard_core_subnet_validation(access_token):
+ """WG cores need at least one client subnet; overlapping CIDRs are rejected,
+ while identical CIDRs may be shared across cores."""
+ no_addr = _wg_core_payload(unique_name("wg_no_addr"), [])
+ response = client.post("/api/core", headers=auth_headers(access_token), json=no_addr)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "IPv4 or IPv6" in response.json()["detail"]
- core = create_core(
- access_token,
- name=unique_name("wireguard_subnet_core"),
- config={
- "interface_name": interface_name,
- "private_key": interface_private_key,
- "listen_port": 51820,
- "address": ["10.88.0.1/24"],
- },
- type="wg",
- fallbacks=[],
- )
+ # outside the old global pool is fine now
+ outside = _create_wg_core(access_token, unique_name("wg_outside"), ["192.168.5.1/24"])
- host_response = client.post(
- "/api/host",
- headers=auth_headers(access_token),
- json={
- "remark": "WG Subnet Val {USERNAME}",
- "address": [endpoint],
- "port": 51820,
- "inbound_tag": interface_name,
- "priority": 1,
- },
+ core = _create_wg_core(access_token, unique_name("wg_base"), ["10.77.0.1/24"])
+ try:
+ nested = _wg_core_payload(unique_name("wg_nested"), ["10.77.0.129/25"])
+ response = client.post("/api/core", headers=auth_headers(access_token), json=nested)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "overlaps" in response.json()["detail"]
+
+ # same base with another prefix also overlaps and is rejected
+ sibling = _wg_core_payload(unique_name("wg_sibling"), ["10.77.0.5/23"])
+ response = client.post("/api/core", headers=auth_headers(access_token), json=sibling)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "overlaps" in response.json()["detail"]
+
+ # identical CIDR is allowed (shared namespace)
+ twin = _create_wg_core(access_token, unique_name("wg_twin"), ["10.77.0.5/24"], listen_port=51822)
+ delete_core(access_token, twin["id"])
+ finally:
+ delete_core(access_token, core["id"])
+ delete_core(access_token, outside["id"])
+
+
+def test_wireguard_shared_subnet_cores_share_allocation(access_token):
+ """Cores with the identical subnet CIDR share one allocation: same IP on both interfaces."""
+ first_interface = unique_name("wg_share_a")
+ second_interface = unique_name("wg_share_b")
+ first_core = _create_wg_core(access_token, first_interface, ["10.55.0.1/24"])
+ second_core = _create_wg_core(access_token, second_interface, ["10.55.0.5/24"], listen_port=51821)
+ first_host = _wg_host(access_token, first_interface, "198.51.100.31")
+ second_host = _wg_host(access_token, second_interface, "198.51.100.32", port=51821)
+ group = create_group(
+ access_token, name=unique_name("wg_share_group"), inbound_tags=[first_interface, second_interface]
)
- assert host_response.status_code == status.HTTP_201_CREATED
- host_id = host_response.json()["id"]
+ user = None
+ try:
+ user = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_share_user")})
+ # one namespace, one IP; .1 and .5 are both servers and reserved
+ assert user["proxy_settings"]["wireguard"]["peer_ips"] == ["10.55.0.2/32"]
- group = create_group(access_token, name=unique_name("wg_subnet_val_group"), inbound_tags=[interface_name])
+ links_response = client.get(f"{user['subscription_url']}/links")
+ assert links_response.status_code == status.HTTP_200_OK
+ addresses = [
+ parse_qs(urlsplit(line).query)["address"]
+ for line in links_response.text.splitlines()
+ if line.startswith("wireguard://")
+ ]
+ assert addresses == [["10.55.0.2/32"], ["10.55.0.2/32"]]
+ finally:
+ if user:
+ delete_user(access_token, user["username"])
+ delete_group(access_token, group["id"])
+ client.delete(f"/api/host/{first_host}", headers=auth_headers(access_token))
+ client.delete(f"/api/host/{second_host}", headers=auth_headers(access_token))
+ delete_core(access_token, first_core["id"])
+ delete_core(access_token, second_core["id"])
+
+
+def test_wireguard_subnet_resize_and_release(access_token):
+ """Growing a subnet keeps every IP; shrinking reallocates only what no longer fits;
+ losing group access releases the IP back to the pool."""
+ interface_name = unique_name("wg_resize")
+ core = _create_wg_core(access_token, interface_name, ["10.99.0.1/25"])
+ host_id = _wg_host(access_token, interface_name, "198.51.100.33")
+ group = create_group(access_token, name=unique_name("wg_resize_group"), inbound_tags=[interface_name])
+
+ def modify_core_address(address: str):
+ core_payload = _wg_core_payload(interface_name, [address])
+ core_payload["name"] = core["name"]
+ core_payload["config"]["private_key"] = core["config"]["private_key"]
+ response = client.put(
+ f"/api/core/{core['id']}?restart_nodes=false", headers=auth_headers(access_token), json=core_payload
+ )
+ assert response.status_code == status.HTTP_200_OK, response.text
+ def get_peer_ips(username: str) -> list[str]:
+ response = client.get(f"/api/user/{username}", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ return response.json()["proxy_settings"]["wireguard"]["peer_ips"]
+
+ user1 = user2 = None
+ try:
+ user1 = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_rs_user1")})
+ user2 = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_rs_user2")})
+ assert get_peer_ips(user1["username"]) == ["10.99.0.2/32"]
+ assert get_peer_ips(user2["username"]) == ["10.99.0.3/32"]
+
+ # grow /25 -> /24: nothing moves
+ modify_core_address("10.99.0.1/24")
+ assert get_peer_ips(user1["username"]) == ["10.99.0.2/32"]
+ assert get_peer_ips(user2["username"]) == ["10.99.0.3/32"]
+
+ # shrink to /30: only offset 2 remains usable; user2 no longer fits
+ modify_core_address("10.99.0.1/30")
+ assert get_peer_ips(user1["username"]) == ["10.99.0.2/32"]
+ assert get_peer_ips(user2["username"]) == [] # subnet exhausted for user2
+
+ # grow back: user2 gets an address again
+ modify_core_address("10.99.0.1/24")
+ assert get_peer_ips(user1["username"]) == ["10.99.0.2/32"]
+ assert get_peer_ips(user2["username"]) == ["10.99.0.3/32"]
+
+ # losing WG access releases the IP...
+ other_core = create_core(access_token)
+ other_groups = [
+ create_group(access_token, name=unique_name("wg_rs_other"), inbound_tags=["VLESS WebSocket TEST"])
+ ]
+ try:
+ response = client.put(
+ f"/api/user/{user2['username']}",
+ headers=auth_headers(access_token),
+ json={"group_ids": [other_groups[0]["id"]]},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["proxy_settings"]["wireguard"]["peer_ips"] == []
+ usage = _subnet_usage(access_token, "10.99.0.0/24")
+ assert "10.99.0.3" in usage["free_ips"]
+
+ # ...and coming back reuses the freed address
+ response = client.put(
+ f"/api/user/{user2['username']}",
+ headers=auth_headers(access_token),
+ json={"group_ids": [group["id"]]},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["proxy_settings"]["wireguard"]["peer_ips"] == ["10.99.0.3/32"]
+ finally:
+ for other_group in other_groups:
+ delete_group(access_token, other_group["id"])
+ delete_core(access_token, other_core["id"])
+ finally:
+ for user in (user1, user2):
+ if user:
+ delete_user(access_token, user["username"])
+ delete_group(access_token, group["id"])
+ client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token))
+ delete_core(access_token, core["id"])
+
+
+def test_wireguard_subnet_exhaustion_returns_400(access_token):
+ """A full subnet turns user creation into a 400, not a silent duplicate."""
+ interface_name = unique_name("wg_tiny")
+ core = _create_wg_core(access_token, interface_name, ["10.44.0.1/30"])
+ group = create_group(access_token, name=unique_name("wg_tiny_group"), inbound_tags=[interface_name])
+ user1 = None
try:
+ # /30: .0 network, .1 server, .3 broadcast -> only .2 is usable
+ user1 = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_tiny_u1")})
+ assert user1["proxy_settings"]["wireguard"]["peer_ips"] == ["10.44.0.2/32"]
+
response = client.post(
"/api/user",
headers=auth_headers(access_token),
- json={
- "username": unique_name("wg_bad_subnet_user"),
- "proxy_settings": {
- "wireguard": {
- "peer_ips": ["172.16.0.50/32"],
- }
- },
- "group_ids": [group["id"]],
- },
+ json={"username": unique_name("wg_tiny_u2"), "group_ids": [group["id"]]},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
- assert "outside WIREGUARD_GLOBAL_POOL" in response.json()["detail"]
+ assert "no free addresses" in response.json()["detail"]
finally:
+ if user1:
+ delete_user(access_token, user1["username"])
delete_group(access_token, group["id"])
- client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token))
delete_core(access_token, core["id"])
diff --git a/tests/test_core_manager_skip_broken.py b/tests/test_core_manager_skip_broken.py
new file mode 100644
index 000000000..6642c19f2
--- /dev/null
+++ b/tests/test_core_manager_skip_broken.py
@@ -0,0 +1,52 @@
+from types import SimpleNamespace
+
+import pytest
+
+from app.core.manager import CoreManager
+from app.db.models import CoreType
+from app.utils.crypto import generate_wireguard_keypair
+
+
+@pytest.mark.asyncio
+async def test_initialize_skips_broken_cores(monkeypatch):
+ """A bad xray/wg config must not abort startup — only that core is skipped."""
+ manager = CoreManager()
+ manager._nats_enabled = False
+
+ private_key, _ = generate_wireguard_keypair()
+ good = SimpleNamespace(
+ id=1,
+ name="ok",
+ type=CoreType.wg,
+ config={
+ "interface_name": "wg0",
+ "private_key": private_key,
+ "listen_port": 51820,
+ "address": ["10.0.0.1/24"],
+ },
+ exclude_inbound_tags=set(),
+ fallbacks_inbound_tags=set(),
+ )
+ broken = SimpleNamespace(
+ id=2,
+ name="broken",
+ type=CoreType.wg,
+ config={"interface_name": "", "private_key": "nope", "listen_port": 0, "address": []},
+ exclude_inbound_tags=set(),
+ fallbacks_inbound_tags=set(),
+ )
+
+ async def fake_get_core_configs(db, query):
+ return [good, broken], 2
+
+ async def noop_persist():
+ return None
+
+ monkeypatch.setattr("app.core.manager.get_core_configs", fake_get_core_configs)
+ monkeypatch.setattr(manager, "_persist_state", noop_persist)
+
+ await manager.initialize(db=None)
+
+ cores = await manager.get_cores()
+ assert set(cores) == {1}
+ assert 2 not in cores
diff --git a/tests/test_wireguard_alloc_unit.py b/tests/test_wireguard_alloc_unit.py
new file mode 100644
index 000000000..55c2ec58e
--- /dev/null
+++ b/tests/test_wireguard_alloc_unit.py
@@ -0,0 +1,145 @@
+from ipaddress import ip_network
+from types import SimpleNamespace
+
+import pytest
+
+from app.db.crud.wireguard import (
+ _give_back,
+ _take_offset,
+ _usable_capacity,
+ match_namespace,
+ peer_host,
+ pick_peer_ip_for_inbound,
+ render_peer_ip,
+ wg_core_subnets,
+ wg_namespaces,
+)
+
+
+def core(interface_name, addresses):
+ return SimpleNamespace(config={"interface_name": interface_name, "address": addresses})
+
+
+def pool_row(next_offset=1, free_offsets=None):
+ return SimpleNamespace(next_offset=next_offset, free_offsets=free_offsets or [])
+
+
+def test_wg_core_subnets_v4_and_v6():
+ assert wg_core_subnets({"address": ["fd00::1/64", "10.0.0.1/24"]}) == [
+ ip_network("fd00::/64"),
+ ip_network("10.0.0.0/24"),
+ ]
+ assert wg_core_subnets({"address": ["10.5.1.7/20"]}) == [ip_network("10.5.0.0/20")]
+ assert wg_core_subnets({"address": ["fd00::1/64"]}) == [ip_network("fd00::/64")]
+ assert wg_core_subnets({"address": []}) == []
+ assert wg_core_subnets({}) == []
+ assert wg_core_subnets({"address": ["garbage"]}) == []
+
+
+def test_render_and_parse_peer_ip():
+ v4 = ip_network("10.0.0.0/24")
+ assert render_peer_ip(v4, 5) == "10.0.0.5/32"
+ assert peer_host("10.0.0.5/32") == (4, int(v4.network_address) + 5)
+ assert peer_host("10.0.0.5") == (4, int(v4.network_address) + 5)
+
+ v6 = ip_network("fd00::/64")
+ assert render_peer_ip(v6, 5) == "fd00::5/128"
+ assert peer_host("fd00::5/128") == (6, int(v6.network_address) + 5)
+ assert peer_host("garbage") is None
+
+
+def test_single_core_namespace_reserves_network_broadcast_and_server():
+ namespaces = wg_namespaces([core("WG_1", ["10.0.0.1/24"])])
+ assert set(namespaces) == {"10.0.0.0/24"}
+ ns = namespaces["10.0.0.0/24"]
+ assert ns.subnet == ip_network("10.0.0.0/24")
+ assert ns.tags == {"WG_1"}
+ assert ns.reserved == {0, 1, 255}
+ assert _usable_capacity(ns) == 253
+
+
+def test_identical_subnet_cores_share_namespace():
+ namespaces = wg_namespaces([core("WG_A", ["10.0.0.1/24"]), core("WG_B", ["10.0.0.5/24", "fd00::1/64"])])
+ assert set(namespaces) == {"10.0.0.0/24", "fd00::/64"}
+ ns = namespaces["10.0.0.0/24"]
+ assert ns.tags == {"WG_A", "WG_B"}
+ assert {1, 5} <= ns.reserved
+ assert namespaces["fd00::/64"].tags == {"WG_B"}
+
+
+def test_overlapping_subnets_keep_largest_and_merge_tags():
+ """Smaller overlapping CIDRs are dropped; tags/reserved merge into the largest."""
+ namespaces = wg_namespaces([core("WG_A", ["10.0.0.1/20"]), core("WG_B", ["10.0.0.5/24"])])
+ assert set(namespaces) == {"10.0.0.0/20"}
+ ns = namespaces["10.0.0.0/20"]
+ assert ns.tags == {"WG_A", "WG_B"}
+ assert {1, 5} <= ns.reserved
+
+
+def test_disjoint_cores_get_separate_namespaces():
+ namespaces = wg_namespaces([core("WG_A", ["10.1.0.1/24"]), core("WG_B", ["10.2.0.1/24"])])
+ assert len(namespaces) == 2
+ assert match_namespace(namespaces, *peer_host("10.1.0.7")).tags == {"WG_A"}
+ assert match_namespace(namespaces, *peer_host("10.2.0.7")).tags == {"WG_B"}
+ assert match_namespace(namespaces, *peer_host("10.3.0.7")) is None
+
+
+def test_ipv6_only_core_gets_namespace():
+ namespaces = wg_namespaces([core("WG_V6", ["fd00::1/64"])])
+ assert set(namespaces) == {"fd00::/64"}
+ ns = namespaces["fd00::/64"]
+ assert ns.reserved == {0, 1, ns.subnet.num_addresses - 1}
+
+
+def test_take_offset_skips_server_and_advances_high_water():
+ ns = wg_namespaces([core("WG_1", ["10.0.0.1/24"])])["10.0.0.0/24"]
+ row = pool_row()
+ assert _take_offset(ns, row) == 2 # .0 network, .1 server -> first user gets .2
+ assert _take_offset(ns, row) == 3
+ assert row.next_offset == 4
+
+
+def test_take_offset_prefers_free_list():
+ ns = wg_namespaces([core("WG_1", ["10.0.0.1/24"])])["10.0.0.0/24"]
+ row = pool_row(next_offset=50, free_offsets=[9, 4, 1]) # 1 is the server: invalid, skipped
+ assert _take_offset(ns, row) == 4
+ assert row.free_offsets == [9, 1]
+ assert row.next_offset == 50
+
+
+def test_take_offset_exhaustion():
+ ns = wg_namespaces([core("WG_T", ["10.9.9.1/30"])])["10.9.9.0/30"]
+ row = pool_row()
+ assert _take_offset(ns, row) == 2 # .1 server, .2 the only usable host
+ with pytest.raises(ValueError):
+ _take_offset(ns, row)
+
+
+def test_take_offset_ipv6():
+ ns = wg_namespaces([core("WG_V6", ["fd00::1/64"])])["fd00::/64"]
+ row = pool_row()
+ assert _take_offset(ns, row) == 2
+ assert render_peer_ip(ns.subnet, 2) == "fd00::2/128"
+
+
+def test_grow_subnet_unblocks_exhausted_pool():
+ row = pool_row(next_offset=255) # /24 fully handed out
+ ns24 = wg_namespaces([core("WG_1", ["10.0.0.1/24"])])["10.0.0.0/24"]
+ with pytest.raises(ValueError):
+ _take_offset(ns24, row)
+ ns20 = wg_namespaces([core("WG_1", ["10.0.0.1/20"])])["10.0.0.0/20"]
+ assert _take_offset(ns20, row) == 255 # /24's broadcast offset is a normal host in /20
+
+
+def test_give_back_dedupes_and_bounds():
+ row = pool_row(next_offset=10, free_offsets=[3])
+ _give_back(row, [3, 4, 0, 42]) # dup, valid, never-valid, beyond high-water
+ assert row.free_offsets == [3, 4]
+
+
+def test_pick_peer_ip_for_inbound():
+ peer_ips = ["10.1.0.5/32", "10.2.0.9/32", "fd00::5/128"]
+ assert pick_peer_ip_for_inbound(["10.1.0.1/24"], peer_ips) == ["10.1.0.5/32"]
+ assert pick_peer_ip_for_inbound(["10.2.0.1/24", "fd00::1/64"], peer_ips) == ["10.2.0.9/32", "fd00::5/128"]
+ assert pick_peer_ip_for_inbound(["10.3.0.1/24"], peer_ips) == []
+ assert pick_peer_ip_for_inbound(["fd00::1/64"], peer_ips) == ["fd00::5/128"]
From 8352be44d28081b73882b02c0fc1f71f21e56ce8 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 20 Jul 2026 19:12:59 +0330
Subject: [PATCH 2/7] fix(migration): cast core_configs.type to string in
WireGuard config selection
---
.../versions/3c1a7e5b9d20_add_wireguard_subnets_pool_table.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/db/migrations/versions/3c1a7e5b9d20_add_wireguard_subnets_pool_table.py b/app/db/migrations/versions/3c1a7e5b9d20_add_wireguard_subnets_pool_table.py
index 356797b90..20dc282d4 100644
--- a/app/db/migrations/versions/3c1a7e5b9d20_add_wireguard_subnets_pool_table.py
+++ b/app/db/migrations/versions/3c1a7e5b9d20_add_wireguard_subnets_pool_table.py
@@ -159,7 +159,7 @@ def upgrade() -> None:
core_rows = [
_load_json(config)
for (config,) in bind.execute(
- sa.select(core_configs.c.config).where(core_configs.c.type == "wg")
+ sa.select(core_configs.c.config).where(sa.cast(core_configs.c.type, sa.String) == "wg")
).fetchall()
]
namespaces = _build_namespaces(core_rows)
From 53b6a391bb3d8c7721e733d4525d29f17678d5b5 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 20 Jul 2026 19:53:24 +0330
Subject: [PATCH 3/7] fix: format
---
app/core/manager.py | 2 +-
app/db/crud/bulk.py | 1 -
app/db/crud/wireguard.py | 5 ++++-
app/models/host.py | 8 ++------
app/models/reality_scan.py | 4 +++-
app/operation/core.py | 4 +---
app/routers/user.py | 2 +-
app/subscription/share.py | 4 +---
app/utils/reality_scan.py | 30 +++++++++++++++++++++---------
app/utils/wireguard.py | 4 +---
config.py | 4 +---
tests/api/test_user.py | 8 ++------
tests/test_reality_scan_unit.py | 30 +++++++++++++++++++++---------
13 files changed, 59 insertions(+), 47 deletions(-)
diff --git a/app/core/manager.py b/app/core/manager.py
index 0f306fd46..33007d576 100644
--- a/app/core/manager.py
+++ b/app/core/manager.py
@@ -80,7 +80,7 @@ async def _load_state_from_cache(self) -> bool:
# Deserialize state using JSON
try:
cached_state = json.loads(entry.value.decode("utf-8"))
- except (json.JSONDecodeError, UnicodeDecodeError):
+ except json.JSONDecodeError, UnicodeDecodeError:
self._logger.warning("Failed to decode CoreManager state as JSON, ignoring...")
return False
diff --git a/app/db/crud/bulk.py b/app/db/crud/bulk.py
index 64d95c668..a5be01663 100644
--- a/app/db/crud/bulk.py
+++ b/app/db/crud/bulk.py
@@ -4,7 +4,6 @@
from sqlalchemy import and_, case, cast, delete, func, or_, select, text, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
from app.db.models import (
Admin,
diff --git a/app/db/crud/wireguard.py b/app/db/crud/wireguard.py
index cddcacedf..af5b98cfd 100644
--- a/app/db/crud/wireguard.py
+++ b/app/db/crud/wireguard.py
@@ -549,7 +549,10 @@ async def get_subnet_usage(db: AsyncSession, *, free_limit: int = FREE_IPS_LIMIT
used = max(0, (next_offset - 1) - reserved_below - len(free_list))
capacity = _usable_capacity(ns)
- free_ips = [str(_host_address(ns.subnet.version, int(ns.subnet.network_address) + offset)) for offset in free_list[:free_limit]]
+ free_ips = [
+ str(_host_address(ns.subnet.version, int(ns.subnet.network_address) + offset))
+ for offset in free_list[:free_limit]
+ ]
offset = next_offset
last_host = ns.subnet.num_addresses - 1
while len(free_ips) < free_limit and offset < last_host:
diff --git a/app/models/host.py b/app/models/host.py
index c0e17eb47..b03216773 100644
--- a/app/models/host.py
+++ b/app/models/host.py
@@ -268,12 +268,8 @@ class XMuxSettings(BaseModel):
max_concurrency: str | None = Field(None, pattern=r"^\d{1,16}(-\d{1,16})?$", alias="maxConcurrency")
max_connections: str | None = Field(None, pattern=r"^\d{1,16}(-\d{1,16})?$", alias="maxConnections")
c_max_reuse_times: str | None = Field(None, pattern=r"^\d{1,16}(-\d{1,16})?$", alias="cMaxReuseTimes")
- h_max_reusable_secs: str | None = Field(
- None, pattern=r"^\d{1,16}(-\d{1,16})?$", alias="hMaxReusableSecs"
- )
- h_max_request_times: str | None = Field(
- None, pattern=r"^\d{1,16}(-\d{1,16})?$", alias="hMaxRequestTimes"
- )
+ h_max_reusable_secs: str | None = Field(None, pattern=r"^\d{1,16}(-\d{1,16})?$", alias="hMaxReusableSecs")
+ h_max_request_times: str | None = Field(None, pattern=r"^\d{1,16}(-\d{1,16})?$", alias="hMaxRequestTimes")
h_keep_alive_period: int | None = Field(None, alias="hKeepAlivePeriod")
@field_validator(
diff --git a/app/models/reality_scan.py b/app/models/reality_scan.py
index a1d918a36..22468eb3b 100644
--- a/app/models/reality_scan.py
+++ b/app/models/reality_scan.py
@@ -3,7 +3,9 @@
class RealityScanRequest(BaseModel):
target: str = Field(min_length=1, max_length=253, description="host or host:port to probe (port defaults to 443)")
- timeout: float | None = Field(default=None, ge=1, le=20, description="Per-probe timeout in seconds (1-20, default 10)")
+ timeout: float | None = Field(
+ default=None, ge=1, le=20, description="Per-probe timeout in seconds (1-20, default 10)"
+ )
class RealityScanResult(BaseModel):
diff --git a/app/operation/core.py b/app/operation/core.py
index d897f02a1..6ee3edeef 100644
--- a/app/operation/core.py
+++ b/app/operation/core.py
@@ -52,9 +52,7 @@ async def _validate_wireguard_subnet(self, db: AsyncSession, config: dict, *, ex
are rejected; identical CIDRs may be shared across cores (one allocation namespace)."""
subnets = wg_core_subnets(config)
if not subnets:
- await self.raise_error(
- message="WireGuard core needs an IPv4 or IPv6 interface address", code=400, db=db
- )
+ await self.raise_error(message="WireGuard core needs an IPv4 or IPv6 interface address", code=400, db=db)
for other in await get_wg_cores(db):
if other.id == exclude_core_id:
continue
diff --git a/app/routers/user.py b/app/routers/user.py
index 1d8a4324d..269aafc80 100644
--- a/app/routers/user.py
+++ b/app/routers/user.py
@@ -1,6 +1,6 @@
from typing import Annotated
-from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi import APIRouter, Depends, Request, status
from app.db import AsyncSession, get_db
from app.models.admin import AdminDetails
diff --git a/app/subscription/share.py b/app/subscription/share.py
index d5db27fe7..7f8313f8f 100644
--- a/app/subscription/share.py
+++ b/app/subscription/share.py
@@ -296,9 +296,7 @@ async def process_host(
# Each WG interface only gets the user's peer IP from its own subnet.
if inbound.protocol == "wireguard":
- settings["peer_ips"] = pick_peer_ip_for_inbound(
- inbound.wireguard_local_address, settings.get("peer_ips") or []
- )
+ settings["peer_ips"] = pick_peer_ip_for_inbound(inbound.wireguard_local_address, settings.get("peer_ips") or [])
# Update format variables
format_variables.update({"PROTOCOL": inbound.protocol})
diff --git a/app/utils/reality_scan.py b/app/utils/reality_scan.py
index 44d52c51c..8eefd7c2e 100644
--- a/app/utils/reality_scan.py
+++ b/app/utils/reality_scan.py
@@ -24,7 +24,9 @@
DNS_TIMEOUT = 5.0
MAX_CONCURRENT_SCANS = 4
-_scan_semaphores: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = weakref.WeakKeyDictionary()
+_scan_semaphores: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = (
+ weakref.WeakKeyDictionary()
+)
_scan_executor: "ThreadPoolExecutor | None" = None
@@ -152,7 +154,9 @@ def _select_public_ip(host: str, infos: list) -> str:
saw_blocked = True
if saw_blocked:
- raise RealityScanError("Target resolves only to private or reserved addresses; only public hosts can be scanned.")
+ raise RealityScanError(
+ "Target resolves only to private or reserved addresses; only public hosts can be scanned."
+ )
raise RealityScanError(f"Could not resolve host to a usable address: {host}")
@@ -177,7 +181,7 @@ async def _resolve_public_ip_async(host: str, timeout: float) -> str:
loop = asyncio.get_running_loop()
try:
infos = await asyncio.wait_for(loop.getaddrinfo(host, None, type=socket.SOCK_STREAM), timeout=timeout)
- except (asyncio.TimeoutError, TimeoutError):
+ except asyncio.TimeoutError, TimeoutError:
raise RealityScanError(f"DNS lookup for {host} timed out.")
except socket.gaierror:
raise RealityScanError(f"Could not resolve host: {host}")
@@ -221,7 +225,9 @@ def _parse_certificate(der: bytes | None) -> dict:
return out
out["cert_subject"] = _name_common_name(cert.subject) or (cert.subject.rfc4514_string() or None)
- out["cert_issuer"] = _name_organization(cert.issuer) or _name_common_name(cert.issuer) or (cert.issuer.rfc4514_string() or None)
+ out["cert_issuer"] = (
+ _name_organization(cert.issuer) or _name_common_name(cert.issuer) or (cert.issuer.rfc4514_string() or None)
+ )
try:
out["not_after"] = cert.not_valid_after_utc.isoformat()
@@ -310,7 +316,9 @@ def _drive_handshake(tls: ssl.SSLSocket, deadline: float) -> None:
raise TimeoutError("TLS handshake timed out")
-def _tls_wrap_with_deadline(ctx: ssl.SSLContext, ip: str, port: int, server_hostname: str | None, timeout: float) -> tuple[ssl.SSLSocket, float]:
+def _tls_wrap_with_deadline(
+ ctx: ssl.SSLContext, ip: str, port: int, server_hostname: str | None, timeout: float
+) -> tuple[ssl.SSLSocket, float]:
started = time.monotonic()
deadline = started + timeout
sock = socket.create_connection((ip, port), timeout=timeout)
@@ -365,7 +373,9 @@ def _tls_probe(ip: str, port: int, sni: str | None, timeout: float) -> dict:
alpn: str | None = None
der: bytes | None = None
- def _handshake(ctx: ssl.SSLContext, server_hostname: str | None) -> tuple[str | None, str | None, bytes | None, float]:
+ def _handshake(
+ ctx: ssl.SSLContext, server_hostname: str | None
+ ) -> tuple[str | None, str | None, bytes | None, float]:
tls, latency = _tls_wrap_with_deadline(ctx, ip, port, server_hostname, timeout)
with tls:
return tls.version(), tls.selected_alpn_protocol(), tls.getpeercert(binary_form=True), latency
@@ -401,7 +411,7 @@ def _handshake(ctx: ssl.SSLContext, server_hostname: str | None) -> tuple[str |
result["reason"] = f"Certificate did not validate: {getattr(exc, 'verify_message', None) or exc}"
except (ssl.SSLError, socket.timeout, TimeoutError, OSError, UnicodeError) as exc:
result["reason"] = f"Certificate re-validation failed: {exc}"
- except (socket.timeout, TimeoutError):
+ except socket.timeout, TimeoutError:
result["reason"] = "Connection timed out."
return result
except ssl.SSLError as exc:
@@ -503,7 +513,7 @@ def _recv_exact(sock: socket.socket, count: int, deadline: float) -> bytes | Non
sock.settimeout(remaining)
try:
chunk = sock.recv(count - len(buf))
- except (socket.timeout, TimeoutError):
+ except socket.timeout, TimeoutError:
return None
if not chunk:
return None
@@ -677,7 +687,9 @@ def _scan_sync(host: str, ip: str, port: int, sni: str | None, timeout: float) -
result["feasible"] = base_ok and result["x25519"] is True
if base_ok and result["x25519"] is not True and not result["reason"]:
if result["x25519"] is False:
- result["reason"] = f"Key exchange is {result['curve'] or 'not X25519'}; REALITY needs X25519 or X25519MLKEM768."
+ result["reason"] = (
+ f"Key exchange is {result['curve'] or 'not X25519'}; REALITY needs X25519 or X25519MLKEM768."
+ )
else:
result["reason"] = "Could not confirm an X25519 key exchange."
return result
diff --git a/app/utils/wireguard.py b/app/utils/wireguard.py
index 3ebb44749..c6d9ed1e6 100644
--- a/app/utils/wireguard.py
+++ b/app/utils/wireguard.py
@@ -17,9 +17,7 @@ async def wireguard_public_key_in_use(
*,
exclude_user_id: int | None = None,
) -> bool:
- stmt = (
- select(User.id).where(User.proxy_settings["wireguard"]["public_key"].as_string() == public_key).limit(1)
- )
+ stmt = select(User.id).where(User.proxy_settings["wireguard"]["public_key"].as_string() == public_key).limit(1)
if exclude_user_id is not None:
stmt = stmt.where(User.id != exclude_user_id)
return (await db.execute(stmt)).first() is not None
diff --git a/config.py b/config.py
index da1fe901e..95164b813 100644
--- a/config.py
+++ b/config.py
@@ -88,9 +88,7 @@ class NatsSettings(EnvSettings):
node_log_subject: str = Field(default="pasarguard.node.logs", validation_alias="NATS_NODE_LOG_SUBJECT")
node_rpc_timeout: float = Field(default=30.0, validation_alias="NATS_NODE_RPC_TIMEOUT")
scheduler_rpc_timeout: float = Field(default=5.0, validation_alias="NATS_SCHEDULER_RPC_TIMEOUT")
- node_command_max_payload_bytes: int = Field(
- default=900000, validation_alias="NATS_NODE_COMMAND_MAX_PAYLOAD_BYTES"
- )
+ node_command_max_payload_bytes: int = Field(default=900000, validation_alias="NATS_NODE_COMMAND_MAX_PAYLOAD_BYTES")
node_update_users_batch_size: int = Field(default=100, validation_alias="NATS_NODE_UPDATE_USERS_BATCH_SIZE")
core_pubsub_channel: str = Field(default="core_hosts_updates", validation_alias="CORE_PUBSUB_CHANNEL")
host_pubsub_channel: str = Field(default="host_manager_updates", validation_alias="HOST_PUBSUB_CHANNEL")
diff --git a/tests/api/test_user.py b/tests/api/test_user.py
index e9e078875..b316040e3 100644
--- a/tests/api/test_user.py
+++ b/tests/api/test_user.py
@@ -2858,9 +2858,7 @@ def test_wireguard_pool_allocation_ignores_manual_input(access_token):
assert link.startswith("wireguard://")
assert parse_qs(urlsplit(link).query)["address"] == ["10.88.0.2/32"]
- user2 = create_user(
- access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_pool_user2")}
- )
+ user2 = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_pool_user2")})
assert user2["proxy_settings"]["wireguard"]["peer_ips"] == ["10.88.0.3/32"]
usage = _subnet_usage(access_token, "10.88.0.0/24")
@@ -2878,9 +2876,7 @@ def test_wireguard_pool_allocation_ignores_manual_input(access_token):
assert "10.88.0.2" in usage["free_ips"]
# ...and the freed IP is handed out again
- user3 = create_user(
- access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_pool_user3")}
- )
+ user3 = create_user(access_token, group_ids=[group["id"]], payload={"username": unique_name("wg_pool_user3")})
assert user3["proxy_settings"]["wireguard"]["peer_ips"] == ["10.88.0.2/32"]
finally:
for user in (user1, user2, user3):
diff --git a/tests/test_reality_scan_unit.py b/tests/test_reality_scan_unit.py
index 70df82d96..b6b90efa0 100644
--- a/tests/test_reality_scan_unit.py
+++ b/tests/test_reality_scan_unit.py
@@ -169,7 +169,9 @@ def recv(self, n):
def _self_signed_der(cn: str, sans: list[str]) -> bytes:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
- name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Org")])
+ name = x509.Name(
+ [x509.NameAttribute(NameOID.COMMON_NAME, cn), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Org")]
+ )
now = datetime.datetime.now(datetime.timezone.utc)
builder = (
x509.CertificateBuilder()
@@ -250,7 +252,12 @@ def _patch_probes(monkeypatch, *, tls, group, h3):
def test_scan_sync_feasible_when_all_pass(monkeypatch):
- _patch_probes(monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=True)
+ _patch_probes(
+ monkeypatch,
+ tls=dict(_GOOD_TLS),
+ group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"},
+ h3=True,
+ )
out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5)
assert out["feasible"] is True
assert out["post_quantum"] is True
@@ -258,7 +265,9 @@ def test_scan_sync_feasible_when_all_pass(monkeypatch):
def test_scan_sync_not_feasible_when_group_unknown(monkeypatch):
- _patch_probes(monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": None, "post_quantum": None, "curve": None}, h3=False)
+ _patch_probes(
+ monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": None, "post_quantum": None, "curve": None}, h3=False
+ )
out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5)
assert out["feasible"] is False
assert "X25519" in out["reason"]
@@ -266,7 +275,9 @@ def test_scan_sync_not_feasible_when_group_unknown(monkeypatch):
def test_scan_sync_carries_discovered_sni(monkeypatch):
tls = dict(_GOOD_TLS, sni="cloudflare-dns.com", sni_discovered=True)
- _patch_probes(monkeypatch, tls=tls, group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=False)
+ _patch_probes(
+ monkeypatch, tls=tls, group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=False
+ )
out = rs._scan_sync("1.0.0.1", "1.0.0.1", 443, None, 5)
assert out["sni"] == "cloudflare-dns.com"
assert out["sni_discovered"] is True
@@ -274,7 +285,9 @@ def test_scan_sync_carries_discovered_sni(monkeypatch):
def test_scan_sync_not_feasible_when_definitely_not_x25519(monkeypatch):
- _patch_probes(monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": False, "post_quantum": False, "curve": "secp256r1"}, h3=False)
+ _patch_probes(
+ monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": False, "post_quantum": False, "curve": "secp256r1"}, h3=False
+ )
out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5)
assert out["feasible"] is False
assert "secp256r1" in out["reason"]
@@ -412,7 +425,9 @@ def test_parse_certificate_without_sans():
def test_scan_sync_not_feasible_without_h2(monkeypatch):
tls = dict(_GOOD_TLS, h2=False, alpn="http/1.1")
- _patch_probes(monkeypatch, tls=tls, group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=False)
+ _patch_probes(
+ monkeypatch, tls=tls, group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=False
+ )
out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5)
assert out["feasible"] is False
@@ -453,10 +468,7 @@ def fake_sync(*a, **k):
assert resolver["peak"] <= rs.MAX_CONCURRENT_SCANS
-
-
class _FakeTLS:
-
def __init__(self, version="TLSv1.3", alpn="h2", der=b"DER"):
self._version, self._alpn, self._der = version, alpn, der
From aa11210d5afd45eb39cb7b6df5feaee605d30fde Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Tue, 21 Jul 2026 02:07:24 +0330
Subject: [PATCH 4/7] feat: add WireGuard subnets list component and route
- Implemented WireGuardSubnetsList component to display usage of WireGuard subnets.
- Added a new route for WireGuard subnets in the dashboard.
- Updated RBAC to allow access to WireGuard subnets page.
---
app/routers/system.py | 2 +-
dashboard/public/statics/locales/en.json | 12 +
dashboard/public/statics/locales/fa.json | 12 +
dashboard/public/statics/locales/ru.json | 12 +
dashboard/public/statics/locales/zh.json | 12 +
dashboard/src/app/router.tsx | 9 +
dashboard/src/components/layout/sidebar.tsx | 6 +
.../layout/tabbed-route-suspense-fallback.tsx | 6 +
.../components/wireguard-subnets-list.tsx | 127 ++++++
dashboard/src/pages/_dashboard.nodes.tsx | 13 +-
.../src/pages/_dashboard.nodes.wireguard.tsx | 5 +
dashboard/src/service/api/index.ts | 397 +++++++++++++-----
dashboard/src/utils/rbac.ts | 1 +
13 files changed, 500 insertions(+), 114 deletions(-)
create mode 100644 dashboard/src/features/nodes/components/wireguard-subnets-list.tsx
create mode 100644 dashboard/src/pages/_dashboard.nodes.wireguard.tsx
diff --git a/app/routers/system.py b/app/routers/system.py
index f63858784..d987417e7 100644
--- a/app/routers/system.py
+++ b/app/routers/system.py
@@ -83,7 +83,7 @@ async def get_inbound_details(_: AdminDetails = Depends(require_permission("syst
@router.get("/wireguard/subnets", response_model=list[WireGuardSubnetUsage])
async def get_wireguard_subnets(
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(require_permission("system", "read")),
+ _: AdminDetails = Depends(require_permission("cores", "read")),
):
"""Per-subnet WireGuard address usage: capacity, used/free counts and the first free IPs."""
return await get_subnet_usage(db)
diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json
index 76f032111..b0bd8ccb2 100644
--- a/dashboard/public/statics/locales/en.json
+++ b/dashboard/public/statics/locales/en.json
@@ -118,6 +118,18 @@
"2h": "2 hours"
}
},
+ "wireguard": {
+ "title": "WireGuard",
+ "description": "Peer IP pools for WireGuard cores — usage, free addresses, and interface tags",
+ "empty": "No WireGuard subnets yet. Create a WireGuard core with an interface address to start allocating peer IPs.",
+ "used": "Used",
+ "free": "Free",
+ "capacity": "Capacity",
+ "usage": "Usage",
+ "freeIps": "Free IPs",
+ "noTags": "No interface tags",
+ "refresh": "Refresh"
+ },
"reconnectinfo": "Refresh all nodes connections to resolve connectivity issues",
"reconnectAll": "Reconnect All Nodes",
"reconnectingAll": "Reconnecting...",
diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json
index 4752b0fce..9ee0b7eed 100644
--- a/dashboard/public/statics/locales/fa.json
+++ b/dashboard/public/statics/locales/fa.json
@@ -1977,6 +1977,18 @@
"2h": "۲ ساعت"
}
},
+ "wireguard": {
+ "title": "WireGuard",
+ "description": "استخر IP همتا برای هستههای WireGuard — مصرف، آدرسهای آزاد و تگ اینترفیس",
+ "empty": "هنوز زیرشبکه WireGuard وجود ندارد. یک هسته WireGuard با آدرس اینترفیس بسازید تا تخصیص IP شروع شود.",
+ "used": "مصرفشده",
+ "free": "آزاد",
+ "capacity": "ظرفیت",
+ "usage": "مصرف",
+ "freeIps": "IPهای آزاد",
+ "noTags": "بدون تگ اینترفیس",
+ "refresh": "بازنشانی"
+ },
"reconnectinfo": "بهروزرسانی همه اتصالات گرهها برای رفع مشکلات اتصال",
"reconnectAll": "اتصال مجدد همه گرهها",
"reconnectingAll": "در حال اتصال مجدد...",
diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json
index 3007271d8..b763d99da 100644
--- a/dashboard/public/statics/locales/ru.json
+++ b/dashboard/public/statics/locales/ru.json
@@ -145,6 +145,18 @@
"2h": "2 часа"
}
},
+ "wireguard": {
+ "title": "WireGuard",
+ "description": "Пулы peer IP для ядер WireGuard — использование, свободные адреса и теги интерфейсов",
+ "empty": "Подсетей WireGuard пока нет. Создайте ядро WireGuard с адресом интерфейса, чтобы начать выдачу peer IP.",
+ "used": "Занято",
+ "free": "Свободно",
+ "capacity": "Ёмкость",
+ "usage": "Использование",
+ "freeIps": "Свободные IP",
+ "noTags": "Нет тегов интерфейса",
+ "refresh": "Обновить"
+ },
"reconnectinfo": "Обновить все соединения узлов для устранения проблем с подключением",
"reconnectAll": "Переподключить все узлы",
"reconnectingAll": "Переподключение...",
diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json
index ce8bfdbd8..8a2759038 100644
--- a/dashboard/public/statics/locales/zh.json
+++ b/dashboard/public/statics/locales/zh.json
@@ -114,6 +114,18 @@
"2h": "2小时"
}
},
+ "wireguard": {
+ "title": "WireGuard",
+ "description": "WireGuard 核心的对端 IP 池 — 用量、空闲地址与接口标签",
+ "empty": "尚无 WireGuard 子网。请创建带接口地址的 WireGuard 核心以开始分配对端 IP。",
+ "used": "已用",
+ "free": "空闲",
+ "capacity": "容量",
+ "usage": "使用率",
+ "freeIps": "空闲 IP",
+ "noTags": "无接口标签",
+ "refresh": "刷新"
+ },
"reconnectinfo": "刷新所有节点连接以解决连接问题",
"reconnectAll": "重新连接所有节点",
"reconnectingAll": "重新连接中...",
diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx
index ba700ca44..d10fafb43 100644
--- a/dashboard/src/app/router.tsx
+++ b/dashboard/src/app/router.tsx
@@ -27,6 +27,7 @@ const Hosts = lazyWithChunkRecovery(() => import('../pages/_dashboard.hosts'))
const Nodes = lazyWithChunkRecovery(() => import('../pages/_dashboard.nodes'))
const NodesPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.nodes._index'))
const NodeLogs = lazyWithChunkRecovery(() => import('../pages/_dashboard.nodes.logs'))
+const NodeWireGuard = lazyWithChunkRecovery(() => import('../pages/_dashboard.nodes.wireguard'))
const Settings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings'))
const CleanupSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.cleanup'))
const GeneralSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.general'))
@@ -167,6 +168,14 @@ export const router = createHashRouter([
),
},
+ {
+ path: '/nodes/wireguard',
+ element: (
+ }>
+
+
+ ),
+ },
],
},
{
diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx
index 577c30977..5e5e882e5 100644
--- a/dashboard/src/components/layout/sidebar.tsx
+++ b/dashboard/src/components/layout/sidebar.tsx
@@ -41,6 +41,7 @@ import {
ListTodo,
Lock,
Logs,
+ Network,
Palette,
PieChart,
RssIcon,
@@ -93,6 +94,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
icon: Cpu,
matchPrefix: true,
},
+ {
+ title: 'nodes.wireguard.title',
+ url: '/nodes/wireguard',
+ icon: Network,
+ },
]
: []),
...(canReadNodeLogs
diff --git a/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx b/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx
index 0137ec883..69c727cc9 100644
--- a/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx
+++ b/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx
@@ -18,6 +18,7 @@ import {
Lock,
Logs,
LucideIcon,
+ Network,
Palette,
Send,
Settings as SettingsIcon,
@@ -33,6 +34,7 @@ type TabDef = { id: string; labelKey: string; icon: LucideIcon; url: string }
const NODES_TABS: TabDef[] = [
{ id: 'nodes.title', labelKey: 'nodes.title', icon: Share2, url: '/nodes' },
{ id: 'core', labelKey: 'core', icon: Cpu, url: '/nodes/cores' },
+ { id: 'nodes.wireguard.title', labelKey: 'nodes.wireguard.title', icon: Network, url: '/nodes/wireguard' },
{ id: 'nodes.logs.title', labelKey: 'nodes.logs.title', icon: Logs, url: '/nodes/logs' },
]
@@ -65,6 +67,7 @@ const TEMPLATES_TABS: TabDef[] = [
function nodesActiveTabId(pathname: string): string {
if (pathname.startsWith('/nodes/cores')) return 'core'
+ if (pathname.startsWith('/nodes/wireguard')) return 'nodes.wireguard.title'
if (pathname.startsWith('/nodes/logs')) return 'nodes.logs.title'
return 'nodes.title'
}
@@ -73,6 +76,9 @@ function nodesHeader(pathname: string): { title: string; description: string } {
if (pathname.startsWith('/nodes/cores')) {
return { title: 'settings.cores.title', description: 'settings.cores.description' }
}
+ if (pathname.startsWith('/nodes/wireguard')) {
+ return { title: 'nodes.wireguard.title', description: 'nodes.wireguard.description' }
+ }
if (pathname.startsWith('/nodes/logs')) {
return { title: 'nodes.logs.title', description: 'nodes.logs.description' }
}
diff --git a/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx b/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx
new file mode 100644
index 000000000..05c752f76
--- /dev/null
+++ b/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx
@@ -0,0 +1,127 @@
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card } from '@/components/ui/card'
+import { Progress } from '@/components/ui/progress'
+import { Skeleton } from '@/components/ui/skeleton'
+import { useGetWireGuardSubnets, type WireGuardSubnetUsage } from '@/service/api'
+import { cn } from '@/lib/utils'
+import { ChevronDown, RefreshCw } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+function usagePercent(row: WireGuardSubnetUsage): number {
+ if (row.capacity <= 0) return 0
+ return Math.min(100, Math.round((row.used / row.capacity) * 100))
+}
+
+function SubnetCard({ row }: { row: WireGuardSubnetUsage }) {
+ const { t } = useTranslation()
+ const [showFree, setShowFree] = useState(false)
+ const percent = usagePercent(row)
+ const previewFree = row.free_ips.slice(0, 12)
+ const hasMoreFree = row.free_ips.length > previewFree.length
+
+ return (
+
+
+
+
+
{row.subnet}
+
+ {row.interface_tags.length === 0 ? (
+ {t('nodes.wireguard.noTags')}
+ ) : (
+ row.interface_tags.map(tag => (
+
+ {tag}
+
+ ))
+ )}
+
+
+
+
+ {t('nodes.wireguard.used')}: {row.used}
+
+
+ {t('nodes.wireguard.free')}: {row.free}
+
+
+ {t('nodes.wireguard.capacity')}: {row.capacity}
+
+
+
+
+
+
+ {t('nodes.wireguard.usage')}
+ {percent}%
+
+
= 90 ? 'bg-destructive' : percent >= 70 ? 'bg-amber-500' : undefined)}
+ />
+
+
+ {row.free_ips.length > 0 && (
+
+
setShowFree(open => !open)}
+ >
+
+ {t('nodes.wireguard.freeIps')} ({row.free_ips.length}
+ {row.free_ips.length < row.free ? '+' : ''})
+
+ {showFree && (
+
+ {previewFree.map(ip => (
+
+ {ip}
+
+ ))}
+ {hasMoreFree && … }
+
+ )}
+
+ )}
+
+
+ )
+}
+
+export default function WireGuardSubnetsList() {
+ const { t } = useTranslation()
+ const { data, isLoading, isFetching, refetch } = useGetWireGuardSubnets()
+ const rows = useMemo(() => data ?? [], [data])
+
+ return (
+
+
+ refetch()} disabled={isFetching}>
+
+ {t('nodes.wireguard.refresh')}
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : rows.length === 0 ? (
+
{t('nodes.wireguard.empty')}
+ ) : (
+
+ {rows.map(row => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/dashboard/src/pages/_dashboard.nodes.tsx b/dashboard/src/pages/_dashboard.nodes.tsx
index 81148ba4f..54329359b 100644
--- a/dashboard/src/pages/_dashboard.nodes.tsx
+++ b/dashboard/src/pages/_dashboard.nodes.tsx
@@ -3,7 +3,7 @@ import PageTransition from '@/components/layout/page-transition'
import { useAdmin } from '@/hooks/use-admin'
import { getDocsUrl } from '@/utils/docs-url'
import { hasPermission } from '@/utils/rbac'
-import { Cpu, LucideIcon, Share2, Plus, Logs } from 'lucide-react'
+import { Cpu, LucideIcon, Share2, Plus, Logs, Network } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useLocation, useNavigate } from 'react-router'
@@ -18,6 +18,7 @@ interface Tab {
const tabs: Tab[] = [
{ id: 'nodes.title', label: 'nodes.title', icon: Share2, url: '/nodes' },
{ id: 'core', label: 'core', icon: Cpu, url: '/nodes/cores' },
+ { id: 'nodes.wireguard.title', label: 'nodes.wireguard.title', icon: Network, url: '/nodes/wireguard' },
{ id: 'nodes.logs.title', label: 'nodes.logs.title', icon: Logs, url: '/nodes/logs' },
]
@@ -34,6 +35,7 @@ const Settings = () => {
const visibleTabs = tabs.filter(tab => {
if (tab.url === '/nodes') return canReadNodes
if (tab.url === '/nodes/cores') return canReadCores
+ if (tab.url === '/nodes/wireguard') return canReadCores
if (tab.url === '/nodes/logs') return canReadNodeLogs
return false
})
@@ -73,6 +75,15 @@ const Settings = () => {
: undefined,
}
}
+ if (location.pathname === '/nodes/wireguard') {
+ return {
+ title: 'nodes.wireguard.title',
+ description: 'nodes.wireguard.description',
+ buttonIcon: undefined,
+ buttonText: undefined,
+ onButtonClick: undefined,
+ }
+ }
if (location.pathname === '/nodes/logs') {
return {
title: 'nodes.logs.title',
diff --git a/dashboard/src/pages/_dashboard.nodes.wireguard.tsx b/dashboard/src/pages/_dashboard.nodes.wireguard.tsx
new file mode 100644
index 000000000..5d067e40c
--- /dev/null
+++ b/dashboard/src/pages/_dashboard.nodes.wireguard.tsx
@@ -0,0 +1,5 @@
+import WireGuardSubnetsList from '@/features/nodes/components/wireguard-subnets-list'
+
+export default function WireGuardSubnetsPage() {
+ return
+}
diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts
index 1ed4dd61c..d19425a43 100644
--- a/dashboard/src/service/api/index.ts
+++ b/dashboard/src/service/api/index.ts
@@ -518,18 +518,6 @@ export type XHttpSettingsXPaddingBytes = string | null
export type XHttpSettingsNoGrpcHeader = boolean | null
-export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes]
-
-// eslint-disable-next-line @typescript-eslint/no-redeclare
-export const XHttpModes = {
- auto: 'auto',
- 'packet-up': 'packet-up',
- 'stream-up': 'stream-up',
- 'stream-one': 'stream-one',
-} as const
-
-export type XHttpSettingsMode = XHttpModes | null
-
export interface XHttpSettings {
mode?: XHttpSettingsMode
no_grpc_header?: XHttpSettingsNoGrpcHeader
@@ -553,6 +541,23 @@ export interface XHttpSettings {
download_settings?: XHttpSettingsDownloadSettings
}
+export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes]
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const XHttpModes = {
+ auto: 'auto',
+ 'packet-up': 'packet-up',
+ 'stream-up': 'stream-up',
+ 'stream-one': 'stream-one',
+} as const
+
+export type XHttpSettingsMode = XHttpModes | null
+
+export interface WorkersHealth {
+ scheduler: WorkerHealth
+ node: WorkerHealth
+}
+
export type WorkerHealthError = string | null
export type WorkerHealthResponseTimeMs = number | null
@@ -563,9 +568,13 @@ export interface WorkerHealth {
error?: WorkerHealthError
}
-export interface WorkersHealth {
- scheduler: WorkerHealth
- node: WorkerHealth
+export interface WireGuardSubnetUsage {
+ subnet: string
+ interface_tags: string[]
+ capacity: number
+ used: number
+ free: number
+ free_ips: string[]
}
export type WireGuardSettingsPublicKey = string | null
@@ -1683,6 +1692,71 @@ export interface RemoveAPIKeysResponse {
count: number
}
+export type RealityScanResultReason = string | null
+
+export type RealityScanResultLatencyMs = number | null
+
+export type RealityScanResultNotAfter = string | null
+
+export type RealityScanResultCertIssuer = string | null
+
+export type RealityScanResultCertSubject = string | null
+
+export type RealityScanResultCurve = string | null
+
+export type RealityScanResultPostQuantum = boolean | null
+
+export type RealityScanResultX25519 = boolean | null
+
+export type RealityScanResultAlpn = string | null
+
+export type RealityScanResultTlsVersion = string | null
+
+export type RealityScanResultSni = string | null
+
+export type RealityScanResultIp = string | null
+
+export interface RealityScanResult {
+ target: string
+ host: string
+ ip?: RealityScanResultIp
+ port: number
+ sni?: RealityScanResultSni
+ sni_discovered?: boolean
+ feasible: boolean
+ tls13: boolean
+ tls_version?: RealityScanResultTlsVersion
+ h2: boolean
+ alpn?: RealityScanResultAlpn
+ x25519?: RealityScanResultX25519
+ post_quantum?: RealityScanResultPostQuantum
+ curve?: RealityScanResultCurve
+ h3?: boolean
+ cert_valid: boolean
+ cert_subject?: RealityScanResultCertSubject
+ cert_issuer?: RealityScanResultCertIssuer
+ not_after?: RealityScanResultNotAfter
+ server_names?: string[]
+ latency_ms?: RealityScanResultLatencyMs
+ reason?: RealityScanResultReason
+}
+
+/**
+ * Per-probe timeout in seconds (1-20, default 10)
+ */
+export type RealityScanRequestTimeout = number | null
+
+export interface RealityScanRequest {
+ /**
+ * host or host:port to probe (port defaults to 443)
+ * @minLength 1
+ * @maxLength 253
+ */
+ target: string
+ /** Per-probe timeout in seconds (1-20, default 10) */
+ timeout?: RealityScanRequestTimeout
+}
+
export interface ProxyTable {
vmess?: VMessSettings
vless?: VlessSettings
@@ -1817,21 +1891,6 @@ export interface NotificationEnable {
percentage_reached?: boolean
}
-/**
- * Per-object notification channels
- */
-export interface NotificationChannels {
- admin?: NotificationChannel
- admin_role?: NotificationChannel
- core?: NotificationChannel
- group?: NotificationChannel
- host?: NotificationChannel
- node?: NotificationChannel
- user?: NotificationChannel
- user_template?: NotificationChannel
- api_key?: NotificationChannel
-}
-
export type NotificationChannelDiscordWebhookUrl = string | null
export type NotificationChannelTelegramTopicId = number | null
@@ -1847,6 +1906,21 @@ export interface NotificationChannel {
discord_webhook_url?: NotificationChannelDiscordWebhookUrl
}
+/**
+ * Per-object notification channels
+ */
+export interface NotificationChannels {
+ admin?: NotificationChannel
+ admin_role?: NotificationChannel
+ core?: NotificationChannel
+ group?: NotificationChannel
+ host?: NotificationChannel
+ node?: NotificationChannel
+ user?: NotificationChannel
+ user_template?: NotificationChannel
+ api_key?: NotificationChannel
+}
+
export interface NotFound {
detail?: string
}
@@ -1874,6 +1948,18 @@ export type NodesPermissionsStatsAnyOf = { [key: string]: PermissionScope | numb
export type NodesPermissionsStats = boolean | NodesPermissionsStatsAnyOf | null
+export interface NodesPermissions {
+ create?: NodesPermissionsCreate
+ read?: NodesPermissionsRead
+ read_simple?: NodesPermissionsReadSimple
+ update?: NodesPermissionsUpdate
+ delete?: NodesPermissionsDelete
+ reconnect?: NodesPermissionsReconnect
+ update_core?: NodesPermissionsUpdateCore
+ logs?: NodesPermissionsLogs
+ stats?: NodesPermissionsStats
+}
+
export type NodesPermissionsLogsAnyOf = { [key: string]: PermissionScope | number }
export type NodesPermissionsLogs = boolean | NodesPermissionsLogsAnyOf | null
@@ -1906,20 +1992,15 @@ export type NodesPermissionsCreateAnyOf = { [key: string]: PermissionScope | num
export type NodesPermissionsCreate = boolean | NodesPermissionsCreateAnyOf | null
-export interface NodesPermissions {
- create?: NodesPermissionsCreate
- read?: NodesPermissionsRead
- read_simple?: NodesPermissionsReadSimple
- update?: NodesPermissionsUpdate
- delete?: NodesPermissionsDelete
- reconnect?: NodesPermissionsReconnect
- update_core?: NodesPermissionsUpdateCore
- logs?: NodesPermissionsLogs
- stats?: NodesPermissionsStats
-}
-
export type NodeUsageStatsListPeriod = Period | null
+export interface NodeUsageStatsList {
+ period?: NodeUsageStatsListPeriod
+ start: string
+ end: string
+ stats: NodeUsageStatsListStats
+}
+
export interface NodeUsageStat {
uplink: number
downlink: number
@@ -1928,13 +2009,6 @@ export interface NodeUsageStat {
export type NodeUsageStatsListStats = { [key: string]: NodeUsageStat[] }
-export interface NodeUsageStatsList {
- period?: NodeUsageStatsListPeriod
- start: string
- end: string
- stats: NodeUsageStatsListStats
-}
-
export type NodeStatus = (typeof NodeStatus)[keyof typeof NodeStatus]
// eslint-disable-next-line @typescript-eslint/no-redeclare
@@ -2283,12 +2357,6 @@ export type HostsPermissionsUpdateAnyOf = { [key: string]: PermissionScope | num
export type HostsPermissionsUpdate = boolean | HostsPermissionsUpdateAnyOf | null
-export interface HostsPermissions {
- create?: HostsPermissionsCreate
- read?: HostsPermissionsRead
- update?: HostsPermissionsUpdate
-}
-
export type HostsPermissionsReadAnyOf = { [key: string]: PermissionScope | number }
export type HostsPermissionsRead = boolean | HostsPermissionsReadAnyOf | null
@@ -2297,6 +2365,12 @@ export type HostsPermissionsCreateAnyOf = { [key: string]: PermissionScope | num
export type HostsPermissionsCreate = boolean | HostsPermissionsCreateAnyOf | null
+export interface HostsPermissions {
+ create?: HostsPermissionsCreate
+ read?: HostsPermissionsRead
+ update?: HostsPermissionsUpdate
+}
+
export interface HostNotificationEnable {
create?: boolean
modify?: boolean
@@ -2362,6 +2436,11 @@ export interface HTTPException {
detail: string
}
+export interface GroupsResponse {
+ groups: GroupResponse[]
+ total: number
+}
+
/**
* Lightweight group model with only id and name for performance.
*/
@@ -2392,11 +2471,6 @@ export interface GroupResponse {
total_users?: number
}
-export interface GroupsResponse {
- groups: GroupResponse[]
- total: number
-}
-
export type GroupModifyInboundTags = string[] | null
export interface GroupModify {
@@ -2559,14 +2633,6 @@ export interface FinalMaskTcpLayerOutput {
export type FinalMaskTcpLayerInputSettingsAnyOf = { [key: string]: unknown }
-export type FinalMaskTcpLayerInputSettings = FinalMaskTcpHeaderCustomSettings | XrayFragmentSettingsInput | FinalMaskSudokuSettings | FinalMaskTcpLayerInputSettingsAnyOf
-
-export interface FinalMaskTcpLayerInput {
- type: FinalMaskTcpType
- settings?: FinalMaskTcpLayerInputSettings
- [key: string]: unknown
-}
-
export type FinalMaskTcpHeaderCustomSettingsErrors = XrayNoiseSettings[][] | null
export type FinalMaskTcpHeaderCustomSettingsServers = XrayNoiseSettings[][] | null
@@ -2602,6 +2668,14 @@ export interface FinalMaskSudokuSettings {
[key: string]: unknown
}
+export type FinalMaskTcpLayerInputSettings = FinalMaskTcpHeaderCustomSettings | XrayFragmentSettingsInput | FinalMaskSudokuSettings | FinalMaskTcpLayerInputSettingsAnyOf
+
+export interface FinalMaskTcpLayerInput {
+ type: FinalMaskTcpType
+ settings?: FinalMaskTcpLayerInputSettings
+ [key: string]: unknown
+}
+
export type FinalMaskQuicParamsMaxIncomingStreams = number | null
export type FinalMaskQuicParamsDisablePathMTUDiscovery = boolean | null
@@ -2779,26 +2853,6 @@ export type CreateHostMuxSettings = MuxSettingsInput | null
export type CreateHostTransportSettings = TransportSettings | null
-export type CreateHostHttpHeadersAnyOf = { [key: string]: string }
-
-export type CreateHostHttpHeaders = CreateHostHttpHeadersAnyOf | null
-
-export type CreateHostAllowinsecure = boolean | null
-
-export type CreateHostAlpn = ProxyHostALPN[] | null
-
-export type CreateHostPath = string | null
-
-export type CreateHostHost = string[] | null
-
-export type CreateHostSni = string[] | null
-
-export type CreateHostPort = number | null
-
-export type CreateHostInboundTag = string | null
-
-export type CreateHostId = number | null
-
export interface CreateHost {
id?: CreateHostId
remark: string
@@ -2832,6 +2886,34 @@ export interface CreateHost {
final_mask_settings?: CreateHostFinalMaskSettings
}
+export type CreateHostHttpHeadersAnyOf = { [key: string]: string }
+
+export type CreateHostHttpHeaders = CreateHostHttpHeadersAnyOf | null
+
+export type CreateHostAllowinsecure = boolean | null
+
+export type CreateHostAlpn = ProxyHostALPN[] | null
+
+export type CreateHostPath = string | null
+
+export type CreateHostHost = string[] | null
+
+export type CreateHostSni = string[] | null
+
+export type CreateHostPort = number | null
+
+export type CreateHostInboundTag = string | null
+
+export type CreateHostId = number | null
+
+/**
+ * Response model for lightweight core list.
+ */
+export interface CoresSimpleResponse {
+ cores: CoreSimple[]
+ total: number
+}
+
export type CoreType = (typeof CoreType)[keyof typeof CoreType]
// eslint-disable-next-line @typescript-eslint/no-redeclare
@@ -2853,14 +2935,6 @@ export interface CoreSimple {
type?: CoreSimpleType
}
-/**
- * Response model for lightweight core list.
- */
-export interface CoresSimpleResponse {
- cores: CoreSimple[]
- total: number
-}
-
export interface CoreResponseList {
count: number
cores?: CoreResponse[]
@@ -2998,6 +3072,14 @@ export type CRUDPermissionsDeleteAnyOf = { [key: string]: PermissionScope | numb
export type CRUDPermissionsDelete = boolean | CRUDPermissionsDeleteAnyOf | null
+export type CRUDPermissionsUpdateAnyOf = { [key: string]: PermissionScope | number }
+
+export type CRUDPermissionsUpdate = boolean | CRUDPermissionsUpdateAnyOf | null
+
+export type CRUDPermissionsReadSimpleAnyOf = { [key: string]: PermissionScope | number }
+
+export type CRUDPermissionsReadSimple = boolean | CRUDPermissionsReadSimpleAnyOf | null
+
/**
* Standard create/read/read_simple/update/delete permissions.
Used directly by: groups, templates, client_templates, cores, admin_roles.
@@ -3011,14 +3093,6 @@ export interface CRUDPermissions {
delete?: CRUDPermissionsDelete
}
-export type CRUDPermissionsUpdateAnyOf = { [key: string]: PermissionScope | number }
-
-export type CRUDPermissionsUpdate = boolean | CRUDPermissionsUpdateAnyOf | null
-
-export type CRUDPermissionsReadSimpleAnyOf = { [key: string]: PermissionScope | number }
-
-export type CRUDPermissionsReadSimple = boolean | CRUDPermissionsReadSimpleAnyOf | null
-
export type CRUDPermissionsReadAnyOf = { [key: string]: PermissionScope | number }
export type CRUDPermissionsRead = boolean | CRUDPermissionsReadAnyOf | null
@@ -3360,6 +3434,14 @@ export type AdminsPermissionsResetUsageAnyOf = { [key: string]: PermissionScope
export type AdminsPermissionsResetUsage = boolean | AdminsPermissionsResetUsageAnyOf | null
+export type AdminsPermissionsDeleteAnyOf = { [key: string]: PermissionScope | number }
+
+export type AdminsPermissionsDelete = boolean | AdminsPermissionsDeleteAnyOf | null
+
+export type AdminsPermissionsUpdateAnyOf = { [key: string]: PermissionScope | number }
+
+export type AdminsPermissionsUpdate = boolean | AdminsPermissionsUpdateAnyOf | null
+
export interface AdminsPermissions {
create?: AdminsPermissionsCreate
read?: AdminsPermissionsRead
@@ -3369,14 +3451,6 @@ export interface AdminsPermissions {
reset_usage?: AdminsPermissionsResetUsage
}
-export type AdminsPermissionsDeleteAnyOf = { [key: string]: PermissionScope | number }
-
-export type AdminsPermissionsDelete = boolean | AdminsPermissionsDeleteAnyOf | null
-
-export type AdminsPermissionsUpdateAnyOf = { [key: string]: PermissionScope | number }
-
-export type AdminsPermissionsUpdate = boolean | AdminsPermissionsUpdateAnyOf | null
-
export type AdminsPermissionsReadSimpleAnyOf = { [key: string]: PermissionScope | number }
export type AdminsPermissionsReadSimple = boolean | AdminsPermissionsReadSimpleAnyOf | null
@@ -6807,6 +6881,60 @@ export function useGetInboundDetails {
+ return orvalFetcher({ url: `/api/wireguard/subnets`, method: 'GET', signal })
+}
+
+export const getGetWireguardSubnetsQueryKey = () => {
+ return [`/api/wireguard/subnets`] as const
+}
+
+export const getGetWireguardSubnetsQueryOptions = >, TError = ErrorType>(options?: {
+ query?: Partial>, TError, TData>>
+}) => {
+ const { query: queryOptions } = options ?? {}
+
+ const queryKey = queryOptions?.queryKey ?? getGetWireguardSubnetsQueryKey()
+
+ const queryFn: QueryFunction>> = ({ signal }) => getWireguardSubnets(signal)
+
+ return { queryKey, queryFn, ...queryOptions } as UseQueryOptions>, TError, TData> & { queryKey: DataTag }
+}
+
+export type GetWireguardSubnetsQueryResult = NonNullable>>
+export type GetWireguardSubnetsQueryError = ErrorType
+
+export function useGetWireguardSubnets>, TError = ErrorType>(options: {
+ query: Partial>, TError, TData>> &
+ Pick>, TError, TData>, 'initialData'>
+}): DefinedUseQueryResult & { queryKey: DataTag }
+export function useGetWireguardSubnets>, TError = ErrorType>(options?: {
+ query?: Partial>, TError, TData>> &
+ Pick>, TError, TData>, 'initialData'>
+}): UseQueryResult & { queryKey: DataTag }
+export function useGetWireguardSubnets>, TError = ErrorType>(options?: {
+ query?: Partial>, TError, TData>>
+}): UseQueryResult & { queryKey: DataTag }
+/**
+ * @summary Get Wireguard Subnets
+ */
+
+export function useGetWireguardSubnets>, TError = ErrorType>(options?: {
+ query?: Partial>, TError, TData>>
+}): UseQueryResult & { queryKey: DataTag } {
+ const queryOptions = getGetWireguardSubnetsQueryOptions(options)
+
+ const query = useQuery(queryOptions) as UseQueryResult & { queryKey: DataTag }
+
+ query.queryKey = queryOptions.queryKey
+
+ return query
+}
+
/**
* @summary Get Workers Health
*/
@@ -7635,6 +7763,51 @@ export const useCreateCoreConfig = , signal?: AbortSignal) => {
+ return orvalFetcher({ url: `/api/core/reality-scan`, method: 'POST', headers: { 'Content-Type': 'application/json' }, data: realityScanRequest, signal })
+}
+
+export const getScanRealityTargetMutationOptions = <
+ TData = Awaited>,
+ TError = ErrorType,
+ TContext = unknown,
+>(options?: {
+ mutation?: UseMutationOptions }, TContext>
+}) => {
+ const mutationKey = ['scanRealityTarget']
+ const { mutation: mutationOptions } = options
+ ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
+ ? options
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
+ : { mutation: { mutationKey } }
+
+ const mutationFn: MutationFunction>, { data: BodyType }> = props => {
+ const { data } = props ?? {}
+
+ return scanRealityTarget(data)
+ }
+
+ return { mutationFn, ...mutationOptions } as UseMutationOptions }, TContext>
+}
+
+export type ScanRealityTargetMutationResult = NonNullable>>
+export type ScanRealityTargetMutationBody = BodyType
+export type ScanRealityTargetMutationError = ErrorType
+
+/**
+ * @summary Scan Reality Target
+ */
+export const useScanRealityTarget = >, TError = ErrorType, TContext = unknown>(options?: {
+ mutation?: UseMutationOptions }, TContext>
+}): UseMutationResult }, TContext> => {
+ const mutationOptions = getScanRealityTargetMutationOptions(options)
+
+ return useMutation(mutationOptions)
+}
+
/**
* Get a core configuration by its ID.
* @summary Get Core Config
diff --git a/dashboard/src/utils/rbac.ts b/dashboard/src/utils/rbac.ts
index d066f20f0..d5a2d58ca 100644
--- a/dashboard/src/utils/rbac.ts
+++ b/dashboard/src/utils/rbac.ts
@@ -85,6 +85,7 @@ export const canAccessRoute = (admin: AdminDetails | null | undefined, pathname:
if (pathname === '/nodes/cores') return canReadResourcePage(admin, 'cores')
if (pathname === '/nodes/cores/new') return hasPermission(admin, 'cores', 'create')
if (pathname.startsWith('/nodes/cores/')) return hasPermission(admin, 'cores', 'update')
+ if (pathname.startsWith('/nodes/wireguard')) return canReadResourcePage(admin, 'cores')
if (pathname.startsWith('/nodes/logs')) return hasPermission(admin, 'nodes', 'logs')
if (pathname === '/nodes') return canReadResourcePage(admin, 'nodes')
if (pathname.startsWith('/nodes')) return canReadResourcePage(admin, 'nodes')
From 0aefa3e4d63cb7b16c6e7f1b875274461d5f5ecc Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:46:12 +0330
Subject: [PATCH 5/7] fix: user allocation sync and enhance peer IP sorting in
WireGuard
---
Makefile | 4 ++--
app/db/crud/wireguard.py | 5 ++++-
app/operation/group.py | 18 ++++++++++++------
tests/test_wireguard_alloc_unit.py | 14 ++++++++++++++
4 files changed, 32 insertions(+), 9 deletions(-)
diff --git a/Makefile b/Makefile
index 3963e51b1..25ad8543a 100644
--- a/Makefile
+++ b/Makefile
@@ -68,12 +68,12 @@ check-bun: check-nodejs
# Install frontend dependencies (Node.js packages)
.PHONY: install-front
install-front: check-bun
- @cd dashboard && bun install
+ @cd dashboard && bun install
# Run database migrations using Alembic
.PHONY: run-migration
run-migration:
- @uv run alembic upgrade head
+ @uv run alembic upgrade head
.PHONY: check-migrations
check-migrations:
diff --git a/app/db/crud/wireguard.py b/app/db/crud/wireguard.py
index af5b98cfd..b60760cc9 100644
--- a/app/db/crud/wireguard.py
+++ b/app/db/crud/wireguard.py
@@ -379,7 +379,10 @@ async def sync_users_allocations(
for key in sorted(targets - set(kept)):
kept[key] = _take_offset(namespaces[key], rows[key])
- new_ips = [render_peer_ip(namespaces[key].subnet, offset) for key, offset in sorted(kept.items())]
+ new_ips = sorted(
+ (render_peer_ip(namespaces[key].subnet, offset) for key, offset in kept.items()),
+ key=_peer_sort_key,
+ )
user_changed = False
if new_ips != old_ips:
_set_user_peer_ips(user, new_ips)
diff --git a/app/operation/group.py b/app/operation/group.py
index 66660eadf..394e5537f 100644
--- a/app/operation/group.py
+++ b/app/operation/group.py
@@ -50,6 +50,12 @@ async def _get_group_with_access(self, db: AsyncSession, group_id: int, admin: A
db_group = await self.get_validated_group(db, group_id)
return db_group
+ async def _sync_users_allocations(self, db: AsyncSession, users) -> None:
+ try:
+ await sync_users_allocations(db, users)
+ except ValueError as exc: # WireGuard subnet exhausted
+ await self.raise_error(message=str(exc), code=400, db=db)
+
async def create_group(self, db: AsyncSession, new_group: GroupCreate, admin: Admin) -> Group:
await self.check_inbound_tags(new_group.inbound_tags)
db_group = await create_group(db, new_group)
@@ -89,7 +95,7 @@ async def modify_group(self, db: AsyncSession, group_id: int, modified_group: Gr
query=UserListQuery(group_ids=[db_group.id]),
load_admin_role=True,
)
- await sync_users_allocations(db, users)
+ await self._sync_users_allocations(db, users)
await db.commit()
await sync_users(users)
@@ -109,7 +115,7 @@ async def remove_group(self, db: AsyncSession, group_id: int, admin: Admin) -> N
await remove_group(db, db_group)
users = await get_users(db, query=UserListQuery(username=username_list), load_admin_role=True)
- await sync_users_allocations(db, users)
+ await self._sync_users_allocations(db, users)
await db.commit()
await sync_users(users)
@@ -124,7 +130,7 @@ async def bulk_add_groups(self, db: AsyncSession, bulk_model: BulkGroup):
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await add_groups_to_users(db, bulk_model)
- await sync_users_allocations(db, users)
+ await self._sync_users_allocations(db, users)
await db.commit()
await sync_users(users)
@@ -139,7 +145,7 @@ async def bulk_remove_groups(self, db: AsyncSession, bulk_model: BulkGroup):
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await remove_groups_from_users(db, bulk_model)
- await sync_users_allocations(db, users)
+ await self._sync_users_allocations(db, users)
await db.commit()
await sync_users(users)
@@ -175,7 +181,7 @@ async def bulk_remove_groups_by_id(
users = await get_users(
db, query=UserListQuery(username=list(all_affected_usernames)), load_admin_role=True
)
- await sync_users_allocations(db, users)
+ await self._sync_users_allocations(db, users)
await db.commit()
await sync_users(users)
@@ -223,7 +229,7 @@ async def bulk_set_groups_disabled(
query=UserListQuery(group_ids=[group.id for group in groups_to_update]),
load_admin_role=True,
)
- await sync_users_allocations(db, users)
+ await self._sync_users_allocations(db, users)
await db.commit()
await sync_users(users)
diff --git a/tests/test_wireguard_alloc_unit.py b/tests/test_wireguard_alloc_unit.py
index 55c2ec58e..8b7bfd555 100644
--- a/tests/test_wireguard_alloc_unit.py
+++ b/tests/test_wireguard_alloc_unit.py
@@ -5,6 +5,7 @@
from app.db.crud.wireguard import (
_give_back,
+ _peer_sort_key,
_take_offset,
_usable_capacity,
match_namespace,
@@ -143,3 +144,16 @@ def test_pick_peer_ip_for_inbound():
assert pick_peer_ip_for_inbound(["10.2.0.1/24", "fd00::1/64"], peer_ips) == ["10.2.0.9/32", "fd00::5/128"]
assert pick_peer_ip_for_inbound(["10.3.0.1/24"], peer_ips) == []
assert pick_peer_ip_for_inbound(["fd00::1/64"], peer_ips) == ["fd00::5/128"]
+
+
+def test_peer_sort_key_orders_by_host_not_cidr_string():
+ """sync/reconcile must compare peer_ips in host order, not lexical CIDR key order."""
+ namespaces = wg_namespaces([core("WG_A", ["10.10.0.1/24"]), core("WG_B", ["10.2.0.1/24"])])
+ kept = {"10.10.0.0/24": 2, "10.2.0.0/24": 2}
+ by_key = [render_peer_ip(namespaces[key].subnet, offset) for key, offset in sorted(kept.items())]
+ by_peer = sorted(
+ (render_peer_ip(namespaces[key].subnet, offset) for key, offset in kept.items()),
+ key=_peer_sort_key,
+ )
+ assert by_key == ["10.10.0.2/32", "10.2.0.2/32"]
+ assert by_peer == ["10.2.0.2/32", "10.10.0.2/32"]
From 30b45187ee93ab44dac5da4c6638f7962400165e Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:47:55 +0330
Subject: [PATCH 6/7] fix: correct casing in useGetWireguardSubnets import
---
.../src/features/nodes/components/wireguard-subnets-list.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx b/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx
index 05c752f76..69c82c347 100644
--- a/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx
+++ b/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
-import { useGetWireGuardSubnets, type WireGuardSubnetUsage } from '@/service/api'
+import { useGetWireguardSubnets, type WireGuardSubnetUsage } from '@/service/api'
import { cn } from '@/lib/utils'
import { ChevronDown, RefreshCw } from 'lucide-react'
import { useMemo, useState } from 'react'
@@ -95,7 +95,7 @@ function SubnetCard({ row }: { row: WireGuardSubnetUsage }) {
export default function WireGuardSubnetsList() {
const { t } = useTranslation()
- const { data, isLoading, isFetching, refetch } = useGetWireGuardSubnets()
+ const { data, isLoading, isFetching, refetch } = useGetWireguardSubnets()
const rows = useMemo(() => data ?? [], [data])
return (
From 1106cf798cd47d52eb2e8583779e2b2cfe9e3081 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Wed, 22 Jul 2026 13:39:02 +0330
Subject: [PATCH 7/7] fix: enhance type hints for IP address handling in
WireGuard functions
---
app/db/crud/wireguard.py | 24 +++++++++++++++---------
1 file changed, 15 insertions(+), 9 deletions(-)
diff --git a/app/db/crud/wireguard.py b/app/db/crud/wireguard.py
index b60760cc9..25deba88c 100644
--- a/app/db/crud/wireguard.py
+++ b/app/db/crud/wireguard.py
@@ -2,7 +2,7 @@
import json
from dataclasses import dataclass
-from ipaddress import IPv4Address, IPv6Address, ip_interface, ip_network
+from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_interface, ip_network
from typing import Iterable
from sqlalchemy import and_, delete, insert, select
@@ -30,6 +30,8 @@
# reclaimable on reconcile (upgrade: sparse bitmap / run-length encoding).
FREE_OFFSETS_CAP = FREE_IPS_LIMIT
+IpNetwork = IPv4Network | IPv6Network
+IpAddress = IPv4Address | IPv6Address
# --- pure helpers -----------------------------------------------------------
@@ -41,9 +43,9 @@ def core_config_dict(core: CoreConfig) -> dict:
return cfg
-def wg_core_subnets(config: dict) -> list:
+def wg_core_subnets(config: dict) -> list[IpNetwork]:
"""Unique client subnets of a WG core (IPv4 and/or IPv6), from interface addresses."""
- seen: dict[str, object] = {}
+ seen: dict[str, IpNetwork] = {}
for cidr in (config or {}).get("address") or []:
try:
net = ip_interface(str(cidr).strip()).network
@@ -53,7 +55,7 @@ def wg_core_subnets(config: dict) -> list:
return list(seen.values())
-def render_peer_ip(subnet, offset: int) -> str:
+def render_peer_ip(subnet: IpNetwork, offset: int) -> str:
host = type(subnet.network_address)(int(subnet.network_address) + offset)
return f"{host}/{'32' if subnet.version == 4 else '128'}"
@@ -67,7 +69,7 @@ def peer_host(entry: str) -> tuple[int, int] | None:
return net.version, int(net.network_address)
-def _host_address(version: int, host_int: int):
+def _host_address(version: int, host_int: int) -> IpAddress:
return IPv4Address(host_int) if version == 4 else IPv6Address(host_int)
@@ -76,13 +78,13 @@ class WgNamespace:
"""One allocation namespace: an exact client subnet shared by every WG core that uses it."""
key: str # canonical str(subnet), e.g. "10.0.0.0/24" or "fd00::/64"
- subnet: object # IPv4Network | IPv6Network
+ subnet: IpNetwork
tags: frozenset[str]
reserved: frozenset[int] # network, last, and server interface offsets
def wg_namespaces(cores: Iterable[CoreConfig]) -> dict[str, WgNamespace]:
- by_key: dict[str, list[tuple[dict, object]]] = {}
+ by_key: dict[str, list[tuple[dict, IpNetwork]]] = {}
for core in cores:
cfg = core_config_dict(core)
for subnet in wg_core_subnets(cfg):
@@ -367,7 +369,9 @@ async def sync_users_allocations(
kept: dict[str, int] = {}
for entry in old_ips:
host = peer_host(entry)
- ns = match_namespace(namespaces, *host) if host is not None else None
+ if host is None:
+ continue
+ ns = match_namespace(namespaces, *host)
if ns is None:
continue # foreign/legacy entry: drop, nothing to give back
offset = host[1] - int(ns.subnet.network_address)
@@ -473,7 +477,9 @@ async def reconcile_wireguard_subnets(db: AsyncSession) -> list[int]:
kept: dict[str, int] = {}
for entry in old_ips:
host = peer_host(entry)
- ns = match_namespace(namespaces, *host) if host is not None else None
+ if host is None:
+ continue
+ ns = match_namespace(namespaces, *host)
if ns is None:
continue
offset = host[1] - int(ns.subnet.network_address)