Skip to content

feat(providers): A subscription's tunnelled nodes all register as one proxy, so its pool has a single egress #13460

Closed

Description

@maxmad64bis

Problem / Use Case

Paste a subscription link holding 300 nodes, bind it to a pool, pick a rotation strategy — and every request still leaves by the same address. Nothing fails and nothing warns: the pool page reports 300 nodes while every request leaves by the same address, so the rotation strategy on screen has nothing to rotate over.

It lands hardest on the users this feature exists for. docs/ops/PROXY_GUIDE.md opens on developers in Russia, China, Iran, Cuba and Turkey — and a network that blocks plain http/socks5 is exactly where people run vless, vmess or shadowsocks instead, which are the nodes that collapse into a single entry. The operators with the fewest alternatives get the degenerate case, silently.

Per #9611, serving one account per route needs one distinct route per account. A subscription can't supply that today, however many nodes it carries.

The mechanism, for the record: tunnelled nodes are registered as one row for the whole set, pointing at localCoreEndpoint (subscriptionService.ts:433-450), while http/socks5 nodes each get their own (:417-431). So the cursor rotates over one member, under a UI that offers "a rotation strategy that cycles egress IPs" (ProxyRegistryManager.tsx:67-68).

Proposed Solution

What an operator would get: one pasted link becomes a pool with as many real exits as it has working nodes — no commercial rotating proxy, no hand-written core config, and the pool page finally means what it says.

Underneath the node count, this is about who decides the route. Everything tunnelled goes into one local port today, and the core alone picks which node the request leaves by — so the pool, the rotation strategies and the per-IP cooldown all reason about a choice the app never made. Plural entries hand that decision back to OmniRoute, which is what makes every other proxy feature mean something on a subscription.

Mechanically, a subscription declares several loopback entries instead of one, and each pool member binds to its own. The existing gate (coreEndpoint.ts:9-32) applies unchanged per entry, and a single entry behaves exactly as today, so nobody has to migrate anything.

This adds no capacity anywhere — it stops the app from presenting capacity that isn't there.

I'm after a direction before writing code, not a spec, and I'm happy to shape it however you prefer.

Alternatives Considered

Hand-building a multi-entry core config and adding each local port as a manual proxy. That's the current workaround; it drifts on every subscription refresh and duplicates a node list the app already holds.

Acceptance Criteria

  • One entry: identical behavior to today, no config change required.
  • N entries: N pool members, each bound to its own entry, and rotation visits them.
  • Every entry passes the existing loopback gate, and a busy port fails loudly instead of collapsing back to a single endpoint.

Area

Proxy / Routing

Additional Context

Two open questions: who owns the port range — the operator declares it, or the app picks free ports? And would you want a proxy core supervised in-tree at all, or should the app stay out of the binary's life?

Activity

  1. changed the title [-][Feature] A subscription's tunnelled nodes all register as one proxy, so its pool has a single egress[/-] [+]feat(providers): A subscription's tunnelled nodes all register as one proxy, so its pool has a single egress[/+] on Sep 14, 2026
  2. diegosouzapw commented on Sep 15, 2026

    @diegosouzapw
    Collaborator

    Thanks for the detailed write-up — the subscriptionService.ts:433-450 vs :417-431 split is exactly right, and it's a real gap: tunnelled nodes collapse to one proxy_registry row no matter how many the subscription carries.

    Before we scope this, we want to settle the fork you already flagged, because it changes the shape of the fix a lot:

    1. Light path — localCoreEndpoint becomes a list of loopback entries (still validated by the existing coreEndpoint.ts gate), each bound to its own proxy_registry row, same as http/socks5 nodes today. This matches your acceptance criteria literally, but it still requires you (or whoever administers the core) to hand-configure a multi-inbound core config with one port per node — it formalizes the current workaround rather than removing it.
    2. Heavy path — OmniRoute actually generates the per-node core config and supervises a proxy-core process itself, so a pasted link becomes N real exits with zero manual core setup. We checked: there's no existing sing-box/xray/mihomo precedent in the codebase today, so this would be a brand-new subsystem (binary packaging across platforms, per-protocol — vless/vmess/ss/trojan — config templating, port allocation, its own supervisor layer). That's genuinely the "no commercial rotating proxy, no hand-written core config" outcome you described in Proposed Solution, but it's a much bigger build.

    We're marking this defer rather than picking one silently, because the two are different multi-week efforts and we'd rather have your input given you've already thought about the trade-off (your two open questions in Additional Context are exactly this fork). Which one matches what you had in mind — and if it's option 2, do you have a preference for which core binary (xray/sing-box/other) we'd standardize on?

    We also want to flag: PR #13581 (which you may have seen referencing this issue) only adds an observability line showing how many distinct exits a pool actually used — it's a good diagnostic to confirm this fix worked once shipped, but it doesn't touch the registration path, so it doesn't resolve this on its own.

    This is filed and staying open — once we land on a direction we'll turn it into a concrete plan.

  3. maxmad64bis commented on Sep 15, 2026

    @maxmad64bis
    ContributorAuthor

    Thanks for splitting it cleanly. I'd start with the light path without picking a core binary yet.

    One fix: no port per node. An entry is one exit you'd use with the others, and your core groups nodes behind it. A few entries can front thousands of nodes. To be clear: the light path keeps hand-written core config; what it changes is the pool finally sees the exits.

    Light first because the heavy path needs registry entries. Since #13578, a failing entry is skipped alone.

    In the PR: localCoreEndpoint takes loopback URLs checked by the existing gate. Full URLs work better than a port range here, since an entry isn't limited to a bare host and port. Each entry gets its own registry row and pool member, keyed as today so history stays linked, and you own the ports. One entry acts as today, and an unreachable one errors loudly with no quiet fallback.

    Heavy path later, I'd split it: config from the subscription with no process handling, then install and supervision as a separate optional step. It'd pin the version with a checksum and keep errors visible (#12962), without the retry loop (#9937).

    For the binary, what counts first is subscription formats and clean hot reload. Release builds and license come next. I can open the light-path PR against the current release branch if that works.

  4. diegosouzapw commented on Sep 16, 2026

    @diegosouzapw
    Collaborator

    Thanks for narrowing this down — light path it is, and the design in your last comment is concrete enough to build.

    To confirm what we're aligning on: localCoreEndpoint becomes a list of loopback URLs (one per line), each validated by the existing coreEndpoint.ts gate, each synced into its own proxy_registry row via the same (host, port, username) keying upsertProxy already uses for http/socks5 nodes today. A single entry stays byte-identical to current behavior — no migration needed since the field stays a string. An unreachable/busy entry surfaces as an error on that pool member instead of silently collapsing back to one endpoint.

    Two small things to settle before you open the PR:

    1. Delimiter for multiple entries in the field — newline or comma? Newline reads cleaner once the input becomes a textarea, but it's your call.
    2. For "a busy port fails loudly" — should that be a new subscription-level warning code (distinct from the existing LOCAL_CORE_ENDPOINT_INVALID), or is it enough that the affected pool member just shows up error/dead through the existing per-proxy health check, with no subscription-level signal? Either is fine on our end, just want the PR's error-handling shape agreed up front.

    Go ahead and open it against the current release branch — flagging it here as accepted so nothing else collides with the area in the meantime.

  5. maxmad64bis commented on Sep 16, 2026

    @maxmad64bis
    ContributorAuthor

    Opened as #13923 — newline delimiter, and existing code + detail (the health-check branch writes nothing by default, so it could not carry the signal).

  6. maxmad64bis commented on Oct 7, 2026

    @maxmad64bis
    ContributorAuthor

    Closing this one: the light path (plural local_core_endpoint) shipped in #13923, and the generation work that followed is tracked in #14896 and now on release/v3.8.52 through #15564.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions