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:
Kuzz007
2026-08-04 14:13:20 +03:00
parent 569b7fbc6b
commit 878ee839db
13 changed files with 500 additions and 62 deletions
+1
View File
@@ -236,6 +236,7 @@ export const EXAMPLES: Record<string, unknown> = {
"allowedIPs": [
""
],
"allowedIPsByInbound": {},
"auth": "",
"comment": "",
"created_at": 0,
+10
View File
@@ -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"
+1
View File
@@ -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;
+1
View File
@@ -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(),
+102 -30
View File
@@ -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}
+7
View File
@@ -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'] });
});
});