diff --git a/.gitignore b/.gitignore index b405d1de..1dfd9b67 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ Thumbs.db # Secrets .env .env.* +# ...but keep checked-in example/templates (never contain real secrets) +!.env.example +!**/.env.example # Project *.sqlite diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example new file mode 100644 index 00000000..e02938aa --- /dev/null +++ b/deploy/docker/.env.example @@ -0,0 +1,7 @@ +# Copy to `.env` in this directory (deploy/docker/.env) before `docker compose up`. +# docker-compose.yml requires this — the container binds 0.0.0.0, so the +# server refuses to start without an API key. +# +# Generate a key with: jarvis auth generate-key +# Then clients must send: Authorization: Bearer +OPENJARVIS_API_KEY= diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 284f0897..8edbb9bf 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -8,6 +8,10 @@ services: environment: - OPENJARVIS_ENGINE_DEFAULT=ollama - OLLAMA_HOST=http://ollama:11434 + # The container binds 0.0.0.0, so an API key is REQUIRED. Compose fails + # fast if OPENJARVIS_API_KEY is unset (set it in deploy/docker/.env — + # see .env.example, or `export` it). Generate one: `jarvis auth generate-key`. + - OPENJARVIS_API_KEY=${OPENJARVIS_API_KEY:?OPENJARVIS_API_KEY must be set (see deploy/docker/.env.example)} depends_on: ollama: condition: service_healthy diff --git a/deploy/launchd/com.openjarvis.plist b/deploy/launchd/com.openjarvis.plist index f0ff55de..f9d82b56 100644 --- a/deploy/launchd/com.openjarvis.plist +++ b/deploy/launchd/com.openjarvis.plist @@ -4,15 +4,27 @@ Label com.openjarvis + ProgramArguments /usr/local/bin/jarvis serve --host - 0.0.0.0 + 127.0.0.1 --port 8000 + RunAtLoad KeepAlive diff --git a/deploy/systemd/openjarvis.service b/deploy/systemd/openjarvis.service index 0c9b3304..3a94d36b 100644 --- a/deploy/systemd/openjarvis.service +++ b/deploy/systemd/openjarvis.service @@ -10,6 +10,11 @@ ExecStart=/opt/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000 Restart=on-failure RestartSec=5 Environment=HOME=/opt/openjarvis +# Binding 0.0.0.0 requires authentication. This file MUST exist and contain: +# OPENJARVIS_API_KEY= (generate one: `jarvis auth generate-key`) +# It is not prefixed with "-", so the unit fails to start if the file is +# missing — preventing an accidentally unauthenticated public server. +EnvironmentFile=/etc/openjarvis/env [Install] WantedBy=multi-user.target diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 0d679d52..b2ca68f7 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -4,12 +4,25 @@ OpenJarvis provides Docker images for both CPU-only and GPU-accelerated deployme ## Quick Start -The fastest way to get OpenJarvis running in Docker is with Docker Compose, which starts both the API server and an Ollama backend: +The container binds `0.0.0.0`, so an **API key is required** — the server +refuses to start on a non-loopback address without one. Set it first: + +```bash +cd deploy/docker +cp .env.example .env +echo "OPENJARVIS_API_KEY=$(jarvis auth generate-key)" > .env # or paste your own +``` + +Then start both the API server and an Ollama backend with Docker Compose: ```bash docker compose up -d ``` +`docker compose` reads `OPENJARVIS_API_KEY` from `.env` (or your shell +environment) and fails fast if it is unset. Clients must then send +`Authorization: Bearer ` on `/v1/*` and `/api/*` requests. + This brings up two services: | Service | Port | Description | diff --git a/docs/deployment/launchd.md b/docs/deployment/launchd.md index 04cbcd33..a5f875c2 100644 --- a/docs/deployment/launchd.md +++ b/docs/deployment/launchd.md @@ -24,6 +24,14 @@ launchctl load ~/Library/LaunchAgents/com.openjarvis.plist The service starts immediately (due to `RunAtLoad`) and will automatically restart at each login. +!!! note "Binds loopback by default" + The plist binds `127.0.0.1` — reachable from this Mac but not the network, + the right default for a personal device, and no API key is needed. To + expose it on your LAN, change the host to `0.0.0.0` **and** uncomment the + `EnvironmentVariables` block to set `OPENJARVIS_API_KEY` + (`jarvis auth generate-key`); an unauthenticated `0.0.0.0` server refuses + to start. + Verify it is running: ```bash @@ -55,10 +63,17 @@ The provided plist file at `deploy/launchd/com.openjarvis.plist`: /usr/local/bin/jarvis serve --host - 0.0.0.0 + 127.0.0.1 --port 8000 + RunAtLoad KeepAlive @@ -76,7 +91,7 @@ The provided plist file at `deploy/launchd/com.openjarvis.plist`: | Key | Value | Description | |----------------------|--------------------------------|------------------------------------------------------------------------------------------------------| | `Label` | `com.openjarvis` | Unique identifier for the service. Used with `launchctl` commands to manage the service. | -| `ProgramArguments` | `["/usr/local/bin/jarvis", "serve", "--host", "0.0.0.0", "--port", "8000"]` | The command and arguments to execute. Each element of the command line is a separate string in the array. | +| `ProgramArguments` | `["/usr/local/bin/jarvis", "serve", "--host", "127.0.0.1", "--port", "8000"]` | The command and arguments to execute. Binds loopback by default; see the note above to expose on the LAN with an API key. | | `RunAtLoad` | `true` | Start the service immediately when the plist is loaded (and on each login). | | `KeepAlive` | `true` | Automatically restart the service if it exits for any reason. launchd monitors the process and relaunches it. | | `StandardOutPath` | `/tmp/openjarvis.stdout.log` | File where standard output is written. Contains server startup messages and access logs. | diff --git a/docs/deployment/systemd.md b/docs/deployment/systemd.md index d69d7a5d..3e13dede 100644 --- a/docs/deployment/systemd.md +++ b/docs/deployment/systemd.md @@ -21,7 +21,17 @@ cd /opt/openjarvis/OpenJarvis && sudo -u openjarvis uv sync --extra server ## Installing the Service -Copy the unit file to the systemd directory, reload the daemon, and enable the service: +The unit binds `0.0.0.0`, so an **API key is required** — and the unit +declares `EnvironmentFile=/etc/openjarvis/env` (no `-` prefix), so it will +**fail to start** until that file exists with a key. Create it first: + +```bash +sudo mkdir -p /etc/openjarvis +echo "OPENJARVIS_API_KEY=$(jarvis auth generate-key)" | sudo tee /etc/openjarvis/env +sudo chmod 600 /etc/openjarvis/env +``` + +Then copy the unit file, reload the daemon, and enable the service: ```bash sudo cp deploy/systemd/openjarvis.service /etc/systemd/system/ @@ -30,6 +40,10 @@ sudo systemctl enable openjarvis sudo systemctl start openjarvis ``` +Clients must send `Authorization: Bearer ` on `/v1/*` and `/api/*` +requests. (If you instead bind to `127.0.0.1`, the key is optional and you +can drop the `EnvironmentFile` line.) + Verify it is running: ```bash diff --git a/tests/deploy/__init__.py b/tests/deploy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/deploy/test_deploy_auth.py b/tests/deploy/test_deploy_auth.py new file mode 100644 index 00000000..428750aa --- /dev/null +++ b/tests/deploy/test_deploy_auth.py @@ -0,0 +1,69 @@ +"""Deployment configs must not ship an unauthenticated public server (#221). + +Every shipped deployment method must either bind loopback (no network +exposure) or require an API key, so that following the docs never yields an +open `0.0.0.0:8000` server. `check_bind_safety` is the runtime backstop; +these tests guard the static config files that drive it. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEPLOY = REPO_ROOT / "deploy" + + +def _read(rel: str) -> str: + return (DEPLOY / rel).read_text() + + +def test_docker_compose_requires_api_key(): + text = _read("docker/docker-compose.yml") + # The container binds 0.0.0.0, so the key must be a *required* variable + # (compose's ${VAR:?...} fails fast when unset). + assert "OPENJARVIS_API_KEY" in text + assert "OPENJARVIS_API_KEY:?" in text + + +def test_docker_env_example_present(): + assert (DEPLOY / "docker" / ".env.example").is_file() + assert "OPENJARVIS_API_KEY" in _read("docker/.env.example") + + +def test_systemd_unit_binds_public_and_requires_env_file(): + text = _read("systemd/openjarvis.service") + # Public bind -> must pull in an EnvironmentFile (no leading '-', so the + # unit fails to start if it's missing). + assert "--host 0.0.0.0" in text + assert "EnvironmentFile=/etc/openjarvis/env" in text + assert "\n-EnvironmentFile" not in text and "=-/etc" not in text + + +def test_launchd_plist_binds_loopback(): + text = _read("launchd/com.openjarvis.plist") + # Personal-device default: loopback, not the network. + assert "127.0.0.1" in text + assert "0.0.0.0" not in text + + +@pytest.mark.parametrize( + ("host", "api_key", "should_exit"), + [ + ("127.0.0.1", "", False), + ("localhost", "", False), + ("0.0.0.0", "oj_sk_x", False), + ("0.0.0.0", "", True), + ("192.168.1.10", "", True), + ], +) +def test_check_bind_safety(host, api_key, should_exit): + from openjarvis.server.auth_middleware import check_bind_safety + + if should_exit: + with pytest.raises(SystemExit): + check_bind_safety(host, api_key=api_key) + else: + check_bind_safety(host, api_key=api_key) # must not raise