diff --git a/.env.example b/.env.example index 9ed60b9995cf..74d67608400b 100644 --- a/.env.example +++ b/.env.example @@ -3672,6 +3672,9 @@ QUOTA_STORE_DRIVER=sqlite # src/lib/services/bootstrap.ts when OmniRoute manages the Bifrost sidecar lifecycle. # Default: 8080. # BIFROST_PORT=8080 +# Port the supervised sing-box embedded service binds to (127.0.0.1:). +# Default: 20140. +# SINGBOX_PORT=20140 # API key for the Bifrost gateway (sent as Authorization: Bearer ...). If # unset, the route expects the request to carry a valid OmniRoute API key; # this key is for gateway-side auth only. diff --git a/changelog.d/features/12962-singbox-tproxy-sidecar.md b/changelog.d/features/12962-singbox-tproxy-sidecar.md new file mode 100644 index 000000000000..9b2d71128832 --- /dev/null +++ b/changelog.d/features/12962-singbox-tproxy-sidecar.md @@ -0,0 +1 @@ +- **feat(services):** sing-box embedded service — a supervised TPROXY sidecar installed from the official SagerNet release (pinned v1.14.1, SHA256-verified per platform; any other version or a checksum mismatch is refused). It gets the full embedded-service surface (8 loopback-only `/api/services/singbox/*` routes, dashboard tab, `SINGBOX_PORT`, seed migration 221). When TPROXY capture is applied, sing-box listens on its own port — never the native IP_TRANSPARENT listener's `onPort` — and every outbound is SO_MARK'd with the capture `bypassMark` the `mangle OUTPUT` rule excludes, so it neither collides with the native listener nor loops its own egress; without a `bypassMark` it is not started, and every failure path logs at error level. Thanks @rqzbeh. (#12962) diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index acdf5c3ac539..44760eb7a836 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -1,13 +1,13 @@ --- title: "Embedded Services" -description: "Reference for 9Router, CLIProxyAPI, Mux, Bifrost, open-wa, and LLMLingua" +description: "Reference for 9Router, CLIProxyAPI, Mux, Bifrost, open-wa, LLMLingua, and sing-box" --- # Embedded Services > **Version:** v3.8.44 -> **Last updated:** 2026-09-16 -> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost, open-wa, LLMLingua). +> **Last updated:** 2026-10-28 +> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost, open-wa, LLMLingua, sing-box). Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and exposes as first-class routing targets. Unlike external providers (which are reached over the internet @@ -32,19 +32,20 @@ via API keys), embedded services run on the same machine as OmniRoute and commun ### Why embedded services? -Seven services are embedded: +Eight 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. | +| **sing-box** | GitHub release binary (`singbox`) | 20140 | TPROXY sidecar backing `applyTproxy()`'s transparent-proxy interception (`src/mitm/tproxy/setup.ts`). Installer downloads the pinned, checksum-verified release. Lifecycle-managed only — not a routing target (no LLM proxying) | -All seven follow the same supervisory model: +All eight follow the same supervisory model: - OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`) - OmniRoute spawns and monitors them as child processes @@ -154,7 +155,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) | @@ -574,7 +575,38 @@ response) falls back to the in-process worker-thread backend (`./worker.ts`). --- -### 4.8 Reverse proxy (9Router dashboard embed) +### 4.8 sing-box endpoints (8 routes) + +sing-box has the same endpoint shape as Mux — no `rotate-key` route, no API key +injection (`needsApiKey: false` in `bootstrap.ts`). It is lifecycle-managed only: +never a routing/LLM provider. `install()` downloads the official GitHub release +binary (`SagerNet/sing-box`) for the pinned version (`SINGBOX_PINNED_VERSION` in +`src/lib/services/installers/singbox.ts`) and verifies its SHA256 against a +checksum baked into the same file before ever writing it to disk — any other +requested version is rejected. + +| Method | Path | Description | +| ------ | -------------------------------------------- | --------------------------------------------------------- | +| `POST` | `/api/services/singbox/install` | Download + verify the pinned sing-box release | +| `POST` | `/api/services/singbox/start` | Start sing-box (`sing-box run -c config.json`) | +| `POST` | `/api/services/singbox/stop` | Stop sing-box | +| `POST` | `/api/services/singbox/restart` | Restart sing-box | +| `POST` | `/api/services/singbox/update` | Re-verify/reinstall the pinned release | +| `GET` | `/api/services/singbox/status` | Live + DB status | +| `POST` | `/api/services/singbox/auto-start` | Toggle auto-start | +| `POST` | `/api/services/singbox/auto-restart-adopted` | Toggle auto-restart for an adopted (pre-existing) process | +| `GET` | `/api/services/singbox/logs` | SSE log tail (via shared `[name]/logs` dynamic route) | + +**TPROXY wiring:** `ensureSingboxTproxy()` (`src/mitm/tproxy/setup.ts`) writes +`config.json` and starts the supervisor before `applyTproxy()` installs the +TPROXY firewall rules. If the supervisor is missing or fails to reach `running`, +it now logs at error level (rather than silently swallowing the failure) so an +operator can tell the TPROXY rules were applied without a working listener +behind them. + +--- + +### 4.9 Reverse proxy (9Router dashboard embed) The dashboard embeds the 9Router web UI inside an iframe via an internal reverse proxy at: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d863455d4c70..5bf5dd1054e2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -6558,6 +6558,186 @@ paths: "500": description: Update failed + /api/services/singbox/install: + post: + tags: [Embedded Services] + summary: Install sing-box + description: >- + Downloads the pinned sing-box release binary from GitHub + (SagerNet/sing-box) and verifies its SHA256 against a checksum baked + into `src/lib/services/installers/singbox.ts` before writing it to + DATA_DIR/services/singbox/. Any version other than the pinned one is + rejected. **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "400": + description: Invalid request body, or a version with no pinned checksum + "500": + description: Download failed or checksum verification failed + + /api/services/singbox/start: + post: + tags: [Embedded Services] + summary: Start sing-box + description: >- + Spawns `sing-box run -c config.json`. Idempotent if already running. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service started + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: sing-box is not installed + "503": + description: Start failed + + /api/services/singbox/stop: + post: + tags: [Embedded Services] + summary: Stop sing-box + description: >- + Gracefully stops sing-box. Idempotent. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/singbox/restart: + post: + tags: [Embedded Services] + summary: Restart sing-box + description: >- + stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/singbox/update: + post: + tags: [Embedded Services] + summary: Re-verify/reinstall the pinned sing-box release + description: >- + Stops, re-downloads and checksum-verifies the pinned release, restarts. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "500": + description: Update failed + + /api/services/singbox/status: + get: + tags: [Embedded Services] + summary: Get sing-box status + description: >- + Returns live supervisor state and DB metadata. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/singbox/auto-start: + post: + tags: [Embedded Services] + summary: Toggle sing-box auto-start + description: >- + When enabled, sing-box starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body + + /api/services/singbox/auto-restart-adopted: + post: + tags: [Embedded Services] + summary: Toggle sing-box auto-restart-when-adopted + description: >- + When enabled, an externally-adopted (not OmniRoute-spawned) sing-box + process is restarted under OmniRoute's own supervisor on the next + health-check cycle instead of being left as adopted-only. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "204": + description: Flag updated + "400": + description: Invalid request body + "500": + description: Update failed + /api/services/{name}/logs: get: tags: [Embedded Services] diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f3c109c4bac0..b624212b6b75 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1623,6 +1623,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Master kill switch for the bifrost sidecar proxy. When set to `0`, the route returns 503 with the `X-Bifrost-Killswitch` header and the operator is bounced to the TS path. Use to disable the sidecar without redeploying (tier-1 router incident, key rotation). | | `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. | | `BIFROST_PORT` | `8080` | `src/lib/services/bootstrap.ts` | Port the supervised Bifrost embedded service binds to (`127.0.0.1:`) when OmniRoute manages the Bifrost sidecar lifecycle. Defaults to `8080`. | +| `SINGBOX_PORT` | `20140` | `src/lib/services/bootstrap.ts` | Port the supervised sing-box embedded service binds to (`127.0.0.1:`) when OmniRoute manages the sing-box sidecar lifecycle. Defaults to `20140`. | | `LLMLINGUA_PORT` | `20135` | `src/lib/services/bootstrap.ts` | Port the supervised LLMLingua compression service binds to (`127.0.0.1:`). Defaults to `20135`. | | `LLMLINGUA_BASE_URL` | `http://127.0.0.1:20135` | `open-sse/services/compression/engines/llmlingua/index.ts` | Base URL for the local LLMLingua HTTP compression microservice daemon. Defaults to `http://127.0.0.1:20135`. | | `LLMLINGUA_MODEL` | `atjsh/llmlingua-2-js-tinybert-meetingbank` | `src/lib/services/installers/llmlingua.ts` | Hugging Face model id the supervised LLMLingua sidecar loads (passed to the child process env). | diff --git a/src/app/(dashboard)/dashboard/providers/services/page.tsx b/src/app/(dashboard)/dashboard/providers/services/page.tsx index 9b22d348e4b3..7f0f8f8a1674 100644 --- a/src/app/(dashboard)/dashboard/providers/services/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/page.tsx @@ -12,8 +12,10 @@ import { BifrostServiceTab } from "./tabs/BifrostServiceTab"; import { DarioServiceTab } from "./tabs/DarioServiceTab"; import { OpenwaServiceTab } from "./tabs/OpenwaServiceTab"; import { LlmlinguaServiceTab } from "./tabs/LlmlinguaServiceTab"; +import { SingboxServiceTab } from "./tabs/SingboxServiceTab"; -type Tab = "cliproxy" | "9router" | "mux" | "bifrost" | "dario" | "openwa" | "llmlingua"; +type Tab = + "cliproxy" | "9router" | "mux" | "bifrost" | "dario" | "openwa" | "llmlingua" | "singbox"; const TABS: { id: Tab; label: string; icon: string }[] = [ { id: "cliproxy", label: "CLIProxyAPI", icon: "swap_horiz" }, @@ -23,6 +25,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [ { id: "dario", label: "Dario", icon: "shield_person" }, { id: "openwa", label: "open-wa", icon: "chat" }, { id: "llmlingua", label: "LLMLingua", icon: "compress" }, + { id: "singbox", label: "sing-box", icon: "vpn_lock" }, ]; export default function ServicesPage() { @@ -43,6 +46,7 @@ export default function ServicesPage() { dario: , openwa: , llmlingua: , + singbox: , }; return ( diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/SingboxServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/SingboxServiceTab.tsx new file mode 100644 index 000000000000..633631c41808 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/SingboxServiceTab.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ServiceStatusCard } from "../components/ServiceStatusCard"; +import { ServiceLifecycleButtons } from "../components/ServiceLifecycleButtons"; +import { ServiceLogsPanel } from "../components/ServiceLogsPanel"; +import { AutoStartToggle } from "../components/AutoStartToggle"; +import { AutoRestartAdoptedToggle } from "../components/AutoRestartAdoptedToggle"; + +const NAME = "singbox"; + +export function SingboxServiceTab() { + return ( +
+ + + + + +
+ ); +} diff --git a/src/app/api/services/singbox/_lib.ts b/src/app/api/services/singbox/_lib.ts new file mode 100644 index 000000000000..ed971ddda93e --- /dev/null +++ b/src/app/api/services/singbox/_lib.ts @@ -0,0 +1,33 @@ +/** + * Shared helpers for /api/services/singbox/* route handlers. + * Creates a supervisor on demand if bootstrap hasn't registered one yet. + */ + +import { getSupervisor, registerSupervisor } from "@/lib/services/registry"; +import { ServiceSupervisor } from "@/lib/services/ServiceSupervisor"; +import { resolveSpawnArgs, SINGBOX_DEFAULT_PORT } from "@/lib/services/installers/singbox"; + +const TOOL = "singbox"; +const PORT = parseInt(process.env.SINGBOX_PORT ?? String(SINGBOX_DEFAULT_PORT), 10); + +export async function getOrInitSupervisor(): Promise { + const existing = getSupervisor(TOOL); + if (existing) return existing; + + const sup = new ServiceSupervisor({ + tool: TOOL, + port: PORT, + spawnArgs: () => resolveSpawnArgs(PORT), + healthUrl: () => `http://127.0.0.1:${PORT}/`, + healthIntervalMs: 5_000, + stopTimeoutMs: 15_000, + logsBufferBytes: 5_242_880, + // #6205: mirrors bootstrap.ts's own supervisor construction — adopt a + // healthy prior instance instead of crashing on-demand creation (e.g. a + // direct API hit before bootstrap runs) into a raw EADDRINUSE. + probeBeforeSpawn: true, + }); + + registerSupervisor(sup); + return sup; +} diff --git a/src/app/api/services/singbox/auto-restart-adopted/route.ts b/src/app/api/services/singbox/auto-restart-adopted/route.ts new file mode 100644 index 000000000000..b12d8c75e5ca --- /dev/null +++ b/src/app/api/services/singbox/auto-restart-adopted/route.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ status: 400, message: parsed.error.message }); + } + + try { + await updateServiceField("singbox", "autoRestartAdopted", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/singbox/auto-start/route.ts b/src/app/api/services/singbox/auto-start/route.ts new file mode 100644 index 000000000000..c5d8ab5af9f4 --- /dev/null +++ b/src/app/api/services/singbox/auto-start/route.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ status: 400, message: parsed.error.message }); + } + + try { + await updateServiceField("singbox", "autoStart", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/singbox/install/route.ts b/src/app/api/services/singbox/install/route.ts new file mode 100644 index 000000000000..f87380cff41b --- /dev/null +++ b/src/app/api/services/singbox/install/route.ts @@ -0,0 +1,6 @@ +import { install } from "@/lib/services/installers/singbox"; +import { handleServiceInstall } from "@/app/api/services/_shared/installRoute"; + +export async function POST(request: Request): Promise { + return handleServiceInstall(request, install); +} diff --git a/src/app/api/services/singbox/restart/route.ts b/src/app/api/services/singbox/restart/route.ts new file mode 100644 index 000000000000..1888dd4b2189 --- /dev/null +++ b/src/app/api/services/singbox/restart/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "singbox"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "sing-box não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.restart(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/singbox/start/route.ts b/src/app/api/services/singbox/start/route.ts new file mode 100644 index 000000000000..813fc557a3b4 --- /dev/null +++ b/src/app/api/services/singbox/start/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "singbox"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "sing-box não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.start(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/singbox/status/route.ts b/src/app/api/services/singbox/status/route.ts new file mode 100644 index 000000000000..ab4a28a94b99 --- /dev/null +++ b/src/app/api/services/singbox/status/route.ts @@ -0,0 +1,81 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getServiceRow } from "@/lib/db/versionManager"; +import { + getInstalledVersion, + getLatestVersion, + SINGBOX_DEFAULT_PORT, +} from "@/lib/services/installers/singbox"; +import type { ServiceStatus } from "@/lib/services/types"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "singbox"; + +type ServiceRow = Awaited>; + +// The status body is assembled from small helpers so no single function carries +// every `??` fallback: live supervisor status wins, then the version_manager row, +// then the static default. + +function runtimeFields(live: ServiceStatus | null) { + if (!live) return { pid: null, health: "unknown", startedAt: null, adopted: false }; + return { + pid: live.pid ?? null, + health: live.health ?? "unknown", + startedAt: live.startedAt ?? null, + adopted: live.adopted ?? false, + }; +} + +function mergedFields(live: ServiceStatus | null, row: ServiceRow) { + return { + state: live?.state ?? row?.status ?? "unknown", + port: live?.port ?? row?.port ?? SINGBOX_DEFAULT_PORT, + lastError: live?.lastError ?? row?.errorMessage ?? null, + }; +} + +function versionFields(installed: string | null, latest: string | null, row: ServiceRow) { + return { + installedVersion: installed ?? row?.installedVersion ?? null, + latestVersion: latest, + updateAvailable: !!installed && !!latest && installed !== latest, + }; +} + +function settingsFields(row: ServiceRow) { + return { + autoStart: row?.autoStart ?? false, + autoRestartAdopted: row?.autoRestartAdopted ?? false, + }; +} + +export async function GET(): Promise { + try { + const liveStatus = getSupervisor(TOOL)?.getStatus() ?? null; + const row = await getServiceRow(TOOL); + const runtime = runtimeFields(liveStatus); + const merged = mergedFields(liveStatus, row); + const versions = versionFields(await getInstalledVersion(), await getLatestVersion(), row); + const settings = settingsFields(row); + + return Response.json({ + tool: TOOL, + state: merged.state, + pid: runtime.pid, + port: merged.port, + health: runtime.health, + startedAt: runtime.startedAt, + lastError: merged.lastError, + installedVersion: versions.installedVersion, + latestVersion: versions.latestVersion, + updateAvailable: versions.updateAvailable, + autoStart: settings.autoStart, + adopted: runtime.adopted, + autoRestartAdopted: settings.autoRestartAdopted, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/singbox/stop/route.ts b/src/app/api/services/singbox/stop/route.ts new file mode 100644 index 000000000000..cccf9058abb3 --- /dev/null +++ b/src/app/api/services/singbox/stop/route.ts @@ -0,0 +1,19 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "singbox"; + +export async function POST(): Promise { + try { + const sup = getSupervisor(TOOL); + if (!sup) { + return Response.json({ tool: TOOL, state: "stopped" }); + } + const status = await sup.stop(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/singbox/update/route.ts b/src/app/api/services/singbox/update/route.ts new file mode 100644 index 000000000000..339dc9449288 --- /dev/null +++ b/src/app/api/services/singbox/update/route.ts @@ -0,0 +1,45 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getOrInitSupervisor } from "../_lib"; +import { + getInstalledVersion, + getLatestVersion, + update as downloadUpdate, +} from "@/lib/services/installers/singbox"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export async function POST(): Promise { + try { + const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]); + + if (installed && latest && installed === latest) { + return Response.json({ updated: false, installedVersion: installed, latestVersion: latest }); + } + + const sup = getSupervisor("singbox"); + const wasRunning = sup?.getStatus().state === "running"; + + if (wasRunning && sup) { + await sup.stop(); + } + + const result = await downloadUpdate(); + + if (wasRunning) { + const freshSup = await getOrInitSupervisor(); + await freshSup.start().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[Services] Could not restart singbox after update:", msg); + }); + } + + return Response.json({ + updated: true, + oldVersion: installed ?? null, + newVersion: result.installedVersion, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/lib/db/migrations/221_singbox_service_seed.sql b/src/lib/db/migrations/221_singbox_service_seed.sql new file mode 100644 index 000000000000..dfc7d7c36e87 --- /dev/null +++ b/src/lib/db/migrations/221_singbox_service_seed.sql @@ -0,0 +1,10 @@ +-- Migration 221: Seed version_manager row for sing-box embedded service +-- +-- sing-box is a supervised TPROXY sidecar (see src/lib/services/installers/singbox.ts), +-- not an LLM provider, so provider_expose stays 0 (never exposes models for routing) and +-- auto_update stays 0 (installer only supports the pinned, checksum-verified release — +-- see SINGBOX_PINNED_VERSION). The row is seeded with status='not_installed' so the +-- bootstrap loop skips it until the user installs via /api/services/singbox/install. + +INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose) +VALUES ('singbox', 'not_installed', 20140, 0, 0, 0); diff --git a/src/lib/services/bootstrap.ts b/src/lib/services/bootstrap.ts index 51dbffbf9331..f7afdb4b9865 100644 --- a/src/lib/services/bootstrap.ts +++ b/src/lib/services/bootstrap.ts @@ -17,6 +17,7 @@ import { resolveSpawnArgs as llmlinguaSpawnArgs, LLMLINGUA_DEFAULT_PORT, } from "./installers/llmlingua"; +import { resolveSpawnArgs as singboxSpawnArgs, SINGBOX_DEFAULT_PORT } from "./installers/singbox"; import { getOrCreateApiKey } from "./apiKey"; import { scheduleServiceModelSync, stopServiceModelSync } from "./modelSync"; import type { ServiceStatus } from "./types"; @@ -41,6 +42,7 @@ const BIFROST_PORT = parseInt(process.env.BIFROST_PORT ?? String(BIFROST_DEFAULT const DARIO_PORT = parseInt(process.env.DARIO_PORT ?? String(DARIO_DEFAULT_PORT), 10); const OPENWA_PORT = parseInt(process.env.OPENWA_SERVICE_PORT ?? String(OPENWA_DEFAULT_PORT), 10); const LLMLINGUA_PORT = parseInt(process.env.LLMLINGUA_PORT ?? String(LLMLINGUA_DEFAULT_PORT), 10); +const SINGBOX_PORT = parseInt(process.env.SINGBOX_PORT ?? String(SINGBOX_DEFAULT_PORT), 10); type ServiceEntry = { tool: string; @@ -145,6 +147,15 @@ const SERVICES: ServiceEntry[] = [ logsBufferBytes: 5_242_880, needsApiKey: false, }, + { + tool: "singbox", + port: SINGBOX_PORT, + healthPath: "/", + healthIntervalMs: 5_000, + stopTimeoutMs: 15_000, + logsBufferBytes: 5_242_880, + needsApiKey: false, + }, ]; function buildSpawnArgsFactory( @@ -169,6 +180,9 @@ function buildSpawnArgsFactory( if (cfg.tool === "llmlingua") { return () => llmlinguaSpawnArgs(cfg.port); } + if (cfg.tool === "singbox") { + return () => singboxSpawnArgs(cfg.port); + } return () => cliproxySpawnArgs(cfg.port, apiKey); } diff --git a/src/lib/services/installers/singbox.ts b/src/lib/services/installers/singbox.ts new file mode 100644 index 000000000000..e26c10219b6e --- /dev/null +++ b/src/lib/services/installers/singbox.ts @@ -0,0 +1,357 @@ +/** + * sing-box installer adapter for the ServiceSupervisor framework. + * + * sing-box is a universal proxy platform that natively provides Linux TPROXY, + * REDIRECT, and TUN interfaces with zero-copy TCP/UDP transparent proxying. + * Running sing-box as an embedded service provides transparent network + * interception for IDEs without requiring native C N-API compilation or + * root node-gyp build steps on the main application. + * + * `install()` downloads the real, official sing-box release binary from + * GitHub (https://github.com/SagerNet/sing-box/releases) and verifies its + * SHA256 against a checksum pinned in {@link SINGBOX_CHECKSUMS} before ever + * writing it to disk or letting the supervisor spawn it — there is no mock + * binary path. Only {@link SINGBOX_PINNED_VERSION} has a known-good checksum; + * any other requested version is rejected rather than downloaded unverified. + * + * Binary location: $DATA_DIR/services/singbox/sing-box + * Config location: $DATA_DIR/services/singbox/config.json + * DB row: version_manager WHERE tool = 'singbox' + */ + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { pipeline } from "node:stream/promises"; +import { DATA_DIR } from "@/lib/db/core"; +import { upsertVersionManagerTool } from "@/lib/db/versionManager"; +import { getTargetPlatform, buildExtractZipCommand } from "@/lib/versionManager/binaryManager.ts"; +import { InstallError } from "./utils"; + +const execFileAsync = promisify(execFile); + +export const SINGBOX_DEFAULT_PORT = 20140; +export const SINGBOX_INSTALL_DIR = path.join(DATA_DIR, "services", "singbox"); + +/** + * Pinned sing-box release (https://github.com/SagerNet/sing-box/releases/tag/v1.14.1). + * Only this version has a verified checksum in {@link SINGBOX_CHECKSUMS} below — + * `install()` refuses any other version rather than downloading an unverified + * binary. Bump both together after manually downloading the new release and + * recording its published SHA256 for every supported platform/arch. + */ +export const SINGBOX_PINNED_VERSION = "1.14.1"; + +/** + * SHA256 of the official `sing-box---.{tar.gz,zip}` + * release asset, keyed by `-`. Verified 2026-09-16 against + * the GitHub Releases API `assets[].digest` field and by downloading and + * hashing the linux-amd64 and darwin-arm64 archives locally. + */ +export const SINGBOX_CHECKSUMS: Record = { + "linux-amd64": "12cb2816b52febb356f6a885b740cc8758c3f30b8ae0ca8edba80f0d2d35343f", + "linux-arm64": "6060b42fa84c5dcaeae1799af7f61b0f1ae4855d9d5ddc9e02baba17154b3ae2", + "darwin-amd64": "b34381b047106fe84895df14f7aaae06f3182130b728006944deb0d59d8590c3", + "darwin-arm64": "b9024642ef7b4848252df5469b7f60ef3c18bb5e217a16a0934f0174f8ad11b4", + "windows-amd64": "5197f16d492d93202dc623622149a6ed040f8eca263128f91d603f2b901baa89", +}; + +export interface InstallResult { + installedVersion: string; + installPath: string; + durationMs: number; +} + +export interface SpawnArgs { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; +} + +function getSingboxInstallDir(): string { + return process.env.DATA_DIR + ? path.join(process.env.DATA_DIR, "services", "singbox") + : SINGBOX_INSTALL_DIR; +} + +export function getBinPath(): string { + const dir = getSingboxInstallDir(); + const binName = process.platform === "win32" ? "sing-box.exe" : "sing-box"; + return path.join(dir, binName); +} + +export function getConfigPath(): string { + return path.join(getSingboxInstallDir(), "config.json"); +} + +/** + * Picks the port for sing-box's tproxy inbound (its mixed inbound takes +1) so that + * NEITHER ever equals `reservedPort` — the port the native IP_TRANSPARENT listener + * binds and the PREROUTING `TPROXY --on-port` rule targets. Two listeners on that + * port would make one of them fail with EADDRINUSE or let sing-box swallow the + * traffic the MITM is meant to receive. + */ +export function resolveSingboxListenPort( + reservedPort: number, + basePort = SINGBOX_DEFAULT_PORT +): number { + let port = basePort; + while (port === reservedPort || port + 1 === reservedPort) port += 2; + return port; +} + +/** + * @param tproxyPort listen port of the tproxy inbound (the mixed inbound uses +1) + * @param bypassMark SO_MARK stamped on every outbound (`routing_mark`). It MUST be the + * mark the `mangle OUTPUT` rule excludes (`! --mark `), otherwise + * sing-box's own egress is re-marked and re-TPROXY'd in a loop. + */ +export function generateDefaultSingboxConfig( + tproxyPort = SINGBOX_DEFAULT_PORT, + bypassMark?: number +): Record { + return { + log: { + level: "warn", + timestamp: true, + }, + inbounds: [ + { + type: "tproxy", + tag: "tproxy-in", + listen: "127.0.0.1", + listen_port: tproxyPort, + sniff: true, + }, + { + type: "mixed", + tag: "mixed-in", + listen: "127.0.0.1", + listen_port: tproxyPort + 1, + }, + ], + outbounds: [ + { + type: "direct", + tag: "direct", + ...(bypassMark !== undefined ? { routing_mark: bypassMark } : {}), + }, + ], + }; +} + +export async function getInstalledVersion(): Promise { + try { + const manifestPath = path.join(getSingboxInstallDir(), "package.json"); + if (!fs.existsSync(manifestPath)) return null; + const raw = fs.readFileSync(manifestPath, "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return typeof parsed.version === "string" ? parsed.version : null; + } catch { + return null; + } +} + +export async function getLatestVersion(): Promise { + return SINGBOX_PINNED_VERSION; +} + +function assetPlatformArch(): { + platform: "linux" | "darwin" | "windows"; + arch: "amd64" | "arm64"; +} { + const { platform, arch } = getTargetPlatform(); + if (platform === "freebsd") { + throw new InstallError( + `sing-box has no pinned checksum for platform "${platform}"`, + "sing-box não tem checksum verificado para esta plataforma (FreeBSD).", + 500 + ); + } + return { platform, arch }; +} + +function assetFileName(version: string, platform: string, arch: string): string { + const ext = platform === "windows" ? "zip" : "tar.gz"; + return `sing-box-${version}-${platform}-${arch}.${ext}`; +} + +async function downloadToFile(url: string, dest: string): Promise { + const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!res.ok || !res.body) { + throw new InstallError( + `sing-box download failed: HTTP ${res.status} for ${url}`, + "Falha ao baixar o binário do sing-box. Verifique a conexão e tente novamente.", + 503 + ); + } + await pipeline(res.body as unknown as NodeJS.ReadableStream, fs.createWriteStream(dest)); +} + +async function sha256File(filePath: string): Promise { + const hash = crypto.createHash("sha256"); + await new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath); + stream.on("data", (chunk: Buffer) => hash.update(chunk)); + stream.on("end", resolve); + stream.on("error", reject); + }); + return hash.digest("hex"); +} + +async function extractArchive( + archivePath: string, + destDir: string, + platform: string +): Promise { + if (platform === "windows") { + const { command, args } = buildExtractZipCommand("win32", archivePath, destDir); + await execFileAsync(command, args); + } else { + await execFileAsync("tar", ["xzf", archivePath, "-C", destDir]); + } +} + +/** + * Downloads the pinned sing-box release, verifies its SHA256 against + * {@link SINGBOX_CHECKSUMS}, extracts it and returns the path to the real + * `sing-box`/`sing-box.exe` binary inside the extracted archive. + */ +async function downloadRealBinary( + version: string, + workDir: string, + checksums: Record +): Promise { + const { platform, arch } = assetPlatformArch(); + const key = `${platform}-${arch}`; + const expectedSha256 = checksums[key]; + if (!expectedSha256) { + throw new InstallError( + `No pinned sing-box checksum for ${key} (only ${Object.keys(checksums).join(", ")} are verified)`, + `sing-box não tem checksum verificado para ${key}.`, + 500 + ); + } + if (version !== SINGBOX_PINNED_VERSION) { + throw new InstallError( + `sing-box version "${version}" has no pinned checksum — only ${SINGBOX_PINNED_VERSION} is verified`, + `Apenas a versão fixada do sing-box (${SINGBOX_PINNED_VERSION}) tem checksum verificado.`, + 400 + ); + } + + fs.mkdirSync(workDir, { recursive: true }); + const fileName = assetFileName(version, platform, arch); + const archivePath = path.join(workDir, fileName); + const url = `https://github.com/SagerNet/sing-box/releases/download/v${version}/${fileName}`; + + await downloadToFile(url, archivePath); + + const actualSha256 = await sha256File(archivePath); + if (actualSha256.toLowerCase() !== expectedSha256.toLowerCase()) { + fs.unlinkSync(archivePath); + throw new InstallError( + `sing-box checksum mismatch for ${fileName}: expected ${expectedSha256}, got ${actualSha256}`, + "Falha na verificação de integridade do binário do sing-box baixado.", + 500 + ); + } + + const extractDir = path.join(workDir, `sing-box-${version}-${platform}-${arch}`); + fs.rmSync(extractDir, { recursive: true, force: true }); + await extractArchive(archivePath, workDir, platform); + fs.unlinkSync(archivePath); + + const binName = platform === "windows" ? "sing-box.exe" : "sing-box"; + const binaryPath = path.join(extractDir, binName); + if (!fs.existsSync(binaryPath)) { + throw new InstallError( + `sing-box binary not found at ${binaryPath} after extraction`, + "Binário do sing-box não encontrado após a extração do pacote.", + 500 + ); + } + return binaryPath; +} + +export async function install(version = SINGBOX_PINNED_VERSION): Promise { + return installWithChecksums(version, SINGBOX_CHECKSUMS); +} + +/** + * `install()` with an explicit checksum table. Exists so tests can drive the full + * download → verify → extract → register path against a locally built archive; + * no route or caller outside tests passes anything but {@link SINGBOX_CHECKSUMS}. + */ +export async function installWithChecksums( + version: string, + checksums: Record +): Promise { + const startMs = Date.now(); + // The shared install route defaults an omitted body to the literal string + // "latest" (see handleServiceInstall) — resolve that to the one pinned, + // checksum-verified release so a plain POST with no body still works. + // Any OTHER explicit version string still hits the pinned-checksum guard + // in downloadRealBinary() and is rejected. + const resolvedVersion = version === "latest" ? SINGBOX_PINNED_VERSION : version; + const installDir = getSingboxInstallDir(); + fs.mkdirSync(installDir, { recursive: true }); + + const downloadedBinary = await downloadRealBinary(resolvedVersion, installDir, checksums); + const binPath = getBinPath(); + fs.copyFileSync(downloadedBinary, binPath); + fs.chmodSync(binPath, 0o755); + fs.rmSync(path.dirname(downloadedBinary), { recursive: true, force: true }); + + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, JSON.stringify(generateDefaultSingboxConfig(), null, 2), "utf8"); + } + + const manifestPath = path.join(installDir, "package.json"); + fs.writeFileSync( + manifestPath, + JSON.stringify( + { name: "omniroute-singbox-host", version: resolvedVersion, private: true }, + null, + 2 + ), + "utf8" + ); + + await upsertVersionManagerTool({ + tool: "singbox", + installedVersion: resolvedVersion, + binaryPath: binPath, + status: "stopped", + port: SINGBOX_DEFAULT_PORT, + }); + + return { + installedVersion: resolvedVersion, + installPath: installDir, + durationMs: Date.now() - startMs, + }; +} + +export async function update(): Promise { + return install(SINGBOX_PINNED_VERSION); +} + +export function resolveSpawnArgs(port: number): SpawnArgs { + const binPath = getBinPath(); + const configPath = getConfigPath(); + + return { + command: binPath, + args: ["run", "-c", configPath], + env: { + ...process.env, + SINGBOX_PORT: String(port), + }, + cwd: getSingboxInstallDir(), + }; +} diff --git a/src/mitm/tproxy/setup.ts b/src/mitm/tproxy/setup.ts index 6089d4753943..4e430d0f9c9c 100644 --- a/src/mitm/tproxy/setup.ts +++ b/src/mitm/tproxy/setup.ts @@ -16,6 +16,15 @@ */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import fs from "node:fs"; +import { getSupervisor } from "@/lib/services/registry"; +import { + generateDefaultSingboxConfig, + getConfigPath, + resolveSingboxListenPort, + SINGBOX_DEFAULT_PORT, +} from "@/lib/services/installers/singbox"; +import { createLogger } from "@/shared/utils/logger.ts"; import { buildTproxyApplyCommands, buildTproxyRevertCommands, @@ -24,6 +33,7 @@ import { } from "./commands"; const execFileAsync = promisify(execFile); +const log = createLogger("mitm-tproxy-singbox"); /** Runs a single command. Injected in tests; defaults to execFile (no shell). */ export type CommandRunner = (bin: string, args: string[]) => Promise; @@ -37,10 +47,15 @@ const defaultRunner: CommandRunner = async (bin, args) => { * fails, runs a best-effort full revert (so a half-applied rule set never * lingers) and rethrows the original error. */ -export async function applyTproxy(cfg: TproxyConfig, run: CommandRunner = defaultRunner): Promise { +export async function applyTproxy( + cfg: TproxyConfig, + run: CommandRunner = defaultRunner +): Promise { const invalid = validateTproxyConfig(cfg); if (invalid) throw new Error(invalid); + await ensureSingboxTproxy(cfg); + try { for (const cmd of buildTproxyApplyCommands(cfg)) { await run(cmd.bin, cmd.args); @@ -57,7 +72,10 @@ export async function applyTproxy(cfg: TproxyConfig, run: CommandRunner = defaul * prior crash) — those failures are swallowed so a clean teardown always runs * to completion. Safe for `repairMitm()` to call unconditionally. */ -export async function revertTproxy(cfg: TproxyConfig, run: CommandRunner = defaultRunner): Promise { +export async function revertTproxy( + cfg: TproxyConfig, + run: CommandRunner = defaultRunner +): Promise { for (const cmd of buildTproxyRevertCommands(cfg)) { try { await run(cmd.bin, cmd.args); @@ -66,3 +84,70 @@ export async function revertTproxy(cfg: TproxyConfig, run: CommandRunner = defau } } } + +/** + * Starts (or confirms) the sing-box supervisor before TPROXY rules are applied. + * Never throws — `applyTproxy()` always proceeds to install the firewall rules + * regardless of the outcome (pre-existing behavior, `tests/unit/tproxy-setup.test.ts` + * exercises `applyTproxy()` with no sing-box supervisor registered at all) — but + * every failure path logs at error level instead of being swallowed by a bare + * `.catch(() => false)`, so an operator has a visible signal that TPROXY rules were + * installed without a working sing-box listener behind them. + * + * Coexistence with the native IP_TRANSPARENT listener (`cfg.onPort`): + * - sing-box listens on its own port (never `cfg.onPort`, see `resolveSingboxListenPort`); + * - every sing-box outbound is SO_MARK'd with `cfg.bypassMark`, the mark the OUTPUT + * rule excludes. Without a `bypassMark` that exclusion does not exist, so sing-box is + * not started at all rather than looping its own egress. + * Covered by `tests/unit/singbox-tproxy-coexistence.test.ts`. + */ +export async function ensureSingboxTproxy(cfg: TproxyConfig): Promise { + const supervisor = getSupervisor("singbox"); + if (!supervisor) { + log.error( + "sing-box supervisor is not registered — TPROXY rules will be applied with no proxy " + + "listening behind them; traffic will NOT actually be intercepted" + ); + return false; + } + + if (cfg.bypassMark === undefined) { + log.error( + "TPROXY config has no bypassMark — the OUTPUT rule cannot exclude sing-box's own egress, " + + "so sing-box was NOT started (it would loop its own traffic). Set bypassMark to enable it." + ); + return false; + } + + try { + const basePort = parseInt(process.env.SINGBOX_PORT ?? String(SINGBOX_DEFAULT_PORT), 10); + const listenPort = resolveSingboxListenPort(cfg.onPort, basePort); + const cfgContent = JSON.stringify( + generateDefaultSingboxConfig(listenPort, cfg.bypassMark), + null, + 2 + ); + fs.writeFileSync(getConfigPath(), cfgContent, "utf8"); + + if (supervisor.getStatus().state !== "running") { + await supervisor.start(); + } + } catch (err) { + log.error( + { err: err instanceof Error ? err.message : String(err) }, + "sing-box failed to start — TPROXY rules will be applied with no proxy listening " + + "behind them; traffic will NOT actually be intercepted" + ); + return false; + } + + const running = supervisor.getStatus().state === "running"; + if (!running) { + log.error( + { state: supervisor.getStatus().state }, + "sing-box did not reach the running state after start() — TPROXY rules will be applied " + + "with no proxy listening behind them; traffic will NOT actually be intercepted" + ); + } + return running; +} diff --git a/tests/integration/services/full-lifecycle.int.test.ts b/tests/integration/services/full-lifecycle.int.test.ts index 24e8b928cc96..d8966b819676 100644 --- a/tests/integration/services/full-lifecycle.int.test.ts +++ b/tests/integration/services/full-lifecycle.int.test.ts @@ -247,6 +247,60 @@ describe("cliproxy — full lifecycle (opt-in, RUN_SERVICES_INT=1)", () => { }); }); +// --------------------------------------------------------------------------- +// sing-box lifecycle +// --------------------------------------------------------------------------- + +describe("singbox — full lifecycle (opt-in, RUN_SERVICES_INT=1)", () => { + it("STEP 1: install singbox (pinned version)", async (t) => { + if (maybeSkip(t)) return; + const { status, body } = await apiPost("/api/services/singbox/install", { + version: "1.14.1", + }); + assert.ok( + status === 200, + `Expected 200 from install, got ${status}: ${JSON.stringify(body).slice(0, 300)}` + ); + const b = body as Record; + assert.ok(b.ok === true, "install response must have ok:true"); + assert.equal(b.installedVersion, "1.14.1"); + }); + + it("STEP 2: start singbox", async (t) => { + if (maybeSkip(t)) return; + const { status, body } = await apiPost("/api/services/singbox/start"); + assert.ok( + status === 200, + `Expected 200 from start, got ${status}: ${JSON.stringify(body).slice(0, 300)}` + ); + }); + + it("STEP 3: wait for the real sing-box binary process to be running (≤30s)", async (t) => { + if (maybeSkip(t)) return; + // sing-box's inbounds are TPROXY/mixed sockets, not a plain HTTP health + // endpoint, so — unlike cliproxy/bifrost above — this only asserts the + // supervised process reaches "running" with a real pid, proving the + // downloaded binary actually starts (the old mock script exited + // immediately and never reached this state). + const finalStatus = await waitForState("/api/services/singbox/status", "running", 30_000); + const b = finalStatus as Record; + assert.equal(b.state, "running"); + assert.ok(typeof b.pid === "number", "pid must be a number when running"); + }); + + it("STEP 4: stop singbox", async (t) => { + if (maybeSkip(t)) return; + const { status } = await apiPost("/api/services/singbox/stop"); + assert.equal(status, 200); + }); + + it("STEP 5: status returns stopped after stop", async (t) => { + if (maybeSkip(t)) return; + const final = await waitForState("/api/services/singbox/status", "stopped", 15_000); + assert.equal((final as Record).state, "stopped"); + }); +}); + // --------------------------------------------------------------------------- // bifrost lifecycle // --------------------------------------------------------------------------- diff --git a/tests/unit/api/services/singbox-status-route.test.ts b/tests/unit/api/services/singbox-status-route.test.ts new file mode 100644 index 000000000000..7a25840d65db --- /dev/null +++ b/tests/unit/api/services/singbox-status-route.test.ts @@ -0,0 +1,119 @@ +/** + * GET /api/services/singbox/status — response contract. + * + * Pins the field-by-field precedence (live supervisor status first, then the + * version_manager row, then the static default) so the handler can be split into + * small helpers without changing the JSON the Services dashboard tab reads. + */ +import { describe, it, before, after, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-singbox-status-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../../src/lib/db/core.ts"); +const { upsertVersionManagerTool } = await import("../../../../src/lib/db/versionManager.ts"); +const { registerSupervisor, unregisterSupervisor } = + await import("../../../../src/lib/services/registry.ts"); +const { SINGBOX_DEFAULT_PORT, SINGBOX_PINNED_VERSION } = + await import("../../../../src/lib/services/installers/singbox.ts"); +const { GET } = await import("../../../../src/app/api/services/singbox/status/route.ts"); + +type Registered = Parameters[0]; + +function fakeSupervisor(status: Record): Registered { + return { getStatus: () => ({ tool: "singbox", ...status }) } as unknown as Registered; +} + +function writeInstalledManifest(version: string): void { + const dir = path.join(TEST_DATA_DIR, "services", "singbox"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ version })); +} + +describe("GET /api/services/singbox/status", () => { + before(async () => { + await upsertVersionManagerTool({ + tool: "singbox", + status: "stopped", + port: 20999, + autoStart: true, + installedVersion: "1.0.0-row", + errorMessage: "row error", + }); + }); + + afterEach(() => { + unregisterSupervisor("singbox"); + fs.rmSync(path.join(TEST_DATA_DIR, "services"), { recursive: true, force: true }); + }); + + after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + }); + + it("falls back to the version_manager row when no supervisor is registered", async () => { + const res = await GET(); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + tool: "singbox", + state: "stopped", + pid: null, + port: 20999, + health: "unknown", + startedAt: null, + lastError: "row error", + installedVersion: "1.0.0-row", + latestVersion: SINGBOX_PINNED_VERSION, + updateAvailable: false, + autoStart: true, + adopted: false, + autoRestartAdopted: false, + }); + }); + + it("prefers the live supervisor status and the on-disk installed version", async () => { + registerSupervisor( + fakeSupervisor({ + state: "running", + pid: 4321, + port: SINGBOX_DEFAULT_PORT, + health: "healthy", + startedAt: "2026-10-28T00:00:00.000Z", + lastError: "live error", + adopted: true, + }) + ); + writeInstalledManifest("1.13.0"); + + const res = await GET(); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + tool: "singbox", + state: "running", + pid: 4321, + port: SINGBOX_DEFAULT_PORT, + health: "healthy", + startedAt: "2026-10-28T00:00:00.000Z", + lastError: "live error", + installedVersion: "1.13.0", + latestVersion: SINGBOX_PINNED_VERSION, + updateAvailable: true, + autoStart: true, + adopted: true, + autoRestartAdopted: false, + }); + }); + + it("reports no update when the on-disk version is the pinned release", async () => { + writeInstalledManifest(SINGBOX_PINNED_VERSION); + const body = (await (await GET()).json()) as Record; + assert.equal(body.installedVersion, SINGBOX_PINNED_VERSION); + assert.equal(body.updateAvailable, false); + }); +}); diff --git a/tests/unit/services/installers/singbox.test.ts b/tests/unit/services/installers/singbox.test.ts new file mode 100644 index 000000000000..bc576f91f5d4 --- /dev/null +++ b/tests/unit/services/installers/singbox.test.ts @@ -0,0 +1,246 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-singbox-installer-")); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +// DB bootstrap +const core = await import("../../../../src/lib/db/core.ts"); +const db = core.getDbInstance(); +db.prepare( + `INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose) + VALUES ('singbox', 'not_installed', 20140, 0, 0, 0)` +).run(); + +const singbox = await import("../../../../src/lib/services/installers/singbox.ts"); + +test("singbox installer: getInstalledVersion returns null when not installed", async () => { + const version = await singbox.getInstalledVersion(); + assert.equal(version, null); +}); + +test("singbox installer: resolveSpawnArgs builds executable command", () => { + const spawnArgs = singbox.resolveSpawnArgs(20140); + assert.equal(spawnArgs.command, singbox.getBinPath()); + assert.deepEqual(spawnArgs.args, ["run", "-c", singbox.getConfigPath()]); + assert.equal(spawnArgs.env.SINGBOX_PORT, "20140"); +}); + +test("singbox installer: generateDefaultSingboxConfig wires a tproxy inbound on the given port", () => { + const cfg = singbox.generateDefaultSingboxConfig(31000) as { + inbounds: Array<{ type: string; listen_port: number }>; + }; + const tproxyInbound = cfg.inbounds.find((i) => i.type === "tproxy"); + assert.ok(tproxyInbound, "config must declare a tproxy inbound"); + assert.equal(tproxyInbound?.listen_port, 31000); +}); + +test("singbox installer: install() refuses a version with no pinned checksum, without ever downloading", async () => { + let fetchCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetchCalled = true; + throw new Error("must not be called for an unpinned version"); + }) as typeof fetch; + + try { + await assert.rejects( + () => singbox.install("9.9.9-not-pinned"), + /no pinned checksum|checksum verificado/i + ); + assert.equal(fetchCalled, false, "the pinned-version guard must run before any network call"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("singbox installer: install() rejects the pinned version when the downloaded archive fails checksum verification", async () => { + const originalFetch = globalThis.fetch; + const fakeArchiveBytes = Buffer.from("definitely not the real sing-box release archive"); + globalThis.fetch = (async () => new Response(fakeArchiveBytes, { status: 200 })) as typeof fetch; + + try { + await assert.rejects( + () => singbox.install(singbox.SINGBOX_PINNED_VERSION), + /checksum mismatch/i + ); + // no partial/tampered binary must ever be written or registered as installed + assert.equal(fs.existsSync(singbox.getBinPath()), false); + assert.equal(await singbox.getInstalledVersion(), null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("singbox installer: install() surfaces a download failure instead of silently installing a mock binary", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(null, { status: 503 })) as typeof fetch; + + try { + await assert.rejects( + () => singbox.install(singbox.SINGBOX_PINNED_VERSION), + /download failed|falha ao baixar/i + ); + assert.equal(fs.existsSync(singbox.getBinPath()), false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── Stubbed-downloader contract tests ───────────────────────────────────────────── +// The archive layout and URL below follow the official SagerNet release assets: +// https://github.com/SagerNet/sing-box/releases/download/v/sing-box---.tar.gz +// containing sing-box---/sing-box (+ LICENSE). +// This proves our code matches that SHAPE with a locally built archive; it does NOT prove +// GitHub still serves that layout, nor that the pinned SHA256s match today's assets. + +const hostPlatform = process.platform === "win32" ? "windows" : process.platform; +const hostArch = process.arch === "arm64" ? "arm64" : "amd64"; +const PLATFORM_KEY = `${hostPlatform}-${hostArch}`; +const POSIX_HOST = process.platform === "linux" || process.platform === "darwin"; + +function buildReleaseArchive(opts: { withBinary: boolean }): { bytes: Buffer; sha256: string } { + const version = singbox.SINGBOX_PINNED_VERSION; + const stage = fs.mkdtempSync(path.join(os.tmpdir(), "singbox-fixture-")); + const topDir = `sing-box-${version}-${hostPlatform}-${hostArch}`; + fs.mkdirSync(path.join(stage, topDir)); + fs.writeFileSync(path.join(stage, topDir, "LICENSE"), "fixture license\n"); + if (opts.withBinary) { + fs.writeFileSync(path.join(stage, topDir, "sing-box"), "#!/bin/sh\n# fixture archive entry\n"); + } + const archive = path.join(stage, "release.tar.gz"); + execFileSync("tar", ["czf", archive, "-C", stage, topDir]); + const bytes = fs.readFileSync(archive); + fs.rmSync(stage, { recursive: true, force: true }); + return { bytes, sha256: crypto.createHash("sha256").update(bytes).digest("hex") }; +} + +function stubFetch(bytes: Buffer) { + const urls: string[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + urls.push(String(input)); + return new Response(bytes, { status: 200 }); + }) as typeof fetch; + return { urls, restore: () => (globalThis.fetch = original) }; +} + +test("singbox installer: pinned checksum table is well-formed and covers every release platform", () => { + assert.match(singbox.SINGBOX_PINNED_VERSION, /^\d+\.\d+\.\d+$/); + assert.deepEqual(Object.keys(singbox.SINGBOX_CHECKSUMS).sort(), [ + "darwin-amd64", + "darwin-arm64", + "linux-amd64", + "linux-arm64", + "windows-amd64", + ]); + for (const [key, sum] of Object.entries(singbox.SINGBOX_CHECKSUMS)) { + assert.match(sum, /^[0-9a-f]{64}$/, `${key} must be a lowercase SHA256 hex digest`); + } +}); + +test( + "singbox installer: install() rejects an archive that has no sing-box binary after extraction", + { + skip: !POSIX_HOST, + }, + async () => { + const { bytes, sha256 } = buildReleaseArchive({ withBinary: false }); + const f = stubFetch(bytes); + try { + await assert.rejects( + () => + singbox.installWithChecksums(singbox.SINGBOX_PINNED_VERSION, { [PLATFORM_KEY]: sha256 }), + /binary not found/i + ); + assert.equal(fs.existsSync(singbox.getBinPath()), false); + assert.equal(await singbox.getInstalledVersion(), null); + } finally { + f.restore(); + } + } +); + +test( + "singbox installer: a checksum mismatch deletes the downloaded archive and installs nothing", + { + skip: !POSIX_HOST, + }, + async () => { + const { bytes } = buildReleaseArchive({ withBinary: true }); + const f = stubFetch(bytes); + try { + await assert.rejects( + () => + singbox.installWithChecksums(singbox.SINGBOX_PINNED_VERSION, { + [PLATFORM_KEY]: "0".repeat(64), + }), + /checksum mismatch/i + ); + const installDir = path.dirname(singbox.getBinPath()); + const leftovers = fs.readdirSync(installDir).filter((n) => n.endsWith(".tar.gz")); + assert.deepEqual(leftovers, [], "the unverified archive must not be left on disk"); + assert.equal(fs.existsSync(singbox.getBinPath()), false); + } finally { + f.restore(); + } + } +); + +test( + "singbox installer: verified archive → official URL, extracted real binary, config + manifest + DB row", + { + skip: !POSIX_HOST, + }, + async () => { + const { bytes, sha256 } = buildReleaseArchive({ withBinary: true }); + const f = stubFetch(bytes); + try { + const result = await singbox.installWithChecksums("latest", { [PLATFORM_KEY]: sha256 }); + + assert.equal( + result.installedVersion, + singbox.SINGBOX_PINNED_VERSION, + '"latest" resolves to the pinned release' + ); + assert.deepEqual(f.urls, [ + `https://github.com/SagerNet/sing-box/releases/download/v${singbox.SINGBOX_PINNED_VERSION}/sing-box-${singbox.SINGBOX_PINNED_VERSION}-${PLATFORM_KEY}.tar.gz`, + ]); + + const bin = singbox.getBinPath(); + assert.match( + fs.readFileSync(bin, "utf8"), + /fixture archive entry/, + "binary comes from the archive, not a generated stub" + ); + assert.ok((fs.statSync(bin).mode & 0o111) !== 0, "binary is executable"); + + const installDir = path.dirname(bin); + assert.deepEqual( + fs + .readdirSync(installDir) + .filter((n) => n.endsWith(".tar.gz") || n.startsWith("sing-box-")), + [], + "downloaded archive and extraction dir are cleaned up" + ); + assert.ok(fs.existsSync(singbox.getConfigPath()), "default config is written"); + assert.equal(await singbox.getInstalledVersion(), singbox.SINGBOX_PINNED_VERSION); + + const row = db + .prepare("SELECT status, installed_version FROM version_manager WHERE tool = 'singbox'") + .get() as { status: string; installed_version: string } | undefined; + assert.equal(row?.installed_version, singbox.SINGBOX_PINNED_VERSION); + assert.equal(row?.status, "stopped"); + } finally { + f.restore(); + } + } +); diff --git a/tests/unit/singbox-routes-local-only.test.ts b/tests/unit/singbox-routes-local-only.test.ts new file mode 100644 index 000000000000..9dedc2cd3c80 --- /dev/null +++ b/tests/unit/singbox-routes-local-only.test.ts @@ -0,0 +1,52 @@ +/** + * Hard Rule #17 — every /api/services/singbox/* route can spawn/stop a child process + * (`sing-box run`) and the TPROXY path edits host iptables, so each one must be + * classified LOCAL_ONLY in src/server/authz/routeGuard.ts (loopback enforced before auth). + * The route list is read from disk, so a route added later without being covered fails here. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; + +const SINGBOX_API_DIR = join(import.meta.dirname, "../../src/app/api/services/singbox"); + +function discoveredRoutes(): string[] { + return readdirSync(SINGBOX_API_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory() && existsSync(join(SINGBOX_API_DIR, d.name, "route.ts"))) + .map((d) => `/api/services/singbox/${d.name}`); +} + +test("singbox exposes the full 8-route lifecycle surface", () => { + assert.deepEqual( + discoveredRoutes().sort(), + [ + "install", + "start", + "stop", + "restart", + "status", + "update", + "auto-start", + "auto-restart-adopted", + ] + .map((n) => `/api/services/singbox/${n}`) + .sort() + ); +}); + +test("every /api/services/singbox/* route is a LOCAL_ONLY path", () => { + for (const route of discoveredRoutes()) { + assert.equal(isLocalOnlyPath(route), true, `${route} must be local-only (Hard Rule #17)`); + } +}); + +test("singbox embed/UI proxy paths and a trailing-slash variant stay local-only", () => { + assert.equal(isLocalOnlyPath("/api/services/singbox/status/"), true); + assert.equal(isLocalOnlyPath("/dashboard/providers/services/singbox/embed/"), true); +}); + +test("the classification is not accidental: an unrelated public path is NOT local-only", () => { + assert.equal(isLocalOnlyPath("/api/v1/models"), false); +}); diff --git a/tests/unit/singbox-tproxy-coexistence.test.ts b/tests/unit/singbox-tproxy-coexistence.test.ts new file mode 100644 index 000000000000..30c7bd1d578c --- /dev/null +++ b/tests/unit/singbox-tproxy-coexistence.test.ts @@ -0,0 +1,240 @@ +/** + * sing-box sidecar × native IP_TRANSPARENT listener coexistence (#12962). + * + * `applyTproxy()` brings the sing-box sidecar up and installs the iptables rules, + * then `startTproxyCapture()` binds the native IP_TRANSPARENT listener on + * `cfg.onPort` (0.0.0.0 by default). The PREROUTING TPROXY rule hands every + * intercepted connection to `--on-port `. Two invariants must hold: + * + * 1. sing-box never binds `onPort` (nor any other port the native listener or + * the TPROXY rule targets) — otherwise one of the two listeners gets + * EADDRINUSE, or sing-box silently wins the traffic and the MITM never sees it. + * 2. sing-box's own upstream connections carry the bypass SO_MARK + * (`routing_mark` on every outbound) that the `mangle OUTPUT` rule excludes + * (`! --mark `), so its egress is not re-marked and re-TPROXY'd (loop). + * + * The real iptables/root/IP_TRANSPARENT run is NOT covered here — see the PR notes. + * This test asserts the generated sing-box JSON, the exact iptables rule text, and + * (with real sockets) that the generated listen addresses do not collide. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-singbox-coexist-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +delete process.env.SINGBOX_PORT; + +const singbox = await import("../../src/lib/services/installers/singbox.ts"); +const { ensureSingboxTproxy, applyTproxy } = await import("../../src/mitm/tproxy/setup.ts"); +const { buildTproxyApplyCommands } = await import("../../src/mitm/tproxy/commands.ts"); +const { registerSupervisor, unregisterSupervisor } = + await import("../../src/lib/services/registry.ts"); + +type SingboxInbound = { type: string; listen: string; listen_port: number }; +type SingboxOutbound = { type: string; tag: string; routing_mark?: number }; +type SingboxConfig = { inbounds: SingboxInbound[]; outbounds: SingboxOutbound[] }; + +const BYPASS = 1337; // 0x539 — the route's default SO_MARK for the proxy's own egress +const CFG = { dport: 443, mark: 0x2333, onPort: 8443, routeTable: 233, bypassMark: BYPASS }; + +function renderRules(cfg: typeof CFG): string[] { + return buildTproxyApplyCommands(cfg).map((c) => `${c.bin} ${c.args.join(" ")}`); +} + +function fakeSupervisor(initial: "stopped" | "running" = "stopped") { + let state: string = initial; + const calls = { start: 0 }; + const sup = { + getStatus: () => ({ tool: "singbox", state }), + start: async () => { + calls.start++; + state = "running"; + return { tool: "singbox", state }; + }, + }; + return { sup, calls }; +} + +function readWrittenConfig(): SingboxConfig { + return JSON.parse(fs.readFileSync(singbox.getConfigPath(), "utf8")) as SingboxConfig; +} + +test.beforeEach(() => { + fs.mkdirSync(path.dirname(singbox.getConfigPath()), { recursive: true }); + fs.rmSync(singbox.getConfigPath(), { force: true }); +}); + +test.after(() => { + unregisterSupervisor("singbox"); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("generated config: no sing-box inbound binds the native listener's onPort, even when the service port is configured onto it", () => { + const cfg = singbox.generateDefaultSingboxConfig( + singbox.resolveSingboxListenPort(CFG.onPort, CFG.onPort), + BYPASS + ) as unknown as SingboxConfig; + const ports = cfg.inbounds.map((i) => i.listen_port); + assert.ok(ports.length >= 1); + assert.ok( + !ports.includes(CFG.onPort), + `sing-box inbound ports ${ports} must not include ${CFG.onPort}` + ); + assert.equal( + new Set(ports).size, + ports.length, + "sing-box inbounds must not collide with each other" + ); +}); + +test("resolveSingboxListenPort skips both the reserved port and the paired mixed-inbound port", () => { + assert.equal( + singbox.resolveSingboxListenPort(9000, 20140), + 20140, + "no clash → the service port is kept" + ); + assert.notEqual(singbox.resolveSingboxListenPort(20140, 20140), 20140); + assert.notEqual( + singbox.resolveSingboxListenPort(20141, 20140), + 20140, + "tproxyPort+1 (mixed) would hit the reserved port" + ); +}); + +test("generated config: every outbound carries routing_mark = the bypass mark", () => { + const cfg = singbox.generateDefaultSingboxConfig(20140, BYPASS) as unknown as SingboxConfig; + assert.ok(cfg.outbounds.length >= 1); + for (const o of cfg.outbounds) { + assert.equal( + o.routing_mark, + BYPASS, + `outbound ${o.tag} must be SO_MARK'd with the bypass mark` + ); + } +}); + +test("ensureSingboxTproxy writes a config that avoids onPort and marks egress with cfg.bypassMark", async () => { + const { sup, calls } = fakeSupervisor(); + registerSupervisor(sup as never); + const ok = await ensureSingboxTproxy(CFG); + assert.equal(ok, true); + assert.equal(calls.start, 1); + + const written = readWrittenConfig(); + const inboundPorts = written.inbounds.map((i) => i.listen_port); + assert.ok( + !inboundPorts.includes(CFG.onPort), + `inbounds ${inboundPorts} must not include onPort ${CFG.onPort}` + ); + for (const o of written.outbounds) assert.equal(o.routing_mark, BYPASS); + // sing-box's mark must differ from the interception mark, or egress would be re-routed to lo + for (const o of written.outbounds) assert.notEqual(o.routing_mark, CFG.mark); +}); + +test("iptables rules: OUTPUT excludes sing-box's mark; TPROXY target is only the native listener's port", async () => { + const { sup } = fakeSupervisor(); + registerSupervisor(sup as never); + await ensureSingboxTproxy(CFG); + const written = readWrittenConfig(); + const singboxMark = written.outbounds[0].routing_mark; + const rules = renderRules(CFG); + + const output = rules.find((r) => r.includes(" OUTPUT ")); + const prerouting = rules.find((r) => r.includes(" PREROUTING ")); + assert.ok(output && prerouting); + assert.equal( + output, + `iptables -t mangle -A OUTPUT -p tcp --dport 443 -m mark ! --mark ${singboxMark} -j MARK --set-mark ${CFG.mark}` + ); + assert.equal( + prerouting, + `iptables -t mangle -A PREROUTING -p tcp --dport 443 -m mark --mark ${CFG.mark} -j TPROXY --on-port ${CFG.onPort} --tproxy-mark ${CFG.mark}` + ); + // the TPROXY --on-port must not be a port sing-box listens on + const onPortInRule = Number(/--on-port (\d+)/.exec(prerouting)![1]); + assert.ok(!written.inbounds.some((i) => i.listen_port === onPortInRule)); +}); + +test("without cfg.bypassMark the OUTPUT rule cannot exclude sing-box egress → sing-box is NOT started (loop guard)", async () => { + const { sup, calls } = fakeSupervisor(); + registerSupervisor(sup as never); + const { bypassMark: _omit, ...noBypass } = CFG; + const ok = await ensureSingboxTproxy(noBypass); + assert.equal(ok, false); + assert.equal( + calls.start, + 0, + "starting sing-box with an un-excluded egress would loop its own traffic" + ); + assert.ok(!fs.existsSync(singbox.getConfigPath()), "no config is written for the refused start"); +}); + +test("real sockets: sing-box inbounds and the native 0.0.0.0:onPort listener bind side by side", async () => { + // Pick a free port to play the role of cfg.onPort for the native IP_TRANSPARENT listener. + const probe = net.createServer(); + await new Promise((r) => probe.listen(0, "0.0.0.0", r)); + const onPort = (probe.address() as net.AddressInfo).port; + await new Promise((r) => probe.close(() => r())); + + const { sup } = fakeSupervisor(); + registerSupervisor(sup as never); + process.env.SINGBOX_PORT = String(onPort); // worst case: the service port was configured onto onPort + try { + assert.equal(await ensureSingboxTproxy({ ...CFG, onPort }), true); + } finally { + delete process.env.SINGBOX_PORT; + } + const written = readWrittenConfig(); + + const servers: net.Server[] = []; + try { + // native listener first (what startTproxyCapture does, default listenIp 0.0.0.0) + const native = net.createServer(); + await new Promise((resolve, reject) => { + native.once("error", reject); + native.listen(onPort, "0.0.0.0", resolve); + }); + servers.push(native); + // then every sing-box inbound exactly as generated + for (const inbound of written.inbounds) { + const s = net.createServer(); + await new Promise((resolve, reject) => { + s.once("error", (e) => + reject( + new Error( + `${inbound.type} inbound ${inbound.listen}:${inbound.listen_port}: ${e.message}` + ) + ) + ); + s.listen(inbound.listen_port, inbound.listen, resolve); + }); + servers.push(s); + } + } finally { + await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r())))); + } +}); + +test("ensureSingboxTproxy never throws on a config-write failure and reports false", async () => { + const { sup, calls } = fakeSupervisor(); + registerSupervisor(sup as never); + fs.rmSync(path.dirname(singbox.getConfigPath()), { recursive: true, force: true }); // not installed + const ok = await ensureSingboxTproxy(CFG); + assert.equal(ok, false); + assert.equal(calls.start, 0); +}); + +test("applyTproxy still installs the firewall rules when the sidecar is unavailable", async () => { + unregisterSupervisor("singbox"); + const seen: string[] = []; + await applyTproxy(CFG, async (bin, args) => { + seen.push(`${bin} ${args.join(" ")}`); + }); + assert.equal(seen.length, 4); +});