mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
21
contrib/vps/.env.example
Normal file
21
contrib/vps/.env.example
Normal 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
151
contrib/vps/README.md
Normal 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
69
contrib/vps/compose.yaml
Normal 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:
|
||||
62
tests/unit/vps-compose.test.ts
Normal file
62
tests/unit/vps-compose.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user