From 154c2945d18dce570edf670d5dbb6f0582ffc33e Mon Sep 17 00:00:00 2001 From: freudantunes <38343817+freudantunes@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:50:36 -0500 Subject: [PATCH] feat(docker): add hardened Linux VPS deployment (#10623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado no worktree combinado: typecheck:core, changelog-integrity, complexity, cognitive-complexity, file-size, lint e teste focado (vps-compose) todos verdes. Bundle Docker aditivo, seguro-por-padrão (loopback, secrets obrigatórios, imagem pinada), bem documentado. CI vermelho é o base-red já rastreado em #9985. --- contrib/vps/.env.example | 21 +++++ contrib/vps/README.md | 151 +++++++++++++++++++++++++++++++++ contrib/vps/compose.yaml | 69 +++++++++++++++ tests/unit/vps-compose.test.ts | 62 ++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 contrib/vps/.env.example create mode 100644 contrib/vps/README.md create mode 100644 contrib/vps/compose.yaml create mode 100644 tests/unit/vps-compose.test.ts diff --git a/contrib/vps/.env.example b/contrib/vps/.env.example new file mode 100644 index 0000000000..31b5a38fee --- /dev/null +++ b/contrib/vps/.env.example @@ -0,0 +1,21 @@ +# Build this local image from the exact release checkout as documented below, +# or replace it with an immutable published image digest. +OMNIROUTE_IMAGE=omniroute:3.8.50-vps + +# The dashboard is loopback-only by default. Keep this value unless a trusted +# reverse proxy or private overlay network is configured on the same host. +OMNIROUTE_BIND_HOST=127.0.0.1 +OMNIROUTE_PORT=20128 + +# Generate unique values before the first start. Do not commit the resulting .env. +JWT_SECRET= +API_KEY_SECRET= +OMNIROUTE_WS_BRIDGE_SECRET= +INITIAL_PASSWORD= +REQUIRE_API_KEY=true + +# Conservative defaults for a small VPS. Adjust after observing real usage. +OMNIROUTE_MEMORY_LIMIT=1536m +OMNIROUTE_CPUS=1.0 +OMNIROUTE_PIDS_LIMIT=256 +APP_LOG_LEVEL=info diff --git a/contrib/vps/README.md b/contrib/vps/README.md new file mode 100644 index 0000000000..521f468f95 --- /dev/null +++ b/contrib/vps/README.md @@ -0,0 +1,151 @@ +# Headless Linux VPS deployment + +This bundle runs the published OmniRoute server image on a Linux VPS without +the Electron desktop shell. It keeps the dashboard on loopback by default, +does not publish Redis, persists application data, and adds conservative +resource and log limits. + +Use this bundle when the VPS only needs the API and web dashboard. The existing +root-level Compose profiles remain the right choice for local development, +building from source, bundled provider CLIs, or the Playwright/Chromium image. + +## Prerequisites + +- A supported Linux distribution with Docker Engine and Docker Compose v2. +- At least 2 GiB of available RAM for the default limits. The host needs more + headroom if other workloads run beside OmniRoute. +- SSH access for the loopback dashboard tunnel. + +## Install + +Build the headless server image from the exact release checkout. Building it +locally avoids assuming that a matching version tag has already been published +to a container registry: + +```bash +git switch --detach release/v3.8.50 +test "$(node -p "require('./package.json').version")" = "3.8.50" +docker build --target runner-base --tag omniroute:3.8.50-vps . +``` + +Then initialize the deployment from the repository root: + +```bash +cd contrib/vps +cp .env.example .env +chmod 600 .env +``` + +Generate separate values for every secret, then paste them into `.env`: + +```bash +openssl rand -base64 48 # JWT_SECRET +openssl rand -hex 32 # API_KEY_SECRET +openssl rand -base64 48 # OMNIROUTE_WS_BRIDGE_SECRET +openssl rand -base64 24 # INITIAL_PASSWORD +``` + +Do not reuse these values across installations. Keep `REQUIRE_API_KEY=true`. +Keep `OMNIROUTE_IMAGE` on the locally built version tag, or replace it with an +immutable registry digest; do not use the floating `latest` or `next` tags for +unattended production. + +Validate and start the stack: + +```bash +docker compose config --quiet +docker compose up -d +docker compose ps +``` + +The dashboard is intentionally bound to `127.0.0.1`. Reach it through SSH: + +```bash +ssh -L 20128:127.0.0.1:20128 user@your-vps +``` + +Then open `http://127.0.0.1:20128` locally. For a public hostname, put a trusted +reverse proxy on the same host in front of the loopback port and terminate TLS +there. Do not change `OMNIROUTE_BIND_HOST` to `0.0.0.0` merely to make the +dashboard reachable. + +## Verify + +```bash +docker compose ps +curl --fail --silent http://127.0.0.1:20128/healthz +docker compose logs --tail=100 omniroute +``` + +`/healthz` is a lifecycle probe. Use the authenticated monitoring/API routes +for deeper provider validation after the first login. + +## Web-session providers on a VPS + +Consumer web-session providers can enforce IP reputation, TLS fingerprint, or +browser-session binding. A cookie copied on a workstation may therefore fail +from a datacenter VPS even when the Linux container is healthy. In particular, +Grok clearance cookies can be tied to the browser IP, User-Agent, and TLS +fingerprint. Prefer official API credentials for unattended workloads. When a +web-session provider is required, use only credentials from an account you own +and follow that provider's guide; do not weaken TLS verification or bypass an +access challenge. + +## Backup + +Stop writes before copying SQLite data, then archive the named volume: + +```bash +docker compose stop omniroute +mkdir -p backups +docker run --rm \ + -v omniroute-vps_omniroute-data:/data:ro \ + -v "$PWD/backups:/backup" \ + docker.io/library/alpine:3.23 \ + tar -C /data -czf /backup/omniroute-data.tar.gz . +docker compose start omniroute +``` + +Verify the archive before relying on it: + +```bash +tar -tzf backups/omniroute-data.tar.gz >/dev/null +``` + +Store a timestamped copy outside the VPS. The fixed filename above is kept +simple for copy/paste; rename it after each verified backup. + +## Update and rollback + +Before updating, record the currently running immutable digest and take a +verified backup: + +```bash +docker image inspect "$(docker compose images -q omniroute)" \ + --format '{{index .RepoDigests 0}}' +``` + +Build the new local version tag first, or set `OMNIROUTE_IMAGE` in `.env` to a +new immutable registry digest. Pull only when the selected image is remote, +then recreate the application container: + +```bash +# Registry images only: docker compose pull omniroute +docker compose up -d --no-deps omniroute +docker compose ps +curl --fail --silent http://127.0.0.1:20128/healthz +``` + +To roll back the application image, restore the previous value of +`OMNIROUTE_IMAGE` and repeat the applicable `pull` and `up` commands. Restore the data +archive only when a migration changed the persisted data and image rollback +alone is insufficient. Keep the stack stopped while restoring the volume. + +## Remove the stack + +```bash +docker compose down +``` + +This preserves both named volumes. `docker compose down -v` deletes persistent +data and is intentionally not part of the normal uninstall path. diff --git a/contrib/vps/compose.yaml b/contrib/vps/compose.yaml new file mode 100644 index 0000000000..4e7c43ebd6 --- /dev/null +++ b/contrib/vps/compose.yaml @@ -0,0 +1,69 @@ +name: omniroute-vps + +services: + redis: + image: docker.io/library/redis:8.6.5-alpine + restart: unless-stopped + command: ["redis-server", "--save", "60", "1", "--appendonly", "yes", "--loglevel", "warning"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + omniroute: + image: ${OMNIROUTE_IMAGE:?Set OMNIROUTE_IMAGE to a versioned tag or digest} + restart: unless-stopped + stop_grace_period: 40s + depends_on: + redis: + condition: service_healthy + env_file: + - .env + environment: + NODE_ENV: production + PORT: "20128" + DASHBOARD_PORT: "20128" + HOSTNAME: 0.0.0.0 + DATA_DIR: /app/data + REDIS_URL: redis://redis:6379 + REQUIRE_API_KEY: ${REQUIRE_API_KEY:-true} + JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env} + API_KEY_SECRET: ${API_KEY_SECRET:?Set API_KEY_SECRET in .env} + INITIAL_PASSWORD: ${INITIAL_PASSWORD:?Set INITIAL_PASSWORD in .env} + OMNIROUTE_WS_BRIDGE_SECRET: ${OMNIROUTE_WS_BRIDGE_SECRET:?Set OMNIROUTE_WS_BRIDGE_SECRET in .env} + ports: + - "${OMNIROUTE_BIND_HOST:-127.0.0.1}:${OMNIROUTE_PORT:-20128}:20128" + volumes: + - omniroute-data:/app/data + tmpfs: + - /tmp:size=256m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: ${OMNIROUTE_PIDS_LIMIT:-256} + mem_limit: ${OMNIROUTE_MEMORY_LIMIT:-1536m} + cpus: ${OMNIROUTE_CPUS:-1.0} + healthcheck: + test: ["CMD", "node", "healthcheck.mjs"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + omniroute-data: + redis-data: diff --git a/tests/unit/vps-compose.test.ts b/tests/unit/vps-compose.test.ts new file mode 100644 index 0000000000..592cbf2ff4 --- /dev/null +++ b/tests/unit/vps-compose.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +import { load } from "js-yaml"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); +const COMPOSE_PATH = path.join(REPO_ROOT, "contrib/vps/compose.yaml"); +const ENV_EXAMPLE_PATH = path.join(REPO_ROOT, "contrib/vps/.env.example"); + +type ComposeService = { + image?: string; + ports?: string[]; + environment?: Record; + volumes?: string[]; +}; + +type ComposeDocument = { + services?: Record; +}; + +function readCompose(): { raw: string; parsed: ComposeDocument } { + const raw = fs.readFileSync(COMPOSE_PATH, "utf8"); + return { raw, parsed: load(raw) as ComposeDocument }; +} + +test("VPS compose publishes only the OmniRoute dashboard on loopback by default", () => { + const { parsed } = readCompose(); + const services = parsed.services ?? {}; + + assert.deepEqual(services.redis?.ports, undefined, "Redis must not publish a host port"); + assert.deepEqual(services.omniroute?.ports, [ + "${OMNIROUTE_BIND_HOST:-127.0.0.1}:${OMNIROUTE_PORT:-20128}:20128", + ]); +}); + +test("VPS compose requires an explicitly pinned image and production secrets", () => { + const { raw, parsed } = readCompose(); + const omniroute = parsed.services?.omniroute; + + assert.equal( + omniroute?.image, + "${OMNIROUTE_IMAGE:?Set OMNIROUTE_IMAGE to a versioned tag or digest}" + ); + assert.equal(omniroute?.environment?.REQUIRE_API_KEY, "${REQUIRE_API_KEY:-true}"); + assert.match(raw, /JWT_SECRET: \$\{JWT_SECRET:\?Set JWT_SECRET in \.env\}/); + assert.match(raw, /API_KEY_SECRET: \$\{API_KEY_SECRET:\?Set API_KEY_SECRET in \.env\}/); + assert.match(raw, /INITIAL_PASSWORD: \$\{INITIAL_PASSWORD:\?Set INITIAL_PASSWORD in \.env\}/); + assert.match( + raw, + /OMNIROUTE_WS_BRIDGE_SECRET: \$\{OMNIROUTE_WS_BRIDGE_SECRET:\?Set OMNIROUTE_WS_BRIDGE_SECRET in \.env\}/ + ); +}); + +test("VPS environment example uses a versioned image rather than a floating channel", () => { + const env = fs.readFileSync(ENV_EXAMPLE_PATH, "utf8"); + + assert.match(env, /^OMNIROUTE_IMAGE=[^\s:]+:\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/m); + assert.doesNotMatch(env, /^OMNIROUTE_IMAGE=.*:(?:latest|next)$/m); + assert.match(env, /^REQUIRE_API_KEY=true$/m); +});