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
This commit is contained in:
sagitchu
2026-03-03 16:54:05 +08:00
parent 7ba68778c1
commit 7c54192055
4 changed files with 134 additions and 17 deletions
@@ -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
+3 -3
View File
@@ -4620,10 +4620,10 @@ export default function ForwardPage() {
) : (
<>
<p className="text-small text-default-500">
ny格式:JSON对象,支持多个目标地址(负载均衡)
ny格式:JSON对象,支持多个目标地址(负载均衡),按所选隧道导入
</p>
<p className="text-small text-default-400">
格式:&#123;&quot;dest&quot;:[&quot;地址:端口&quot;],&quot;listen_port&quot;:端口,&quot;name&quot;:&quot;名称&quot;&#125;
格式:&#123;&quot;dest&quot;:[&quot;地址:端口&quot;],&quot;listen_port&quot;:端口,&quot;name&quot;:&quot;名称&quot;&#125;(listen_port可省略,自动分配端口)
</p>
</>
)}
@@ -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"
@@ -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",
});
});
@@ -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<string, unknown>,
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<string, unknown>;
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(),
},
};