Repository navigation
feat(providers): A subscription's tunnelled nodes all register as one proxy, so its pool has a single egress #13460
Description
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
[-][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[/+]diegosouzapw commentedon Sep 15, 2026
Thanks for the detailed write-up — the
subscriptionService.ts:433-450vs:417-431split is exactly right, and it's a real gap: tunnelled nodes collapse to oneproxy_registryrow 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:
localCoreEndpointbecomes a list of loopback entries (still validated by the existingcoreEndpoint.tsgate), each bound to its ownproxy_registryrow, 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.We're marking this
deferrather 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.
maxmad64bis commentedon Sep 15, 2026
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.
diegosouzapw commentedon Sep 16, 2026
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:
localCoreEndpointbecomes a list of loopback URLs (one per line), each validated by the existingcoreEndpoint.tsgate, each synced into its ownproxy_registryrow via the same(host, port, username)keyingupsertProxyalready 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:
LOCAL_CORE_ENDPOINT_INVALID), or is it enough that the affected pool member just shows uperror/deadthrough 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.
maxmad64bis commentedon Sep 16, 2026
Opened as #13923 — newline delimiter, and existing code + detail (the health-check branch writes nothing by default, so it could not carry the signal).
maxmad64bis commentedon Oct 7, 2026
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 onrelease/v3.8.52through #15564.