An empty ELECTRUM_FALLBACK_SERVERS (the .env.example default) silently made every corroboration pass, and with fallbacks the majority was taken over whoever answered, so one reachable fallback decided alone. TLS certificates were never checked. - _corroborate_majority passes only with at least 2 answers (MIN_QUORUM_RESPONSES) and a strict majority of the answers; no other server configured, or fewer than 2 answering, fails. ALLOW_NO_QUORUM=true keeps the old reduced rule. - config.validate_quorum_servers, called from the lifespan after parse_endpoints, refuses to start with fewer than 2 fallback servers unless ALLOW_NO_QUORUM=true (which logs a warning at every start). - Optional pinning: `host:port[:mode]:pin=<sha256>` per fallback and ELECTRUM_CERT_PIN for the primary; ElectrumClient compares the SHA-256 of the DER certificate after the TLS handshake and a mismatch fails like an unreachable server. A pin on a plaintext entry is a startup error. - .env.example now ships the four bootstrap servers and documents both new variables; docs/setup.md, spec, storico and BUGS.md updated. - Tests changed because the spec changed: the two "no other servers configured -> True" corroboration tests now assert that outcome only under allow_no_quorum. Note: a dev .env with fewer than 2 fallback servers will now refuse to start. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
131 lines
6.0 KiB
Python
131 lines
6.0 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
|
|
|
|
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)"
|
|
)
|