From 7c54192055725d9bb5e8ae06986380dd036fd47a Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 3 Mar 2026 16:54:05 +0800 Subject: [PATCH] feat: add ny import compatibility with auto port assignment - Support optional listen_port field for automatic port assignment - Add alias field mapping (dest/dst/target, listenPort/port, name/forward_name) - Update help text to document auto port assignment - Add parser tests for alias fields and missing port handling Entire-Checkpoint: efae74a1f03c --- .../002-ny-import-tunnel-selection-compat.md | 7 ++ vite-frontend/src/pages/forward.tsx | 6 +- .../src/pages/forward/import-format.test.ts | 42 ++++++++ .../src/pages/forward/import-format.ts | 96 ++++++++++++++++--- 4 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 plans/002-ny-import-tunnel-selection-compat.md diff --git a/plans/002-ny-import-tunnel-selection-compat.md b/plans/002-ny-import-tunnel-selection-compat.md new file mode 100644 index 0000000..e0efebf --- /dev/null +++ b/plans/002-ny-import-tunnel-selection-compat.md @@ -0,0 +1,7 @@ +- [x] Review current forward import flow and confirm ny import uses tunnel selection +- [x] Define ny compatibility update with tunnel-first behavior and auto port assignment fallback +- [x] Update ny parser to accept alias fields and optional `listen_port` +- [x] Keep import execution bound to selected tunnel and remove entry-selection dependency from ux copy +- [x] Update ny import help text to document optional port auto assignment +- [x] Add parser tests for alias-field compatibility and missing-port auto assignment +- [x] Validate updated import parser tests locally diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 37002c0..714369a 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -4620,10 +4620,10 @@ export default function ForwardPage() { ) : ( <>

- ny格式:JSON对象,支持多个目标地址(负载均衡) + ny格式:JSON对象,支持多个目标地址(负载均衡),按所选隧道导入

- 格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"} + 格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}(listen_port可省略,自动分配端口)

)} @@ -4696,7 +4696,7 @@ export default function ForwardPage() { placeholder={ importFormat === "flvx" ? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口" - : '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"}' + : '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"};listen_port可省略自动分配' } value={importData} variant="flat" diff --git a/vite-frontend/src/pages/forward/import-format.test.ts b/vite-frontend/src/pages/forward/import-format.test.ts index ce656c6..4a9bb54 100644 --- a/vite-frontend/src/pages/forward/import-format.test.ts +++ b/vite-frontend/src/pages/forward/import-format.test.ts @@ -46,6 +46,33 @@ test("parseNyFormatData returns validation errors for invalid fields", () => { assert.match(result[1].error || "", /目标地址格式错误/); }); +test("parseNyFormatData allows missing listen_port for auto assignment", () => { + const input = '{"dest":["1.1.1.1:1000"],"name":"No Port"}'; + + const result = parseNyFormatData(input); + + assert.equal(result.length, 1); + assert.equal(result[0].error, undefined); + assert.equal(result[0].parsed?.listen_port, null); +}); + +test("parseNyFormatData supports ny alias fields", () => { + const input = + '{"dst":["2.2.2.2:2000"],"listenPort":"3000","forward_name":"Alias A"}\n{"target":"3.3.3.3:4000,4.4.4.4:5000","port":6000,"forwardName":"Alias B"}'; + + const result = parseNyFormatData(input); + + assert.equal(result.length, 2); + assert.equal(result[0].error, undefined); + assert.equal(result[1].error, undefined); + assert.deepEqual(result[0].parsed?.dest, ["2.2.2.2:2000"]); + assert.equal(result[0].parsed?.listen_port, 3000); + assert.equal(result[0].parsed?.name, "Alias A"); + assert.deepEqual(result[1].parsed?.dest, ["3.3.3.3:4000", "4.4.4.4:5000"]); + assert.equal(result[1].parsed?.listen_port, 6000); + assert.equal(result[1].parsed?.name, "Alias B"); +}); + test("convertNyItemToForwardInput maps ny fields correctly", () => { const mapped = convertNyItemToForwardInput({ dest: ["1.1.1.1:1111", "2.2.2.2:2222"], @@ -60,3 +87,18 @@ test("convertNyItemToForwardInput maps ny fields correctly", () => { strategy: "fifo", }); }); + +test("convertNyItemToForwardInput keeps null inPort for auto assignment", () => { + const mapped = convertNyItemToForwardInput({ + dest: ["1.1.1.1:1111"], + listen_port: null, + name: "No Port", + }); + + assert.deepEqual(mapped, { + name: "No Port", + inPort: null, + remoteAddr: "1.1.1.1:1111", + strategy: "fifo", + }); +}); diff --git a/vite-frontend/src/pages/forward/import-format.ts b/vite-frontend/src/pages/forward/import-format.ts index dff602a..8fadd6e 100644 --- a/vite-frontend/src/pages/forward/import-format.ts +++ b/vite-frontend/src/pages/forward/import-format.ts @@ -1,6 +1,6 @@ export interface NyImportItem { dest: string[]; - listen_port: number; + listen_port: number | null; name: string; } @@ -12,6 +12,70 @@ export interface ParsedNyImportLine { const ADDRESS_PATTERN = /^[^:]+:\d+$/; +const getAliasField = ( + item: Record, + aliases: string[], +): unknown => { + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(item, alias)) { + return item[alias]; + } + } + + return undefined; +}; + +const normalizeDestList = (value: unknown): string[] | null => { + if (Array.isArray(value)) { + const normalized = value.map((itemValue) => + typeof itemValue === "string" ? itemValue.trim() : "", + ); + + if (normalized.some((itemValue) => itemValue === "")) { + return null; + } + + return normalized; + } + + if (typeof value === "string") { + const normalized = value + .split(",") + .map((itemValue) => itemValue.trim()) + .filter((itemValue) => itemValue !== ""); + + return normalized.length > 0 ? normalized : null; + } + + return null; +}; + +const normalizeListenPort = (value: unknown): number | null | undefined => { + if (value === undefined || value === null || value === "") { + return null; + } + + if (typeof value === "number") { + return Number.isInteger(value) ? value : undefined; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + + if (!trimmed) { + return null; + } + + if (!/^\d+$/.test(trimmed)) { + return undefined; + } + + return Number.parseInt(trimmed, 10); + } + + return undefined; +}; + const isValidListenPort = (value: unknown): value is number => { return ( typeof value === "number" && @@ -27,11 +91,19 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => { } const item = value as Record; - const dest = item.dest; - const listenPort = item.listen_port; - const name = item.name; + const dest = getAliasField(item, ["dest", "dst", "target", "targets"]); + const listenPortRaw = getAliasField(item, [ + "listen_port", + "listenPort", + "port", + "in_port", + "inPort", + ]); + const name = getAliasField(item, ["name", "forward_name", "forwardName"]); + const normalizedDest = normalizeDestList(dest); + const normalizedListenPort = normalizeListenPort(listenPortRaw); - if (!Array.isArray(dest) || dest.length === 0) { + if (!normalizedDest || normalizedDest.length === 0) { return { line, error: "dest数组为空或格式错误" }; } @@ -39,16 +111,12 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => { return { line, error: "name不能为空" }; } - if (!isValidListenPort(listenPort)) { - return { line, error: "listen_port必须为1-65535之间的数字" }; + if (normalizedListenPort === undefined) { + return { line, error: "listen_port格式错误,应为1-65535之间的数字" }; } - const normalizedDest = dest.map((itemValue) => - typeof itemValue === "string" ? itemValue.trim() : "", - ); - - if (normalizedDest.some((itemValue) => itemValue === "")) { - return { line, error: "dest中包含空地址" }; + if (normalizedListenPort !== null && !isValidListenPort(normalizedListenPort)) { + return { line, error: "listen_port必须为1-65535之间的数字" }; } const invalid = normalizedDest.find( @@ -63,7 +131,7 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => { line, parsed: { dest: normalizedDest, - listen_port: listenPort, + listen_port: normalizedListenPort, name: name.trim(), }, };