From d138fac4e4291f3cb5858ce44a52e141df7a12c0 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sat, 3 Oct 2026 13:34:06 +0200 Subject: [PATCH] DRU-708 -- Read deployment secrets from files A process reads a setting from /run/secrets/ when that directory exists, so a deployment can keep secrets out of the environment. The provider settings also hide their input in startup errors. --- docs/deploy.md | 42 ++++++++++++++++++++ docs/security.md | 5 ++- src/core/settings.py | 10 +++++ src/core/tests/test_settings.py | 57 ++++++++++++++++++++++++++++ src/networking/tailscale_settings.py | 4 +- src/providers/exe/settings.py | 4 ++ src/providers/exoscale/settings.py | 4 ++ src/providers/hetzner/settings.py | 4 ++ 8 files changed, 127 insertions(+), 3 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 97c3fb5..e537ca7 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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/`. 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 @@ -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 | diff --git a/docs/security.md b/docs/security.md index 145e232..636a783 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/src/core/settings.py b/src/core/settings.py index a3668f1..ae36726 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -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 @@ -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( diff --git a/src/core/tests/test_settings.py b/src/core/tests/test_settings.py index 14fcc91..7510245 100644 --- a/src/core/tests/test_settings.py +++ b/src/core/tests/test_settings.py @@ -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]: @@ -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(), diff --git a/src/networking/tailscale_settings.py b/src/networking/tailscale_settings.py index 0f56f6d..a8f7867 100644 --- a/src/networking/tailscale_settings.py +++ b/src/networking/tailscale_settings.py @@ -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): @@ -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( diff --git a/src/providers/exe/settings.py b/src/providers/exe/settings.py index 7572654..c67a727 100644 --- a/src/providers/exe/settings.py +++ b/src/providers/exe/settings.py @@ -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.""" @@ -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( diff --git a/src/providers/exoscale/settings.py b/src/providers/exoscale/settings.py index 353b2c5..fe585ad 100644 --- a/src/providers/exoscale/settings.py +++ b/src/providers/exoscale/settings.py @@ -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.""" @@ -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( diff --git a/src/providers/hetzner/settings.py b/src/providers/hetzner/settings.py index f880ac3..562624b 100644 --- a/src/providers/hetzner/settings.py +++ b/src/providers/hetzner/settings.py @@ -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.""" @@ -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(