From 19082fdfe9f7414132d53a00284dc359c22b8af1 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sat, 25 Jul 2026 01:33:18 +0300 Subject: [PATCH] feat(amneziawg): add frontend support and fix a Go->Zod generator gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/generated/examples.ts | 23 +++++ frontend/src/generated/schemas.ts | 98 ++++++++++++++++++- frontend/src/generated/types.ts | 25 +++++ frontend/src/generated/zod.ts | 30 +++++- frontend/src/lib/xray/inbound-defaults.ts | 42 +++++++- frontend/src/lib/xray/inbound-form-adapter.ts | 2 + .../src/lib/xray/protocol-capabilities.ts | 7 +- .../src/pages/clients/ClientFormModal.tsx | 43 ++++++-- frontend/src/pages/clients/ClientQrModal.tsx | 24 ++++- frontend/src/pages/clients/amneziawgConfig.ts | 73 ++++++++++++++ .../pages/inbounds/form/InboundFormModal.tsx | 29 ++++++ .../inbounds/form/protocols/amneziawg.tsx | 95 ++++++++++++++++++ .../pages/inbounds/form/protocols/index.ts | 1 + frontend/src/pages/inbounds/list/helpers.ts | 1 + frontend/src/schemas/primitives/protocol.ts | 2 + .../schemas/protocols/inbound/amneziawg.ts | 68 +++++++++++++ .../src/schemas/protocols/inbound/index.ts | 3 + internal/amneziawg/manager.go | 2 +- internal/amneziawg/types.go | 34 ++++++- internal/web/service/inbound.go | 20 ++++ internal/web/service/inbound_amneziawg.go | 24 +++-- internal/web/translation/en-US.json | 31 ++++++ internal/web/translation/ru-RU.json | 31 ++++++ tools/openapigen/main.go | 4 + 24 files changed, 684 insertions(+), 28 deletions(-) create mode 100644 frontend/src/pages/clients/amneziawgConfig.ts create mode 100644 frontend/src/pages/inbounds/form/protocols/amneziawg.tsx create mode 100644 frontend/src/schemas/protocols/inbound/amneziawg.ts diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 8b3649907..8030a06d4 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -467,6 +467,7 @@ export const EXAMPLES: Record = { "xver": 0 }, "InboundOption": { + "awgServer": null, "enable": true, "id": 1, "listen": "", @@ -646,6 +647,28 @@ export const EXAMPLES: Record = { "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": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 8ea931c04..510ee0a98 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1784,7 +1784,8 @@ export const SCHEMAS: Record = { "mixed", "tunnel", "tun", - "mtproto" + "mtproto", + "amneziawg" ], "example": "vless", "type": "string" @@ -1927,6 +1928,15 @@ export const SCHEMAS: Record = { }, "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 = { ], "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": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index c93b746fc..9717ec53a 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index f54d0c5fe..35b602a85 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -12,6 +12,9 @@ export type Protocol = z.infer; export const SubLinkProviderSchema = z.unknown(); export type SubLinkProvider = z.infer; +export const ensureActionSchema = z.number().int(); +export type ensureAction = z.infer; + export const staticEgressResolverSchema = z.string(); export type staticEgressResolver = z.infer; @@ -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; 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; +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; + export const SettingSchema = z.object({ id: z.number().int(), key: z.string(), diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 1ec9bf3bf..b0bf915a1 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -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; } } diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 4362655c3..1eb23a967 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -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; } } diff --git a/frontend/src/lib/xray/protocol-capabilities.ts b/frontend/src/lib/xray/protocol-capabilities.ts index 9568ea111..7d8fb7a91 100644 --- a/frontend/src/lib/xray/protocol-capabilities.ts +++ b/frontend/src/lib/xray/protocol-capabilities.ts @@ -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 diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index b7673053d..11962a984 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -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(); + for (const row of inbounds || []) { + if (row && row.protocol === 'amneziawg') ids.add(row.id); + } + return ids; + }, [inbounds]); + const mtprotoIds = useMemo(() => { const ids = new Set(); 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({ /> )} - {showWireguard && ( + {(showWireguard || showAmneziawg) && ( <> - + } onClick={regenerateWireguardKeys} /> - + - + - + )} diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx index fd2121e27..4bc80448e 100644 --- a/frontend/src/pages/clients/ClientQrModal.tsx +++ b/frontend/src/pages/clients/ClientQrModal.tsx @@ -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: {t('pages.clients.amneziaWgConfig')}, + children: ( + + ), + }); + } return out; - }, [subLink, subJsonLink, wgConfigText, links, client?.email, t]); + }, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]); useEffect(() => { if (!open) { diff --git a/frontend/src/pages/clients/amneziawgConfig.ts b/frontend/src/pages/clients/amneziawgConfig.ts new file mode 100644 index 000000000..d083494d8 --- /dev/null +++ b/frontend/src/pages/clients/amneziawgConfig.ts @@ -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, +): 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'); +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index a7fecd507..17110cee4 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -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 && } + {protocol === Protocols.AMNEZIAWG && } + {protocol === Protocols.TUN && } {protocol === Protocols.TUNNEL && } @@ -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 }] : []), diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx new file mode 100644 index 000000000..2c780a8c9 --- /dev/null +++ b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx @@ -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 ( + <> + + + + + +