import os import secrets from pathlib import Path # The file in the data directory that holds the instance's own SECRET_KEY. SECRET_KEY_FILENAME = "secret_key" # Placeholder keys that sample files and older compose files have shipped. A # key anyone can read in this repository signs nothing safely. PLACEHOLDER_SECRET_KEYS = frozenset({"dev-secret-change-me", "change-me-in-production"}) def _read_secret(env_var: str, secret_file_var: str, default: str) -> str: """Read a config value from env var, or from a Docker secrets file.""" value = os.environ.get(env_var) if value: return value secret_file = os.environ.get(secret_file_var) if secret_file: try: with open(secret_file) as f: return f.read().strip() except FileNotFoundError: pass return default def load_or_create_secret_key(data_dir: Path) -> str | None: """The instance's own SECRET_KEY, kept in `data_dir` and made on first use. The first call writes a random key to `/secret_key`, readable by its owner only; every later call, from this process or another, reads that same key back. The file is published with a hard link, so two processes starting together agree on one key and a crash never leaves an empty file. Answers None when `data_dir` doesn't exist, which is a run outside the container. An empty key file raises rather than being replaced, because a new key would orphan everything sealed under the old one. """ if not data_dir.is_dir(): return None path = data_dir / SECRET_KEY_FILENAME if not path.exists(): tmp = data_dir / f".{SECRET_KEY_FILENAME}.{os.getpid()}" fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: with os.fdopen(fd, "w") as f: f.write(secrets.token_hex(32) + "\n") f.flush() os.fsync(f.fileno()) try: os.link(tmp, path) except FileExistsError: pass finally: tmp.unlink(missing_ok=True) key = path.read_text().strip() if not key: raise RuntimeError( f"{path} is empty. Restore it from a copy, or delete it to start " "with a new key (saved forge tokens and the backup secret are then " "entered again)." ) return key def _secret_key() -> tuple[str, str]: """SECRET_KEY and where it came from: "env", "data" or "ephemeral". An explicit key (SECRET_KEY or SECRET_KEY_FILE) wins and leaves the data directory's key file alone, so unsetting it returns to that key. With no explicit key the instance keeps its own in DATA_DIR. With neither, the process gets a random key of its own that dies with it. """ explicit = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "") if explicit: return explicit, "env" if os.environ.get("SECRET_KEY_FILE"): # Falling through to another key would orphan what the named one sealed. raise RuntimeError( f"SECRET_KEY_FILE={os.environ['SECRET_KEY_FILE']} is missing or empty." ) kept = load_or_create_secret_key(Path(os.environ.get("DATA_DIR", "/data"))) if kept: return kept, "data" return secrets.token_hex(32), "ephemeral" def _trusted_proxy_hops() -> int: """TRUSTED_PROXY_HOPS, default 1. The older TRUST_PROXY_HEADERS switch is still read when the hop count is unset: false means 0 hops. A value that is not a whole number reads as -1, which validate() refuses.""" raw = os.environ.get("TRUSTED_PROXY_HOPS", "").strip() if raw: try: return int(raw) except ValueError: return -1 if os.environ.get("TRUST_PROXY_HEADERS", "").strip().lower() in ("0", "false", "no"): return 0 return 1 class Config: DATABASE_URL: str = _read_secret( "DATABASE_URL", "DATABASE_URL_FILE", "postgresql+asyncpg://grimoire:grimoire@localhost:5432/grimoire", ) # Where the instance keeps its own SECRET_KEY. /data is a volume in every # shipped compose file, beside the model cache and diagnostics. DATA_DIR: str = os.environ.get("DATA_DIR", "/data") SECRET_KEY, SECRET_KEY_SOURCE = _secret_key() LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO") LOG_RETENTION_DAYS: int = int(os.environ.get("LOG_RETENTION_DAYS", "90")) # SMTP defaults (overridden by DB settings when configured via admin UI) SMTP_HOST: str = os.environ.get("SMTP_HOST", "") SMTP_PORT: int = int(os.environ.get("SMTP_PORT", "587")) SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "") SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "") SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "") SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Grimoire") SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes") BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/") # How many reverse proxies stand in front of the app; the client address # and scheme are read that many entries in from the right of the # forwarding headers (grimoire/proxy.py). 0 trusts no proxy. TRUSTED_PROXY_HOPS: int = _trusted_proxy_hops() # Git URL of the repo that ships this Grimoire plugin. There is one correct # value per deployment (the repo serving the plugin), so it's a config # default — not something each user types. Surfaced in Settings → MCP Access # as the default install-command marketplace; an admin can still override it # per-instance via the Plugin marketplace setting. PLUGIN_MARKETPLACE_URL: str = os.environ.get( "PLUGIN_MARKETPLACE_URL", "https://git.fabledsword.com/bvandeusen/FabledGrimoire.git", ) # OIDC / OAuth2 SSO (e.g. Authentik) OIDC_ISSUER: str = os.environ.get("OIDC_ISSUER", "") OIDC_CLIENT_ID: str = os.environ.get("OIDC_CLIENT_ID", "") OIDC_CLIENT_SECRET: str = _read_secret("OIDC_CLIENT_SECRET", "OIDC_CLIENT_SECRET_FILE", "") OIDC_SCOPES: str = os.environ.get("OIDC_SCOPES", "openid profile email") LOCAL_AUTH_ENABLED: bool = os.environ.get("LOCAL_AUTH_ENABLED", "true").lower() not in ("0", "false", "no") # SearXNG web search (external instance). Currently only surfaced via # /api/settings/search for the Integrations tab's status indicator — # the MCP layer doesn't proxy web search (Claude has its own). SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "") # Git forge integration — optional read access to a git forge so # snippet bodies can be fetched/verified server-side. Connections are # per-user keyring rows (Settings → Git forges); these env values # survive as an implicit keyring entry for ADMIN users' projects only, so # a deployment can keep the operator's token in a Docker secret instead # of the database. A stored row for the same host wins over the env entry. FORGE_KIND: str = os.environ.get("FORGE_KIND", "") FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/") FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "") FORGE_WEBHOOK_SECRET: str = _read_secret( "FORGE_WEBHOOK_SECRET", "FORGE_WEBHOOK_SECRET_FILE", "" ) @classmethod def oidc_enabled(cls) -> bool: return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET) @classmethod def searxng_enabled(cls) -> bool: return bool(cls.SEARXNG_URL) @classmethod def validate(cls) -> None: """Validate critical config values at startup. Raises ValueError on misconfiguration.""" errors: list[str] = [] if cls.LOG_RETENTION_DAYS < 1: errors.append(f"LOG_RETENTION_DAYS={cls.LOG_RETENTION_DAYS} must be >= 1") if not (1 <= cls.SMTP_PORT <= 65535): errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535") if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")): errors.append(f"BASE_URL='{cls.BASE_URL}' must start with http:// or https:// when OIDC is enabled") if cls.TRUSTED_PROXY_HOPS < 0: errors.append( f"TRUSTED_PROXY_HOPS={os.environ.get('TRUSTED_PROXY_HOPS')!r} must be a whole " "number of proxies, 0 or more" ) if cls.SECRET_KEY in PLACEHOLDER_SECRET_KEYS: errors.append( f"SECRET_KEY is set to the public placeholder {cls.SECRET_KEY!r}, so anyone " "could forge a session. Remove SECRET_KEY to let the instance keep its own " "key in DATA_DIR, or set a real one." ) if errors: raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))