Files
OmniRoute/docs/proxy-subscriptions.md
Diego Rodrigues de Sa e Souza 287802cf86 fix: repair pre-existing red gates on the release/v3.8.49 tip (#8055)
* fix(dashboard): resolve Kimi banner casing collision + shrink frozen test file (release tip)

- Rename src/app/(dashboard)/dashboard/kimiSponsorBanner.ts to
  kimiSponsorBannerGate.ts so it no longer differs from
  KimiSponsorBanner.tsx only by the first letter's case (breaks next
  build on case-insensitive filesystems). Updates the sole importer
  (KimiSponsorBanner.tsx) and the two tests that reference it.
- Extract the 8 Kimi/Moonshot featured-ordering tests out of the
  frozen tests/unit/providers-page-utils.test.ts (grown 3 lines past
  its 1294 cap by #8039's rebrand-comment update) into a new sibling
  file tests/unit/providers-page-utils-kimi.test.ts. No assertions
  dropped; both files pass in full (24 + 8 = 32 tests).

* fix(sse): register PromptQlExecutor in the executor registry (release tip)

getExecutor("promptql") had no entry in open-sse/executors/index.ts, so it
silently fell through to DefaultExecutor's provider fallback, which issues a
raw fetch() and returns the bare upstream Response instead of the executor
wrapper shape {response, url, headers, transformedBody}. The real
PromptQlExecutor class (open-sse/executors/promptql.ts) already honors the
contract correctly — it was just never wired into the registry.

Fixes tests/unit/executor-web-cookie-sweep.test.ts "promptql executor
returns wrapper shape".

* fix(i18n): backfill 2220 missing pt-BR keys to restore en.json parity (release tip)

pt-BR.json fell behind after #7935 restored +2220 keys into en.json and
vi.json but left pt-BR.json unmodified. Translated all missing entries to
Brazilian Portuguese, preserving ICU/interpolation placeholders and existing
terminology, and merged them mirroring en.json's key order so the diff is
additions-only (the small comma-only deletions are pure JSON reformatting
from new sibling keys).

* fix(providers): repair 4 pre-existing catalog/registry reds on release tip

- providers-constants-split.test.ts: APIKEY_PROVIDERS grew 182->187 (PR #7887
  added 5 free-tier providers: ainative/aion/sealion/routeway/nara). Verified
  no dup/loss (6-family partition sums exactly to 187) and updated the stale
  expected count + comment trail to match.
- cline registry: added the missing minimax/minimax-m3 free OpenRouter entry
  (#3321) and fixed the neighbouring nemotron-3-ultra-550b-a55b entry, which
  carried a stray ":free" id suffix and an imprecise 1_000_000 contextLength
  instead of the 1_048_576 the test (and every sibling 1M-context entry in
  this catalog) expects.
- promptqlModels.ts / registry/promptql/index.ts: PROMPTQL_FALLBACK_MODELS's
  minimax-m3 entry was missing supportsVision, and the registry mapping
  dropped it entirely (only id/name were passed through) — it was the sole
  minimax-m3 entry across the whole registry not flagged multimodal, despite
  every other provider (minimax, minimax-cn, ollama-cloud, trae, bazaarlink,
  clinepass, codebuddy-cn, opencode-zen/go, synthetic, huggingchat, lmarena)
  agreeing MiniMax-M3 supports vision. Added the field to the PromptQlModel
  type and threaded it through.
- tests/snapshots/provider/translate-path.json: regenerated the golden via
  UPDATE_GOLDEN=1. Diffed old vs new — zero providers removed, 5 added
  (ainative/aion/nara/routeway/sealion, matching #7887), and the only
  changed entry (cline) reflects the already-merged #7914 ClinePass header
  protocol change (Cline/<version> User-Agent + X-Task-ID) that a prior
  narrow golden touch-up missed capturing.

* fix(docs): repair docs-sync/env-sync/repo-contract gates (release tip)

Six pre-existing reds on release/v3.8.49, all "repo drifted from its own
documented contract":

- check-docs-counts-sync: free-tier headline was stale (~1.4B/~2.0B) vs the
  live catalog (~1.53B steady / ~2.15B first month, 43 pools). Updated
  README.md and docs/reference/FREE_TIERS.md to the live numbers and added a
  v3.8.49 correction note explaining the pool-count delta (39->43, #7840).
  Also fixed a soft executors-count drift in ARCHITECTURE.md (84->86,
  268->271 providers) while touching that line.
- release-green-docs-drift-7253: docs/proxy-subscriptions.md referenced a
  fabricated migration filename (123_proxy_subscriptions.sql); the real file
  is 131_proxy_subscriptions.sql. Fixed all 3 occurrences.
- check-env-doc-sync + issue-7793-env-doc-sync-repro: OMNIROUTE_DATA_DIR
  (DATA_DIR fallback alias read by
  open-sse/executors/promptql/threadSticky.ts) was undocumented. Added to
  .env.example and docs/reference/ENVIRONMENT.md.
- check-db-rules: src/lib/db/proxySubscriptions.ts (#7299) is a db-internal
  split of proxies.ts (kept under the frozen file-size cap) whose one export
  is already re-exported via proxies.ts -> localDb.ts. Added it to
  INTENTIONALLY_INTERNAL with the same db-internal justification used for
  identical split modules (apiKeyColumnFallbacks, providerNodeSelect,
  webSessionDedup) rather than a redundant direct re-export from localDb.ts.
- mcp-server-hollow-dist-deps: the sanity test expected better-sqlite3 among
  the MCP bundle's static top-level external imports. That's been stale
  since the pre-#7878 migration to a cascading SqliteAdapter driver factory
  (createRequire()-based lazy require, not a static import); better-sqlite3
  already has its own native-asset copy guarantee in assembleStandalone.mjs,
  unrelated to this test's EXTRA_MODULE_ENTRIES concern. Updated the
  assertion to a still-genuinely-static external (zod) with a comment
  explaining the change.

No production runtime behavior changed — docs, .env.example, and a checker
allowlist/test-expectation only.

* fix(dashboard): repair stale UI component-shape test assertions (release tip)

Two pre-existing reds in the dashboard UI component-contract cluster were
caused by test assertions that had gone stale after intentional, correct
refactors — not by real defects in the components:

- quota-pool-wizard-multi.test.ts: the step-3 preview assertion required
  the literal single-line substring "connectionIds.map((cid)". Prettier
  (100-char width, project config) legitimately breaks the
  connectionIds.map(...).filter(...) chain across lines because of the
  multi-line callback body, so the literal never matches. PoolWizard.tsx
  still builds previewByProvider correctly by mapping over connectionIds;
  updated the assertion to a regex that tolerates the line break.

- v388-phase1-screen-fixes.test.ts: the shared Select placeholder-guard
  assertion required the literal "!children && placeholder". An earlier,
  intentional i18n commit changed the hardcoded "Select an option" default
  to a translated fallback (`placeholder ?? t("selectOption")`), which
  requires parens around the ?? expression for operator precedence. The
  guard behavior is unchanged (still gated on !children); updated the
  assertion to match the current, correct guard shape.

Both fixes are read-only test-file changes; no production behavior changed.

review-reviews-v3814-fixes.test.ts still has one pre-existing, unrelated
red (LEDGER-4: minimax-m3 registry entries missing supportsVision) that
requires editing the promptql provider registry/catalog — out of this
cluster's scope, left untouched and reported separately.

* fix(providers): reconcile cline catalog contradictions + deterministic golden (release tip)

The first tip-green pass introduced 3 regressions caught by CI on sibling guard tests:

- clinepass-provider + cline-catalog-models-3321 encoded OPPOSITE expectations of
  the same cline model list (minimax presence, nvidia :free suffix). Reference
  upstream (OpenRouter free lineup) confirms nvidia/nemotron-3-ultra-550b-a55b:free
  (with :free, 1M ctx) is correct, so restore that id and fix #3321's stale no-:free
  assertion; add minimax/minimax-m3 (the real #3321 gap) to clinepass-provider's list.
- check-db-rules-classification froze INTENTIONALLY_INTERNAL at 35; proxySubscriptions
  was the intentional 36th entry — add it + bump the count.
- provider-translate-path golden stored a LITERAL Cline/3.8.49: clineAuth resolves the
  version from APP_CONFIG.version (stable), but the golden sanitizer collapsed only
  process.env.npm_package_version (unset under `node`, set under `npm run`) — so the
  golden was shard-dependent. Resolve APP_VERSION from APP_CONFIG.version like clineAuth
  and regenerate; now Cline/<APP> normalizes identically in every shard.

* fix(services): type execFile signal/killed in classifyError + ratchet dashboard baseline (release tip)

Pre-existing base-red on the tip's Fast Quality Gates (dashboard-typecheck), missed
in the first inventory:

- src/lib/services/installers/utils.ts TS2339 — `err.signal` was read off a value typed
  as NodeJS.ErrnoException, which @types/node does not declare `signal`/`killed` on
  (those belong to execFile's ExecFileException). Widen classifyError's param to type
  both, and drop the now-redundant `(err as … { killed })` cast.
- Ratchet config/quality/dashboard-typecheck-baseline.json down: 5 baselined errors were
  fixed by already-merged PRs but never ratcheted (OAuthModal TS2769 4→3 / TS2345 4→3,
  CliproxyModelMappingEditor TS2339, CompressionPreviewAccordion TS4104, MonacoEditor
  TS2307). Baseline now 254, matching live — gate exits 0.
2026-07-21 21:25:00 -03:00

18 KiB

Operator Proxy Subscriptions (Karing-style)

Feature design + implementation notes for OmniRoute's operator-level proxy subscription flow. This is the v1 cut: a single operator pastes subscription links, picks a mode (global or rule), and OmniRoute binds the resulting proxy pool into the existing scope resolution. Multi-tenant per-API-key, advanced traffic rules, latency-driven per-rule weights, and so on are explicitly out-of-scope and listed in §7.


1. Motivation

Today, OmniRoute's proxy pool is hand-curated: every node lives in proxy_registry with hand-written host/port/credentials, and every binding to the upstream dispatchers (account → provider → combo → global → direct) is a manual proxy_assignments row. Operators who already maintain a Clash/V2Ray/ sing-box subscription (e.g. from an airport service) have to retype every node into OmniRoute and re-bind them whenever the upstream list changes.

The goal of v1 is to make OmniRoute first-class for operator-supplied subscriptions, similar to how Karing / Clash / sing-box let users paste a https://... URL and have the client manage the lifecycle.

2. User stories

# As a(n) I want to So that
U1 Operator paste a subscription URL once I don't retype nodes every time the airport refreshes
U2 Operator toggle the subscription on/off I can fall back to direct without deleting the URL
U3 Operator pick global mode every provider's traffic exits via the subscription
U4 Operator pick rule mode and select specific providers only selected providers route through the proxy; others stay direct
U5 Operator supply a local sing-box/clash SOCKS5 endpoint SS/VMess/Trojan/VLESS nodes (which OmniRoute's dispatcher can't speak natively) become usable through a local kernel bridge
U6 Operator see fetch status and a recent redacted node summary I can debug "why is this empty / erroring" without leaking credentials

3. Non-goals (v1)

  • Per-API-key subscription overrides (multi-tenant). v1 is operator-only.
  • Per-provider traffic rules beyond global / rule-on-selected-providers.
  • Latency-based smart routing between subscription nodes and other pools (existing resolveProxyForConnectionFromRegistry already does this for the global pool; v1 just feeds subscription nodes into it).
  • Auto-importing URL/password from headers or query params.
  • SSRF mitigation beyond loopback-only local-core endpoints (the subscription URL itself is operator-controlled, so we trust it the same way we trust upstream provider URLs today).

4. Architecture

            ┌─────────────────────────────────────────┐
            │  dashboard / settings / 代理 / 订阅代理   │
            │  (client component, SubscriptionTab)    │
            └──────────────────┬──────────────────────┘
                               │ fetch
                               ▼
   ┌────────────────────────────────────────────────────────┐
   │  /api/v1/management/proxy-subscriptions                │
   │  ├ GET    list                                        │
   │  ├ POST   create                                      │
   │  ├ GET    /:id                                        │
   │  ├ PATCH  /:id                                        │
   │  ├ DELETE /:id                                        │
   │  ├ POST   /:id/refresh                                │
   │  └ GET    /:id/nodes                                  │
   └────────────────────────┬───────────────────────────────┘
                            │ uses
                            ▼
   ┌────────────────────────────────────────────────────────┐
   │  src/lib/proxySubscription/                           │
   │  ├ parse.ts          (Clash YAML / V2Ray JSON / URIs) │
   │  ├ subscriptionService.ts                              │
   │  │   CRUD, sync, apply, unapply, scheduler            │
   │  └ index.ts          (barrel)                          │
   └──────────┬─────────────────────────────┬───────────────┘
              │ upsert/scope-bind            │ DB
              ▼                              ▼
   ┌─────────────────────────┐    ┌──────────────────────────┐
   │  proxy_registry          │    │  proxy_subscriptions     │
   │  (existing) +             │    │  (NEW — subscription     │
   │  subscription_id column  │    │   metadata + scheduler   │
   │  + status/health checks  │    │   state)                 │
   └─────────────────────────┘    └──────────────────────────┘
              │
              ▼ (existing)
   resolveProxyForConnectionFromRegistry
   hasBlockingProxyAssignment (fail-closed)
   proxyDispatcher (open-sse/utils/proxyDispatcher)

Key design decision: we do not invent a new scope or routing pipeline. We upsert subscription-derived nodes into proxy_registry with source = 'subscription' + subscription_id, and then applySubscription() walks the existing addProxyToScopePool(scope, scopeId, proxyId) API. This means:

  • Existing rotation, health checks, and fail-closed guards apply for free.
  • Existing dashboards (ProxyPoolTab, SourceToggleBar, GlobalConfigTab) work unchanged — subscription nodes just appear in the pool with a source badge.
  • Deleting/disabling a subscription cleanly removes its bindings without touching manual proxies.

5. Data model

5.1 New table proxy_subscriptions

Column Type Notes
id TEXT PK UUID
name TEXT NOT NULL display name
url TEXT NOT NULL subscription URL
enabled INTEGER NOT NULL DEFAULT 0 1 = active
mode TEXT NOT NULL DEFAULT 'global' 'global' or 'rule'
rule_providers TEXT NULL JSON array of provider IDs (mode='rule' only)
local_core_endpoint TEXT NULL loopback SOCKS5/HTTP for SS/VMess/etc. (e.g. socks5://127.0.0.1:2080)
update_interval_minutes INTEGER NOT NULL DEFAULT 60 background refresh cadence
last_fetched_at TEXT NULL ISO timestamp of last successful fetch
status TEXT NOT NULL DEFAULT 'empty' 'ok' / 'error' / 'empty'
error TEXT NULL last error / warning text (redacted)
last_nodes TEXT NULL JSON array, redacted node summaries
created_at TEXT NOT NULL ISO
updated_at TEXT NOT NULL ISO

Index: idx_proxy_subscriptions_enabled (enabled) for the scheduler tick.

5.2 Extended proxy_registry

Added one column:

Column Type Notes
subscription_id TEXT NULL FK by convention (no enforced FK; subscription row lives in proxy_subscriptions)

Existing rows on upgrade: subscription_id = NULL, behavior unchanged. Migration: ALTER TABLE proxy_registry ADD COLUMN subscription_id TEXT; (applied as 131_proxy_subscriptions.sql, idempotent via the migration runner's ALTER semantics).

5.3 Extended proxy_subscriptions test isolation

The migration runner applies new migrations automatically; the only places that need to know about the new column are types.ts and mappers.ts (one extra field each) and proxies.ts (3 SQL statements: INSERT/UPDATE/SELECT).

6. Modes

6.1 Global mode

  • Pool bound to scope='global', scope_id=NULL.
  • proxyEnabled setting forced to true whenever any subscription (or any non-subscription global proxy) is active.
  • All provider traffic exits via the subscription pool, with rotation/health applied by the existing resolveProxyForConnectionFromRegistry.

6.2 Rule mode

  • Pool bound to scope='provider', scope_id=<selected provider id> for each selected provider.
  • Providers NOT in the list fall through to direct (their own provider-level proxy or no proxy).
  • Toggling a subscription from global → rule first calls unapplySubscription to detach the previous global bindings, then re-syncs.

7. Protocol support

The existing proxyDispatcher only speaks http / https / socks5 / vercel / deno / cloudflare. v1 follows that:

Parser-detected type Goes into pool directly? Needs localCoreEndpoint?
http / https yes no
socks5 yes no
ss / ssr no yes (sing-box/clash → loopback SOCKS5)
vmess / vless no yes
trojan no yes
hysteria / tuic / wireguard no yes
relay (vercel/deno/cloudflare) yes no

Without localCoreEndpoint, SS-class nodes are surfaced in the status as a warning but not routed. This matches the "fail-closed, but don't lie about capability" policy: we never silently drop traffic; we report unrouteable nodes and let the operator decide.

8. Parser (src/lib/proxySubscription/parse.ts)

Hand-rolled, no external dependency. Inputs accepted:

  1. Clash / Clash.Meta YAMLproxies: array, with type dispatch.
  2. Base64-wrapped URI listparseSubscription detects base64 by length and charset, decodes, then URI-parses.
  3. V2RayN-style JSON-array-of-URI — uses vmess:// / vless:// URIs.
  4. Plain URI listss://, vmess://, vless://, trojan://, hysteria://, tuic://, wireguard://, socks5://, http(s)://.

Output:

type ParsedSubscription = {
  nodes: DirectlyUsableNode[];   // http/https/socks5/relay
  needsCore: NeedsCoreNode[];    // ss/vmess/... — redacted summary
  rawProtocols: string[];        // for diagnostics
  parserWarnings: string[];      // per-line parse errors, redacted
};

type DirectlyUsableNode = {
  name: string;
  type: "http" | "https" | "socks5" | "vercel" | "deno" | "cloudflare";
  host: string;
  port: number;
  username?: string;
  password?: string;
};

redactedNodeSummary returns a JSON-serializable array of {name, type, host, port, hasCredentials} with credentials omitted. This is what gets persisted in last_nodes for the operator UI.

9. Security

  • SSRF on localCoreEndpoint: the only SSRF surface here is the local core endpoint (the subscription URL itself is operator-supplied). Allowed hosts: 127.0.0.1, ::1, localhost. Any other host is rejected at parse time with a subscription_needs_core_endpoint_invalid status.
  • No outbound to operator-internal hosts from a subscription URL. The URL fetch goes through Node's fetch (same trust model as the existing proxyLatency health checks and the provider ping tasks). The operator already trusts the URL by pasting it.
  • Fail-closed: if a subscription's proxy is dead but still bound to a scope, hasBlockingProxyAssignment returns true and traffic fails closed — matches existing policy for any pool proxy. The operator can always disable the subscription or remove the binding.
  • No secret echo: last_nodes is redacted; the UI never sends secrets back. password / username are stored encrypted at rest by the existing proxy_registry encryption path.
  • No cross-tenant write: the API routes are gated by requireManagementAuth (dashboard session OR a manage-scope API key). Per-API-key overrides are explicitly out-of-scope.

10. UI

A new sub-tab "订阅代理" in dashboard / settings / 代理, placed after "documentation". List view shows:

  • Name + URL (truncated, with full URL in title attribute)
  • Status badge: ok / error / empty
  • Enabled switch (optimistic toggle)
  • Action buttons: edit / refresh / delete

The edit form has:

  • Name (text, required)
  • URL (text, required, validated as URL)
  • Mode toggle (global / rule)
  • Provider multi-select (visible only in rule mode; populated from /api/providers)
  • Local core endpoint (text, optional; placeholder socks5://127.0.0.1:2080)
  • Update interval (number, default 60 minutes)
  • Enabled toggle

When status === 'error', an inline warning banner shows subscription.error. When status === 'ok' and there are nodes that needed a local core, a soft warning banner shows which protocols were skipped.

11. Migration & rollout

  1. New migration 131_proxy_subscriptions.sql runs on first DB open after upgrade (auto-discovered by the existing migration runner).
  2. The migration is idempotent: ALTER TABLE … ADD COLUMN … against an already-migrated DB is a no-op in SQLite when wrapped in the runner's "ignore duplicate column" path. See the existing 040_oneproxy_proxy_fields.sql and 093_proxy_enable_toggles.sql precedents.
  3. No backfill: existing rows get subscription_id = NULL, which the service treats as "manual, not subscription-managed".
  4. UI hides the tab when there are zero subscriptions, but the API is always available — that's intentional, so headless operators can manage subscriptions via API only.

12. Auto-refresh

startSubscriptionScheduler() is idempotent and:

  • Skips in the browser (typeof window !== "undefined").
  • Skips under NODE_ENV=test.
  • Otherwise starts a 60s setInterval that:
    • Lists enabled subscriptions.
    • For each, computes due = now - lastFetchedAt >= updateIntervalMinutes * 60_000.
    • Calls syncSubscription for due ones, swallowing errors (logged).
  • The interval timer is .unref()'d so it never blocks process exit.

The scheduler is started on:

  • First GET /api/v1/management/proxy-subscriptions (dashboard open).
  • Any syncSubscription call (defensive — for CLI / automation paths that bypass the GET).

13. Testing strategy

tests/unit/proxySubscription.parse.test.ts — 7 pure-parser cases, no DB, runnable in <1s:

  1. Clash YAML with direct (http) and needsCore (ss) nodes.
  2. Base64-wrapped URI list (decoded correctly).
  3. V2Ray JSON-array-of-URI (vmess / vless).
  4. Plain URI list (mixed protocols).
  5. Clash.Meta outbounds (socks5).
  6. Empty / unknown input → nodes=[], needsCore=[], parserWarnings filled.
  7. redactedNodeSummary strips credentials.

tests/unit/proxySubscription.service.test.ts — 4 integration tests using process.env.DATA_DIR + core.resetDbInstance():

  1. Global: create enabled global subscription → syncSubscription → verify pool rows in proxy_registry with subscription_id set → resolveProxyForConnectionFromRegistry returns one of those rows → proxyEnabled is true.
  2. Rule: create enabled rule subscription on provider P1 → verify only P1's scope is bound, P2's scope is untouched.
  3. Fail-closed: subscription fetch URL is unreachable → status='error', pool is empty, but if pool ever had rows they are cleaned up; hasBlockingProxyAssignment returns false (no dead proxies in any scope).
  4. Delete: delete subscription → registry rows for that subscription are removed with force: true (manual deletions can't cascade-block it) → proxyEnabled recomputed.

Test runner command:

node --import tsx/esm \
     --import ./open-sse/utils/setupPolyfill.ts \
     --import ./tests/_setup/isolateDataDir.ts \
     --test \
     tests/unit/proxySubscription.parse.test.ts \
     tests/unit/proxySubscription.service.test.ts

14. Future work (NOT in v1)

  • Per-API-key subscription overrides (multi-tenant; needs a key_subscription_overrides table).
  • Per-provider traffic rules with domain matchers (would slot into the existing interceptionRules table).
  • Latency-weighted rotation across subscription pools (we already have ProxyRotationStrategy = "latency"; just expose it in the UI).
  • Proxying the subscription fetch itself through a separate egress (so operators can fetch behind a corporate firewall).
  • Browser-side preview of a parsed subscription before saving (currently must save → wait → see nodes).

15. Files touched / added

Added (new):

  • src/lib/proxySubscription/parse.ts
  • src/lib/proxySubscription/subscriptionService.ts
  • src/lib/proxySubscription/index.ts
  • src/lib/db/migrations/131_proxy_subscriptions.sql
  • src/app/api/v1/management/proxy-subscriptions/route.ts
  • src/app/api/v1/management/proxy-subscriptions/[id]/route.ts
  • src/app/api/v1/management/proxy-subscriptions/[id]/refresh/route.ts
  • src/app/api/v1/management/proxy-subscriptions/[id]/nodes/route.ts
  • src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx
  • tests/unit/proxySubscription.parse.test.ts
  • tests/unit/proxySubscription.service.test.ts
  • docs/proxy-subscriptions.md (this file)

Modified (minimal):

  • src/lib/db/proxies/types.ts+ subscriptionId: string | null on ProxyRegistryRecord; + subscriptionId?: string | null on ProxyPayload.
  • src/lib/db/proxies/mappers.tsmapProxyRow reads subscription_id from the row.
  • src/lib/db/proxies.ts — INSERT / UPDATE / SELECT add subscription_id.
  • src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx — adds one new sub-tab ("订阅代理") + the literal fallback for labels that aren't in the i18n catalog yet.