Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions packages/core/src/generator/group-listeners.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { generateClashConfig } from "./index";
import { GroupListenerError } from "./group-listeners";
import type { ParsedNode } from "@subboost/core/types/node";
import type { GroupListenerBinding } from "@subboost/core/types/config";

function ssNode(patch: Partial<ParsedNode> = {}): ParsedNode {
return {
name: "Node",
type: "ss",
server: "ss.example.com",
port: 8388,
cipher: "aes-128-gcm",
password: "secret",
...patch,
} as ParsedNode;
}

function binding(patch: Partial<GroupListenerBinding> = {}): GroupListenerBinding {
return {
id: "gl-1",
target: { kind: "module", id: "auto" },
port: 7891,
...patch,
};
}

function findGroupListeners(config: ReturnType<typeof generateClashConfig>) {
return ((config.listeners ?? []) as Array<Record<string, unknown>>).filter((l) =>
String(l.name).startsWith("group-mixed-")
);
}

describe("group listeners", () => {
it("generates listeners for builtin, custom, and dialer groups by stable id", () => {
const config = generateClashConfig({
nodes: [ssNode()],
customProxyGroups: [{ id: "custom-1", name: "C Custom", emoji: "C", groupType: "select" }],
dialerProxyGroups: [
{
id: "dialer-1",
name: "Chain",
relayNodes: ["Node"],
type: "select",
targetNodes: [],
},
],
groupListeners: [
binding({ id: "gl-1", target: { kind: "module", id: "auto" }, port: 7891 }),
binding({ id: "gl-2", target: { kind: "custom", id: "custom-1" }, port: 7892 }),
binding({ id: "gl-3", target: { kind: "dialer", id: "dialer-1" }, port: 7893 }),
],
userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] },
});

expect(findGroupListeners(config)).toEqual([
{ name: "group-mixed-0", type: "mixed", listen: "127.0.0.1", port: 7891, proxy: "⚡ 自动选择", udp: true },
{ name: "group-mixed-1", type: "mixed", listen: "127.0.0.1", port: 7892, proxy: "C Custom", udp: true },
{ name: "group-mixed-2", type: "mixed", listen: "127.0.0.1", port: 7893, proxy: "Chain", udp: true },
]);
});

it("keeps listeners working after the target group is renamed", () => {
const config = generateClashConfig({
nodes: [ssNode()],
proxyGroupNameOverrides: { auto: "改名后的自动" },
groupListeners: [binding()],
userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] },
});

expect(findGroupListeners(config)).toEqual([
expect.objectContaining({ proxy: "⚡ 改名后的自动", port: 7891 }),
]);
});

it("listens on 0.0.0.0 only when allowLan is explicitly enabled", () => {
const config = generateClashConfig({
nodes: [ssNode()],
groupListeners: [binding({ allowLan: true })],
userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] },
});

expect(findGroupListeners(config)[0]).toMatchObject({ listen: "0.0.0.0" });
});

it("pauses generation while the binding or target group is disabled and resumes after re-enable", () => {
const base = {
nodes: [ssNode()],
customProxyGroups: [
{ id: "custom-1", name: "C Custom", emoji: "C", groupType: "select" as const, enabled: false },
],
groupListeners: [binding({ target: { kind: "custom" as const, id: "custom-1" }, port: 7892 })],
userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] },
};

// 目标组停用:暂停生成,但不报错(配置保留)
expect(findGroupListeners(generateClashConfig(base))).toEqual([]);

// 重新启用:恢复生成
const reEnabled = generateClashConfig({
...base,
customProxyGroups: [{ ...base.customProxyGroups[0], enabled: true }],
});
expect(findGroupListeners(reEnabled)).toEqual([expect.objectContaining({ proxy: "C Custom" })]);

// 绑定本身停用:同样暂停
const bindingDisabled = generateClashConfig({
...base,
customProxyGroups: [{ ...base.customProxyGroups[0], enabled: true }],
groupListeners: [binding({ target: { kind: "custom", id: "custom-1" }, port: 7892, enabled: false })],
});
expect(findGroupListeners(bindingDisabled)).toEqual([]);
});

it("throws a clear error when the target group has been deleted", () => {
expect(() =>
generateClashConfig({
nodes: [ssNode()],
groupListeners: [binding({ target: { kind: "custom", id: "gone" } })],
userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] },
})
).toThrow(GroupListenerError);
expect(() =>
generateClashConfig({
nodes: [ssNode()],
groupListeners: [binding({ target: { kind: "custom", id: "gone" } })],
userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] },
})
).toThrow(/已被删除/);
});

it("rejects conflicts with the effective mixed-port from base YAML overrides", () => {
expect(() =>
generateClashConfig({
nodes: [ssNode()],
groupListeners: [binding({ port: 9999 })],
userConfig: {
dnsYaml: "mixed-port: 9999\n",
enabledGroups: ["select", "auto", "global", "final"],
},
})
).toThrow(/mixed-port/);
});

it("rejects conflicts with node listener ports and base YAML listeners", () => {
// 节点监听端口冲突
expect(() =>
generateClashConfig({
nodes: [ssNode()],
groupListeners: [binding({ port: 12000 })],
userConfig: {
dnsYaml: "",
listenerPorts: { Node: 12000 },
enabledGroups: ["select", "auto", "global", "final"],
},
})
).toThrow(/节点监听端口/);

// 基础 YAML 已有 listeners 冲突
expect(() =>
generateClashConfig({
nodes: [ssNode()],
groupListeners: [binding({ port: 7000 })],
userConfig: {
dnsYaml: "listeners:\n - name: base-in\n type: mixed\n port: 7000\n",
enabledGroups: ["select", "auto", "global", "final"],
},
})
).toThrow(/listeners/);
});

it("rejects conflicts between two group listener bindings", () => {
expect(() =>
generateClashConfig({
nodes: [ssNode()],
customProxyGroups: [{ id: "custom-1", name: "C Custom", emoji: "C", groupType: "select" }],
groupListeners: [
binding({ id: "gl-1", target: { kind: "module", id: "auto" }, port: 7891 }),
binding({ id: "gl-2", target: { kind: "custom", id: "custom-1" }, port: 7891 }),
],
userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] },
})
).toThrow(/冲突/);
});

it("keeps one port per group, merges after node listeners, and avoids name clashes", () => {
const config = generateClashConfig({
nodes: [ssNode()],
groupListeners: [
binding({ id: "gl-1", port: 7891 }),
// 同一目标的第二条绑定被忽略(一组一端口)
binding({ id: "gl-2", port: 7899 }),
],
userConfig: {
dnsYaml: "listeners:\n - name: group-mixed-0\n type: socks\n port: 6000\n",
listenerPorts: { Node: 12000 },
enabledGroups: ["select", "auto", "global", "final"],
},
});

const listeners = (config.listeners ?? []) as Array<Record<string, unknown>>;
// 基础 YAML listeners 在前,节点监听随后,分组监听最后;名字避开 base 中的 group-mixed-0
expect(listeners.map((l) => l.name)).toEqual(["group-mixed-0", "mixed0", "group-mixed-1"]);
expect(listeners[2]).toMatchObject({ port: 7891, proxy: "⚡ 自动选择" });
});
});
119 changes: 119 additions & 0 deletions packages/core/src/generator/group-listeners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* 分组监听:把绑定到策略组的本地 mixed 监听端口展开成 mihomo listeners 条目。
*
* mihomo 依据(wiki.metacubex.one/config/inbound/listeners/):
* - listener 的 proxy 字段可直接指向策略组,流量绕过 rules 固定出站;
* - proxy 名称必须合法(真实存在的出站/策略组),否则 mihomo 报错;
* - listen 控制监听地址;mixed 类型支持 udp: true。
*/

import type { GroupListenerBinding } from "@subboost/core/types/config";

export class GroupListenerError extends Error {
constructor(message: string) {
super(message);
this.name = "GroupListenerError";
}
}

/** 目标策略组解析结果:exists = 配置里是否还有这个组;active = 该组当前是否参与生成 */
export interface GroupListenerTargetResolution {
exists: boolean;
active: boolean;
/** 组当前显示名(exists 时必有) */
name?: string;
}

export interface ResolveGroupListenersOptions {
bindings: GroupListenerBinding[];
/** 由稳定 ID 解析目标策略组(三类:module/custom/dialer) */
resolveTarget: (binding: GroupListenerBinding) => GroupListenerTargetResolution;
/** 最终生效的 mixed-port(含基础 YAML 覆盖值) */
effectiveMixedPort?: number;
/** 节点监听端口(listenerPorts 生成的 listeners) */
nodeListenerPorts: number[];
/** 基础 YAML 中已有的 listeners 占用的端口 */
baseListenerPorts: number[];
/** 已占用的 listener 名称(节点监听 + 基础 YAML listeners) */
usedNames: Set<string>;
}

function isValidPort(port: unknown): port is number {
return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535;
}

/**
* 展开分组监听绑定。无效目标或端口冲突会抛出 GroupListenerError(不静默跳过);
* 仅"目标组存在但被停用"或"绑定本身被停用"时暂停生成(保留配置,不报错)。
*/
export function resolveGroupListenerEntries(
options: ResolveGroupListenersOptions
): Array<Record<string, unknown>> {
const { bindings, resolveTarget, effectiveMixedPort, nodeListenerPorts, baseListenerPorts, usedNames } = options;
if (!Array.isArray(bindings) || bindings.length === 0) return [];

const usedPorts = new Map<number, string>();
if (isValidPort(effectiveMixedPort)) usedPorts.set(effectiveMixedPort, "全局 mixed-port");
for (const port of nodeListenerPorts) {
if (isValidPort(port) && !usedPorts.has(port)) usedPorts.set(port, "节点监听端口");
}
for (const port of baseListenerPorts) {
if (isValidPort(port) && !usedPorts.has(port)) usedPorts.set(port, "基础和 DNS 配置中的 listeners");
}

const seenTargets = new Set<string>();
const names = new Set(usedNames);
const out: Array<Record<string, unknown>> = [];
let autoIndex = 0;

for (const binding of bindings) {
if (!binding || typeof binding !== "object") continue;

const resolution = resolveTarget(binding);
if (!resolution.exists) {
throw new GroupListenerError(
"分组监听的目标策略组已被删除,请在对应策略组的高级设置中移除或修改该监听配置。"
);
}

// 组存在但停用,或绑定本身停用:暂停生成,保留配置
if (binding.enabled === false || !resolution.active) continue;

const targetName = (resolution.name ?? "").trim();
if (!targetName) {
throw new GroupListenerError("分组监听的目标策略组名称为空,无法生成 listener。");
}

// 一组一端口:同一目标重复绑定只保留首条
const targetKey = `${binding.target.kind}:${binding.target.id}`;
if (seenTargets.has(targetKey)) continue;
seenTargets.add(targetKey);

if (!isValidPort(binding.port)) {
throw new GroupListenerError(`策略组「${targetName}」的监听端口无效(需为 1-65535 的整数)。`);
}
const conflict = usedPorts.get(binding.port);
if (conflict) {
throw new GroupListenerError(
`策略组「${targetName}」的监听端口 ${binding.port} 与${conflict}冲突,请修改后再生成。`
);
}
usedPorts.set(binding.port, `策略组「${targetName}」的监听端口`);

let name = `group-mixed-${autoIndex++}`;
while (names.has(name)) name = `group-mixed-${autoIndex++}`;
names.add(name);

out.push({
name,
type: "mixed",
// 默认仅本机可用;「允许局域网访问」需用户显式开启
listen: binding.allowLan === true ? "0.0.0.0" : "127.0.0.1",
port: binding.port,
proxy: targetName,
udp: true,
});
}

return out;
}
Loading