inbound-form-adapter.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import type {
  2. InboundFormValues,
  3. ShareAddrStrategy,
  4. TrafficReset,
  5. } from '@/schemas/forms/inbound-form';
  6. import type { InboundSettings } from '@/schemas/protocols/inbound';
  7. import {
  8. AmneziawgClientSchema,
  9. HysteriaClientSchema,
  10. MtprotoClientSchema,
  11. ShadowsocksClientSchema,
  12. TrojanClientSchema,
  13. TuicClientSchema,
  14. VlessClientSchema,
  15. VmessClientSchema,
  16. WireguardClientSchema,
  17. } from '@/schemas/protocols/inbound';
  18. import type { StreamSettings } from '@/schemas/api/inbound';
  19. import type { Sniffing } from '@/schemas/primitives';
  20. import type { z } from 'zod';
  21. import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
  22. import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
  23. import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
  24. import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
  25. import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
  26. const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
  27. // Plain-data adapter between the panel's stored inbound row shape and
  28. // the typed InboundFormValues that Form.useForm<T> carries inside
  29. // InboundFormModal. No dependency on the legacy Inbound/DBInbound
  30. // classes — the modal hands the raw row in, takes typed values out, and
  31. // on submit calls formValuesToWirePayload() to get a payload ready to
  32. // POST to /panel/api/inbounds/add or /update/:id.
  33. export interface RawInboundRow {
  34. port?: number;
  35. listen?: string;
  36. protocol?: string;
  37. tag?: string;
  38. settings?: unknown;
  39. streamSettings?: unknown;
  40. sniffing?: unknown;
  41. up?: number;
  42. down?: number;
  43. total?: number;
  44. remark?: string;
  45. enable?: boolean;
  46. expiryTime?: number;
  47. trafficReset?: string;
  48. trafficResetDay?: number;
  49. lastTrafficResetTime?: number;
  50. nodeId?: number | null;
  51. shareAddrStrategy?: string;
  52. shareAddr?: string;
  53. subSortIndex?: number;
  54. disableFlow?: boolean;
  55. clientStats?: unknown;
  56. }
  57. // The wire payload — settings/streamSettings/sniffing arrive as JSON
  58. // strings, mirroring what the Go endpoints expect (xray-core wants the
  59. // nested config slices as strings to round-trip through its loader).
  60. export interface WireInboundPayload {
  61. up: number;
  62. down: number;
  63. total: number;
  64. remark: string;
  65. enable: boolean;
  66. expiryTime: number;
  67. trafficReset: TrafficReset;
  68. trafficResetDay: number;
  69. lastTrafficResetTime: number;
  70. listen: string;
  71. port: number;
  72. protocol: string;
  73. settings: string;
  74. streamSettings: string;
  75. sniffing: string;
  76. tag: string;
  77. clientStats?: unknown;
  78. nodeId?: number;
  79. shareAddrStrategy: ShareAddrStrategy;
  80. shareAddr: string;
  81. subSortIndex: number;
  82. disableFlow: boolean;
  83. }
  84. function coerceJsonObject(value: unknown): Record<string, unknown> {
  85. if (value == null) return {};
  86. if (typeof value === 'object' && !Array.isArray(value)) {
  87. return value as Record<string, unknown>;
  88. }
  89. if (typeof value !== 'string') return {};
  90. const trimmed = value.trim();
  91. if (trimmed === '') return {};
  92. try {
  93. const parsed = JSON.parse(trimmed);
  94. return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
  95. ? (parsed as Record<string, unknown>)
  96. : {};
  97. } catch {
  98. return {};
  99. }
  100. }
  101. const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
  102. const SHARE_ADDR_STRATEGIES: ShareAddrStrategy[] = ['node', 'listen', 'custom'];
  103. function coerceTrafficReset(v: unknown): TrafficReset {
  104. return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
  105. ? (v as TrafficReset)
  106. : 'never';
  107. }
  108. function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
  109. return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
  110. ? (v as ShareAddrStrategy)
  111. : 'node';
  112. }
  113. // Network values that map to a required `${network}Settings` key in
  114. // NetworkSettingsSchema. Older saved inbounds may be missing the per-
  115. // network sub-object (the legacy panel sometimes emitted streamSettings
  116. // without it, and an earlier panel-side prune wrongly stripped empty
  117. // `tcpSettings: {}` out of the wire payload). Reseat an empty object
  118. // here so InboundFormSchema.safeParse doesn't blow up at edit time.
  119. const NETWORK_SETTINGS_KEY: Record<string, string> = {
  120. tcp: 'tcpSettings',
  121. kcp: 'kcpSettings',
  122. ws: 'wsSettings',
  123. grpc: 'grpcSettings',
  124. httpupgrade: 'httpupgradeSettings',
  125. xhttp: 'xhttpSettings',
  126. hysteria: 'hysteriaSettings',
  127. };
  128. function healStreamNetworkKey(stream: Record<string, unknown>): void {
  129. if (typeof stream.method === 'string' && stream.method !== '') {
  130. stream.network = stream.method;
  131. }
  132. delete stream.method;
  133. const network = typeof stream.network === 'string' ? stream.network : '';
  134. const key = NETWORK_SETTINGS_KEY[network];
  135. if (!key) return;
  136. if (stream[key] == null || typeof stream[key] !== 'object') {
  137. stream[key] = {};
  138. }
  139. }
  140. function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
  141. const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
  142. return Array.isArray(tls?.certificates) ? (tls.certificates as Record<string, unknown>[]) : [];
  143. }
  144. function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
  145. for (const c of tlsCerts(stream)) c.useFile = tlsCertUsesFiles(c);
  146. }
  147. function stripTlsCertUseFile(stream: Record<string, unknown>): void {
  148. for (const c of tlsCerts(stream)) delete c.useFile;
  149. }
  150. export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
  151. const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
  152. const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
  153. const rawStream = coerceJsonObject(row.streamSettings);
  154. const streamSettings =
  155. Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;
  156. if (streamSettings) {
  157. healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
  158. synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
  159. const streamRecord = streamSettings as unknown as Record<string, unknown>;
  160. const xh = streamRecord.xhttpSettings;
  161. if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
  162. const parsed = XHttpStreamSettingsSchema.safeParse(xh);
  163. const xhttp = (parsed.success ? parsed.data : xh) as Record<string, unknown>;
  164. streamRecord.xhttpSettings = xhttp;
  165. const xmux = xhttp.xmux;
  166. if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
  167. xhttp.enableXmux = true;
  168. xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
  169. }
  170. }
  171. const so = streamRecord.sockopt;
  172. if (so && typeof so === 'object' && !Array.isArray(so)) {
  173. const raw = { ...(so as Record<string, unknown>) };
  174. // Imported/API configs may use lowercase v6only; the form key is V6Only.
  175. if ('v6only' in raw) {
  176. if (!('V6Only' in raw)) raw.V6Only = Boolean(raw.v6only);
  177. delete raw.v6only;
  178. }
  179. const parsed = SockoptStreamSettingsSchema.safeParse(raw);
  180. if (parsed.success) {
  181. streamRecord.sockopt = { ...raw, ...parsed.data };
  182. } else {
  183. streamRecord.sockopt = raw;
  184. }
  185. }
  186. }
  187. const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
  188. return {
  189. remark: row.remark ?? '',
  190. enable: row.enable ?? true,
  191. port: row.port ?? 0,
  192. listen: row.listen ?? '',
  193. tag: row.tag ?? '',
  194. expiryTime: row.expiryTime ?? 0,
  195. sniffing,
  196. streamSettings,
  197. up: row.up ?? 0,
  198. down: row.down ?? 0,
  199. total: row.total ?? 0,
  200. trafficReset: coerceTrafficReset(row.trafficReset),
  201. trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
  202. lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
  203. nodeId: row.nodeId ?? null,
  204. shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
  205. shareAddr: row.shareAddr ?? '',
  206. subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
  207. disableFlow: row.disableFlow ?? false,
  208. protocol,
  209. settings,
  210. } as InboundFormValues;
  211. }
  212. // Recursively strip undefined leaves from the wire payload. Empty arrays
  213. // and empty objects are PRESERVED — legacy XrayCommonClass.toJson() kept
  214. // shells like `tcpSettings: {}` so xray-core picks up its built-in
  215. // defaults, and stripping them led the FE to lose required-but-empty
  216. // arrays (vless clients, wireguard peers, etc.) which the Go side then
  217. // serialized back as `null`. Primitive values (including 0, false, '')
  218. // are kept verbatim.
  219. export function pruneEmpty(value: unknown): unknown {
  220. if (Array.isArray(value)) {
  221. return value.map(pruneEmpty);
  222. }
  223. if (value !== null && typeof value === 'object') {
  224. const out: Record<string, unknown> = {};
  225. for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
  226. const p = pruneEmpty(v);
  227. if (p === undefined) continue;
  228. out[k] = p;
  229. }
  230. return out;
  231. }
  232. return value;
  233. }
  234. // Per-protocol client field whitelist — the Zod schemas in
  235. // schemas/protocols/inbound/<proto>.ts define which keys a given
  236. // protocol's clients accept on the wire. When a global client is created
  237. // the panel may persist cross-protocol fields on the same row (`auth` for
  238. // hysteria, `password` for trojan, `security` for vmess, etc.); rendering
  239. // those inside a vless inbound's settings.clients is confusing and rides
  240. // dead weight in the wire payload. Parsing through the protocol's schema
  241. // gives us the canonical projection.
  242. function clientSchemaForProtocol(protocol: string): z.ZodType | null {
  243. switch (protocol) {
  244. case 'vless':
  245. return VlessClientSchema;
  246. case 'vmess':
  247. return VmessClientSchema;
  248. case 'trojan':
  249. return TrojanClientSchema;
  250. case 'shadowsocks':
  251. return ShadowsocksClientSchema;
  252. case 'hysteria':
  253. return HysteriaClientSchema;
  254. case 'wireguard':
  255. return WireguardClientSchema;
  256. case 'mtproto':
  257. return MtprotoClientSchema;
  258. case 'amneziawg':
  259. return AmneziawgClientSchema;
  260. case 'tuic':
  261. return TuicClientSchema;
  262. default:
  263. return null;
  264. }
  265. }
  266. export function normalizeClients(protocol: string, clients: unknown): unknown {
  267. const schema = clientSchemaForProtocol(protocol);
  268. if (!schema || !Array.isArray(clients)) return clients;
  269. return clients.map((c) => {
  270. const parsed = schema.safeParse(c);
  271. return parsed.success ? parsed.data : c;
  272. });
  273. }
  274. // Sniffing normalizer matching the legacy Sniffing.toJson(): when
  275. // disabled the payload is the bare `{ enabled: false }` regardless of
  276. // what the form holds; when enabled, only non-default fields ride.
  277. export function normalizeSniffing(s: Sniffing | undefined): Record<string, unknown> {
  278. if (!s || !s.enabled) return { enabled: false };
  279. const out: Record<string, unknown> = {
  280. enabled: true,
  281. destOverride: s.destOverride,
  282. };
  283. if (s.metadataOnly) out.metadataOnly = true;
  284. if (s.routeOnly) out.routeOnly = true;
  285. if (s.ipsExcluded?.length) out.ipsExcluded = s.ipsExcluded;
  286. if (s.domainsExcluded?.length) out.domainsExcluded = s.domainsExcluded;
  287. return out;
  288. }
  289. // Drops cosmetic empty-array keys that legacy XrayCommonClass.toJson()
  290. // explicitly skipped (fallbacks/finalmask). Mutates the pruned settings
  291. // objects in place; called AFTER pruneEmpty so we can lean on the
  292. // already-shallow shape.
  293. export function dropLegacyOptionalEmpties(
  294. settings: Record<string, unknown>,
  295. stream: Record<string, unknown> | undefined,
  296. ): void {
  297. // VLESS/Trojan emit `fallbacks` only when non-empty.
  298. const fb = settings.fallbacks;
  299. if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
  300. if (stream) {
  301. // StreamSettings emits `finalmask` only when at least one transport
  302. // mask exists (legacy `hasFinalMask`). Drop the whole block when all
  303. // sub-fields are empty; otherwise drop only the empty sub-arrays so
  304. // the wire payload doesn't carry a stray `"tcp": []` next to a
  305. // populated UDP mask list (and vice versa).
  306. const fm = stream.finalmask as
  307. | { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown }
  308. | undefined;
  309. if (fm && typeof fm === 'object') {
  310. const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
  311. const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
  312. const hasQuic = fm.quicParams != null;
  313. if (!hasTcp && !hasUdp && !hasQuic) {
  314. delete stream.finalmask;
  315. } else {
  316. if (!hasTcp) delete fm.tcp;
  317. if (!hasUdp) delete fm.udp;
  318. }
  319. }
  320. // Hysteria's per-client auth lives in settings.clients[*].auth; the
  321. // streamSettings.hysteriaSettings.auth slot is a holdover from older
  322. // hysteria builds and serves no purpose on the inbound side, so an
  323. // empty value shouldn't ride along in the JSON payload.
  324. const hs = stream.hysteriaSettings as { auth?: string } | undefined;
  325. if (hs && typeof hs === 'object' && (hs.auth === '' || hs.auth == null)) {
  326. delete hs.auth;
  327. }
  328. }
  329. }
  330. export function formValuesToWirePayload(values: InboundFormValues): WireInboundPayload {
  331. const settingsPruned = (pruneEmpty(values.settings ?? {}) ?? {}) as Record<string, unknown>;
  332. if (Array.isArray(settingsPruned.clients)) {
  333. settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
  334. }
  335. let streamPruned = values.streamSettings
  336. ? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
  337. : undefined;
  338. if (streamPruned) {
  339. streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
  340. stripTlsCertUseFile(streamPruned);
  341. }
  342. dropLegacyOptionalEmpties(settingsPruned, streamPruned);
  343. const payload: WireInboundPayload = {
  344. up: values.up,
  345. down: values.down,
  346. total: values.total,
  347. remark: values.remark,
  348. enable: values.enable,
  349. expiryTime: values.expiryTime,
  350. trafficReset: values.trafficReset,
  351. trafficResetDay: values.trafficResetDay,
  352. lastTrafficResetTime: values.lastTrafficResetTime,
  353. listen: values.listen,
  354. port: values.port,
  355. protocol: values.protocol,
  356. settings: JSON.stringify(settingsPruned),
  357. streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
  358. // mtproto is mtg-served, not Xray, so sniffing never applies — emit empty
  359. // rather than the default { enabled: false } so the row carries no sniffing.
  360. sniffing: canEnableSniffing({ protocol: values.protocol })
  361. ? JSON.stringify(normalizeSniffing(values.sniffing))
  362. : '',
  363. tag: values.tag,
  364. shareAddrStrategy: values.shareAddrStrategy,
  365. shareAddr: values.shareAddr,
  366. subSortIndex: values.subSortIndex,
  367. disableFlow: values.disableFlow,
  368. };
  369. if (values.nodeId != null) payload.nodeId = values.nodeId;
  370. return payload;
  371. }