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
+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) });