Files
plm-lottery/app/config.py
T
davideandClaude Sonnet 5.5 40ac490826 Safe restore from a backup: START_PAUSED and the derivation-gap check (B-90, REG-15, OPS-27)
START_PAUSED=true pauses the lottery as the operator before the scheduler exists.
On the first Electrum session the 20 derivation indexes past the highest
assigned one are looked up on every server; if any has history, registration
stays closed (503 registration_unavailable) and restore_gap_detected is
written, so a restored database cannot hand a lost user's address to a new one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:43:45 +02:00

134 lines
6.2 KiB
Python

import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather
# than being shipped by accident.
MIN_JWT_SECRET_LENGTH = 32
# B-84 (ELC-19, ELC-14): fallback servers needed besides the primary. With fewer than
# three servers, a corroboration (which never asks the active one) cannot have two
# independent answers — one server would decide, or none would.
MIN_QUORUM_FALLBACK_SERVERS = 2
MIN_QUORUM_RESPONSES = 2
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
electrum_host: str = "santantonio.sytes.net"
electrum_port: int = 50002
electrum_use_ssl: bool = True
# Additional servers to fall back to, comma-separated `host:port[:notls]`.
# The listener rotates over primary + these (app/electrum/listener.py), so one
# unreachable server costs a single reconnect attempt instead of an outage:
# every deposit credit, broadcast and confirmation goes through this one
# connection, which makes a single hardcoded server the platform's biggest
# single point of failure. Parsed by electrum.client.parse_endpoints.
electrum_fallback_servers: str = ""
# B-84 (ELC-03): SHA-256 (hex) of the primary server's DER certificate. Empty = not
# pinned. Fallback servers carry their own `:pin=<sha256>` in the list above.
electrum_cert_pin: str = ""
# B-84 (ELC-19): development only. Lets the server start with fewer than
# MIN_QUORUM_FALLBACK_SERVERS fallback servers, and corroboration then follows the
# reduced rule (no other server: passes; otherwise a strict majority of however
# many answer, even one). Never for production: it is exactly the single-server
# trust the quorum exists to remove.
allow_no_quorum: bool = False
# B-90 (OPS-27): start with the lottery paused, as after restoring a backup.
start_paused: bool = False
xprv_encryption_key: str = ""
master_key_path: str = "./master.xprv.enc"
jwt_secret: str = ""
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24
admin_token: str = ""
# Swagger/ReDoc/OpenAPI JSON expose the entire API surface (admin endpoints
# included) to anyone who requests them. Off by default (B-42) — set to true
# only for local development, never in production.
enable_api_docs: bool = False
# Every business/round parameter (bet amount, round duration/cooldown,
# min amount, fee rate, RBF timeout, fee address) lives in the round_config
# DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) —
# editable live via the admin panel/API, no env var, no restart. Only true
# infra/secrets belong in this Settings class.
settings = Settings()
class ConfigError(Exception):
"""A misconfiguration serious enough that the app must refuse to serve."""
def validate_runtime_secrets(config: Settings | None = None) -> None:
"""Fail fast on secrets that would otherwise only break at first use: an empty
jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty
xprv_encryption_key makes Fernet fail on the first key derivation. Either way
the container comes up looking healthy and breaks the moment a real user
touches it.
Called from the app's lifespan (app/main.py) rather than as a Settings
field_validator on purpose: Settings is constructed at import time by every
module that reads config, including the test suite, which has no .env and no
business holding real secrets. At startup the guarantee still holds where it
matters — the server refuses to serve half-configured — without coupling every
import to a gitignored file.
ADMIN_TOKEN is deliberately not fatal: require_admin already denies every
request when it's empty, so the effect is a locked admin panel, not an open one.
"""
config = config or settings
problems = []
if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH:
problems.append(
f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters "
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
)
if not config.xprv_encryption_key.strip():
problems.append(
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
)
if problems:
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
def validate_quorum_servers(endpoints: list, config: Settings | None = None) -> None:
"""B-84 (ELC-19): refuse to serve with fewer than MIN_QUORUM_FALLBACK_SERVERS
fallback servers (`endpoints` is the deduplicated rotation: primary first, then the
fallbacks), unless ALLOW_NO_QUORUM=true. The old behaviour — an empty
ELECTRUM_FALLBACK_SERVERS, the .env.example default — silently made every
corroboration pass, i.e. one server decided the draw, the credits and the spends.
Called from the lifespan, after parse_endpoints, rather than from
validate_runtime_secrets: it needs the parsed list, and a Settings validator would
couple every import (tests included) to a populated .env.
"""
config = config or settings
fallbacks = len(endpoints) - 1
if fallbacks >= MIN_QUORUM_FALLBACK_SERVERS:
return
if config.allow_no_quorum:
logger.warning(
"ALLOW_NO_QUORUM=true: only %s fallback Electrum server(s) configured (%s needed) — "
"corroboration is reduced to the weaker rule. Development only, never in production.",
fallbacks,
MIN_QUORUM_FALLBACK_SERVERS,
)
return
raise ConfigError(
f"ELECTRUM_FALLBACK_SERVERS lists {fallbacks} usable fallback server(s) besides the primary, "
f"but at least {MIN_QUORUM_FALLBACK_SERVERS} are required so every corroboration has two independent "
"answers (set ALLOW_NO_QUORUM=true to override, development only)"
)