mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-08 22:25:00 +03:00
Split the client edit form's AllowedIPs into per-protocol fields
A client attached to both WireGuard and AmneziaWG shared one AllowedIPs form field with a dynamically-switching label, so its two genuinely different addresses could never both be shown or edited correctly. Worse, Update/Create broadcast that one shared value to every attached wg/awg inbound with no subnet-fit check, so an ordinary edit save could silently overwrite one protocol's address with the other's -- the same bug class already fixed for Attach, but reachable from any client edit. model.Client gains an optional AllowedIPsByInbound map so a caller can send distinct values per inbound; Update/Create honor it and, when it's absent, clear a shared value that doesn't fit an AmneziaWG inbound's own subnet instead of writing it through. A new TunnelAllowedIPsByInbound read path feeds the real per-inbound address to the client edit form via GET, which now renders two separate, correctly-labeled fields whenever both protocols are attached (unchanged single dynamic field otherwise).
This commit is contained in:
@@ -236,6 +236,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"allowedIPs": [
|
||||
""
|
||||
],
|
||||
"allowedIPsByInbound": {},
|
||||
"auth": "",
|
||||
"comment": "",
|
||||
"created_at": 0,
|
||||
|
||||
@@ -1002,6 +1002,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"allowedIPsByInbound": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"description": "AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound\nbasis, keyed by inbound id. Lets one identity attached to both\nWireGuard and AmneziaWG carry two genuinely different addresses in a\nsingle Create/Update call instead of the shared AllowedIPs field\nbeing broadcast to every attached tunnel inbound. Absent/unset for a\ngiven inbound id falls back to the shared AllowedIPs exactly as\nbefore -- fully backward compatible for callers that never set this.",
|
||||
"type": "object"
|
||||
},
|
||||
"auth": {
|
||||
"description": "Auth password (Hysteria)",
|
||||
"type": "string"
|
||||
|
||||
@@ -245,6 +245,7 @@ export interface ApiTokenView {
|
||||
export interface Client {
|
||||
adTag?: string;
|
||||
allowedIPs?: string[];
|
||||
allowedIPsByInbound?: Record<number, string[]>;
|
||||
auth?: string;
|
||||
comment: string;
|
||||
created_at?: number;
|
||||
|
||||
@@ -263,6 +263,7 @@ export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
||||
export const ClientSchema = z.object({
|
||||
adTag: z.string().optional(),
|
||||
allowedIPs: z.array(z.string()).optional(),
|
||||
allowedIPsByInbound: z.record(z.number().int(), z.array(z.string())).optional(),
|
||||
auth: z.string().optional(),
|
||||
comment: z.string(),
|
||||
created_at: z.number().int().optional(),
|
||||
|
||||
@@ -85,6 +85,7 @@ interface ClientFormModalProps {
|
||||
inbounds: InboundOption[];
|
||||
attachedExternalLinks?: ExternalLink[];
|
||||
attachedIds?: number[];
|
||||
tunnelAllowedIPs?: Record<number, string>;
|
||||
tgBotEnable?: boolean;
|
||||
groups?: string[];
|
||||
save: (
|
||||
@@ -102,6 +103,7 @@ type Values = ClientFormValues & {
|
||||
wgPublicKey: string;
|
||||
wgPreSharedKey: string;
|
||||
wgAllowedIPs: string;
|
||||
awgAllowedIPs: string;
|
||||
awgForwardedPorts: string;
|
||||
secret: string;
|
||||
adTag: string;
|
||||
@@ -132,6 +134,7 @@ const EMPTY: Values = {
|
||||
wgPublicKey: '',
|
||||
wgPreSharedKey: '',
|
||||
wgAllowedIPs: '',
|
||||
awgAllowedIPs: '',
|
||||
awgForwardedPorts: '',
|
||||
secret: '',
|
||||
adTag: '',
|
||||
@@ -155,6 +158,31 @@ export function gbToBytes(gb: number): number {
|
||||
return Math.round(gb * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
export function parseAllowedIPsList(raw: string): string[] {
|
||||
return raw.split(',').map((s) => s.trim()).filter((s) => s !== '');
|
||||
}
|
||||
|
||||
// Maps each of the two AllowedIPs fields to the specific wg/awg inbound the
|
||||
// client is currently attached to, so a save with both protocols attached at
|
||||
// once can send each its own value instead of one shared field ambiguously
|
||||
// covering both (see model.Client.AllowedIPsByInbound on the Go side).
|
||||
// Absent from the result when the client isn't actually attached to that
|
||||
// protocol's inbound (e.g. mid-edit, before the attach takes effect).
|
||||
export function resolveTunnelAllowedIPsByInbound(
|
||||
attachedInboundIds: number[],
|
||||
wireguardInboundIds: Set<number>,
|
||||
amneziawgInboundIds: Set<number>,
|
||||
wgAllowedIPs: string[],
|
||||
awgAllowedIPs: string[],
|
||||
): Record<number, string[]> {
|
||||
const wgId = attachedInboundIds.find((id) => wireguardInboundIds.has(id));
|
||||
const awgId = attachedInboundIds.find((id) => amneziawgInboundIds.has(id));
|
||||
const result: Record<number, string[]> = {};
|
||||
if (wgId != null) result[wgId] = wgAllowedIPs;
|
||||
if (awgId != null) result[awgId] = awgAllowedIPs;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number {
|
||||
if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
|
||||
return originalBytes;
|
||||
@@ -169,6 +197,7 @@ export default function ClientFormModal({
|
||||
inbounds,
|
||||
attachedExternalLinks = [],
|
||||
attachedIds = [],
|
||||
tunnelAllowedIPs = {},
|
||||
tgBotEnable = false,
|
||||
groups = [],
|
||||
save,
|
||||
@@ -210,6 +239,27 @@ export default function ClientFormModal({
|
||||
const limitIpDisabled = !fail2ban.usable;
|
||||
const limitIpNotice = getLimitIpNotice(fail2ban, t);
|
||||
|
||||
// Declared ahead of the seeding effect below (which needs them to resolve
|
||||
// which specific wg/awg inbound this client is attached to, for seeding
|
||||
// wgAllowedIPs/awgAllowedIPs from tunnelAllowedIPs) -- both are pure
|
||||
// derivations of the stable `inbounds` prop, so moving them earlier is
|
||||
// just a declaration-order change, not a behavior change.
|
||||
const wireguardIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row && row.protocol === 'wireguard') ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const amneziawgIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row && row.protocol === 'amneziawg') ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
function addExternalLinkRow(kind: 'link' | 'subscription') {
|
||||
appendExternalLink({ kind, value: '', remark: '' });
|
||||
}
|
||||
@@ -220,6 +270,11 @@ export default function ClientFormModal({
|
||||
|
||||
if (isEdit && client) {
|
||||
const et = Number(client.expiryTime) || 0;
|
||||
const seedIds = Array.isArray(attachedIds) ? attachedIds : [];
|
||||
const attachedWireguardId = seedIds.find((id) => wireguardIds.has(id));
|
||||
const attachedAmneziawgId = seedIds.find((id) => amneziawgIds.has(id));
|
||||
const wgTunnelIPs = attachedWireguardId != null ? tunnelAllowedIPs[attachedWireguardId] : undefined;
|
||||
const awgTunnelIPs = attachedAmneziawgId != null ? tunnelAllowedIPs[attachedAmneziawgId] : undefined;
|
||||
const seed: Values = {
|
||||
...EMPTY,
|
||||
email: client.email || '',
|
||||
@@ -244,7 +299,8 @@ export default function ClientFormModal({
|
||||
wgPrivateKey: client.privateKey || '',
|
||||
wgPublicKey: client.publicKey || '',
|
||||
wgPreSharedKey: client.preSharedKey || '',
|
||||
wgAllowedIPs: client.allowedIPs || '',
|
||||
wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
|
||||
awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
|
||||
awgForwardedPorts: client.forwardedPorts || '',
|
||||
secret: client.secret || '',
|
||||
adTag: client.adTag || '',
|
||||
@@ -301,22 +357,6 @@ export default function ClientFormModal({
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const wireguardIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row && row.protocol === 'wireguard') ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const amneziawgIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row && row.protocol === 'amneziawg') ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const mtprotoIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
@@ -554,12 +594,25 @@ export default function ClientFormModal({
|
||||
if (values.wgPreSharedKey) {
|
||||
clientPayload.preSharedKey = values.wgPreSharedKey;
|
||||
}
|
||||
const allowedIPs = values.wgAllowedIPs
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
if (allowedIPs.length > 0) {
|
||||
clientPayload.allowedIPs = allowedIPs;
|
||||
const wgAllowedIPs = parseAllowedIPsList(values.wgAllowedIPs);
|
||||
if (showWireguard && showAmneziawg) {
|
||||
// Both protocols are attached at once: the two fields hold genuinely
|
||||
// different addresses, so each must land on its own inbound instead
|
||||
// of one broadcast value overwriting the other's (allowedIPsByInbound
|
||||
// is what Update/Create key their per-inbound override off of).
|
||||
const awgAllowedIPs = parseAllowedIPsList(values.awgAllowedIPs);
|
||||
clientPayload.allowedIPsByInbound = resolveTunnelAllowedIPsByInbound(
|
||||
values.inboundIds || [],
|
||||
wireguardIds,
|
||||
amneziawgIds,
|
||||
wgAllowedIPs,
|
||||
awgAllowedIPs,
|
||||
);
|
||||
if (wgAllowedIPs.length > 0) {
|
||||
clientPayload.allowedIPs = wgAllowedIPs;
|
||||
}
|
||||
} else if (wgAllowedIPs.length > 0) {
|
||||
clientPayload.allowedIPs = wgAllowedIPs;
|
||||
}
|
||||
// Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
|
||||
// has no host-level iptables layer to hang per-client DNAT off of.
|
||||
@@ -901,13 +954,32 @@ export default function ClientFormModal({
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="wgAllowedIPs"
|
||||
label={t(showAmneziawg ? 'pages.clients.amneziaWgAllowedIPs' : 'pages.clients.wireguardAllowedIPs')}
|
||||
extra={t(showAmneziawg ? 'pages.clients.amneziaWgAllowedIPsHint' : 'pages.clients.wireguardAllowedIPsHint')}
|
||||
>
|
||||
<Input placeholder="10.8.1.2/32" />
|
||||
</FormField>
|
||||
{showWireguard && showAmneziawg ? (
|
||||
<>
|
||||
<FormField
|
||||
name="wgAllowedIPs"
|
||||
label={t('pages.clients.wireguardAllowedIPs')}
|
||||
extra={t('pages.clients.wireguardAllowedIPsHint')}
|
||||
>
|
||||
<Input placeholder="10.0.0.2/32" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="awgAllowedIPs"
|
||||
label={t('pages.clients.amneziaWgAllowedIPs')}
|
||||
extra={t('pages.clients.amneziaWgAllowedIPsHint')}
|
||||
>
|
||||
<Input placeholder="10.8.1.2/32" />
|
||||
</FormField>
|
||||
</>
|
||||
) : (
|
||||
<FormField
|
||||
name="wgAllowedIPs"
|
||||
label={t(showAmneziawg ? 'pages.clients.amneziaWgAllowedIPs' : 'pages.clients.wireguardAllowedIPs')}
|
||||
extra={t(showAmneziawg ? 'pages.clients.amneziaWgAllowedIPsHint' : 'pages.clients.wireguardAllowedIPsHint')}
|
||||
>
|
||||
<Input placeholder="10.8.1.2/32" />
|
||||
</FormField>
|
||||
)}
|
||||
{showAmneziawg && (
|
||||
<FormField
|
||||
name="awgForwardedPorts"
|
||||
|
||||
@@ -256,6 +256,7 @@ export default function ClientsPage() {
|
||||
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
|
||||
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
|
||||
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
|
||||
const [editingTunnelAllowedIPs, setEditingTunnelAllowedIPs] = useState<Record<number, string>>({});
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
@@ -503,6 +504,7 @@ export default function ClientsPage() {
|
||||
setEditingClient(null);
|
||||
setEditingAttachedIds([]);
|
||||
setEditingExternalLinks([]);
|
||||
setEditingTunnelAllowedIPs({});
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
@@ -518,6 +520,7 @@ export default function ClientsPage() {
|
||||
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
|
||||
setEditingAttachedIds([...ids]);
|
||||
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||
setEditingTunnelAllowedIPs(full?.tunnelAllowedIPs ?? {});
|
||||
setFormOpen(true);
|
||||
}, [hydrate]);
|
||||
|
||||
@@ -1506,6 +1509,7 @@ export default function ClientsPage() {
|
||||
client={editingClient}
|
||||
attachedIds={editingAttachedIds}
|
||||
attachedExternalLinks={editingExternalLinks}
|
||||
tunnelAllowedIPs={editingTunnelAllowedIPs}
|
||||
inbounds={inbounds}
|
||||
tgBotEnable={tgBotEnable}
|
||||
groups={allGroups}
|
||||
|
||||
@@ -133,10 +133,17 @@ export const ExternalLinkSchema = z.object({
|
||||
|
||||
export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
|
||||
|
||||
// tunnelAllowedIPs carries the real, per-inbound AllowedIPs value (keyed by
|
||||
// inbound id) for every WireGuard/AmneziaWG inbound this client is attached
|
||||
// to. ClientRecord's own allowedIPs is a single string and cannot represent
|
||||
// two different addresses when one identity holds both a WireGuard and an
|
||||
// AmneziaWG attachment at once -- this is what lets the edit form show each
|
||||
// protocol's real, distinct address instead of one ambiguous shared field.
|
||||
export const ClientHydrateSchema = z.object({
|
||||
client: ClientRecordSchema,
|
||||
inboundIds: nullableNumberArray,
|
||||
externalLinks: ExternalLinkListSchema.optional(),
|
||||
tunnelAllowedIPs: z.record(z.number().int(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const BulkAdjustResultSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { parseAllowedIPsList, resolveTunnelAllowedIPsByInbound } from '@/pages/clients/ClientFormModal';
|
||||
|
||||
describe('parseAllowedIPsList', () => {
|
||||
it('splits, trims, and drops empty entries', () => {
|
||||
expect(parseAllowedIPsList(' 10.0.0.2/32 , 10.0.0.3/32,')).toEqual(['10.0.0.2/32', '10.0.0.3/32']);
|
||||
});
|
||||
|
||||
it('returns an empty array for a blank string', () => {
|
||||
expect(parseAllowedIPsList('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTunnelAllowedIPsByInbound', () => {
|
||||
// Regression coverage for the bug this whole feature exists to fix: a
|
||||
// client attached to both a WireGuard and an AmneziaWG inbound must get
|
||||
// each protocol's own address routed to its own inbound id, never the
|
||||
// other's -- a single shared field can't represent two different
|
||||
// addresses, which is exactly what confused wg's 10.0.0.2/32 with awg's
|
||||
// 10.8.1.0/24 subnet in the real production bug report.
|
||||
it('maps each protocol field to its own attached inbound id', () => {
|
||||
const wireguardIds = new Set([7]);
|
||||
const amneziawgIds = new Set([10]);
|
||||
const result = resolveTunnelAllowedIPsByInbound(
|
||||
[7, 10],
|
||||
wireguardIds,
|
||||
amneziawgIds,
|
||||
['10.0.0.2/32'],
|
||||
['10.8.1.21/32'],
|
||||
);
|
||||
expect(result).toEqual({ 7: ['10.0.0.2/32'], 10: ['10.8.1.21/32'] });
|
||||
});
|
||||
|
||||
it('omits a protocol entirely when its inbound is not among the attached ids', () => {
|
||||
const wireguardIds = new Set([7]);
|
||||
const amneziawgIds = new Set([10]);
|
||||
const result = resolveTunnelAllowedIPsByInbound([7], wireguardIds, amneziawgIds, ['10.0.0.2/32'], ['10.8.1.21/32']);
|
||||
expect(result).toEqual({ 7: ['10.0.0.2/32'] });
|
||||
expect(result).not.toHaveProperty('10');
|
||||
});
|
||||
|
||||
it('returns an empty object when neither protocol is attached', () => {
|
||||
const result = resolveTunnelAllowedIPsByInbound([3], new Set([7]), new Set([10]), ['x'], ['y']);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('picks the first matching id when multiple inbounds of the same protocol are attached', () => {
|
||||
const wireguardIds = new Set([7, 8]);
|
||||
const amneziawgIds = new Set([10]);
|
||||
const result = resolveTunnelAllowedIPsByInbound([8, 7, 10], wireguardIds, amneziawgIds, ['10.0.0.2/32'], ['10.8.1.21/32']);
|
||||
expect(result).toEqual({ 8: ['10.0.0.2/32'], 10: ['10.8.1.21/32'] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user