Files
OmniRoute/docs/proxy-subscriptions.md
Adam f31f3c081e feat(proxy): operator-level proxy subscriptions (Karing-style) — hardened, ready for review (#7299)
* feat(proxy-subscriptions): src/lib/proxySubscription/parse.ts

* feat(proxy-subscriptions): src/lib/proxySubscription/subscriptionService.ts

* feat(proxy-subscriptions): src/lib/proxySubscription/index.ts

* feat(proxy-subscriptions): src/lib/db/migrations/123_proxy_subscriptions.sql

* feat(proxy-subscriptions): src/app/api/v1/management/proxy-subscriptions/route.ts

* feat(proxy-subscriptions): src/app/api/v1/management/proxy-subscriptions/[id]/route.ts

* feat(proxy-subscriptions): src/app/api/v1/management/proxy-subscriptions/[id]/refresh/route.ts

* feat(proxy-subscriptions): src/app/api/v1/management/proxy-subscriptions/[id]/nodes/route.ts

* feat(proxy-subscriptions): src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx

* feat(proxy-subscriptions): tests/unit/proxySubscription.parse.test.ts

* feat(proxy-subscriptions): tests/unit/proxySubscription.service.test.ts

* feat(proxy-subscriptions): docs/proxy-subscriptions.md

* feat(proxy-subscriptions): src/lib/db/proxies/types.ts

* feat(proxy-subscriptions): src/lib/db/proxies/mappers.ts

* feat(proxy-subscriptions): src/lib/db/proxies.ts

* feat(proxy-subscriptions): src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx

* i18n(proxy-subscriptions): add proxySubscriptionsTab key + use in ProxyTab

* i18n(proxy-subscriptions): add proxySubscriptionsTab key + use in ProxyTab

* i18n(proxy-subscriptions): add proxySubscriptionsTab key + use in ProxyTab

* i18n(proxy-subscriptions): add proxySubscriptionsTab key + use in ProxyTab

* test(proxy-subscriptions): extract isSubscriptionDue + unit tests

* test(proxy-subscriptions): extract isSubscriptionDue + unit tests

* test(proxy-subscriptions): extract isSubscriptionDue + unit tests

* test(proxy-subscriptions): add global->rule switch re-bind integration test

* feat(proxy-subscriptions): inline needs-local-core guidance in SubscriptionTab

* i18n: add proxySubscriptionsTab to en (rebased on current main)

* i18n: add proxySubscriptionsTab to zh-CN (rebased on current main)

* i18n: add proxySubscriptionsTab to pt-BR (rebased on current main)

* test(proxy-subscriptions): extract needsCore detection into pure module + unit tests

* test(proxy-subscriptions): extract needsCore detection into pure module + unit tests

* test(proxy-subscriptions): extract needsCore detection into pure module + unit tests

* refactor(proxy-subscriptions): extract scopes.ts into pure module + unit tests (#65)

* refactor(proxy-subscriptions): extract coreEndpoint.ts into pure module + unit tests (#65)

* refactor(proxy-subscriptions): extract subscriptionService.ts into pure module + unit tests (#65)

* refactor(proxy-subscriptions): extract proxySubscription.scopes.test.ts into pure module + unit tests (#65)

* refactor(proxy-subscriptions): extract proxySubscription.coreEndpoint.test.ts into pure module + unit tests (#65)

* security(proxy-subscriptions): fetchGuard.ts — SSRF guard + core scheme (#P0)

* security(proxy-subscriptions): coreEndpoint.ts — SSRF guard + core scheme (#P0)

* security(proxy-subscriptions): subscriptionService.ts — SSRF guard + core scheme (#P0)

* security(proxy-subscriptions): proxySubscription.fetchGuard.test.ts — SSRF guard + core scheme (#P0)

* security(proxy-subscriptions): proxySubscription.coreEndpoint.test.ts — SSRF guard + core scheme (#P0)

* refactor(proxy-subscriptions): subscriptionService.ts — concurrency lock / resilience / url redaction (#P1)

* refactor(proxy-subscriptions): url.ts — concurrency lock / resilience / url redaction (#P1)

* refactor(proxy-subscriptions): index.ts — concurrency lock / resilience / url redaction (#P1)

* refactor(proxy-subscriptions): route.ts — concurrency lock / resilience / url redaction (#P1)

* refactor(proxy-subscriptions): route.ts — concurrency lock / resilience / url redaction (#P1)

* refactor(proxy-subscriptions): proxySubscription.url.test.ts — concurrency lock / resilience / url redaction (#P1)

* i18n(proxy-subscriptions): subscriptionService.ts — stable error codes + locale keys (#P2-6)

* i18n(proxy-subscriptions): SubscriptionTab.tsx — stable error codes + locale keys (#P2-6)

* i18n(proxy-subscriptions): en.json — stable error codes + locale keys (#P2-6)

* i18n(proxy-subscriptions): zh-CN.json — stable error codes + locale keys (#P2-6)

* i18n(proxy-subscriptions): pt-BR.json — stable error codes + locale keys (#P2-6)

* i18n(proxy-subscriptions): en.json — normalize to LF line endings (#P2-6)

* i18n(proxy-subscriptions): zh-CN.json — normalize to LF line endings (#P2-6)

* i18n(proxy-subscriptions): pt-BR.json — normalize to LF line endings (#P2-6)

* enhance(proxy-subscriptions): reject subscription fetch if ANY resolved DNS address is blocked (P3-1)

* enhance(proxy-subscriptions): add withRetry() exponential-backoff helper (P3-2)

* enhance(proxy-subscriptions): DNS multi-record guard + retry/backoff fetch + batch scope writes + observability (P3-1..P3-4)

* enhance(proxy-subscriptions): batch addProxiesToScopePool() to drop N+1 writes (P3-3)

* enhance(proxy-subscriptions): add last_error_at + consecutive_failures observability columns (P3-4)

* enhance(proxy-subscriptions): surface consecutive failures + last error time in subscription cards (P3-4)

* test(proxy-subscriptions): cover multi-record DNS SSRF (block if ANY address internal) (P3-1)

* test(proxy-subscriptions): cover withRetry() first-success / retries / backoff / non-retryable stop (P3-2)

* fix(proxy-subscriptions): resolve js-yaml import + missing backup/generation-bump imports

Two bugs made the feature non-functional and its own test suite false:

1. parse.ts used `import yaml from "js-yaml"` (default import), but
   js-yaml@^5 is ESM-only with no default export — this threw a
   SyntaxError at module load, crashing every caller (index.ts
   re-exports parse.ts, so every API route hit this too). Switch to
   `import * as yaml`, matching how the rest of the codebase already
   imports js-yaml (hermes-agent.ts, openapiParser.ts, openapi/spec
   route.ts, guide-settings route.ts).

2. subscriptionService.ts's unapplySubscription() called backupDbFile()
   and bumpProxyRegistryGeneration() without importing either —
   backupDbFile exists but wasn't imported; bumpProxyRegistryGeneration
   was a private, non-exported function in db/proxies.ts. This threw a
   ReferenceError whenever a subscription with bound proxies was
   disabled/deleted (the normal path). Import backupDbFile from
   ../db/backup and export+import bumpProxyRegistryGeneration from
   ../db/proxies, matching the identical backup+bump pattern already
   used by deleteProxyById for the same proxy_assignments/proxy_registry
   mutation shape.

Fixing both unmasked a third, previously-unreachable bug (both crashes
happened before any test assertion could run): recomputeProxyEnabled()
checked `proxy_subscriptions.enabled = 1` alone, which stays true across
an unapply/disable cycle since unapplySubscription() never touches that
column — the proxyEnabled flag would get stuck on `true` even after the
subscription's proxies were fully detached. Changed the check to require
an actually-bound proxy_assignments row for an enabled subscription,
matching hasNonSubscriptionGlobalProxy()'s existing bound-check pattern.
Traced all 3 production call sites (mode/rule switch, disable, delete)
to confirm this doesn't change their outcome — only the previously-wrong
"unapply in isolation" case.

Also fixed the test file's own pre-existing bug: its provider_connections
inserts omitted created_at/updated_at (NOT NULL, no default in the
schema since 001_initial_schema.sql), which 0 assertions had ever
reached before because the SyntaxError always crashed the file first.

All 9 proxySubscription test files now run clean: 49/49 pass (previously
2 files crashed outright at import time, 0 assertions ever ran).

Separately confirmed via testing against the pristine PR head: this PR
has 3 more pre-existing gate failures unrelated to the above (file-size
on db/proxies.ts, cognitive-complexity, complexity, changelog-integrity
vs the current release tip) plus 3 pre-existing TS2345 errors in
parse.ts (lines 228/233/263, unrelated to the yaml import). All are the
PR's own scope/base-drift, out of scope for this fix — the PR has never
had a real CI run (base=main), so no gate has ever surfaced them; they
need the base retarget + a full CI pass called out in the plan file's
own remaining mandatory items.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(db): renumber proxy-subscriptions migrations to avoid version collision

release/v3.8.49 already ships 123_quota_auto_ping.sql and
124_generic_session_affinity_ttl.sql; this PR's 123/124 files collided,
tripping migrationRunner's version-collision guard and failing all
proxySubscription.service tests. Renumber to 127/128 (next free slots
after 126_reasoning_routing_rules.sql).

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>

* refactor(db): extract proxySubscriptions + registryGeneration to keep proxies.ts under file-size cap

Moves addProxiesToScopePool to ./proxySubscriptions.ts and the registry-generation
helpers to ./proxies/registryGeneration.ts (re-exported from proxies.ts), keeping the
module under its frozen 1177-line cap after the operator-proxy-subscriptions feature.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(db): renumber proxy-subscriptions migrations past release collision

Rebasing onto release/v3.8.49 surfaced a migration version collision: this
branch's 127_proxy_subscriptions.sql and 128_proxy_subscriptions_meta.sql
now collide with 127_usage_history_account_identity.sql and
128_auto_candidate_overrides.sql that landed on release since this branch
last synced. Renumbered to 131/132 (next free prefixes after the current
130_remove_unregistered_qwen_data.sql) and updated the in-file header
comments to match. No schema/behavior change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
Co-authored-by: xier2012 <xier2012@users.noreply.github.com>
2026-07-21 13:16:41 -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 123_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 123_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/123_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.