Merge branch 'feat/amneziawg'

Native AmneziaWG protocol support (backend + frontend) for this fork.
See the three merged commits for details.
This commit is contained in:
Kuzz007
2026-07-25 02:19:34 +03:00
59 changed files with 3086 additions and 36 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;
}
}
+133 -1
View File
@@ -1,6 +1,7 @@
import { Base64, Wireguard } from '@/utils';
import type { Inbound } from '@/schemas/api/inbound';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import type { VlessClient } from '@/schemas/protocols/inbound/vless';
import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
import type {
@@ -869,6 +870,134 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
return txt;
}
// Shared input shape for both the per-client amneziawg:// link and .conf
// builders below — settings.clients (not a peers array; unlike WireGuard,
// AmneziaWG was multi-client from day one, so there's no legacy format).
export interface GenAmneziaWGLinkInput {
settings: AmneziawgInboundSettings;
address: string;
port: number;
remark?: string;
peerIndex: number;
}
function amneziaWGHLine(key: string, value: string | undefined, fallback: string): string {
return `${key} = ${value && value.trim() !== '' ? value : fallback}`;
}
// AmneziaWG share link: amneziawg://<clientPrivKey>@<host>:<port>
// ?publickey=<serverPub>&address=<clientAllowedIP>&mtu=<mtu>#<remark>
// Unlike WireGuard, the server's publicKey is a real persisted field (not
// derived from a secretKey at call time), so this just reads it straight off
// settings.server. Mirrors genWireguardLink.
export function genAmneziaWGLink(input: GenAmneziaWGLinkInput): string {
const { settings, address, port, remark = '', peerIndex } = input;
const client = settings.clients[peerIndex];
if (!client) return '';
const server = settings.server;
const url = new URL(`amneziawg://${formatUrlHost(address)}:${port}`);
url.username = client.privateKey ?? '';
if (server.publicKey.length > 0) url.searchParams.set('publickey', server.publicKey);
if ((client.allowedIPs ?? []).length > 0) {
url.searchParams.set('address', client.allowedIPs.join(','));
}
if (typeof server.mtu === 'number' && server.mtu > 0) {
url.searchParams.set('mtu', String(server.mtu));
}
url.hash = encodeURIComponent(remark);
return url.toString();
}
// Plain-text AmneziaWG client config (.conf format). Mirrors
// genWireguardConfig, plus the obfuscation lines every AmneziaWG client must
// share with the server (see internal/amneziawg.writeObfuscation on the Go
// side).
export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
const { settings, address, port, remark = '', peerIndex } = input;
const client = settings.clients[peerIndex];
if (!client) return '';
const server = settings.server;
let txt = `[Interface]\n`;
txt += `PrivateKey = ${client.privateKey ?? ''}\n`;
txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`;
if (typeof server.mtu === 'number' && server.mtu > 0) {
txt += `MTU = ${server.mtu}\n`;
}
txt += `Jc = ${server.jc}\n`;
txt += `Jmin = ${server.jmin}\n`;
txt += `Jmax = ${server.jmax}\n`;
txt += `S1 = ${server.s1}\n`;
txt += `S2 = ${server.s2}\n`;
if (server.s3) txt += `S3 = ${server.s3}\n`;
if (server.s4) txt += `S4 = ${server.s4}\n`;
txt += `${amneziaWGHLine('H1', server.h1, '1')}\n`;
txt += `${amneziaWGHLine('H2', server.h2, '2')}\n`;
txt += `${amneziaWGHLine('H3', server.h3, '3')}\n`;
txt += `${amneziaWGHLine('H4', server.h4, '4')}\n`;
if (server.i1) txt += `I1 = ${server.i1}\n`;
txt += `\n# ${remark}\n`;
txt += `[Peer]\n`;
txt += `PublicKey = ${server.publicKey ?? ''}\n`;
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
txt += `Endpoint = ${address}:${port}`;
if (client.preSharedKey && client.preSharedKey.length > 0) {
txt += `\nPresharedKey = ${client.preSharedKey}`;
}
if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
txt += `\nPersistentKeepalive = ${client.keepAlive}\n`;
}
return txt;
}
export interface GenAmneziaWGFanoutInput {
inbound: Inbound;
remark?: string;
hostOverride?: string;
fallbackHostname: string;
}
export function genAmneziaWGLinks(input: GenAmneziaWGFanoutInput): string {
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'amneziawg') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = '-';
const settings = inbound.settings as AmneziawgInboundSettings;
const clients = settings.clients ?? [];
return clients
.map((c, i) => genAmneziaWGLink({
settings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
peerIndex: i,
}))
.join('\r\n');
}
export function genAmneziaWGConfigs(input: GenAmneziaWGFanoutInput): string {
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'amneziawg') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = '-';
const settings = inbound.settings as AmneziawgInboundSettings;
const clients = settings.clients ?? [];
return clients
.map((c, i) => genAmneziaWGConfig({
settings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
peerIndex: i,
}))
.join('\r\n');
}
export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
let url: URL;
try {
@@ -1201,7 +1330,7 @@ export interface GenInboundLinksInput {
// Top-level entrypoint that produces the full \r\n-joined block a user
// pastes into a client. Iterates per-client for protocols with clients,
// falls back to a single SS link for single-user 2022-blake3-chacha20,
// and emits per-peer .conf blocks for wireguard. Returns '' for the
// and emits per-peer .conf blocks for wireguard and amneziawg. Returns '' for the
// other clientless protocols (http, mixed, tunnel).
export function genInboundLinks(input: GenInboundLinksInput): string {
const {
@@ -1226,6 +1355,9 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
if (inbound.protocol === 'wireguard') {
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
}
if (inbound.protocol === 'amneziawg') {
return genAmneziaWGConfigs({ inbound, remark, hostOverride, fallbackHostname });
}
return '';
}
+1 -1
View File
@@ -14,7 +14,7 @@ function inboundTransports(
streamSettings: Record<string, unknown> | undefined,
settings: Record<string, unknown> | undefined,
): TransportBits {
if (protocol === 'hysteria' || protocol === 'wireguard') return UDP;
if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP;
let bits: TransportBits = 0;
const network = asString(streamSettings?.network);
@@ -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
@@ -7,7 +7,7 @@ import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkAttachResult } from '@/schemas/client';
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto', 'amneziawg']);
interface BulkAttachInboundsModalProps {
open: boolean;
@@ -7,7 +7,7 @@ import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkDetachResult } from '@/schemas/client';
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto', 'amneziawg']);
interface BulkDetachInboundsModalProps {
open: boolean;
@@ -18,7 +18,7 @@ import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'amneziawg',
]);
const EMPTY: ClientBulkAddFormValues = {
+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>
</>
)}
@@ -13,6 +13,7 @@ import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { QrPanel } from '@/pages/inbounds/qr';
import ConfigBlock from '@/components/clients/ConfigBlock';
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
import { buildAmneziaWGClientConfig, findAmneziaWGInbound, isAmneziaWGClient } from './amneziawgConfig';
import './ClientInfoModal.css';
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -23,6 +24,7 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
hysteria: 'cyan',
hysteria2: 'green',
wireguard: 'gold',
amneziawg: 'yellow',
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
@@ -149,6 +151,12 @@ export default function ClientInfoModal({
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
}, [client, wgInbound, subSettings?.publicHost]);
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]);
async function copyValue(text: string) {
if (!text) return;
const ok = await ClipboardManager.copyText(String(text));
@@ -538,6 +546,18 @@ export default function ClientInfoModal({
/>
</>
)}
{awgConfigText && client && (
<>
<Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
<ConfigBlock
label={t('pages.clients.config')}
text={awgConfigText}
fileName={`${client.email}.conf`}
qrRemark={client.email || 'peer'}
/>
</>
)}
</>
)}
</Modal>
+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');
}
+8 -2
View File
@@ -24,8 +24,9 @@ import {
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
import { genAmneziaWGLinks, genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
import { Protocols } from '@/schemas/primitives';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -274,7 +275,12 @@ export default function InboundsPage() {
{ key: 'config', label: t('pages.clients.config'), content },
{ key: 'links', label: t('pages.clients.tabLinks'), content: genWireguardLinks(genInput) },
]
: undefined;
: projected.protocol === Protocols.AMNEZIAWG
? [
{ key: 'config', label: t('pages.clients.config'), content },
{ key: 'links', label: t('pages.clients.tabLinks'), content: genAmneziaWGLinks(genInput) },
]
: undefined;
openText({
title: t('pages.inbounds.exportLinksTitle'),
content,
@@ -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';
@@ -10,6 +10,8 @@ import { InfinityIcon } from '@/components/ui';
import { useDatepicker } from '@/hooks/useDatepicker';
import {
genAllLinks,
genAmneziaWGConfigs,
genAmneziaWGLinks,
genWireguardConfigs,
genWireguardLinks,
preferPublicHost,
@@ -49,6 +51,8 @@ export default function InboundInfoModal({
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [refreshing, setRefreshing] = useState(false);
@@ -132,6 +136,28 @@ export default function InboundInfoModal({
fallbackHostname,
}).split('\r\n'),
);
setAmneziawgConfigs([]);
setAmneziawgLinks([]);
setLinks([]);
} else if (info.protocol === Protocols.AMNEZIAWG) {
setAmneziawgConfigs(
genAmneziaWGConfigs({
inbound: inboundForLinks,
remark: dbInbound.remark,
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setAmneziawgLinks(
genAmneziaWGLinks({
inbound: inboundForLinks,
remark: dbInbound.remark,
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setWireguardConfigs([]);
setWireguardLinks([]);
setLinks([]);
} else {
setLinks(
@@ -145,6 +171,8 @@ export default function InboundInfoModal({
);
setWireguardConfigs([]);
setWireguardLinks([]);
setAmneziawgConfigs([]);
setAmneziawgLinks([]);
}
if (clientSet?.subId) {
@@ -851,6 +879,41 @@ export default function InboundInfoModal({
</>
)}
{inbound?.protocol === Protocols.AMNEZIAWG && amneziawgConfigs.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{amneziawgConfigs.map((cfg, idx) => (
<Fragment key={idx}>
{cfg && (
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">{t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(cfg, t)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={() => downloadText(cfg, `peer-${idx + 1}.conf`)} />
</Tooltip>
</div>
<code className="link-panel-text">{cfg}</code>
</div>
)}
{amneziawgLinks[idx] && (
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">Peer {idx + 1} link</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(amneziawgLinks[idx], t)} />
</Tooltip>
</div>
<code className="link-panel-text">{amneziawgLinks[idx]}</code>
</div>
)}
</Fragment>
))}
</>
)}
{dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
@@ -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) });
+43 -1
View File
@@ -6,6 +6,8 @@ import type { CollapseProps } from 'antd';
import { Protocols } from '@/schemas/primitives';
import {
genAllLinks,
genAmneziaWGConfigs,
genAmneziaWGLinks,
genWireguardConfigs,
genWireguardLinks,
isPostQuantumLink,
@@ -50,6 +52,8 @@ export default function QrCodeModal({
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([]);
@@ -78,6 +82,31 @@ export default function QrCodeModal({
fallbackHostname,
}).split('\r\n'),
);
setAmneziawgConfigs([]);
setAmneziawgLinks([]);
setLinks([]);
} else if (inbound.protocol === Protocols.AMNEZIAWG) {
const peerRemark = client?.email
? `${dbInbound.remark}-${client.email}`
: dbInbound.remark || '';
setAmneziawgConfigs(
genAmneziaWGConfigs({
inbound,
remark: peerRemark,
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setAmneziawgLinks(
genAmneziaWGLinks({
inbound,
remark: peerRemark,
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
);
setWireguardConfigs([]);
setWireguardLinks([]);
setLinks([]);
} else {
setLinks(
@@ -91,6 +120,8 @@ export default function QrCodeModal({
);
setWireguardConfigs([]);
setWireguardLinks([]);
setAmneziawgConfigs([]);
setAmneziawgLinks([]);
}
const subId = client?.subId;
@@ -126,8 +157,19 @@ export default function QrCodeModal({
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx], showQr: false });
}
});
amneziawgConfigs.forEach((cfg, idx) => {
items.push({
key: `ac${idx}`,
header: `Peer ${idx + 1} config`,
value: cfg,
downloadName: `peer-${idx + 1}.conf`,
});
if (amneziawgLinks[idx]) {
items.push({ key: `al${idx}`, header: `Peer ${idx + 1} link`, value: amneziawgLinks[idx], showQr: false });
}
});
return items;
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, amneziawgConfigs, amneziawgLinks, t]);
const collapseItems: CollapseProps['items'] = useMemo(
() => qrItems.map((item) => ({
@@ -62,6 +62,7 @@ const TRACKED_PROTOCOLS: readonly string[] = [
Protocols.HYSTERIA,
Protocols.WIREGUARD,
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
];
async function fetchSlimInbounds(): Promise<unknown[]> {
+1 -1
View File
@@ -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
@@ -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>;
+634
View File
@@ -0,0 +1,634 @@
package amneziawg
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"maps"
"net"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// configDir is where awg-quick expects to find <interface>.conf, matching
// the AmneziaWG DKMS package's own layout.
const configDir = "/etc/amnezia/amneziawg"
// onlineWindow is how recent a peer's last handshake must be to count it as
// online, matching the typical WireGuard rekey interval (every 120s) plus
// margin.
const onlineWindow = 180 * time.Second
// 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,
})
}
if len(peers) == 0 {
return Instance{}, false
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
InterfaceName: interfaceNameForID(ib.Id),
ListenPort: ib.Port,
PrivateKey: server.PrivateKey,
PublicKey: server.PublicKey,
Address: []string{serverAddress(server.SubnetIP, server.SubnetCIDR)},
MTU: server.MTU,
Obfuscation: server.Obfuscation(),
Peers: peers,
ExternalInterface: server.ExternalInterface,
}, true
}
// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
// "awg42".
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". The server holds the first usable
// host; a base that isn't a bare network address is used as-is.
func serverAddress(subnetIP string, cidr int) string {
if cidr <= 0 {
cidr = 24
}
if strings.HasSuffix(subnetIP, ".0") {
return strings.TrimSuffix(subnetIP, "0") + "1/" + strconv.Itoa(cidr)
}
return fmt.Sprintf("%s/%d", subnetIP, cidr)
}
// structuralFingerprint changes whenever a value that requires a full
// interface bounce (awg-quick down + up) changes.
func (inst Instance) structuralFingerprint() string {
o := inst.Obfuscation
parts := []string{
inst.InterfaceName,
strconv.Itoa(inst.ListenPort),
inst.PrivateKey,
strings.Join(inst.Address, ","),
strconv.Itoa(inst.MTU),
strconv.Itoa(o.Jc), strconv.Itoa(o.Jmin), strconv.Itoa(o.Jmax),
strconv.Itoa(o.S1), strconv.Itoa(o.S2), strconv.Itoa(o.S3), strconv.Itoa(o.S4),
o.H1, o.H2, o.H3, o.H4, o.I1,
inst.ExternalInterface,
}
return strings.Join(parts, "|")
}
// peersFingerprint identifies the reloadable peer set regardless of order, so
// a reordered clients array in the stored settings does not read as a
// change. It moves whenever a peer is added, removed, disabled, re-keyed, or
// re-addressed — all of which `awg syncconf` applies in place.
func (inst Instance) peersFingerprint() string {
pairs := make([]string, 0, len(inst.Peers))
for _, p := range inst.Peers {
pairs = append(pairs, fmt.Sprintf("%s=%s;psk=%s;ips=%s", p.Email, p.PublicKey, p.PresharedKey, strings.Join(p.AllowedIPs, ",")))
}
slices.Sort(pairs)
return strings.Join(pairs, "|")
}
// peerCounters is the last-seen cumulative transfer counters for one peer,
// used to compute per-poll deltas the same way mtproto tracks per-secret
// counters.
type peerCounters struct {
rx int64
tx int64
}
type managed struct {
inst Instance
structuralFP string
peersFP string
last map[string]peerCounters // keyed by peer public key
}
// Manager owns the set of running AmneziaWG interfaces keyed by inbound id.
type Manager struct {
mu sync.Mutex
ifaces map[int]*managed
}
var (
managerOnce sync.Once
manager *Manager
)
// GetManager returns the process-wide AmneziaWG manager singleton.
func GetManager() *Manager {
managerOnce.Do(func() {
manager = &Manager{ifaces: map[int]*managed{}}
})
return manager
}
// ensureAction is what ensureLocked must do to move a running interface to a
// desired instance: leave it alone, hot-reload just its peers, or fully
// bounce it.
type ensureAction int
const (
ensureNoop ensureAction = iota
ensureReload
ensureRestart
)
// ensureActionFor decides how to apply a desired instance to the currently
// managed interface. A structural change (or a down interface) forces a
// restart; a peers-only change is a candidate for an in-place `syncconf`;
// identical fingerprints on an up interface need nothing.
func ensureActionFor(up bool, curStructFP, curPeersFP, newStructFP, newPeersFP string) ensureAction {
if !up || curStructFP != newStructFP {
return ensureRestart
}
if curPeersFP != newPeersFP {
return ensureReload
}
return ensureNoop
}
// Ensure brings one interface to its desired state, or restarts/reloads it
// when its configuration changed. A no-op when it already matches.
func (m *Manager) Ensure(inst Instance) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.ensureLocked(inst)
}
func (m *Manager) ensureLocked(inst Instance) error {
structFP := inst.structuralFingerprint()
peersFP := inst.peersFingerprint()
cur, exists := m.ifaces[inst.Id]
action := ensureRestart
if exists {
action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.peersFP, structFP, peersFP)
}
switch action {
case ensureNoop:
cur.inst = inst
return nil
case ensureReload:
if err := writeConfigFile(inst); err != nil {
return err
}
if err := syncConfig(inst); err != nil {
return err
}
case ensureRestart:
if exists {
_ = interfaceDown(cur.inst.InterfaceName)
}
if err := writeConfigFile(inst); err != nil {
return err
}
if err := interfaceUp(inst.InterfaceName); err != nil {
return err
}
logger.Infof("amneziawg: started interface %s for inbound %d", inst.InterfaceName, inst.Id)
}
last := map[string]peerCounters{}
if exists {
last = cur.last
}
m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, peersFP: peersFP, last: last}
return nil
}
// Remove tears down and forgets the interface for an inbound id.
func (m *Manager) Remove(id int) {
m.mu.Lock()
defer m.mu.Unlock()
if cur, ok := m.ifaces[id]; ok {
_ = interfaceDown(cur.inst.InterfaceName)
removeConfigFile(cur.inst.InterfaceName)
delete(m.ifaces, id)
logger.Infof("amneziawg: stopped interface %s for inbound %d", cur.inst.InterfaceName, id)
}
}
// Reconcile drives the running set toward the desired instances: it tears
// down interfaces that are no longer wanted and ensures the rest. Used at
// boot and periodically to recover from crashes or an out-of-band `awg-quick
// down`.
func (m *Manager) Reconcile(desired []Instance) {
m.mu.Lock()
defer m.mu.Unlock()
want := make(map[int]struct{}, len(desired))
for _, inst := range desired {
want[inst.Id] = struct{}{}
}
for id, cur := range m.ifaces {
if _, ok := want[id]; !ok {
_ = interfaceDown(cur.inst.InterfaceName)
removeConfigFile(cur.inst.InterfaceName)
delete(m.ifaces, id)
logger.Infof("amneziawg: stopped interface %s for removed inbound %d", cur.inst.InterfaceName, id)
}
}
for _, inst := range desired {
if err := m.ensureLocked(inst); err != nil {
logger.Warningf("amneziawg: reconcile failed for inbound %d: %v", inst.Id, err)
}
}
}
// 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 {
_ = interfaceDown(cur.inst.InterfaceName)
delete(m.ifaces, id)
}
}
// HasRunning reports whether any managed interface is currently up.
func (m *Manager) HasRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, cur := range m.ifaces {
if isInterfaceUp(cur.inst.InterfaceName) {
return true
}
}
return false
}
// Traffic is a per-peer traffic delta scraped from `awg show <iface> dump`.
// Tag is the owning inbound's tag and Email is the client the bytes belong
// to.
type Traffic struct {
Tag string
Email string
Up int64
Down int64
}
// CollectTraffic polls `awg show <iface> dump` for every running interface
// and returns the per-peer byte deltas since the previous poll, plus the
// emails of peers with a handshake inside onlineWindow.
func (m *Manager) CollectTraffic() ([]Traffic, []string) {
type snap struct {
id int
inst Instance
last map[string]peerCounters
}
m.mu.Lock()
snaps := make([]snap, 0, len(m.ifaces))
for id, cur := range m.ifaces {
lastCopy := make(map[string]peerCounters, len(cur.last))
maps.Copy(lastCopy, cur.last)
snaps = append(snaps, snap{id: id, inst: cur.inst, last: lastCopy})
}
m.mu.Unlock()
var out []Traffic
var online []string
now := time.Now()
for _, s := range snaps {
stats, err := getPeerStats(s.inst.InterfaceName)
if err != nil {
continue
}
emailByKey := make(map[string]string, len(s.inst.Peers))
for _, p := range s.inst.Peers {
emailByKey[p.PublicKey] = p.Email
}
newLast := make(map[string]peerCounters, len(stats))
for _, st := range stats {
email, ok := emailByKey[st.publicKey]
if !ok || email == "" {
continue
}
newLast[st.publicKey] = peerCounters{rx: st.rx, tx: st.tx}
if st.latestHandshake > 0 && now.Sub(time.Unix(st.latestHandshake, 0)) < onlineWindow {
online = append(online, email)
}
prev, had := s.last[st.publicKey]
if !had {
continue
}
du := st.rx - prev.rx // client upload = bytes the server received
dd := st.tx - prev.tx // client download = bytes the server sent
if du < 0 {
du = 0
}
if dd < 0 {
dd = 0
}
if du > 0 || dd > 0 {
out = append(out, Traffic{Tag: s.inst.Tag, Email: email, Up: du, Down: dd})
}
}
m.mu.Lock()
if cur, ok := m.ifaces[s.id]; ok {
cur.last = newLast
}
m.mu.Unlock()
}
return out, online
}
// --- config rendering ---
// generateServerConfig builds the awg-quick .conf content for an interface:
// its own [Interface] block (keys, address, obfuscation, NAT PostUp/PostDown)
// followed by one [Peer] block per client.
func generateServerConfig(inst Instance) string {
var b strings.Builder
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", inst.PrivateKey)
if len(inst.Address) > 0 {
fmt.Fprintf(&b, "Address = %s\n", strings.Join(inst.Address, ", "))
}
fmt.Fprintf(&b, "ListenPort = %d\n", inst.ListenPort)
if inst.MTU > 0 {
fmt.Fprintf(&b, "MTU = %d\n", inst.MTU)
}
writeObfuscation(&b, inst.Obfuscation)
ext := inst.ExternalInterface
if ext == "" {
ext = detectDefaultInterface()
}
postUp, postDown := defaultPostUpDown(inst.InterfaceName, ext, inst.Address)
fmt.Fprintf(&b, "PostUp = %s\n", postUp)
fmt.Fprintf(&b, "PostDown = %s\n", postDown)
for _, p := range inst.Peers {
b.WriteString("\n[Peer]\n")
if p.Email != "" {
fmt.Fprintf(&b, "# %s\n", p.Email)
}
fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey)
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "AllowedIPs = %s\n", strings.Join(p.AllowedIPs, ", "))
}
return b.String()
}
// writeObfuscation writes the AmneziaWG obfuscation parameters that must be
// identical on both ends of a tunnel. S3/S4 and I1 are emitted only when set,
// so a plain 1.x-equivalent set (S3=S4=0, I1="") produces the classic
// generator's output; a 2.0 set adds the extra padding, header ranges and CPS
// packet.
func writeObfuscation(b *strings.Builder, o Obfuscation20) {
fmt.Fprintf(b, "Jc = %d\n", o.Jc)
fmt.Fprintf(b, "Jmin = %d\n", o.Jmin)
fmt.Fprintf(b, "Jmax = %d\n", o.Jmax)
fmt.Fprintf(b, "S1 = %d\n", o.S1)
fmt.Fprintf(b, "S2 = %d\n", o.S2)
if o.S3 > 0 {
fmt.Fprintf(b, "S3 = %d\n", o.S3)
}
if o.S4 > 0 {
fmt.Fprintf(b, "S4 = %d\n", o.S4)
}
fmt.Fprintf(b, "H1 = %s\n", hOrDefault(o.H1, "1"))
fmt.Fprintf(b, "H2 = %s\n", hOrDefault(o.H2, "2"))
fmt.Fprintf(b, "H3 = %s\n", hOrDefault(o.H3, "3"))
fmt.Fprintf(b, "H4 = %s\n", hOrDefault(o.H4, "4"))
if o.I1 != "" {
fmt.Fprintf(b, "I1 = %s\n", o.I1)
}
}
// hOrDefault returns def when v is blank, guarding against an empty H value
// (which would emit an invalid "H1 = " line) on legacy/partial records.
func hOrDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
// defaultPostUpDown returns basic NAT + forwarding rules: MASQUERADE the
// tunnel subnet out the external interface and accept forwarded traffic in
// both directions. Per-peer port-forwarding, IPv6/NDP and RouteViaXray are a
// later phase (see project TODO).
func defaultPostUpDown(iface, ext string, addresses []string) (postUp, postDown string) {
up := []string{
fmt.Sprintf("iptables -A FORWARD -i %s -j ACCEPT", iface),
fmt.Sprintf("iptables -A FORWARD -o %s -j ACCEPT", iface),
}
down := []string{
fmt.Sprintf("iptables -D FORWARD -i %s -j ACCEPT", iface),
fmt.Sprintf("iptables -D FORWARD -o %s -j ACCEPT", iface),
}
if subnet := firstAddress(addresses); subnet != "" && ext != "" {
up = append([]string{fmt.Sprintf("iptables -t nat -A POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, up...)
down = append([]string{fmt.Sprintf("iptables -t nat -D POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, down...)
}
up = append(up, "sysctl -w net.ipv4.ip_forward=1")
return strings.Join(up, "; "), strings.Join(down, "; ")
}
// firstAddress returns the first configured interface address, used as the
// NAT source subnet for PostUp/PostDown.
func firstAddress(addresses []string) string {
if len(addresses) == 0 {
return ""
}
return addresses[0]
}
// detectDefaultInterface returns the first non-loopback, non-tunnel, UP
// interface that has a routable IPv4 address. Falls back to "eth0" only if
// nothing is found.
func detectDefaultInterface() string {
ifaces, err := net.Interfaces()
if err != nil {
return "eth0"
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
if strings.HasPrefix(iface.Name, "awg") || strings.HasPrefix(iface.Name, "wg") ||
strings.HasPrefix(iface.Name, "docker") || strings.HasPrefix(iface.Name, "br-") ||
strings.HasPrefix(iface.Name, "veth") {
continue
}
addrs, err := iface.Addrs()
if err != nil || len(addrs) == 0 {
continue
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLinkLocalUnicast() && ipNet.IP.To4() != nil {
return iface.Name
}
}
}
return "eth0"
}
// --- process control ---
func configPath(interfaceName string) string {
return filepath.Join(configDir, interfaceName+".conf")
}
// writeConfigFile renders and persists the .conf file awg-quick reads.
func writeConfigFile(inst Instance) error {
if err := os.MkdirAll(configDir, 0o700); err != nil {
return fmt.Errorf("amneziawg: create config dir: %w", err)
}
if err := os.WriteFile(configPath(inst.InterfaceName), []byte(generateServerConfig(inst)), 0o600); err != nil {
return fmt.Errorf("amneziawg: write config for %s: %w", inst.InterfaceName, err)
}
return nil
}
// removeConfigFile deletes the config file for an interface, best-effort.
func removeConfigFile(interfaceName string) {
if err := os.Remove(configPath(interfaceName)); err != nil && !os.IsNotExist(err) {
logger.Warningf("amneziawg: failed to remove config file for %s: %v", interfaceName, err)
}
}
// interfaceUp brings an AmneziaWG interface up via awg-quick.
func interfaceUp(interfaceName string) error {
out, err := exec.Command("awg-quick", "up", configPath(interfaceName)).CombinedOutput()
if err != nil {
return fmt.Errorf("awg-quick up %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err)
}
return nil
}
// interfaceDown takes an AmneziaWG interface down via awg-quick.
func interfaceDown(interfaceName string) error {
out, err := exec.Command("awg-quick", "down", configPath(interfaceName)).CombinedOutput()
if err != nil {
return fmt.Errorf("awg-quick down %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err)
}
return nil
}
// isInterfaceUp checks whether the named AmneziaWG interface currently
// exists.
func isInterfaceUp(interfaceName string) bool {
return exec.Command("awg", "show", interfaceName).Run() == nil
}
// syncConfig applies a peers-only config change without dropping existing
// connections on other peers, falling back to a full restart when the live
// interface won't accept the diff (or isn't up yet).
func syncConfig(inst Instance) error {
if !isInterfaceUp(inst.InterfaceName) {
return interfaceUp(inst.InterfaceName)
}
stripped, err := exec.Command("awg-quick", "strip", configPath(inst.InterfaceName)).Output()
if err != nil {
logger.Warningf("amneziawg: awg-quick strip failed for %s, restarting: %v", inst.InterfaceName, err)
return restartInterface(inst.InterfaceName)
}
sync := exec.Command("awg", "syncconf", inst.InterfaceName, "/dev/stdin")
sync.Stdin = bytes.NewReader(stripped)
if out, err := sync.CombinedOutput(); err != nil {
logger.Warningf("amneziawg: awg syncconf failed for %s, restarting: %s: %v", inst.InterfaceName, strings.TrimSpace(string(out)), err)
return restartInterface(inst.InterfaceName)
}
return nil
}
// restartInterface performs a full down+up cycle.
func restartInterface(interfaceName string) error {
_ = interfaceDown(interfaceName)
return interfaceUp(interfaceName)
}
// peerStat is one peer's runtime stats parsed from `awg show <iface> dump`.
type peerStat struct {
publicKey string
latestHandshake int64 // unix seconds
rx int64 // bytes received from the peer (its upload)
tx int64 // bytes sent to the peer (its download)
}
// getPeerStats parses `awg show <iface> dump`. The dump format is
// tab-separated: line 1 is the interface (private-key, public-key,
// listen-port, fwmark); each following line is one peer (public-key,
// preshared-key, endpoint, allowed-ips, latest-handshake, transfer-rx,
// transfer-tx, persistent-keepalive).
func getPeerStats(interfaceName string) ([]peerStat, error) {
out, err := exec.Command("awg", "show", interfaceName, "dump").Output()
if err != nil {
return nil, fmt.Errorf("awg show %s dump failed: %w", interfaceName, err)
}
var stats []peerStat
scanner := bufio.NewScanner(bytes.NewReader(out))
first := true
for scanner.Scan() {
if first {
first = false
continue
}
fields := strings.Split(scanner.Text(), "\t")
if len(fields) < 8 {
continue
}
handshake, _ := strconv.ParseInt(fields[4], 10, 64)
rx, _ := strconv.ParseInt(fields[5], 10, 64)
tx, _ := strconv.ParseInt(fields[6], 10, 64)
stats = append(stats, peerStat{publicKey: fields[0], latestHandshake: handshake, rx: rx, tx: tx})
}
return stats, nil
}
// IsAwgInstalled reports whether the awg and awg-quick binaries are on PATH.
func IsAwgInstalled() bool {
_, err1 := exec.LookPath("awg")
_, err2 := exec.LookPath("awg-quick")
return err1 == nil && err2 == nil
}
+250
View File
@@ -0,0 +1,250 @@
package amneziawg
import (
"encoding/json"
"strings"
"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 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
{"192.168.5.10", 32, "192.168.5.10/32"},
}
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)
}
}
}
// fixedObfuscation is a deterministic Obfuscation20 for tests that compare
// two instances for equality — GenerateObfuscation20 is randomized per call
// by design (see its doc comment) and must never be used where the test
// expects two "identical" instances to actually match.
func fixedObfuscation() Obfuscation20 {
return Obfuscation20{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: "<r 64>"}
}
func baseInstance() Instance {
return Instance{
Id: 1,
Tag: "awg-1",
InterfaceName: "awg1",
ListenPort: 51820,
PrivateKey: "priv",
PublicKey: "pub",
Address: []string{"10.8.1.1/24"},
Obfuscation: fixedObfuscation(),
Peers: []Peer{
{Email: "a@x", PublicKey: "pubA", PresharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
{Email: "b@x", PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
},
}
}
func TestStructuralFingerprintStableAndSensitive(t *testing.T) {
a := baseInstance()
b := baseInstance()
if a.structuralFingerprint() != b.structuralFingerprint() {
t.Fatal("identical instances must produce the same structural fingerprint")
}
b.ListenPort = 51821
if a.structuralFingerprint() == b.structuralFingerprint() {
t.Fatal("a listen port change must change the structural fingerprint")
}
c := baseInstance()
c.Peers[0].AllowedIPs = []string{"10.8.1.99/32"}
if a.structuralFingerprint() != c.structuralFingerprint() {
t.Fatal("a peer-only change must NOT change the structural fingerprint")
}
}
func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) {
a := baseInstance()
reordered := baseInstance()
reordered.Peers[0], reordered.Peers[1] = reordered.Peers[1], reordered.Peers[0]
if a.peersFingerprint() != reordered.peersFingerprint() {
t.Fatal("reordering peers must not change the peers fingerprint")
}
changed := baseInstance()
changed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
if a.peersFingerprint() == changed.peersFingerprint() {
t.Fatal("changing a peer's AllowedIPs must change the peers fingerprint")
}
fewer := baseInstance()
fewer.Peers = fewer.Peers[:1]
if a.peersFingerprint() == fewer.peersFingerprint() {
t.Fatal("removing a peer must change the peers fingerprint")
}
}
func TestEnsureActionFor(t *testing.T) {
cases := []struct {
name string
up bool
curStruct, curPeers string
newStruct, newPeers string
want ensureAction
}{
{"down forces restart even if identical", false, "s", "p", "s", "p", ensureRestart},
{"structural change forces restart", true, "s1", "p", "s2", "p", ensureRestart},
{"peers-only change reloads", true, "s", "p1", "s", "p2", ensureReload},
{"identical up interface is a noop", true, "s", "p", "s", "p", ensureNoop},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ensureActionFor(c.up, c.curStruct, c.curPeers, c.newStruct, c.newPeers); got != c.want {
t.Errorf("ensureActionFor() = %v, want %v", got, c.want)
}
})
}
}
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
inst := baseInstance()
inst.ExternalInterface = "eth0"
cfg := generateServerConfig(inst)
want := []string{
"[Interface]",
"PrivateKey = priv",
"Address = 10.8.1.1/24",
"ListenPort = 51820",
"[Peer]",
"PublicKey = pubA",
"PresharedKey = pskA",
"AllowedIPs = 10.8.1.2/32",
"PublicKey = pubB",
"AllowedIPs = 10.8.1.3/32",
"MASQUERADE",
}
for _, w := range want {
if !strings.Contains(cfg, w) {
t.Errorf("generated config missing %q\n---\n%s", w, cfg)
}
}
// The second peer has no PresharedKey — its block must not emit the field at all.
if strings.Count(cfg, "PresharedKey") != 1 {
t.Errorf("expected exactly one PresharedKey line (peer b@x has none), got config:\n%s", cfg)
}
}
func TestWriteObfuscationDefaultsBlankH(t *testing.T) {
var b strings.Builder
writeObfuscation(&b, Obfuscation20{})
out := b.String()
for i, want := range []string{"H1 = 1", "H2 = 2", "H3 = 3", "H4 = 4"} {
if !strings.Contains(out, want) {
t.Errorf("blank H%d must fall back to default %q, got:\n%s", i+1, want, out)
}
}
// S3/S4/I1 are zero-valued here and must be omitted entirely.
if strings.Contains(out, "S3") || strings.Contains(out, "S4") || strings.Contains(out, "I1") {
t.Errorf("zero-valued S3/S4/I1 must be omitted, got:\n%s", out)
}
}
func TestInterfaceNameForID(t *testing.T) {
if got := interfaceNameForID(42); got != "awg42" {
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
}
}
+145
View File
@@ -0,0 +1,145 @@
package amneziawg
import (
"crypto/rand"
"fmt"
"math/big"
"strconv"
"strings"
)
// awgHMax is the upper bound for generated H values: 2^31-1. The AmneziaWG
// spec allows the full uint32, but the amneziawg-windows-client config editor
// rejects values above 2^31-1, so generation stays in the safe half for
// cross-client compatibility.
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())
}
// GenerateObfuscation20 produces a randomized AmneziaWG 2.0 parameter set.
// preset "mobile" tunes junk packets for restrictive mobile carriers; any
// other value uses the balanced "default" preset. Values are randomized per
// call so each server gets a unique fingerprint — a static value gets
// profiled by DPI, defeating the point of the obfuscation.
func GenerateObfuscation20(preset string) Obfuscation20 {
var o Obfuscation20
switch preset {
case "mobile":
// Jc=3 and a narrow Jmax survive carriers like Tele2/Yota/Megafon.
o.Jc = 3
o.Jmin = randInt(30, 50)
o.Jmax = o.Jmin + randInt(20, 80)
default:
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 must not equal 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)
}
o.S3 = randInt(8, 55) // cookie padding (max 64)
o.S4 = randInt(4, 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 prepended before each handshake.
o.I1 = fmt.Sprintf("<r %d>", randInt(32, 256))
return o
}
// generateHRanges returns four non-overlapping "low-high" ranges for H1-H4.
// Each is at least hMinWidth wide, the lowest bound is >= 5 (values 1-4 are
// reserved for vanilla WireGuard message types) and the highest is <=
// 2^31-1. The space is split into four bands and a random sub-range is taken
// from each, which guarantees non-overlap (with a gap) and a valid width
// without retries.
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 obfuscation parameters before they
// are saved and applied, so a bad manual entry can't bring the interface
// down on `awg-quick up`. Empty H values are allowed (they fall back to a
// default when the config is generated). Each H value accepts either a
// single integer ("1") or a range ("100-800").
func ValidateObfuscation(o Obfuscation20) 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 := validateHValue(h); err != nil {
return fmt.Errorf("invalid H%d: %w", i+1, err)
}
}
return nil
}
// validateHValue checks one H parameter: empty, a single uint32, or
// "low-high" with 0 <= low <= high <= uint32 max.
func validateHValue(v string) error {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
if lo, hi, isRange := strings.Cut(v, "-"); isRange {
l, err1 := strconv.ParseInt(strings.TrimSpace(lo), 10, 64)
h, err2 := strconv.ParseInt(strings.TrimSpace(hi), 10, 64)
if err1 != nil || err2 != nil {
return fmt.Errorf("range %q must be two integers", v)
}
if l < 0 || h > hMaxValid || l > h {
return fmt.Errorf("range %q must satisfy 0 <= low <= high <= %d", v, hMaxValid)
}
return nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 || n > hMaxValid {
return fmt.Errorf("value %q must be an integer in 0..%d or a low-high range", v, hMaxValid)
}
return nil
}
+155
View File
@@ -0,0 +1,155 @@
package amneziawg
import (
"strconv"
"strings"
"testing"
)
func TestGenerateObfuscation20DefaultRanges(t *testing.T) {
for i := 0; i < 200; i++ {
o := GenerateObfuscation20("default")
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 < 8 || o.S3 > 55 {
t.Fatalf("S3 = %d, want [8,55]", o.S3)
}
if o.S4 < 4 || o.S4 > 27 {
t.Fatalf("S4 = %d, want [4,27]", o.S4)
}
for name, h := range map[string]string{"H1": o.H1, "H2": o.H2, "H3": o.H3, "H4": o.H4} {
if err := validateHValue(h); 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, "<r ") || !strings.HasSuffix(o.I1, ">") {
t.Fatalf("I1 = %q, want \"<r N>\" form", o.I1)
}
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, "<r "), ">"))
if err != nil || n < 32 || n > 256 {
t.Fatalf("I1 = %q, embedded N must be an integer in [32,256]", o.I1)
}
}
}
func TestGenerateObfuscation20MobilePreset(t *testing.T) {
for i := 0; i < 100; i++ {
o := GenerateObfuscation20("mobile")
if o.Jc != 3 {
t.Fatalf("mobile preset: Jc = %d, want 3", o.Jc)
}
if o.Jmin < 30 || o.Jmin > 50 {
t.Fatalf("mobile preset: Jmin = %d, want [30,50]", o.Jmin)
}
if o.Jmax < o.Jmin+20 || o.Jmax > o.Jmin+80 {
t.Fatalf("mobile preset: Jmax = %d, want [Jmin+20, Jmin+80] (Jmin=%d)", o.Jmax, o.Jmin)
}
}
}
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() Obfuscation20 {
return GenerateObfuscation20("default")
}
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 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)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package amneziawg manages native AmneziaWG interfaces (via awg-quick/awg,
// the AmneziaWG DKMS kernel module's userspace tools) as sidecars to the
// panel, the same way internal/mtproto manages mtg processes: one inbound
// row maps to one desired Instance, and a Manager reconciles the running
// interfaces toward whatever the database currently wants.
package amneziawg
import "github.com/mhsanaei/3x-ui/v3/internal/database/model"
// Obfuscation20 is an AmneziaWG 2.0 obfuscation parameter set (junk packets,
// padding, magic headers, the I1 signature packet). 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 Obfuscation20 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 string `json:"i1,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
}
// 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".
Address []string
MTU int
Obfuscation Obfuscation20
Peers []Peer
// ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.
// Empty means auto-detect at config-generation time.
ExternalInterface string
}
// 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 the DNS line of downloadable client
// configs; the server's own interface never sets one (see BuildClientConfig).
PrimaryDNS string `json:"primaryDns,omitempty"`
SecondaryDNS string `json:"secondaryDns,omitempty"`
// ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.
// Empty means auto-detect.
ExternalInterface string `json:"externalInterface,omitempty"`
// 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
// 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"`
}
+2 -1
View File
@@ -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.
@@ -60,7 +61,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"`
+79
View File
@@ -16,6 +16,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"
@@ -616,6 +617,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 ""
}
@@ -662,6 +665,82 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
// genAmneziaWGLink builds a per-client amneziawg:// share link mirroring
// genWireguardLink: the client's private key is the userinfo, the server
// public key and obfuscation parameters plus the client's tunnel address ride
// in the query. 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
link := fmt.Sprintf("amneziawg://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
params := make(map[string]string)
if server.PublicKey != "" {
params["publickey"] = server.PublicKey
}
if joined := strings.Join(client.AllowedIPs, ","); joined != "" {
params["address"] = joined
}
if server.MTU > 0 {
params["mtu"] = strconv.Itoa(server.MTU)
}
var dnsParts []string
if server.PrimaryDNS != "" {
dnsParts = append(dnsParts, server.PrimaryDNS)
}
if server.SecondaryDNS != "" {
dnsParts = append(dnsParts, server.SecondaryDNS)
}
if len(dnsParts) > 0 {
params["dns"] = strings.Join(dnsParts, ",")
}
if client.PreSharedKey != "" {
params["presharedkey"] = client.PreSharedKey
}
if client.KeepAlive > 0 {
params["keepalive"] = strconv.Itoa(client.KeepAlive)
}
params["jc"] = strconv.Itoa(server.Jc)
params["jmin"] = strconv.Itoa(server.Jmin)
params["jmax"] = strconv.Itoa(server.Jmax)
params["s1"] = strconv.Itoa(server.S1)
params["s2"] = strconv.Itoa(server.S2)
if server.S3 > 0 {
params["s3"] = strconv.Itoa(server.S3)
}
if server.S4 > 0 {
params["s4"] = strconv.Itoa(server.S4)
}
if server.H1 != "" {
params["h1"] = server.H1
}
if server.H2 != "" {
params["h2"] = server.H2
}
if server.H3 != "" {
params["h3"] = server.H3
}
if server.H4 != "" {
params["h4"] = server.H4
}
if server.I1 != "" {
params["i1"] = server.I1
}
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
// 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
+72
View File
@@ -0,0 +1,72 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// AmneziaWGJob reconciles the running AmneziaWG interfaces against the
// enabled AmneziaWG inbounds in the database, restarts/reloads any that
// drifted, and folds the per-peer traffic scraped from `awg show dump` into
// the usual client and inbound traffic accounting. Mirrors MtprotoJob.
type AmneziaWGJob struct {
inboundService service.InboundService
}
// NewAmneziaWGJob creates a new AmneziaWG reconcile/traffic job instance.
func NewAmneziaWGJob() *AmneziaWGJob {
return new(AmneziaWGJob)
}
// Run reconciles desired AmneziaWG inbounds with running interfaces and
// records per-peer traffic deltas and online status.
func (j *AmneziaWGJob) Run() {
desired, err := j.inboundService.DesiredAmneziaWGInstances()
if err != nil {
logger.Warning("amneziawg job: get desired instances failed:", err)
return
}
activeTags := make([]string, 0, len(desired))
for _, inst := range desired {
activeTags = append(activeTags, inst.Tag)
}
mgr := amneziawg.GetManager()
mgr.Reconcile(desired)
deltas, onlineEmails := mgr.CollectTraffic()
clientTraffics := make([]*xray.ClientTraffic, 0, len(deltas))
inboundUp := make(map[string]int64)
inboundDown := make(map[string]int64)
for _, d := range deltas {
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: d.Email,
Up: d.Up,
Down: d.Down,
})
inboundUp[d.Tag] += d.Up
inboundDown[d.Tag] += d.Down
}
traffics := make([]*xray.Traffic, 0, len(inboundUp))
for tag, up := range inboundUp {
traffics = append(traffics, &xray.Traffic{
IsInbound: true,
Tag: tag,
Up: up,
Down: inboundDown[tag],
})
}
if len(traffics) > 0 || len(clientTraffics) > 0 {
if _, _, err := j.inboundService.AddTraffic(traffics, clientTraffics); err != nil {
logger.Warning("amneziawg job: add traffic failed:", err)
}
}
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
}
+45 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"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 +54,13 @@ 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
}
return amneziawg.GetManager().Ensure(inst)
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
@@ -67,6 +75,10 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
mtproto.GetManager().Remove(ib.Id)
return nil
}
if ib.Protocol == model.AmneziaWG {
amneziawg.GetManager().Remove(ib.Id)
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
@@ -76,6 +88,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 +127,36 @@ 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 pick a peers-only `syncconf` instead of always bouncing the interface
// (see internal/amneziawg.Manager.ensureLocked).
func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG {
amneziawg.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 {
amneziawg.GetManager().Remove(newIb.Id)
return nil
}
inst, ok := amneziawg.InstanceFromInbound(newIb)
if !ok {
amneziawg.GetManager().Remove(newIb.Id)
return nil
}
return amneziawg.GetManager().Ensure(inst)
}
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 +165,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 {
+100
View File
@@ -0,0 +1,100 @@
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"
)
// defaultAmneziaWGSubnetBase resolves the /CIDR base new peer addresses are
// allocated from, out of the inbound's own configured server subnet — unlike
// WireGuard, which always falls back to a fixed 10.0.0.0/24.
func defaultAmneziaWGSubnetBase(settingsJSON string) (string, 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
}
return fmt.Sprintf("%s/%d", parsed.Server.SubnetIP, cidr), nil
}
// defaultAmneziaWGClients fills in blank AmneziaWG credentials for newly
// added clients: a generated keypair when none was provided, a derived
// public key when only a private key was given, and a unique tunnel address
// allocated from the inbound's own configured 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. Mirrors defaultWireguardClients, reusing
// its IP allocation and validation helpers — the only real difference is
// where the allocation base comes from.
func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any) error {
base, err := defaultAmneziaWGSubnetBase(settingsJSON)
if err != nil {
return err
}
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
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 {
addr, err := allocateWireguardAddress(used, base)
if err != nil {
return err
}
c.AllowedIPs = []string{addr}
} 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 != "" {
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
}
+18 -5
View File
@@ -362,6 +362,11 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
return false, dErr
}
}
if oldInbound.Protocol == model.AmneziaWG {
if dErr := defaultAmneziaWGClients(oldInbound.Settings, existingClients, clients, interfaceClients); dErr != nil {
return false, dErr
}
}
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
@@ -465,6 +470,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 {
@@ -596,10 +603,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
@@ -676,7 +683,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
@@ -843,6 +850,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)
@@ -1024,6 +1033,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.
+26
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.
@@ -729,6 +749,9 @@ 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
}
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
return inbound, false, err
@@ -1149,6 +1172,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)
oldInbound, err := s.GetInbound(inbound.Id)
+198
View File
@@ -0,0 +1,198 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"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"
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 buildRuntimeInboundForAPI 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.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil {
payload = built
}
}
if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil {
logger.Debug("amneziawg: immediate apply failed for inbound", inboundId, ":", err)
}
}
// defaultAmneziaWGServer builds a fresh server block: a random AmneziaWG 2.0
// 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",
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
}
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
}
}
if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
bs, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return err
}
inbound.Settings = string(bs)
return nil
}
+1 -1
View File
@@ -20,7 +20,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
@@ -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))
+1 -1
View File
@@ -139,7 +139,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{}
+31
View File
@@ -915,6 +915,12 @@
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
"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 (حشو رد الكوكي، 2.0)",
"s4": "S4 (حشو حزمة النقل، 2.0)",
"h1": "H1 (رأس سحري)",
"h2": "H2 (رأس سحري)",
"h3": "H3 (رأس سحري)",
"h4": "H4 (رأس سحري)",
"hHint": "رقم واحد أو نطاق. اتركه فارغًا للقيم الافتراضية الكلاسيكية 1/2/3/4.",
"i1": "I1 (حزمة التوقيع، 2.0)",
"i1Hint": "خاص بـ AmneziaWG 2.0 فقط. اتركه فارغًا للتوافق مع الإصدار 1.x."
},
"tun": {
"nameDesc": "اسم واجهة TUN. القيمة الافتراضية هي 'xray0'",
"mtuDesc": "وحدة النقل الأقصى. الحد الأقصى لحجم حزم البيانات. القيمة الافتراضية هي 1500",
+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": "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",
"amneziaWgConfig": "Configuración de AmneziaWG",
"mtprotoSecret": "Secreto MTProto",
"mtprotoSecretHint": "El secreto FakeTLS del cliente. Vuelve a generarlo para cambiarlo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -1800,6 +1806,31 @@
"psk": "Clave precompartida",
"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.",
"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, 2.0)",
"s4": "S4 (relleno del paquete de transporte, 2.0)",
"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, 2.0)",
"i1Hint": "Solo para AmneziaWG 2.0. Déjalo vacío para compatibilidad con 1.x."
},
"tun": {
"nameDesc": "El nombre de la interfaz TUN. El valor predeterminado es 'xray0'",
"mtuDesc": "Unidad Máxima de Transmisión. El tamaño máximo de los paquetes de datos. El valor predeterminado es 1500",
+31
View File
@@ -915,6 +915,12 @@
"wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
"wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
"wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
"amneziaWgPrivateKey": "کلید خصوصی AmneziaWG",
"amneziaWgPublicKey": "کلید عمومی AmneziaWG",
"amneziaWgPreSharedKey": "کلید پیش‌اشتراکی AmneziaWG",
"amneziaWgAllowedIPs": "آی‌پی‌های مجاز 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، نسخه ۲.۰)",
"s4": "S4 (پرکننده بسته انتقال، نسخه ۲.۰)",
"h1": "H1 (سرصفحه جادویی)",
"h2": "H2 (سرصفحه جادویی)",
"h3": "H3 (سرصفحه جادویی)",
"h4": "H4 (سرصفحه جادویی)",
"hHint": "یک عدد صحیح یا یک بازه. برای مقادیر پیش‌فرض کلاسیک ۱/۲/۳/۴ خالی بگذارید.",
"i1": "I1 (بسته امضا، نسخه ۲.۰)",
"i1Hint": "فقط برای AmneziaWG 2.0. برای سازگاری با نسخه ۱.x خالی بگذارید."
},
"tun": {
"nameDesc": "نام رابط TUN. مقدار پیش‌فرض 'xray0' است",
"mtuDesc": "واحد انتقال حداکثر. بیشترین اندازه بسته‌های داده. مقدار پیش‌فرض 1500 است",
+31
View File
@@ -915,6 +915,12 @@
"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",
"amneziaWgConfig": "Konfigurasi AmneziaWG",
"mtprotoSecret": "Secret MTProto",
"mtprotoSecretHint": "Secret FakeTLS klien. Buat ulang untuk menggantinya.",
"mtprotoAdTag": "Ad-tag (kanal bersponsor)",
@@ -1800,6 +1806,31 @@
"psk": "Kunci Pra-Bagi",
"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.",
"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, 2.0)",
"s4": "S4 (padding paket transport, 2.0)",
"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, 2.0)",
"i1Hint": "Hanya untuk AmneziaWG 2.0. Biarkan kosong untuk kompatibilitas dengan 1.x."
},
"tun": {
"nameDesc": "Nama antarmuka TUN. Standar adalah 'xray0'",
"mtuDesc": "Unit Transmisi Maksimum. Ukuran maksimum paket data. Standar adalah 1500",
+31
View File
@@ -915,6 +915,12 @@
"wireguardPreSharedKey": "WireGuard 事前共有鍵",
"wireguardAllowedIPs": "WireGuard 許可IP",
"wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
"amneziaWgPrivateKey": "AmneziaWG 秘密鍵",
"amneziaWgPublicKey": "AmneziaWG 公開鍵",
"amneziaWgPreSharedKey": "AmneziaWG 事前共有鍵",
"amneziaWgAllowedIPs": "AmneziaWG 許可IP",
"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)に使用するホストのNIC。空欄で自動検出。",
"jc": "Jc(ジャンクパケット数)",
"jmin": "Jmin(ジャンクパケット最小サイズ)",
"jmax": "Jmax(ジャンクパケット最大サイズ)",
"s1": "S1(initパケットのパディングサイズ)",
"s2": "S2responseパケットのパディングサイズ)",
"s3": "S3cookie replyパディング、2.0",
"s4": "S4(トランスポートパケットパディング、2.0)",
"h1": "H1(マジックヘッダー)",
"h2": "H2(マジックヘッダー)",
"h3": "H3(マジックヘッダー)",
"h4": "H4(マジックヘッダー)",
"hHint": "整数または範囲を指定。空欄の場合は従来の1/2/3/4がデフォルトになります。",
"i1": "I1(署名パケット、2.0",
"i1Hint": "AmneziaWG 2.0専用。空欄で1.x互換の設定になります。"
},
"tun": {
"nameDesc": "TUN インターフェースの名前。デフォルトは 'xray0' です",
"mtuDesc": "最大伝送単位。データパケットの最大サイズ。デフォルトは 1500 です",
+31
View File
@@ -915,6 +915,12 @@
"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",
"amneziaWgConfig": "Configuração do AmneziaWG",
"mtprotoSecret": "Segredo MTProto",
"mtprotoSecretHint": "O segredo FakeTLS do cliente. Gere novamente para trocá-lo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -1800,6 +1806,31 @@
"psk": "Chave Pré-Compartilhada",
"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.",
"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, 2.0)",
"s4": "S4 (preenchimento do pacote de transporte, 2.0)",
"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, 2.0)",
"i1Hint": "Somente para AmneziaWG 2.0. Deixe vazio para compatibilidade com 1.x."
},
"tun": {
"nameDesc": "O nome da interface TUN. O padrão é 'xray0'",
"mtuDesc": "Unidade Máxima de Transmissão. O tamanho máximo dos pacotes de dados. O padrão é 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",
+31
View File
@@ -915,6 +915,12 @@
"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",
"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)",
@@ -1800,6 +1806,31 @@
"psk": "Ön Paylaşılan Anahtar",
"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.",
"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, 2.0)",
"s4": "S4 (transport paketi dolgusu, 2.0)",
"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, 2.0)",
"i1Hint": "Yalnızca AmneziaWG 2.0 için. 1.x uyumluluğu için boş bırakın."
},
"tun": {
"nameDesc": "TUN arabiriminin adı. Varsayılan değer 'xray0'dır.",
"mtuDesc": "Maksimum İletim Birimi. Veri paketlerinin maksimum boyutu. Varsayılan değer 1500'dür.",
+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 (заповнення транспортного пакета, 2.0)",
"h1": "H1 (магічний заголовок)",
"h2": "H2 (магічний заголовок)",
"h3": "H3 (магічний заголовок)",
"h4": "H4 (магічний заголовок)",
"hHint": "Ціле число або діапазон. Залиште порожнім для класичних значень 1/2/3/4.",
"i1": "I1 (пакет підпису, 2.0)",
"i1Hint": "Лише для AmneziaWG 2.0. Залиште порожнім для сумісності з 1.x."
},
"tun": {
"nameDesc": "Назва інтерфейсу TUN. Значення за замовчуванням - 'xray0'",
"mtuDesc": "Максимальна одиниця передачі. Максимальний розмір пакетів даних. Значення за замовчуванням - 1500",
+31
View File
@@ -915,6 +915,12 @@
"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",
"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ợ)",
@@ -1800,6 +1806,31 @@
"psk": "Khóa chia sẻ",
"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.",
"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, 2.0)",
"s4": "S4 (đệm gói transport, 2.0)",
"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ý, 2.0)",
"i1Hint": "Chỉ dành cho AmneziaWG 2.0. Để trống để tương thích với 1.x."
},
"tun": {
"nameDesc": "Tên của giao diện TUN. Giá trị mặc định là 'xray0'",
"mtuDesc": "Đơn vị Truyền Tối đa. Kích thước tối đa của các gói dữ liệu. Giá trị mặc định là 1500",
+31
View File
@@ -915,6 +915,12 @@
"wireguardPreSharedKey": "WireGuard 预共享密钥",
"wireguardAllowedIPs": "WireGuard 允许的 IP",
"wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
"amneziaWgPrivateKey": "AmneziaWG 私钥",
"amneziaWgPublicKey": "AmneziaWG 公钥",
"amneziaWgPreSharedKey": "AmneziaWG 预共享密钥",
"amneziaWgAllowedIPs": "AmneziaWG 允许的 IP",
"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": "用于 NATPostUp/PostDown)的主机网卡。留空则自动检测。",
"jc": "Jc(垃圾包数量)",
"jmin": "Jmin(垃圾包最小大小)",
"jmax": "Jmax(垃圾包最大大小)",
"s1": "S1init 包填充大小)",
"s2": "S2response 包填充大小)",
"s3": "S3cookie reply 填充,2.0",
"s4": "S4(传输包填充,2.0",
"h1": "H1(魔术头)",
"h2": "H2(魔术头)",
"h3": "H3(魔术头)",
"h4": "H4(魔术头)",
"hHint": "单个整数或范围。留空则使用经典默认值 1/2/3/4。",
"i1": "I1(签名包,2.0",
"i1Hint": "仅适用于 AmneziaWG 2.0。留空则兼容 1.x。"
},
"tun": {
"nameDesc": "TUN 接口的名称。默认值为 'xray0'",
"mtuDesc": "最大传输单元。数据包的最大大小。默认值为 1500",
+31
View File
@@ -915,6 +915,12 @@
"wireguardPreSharedKey": "WireGuard 預共用金鑰",
"wireguardAllowedIPs": "WireGuard 允許的 IP",
"wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
"amneziaWgPrivateKey": "AmneziaWG 私鑰",
"amneziaWgPublicKey": "AmneziaWG 公鑰",
"amneziaWgPreSharedKey": "AmneziaWG 預共用金鑰",
"amneziaWgAllowedIPs": "AmneziaWG 允許的 IP",
"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": "用於 NATPostUp/PostDown)的主機網路介面。留空則自動偵測。",
"jc": "Jc(垃圾封包數量)",
"jmin": "Jmin(垃圾封包最小大小)",
"jmax": "Jmax(垃圾封包最大大小)",
"s1": "S1init 封包填充大小)",
"s2": "S2response 封包填充大小)",
"s3": "S3cookie reply 填充,2.0",
"s4": "S4(傳輸封包填充,2.0",
"h1": "H1(魔術標頭)",
"h2": "H2(魔術標頭)",
"h3": "H3(魔術標頭)",
"h4": "H4(魔術標頭)",
"hHint": "單一整數或範圍。留空則使用經典預設值 1/2/3/4。",
"i1": "I1(簽章封包,2.0",
"i1Hint": "僅適用於 AmneziaWG 2.0。留空則相容 1.x。"
},
"tun": {
"nameDesc": "TUN 介面的名稱。預設值為 'xray0'",
"mtuDesc": "最大傳輸單元。資料包的最大大小。預設值為 1500",
+8
View File
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -288,6 +289,7 @@ const (
cadenceXrayRestart = "@every 30s"
cadenceXrayTraffic = "@every 5s"
cadenceMtproto = "@every 10s"
cadenceAmneziaWG = "@every 10s"
cadenceClientIPScan = "@every 10s"
cadenceNodeHeartbeat = "@every 5s"
cadenceNodeTraffic = "@every 5s"
@@ -327,6 +329,11 @@ func (s *Server) startTask(restartXray bool) {
_, _ = s.cron.AddJob(cadenceMtproto, mtJob)
go mtJob.Run()
// Reconcile AmneziaWG interfaces and scrape their traffic
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())
@@ -680,6 +687,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
if stopXray {
_ = s.xrayService.StopXray()
mtproto.GetManager().StopAll()
amneziawg.GetManager().StopAll()
}
if s.cron != nil {
s.cron.Stop()
+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)