mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-09 11:48:18 +00:00
fix(amneziawg): resolve all 3 real CI failures (typecheck/lint/codegen)
Found by checking the fork's Actions tab after the last two pushes —
the release build passed (it doesn't run these checks) but the
separate CI workflow caught three real issues:
- golangci-lint (noctx): every internal/amneziawg/manager.go exec.Command
call is now exec.CommandContext with a 30s timeout, so a hung
awg-quick/awg invocation can't block the reconcile job indefinitely
(mirrors internal/mtproto/process.go's own CommandContext usage).
- tsc --noEmit: frontend/src/schemas/client.ts's hand-maintained
InboundOptionSchema (used by the useClients hook, separate from the
auto-generated one in generated/) never got an awgServer field added
when the AmneziaWG frontend work was done — every read of
inbound.awgServer.* in amneziawgConfig.ts was typing as {}. Added
AwgServerOptionSchema, nested (not flattened like wg*) to match what
amneziawgConfig.ts already expects. Also guarded server.publicKey in
inbound-link.ts's genAmneziaWGLink against the schema's optional type.
- codegen staleness: frontend/public/openapi.json is produced by a Node
script (gen:api) this machine can't run; hand-applied the exact diff
the CI failure log already showed (amneziawg protocol enum entry,
ServerSettings schema, InboundOption.awgServer, one example payload),
verified as valid JSON.
Also confirmed independently by this run: install_amneziawg (previous
commit) installed and loaded the DKMS module successfully on both amd64
and arm64 CI runners. The two "Deploy Smoke Tests" failures are
unrelated to this change — this fork has only ever published the
dev-latest pre-release, and GitHub's /releases/latest API deliberately
excludes pre-releases, so the smoke test's no-argument install path
(which resolves "latest") has nothing to find. Not a regression; needs
an actual tagged release whenever that's wanted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1810,7 +1810,8 @@
|
|||||||
"mixed",
|
"mixed",
|
||||||
"tunnel",
|
"tunnel",
|
||||||
"tun",
|
"tun",
|
||||||
"mtproto"
|
"mtproto",
|
||||||
|
"amneziawg"
|
||||||
],
|
],
|
||||||
"example": "vless",
|
"example": "vless",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1953,6 +1954,15 @@
|
|||||||
},
|
},
|
||||||
"InboundOption": {
|
"InboundOption": {
|
||||||
"properties": {
|
"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": {
|
"enable": {
|
||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -2789,6 +2799,92 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"Setting": {
|
||||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3243,6 +3339,7 @@
|
|||||||
"success": true,
|
"success": true,
|
||||||
"obj": [
|
"obj": [
|
||||||
{
|
{
|
||||||
|
"awgServer": null,
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
|
|||||||
@@ -899,7 +899,7 @@ export function genAmneziaWGLink(input: GenAmneziaWGLinkInput): string {
|
|||||||
const url = new URL(`amneziawg://${formatUrlHost(address)}:${port}`);
|
const url = new URL(`amneziawg://${formatUrlHost(address)}:${port}`);
|
||||||
url.username = client.privateKey ?? '';
|
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) {
|
if ((client.allowedIPs ?? []).length > 0) {
|
||||||
url.searchParams.set('address', client.allowedIPs.join(','));
|
url.searchParams.set('address', client.allowedIPs.join(','));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,32 @@ export const ClientRecordSchema = z.object({
|
|||||||
updatedAt: z.number().optional(),
|
updatedAt: z.number().optional(),
|
||||||
}).loose();
|
}).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({
|
export const InboundOptionSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
remark: z.string().optional(),
|
remark: z.string().optional(),
|
||||||
@@ -54,6 +80,7 @@ export const InboundOptionSchema = z.object({
|
|||||||
wgPublicKey: z.string().optional(),
|
wgPublicKey: z.string().optional(),
|
||||||
wgMtu: z.number().optional(),
|
wgMtu: z.number().optional(),
|
||||||
wgDns: z.string().optional(),
|
wgDns: z.string().optional(),
|
||||||
|
awgServer: AwgServerOptionSchema.nullable().optional(),
|
||||||
mtprotoDomain: z.string().optional(),
|
mtprotoDomain: z.string().optional(),
|
||||||
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
||||||
nodeId: z.number().nullable().optional(),
|
nodeId: z.number().nullable().optional(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package amneziawg
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"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.
|
// interfaceUp brings an AmneziaWG interface up via awg-quick.
|
||||||
func interfaceUp(interfaceName string) error {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("awg-quick up %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err)
|
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.
|
// interfaceDown takes an AmneziaWG interface down via awg-quick.
|
||||||
func interfaceDown(interfaceName string) error {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("awg-quick down %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err)
|
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
|
// isInterfaceUp checks whether the named AmneziaWG interface currently
|
||||||
// exists.
|
// exists.
|
||||||
func isInterfaceUp(interfaceName string) bool {
|
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
|
// syncConfig applies a peers-only config change without dropping existing
|
||||||
@@ -566,13 +578,17 @@ func syncConfig(inst Instance) error {
|
|||||||
return interfaceUp(inst.InterfaceName)
|
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 {
|
if err != nil {
|
||||||
logger.Warningf("amneziawg: awg-quick strip failed for %s, restarting: %v", inst.InterfaceName, err)
|
logger.Warningf("amneziawg: awg-quick strip failed for %s, restarting: %v", inst.InterfaceName, err)
|
||||||
return restartInterface(inst.InterfaceName)
|
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)
|
sync.Stdin = bytes.NewReader(stripped)
|
||||||
if out, err := sync.CombinedOutput(); err != nil {
|
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)
|
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,
|
// preshared-key, endpoint, allowed-ips, latest-handshake, transfer-rx,
|
||||||
// transfer-tx, persistent-keepalive).
|
// transfer-tx, persistent-keepalive).
|
||||||
func getPeerStats(interfaceName string) ([]peerStat, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("awg show %s dump failed: %w", interfaceName, err)
|
return nil, fmt.Errorf("awg show %s dump failed: %w", interfaceName, err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user