@@ -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/nodes/components/wireguard-subnets-list.tsx b/dashboard/src/features/nodes/components/wireguard-subnets-list.tsx
new file mode 100644
index 000000000..69c82c347
--- /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/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/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 2cd64af54..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
@@ -578,15 +587,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
@@ -1692,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
@@ -1826,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
@@ -1856,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
}
@@ -1883,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
@@ -1915,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
@@ -1937,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
@@ -2292,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
@@ -2306,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
@@ -2371,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.
*/
@@ -2401,11 +2471,6 @@ export interface GroupResponse {
total_users?: number
}
-export interface GroupsResponse {
- groups: GroupResponse[]
- total: number
-}
-
export type GroupModifyInboundTags = string[] | null
export interface GroupModify {
@@ -2568,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
@@ -2611,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
@@ -2788,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
@@ -2841,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
@@ -2862,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[]
@@ -3007,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.
@@ -3020,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
@@ -3036,25 +3101,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
@@ -3388,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
@@ -3397,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
@@ -6835,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
*/
@@ -7663,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
@@ -13481,62 +13626,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..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')
@@ -95,7 +96,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..b316040e3 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,285 @@ 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"]
+ 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"]
- # 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")},
- )
- peer_ips2 = user2["proxy_settings"]["wireguard"]["peer_ips"]
- assert isinstance(peer_ips2, list)
- assert len(peer_ips2) == 1
+ 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"]
- # 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
-
- 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"])
+ # 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:
+ 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_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
diff --git a/tests/test_wireguard_alloc_unit.py b/tests/test_wireguard_alloc_unit.py
new file mode 100644
index 000000000..8b7bfd555
--- /dev/null
+++ b/tests/test_wireguard_alloc_unit.py
@@ -0,0 +1,159 @@
+from ipaddress import ip_network
+from types import SimpleNamespace
+
+import pytest
+
+from app.db.crud.wireguard import (
+ _give_back,
+ _peer_sort_key,
+ _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"]
+
+
+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"]