security(deploy): stop default deployments shipping an open, unauthenticated server (#221)

All three deployment methods bound 0.0.0.0:8000 with no API key, so following
the README produced a server reachable from any device on the network with no
auth. `check_bind_safety` already refuses to start a non-loopback bind without
a key (so these configs actually failed to start) — this wires the key in so
the documented path yields a *working, authenticated* server.

- docker-compose.yml: require `OPENJARVIS_API_KEY` via `${VAR:?...}` so
  `docker compose up` fails fast when unset; added `deploy/docker/.env.example`
  (un-ignored in .gitignore).
- systemd: add `EnvironmentFile=/etc/openjarvis/env` (no `-` prefix, so a
  missing key file blocks startup rather than exposing an open server).
- launchd: bind `127.0.0.1` by default (the personal-device default — no
  network exposure, no key needed) with a documented, commented opt-in to
  0.0.0.0 + `OPENJARVIS_API_KEY`. Avoids shipping a usable default credential.
- Docs (docker/systemd/launchd) updated with the key-setup step.
- Tests assert each config can't reintroduce an open server, plus
  `check_bind_safety` behavior across loopback/public × key/no-key.

Closes #221

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
krypticmouse
2026-05-25 23:56:55 +00:00
co-authored by Claude Opus 4.7
parent 7e8db280e4
commit 9cd760ad1e
10 changed files with 147 additions and 5 deletions
+3
View File
@@ -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
+7
View File
@@ -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 <key>
OPENJARVIS_API_KEY=
+4
View File
@@ -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
+13 -1
View File
@@ -4,15 +4,27 @@
<dict>
<key>Label</key>
<string>com.openjarvis</string>
<!-- Binds loopback only: the personal-device default, reachable from this
Mac but not the network, so no API key is required. To expose it on
your LAN, change the host below to 0.0.0.0 AND uncomment the
EnvironmentVariables block to set an API key (an unauthenticated
0.0.0.0 server will refuse to start). -->
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/jarvis</string>
<string>serve</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>8000</string>
</array>
<!--
<key>EnvironmentVariables</key>
<dict>
<key>OPENJARVIS_API_KEY</key>
<string>REPLACE_WITH_A_REAL_KEY</string>
</dict>
-->
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
+5
View File
@@ -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=<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
+14 -1
View File
@@ -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 <key>` on `/v1/*` and `/api/*` requests.
This brings up two services:
| Service | Port | Description |
+17 -2
View File
@@ -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`:
<string>/usr/local/bin/jarvis</string>
<string>serve</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>8000</string>
</array>
<!-- To expose on the LAN: set host to 0.0.0.0 and uncomment this block.
<key>EnvironmentVariables</key>
<dict>
<key>OPENJARVIS_API_KEY</key>
<string>REPLACE_WITH_A_REAL_KEY</string>
</dict>
-->
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
@@ -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. |
+15 -1
View File
@@ -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 <key>` 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
View File
+69
View File
@@ -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 "<string>127.0.0.1</string>" in text
assert "<string>0.0.0.0</string>" 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