Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3037,6 +3037,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:<port>).
# 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.
Expand Down
42 changes: 37 additions & 5 deletions docs/frameworks/EMBEDDED-SERVICES.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
title: "Embedded Services"
description: "Reference for 9Router, CLIProxyAPI, Mux, Bifrost, and open-wa"
description: "Reference for 9Router, CLIProxyAPI, Mux, Bifrost, open-wa, and sing-box"
---

# Embedded Services

> **Version:** v3.8.44
> **Last updated:** 2026-09-09
> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost, open-wa).
> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost, open-wa, 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
Expand All @@ -32,7 +32,7 @@ via API keys), embedded services run on the same machine as OmniRoute and commun

### Why embedded services?

Six services are embedded:
Seven services are embedded:

| Service | npm package | Default port | Purpose |
| --------------- | ---------------------------------- | :----------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand All @@ -42,8 +42,9 @@ Six services are embedded:
| **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. |
| **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 six follow the same supervisory model:
All seven 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
Expand Down Expand Up @@ -540,7 +541,38 @@ in this integration yet.

---

### 4.7 Reverse proxy (9Router dashboard embed)
### 4.7 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.8 Reverse proxy (9Router dashboard embed)

The dashboard embeds the 9Router web UI inside an iframe via an internal reverse
proxy at:
Expand Down
180 changes: 180 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5572,6 +5572,186 @@ paths:
"500":
description: Import 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]
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,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:<port>`) 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:<port>`) when OmniRoute manages the sing-box sidecar lifecycle. Defaults to `20140`. |
| `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 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. |
| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. |
| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. |
Expand Down
5 changes: 4 additions & 1 deletion src/app/(dashboard)/dashboard/providers/services/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { MuxServiceTab } from "./tabs/MuxServiceTab";
import { BifrostServiceTab } from "./tabs/BifrostServiceTab";
import { DarioServiceTab } from "./tabs/DarioServiceTab";
import { OpenwaServiceTab } from "./tabs/OpenwaServiceTab";
import { SingboxServiceTab } from "./tabs/SingboxServiceTab";

type Tab = "cliproxy" | "9router" | "mux" | "bifrost" | "dario" | "openwa";
type Tab = "cliproxy" | "9router" | "mux" | "bifrost" | "dario" | "openwa" | "singbox";

const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: "cliproxy", label: "CLIProxyAPI", icon: "swap_horiz" },
Expand All @@ -19,6 +20,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: "bifrost", label: "Bifrost", icon: "bolt" },
{ id: "dario", label: "Dario", icon: "shield_person" },
{ id: "openwa", label: "open-wa", icon: "chat" },
{ id: "singbox", label: "sing-box", icon: "vpn_lock" },
];

export default function ServicesPage() {
Expand Down Expand Up @@ -67,6 +69,7 @@ export default function ServicesPage() {
{active === "bifrost" && <BifrostServiceTab />}
{active === "dario" && <DarioServiceTab />}
{active === "openwa" && <OpenwaServiceTab />}
{active === "singbox" && <SingboxServiceTab />}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<ServiceStatusCard name={NAME} />
<ServiceLifecycleButtons name={NAME} />
<AutoStartToggle name={NAME} />
<AutoRestartAdoptedToggle name={NAME} />
<ServiceLogsPanel name={NAME} />
</div>
);
}
33 changes: 33 additions & 0 deletions src/app/api/services/singbox/_lib.ts
Original file line number Diff line number Diff line change
@@ -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<ServiceSupervisor> {
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;
}
28 changes: 28 additions & 0 deletions src/app/api/services/singbox/auto-restart-adopted/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 });
}
}
Loading
Loading