Skip to content
Merged
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
42 changes: 42 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,45 @@ The API binds all interfaces by default. When only loopback callers
reach it (host-networked, co-located client), set `UVICORN_HOST=127.0.0.1`
to keep the credential-holding control plane off other interfaces.

## Secret files

A process reads a setting from a file in `/run/secrets` when that directory
exists. The file name is the variable name, for example
`/run/secrets/DATABASE_URL`. This applies to the core, exe.dev, Hetzner,
Exoscale, and Tailscale settings. Put each secret in a file and keep the other
settings in `drukbox.env`. Then `docker inspect` does not show the secrets, and
subprocesses do not inherit them.

An environment variable or a `.env` entry wins over a file. If a required
setting has no variable and no file, the process stops at startup. The error
names the setting and shows no value.

Compose mounts each file secret at `/run/secrets/<name>`. Give the same secrets
to every drukbox process: the API, the exchange, the SSH gateway, the
migrations, and the cron jobs.

```yaml
services:
api:
image: ghcr.io/czpython/drukbox:latest
env_file: drukbox.env
secrets: [DATABASE_URL, SERVICE_TOKENS, SECRETS_KEY, EXE_API_TOKEN]

secrets:
DATABASE_URL:
file: /srv/drukbox/secrets/DATABASE_URL
SERVICE_TOKENS:
file: /srv/drukbox/secrets/SERVICE_TOKENS
SECRETS_KEY:
file: /srv/drukbox/secrets/SECRETS_KEY
EXE_API_TOKEN:
file: /srv/drukbox/secrets/EXE_API_TOKEN
```

Compose bind-mounts a file secret, so the file keeps its host owner and mode.
The image runs as UID `1001`. Give each file to UID `1001` with mode `0400`.
With `docker run`, mount the directory: `-v /srv/drukbox/secrets:/run/secrets:ro`.

## Admin keys and service accounts

`SERVICE_TOKENS` holds one or more comma-separated admin keys, read at
Expand Down Expand Up @@ -518,6 +557,9 @@ SERVICE_URL=http://localhost:8780 SERVICE_TOKEN=... npm --prefix api-tests test

## Configuration reference

A core, exe.dev, Hetzner, Exoscale, or Tailscale variable can also come from a
file. See [Secret files](#secret-files).

Core, required:

| Variable | Purpose |
Expand Down
5 changes: 3 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ after no stored row needs it.

Provider tokens (`EXE_API_TOKEN`, `HETZNER_API_TOKEN`, Tailscale OAuth), the
registry password (`REGISTRY_PASSWORD`), and AWS credentials are read from the
environment or the AWS SDK default chain. They are never written to the
database and never returned by the API.
environment, from [secret files](deploy.md#secret-files), or from the AWS SDK
default chain. They are never written to the database and never returned by
the API.

## What the proxy protects

Expand Down
10 changes: 10 additions & 0 deletions src/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from functools import lru_cache
from pathlib import Path
from typing import Annotated, Self

from pydantic import BeforeValidator, Field, SecretStr, model_validator
Expand All @@ -16,12 +17,21 @@ def _split_csv(value: object) -> object:
SecretsKey = Annotated[SecretStr, BeforeValidator(validate_keys)]


def get_secrets_dir() -> Path | None:
# Compose mounts each secret file here. pydantic-settings warns on every load
# when the directory is missing, as in a deployment with only environment variables.
secrets_dir = Path("/run/secrets")
if secrets_dir.is_dir():
return secrets_dir


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
hide_input_in_errors=True,
secrets_dir=get_secrets_dir(),
)

database_url: str = Field(
Expand Down
57 changes: 57 additions & 0 deletions src/core/tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import os
from pathlib import Path

import pytest
from pydantic_settings import BaseSettings

import conftest
from core.settings import Settings, get_settings
from networking.tailscale_settings import TailscaleSettings
from providers.exe.settings import ExeSettings
from providers.exoscale.settings import ExoscaleSettings
from providers.hetzner.settings import HetznerSettings


def _base_env() -> dict[str, str]:
Expand Down Expand Up @@ -108,6 +113,58 @@ def test_tailscale_settings_with_all_credentials_constructs_ok(
assert ts.auth_tags == ("tag:sandbox",)


def test_deployment_secrets_come_from_files(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
secrets = {
"DATABASE_URL": "postgresql+psycopg://drukbox:file-password@db/drukbox",
"SERVICE_TOKENS": "file-admin-key",
"SECRETS_KEY": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTE=",
"REGISTRY_PASSWORD": "file-registry-token",
"EXE_API_TOKEN": "file-exe-token",
"TAILSCALE_OAUTH_CLIENT_SECRET": "file-tailscale-secret",
}
for name, value in secrets.items():
monkeypatch.delenv(name, raising=False)
(tmp_path / name).write_text(f"{value}\n")
monkeypatch.setenv("REGISTRY_HOST", "ghcr.io")
monkeypatch.setenv("REGISTRY_USERNAME", "builder")

settings = Settings(_secrets_dir=tmp_path) # pyright: ignore[reportCallIssue]
exe = ExeSettings(_secrets_dir=tmp_path) # pyright: ignore[reportCallIssue]
tailscale = TailscaleSettings(_secrets_dir=tmp_path) # pyright: ignore[reportCallIssue]

assert settings.database_url == secrets["DATABASE_URL"]
assert settings.service_tokens == ("file-admin-key",)
assert settings.secrets_key.get_secret_value() == secrets["SECRETS_KEY"]
assert settings.registry_password.get_secret_value() == secrets["REGISTRY_PASSWORD"]
assert exe.api_token == secrets["EXE_API_TOKEN"]
assert tailscale.oauth_client_secret == secrets["TAILSCALE_OAUTH_CLIENT_SECRET"]


@pytest.mark.parametrize(
("settings_class", "secret", "missing"),
[
(ExeSettings, "EXE_API_TOKEN", "EXE_DEFAULT_IMAGE"),
(TailscaleSettings, "TAILSCALE_OAUTH_CLIENT_SECRET", "TAILSCALE_TAILNET"),
(HetznerSettings, "HETZNER_API_TOKEN", "HETZNER_LOCATION"),
(ExoscaleSettings, "EXOSCALE_API_SECRET", "EXOSCALE_ZONE"),
],
)
def test_provider_settings_errors_hide_secrets(
monkeypatch: pytest.MonkeyPatch, settings_class: type[BaseSettings], secret: str, missing: str
) -> None:
monkeypatch.setenv(secret, "provider-secret")
monkeypatch.delenv(missing, raising=False)

with pytest.raises(ValueError) as error:
settings_class()

# pydantic shortens a long input in the message, so a check for the secret
# alone can pass by luck. A hidden input prints no input_value at all.
assert "input_value" not in str(error.value)


def test_tailscale_disabled_ignores_missing_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
env: dict[str, str | None] = {
**_base_env(),
Expand Down
4 changes: 3 additions & 1 deletion src/networking/tailscale_settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

from core.settings import CsvTuple
from core.settings import CsvTuple, get_secrets_dir


class TailscaleSettings(BaseSettings):
Expand All @@ -12,6 +12,8 @@ class TailscaleSettings(BaseSettings):
env_file_encoding="utf-8",
env_prefix="TAILSCALE_",
extra="ignore",
hide_input_in_errors=True,
secrets_dir=get_secrets_dir(),
)

tailnet: str = Field(
Expand Down
4 changes: 4 additions & 0 deletions src/providers/exe/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

from core.settings import get_secrets_dir


class ExeSettings(BaseSettings):
"""exe.dev provider configuration."""
Expand All @@ -10,6 +12,8 @@ class ExeSettings(BaseSettings):
env_file_encoding="utf-8",
env_prefix="EXE_",
extra="ignore",
hide_input_in_errors=True,
secrets_dir=get_secrets_dir(),
)

api_url: str = Field(
Expand Down
4 changes: 4 additions & 0 deletions src/providers/exoscale/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

from core.settings import get_secrets_dir


class ExoscaleSettings(BaseSettings):
"""Exoscale provider configuration."""
Expand All @@ -10,6 +12,8 @@ class ExoscaleSettings(BaseSettings):
env_file_encoding="utf-8",
env_prefix="EXOSCALE_",
extra="ignore",
hide_input_in_errors=True,
secrets_dir=get_secrets_dir(),
)

api_key: str = Field(
Expand Down
4 changes: 4 additions & 0 deletions src/providers/hetzner/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

from core.settings import get_secrets_dir


class HetznerSettings(BaseSettings):
"""Hetzner Cloud provider configuration."""
Expand All @@ -10,6 +12,8 @@ class HetznerSettings(BaseSettings):
env_file_encoding="utf-8",
env_prefix="HETZNER_",
extra="ignore",
hide_input_in_errors=True,
secrets_dir=get_secrets_dir(),
)

api_token: str = Field(
Expand Down
Loading