inbound-form-adapter.ts 13 KB

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