mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 09:57:14 +00:00
3b5273b1d6
ValidateObfuscation exists, by its own doc comment, so that a bad manual entry cannot break the embedded device's IpcSet. It was not covering enough to do that. Auditing the panel against amneziawg-go v3.1.20260828's full UAPI surface turned up two holes, both confirmed by driving the values through a real IpcSet: S1 = 70000 upstream parses s1-s4 as uint16 S2 = 70000 (device/uapi.go) Jc = -1 jc/jmin/jmax are uint32, so no negatives Jmin/Jmax = -5/-1 Jc = 5000000000 and nothing wider than uint32 I1 = <rand 100> newObfChain hard-fails on an unknown tag I1 = <r 100 ... and on a missing '>' I1 = <> ... and on an empty one All eight passed validation and were then rejected by the device. Only S3 and S4 were bounded, which is why the asymmetry went unnoticed. The inbound saves, the reconcile fails on every tick, and the interface never comes up with a single log line to say so. Bound the five numeric fields to the widths upstream actually parses, and check the I1-I5 chain's <tag value> structure against a tag set mirroring upstream's own obfBuilders map. Each tag's value grammar stays amneziawg-go's to enforce -- that is eight builders across several files, and duplicating them here would drift. So <r abc> still reaches IpcSet, now as the only remaining class rather than one of four. Mirror the same bounds in the Zod schema, next to the max() that s3 and s4 already carried, so the form rejects the value instead of the save doing it. TestValidatedObfuscationAlwaysApplies pins the contract itself: whatever ValidateObfuscation accepts, a real amneziawg-go device must accept too. It covers the specs the new grammar check deliberately allows, not just the ones it rejects, so the allowlist cannot quietly become stricter than upstream. The rest of the audit found no gaps: all 17 settable device keys reach buildUAPIConfig, ServerSettings, the Zod schema and all three .conf emitters. fwmark and persistent_keepalive_interval remain unemitted, both deliberately -- the panel models no fwmark anywhere, and keepAlive is carried client-side where WireGuard puts it.
104 lines
5.0 KiB
TypeScript
104 lines
5.0 KiB
TypeScript
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());
|
|
|
|
// Same null-absorbing preprocess for fields that keep a schema default:
|
|
// clearing the InputNumber refills the default instead of blocking the save.
|
|
const clearedToDefault = <T extends z.ZodType>(schema: T) =>
|
|
z.preprocess((v) => (v == null ? undefined : v), schema);
|
|
|
|
// An AmneziaWG client (multi-client model). Same key/address fields as
|
|
// WireguardClientSchema — the panel's generic ClientRecord already has those
|
|
// exact keys (privateKey/publicKey/preSharedKey/allowedIPs/keepAlive), so
|
|
// bulk operations, the QR modal and subscriptions all work unmodified — plus
|
|
// one AmneziaWG-only addition, forwardedPorts (WireGuard's Xray-native
|
|
// inbound has no host-level iptables layer to hang per-client DNAT off of).
|
|
// 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)),
|
|
forwardedPorts: z.string().default(''),
|
|
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 3.1 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; blank optional fields omit their feature
|
|
// from the rendered config.
|
|
export const AmneziawgServerSchema = z.object({
|
|
privateKey: z.string().optional(),
|
|
publicKey: z.string().optional(),
|
|
subnetIp: z.string().default('10.8.1.0'),
|
|
subnetCidr: clearedToDefault(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(''),
|
|
ipv6Enabled: z.boolean().default(false),
|
|
ipv6Subnet: z.string().default(''),
|
|
ipv6ExternalInterface: z.string().default(''),
|
|
// routeThroughXray is vestigial on the Go side (see ServerSettings' own
|
|
// doc comment) -- the embedded relay is always on, this field is read by
|
|
// nothing. Kept here anyway, with no corresponding form control, purely so
|
|
// z.object's default unknown-key stripping doesn't silently drop it from
|
|
// an existing stored settings blob on the next save.
|
|
routeThroughXray: z.boolean().default(false).optional(),
|
|
// Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go):
|
|
// jc/jmin/jmax are uint32, s1-s4 uint16. Wider values make IpcSet fail.
|
|
jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)),
|
|
jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
|
|
jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)),
|
|
s1: clearedToDefault(z.number().int().min(0).max(65535).default(30)),
|
|
s2: clearedToDefault(z.number().int().min(0).max(65535).default(45)),
|
|
s3: clearedToDefault(z.number().int().min(0).max(64).default(10)),
|
|
s4: clearedToDefault(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(''),
|
|
i2: z.string().default(''),
|
|
i3: z.string().default(''),
|
|
i4: z.string().default(''),
|
|
i5: z.string().default(''),
|
|
headerProtectionKey: z.string().default(''),
|
|
contentPaddingAddition: z.string().default(''),
|
|
rekeyAfterTime: z.string().default(''),
|
|
rekeyTimeout: z.string().default(''),
|
|
rejectAfterTime: z.string().default(''),
|
|
keepaliveTimeout: z.string().default(''),
|
|
maxHandshakeAttempts: z.string().default(''),
|
|
randomTrailers: z.boolean().default(false),
|
|
disableCookies: z.boolean().default(false),
|
|
});
|
|
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>;
|