diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 38eae4a3d..a37fd5f80 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1810,7 +1810,8 @@ "mixed", "tunnel", "tun", - "mtproto" + "mtproto", + "amneziawg" ], "example": "vless", "type": "string" @@ -1953,6 +1954,15 @@ }, "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" @@ -2789,6 +2799,92 @@ ], "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->Zod/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": { @@ -3243,6 +3339,7 @@ "success": true, "obj": [ { + "awgServer": null, "enable": true, "id": 1, "listen": "", diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 638af5314..21680b0e5 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -899,7 +899,7 @@ export function genAmneziaWGLink(input: GenAmneziaWGLinkInput): string { const url = new URL(`amneziawg://${formatUrlHost(address)}:${port}`); url.username = client.privateKey ?? ''; - if (server.publicKey.length > 0) url.searchParams.set('publickey', server.publicKey); + if (server.publicKey && server.publicKey.length > 0) url.searchParams.set('publickey', server.publicKey); if ((client.allowedIPs ?? []).length > 0) { url.searchParams.set('address', client.allowedIPs.join(',')); } diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 5afa8afc9..2e0c9954a 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -43,6 +43,32 @@ export const ClientRecordSchema = z.object({ updatedAt: z.number().optional(), }).loose(); +// AmneziaWG's server block, used by the clients page to render a +// downloadable per-client .conf without a second round trip. Unlike +// WireGuard's flattened wgPublicKey/wgMtu/wgDns below, this stays a nested +// object — AmneziaWG has many more fields (the obfuscation parameter set) and +// buildAmneziaWGClientConfig (pages/clients/amneziawgConfig.ts) already +// expects this exact nested shape. Mirrors the backend's +// InboundOption.AwgServer (internal/web/service/inbound.go). +export const AwgServerOptionSchema = z.object({ + publicKey: z.string().optional(), + mtu: z.number().optional(), + primaryDns: z.string().optional(), + secondaryDns: z.string().optional(), + jc: z.number().optional(), + jmin: z.number().optional(), + jmax: z.number().optional(), + s1: z.number().optional(), + s2: z.number().optional(), + s3: z.number().optional(), + s4: z.number().optional(), + h1: z.string().optional(), + h2: z.string().optional(), + h3: z.string().optional(), + h4: z.string().optional(), + i1: z.string().optional(), +}).loose(); + export const InboundOptionSchema = z.object({ id: z.number(), remark: z.string().optional(), @@ -54,6 +80,7 @@ export const InboundOptionSchema = z.object({ wgPublicKey: z.string().optional(), wgMtu: z.number().optional(), wgDns: z.string().optional(), + awgServer: AwgServerOptionSchema.nullable().optional(), mtprotoDomain: z.string().optional(), // Hosting node id; absent/null for this panel's own inbounds (#4997). nodeId: z.number().nullable().optional(), diff --git a/internal/amneziawg/manager.go b/internal/amneziawg/manager.go index 2e447897c..527d16f34 100644 --- a/internal/amneziawg/manager.go +++ b/internal/amneziawg/manager.go @@ -3,6 +3,7 @@ package amneziawg import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "maps" @@ -534,9 +535,16 @@ func removeConfigFile(interfaceName string) { } } +// awgCommandTimeout bounds every short-lived awg/awg-quick invocation so a +// hung command (e.g. a stuck kernel module operation) can't block the +// reconcile job indefinitely. +const awgCommandTimeout = 30 * time.Second + // interfaceUp brings an AmneziaWG interface up via awg-quick. func interfaceUp(interfaceName string) error { - out, err := exec.Command("awg-quick", "up", configPath(interfaceName)).CombinedOutput() + ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "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) } @@ -545,7 +553,9 @@ func interfaceUp(interfaceName string) error { // interfaceDown takes an AmneziaWG interface down via awg-quick. func interfaceDown(interfaceName string) error { - out, err := exec.Command("awg-quick", "down", configPath(interfaceName)).CombinedOutput() + ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "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) } @@ -555,7 +565,9 @@ func interfaceDown(interfaceName string) error { // isInterfaceUp checks whether the named AmneziaWG interface currently // exists. func isInterfaceUp(interfaceName string) bool { - return exec.Command("awg", "show", interfaceName).Run() == nil + ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) + defer cancel() + return exec.CommandContext(ctx, "awg", "show", interfaceName).Run() == nil } // syncConfig applies a peers-only config change without dropping existing @@ -566,13 +578,17 @@ func syncConfig(inst Instance) error { return interfaceUp(inst.InterfaceName) } - stripped, err := exec.Command("awg-quick", "strip", configPath(inst.InterfaceName)).Output() + ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) + defer cancel() + stripped, err := exec.CommandContext(ctx, "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") + syncCtx, syncCancel := context.WithTimeout(context.Background(), awgCommandTimeout) + defer syncCancel() + sync := exec.CommandContext(syncCtx, "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) @@ -601,7 +617,9 @@ type peerStat struct { // 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() + ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "awg", "show", interfaceName, "dump").Output() if err != nil { return nil, fmt.Errorf("awg show %s dump failed: %w", interfaceName, err) }