mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 09:57:14 +00:00
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:
@@ -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": "",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 است",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "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 です",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)的主机网卡。留空则自动检测。",
|
||||
"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",
|
||||
|
||||
@@ -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)的主機網路介面。留空則自動偵測。",
|
||||
"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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user