From 4bfdc7115180586ea4ae5c52bd471a5e82a7549d Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Tue, 13 Oct 2026 11:27:02 +0330 Subject: [PATCH 01/25] feat: manage proxy cores --- .github/workflows/managed-proxy-cores.yml | 48 ++ .gitignore | 19 + config/quality/eslint-suppressions.json | 5 - docs/frameworks/EMBEDDED-SERVICES.md | 57 +- scripts/test/check-managed-proxy-native.ts | 75 +++ .../settings/components/ProxyTab.tsx | 11 +- .../components/proxy/ManagedProxyTab.tsx | 205 +++++++ src/app/api/services/managed-proxy/route.ts | 106 ++++ .../settings/proxies/batch-delete/route.ts | 4 +- src/lib/api/managedProxyRegistry.ts | 58 ++ src/lib/api/proxyRegistryRouteHandlers.ts | 6 +- src/lib/db/proxies/managedRegistry.ts | 41 ++ src/lib/services/ServiceSupervisor.ts | 82 ++- src/lib/services/bootstrap.ts | 2 + src/lib/services/healthCheck.ts | 10 +- src/lib/services/managedProxy/config.ts | 554 ++++++++++++++++++ src/lib/services/managedProxy/install.ts | 185 ++++++ src/lib/services/managedProxy/manager.ts | 293 +++++++++ src/lib/services/managedProxy/releases.ts | 52 ++ src/lib/services/managedProxy/remove.ts | 72 +++ src/lib/services/managedProxy/runtime.ts | 159 +++++ src/lib/services/types.ts | 4 + src/shared/components/ProxyConfigModal.tsx | 199 ++----- src/shared/components/ProxyCustomFields.tsx | 160 +++++ src/shared/components/managedProxySave.ts | 36 ++ .../components/proxyConfigPersistence.ts | 53 ++ tests/unit/managedProxyArchive.test.ts | 35 ++ tests/unit/managedProxyConfig.test.ts | 74 +++ tests/unit/managedProxyManager.test.ts | 154 +++++ .../components/ProxyConfigModal.test.tsx | 37 +- 30 files changed, 2598 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/managed-proxy-cores.yml create mode 100644 scripts/test/check-managed-proxy-native.ts create mode 100644 src/app/(dashboard)/dashboard/settings/components/proxy/ManagedProxyTab.tsx create mode 100644 src/app/api/services/managed-proxy/route.ts create mode 100644 src/lib/api/managedProxyRegistry.ts create mode 100644 src/lib/db/proxies/managedRegistry.ts create mode 100644 src/lib/services/managedProxy/config.ts create mode 100644 src/lib/services/managedProxy/install.ts create mode 100644 src/lib/services/managedProxy/manager.ts create mode 100644 src/lib/services/managedProxy/releases.ts create mode 100644 src/lib/services/managedProxy/remove.ts create mode 100644 src/lib/services/managedProxy/runtime.ts create mode 100644 src/shared/components/ProxyCustomFields.tsx create mode 100644 src/shared/components/managedProxySave.ts create mode 100644 src/shared/components/proxyConfigPersistence.ts create mode 100644 tests/unit/managedProxyArchive.test.ts create mode 100644 tests/unit/managedProxyConfig.test.ts create mode 100644 tests/unit/managedProxyManager.test.ts diff --git a/.github/workflows/managed-proxy-cores.yml b/.github/workflows/managed-proxy-cores.yml new file mode 100644 index 000000000000..44f1c07949d6 --- /dev/null +++ b/.github/workflows/managed-proxy-cores.yml @@ -0,0 +1,48 @@ +name: Managed proxy cores + +on: + pull_request: + paths: + - "src/lib/services/managedProxy/**" + - "src/lib/services/ServiceSupervisor.ts" + - "src/lib/services/healthCheck.ts" + - "src/lib/services/types.ts" + - "src/app/api/services/managed-proxy/**" + - "src/shared/components/ProxyCustomFields.tsx" + - "tests/unit/managedProxy*.test.ts" + - "scripts/test/check-managed-proxy-native.ts" + - ".github/workflows/managed-proxy-cores.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + native-config: + timeout-minutes: 12 + strategy: + fail-fast: false + max-parallel: 2 + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + - run: npm ci + - name: Check official core configuration fixtures + run: node --import tsx/esm scripts/test/check-managed-proxy-native.ts + env: + CI: "true" + NODE_ENV: test + DATA_DIR: ${{ runner.temp }}/managed-core-data + HOME: ${{ runner.temp }}/managed-core-home + USERPROFILE: ${{ runner.temp }}/managed-core-home + APPDATA: ${{ runner.temp }}/managed-core-home + LOCALAPPDATA: ${{ runner.temp }}/managed-core-home + HOOKMAKER_MAX_TEST_WORKERS: "1" diff --git a/.gitignore b/.gitignore index b4fffd360bd4..8dbb50b3cd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -289,6 +289,25 @@ docker-compose.yml.bak # e impede que um git add -A recapture o symlink (incidente 2026-08-08). /_tasks +# Local development protection +/.ai/ +/.specify/ +/specs/ +/secrets.md +/explain-AI.md +/reference.md +/CLAUDE.md +/AGENTS.md +/.kiro/ +/.codex/ +/.cursor/ +/.cline/ +/graphify-out/ +/.codebase-memory/ +/plans/ +.ignoreme +**/.ignoreme + # CLI local cache/state .playwright-cli diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 8cbc6474a0c3..03a1bd6da494 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1509,11 +1509,6 @@ "count": 1 } }, - "src/lib/services/ServiceSupervisor.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/lib/skills/a2a.ts": { "@typescript-eslint/no-unused-vars": { "count": 2 diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index acdf5c3ac539..507a5a96b106 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -34,14 +34,14 @@ via API keys), embedded services run on the same machine as OmniRoute and commun Seven services are embedded: -| Service | npm package | Default port | Purpose | -| --------------- | ---------------------------------- | :----------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` | -| **CLIProxyAPI** | GitHub release binary (`cliproxy`) | 8317 | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire | -| **Mux** | `mux` (headless `mux server`) | 8322 | Local agent-orchestration daemon (coder/mux). Lifecycle-managed only — not a routing target (no LLM proxying). | -| **Bifrost** | `@maximhq/bifrost` | 8080 | Go AI-gateway relay backend. When running, auto-selected by the relay route (`/v1/relay/`) | -| **Dario** | `@askalf/dario` | 3456 | Claude-subscription proxy — alternative/failover to CLIProxyAPI for Claude-Code-shaped traffic; the injected key becomes `DARIO_ADMIN_TOKEN` gating its `/admin/*` OAuth control plane | -| **open-wa** | `@open-wa/wa-automate` | 8323 | WhatsApp Web automation (headless Chromium via Puppeteer). Lifecycle-managed only — not a routing target. | +| Service | npm package | Default port | Purpose | +| --------------- | ---------------------------------- | :----------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` | +| **CLIProxyAPI** | GitHub release binary (`cliproxy`) | 8317 | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire | +| **Mux** | `mux` (headless `mux server`) | 8322 | Local agent-orchestration daemon (coder/mux). Lifecycle-managed only — not a routing target (no LLM proxying). | +| **Bifrost** | `@maximhq/bifrost` | 8080 | Go AI-gateway relay backend. When running, auto-selected by the relay route (`/v1/relay/`) | +| **Dario** | `@askalf/dario` | 3456 | Claude-subscription proxy — alternative/failover to CLIProxyAPI for Claude-Code-shaped traffic; the injected key becomes `DARIO_ADMIN_TOKEN` gating its `/admin/*` OAuth control plane | +| **open-wa** | `@open-wa/wa-automate` | 8323 | WhatsApp Web automation (headless Chromium via Puppeteer). Lifecycle-managed only — not a routing target. | | **LLMLingua** | `@atjsh/llmlingua-2` | 20135 | Prompt-compression sidecar — real LLMLingua-2 ONNX model (JS/TS port of Microsoft's algorithm). `open-sse/services/compression/engines/llmlingua/index.ts` dispatches `/compress` over HTTP to it, falling back to the in-process worker-thread backend when the sidecar is down. Lifecycle-managed only — not a routing target. | All seven follow the same supervisory model: @@ -51,6 +51,45 @@ All seven follow the same supervisory model: - OmniRoute injects an ephemeral API key into the child's environment and rotates it without downtime (where applicable) - All management routes (`/api/services/*`) are **LOCAL_ONLY** — accessible only from loopback (hard rule #17) +### Managed proxy cores + +The Custom proxy host field also accepts a V2Ray source link. Select **sing-box** or **Xray** +explicitly, then click Save. Typing never downloads or starts a core. Save verifies the official +release archive, validates native configuration, starts a distinct localhost SOCKS listener, and +only then writes the proxy registry assignment. Failed activation restores the previous managed +configuration and process. `STORAGE_ENCRYPTION_KEY` is required; source links are encrypted. + +Managed files live under `DATA_DIR/services/managed-proxy/`, separate from TPROXY. There is one +owned process per selected core, up to 100 listeners. No autostart or global routing default is +changed. Successful Save remembers activation for this managed core only; application startup +restores enabled saved cores using the already verified binary, never a boot-time download. +Explicit Stop clears that activation flag. A missing or damaged binary requires re-saving a source. +Registry deletion removes the source and listener locally; ordinary endpoint edits are rejected for +managed rows so the saved source cannot silently drift from its routed listener. + +| Source format | sing-box | Xray | +| ------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | +| VLESS, VMess, Trojan, Shadowsocks URI | Supported conversion | Supported conversion | +| Hysteria2/hy2, TUIC, AnyTLS URI | Supported conversion | Rejected; use a compatible core | +| TCP/raw, WebSocket, gRPC, HTTPUpgrade | Supported for VLESS/VMess/Trojan | Supported for VLESS/VMess/Trojan | +| XHTTP URI transport | Rejected | Supported conversion | +| Native outbound JSON | Selected core's allowed proxy protocols/options, checked natively | Selected core's allowed proxy protocols/options, checked natively | + +Unknown wire options, insecure TLS flags, external plugins, local file-path options and unsupported +combinations are rejected rather than silently discarded. Native objects are outbound definitions, +not whole-core configurations; control/direct/bypass outbounds are not accepted. WireGuard on +sing-box 1.14 uses endpoints, so it is not accepted as a native outbound. This is not an all-format +converter; SSR, legacy whole-base64 Shadowsocks links, arbitrary Clash objects and transport +variants absent from the table need a separately supported source format. + +`/api/services/managed-proxy` is authenticated and loopback-only: GET lists redacted metadata, +POST saves `{core,name,sourceLink,id?,assignment?}`, PATCH accepts `{core,action:"start"|"stop"}`. +The host's trusted peer-stamp pipeline must be present. Native core diagnostics are suppressed +because they can contain credentials; lifecycle events use the existing structured application +file logger without source links or upstream payloads. Attach only sanitized application logs for +support. Official versions are pinned to sing-box 1.14.3 and Xray 26.3.27 with release-asset +SHA-256 manifests; future updates require changing the version and all verified digests together. + ### Key decisions (from design plan) | Decision | Value | @@ -154,7 +193,7 @@ All seven follow the same supervisory model: | `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI | | `src/lib/services/installers/mux.ts` | npm install/update/uninstall for Mux | | `src/lib/services/installers/openwa.ts` | npm install/update/uninstall for open-wa | -| `src/lib/services/installers/llmlingua.ts` | npm install/update/uninstall for LLMLingua | +| `src/lib/services/installers/llmlingua.ts` | npm install/update/uninstall for LLMLingua | | `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper | | `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint | | `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) | diff --git a/scripts/test/check-managed-proxy-native.ts b/scripts/test/check-managed-proxy-native.ts new file mode 100644 index 000000000000..f6cf714fb08f --- /dev/null +++ b/scripts/test/check-managed-proxy-native.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { execFileSync } from "node:child_process"; +import { + sourceOutbound, + renderManagedConfig, + nativeCheckArgs, + type ManagedCore, +} from "../../src/lib/services/managedProxy/config.ts"; +import { installManagedCore } from "../../src/lib/services/managedProxy/install.ts"; + +// CI-only official binary check; never serves an upstream or starts a core listener. +if (process.env.CI !== "true") throw new Error("Native managed-core verification runs only in CI"); +const root = fs.mkdtempSync(path.join(os.tmpdir(), "managed-core-native-")); +const UUID = "11111111-1111-4111-8111-111111111111"; +const vmess = + "vmess://" + + Buffer.from( + JSON.stringify({ + v: "2", + ps: "synthetic", + add: "127.0.0.1", + port: 9, + id: UUID, + aid: 0, + scy: "auto", + net: "tcp", + type: "none", + tls: false, + }) + ).toString("base64"); +const shared = [ + `vless://${UUID}@127.0.0.1:9?security=none`, + `vless://${UUID}@127.0.0.1:9?security=tls&type=ws&path=%2Fchat&sni=example.invalid`, + `trojan://synthetic-password@127.0.0.1:9?sni=example.invalid`, + "ss://YWVzLTEyOC1nY206c3ludGhldGljLXBhc3N3b3Jk@127.0.0.1:9", + vmess, +]; +try { + for (const core of ["singbox", "xray"] as ManagedCore[]) { + const executable = await installManagedCore(core, root); + const sources = + core === "singbox" + ? [ + ...shared, + "hy2://synthetic-password@127.0.0.1:9?sni=example.invalid", + `tuic://${UUID}:synthetic-password@127.0.0.1:9?sni=example.invalid`, + "anytls://synthetic-password@127.0.0.1:9?sni=example.invalid", + ] + : shared; + for (let i = 0; i < sources.length; i++) { + const outbound = sourceOutbound(core, sources[i]); + const config = renderManagedConfig(core, [{ id: "synthetic", port: 24001, outbound }]); + const file = path.join(root, `${core}-${i}.json`); + fs.writeFileSync(file, JSON.stringify(config), { encoding: "utf8", mode: 0o600 }); + const env: NodeJS.ProcessEnv = {}; + for (const key of ["SystemRoot", "WINDIR", "PATH", "TEMP", "TMP"]) + if (process.env[key]) env[key] = process.env[key]; + const output = execFileSync(executable, nativeCheckArgs(core, file), { + cwd: root, + env, + timeout: 15000, + maxBuffer: 65536, + windowsHide: true, + encoding: "utf8", + }); + if (core === "xray") assert.ok(output.includes("Configuration OK")); + console.log(`Native ${core} fixture ${i + 1}/${sources.length} accepted`); + } + } +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx index 01b308edd153..2dd863e982f7 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx @@ -7,8 +7,15 @@ import ProxyPoolTab from "./proxy/ProxyPoolTab"; import FreePoolTab from "./proxy/FreePoolTab"; import DocumentationTab from "./proxy/DocumentationTab"; import SubscriptionTab from "./proxy/SubscriptionTab"; +import ManagedProxyTab from "./proxy/ManagedProxyTab"; -type TabId = "global-config" | "proxy-pool" | "free-pool" | "documentation" | "subscriptions"; +type TabId = + | "global-config" + | "proxy-pool" + | "free-pool" + | "documentation" + | "subscriptions" + | "managed-cores"; const TABS: Array<{ id: TabId; labelKey: string; fallback: string }> = [ { id: "global-config", labelKey: "proxyGlobalConfigTab", fallback: "Global config" }, @@ -16,6 +23,7 @@ const TABS: Array<{ id: TabId; labelKey: string; fallback: string }> = [ { id: "free-pool", labelKey: "freePoolTab", fallback: "Free pool" }, { id: "documentation", labelKey: "proxyDocumentationTab", fallback: "Documentation" }, { id: "subscriptions", labelKey: "proxySubscriptionsTab", fallback: "Subscriptions" }, + { id: "managed-cores", labelKey: "proxyManagedCoresTab", fallback: "Managed cores" }, ]; export default function ProxyTab() { @@ -67,6 +75,7 @@ export default function ProxyTab() { {activeTab === "free-pool" && } {activeTab === "documentation" && } {activeTab === "subscriptions" && } + {activeTab === "managed-cores" && } ); diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/ManagedProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/ManagedProxyTab.tsx new file mode 100644 index 000000000000..1a2791839847 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/ManagedProxyTab.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Button from "@/shared/components/Button"; + +type Core = "singbox" | "xray"; +type CoreState = { + core: Core; + state: string; + entries: Array<{ id: string; name: string; port: number }>; +}; +const inputClass = + "w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary"; + +async function loadCoreMetadata(): Promise { + const response = await fetch("/api/services/managed-proxy"); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error?.message || "Unable to load managed cores"); + return payload.cores || []; +} + +export default function ManagedProxyTab() { + const [cores, setCores] = useState([]); + const [core, setCore] = useState(""); + const [name, setName] = useState(""); + const [sourceLink, setSourceLink] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + async function load() { + setCores(await loadCoreMetadata()); + } + useEffect(() => { + const controller = new AbortController(); + fetch("/api/services/managed-proxy", { signal: controller.signal }) + .then(async (response) => { + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error?.message || "Unable to load managed cores"); + if (!controller.signal.aborted) setCores(payload.cores || []); + }) + .catch((failure: Error) => { + if (!controller.signal.aborted) setError(failure.message); + }); + return () => controller.abort(); + }, []); + async function operation(method: "POST" | "PATCH", body: unknown) { + setBusy(true); + setError(""); + try { + const response = await fetch("/api/services/managed-proxy", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error?.message || "Managed core operation failed"); + if (method === "POST") setSourceLink(""); + await load(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : "Managed core operation failed"); + } finally { + setBusy(false); + } + } + return ( +
+

Managed proxy cores

+

+ Paste a tunnel link in the Custom proxy host field to save and assign it. Here you can also + save a native outbound JSON definition without changing any assignment. +

+ + +

+ Save verifies the official core if needed and checks native configuration before activation. + Sources require encryption. No preview installation, autostart or transparent-proxy changes. +

+ {error && ( +

+ {error} +

+ )} +
+ +
+
+ ); +} + +function ManagedCoreList({ + cores, + busy, + operation, +}: { + cores: CoreState[]; + busy: boolean; + operation: (method: "POST" | "PATCH", body: unknown) => Promise; +}) { + return ( + <> + {cores.map((item) => ( +
+
+ + {item.core === "singbox" ? "sing-box" : "Xray"}: {item.state} + + +
+ {item.entries.map((entry) => ( +

+ {entry.name} — socks5://127.0.0.1:{entry.port} +

+ ))} +
+ ))} + + ); +} + +function ManagedSourceFields({ + core, + setCore, + name, + setName, + sourceLink, + setSourceLink, +}: { + core: Core | ""; + setCore: (v: Core | "") => void; + name: string; + setName: (v: string) => void; + sourceLink: string; + setSourceLink: (v: string) => void; +}) { + return ( + <> +
+ + +
+
+ + setName(e.target.value)} + className={inputClass} + /> +
+
+ +