Skip to content

feat: group listener ports (binds inbound ports to proxy groups) - #57

Merged
Ryson-32 merged 3 commits into
SubBoost:ryan/devfrom
sodiseng:feat/group-listeners
Jul 28, 2026
Merged

feat: group listener ports (binds inbound ports to proxy groups)#57
Ryson-32 merged 3 commits into
SubBoost:ryan/devfrom
sodiseng:feat/group-listeners

Conversation

@sodiseng

@sodiseng sodiseng commented Jul 13, 2026

Copy link
Copy Markdown

Reworked per maintainer feedback: now a standalone change based on latest ryan/dev, with no dependency on the proxy-providers extension (#56, closed).

Scope

Bind one local mixed inbound port to any proxy group — builtin, custom, or dialer — generating mihomo listeners entries (proxy: <group>), so traffic hitting that port always exits through that group.

Behavior

  • Stable targets: bindings store {kind: module|custom|dialer, id} instead of the display name — renaming a group keeps its listener working.
  • Safe defaults: listen: 127.0.0.1. 0.0.0.0 only via an explicit "allow LAN access" toggle with an amber security hint.
  • Disable = pause, not delete: disabling a group (or the binding) pauses listener generation but keeps the config; re-enabling restores it.
  • Conflict checking covers the effective mixed-port (including base-YAML overrides), node listener ports, other group listeners, and listeners already present in the base YAML. Invalid targets and port conflicts fail generation/saving with a clear message — never silently skipped.
  • Only real proxy groups are valid targets (nodes/DIRECT/REJECT can't be bound).
  • One port per group.

UI

  • No separate management section. The group-type button in each row is upgraded to an advanced settings button (same icon, size, position) opening a unified dialog: group type, load-balance strategy (LB only), listener toggle, port, allow-LAN.
  • Save/cancel semantics — cancel discards all draft edits; conflicts show inline and disable save.
  • A small status dot marks non-default settings; no extra text or badges in the row.
  • Inline filter/member/rule editing is unchanged.

Tests

Regression coverage for: listener generation for all three group kinds; rename stability; disable/pause + re-enable/resume; conflicts with base-YAML mixed-port/listeners, node listeners, and other bindings; clear error on deleted targets; save/re-edit/subscription restore round-trips; dialog width capped at calc(100vw-2rem) for narrow screens.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0862e5d5a4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +50 to +52
...(opts.customProxyGroups ?? [])
.map((group) => (typeof group.name === "string" ? group.name.trim() : ""))
.filter(Boolean),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter disabled groups before reserving provider names

When a disabled custom proxy group has the same name as a grouped provider's requested group name, this helper still reserves that disabled name, but generateClashConfig builds the reserved custom set from only enabled groups. Panels that use buildProviderGroupInfo can therefore show/select a suffixed provider group such as ✈️ Air 2, while the generated YAML actually creates ✈️ Air; selecting the displayed name for features like group listeners then points at a group that does not exist in the output.

Useful? React with 👍 / 👎.

Comment thread packages/core/src/generator/index.ts Outdated
if (list.length === 0) return [] as Array<Record<string, unknown>>;

const validTargets = new Set(availablePolicyTargets);
const usedPorts = new Set<number>(Array.isArray(listeners) ? listeners.map((l) => l.port) : []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include base listeners when rejecting duplicate ports

When the base YAML already declares a listener on the same port as a new group listener, usedPorts only contains generated node-listener ports and mixed-port. The base listeners are merged back in later, so the final YAML can contain two listener entries with the same port, causing the client to fail binding that inbound; seed this set from baseTopLevelPatch.listeners as well before accepting group listener ports.

Useful? React with 👍 / 👎.

Comment thread packages/core/src/generator/index.ts Outdated
Comment on lines +364 to +366
const groupedAttachments = groupedProviderKeys
.map((key) => attachmentByKey.get(key))
.filter((attachment): attachment is ProxyProviderAttachment => Boolean(attachment));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve attachment order for grouped providers

Grouped provider names are assigned after groupedProviderKeys has been derived from the alphabetically sorted provider keys, while the UI/provider plan computes those same names in source attachment order. If two grouped providers request the same group name, or collide with a reserved name, the suffixes can be assigned to different provider keys in the UI versus the generated YAML, so advanced member selections or group listeners saved against one provider key end up pointing at a different airport group.

Useful? React with 👍 / 👎.

Comment thread packages/core/src/generator/index.ts Outdated
Comment on lines +531 to +532
if (typeof config.mixedPort === "number" && Number.isInteger(config.mixedPort)) {
usedPorts.add(config.mixedPort);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve mixed-port from the merged base YAML

When mixed-port is set only in explicit base/DNS YAML, config.mixedPort still holds the default/user-config value, so this check reserves the wrong port. A group listener using the YAML-defined mixed-port is accepted and then merged into the final config alongside that same top-level port, which makes the client fail to bind one of the inbounds.

Useful? React with 👍 / 👎.

const customKey = typeof item.providerKey === "string" ? item.providerKey.trim() : "";
const key = customKey || defaultKey;
// key 重复时跳过后者(与旧行为一致);UI 层负责重复提示
if (Object.prototype.hasOwnProperty.call(providers, key)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent duplicate provider keys from dropping sources

When two URL sources are given the same custom providerKey (or one custom key collides with another source's default key), this branch silently skips the later provider. The new UI exposes free-form key editing but does not block or warn about duplicates, so a user can enable proxy-providers for multiple subscriptions and have one omitted from the generated YAML entirely.

Useful? React with 👍 / 👎.

@Ryson-32

Copy link
Copy Markdown
Member

感谢你实现策略组监听端口,这个功能方向我们希望保留,也愿意继续 review。

不过当前 #57 stacked on #56,而我们目前的产品决定是不引入 #56 的 proxy-providers 扩展。因此按现状无法直接合并。能否请你把 #57 调整为一个基于最新 ryan/dev、不依赖 #56 的独立改动,并按下面的范围优化?

功能范围

  • 只保留“策略组监听端口”,不包含 provider 接入模式、provider 机场组或 provider 组监听。
  • 内置分流组、自定义分流组和中转组都需要支持。
  • 每个策略组最多配置一个 mixed 监听端口。

UI 调整

目前单独的“分组监听管理”区域会继续增加页面密度,希望改为:

  • 将代理组行头现有的类型按钮升级为“高级设置”按钮,保持原来的图标尺寸和位置。
  • 点击后打开统一设置弹窗,在弹窗内配置:
    • 代理组类型;
    • 负载均衡策略(仅负载均衡类型显示);
    • 是否启用监听端口;
    • 监听端口。
  • 主行不要显示 自动测速 · :7891:7891 等额外文字,也不要保留单独的端口 badge。
  • 存在非默认类型或监听配置时,设置按钮可以使用轻量高亮或状态点提示,但不要继续挤占行头文字空间。
  • 设置弹窗使用“保存 / 取消”模式;取消时不能留下部分修改。
  • 原有行内展开的筛选条件、成员管理和分流规则保持不变,不移动到弹窗。

状态与生成逻辑

  • 监听目标不要保存策略组显示名称,应保存稳定 ID,至少区分内置组、自定义组和中转组。策略组改名后监听必须继续有效。
  • 代理组关闭时保留监听配置,但暂停生成对应 listener;重新启用后恢复生成。
  • 目标只允许真实策略组,不能把节点、DIRECTREJECT 当成策略组。
  • 默认使用 127.0.0.1,不要默认暴露到 0.0.0.0。如果提供“允许局域网访问”,应由用户明确开启并显示安全提示。
  • 端口冲突检查需要覆盖:
    • 最终生效的 mixed-port,包括基础 YAML 中的覆盖值;
    • 节点监听端口;
    • 其他策略组监听端口;
    • 基础 YAML 中已有的 listeners
  • 无效目标或端口冲突不能静默跳过,应在 UI 中明确提示并阻止保存或生成。

测试

希望补充以下回归场景:

  • 内置组、自定义组和中转组分别生成 listener;
  • 策略组改名后监听仍有效;
  • 关闭后暂停生成、重新启用后恢复;
  • 与基础 YAML mixed-port / listeners 冲突;
  • 删除监听目标后的明确错误;
  • 保存、重新编辑和订阅配置恢复;
  • 窄屏下弹窗和代理组主行不重叠、不产生横向滚动。

如果你愿意按这个范围调整,我们会优先继续 review #57。功能方向本身很有价值,感谢你的贡献。

@sodiseng
sodiseng force-pushed the feat/group-listeners branch from 0862e5d to c3bd42b Compare July 26, 2026 16:25
@sodiseng
sodiseng changed the base branch from main to ryan/dev July 26, 2026 16:25
@sodiseng

Copy link
Copy Markdown
Author

Reworked as requested: rebased onto latest ryan/dev as a standalone change (no #56 dependency), stable-ID targets, 127.0.0.1 default with explicit allow-LAN opt-in, unified advanced-settings dialog replacing the separate management section, full conflict checking, and the requested regression scenarios. Ready for another look — thanks!

Copy link
Copy Markdown
Member

Thanks for reworking this — the new version is much closer to the requested scope. I confirmed that it is now standalone from #56, uses stable group IDs, defaults listeners to 127.0.0.1, and includes the requested conflict and persistence coverage. I also ran the repository lint and full unit suite locally; lint passed and all 1,155 unit tests passed.

There are still a few blockers before we can merge:

  1. Please rebase onto the latest ryan/dev. The current head (c3bd42b) is 15 target-branch commits behind and conflicts in:

    • packages/core/src/subscription/config-utils.ts
    • packages/ui/src/product/home/use-subscription-link.test.ts

    When resolving config-utils.ts, please retain both the latest nodeNameFilter / effectiveNodes path and this PR's groupListeners normalization.

  2. Deleting a custom or dialer group can leave an unrecoverable listener binding. The delete actions remove the group but leave its groupListeners entry. Generation then correctly reports a missing target, but the group row is already gone, so the user has no UI path to remove the binding. Please either block deletion with a clear prompt until the listener is removed, or remove the listener as part of an explicitly confirmed deletion, and add regression tests for both custom and dialer groups.

  3. A conflicting listener cannot currently be paused while retaining its configuration. In the advanced-settings dialog, a non-empty port is still conflict-validated after the listener switch is turned off, so Save remains disabled. This prevents the intended “disable = pause, not delete” flow. Disabled bindings should be saveable without requiring the inactive port to be conflict-free, matching the generator behavior, with a regression test.

The current PR head also has no GitHub check runs yet. After rebasing and addressing the items above, please trigger the checks and ping us for another review. Thanks again — the feature direction itself looks good.

Bind a local mixed inbound port to any proxy group (builtin, custom, or
dialer), generating mihomo listeners entries that route traffic through
that group directly.

- core: GroupListenerBinding stores a stable target id ({kind, id})
  instead of the display name, so renaming a group keeps its listener
  working; resolveGroupListenerEntries validates targets and ports
  against the effective mixed-port (including base-YAML overrides),
  node listener ports, other group listeners, and base-YAML listeners,
  failing generation with a clear error instead of silently skipping
- listeners default to 127.0.0.1; 0.0.0.0 only with an explicit
  allow-LAN opt-in; disabled groups pause generation but keep the
  binding so re-enabling restores it
- UI: the group-type button in each group row becomes an advanced
  settings button opening a save/cancel dialog (group type, load-balance
  strategy, listener toggle, port, allow-LAN with security hint); a
  status dot marks non-default settings; no extra text in the row
- store action + subscription save/load + auth handoff serialization
- regression tests for all three group kinds, rename stability,
  disable/re-enable, conflict sources, deleted targets, and persistence
  round-trips
@sodiseng
sodiseng force-pushed the feat/group-listeners branch from c3bd42b to af6a4e1 Compare July 28, 2026 02:23
@sodiseng

Copy link
Copy Markdown
Author

Thanks for the detailed review! All three items are addressed in the updated head (af6a4e1):

  1. Rebased onto the latest ryan/dev (843fb83). In config-utils.ts both the new nodeNameFilter/effectiveNodes path and this PR's groupListeners normalization are retained, as requested.
  2. Group deletion now cascades the listener binding. Deleting a custom or dialer group with a bound listener shows an explicit confirm dialog (warning variant with an amber notice that the listener port will be removed along with the group); on confirmation the store removes the binding together with the group, so no orphaned entry can remain. Groups without a binding keep the previous direct-delete behavior. Regression tests added for both custom and dialer groups at the store level and for the confirm flow at the UI level.
  3. Disabled bindings are now saveable regardless of conflicts. When the listener switch is off, a non-empty port only gets format validation (1-65535); conflict validation is skipped, matching the generator, which neither validates nor reserves ports of disabled bindings. Accordingly, disabled bindings of other groups no longer count as conflict sources either. Regression tests cover saving a paused listener on a conflicting port, rejecting malformed ports while paused, and the conflict-source exemption.

The upstream CI run on this head is pending workflow approval — could you approve it? Meanwhile the same commit passed the full CI (lint + unit tests + check:local-app) on my fork: https://github.com/sodiseng/subboost/actions/runs/30323275358

Ready for another review, thanks!

@Ryson-32
Ryson-32 merged commit 6d1af00 into SubBoost:ryan/dev Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants