feat(amneziawg): add frontend support and fix a Go->Zod generator gap

Wires the amneziawg protocol through the panel UI the same way every
other protocol is registered: a Zod settings schema (nested
{server, clients}, matching the Go JSON exactly), the protocol enum,
the inbound-form's per-protocol fields component and its
tab-visibility allowlist, the default-settings factory, the client
schema dispatcher, and the sniffing-capability exclusion (no Xray
inbound exists for amneziawg, same as mtproto).

Client key/allowedIPs fields are reused rather than duplicated: since
AmneziaWG clients are wire-identical to WireGuard clients (same
model.Client fields), ClientFormModal renders one shared field block
for both, switching only the visible label by which protocol is
active. The private-key input also gets a live public-key sync via a
new useEffect, because unlike WireGuard's Xray-native inbound (which
re-derives its public key at runtime and never stores one),
AmneziaWG's server.publicKey is a real persisted field the Go backend
reads directly — free-typing a new private key without this would
silently save a mismatched keypair.

Adds a downloadable per-client .conf (amneziawgConfig.ts, mirroring
wireguardConfig.ts) with the obfuscation lines, and an
InboundOption.AwgServer field on the Go side so the config builder
gets the full server block in one round trip.

Along the way, running tools/openapigen surfaced a real bug: it
doesn't flatten anonymously-embedded Go structs the way encoding/json
does, so ServerSettings embedding Obfuscation20 produced a Zod schema
with a nested `obfuscation20` key that never matches the real wire
JSON. Fixed by un-embedding (flat fields + an accessor method) and
registering internal/amneziawg in the generator's own package list,
which had been silently emitting a dangling schema reference.

English and Russian translations are complete; the other 10 locale
files still fall back to English for the new keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 01:33:18 +03:00
parent 83cc545953
commit 19082fdfe9
24 changed files with 684 additions and 28 deletions
+23
View File
@@ -467,6 +467,7 @@ export const EXAMPLES: Record<string, unknown> = {
"xver": 0
},
"InboundOption": {
"awgServer": null,
"enable": true,
"id": 1,
"listen": "",
@@ -646,6 +647,28 @@ export const EXAMPLES: Record<string, unknown> = {
"tlsVersion": "1.3",
"x25519": true
},
"ServerSettings": {
"externalInterface": "",
"h1": "",
"h2": "",
"h3": "",
"h4": "",
"i1": "",
"jc": 0,
"jmax": 0,
"jmin": 0,
"mtu": 0,
"primaryDns": "",
"privateKey": "",
"publicKey": "",
"s1": 0,
"s2": 0,
"s3": 0,
"s4": 0,
"secondaryDns": "",
"subnetCidr": 0,
"subnetIp": ""
},
"Setting": {
"id": 0,
"key": "",
+97 -1
View File
@@ -1784,7 +1784,8 @@ export const SCHEMAS: Record<string, unknown> = {
"mixed",
"tunnel",
"tun",
"mtproto"
"mtproto",
"amneziawg"
],
"example": "vless",
"type": "string"
@@ -1927,6 +1928,15 @@ export const SCHEMAS: Record<string, unknown> = {
},
"InboundOption": {
"properties": {
"awgServer": {
"allOf": [
{
"$ref": "#/components/schemas/ServerSettings"
}
],
"description": "AwgServer carries the full AmneziaWG server block (keys, subnet,\nobfuscation params) so the clients page can render a downloadable\nper-client .conf without a second round trip.",
"nullable": true
},
"enable": {
"example": true,
"type": "boolean"
@@ -2763,6 +2773,92 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"ServerSettings": {
"description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.",
"properties": {
"externalInterface": {
"description": "ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.\nEmpty means auto-detect.",
"type": "string"
},
"h1": {
"type": "string"
},
"h2": {
"type": "string"
},
"h3": {
"type": "string"
},
"h4": {
"type": "string"
},
"i1": {
"type": "string"
},
"jc": {
"description": "Obfuscation20's fields, repeated flat (not embedded) rather than\nnested under their own key: encoding/json would happily inline an\nembedded Obfuscation20 the same way, but the frontend's Go-\u003eZod/TS\ngenerator (tools/openapigen) does not — it emits a genuinely nested\n`obfuscation20` object, which would silently diverge from the real\nwire JSON. See Obfuscation() below for the manager-facing conversion.",
"type": "integer"
},
"jmax": {
"type": "integer"
},
"jmin": {
"type": "integer"
},
"mtu": {
"type": "integer"
},
"primaryDns": {
"description": "PrimaryDNS/SecondaryDNS seed the DNS line of downloadable client\nconfigs; the server's own interface never sets one (see BuildClientConfig).",
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"s1": {
"type": "integer"
},
"s2": {
"type": "integer"
},
"s3": {
"type": "integer"
},
"s4": {
"type": "integer"
},
"secondaryDns": {
"type": "string"
},
"subnetCidr": {
"type": "integer"
},
"subnetIp": {
"type": "string"
}
},
"required": [
"h1",
"h2",
"h3",
"h4",
"jc",
"jmax",
"jmin",
"privateKey",
"publicKey",
"s1",
"s2",
"s3",
"s4",
"subnetCidr",
"subnetIp"
],
"type": "object"
},
"Setting": {
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
"properties": {
+25
View File
@@ -3,6 +3,7 @@ export type OnlineAPISupport = number;
export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type ensureAction = number;
export type staticEgressResolver = string;
export type transportBits = number;
@@ -448,6 +449,7 @@ export interface InboundFallback {
}
export interface InboundOption {
awgServer?: ServerSettings | null;
enable: boolean;
id: number;
listen?: string;
@@ -628,6 +630,29 @@ export interface RealityScanResult {
x25519: boolean;
}
export interface ServerSettings {
externalInterface?: string;
h1: string;
h2: string;
h3: string;
h4: string;
i1?: string;
jc: number;
jmax: number;
jmin: number;
mtu?: number;
primaryDns?: string;
privateKey: string;
publicKey: string;
s1: number;
s2: number;
s3: number;
s4: number;
secondaryDns?: string;
subnetCidr: number;
subnetIp: string;
}
export interface Setting {
id: number;
key: string;
+29 -1
View File
@@ -12,6 +12,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const ensureActionSchema = z.number().int();
export type ensureAction = z.infer<typeof ensureActionSchema>;
export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
@@ -440,7 +443,7 @@ export const InboundSchema = z.object({
nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg']),
remark: z.string(),
settings: z.unknown(),
shareAddr: z.string(),
@@ -476,6 +479,7 @@ export const InboundFallbackSchema = z.object({
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
export const InboundOptionSchema = z.object({
awgServer: z.lazy(() => ServerSettingsSchema).nullable().optional(),
enable: z.boolean(),
id: z.number().int(),
listen: z.string().optional(),
@@ -665,6 +669,30 @@ export const RealityScanResultSchema = z.object({
});
export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
export const ServerSettingsSchema = z.object({
externalInterface: z.string().optional(),
h1: z.string(),
h2: z.string(),
h3: z.string(),
h4: z.string(),
i1: z.string().optional(),
jc: z.number().int(),
jmax: z.number().int(),
jmin: z.number().int(),
mtu: z.number().int().optional(),
primaryDns: z.string().optional(),
privateKey: z.string(),
publicKey: z.string(),
s1: z.number().int(),
s2: z.number().int(),
s3: z.number().int(),
s4: z.number().int(),
secondaryDns: z.string().optional(),
subnetCidr: z.number().int(),
subnetIp: z.string(),
});
export type ServerSettings = z.infer<typeof ServerSettingsSchema>;
export const SettingSchema = z.object({
id: z.number().int(),
key: z.string(),
+41 -1
View File
@@ -1,5 +1,6 @@
import { RandomUtil, Wireguard } from '@/utils';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
@@ -274,6 +275,43 @@ export function createDefaultWireguardInboundSettings(
};
}
// AmneziaWG is multi-client, like WireGuard, and uses the same Curve25519
// keypair format — Wireguard.generateKeypair() works unchanged. Unlike
// WireGuard's Xray-native inbound, the server's publicKey is a real
// persisted field here (the Go backend reads it directly rather than
// re-deriving it), so it's seeded alongside privateKey. The obfuscation
// parameters (jc/jmin/.../i1) use the same starting values the Go backend's
// own generator range-checks against; the user (or the backend's own
// defaulting on save) can randomize/edit them further — see
// internal/amneziawg.GenerateObfuscation20 on the Go side.
export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSettings {
const kp = Wireguard.generateKeypair();
return {
server: {
privateKey: kp.privateKey,
publicKey: kp.publicKey,
subnetIp: '10.8.1.0',
subnetCidr: 24,
primaryDns: '8.8.8.8',
secondaryDns: '8.8.4.4',
externalInterface: '',
jc: 5,
jmin: 10,
jmax: 50,
s1: 30,
s2: 45,
s3: 10,
s4: 5,
h1: '',
h2: '',
h3: '',
h4: '',
i1: '',
},
clients: [],
};
}
// Protocol-aware dispatch over every inbound-settings factory. Mirrors
// the legacy `Inbound.Settings.getSettings(protocol)` dispatcher, but
// returns a plain Zod-parsable object instead of a class instance.
@@ -290,7 +328,8 @@ export type AnyInboundSettings =
| TunInboundSettings
| TunnelInboundSettings
| WireguardInboundSettings
| MtprotoInboundSettings;
| MtprotoInboundSettings
| AmneziawgInboundSettings;
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
switch (protocol) {
@@ -305,6 +344,7 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
case 'tun': return createDefaultTunInboundSettings();
case 'wireguard': return createDefaultWireguardInboundSettings();
case 'mtproto': return createDefaultMtprotoInboundSettings();
case 'amneziawg': return createDefaultAmneziawgInboundSettings();
default: return null;
}
}
@@ -1,6 +1,7 @@
import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
AmneziawgClientSchema,
HysteriaClientSchema,
MtprotoClientSchema,
ShadowsocksClientSchema,
@@ -252,6 +253,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
case 'hysteria': return HysteriaClientSchema;
case 'wireguard': return WireguardClientSchema;
case 'mtproto': return MtprotoClientSchema;
case 'amneziawg': return AmneziawgClientSchema;
default: return null;
}
}
@@ -67,10 +67,11 @@ export function canEnableStream(values: { protocol: string }): boolean {
return STREAM_PROTOCOLS.includes(values.protocol);
}
// mtproto is served by an external mtg process, not Xray, so the Xray sniffing
// block does not apply to it. Every other inbound supports sniffing.
// mtproto and amneziawg are served by an external process/interface, not
// Xray, so the Xray sniffing block does not apply to either. Every other
// inbound supports sniffing.
export function canEnableSniffing(values: { protocol: string }): boolean {
return values.protocol !== 'mtproto';
return values.protocol !== 'mtproto' && values.protocol !== 'amneziawg';
}
// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
+34 -9
View File
@@ -39,7 +39,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto', 'amneziawg',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -306,6 +306,14 @@ export default function ClientFormModal({
return ids;
}, [inbounds]);
const amneziawgIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row && row.protocol === 'amneziawg') ids.add(row.id);
}
return ids;
}, [inbounds]);
const mtprotoIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
@@ -357,6 +365,11 @@ export default function ClientFormModal({
[inboundIds, wireguardIds],
);
const showAmneziawg = useMemo(
() => (inboundIds || []).some((id) => amneziawgIds.has(id)),
[inboundIds, amneziawgIds],
);
const showMtproto = useMemo(
() => (inboundIds || []).some((id) => mtprotoIds.has(id)),
[inboundIds, mtprotoIds],
@@ -528,7 +541,11 @@ export default function ClientFormModal({
clientPayload.reverse = { tag: reverseTagValue };
}
if (showWireguard) {
if (showWireguard || showAmneziawg) {
// AmneziaWG peers are wire-identical to WireGuard peers (same
// privateKey/publicKey/preSharedKey/allowedIPs fields on model.Client),
// so both protocols share this one field set — see wgPrivateKey etc.
// below and the AmneziaWG-labeled variants of the same inputs.
clientPayload.privateKey = values.wgPrivateKey;
clientPayload.publicKey = values.wgPublicKey;
if (values.wgPreSharedKey) {
@@ -846,9 +863,11 @@ export default function ClientFormModal({
/>
</FormField>
)}
{showWireguard && (
{(showWireguard || showAmneziawg) && (
<>
<Form.Item label={t('pages.clients.wireguardPrivateKey')}>
<Form.Item
label={t(showAmneziawg ? 'pages.clients.amneziaWgPrivateKey' : 'pages.clients.wireguardPrivateKey')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={wgPrivateKey}
@@ -862,18 +881,24 @@ export default function ClientFormModal({
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
</Space.Compact>
</Form.Item>
<FormField name="wgPublicKey" label={t('pages.clients.wireguardPublicKey')}>
<FormField
name="wgPublicKey"
label={t(showAmneziawg ? 'pages.clients.amneziaWgPublicKey' : 'pages.clients.wireguardPublicKey')}
>
<Input disabled />
</FormField>
<FormField name="wgPreSharedKey" label={t('pages.clients.wireguardPreSharedKey')}>
<FormField
name="wgPreSharedKey"
label={t(showAmneziawg ? 'pages.clients.amneziaWgPreSharedKey' : 'pages.clients.wireguardPreSharedKey')}
>
<Input />
</FormField>
<FormField
name="wgAllowedIPs"
label={t('pages.clients.wireguardAllowedIPs')}
extra={t('pages.clients.wireguardAllowedIPsHint')}
label={t(showAmneziawg ? 'pages.clients.amneziaWgAllowedIPs' : 'pages.clients.wireguardAllowedIPs')}
extra={t(showAmneziawg ? 'pages.clients.amneziaWgAllowedIPsHint' : 'pages.clients.wireguardAllowedIPsHint')}
>
<Input placeholder="10.0.0.2/32" />
<Input placeholder="10.8.1.2/32" />
</FormField>
</>
)}
+22 -2
View File
@@ -7,6 +7,7 @@ import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { QrPanel } from '@/pages/inbounds/qr';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
import { buildAmneziaWGClientConfig, findAmneziaWGInbound, isAmneziaWGClient } from './amneziawgConfig';
interface SubSettings {
enable: boolean;
@@ -59,7 +60,13 @@ export default function ClientQrModal({
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
}, [client, wgInbound, subSettings?.publicHost]);
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
const awgInbound = useMemo(() => findAmneziaWGInbound(client, inboundsById), [client, inboundsById]);
const awgConfigText = useMemo(() => {
if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
return buildAmneziaWGClientConfig(client, awgInbound, window.location.hostname, subSettings?.publicHost ?? '');
}, [client, awgInbound, subSettings?.publicHost]);
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
useEffect(() => {
if (!open || !client?.subId) {
@@ -135,8 +142,21 @@ export default function ClientQrModal({
),
});
}
if (awgConfigText) {
out.push({
key: 'awg-config',
label: <Tag color="purple" style={{ margin: 0 }}>{t('pages.clients.amneziaWgConfig')}</Tag>,
children: (
<QrPanel
value={awgConfigText}
remark={client?.email || 'peer'}
downloadName={`${client?.email || 'peer'}.conf`}
/>
),
});
}
return out;
}, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
}, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
useEffect(() => {
if (!open) {
@@ -0,0 +1,73 @@
import { formatInboundLabel } from '@/lib/inbounds/label';
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
// AmneziaWG clients are wire-identical to WireGuard clients (same
// privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on
// model.Client — see wireguardConfig.ts's isWireguardClient), so this duck
// type can't tell the two protocols apart on its own; findAmneziaWGInbound's
// protocol==='amneziawg' filter below is what actually disambiguates.
export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean {
if (!client) return false;
return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive);
}
export function findAmneziaWGInbound(
client: ClientRecord | null | undefined,
inboundsById: Record<number, InboundOption>,
): InboundOption | undefined {
return (client?.inboundIds || [])
.map((id) => inboundsById[id])
.find((ib) => ib?.protocol === 'amneziawg');
}
// h4Line renders one H magic-header line, matching the Go backend's
// hOrDefault fallback (blank -> the classic 1/2/3/4 WireGuard message type).
function hLine(key: string, value: string | undefined, fallback: string): string {
return `${key} = ${value && value.trim() !== '' ? value : fallback}`;
}
export function buildAmneziaWGClientConfig(
client: ClientRecord,
inbound: InboundOption | undefined,
host = window.location.hostname,
publicHost = '',
): string {
const server = inbound?.awgServer;
const endpointHost = resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost));
const address = client.allowedIPs || '10.8.1.2/32';
const endpoint = `${endpointHost}:${inbound?.port || ''}`;
const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== '');
const lines = [
'[Interface]',
`PrivateKey = ${client.privateKey || client.password || ''}`,
`Address = ${address}`,
];
if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`);
if (server?.mtu && server.mtu > 0) lines.push(`MTU = ${server.mtu}`);
// AmneziaWG obfuscation parameters — must match the server's values.
lines.push(`Jc = ${server?.jc ?? 5}`);
lines.push(`Jmin = ${server?.jmin ?? 10}`);
lines.push(`Jmax = ${server?.jmax ?? 50}`);
lines.push(`S1 = ${server?.s1 ?? 30}`);
lines.push(`S2 = ${server?.s2 ?? 45}`);
if (server?.s3) lines.push(`S3 = ${server.s3}`);
if (server?.s4) lines.push(`S4 = ${server.s4}`);
lines.push(hLine('H1', server?.h1, '1'));
lines.push(hLine('H2', server?.h2, '2'));
lines.push(hLine('H3', server?.h3, '3'));
lines.push(hLine('H4', server?.h4, '4'));
if (server?.i1) lines.push(`I1 = ${server.i1}`);
lines.push('');
if (remark) lines.push(`# ${remark}`);
lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`);
return lines.join('\n');
}
@@ -57,6 +57,7 @@ import './InboundFormModal.css';
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
import {
AmneziawgFields,
HttpFields,
HysteriaFields,
MixedFields,
@@ -306,6 +307,31 @@ export default function InboundFormModal({
setV('settings.secretKey', kp.privateKey);
};
// AmneziaWG uses the same Curve25519 keys as WireGuard, just nested under
// settings.server instead of flat on settings — see amneziawg.ts. Unlike
// WireGuard's Xray-native inbound (which re-derives its public key at
// runtime and never stores one), AmneziaWG's server.publicKey is a real,
// persisted field the Go backend reads directly, so it must be kept in
// sync even when the user free-types a new private key instead of using
// the regenerate button.
const awgPrivateKey = useWatch({ control, name: 'settings.server.privateKey' });
const awgPubKey = typeof awgPrivateKey === 'string' && awgPrivateKey.length > 0
? Wireguard.generateKeypair(awgPrivateKey).publicKey
: '';
useEffect(() => {
if (protocol === Protocols.AMNEZIAWG) {
setV('settings.server.publicKey', awgPubKey);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [awgPubKey, protocol]);
const regenInboundAwg = () => {
const kp = Wireguard.generateKeypair();
setV('settings.server.privateKey', kp.privateKey);
setV('settings.server.publicKey', kp.publicKey);
};
const matchesVlessAuth = (
block: { id?: string; label?: string } | undefined | null,
authId: string,
@@ -650,6 +676,8 @@ export default function InboundFormModal({
<>
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />}
{protocol === Protocols.AMNEZIAWG && <AmneziawgFields awgPubKey={awgPubKey} regenInboundAwg={regenInboundAwg} />}
{protocol === Protocols.TUN && <TunFields />}
{protocol === Protocols.TUNNEL && <TunnelFields />}
@@ -952,6 +980,7 @@ export default function InboundFormModal({
Protocols.TUN,
Protocols.WIREGUARD,
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
] as string[]).includes(protocol) || isFallbackHost
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
: []),
@@ -0,0 +1,95 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { FormField } from '@/components/form/rhf';
interface AmneziawgFieldsProps {
awgPubKey: string;
regenInboundAwg: () => void;
}
export default function AmneziawgFields({ awgPubKey, regenInboundAwg }: AmneziawgFieldsProps) {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
<Space.Compact block>
<FormField name={['settings', 'server', 'privateKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundAwg} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.xray.amneziawg.publicKey')}>
<Input value={awgPubKey} disabled />
</Form.Item>
<FormField name={['settings', 'server', 'subnetIp']} label={t('pages.xray.amneziawg.subnetIp')}>
<Input placeholder="10.8.1.0" />
</FormField>
<FormField name={['settings', 'server', 'subnetCidr']} label={t('pages.xray.amneziawg.subnetCidr')}>
<InputNumber min={1} max={32} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 'mtu']} label={t('pages.xray.amneziawg.mtu')}>
<InputNumber style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 'primaryDns']} label={t('pages.xray.amneziawg.primaryDns')}>
<Input placeholder="8.8.8.8" />
</FormField>
<FormField name={['settings', 'server', 'secondaryDns']} label={t('pages.xray.amneziawg.secondaryDns')}>
<Input placeholder="8.8.4.4" />
</FormField>
<FormField
name={['settings', 'server', 'externalInterface']}
label={t('pages.xray.amneziawg.externalInterface')}
extra={t('pages.xray.amneziawg.externalInterfaceHint')}
>
<Input placeholder="eth0" />
</FormField>
<FormField name={['settings', 'server', 'jc']} label={t('pages.xray.amneziawg.jc')}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 'jmin']} label={t('pages.xray.amneziawg.jmin')}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 'jmax']} label={t('pages.xray.amneziawg.jmax')}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
<InputNumber min={0} max={64} style={{ width: '100%' }} />
</FormField>
<FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
<InputNumber min={0} max={32} style={{ width: '100%' }} />
</FormField>
<FormField
name={['settings', 'server', 'h1']}
label={t('pages.xray.amneziawg.h1')}
extra={t('pages.xray.amneziawg.hHint')}
>
<Input placeholder="1 or 100-800" />
</FormField>
<FormField name={['settings', 'server', 'h2']} label={t('pages.xray.amneziawg.h2')}>
<Input placeholder="2 or 100-800" />
</FormField>
<FormField name={['settings', 'server', 'h3']} label={t('pages.xray.amneziawg.h3')}>
<Input placeholder="3 or 100-800" />
</FormField>
<FormField name={['settings', 'server', 'h4']} label={t('pages.xray.amneziawg.h4')}>
<Input placeholder="4 or 100-800" />
</FormField>
<FormField
name={['settings', 'server', 'i1']}
label={t('pages.xray.amneziawg.i1')}
extra={t('pages.xray.amneziawg.i1Hint')}
>
<Input placeholder="<r 64>" />
</FormField>
</>
);
}
@@ -7,3 +7,4 @@ export { default as HttpFields } from './http';
export { default as MixedFields } from './mixed';
export { default as MtprotoFields } from './mtproto';
export { default as VlessFields } from './vless';
export { default as AmneziawgFields } from './amneziawg';
@@ -74,6 +74,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'hysteria':
case 'mtproto':
case 'wireguard':
case 'amneziawg':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
@@ -12,6 +12,7 @@ export const ProtocolSchema = z.enum([
'tunnel',
'tun',
'mtproto',
'amneziawg',
]);
export type Protocol = z.infer<typeof ProtocolSchema>;
@@ -33,4 +34,5 @@ export const Protocols = Object.freeze({
TUNNEL: 'tunnel',
TUN: 'tun',
MTPROTO: 'mtproto',
AMNEZIAWG: 'amneziawg',
});
@@ -0,0 +1,68 @@
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());
// An AmneziaWG client (multi-client model). Field-for-field identical to
// WireguardClientSchema — the panel's generic ClientRecord already has these
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
// bulk operations, the QR modal and subscriptions all work unmodified. 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)),
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<typeof AmneziawgClientSchema>;
// Server-wide AmneziaWG 2.0 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; I1 blank omits the 2.0-only CPS signature
// packet (a 1.x-compatible config).
export const AmneziawgServerSchema = z.object({
privateKey: z.string().optional(),
publicKey: z.string().optional(),
subnetIp: z.string().default('10.8.1.0'),
subnetCidr: 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(''),
jc: z.number().int().min(0).default(5),
jmin: z.number().int().min(0).default(10),
jmax: z.number().int().min(0).default(50),
s1: z.number().int().min(0).default(30),
s2: z.number().int().min(0).default(45),
s3: z.number().int().min(0).max(64).default(10),
s4: 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(''),
});
export type AmneziawgServer = z.infer<typeof AmneziawgServerSchema>;
export const AmneziawgInboundSettingsSchema = z.object({
server: AmneziawgServerSchema,
clients: z.array(AmneziawgClientSchema).default([]),
});
export type AmneziawgInboundSettings = z.infer<typeof AmneziawgInboundSettingsSchema>;
@@ -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<typeof InboundSettingsSchema>;
+1 -1
View File
@@ -70,7 +70,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
PublicKey: server.PublicKey,
Address: []string{serverAddress(server.SubnetIP, server.SubnetCIDR)},
MTU: server.MTU,
Obfuscation: server.Obfuscation20,
Obfuscation: server.Obfuscation(),
Peers: peers,
ExternalInterface: server.ExternalInterface,
}, true
+30 -4
View File
@@ -79,10 +79,36 @@ type ServerSettings struct {
// Empty means auto-detect.
ExternalInterface string `json:"externalInterface,omitempty"`
// Obfuscation20 is embedded (not nested) so its fields (jc, jmin, s1...)
// sit flat in the JSON alongside the rest of the server block, matching
// the upstream AmneziaWG PR's schema.
Obfuscation20
// Obfuscation20's fields, repeated flat (not embedded) rather than
// nested under their own key: encoding/json would happily inline an
// embedded Obfuscation20 the same way, but the frontend's Go->Zod/TS
// generator (tools/openapigen) does not — it emits a genuinely nested
// `obfuscation20` 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"`
}
// Obfuscation extracts the Obfuscation20 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() Obfuscation20 {
return Obfuscation20{
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,
}
}
// InboundSettings is the full Settings JSON shape stored on an AmneziaWG
+20
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"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"
@@ -304,6 +305,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"`
@@ -365,6 +370,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,
@@ -401,6 +407,20 @@ 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.
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 {
return nil
}
return parsed.Server
}
// 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.
+18 -6
View File
@@ -109,12 +109,24 @@ func (s *InboundService) applyLocalAmneziaWG(inboundId int) {
// obfuscation set, the default tunnel subnet/DNS, and a freshly generated
// keypair.
func defaultAmneziaWGServer() (*amneziawg.ServerSettings, error) {
obf := amneziawg.GenerateObfuscation20("default")
server := &amneziawg.ServerSettings{
SubnetIP: "10.8.1.0",
SubnetCIDR: 24,
PrimaryDNS: "8.8.8.8",
SecondaryDNS: "8.8.4.4",
Obfuscation20: amneziawg.GenerateObfuscation20("default"),
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,
}
if err := fillAmneziaWGServerKeys(server); err != nil {
return nil, err
@@ -173,7 +185,7 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
return err
}
}
if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation20); err != nil {
if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
+31
View File
@@ -915,6 +915,12 @@
"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",
"amneziaWgConfig": "AmneziaWG config",
"mtprotoSecret": "MTProto secret",
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
"mtprotoAdTag": "Ad-tag (sponsored channel)",
@@ -1917,6 +1923,31 @@
"psk": "PreShared Key",
"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.",
"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, 2.0)",
"s4": "S4 (transport packet padding, 2.0)",
"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, 2.0)",
"i1Hint": "AmneziaWG 2.0 only. Leave empty for a 1.x-compatible config."
},
"tun": {
"nameDesc": "The name of the TUN interface. Default is 'xray0'",
"mtuDesc": "Maximum Transmission Unit. The maximum size of data packets. Default is 1500",
+31
View File
@@ -915,6 +915,12 @@
"wireguardPreSharedKey": "Общий ключ WireGuard",
"wireguardAllowedIPs": "Разрешённые IP WireGuard",
"wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
"amneziaWgPrivateKey": "Приватный ключ AmneziaWG",
"amneziaWgPublicKey": "Публичный ключ AmneziaWG",
"amneziaWgPreSharedKey": "Общий ключ AmneziaWG",
"amneziaWgAllowedIPs": "Разрешённые IP AmneziaWG",
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
"amneziaWgConfig": "Конфиг AmneziaWG",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
"mtprotoAdTag": "Рекламный тег (спонсорский канал)",
@@ -1800,6 +1806,31 @@
"psk": "Общий ключ",
"domainStrategy": "Стратегия домена"
},
"amneziawg": {
"privateKey": "Приватный ключ",
"publicKey": "Публичный ключ",
"subnetIp": "Подсеть",
"subnetCidr": "Маска подсети (CIDR)",
"mtu": "MTU",
"primaryDns": "Основной DNS",
"secondaryDns": "Резервный DNS",
"externalInterface": "Внешний интерфейс",
"externalInterfaceHint": "Сетевой интерфейс хоста для NAT (PostUp/PostDown). Оставьте пустым для автоопределения.",
"jc": "Jc (кол-во мусорных пакетов)",
"jmin": "Jmin (мин. размер мусорного пакета)",
"jmax": "Jmax (макс. размер мусорного пакета)",
"s1": "S1 (мусор init-пакета)",
"s2": "S2 (мусор response-пакета)",
"s3": "S3 (паддинг cookie reply, 2.0)",
"s4": "S4 (паддинг transport-пакета, 2.0)",
"h1": "H1 (магический заголовок)",
"h2": "H2 (магический заголовок)",
"h3": "H3 (магический заголовок)",
"h4": "H4 (магический заголовок)",
"hHint": "Целое число или диапазон low-high. Оставьте пустым для классических значений 1/2/3/4.",
"i1": "I1 (сигнатурный пакет, 2.0)",
"i1Hint": "Только для AmneziaWG 2.0. Оставьте пустым для совместимости с 1.x."
},
"tun": {
"nameDesc": "Имя интерфейса TUN. Значение по умолчанию - 'xray0'",
"mtuDesc": "Максимальная единица передачи. Максимальный размер пакетов данных. Значение по умолчанию - 1500",
+4
View File
@@ -88,6 +88,10 @@ func run(root, outDir string) error {
Path: resolveRel(root, "internal/web/service/panel"),
StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"),
},
{
Path: resolveRel(root, "internal/amneziawg"),
StructAllow: setOf("ServerSettings"),
},
}
schemas, aliases, err := walkPackages(requests)