Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .sops.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# SOPS encryption configuration for Odysseus secrets at rest.
#
# Replace the placeholder below with your own age public key, then
# encrypt with: sops -e -i secrets.env
#
# Generate a key with:
# age-keygen -o ~/.config/sops/age/keys.txt
# age-keygen -y < ~/.config/sops/age/keys.txt # prints the public key
#
# See SECURITY.md ("Encrypting Secrets At Rest") for the full workflow.
creation_rules:
- path_regex: secrets\.env(\.enc)?$
age: age1REPLACE_WITH_YOUR_AGE_PUBLIC_KEY
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gosu \
&& rm -rf /var/lib/apt/lists/*

# Optional opt-in: SOPS for encrypted-at-rest secrets (see SECURITY.md).
# Small static Go binary; only invoked when /app/secrets.env is present at
# container start. Pinned by version + per-arch SHA256 from the upstream
# release (https://github.com/getsops/sops/releases/tag/v3.13.1) so a
# compromised CDN cannot substitute a different binary.
ARG SOPS_VERSION=3.13.1
ARG SOPS_SHA256_AMD64=620a9d7e3352ababeca6908cea24a6e8b14ce89a448ddbd3f94f1ef3398f470a
ARG SOPS_SHA256_ARM64=19576fb1734dbf8fb77eda0cf0f3a2218f99bf4d33b814318e5e10d6babb9820
RUN arch="$(dpkg --print-architecture)" \
&& case "$arch" in \
amd64) expected_sha="$SOPS_SHA256_AMD64" ;; \
arm64) expected_sha="$SOPS_SHA256_ARM64" ;; \
*) echo "sops install: unsupported arch '$arch'" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /usr/local/bin/sops \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.${arch}" \
&& echo "${expected_sha} /usr/local/bin/sops" > /tmp/sops.sha256 \
&& sha256sum -c /tmp/sops.sha256 \
&& rm /tmp/sops.sha256 \
&& chmod +x /usr/local/bin/sops

WORKDIR /app

# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
Expand Down
56 changes: 56 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,62 @@ Security fixes are handled on the default branch until formal releases are cut.
- Treat shell, model-serving, MCP, email, calendar, and vault features as privileged admin functionality.
- Common internal-only ports are Odysseus `7000`, SearXNG `8080`, ntfy `8091`, ChromaDB `8100`, Ollama `11434`, and local model/provider APIs such as `8000-8020`.

## Encrypting Secrets At Rest (Optional)

By default, secret values (API keys, admin password seed, internal tokens, etc.) live in `.env` as plaintext. The file is gitignored, but any process that reads it (container exec, host backup, snapshot) reveals every secret. For deployments where that's a concern, Odysseus supports [SOPS](https://github.com/getsops/sops) with age keys to encrypt secrets at rest.

**Scope:** the built-in SOPS hook lives in the Docker entrypoint (`docker/entrypoint.sh`). Native startups (`uvicorn app:app` directly, systemd, launchd) do **not** automatically pick it up — they read `.env` / process environment as usual. Native users who want SOPS today have to wrap their own launch — `sops exec-env` takes the command as a single shell-string (not argv passthrough), so the working form is `sops exec-env --same-process secrets.env 'uvicorn app:app --host 0.0.0.0 --port 7000'`. The `--same-process` flag makes uvicorn the signal target so systemd/launchd stop signals reach it directly. A first-class native hook is out of scope for this PR. Cross-service secrets (e.g. `SEARXNG_SECRET`, consumed by the separate `searxng` container) are also out of scope here — see the issue tracker for the cross-service design.

This is **opt-in** — the feature is only active when an encrypted `secrets.env` is present at container start. Existing deployments behave exactly as before.

### Setup

1. Install [`age`](https://github.com/FiloSottile/age) on your host and generate a key:

```bash
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
age-keygen -y < ~/.config/sops/age/keys.txt # prints your age public key
```

2. Paste the printed public key into `.sops.yaml` (replace the `age1REPLACE...` placeholder).

3. Copy the example, fill in real secret values, then encrypt in place:

```bash
cp secrets.env.example secrets.env
$EDITOR secrets.env # fill in real values
sops -e -i secrets.env # encrypt in place — file is now safe to commit
```

To edit later: `sops secrets.env` opens `$EDITOR` with decrypted contents and re-encrypts on save.

4. Provide the encrypted `secrets.env` AND the age **private** key to the container as runtime volume mounts (not baked into the image). Add this to `docker-compose.override.yml`:

```yaml
services:
odysseus:
volumes:
- ./secrets.env:/app/secrets.env:ro
- ~/.config/sops/age/keys.txt:/run/secrets/sops-age-key:ro
environment:
- SOPS_AGE_KEY_FILE=/run/secrets/sops-age-key
```

At container start, the entrypoint runs `sops exec-env` so secrets enter the wrapped process as env vars JIT — plaintext never touches the container filesystem. `setup.py` (which seeds the admin user from `ODYSSEUS_ADMIN_PASSWORD`) runs inside the same wrap, so the seed reaches it. If `secrets.env` is present but **not** SOPS-encrypted, the entrypoint refuses to start (it's almost always a packaging mistake).

### What to put in `secrets.env` vs `.env`

Only true secrets belong in `secrets.env`. Non-secret configuration stays in `.env` because it benefits from plaintext diffs and PR review:

| `.env` (plaintext, gitignored) | `secrets.env` (SOPS-encrypted, mounted at runtime) |
|---|---|
| `APP_PORT`, `LLM_HOST`, `SEARXNG_INSTANCE`, `ALLOWED_ORIGINS`, `CHROMADB_BIND`, `LOCALHOST_BYPASS` | `OPENAI_API_KEY`, `ODYSSEUS_ADMIN_PASSWORD`, `ODYSSEUS_INTERNAL_TOKEN`, MCP OAuth client secrets, IMAP passwords |

`SEARXNG_SECRET` is intentionally absent: it's consumed by a different container (`searxng`), which the in-container `sops exec-env` cannot reach. See the issue tracker for the cross-service design.

To migrate: move only the secret lines out of your existing `.env` into `secrets.env`, encrypt, and delete those lines from `.env`.

## Publishing A Fork

Before pushing a public fork, run:
Expand Down
54 changes: 51 additions & 3 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,57 @@ export VLLM_USE_FLASHINFER_SAMPLER="${VLLM_USE_FLASHINFER_SAMPLER:-0}"
# vLLM and helper scripts land here because /app is the non-root user's HOME.
export PATH="/app/.local/bin:$PATH"

# Run first-time setup as the app user so data/ files get the right ownership.
# setup.py is idempotent — skips auth.json / .env if they already exist.
# || true so a setup failure never prevents the container from starting.
# Optional SOPS-encrypted secrets at rest. When /app/secrets.env is
# present it MUST be SOPS-encrypted (detected via the `sops_version`
# trailing line). On encrypted, we exec via `sops exec-env` so secrets
# enter the process as env vars JIT — plaintext never touches the
# container filesystem. setup.py runs INSIDE the wrap so it sees
# secrets like ODYSSEUS_ADMIN_PASSWORD (reviewer feedback on #236).
# On plaintext, we refuse to start — a plaintext secrets.env in /app
# is almost always a packaging mistake. See SECURITY.md ("Encrypting
# Secrets At Rest") for the user-side workflow.
SECRETS_FILE="/app/secrets.env"
if [ -f "$SECRETS_FILE" ]; then
if ! grep -q '^sops_version' "$SECRETS_FILE"; then
echo "entrypoint: $SECRETS_FILE exists but is not SOPS-encrypted; refusing to start" >&2
echo "entrypoint: encrypt it with: sops -e -i secrets.env" >&2
exit 1
fi
if ! command -v sops >/dev/null 2>&1; then
echo "entrypoint: $SECRETS_FILE is SOPS-encrypted but 'sops' is not on PATH" >&2
exit 1
fi
if [ -z "${SOPS_AGE_KEY_FILE:-}" ] && [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "entrypoint: $SECRETS_FILE is SOPS-encrypted but neither SOPS_AGE_KEY_FILE nor SOPS_AGE_KEY is set" >&2
exit 1
fi
# `sops exec-env` takes a single command string (passed to sh -c),
# not argv passthrough — so we single-quote each positional arg to
# preserve word boundaries when sh re-tokenises.
cmd=""
for a in "$@"; do
qa=$(printf '%s' "$a" | sed "s/'/'\\\\''/g")
cmd="$cmd '$qa'"
done
# Chain: setup.py inherits the SOPS-injected env (so
# ODYSSEUS_ADMIN_PASSWORD reaches it), then exec replaces the shell
# with the real app. setup.py failure is non-fatal via `|| true` as
# in the non-SOPS path below.
#
# --same-process: without it, `sops exec-env` fork/waits and stays as
# PID 1, so `docker stop`'s SIGTERM hits sops instead of uvicorn —
# graceful shutdown gets skipped. With it, sops execve's into
# `sh -c "<chain>"`, the chain's final `exec uvicorn` then replaces
# sh, and uvicorn ends up as PID 1 / the signal target — matching
# the no-SOPS branch's signal contract.
exec "$GOSU_BIN" "$PUID:$PGID" sops exec-env --same-process "$SECRETS_FILE" \
"$PYTHON_BIN /app/setup.py || true; exec$cmd"
fi

# No-SOPS path. Run first-time setup as the app user so data/ files get
# the right ownership. setup.py is idempotent — skips auth.json / .env
# if they already exist. || true so a setup failure never prevents the
# container from starting.
"$GOSU_BIN" "$PUID:$PGID" "$PYTHON_BIN" /app/setup.py || true

# Drop root and run the actual app. `gosu` is preferred over `su` /
Expand Down
36 changes: 36 additions & 0 deletions secrets.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Odysseus — app-internal secrets meant for encryption at rest (optional, via SOPS).
#
# Workflow (idiomatic SOPS — file is encrypted in place):
#
# cp secrets.env.example secrets.env
# $EDITOR secrets.env # fill in real values (plaintext, on your host only)
# sops -e -i secrets.env # encrypt in place — file now safe to commit
#
# To edit later: `sops secrets.env` opens $EDITOR with decrypted contents
# and re-encrypts on save. Never commit a non-encrypted secrets.env.
#
# Scope: this file feeds the in-container `sops exec-env` wrap, which only
# reaches secrets read by the wrapped odysseus process — `OPENAI_API_KEY`,
# `ODYSSEUS_ADMIN_PASSWORD`, `ODYSSEUS_INTERNAL_TOKEN`, IMAP/SMTP passwords,
# MCP OAuth client secrets, etc. The container's entrypoint injects these
# JIT — plaintext never touches the container filesystem. setup.py runs
# inside the same wrap so the admin seed reaches it. Requires
# SOPS_AGE_KEY_FILE (or SOPS_AGE_KEY) set inside the container (see
# SECURITY.md).
#
# Cross-service secrets (e.g. SEARXNG_SECRET, consumed by a separate
# container) are NOT in scope for this file — that's a follow-up; see the
# issue tracker for the cross-service design.
#
# Non-secret configuration (APP_PORT, LLM_HOST, SEARXNG_INSTANCE, etc.)
# stays in .env as today — only true secrets belong here.

# OpenAI / provider API keys
# OPENAI_API_KEY=

# Pre-seeded admin password used on first boot (delete after first run).
# ODYSSEUS_ADMIN_PASSWORD=

# Internal HTTP token used by background workers/tools to call back into
# the odysseus API. Generate with: openssl rand -hex 32
# ODYSSEUS_INTERNAL_TOKEN=