feat(docker): add hardened Linux VPS deployment (#10623)

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.
This commit is contained in:
freudantunes
2026-08-21 06:50:36 -05:00
committed by GitHub
parent 63c0125c1e
commit 154c2945d1
4 changed files with 303 additions and 0 deletions

21
contrib/vps/.env.example Normal file
View File

@@ -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

151
contrib/vps/README.md Normal file
View File

@@ -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.

69
contrib/vps/compose.yaml Normal file
View File

@@ -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:

View File

@@ -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<string, string>;
volumes?: string[];
};
type ComposeDocument = {
services?: Record<string, ComposeService>;
};
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);
});