Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)

- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the
  container healthcheck; the scheduler beats on every tick and draw-wait iteration
- GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation)
- GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters
- registration writes user_registered in its transaction
- the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed
  address only, so the access log shows the client's IP

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 01:15:22 +02:00
1 parent 9520c80dcf
commit 575f1a5354
23 files changed
+478 -91

No files matched your search

+1 -11
View File
@@ -1,7 +1,7 @@
# BUGS.md — open findings
Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
(B-92, B-93). A finding is removed from this file once it is fixed, and
(B-93). A finding is removed from this file once it is fixed, and
`git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's
convention each entry gets its own commit with its own regression test, and the `B-nn`
marker goes in a comment next to the fix.
@@ -35,16 +35,6 @@ decisions, see `docs/limiti-noti.md`):
## Medium
### B-92 — No health, readiness or real audit browsing
`/health` always answers ok; nothing exposes Electrum state; the audit endpoint
returns only the latest 500 rows unfiltered; registration writes no audit row; the
access log records Caddy's IP; app logs go only to the file.
Fix: `GET /health/ready` (Electrum connected, tip age < 1800s, scheduler heartbeat <
30s) used as the container healthcheck; Electrum status in `/admin`; audit filters by
event type, round, user and time, with pagination; `user_registered` audit row with
index and address; uvicorn proxy headers from Caddy only; logs also to stdout.
### B-93 — Deploys and rollbacks
+2 -2
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 749 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 758 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 749 tests
python -m pytest # all 758 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
+1 -1
View File
@@ -22,4 +22,4 @@ USER 1000:1000
EXPOSE 8123
# umask 077: the database, its WAL files and the logs are created readable by the app user only.
CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
749 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
758 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+71 -3
View File
@@ -1,7 +1,8 @@
import json
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, Query, status
from fastapi import APIRouter, Depends, Header, Query, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -369,13 +370,54 @@ class AdminAuditLogResponse(BaseModel):
created_at: str
def _parse_instant(name: str, value: str | None) -> datetime | None:
"""An ISO 8601 instant with an offset, as UTC; anything else is a 422 (API-37)."""
if value is None:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
parsed = None
if parsed is None or parsed.tzinfo is None:
raise http_error(
status.HTTP_422_UNPROCESSABLE_CONTENT,
"invalid_request",
"invalid request",
errors=[{"loc": ["query", name], "msg": "an ISO 8601 instant with an offset is required", "type": "value_error"}],
)
return parsed.astimezone(timezone.utc)
@router.get(
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
)
async def list_audit_log(
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
session: AsyncSession = Depends(get_session),
limit: int = Query(default=200, ge=1, le=500),
before_id: int | None = Query(default=None),
event_type: list[str] | None = Query(default=None),
round_id: int | None = Query(default=None),
user_id: int | None = Query(default=None),
from_: str | None = Query(default=None, alias="from"),
to: str | None = Query(default=None),
) -> list[AdminAuditLogResponse]:
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
"""B-92 (API-37): newest first, a page at a time, every filter optional and combined with
"and". The next page asks for `before_id` equal to the last id received."""
query = select(AuditLog).order_by(AuditLog.id.desc()).limit(limit)
if before_id is not None:
query = query.where(AuditLog.id < before_id)
if event_type:
query = query.where(AuditLog.event_type.in_(event_type))
if round_id is not None:
query = query.where(AuditLog.round_id == round_id)
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
start, end = _parse_instant("from", from_), _parse_instant("to", to)
if start is not None:
query = query.where(AuditLog.created_at >= start)
if end is not None:
query = query.where(AuditLog.created_at < end)
entries = (await session.scalars(query)).all()
return [
AdminAuditLogResponse(
id=e.id,
@@ -517,3 +559,29 @@ async def update_bug_report_status(
username = user.username if user is not None else None
return _bug_report_response(report, username)
class AdminElectrumResponse(BaseModel):
connected: bool
endpoint: str | None
tip_height: int
last_header_age_seconds: int | None
fallback_count: int
quorum_enforced: bool
last_rotation: dict | None
@router.get("/electrum", response_model=AdminElectrumResponse, dependencies=[Depends(require_admin)])
async def electrum_status(request: Request) -> AdminElectrumResponse:
"""B-92 (API-47, ELC-29): the connection to the network, for the panel's indicator."""
listener = request.app.state.electrum_listener
connected = listener.client is not None
return AdminElectrumResponse(
connected=connected,
endpoint=str(listener.current_endpoint) if connected and listener.current_endpoint else None,
tip_height=listener.tip_height,
last_header_age_seconds=listener.last_header_age_seconds(),
fallback_count=listener.fallback_count,
quorum_enforced=listener.quorum_enforced,
last_rotation=listener.last_rotation,
)
+10
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip as _client_ip
from app.api.errors import http_error
from app.audit.log import write_audit_log
from app.auth.rate_limit import AuthRateLimiters
from app.auth.security import (
MIN_PASSWORD_LENGTH,
@@ -121,6 +122,15 @@ async def register(
)
session.add(user)
try:
# B-92 (AUD-41, REG-16): in the same transaction as the user, so after a restore the log
# says which derivation indexes were already assigned.
await session.flush()
await write_audit_log(
session,
"user_registered",
{"derivation_index": next_index, "address": address},
user_id=user.id,
)
await session.commit()
except IntegrityError as exc:
await session.rollback()
+33 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import time
from datetime import datetime, timezone
from collections import Counter
from collections.abc import Callable
@@ -79,6 +80,14 @@ _RESTORE_GAP_WINDOW = 20
_MAX_HEADERS_PER_REQUEST = 2016
def _rotation_reason(exc: BaseException) -> str:
if isinstance(exc, ServerStalledError):
return "stalled_tip"
if isinstance(exc, HeaderValidationError):
return "invalid_header"
return "error"
class ServerStalledError(Exception):
"""The active server stopped following the chain while others report a higher tip
(B-86, ELC-21). Ends the session like a dropped connection, so run() rotates."""
@@ -119,6 +128,9 @@ class ElectrumListener:
self._allow_no_quorum = allow_no_quorum
self._endpoint_index = 0
self._restore_checked = False
# B-92 (API-47): why and when the listener last moved on to another server.
self.last_rotation: dict | None = None
self._end_reason: str | None = None
self._refresh_locks: dict[int, asyncio.Lock] = {}
self._scripthash_to_user: dict[str, int] = {}
# Retains address_for_new_user's fire-and-forget subscribe task so it
@@ -144,6 +156,18 @@ class ElectrumListener:
# Users with a refresh already running because of a new block (see _consume_headers).
self._block_refreshes: set[int] = set()
def last_header_age_seconds(self) -> int | None:
"""B-92 (ELC-29): seconds since a header was last accepted, None before the first."""
return None if self._last_header_at is None else int(_monotonic() - self._last_header_at)
@property
def fallback_count(self) -> int:
return max(len(self._endpoints) - 1, 0)
@property
def quorum_enforced(self) -> bool:
return not self._allow_no_quorum
@property
def current_endpoint(self) -> ElectrumEndpoint | None:
"""Which server the next (or current) session uses — for logging and for
@@ -190,11 +214,13 @@ class ElectrumListener:
logger.error("no Electrum endpoints configured; listener idle")
return
connected = False
self._end_reason = None
try:
connected = await self._run_once(endpoint)
except asyncio.CancelledError:
raise
except Exception:
except Exception as exc:
self._end_reason = _rotation_reason(exc)
logger.exception("Electrum session on %s failed", endpoint)
finally:
self.client = None
@@ -203,6 +229,11 @@ class ElectrumListener:
# server that just dropped us has no claim on being tried first again.
if len(self._endpoints) > 1:
self._endpoint_index = (self._endpoint_index + 1) % len(self._endpoints)
self.last_rotation = {
"reason": self._end_reason or ("connection_closed" if connected else "unreachable"),
"from": str(endpoint),
"at": datetime.now(timezone.utc).isoformat(),
}
if connected:
# We did reach a server, so the network is up — don't let an earlier
@@ -294,6 +325,7 @@ class ElectrumListener:
for task in done:
exc = task.exception()
if exc is not None:
self._end_reason = _rotation_reason(exc)
logger.warning("Electrum session on %s ending: %r", endpoint, exc)
finally:
self.client = None
+8
View File
@@ -1,4 +1,5 @@
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
@@ -16,9 +17,16 @@ def setup_logging() -> None:
handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
handler.setFormatter(logging.Formatter(_FORMAT))
# B-92 (ARC-09): also on stdout, so `docker compose logs app` shows the app's own messages
# (scheduler, listener, reconcilers) next to uvicorn's and the startup output. Only on the
# root logger: uvicorn's loggers already print to the console by themselves.
console = logging.StreamHandler(sys.stdout)
console.setFormatter(logging.Formatter(_FORMAT))
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
root.addHandler(console)
# "uvicorn.error"/"uvicorn.access" have propagate=False in uvicorn's default
# logging config, so they never reach the root handler above and need their
+30
View File
@@ -72,6 +72,7 @@ async def lifespan(app: FastAPI):
app.state.user_locks = UserLocks()
scheduler = RoundScheduler(AsyncSessionLocal, listener)
app.state.scheduler = scheduler # B-92: /health/ready reads its heartbeat
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
# Resolves in-flight transactions against the chain — the piece that lets the
@@ -158,6 +159,35 @@ async def health() -> dict[str, str]:
return {"status": "ok"}
# B-92 (API-46, ELC-29, ARC-61): the header age is generous on purpose — with 120 s blocks, 360 s
# of silence happens by chance about 5% of the time (e^-3), and a healthcheck must not flap on that.
READY_MAX_HEADER_AGE_SECONDS = 1800
READY_MAX_SCHEDULER_HEARTBEAT_SECONDS = 30
@app.get("/health/ready", include_in_schema=False)
async def health_ready() -> JSONResponse:
listener = getattr(app.state, "electrum_listener", None)
scheduler = getattr(app.state, "scheduler", None)
header_age = listener.last_header_age_seconds() if listener is not None else None
heartbeat_age = scheduler.heartbeat_age_seconds() if scheduler is not None else None
checks = {
"electrum_connected": listener is not None and listener.client is not None,
"last_header_age_seconds": header_age,
"scheduler_heartbeat_age_seconds": heartbeat_age,
}
ready = (
checks["electrum_connected"]
and header_age is not None
and header_age < READY_MAX_HEADER_AGE_SECONDS
and heartbeat_age is not None
and heartbeat_age < READY_MAX_SCHEDULER_HEARTBEAT_SECONDS
)
return JSONResponse(
status_code=200 if ready else 503, content={"status": "ready" if ready else "not_ready", "checks": checks}
)
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
+9
View File
@@ -1,4 +1,5 @@
import asyncio
import time
import logging
from datetime import datetime, timedelta, timezone
@@ -62,9 +63,16 @@ class RoundScheduler:
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
self._session_factory = session_factory
self._listener = listener
# B-92 (ARC-61): monotonic time of the last tick or draw-wait iteration, read by
# /health/ready. The draw wait runs inside a tick and lasts several blocks, so it beats too.
self.last_heartbeat: float | None = None
def heartbeat_age_seconds(self) -> int | None:
return None if self.last_heartbeat is None else int(time.monotonic() - self.last_heartbeat)
async def run(self) -> None:
while True:
self.last_heartbeat = time.monotonic()
try:
await self._tick()
except asyncio.CancelledError:
@@ -409,6 +417,7 @@ class RoundScheduler:
reported_hashes: set[str | None] = set()
while True:
self.last_heartbeat = time.monotonic()
now = datetime.now(timezone.utc)
tip = self._listener.tip_height
if tip >= required_tip:
+13 -1
View File
@@ -37,6 +37,7 @@
<span id="chain-status-label">Connessione…</span>
</span>
<span class="chain-block mono" id="chain-block">Blocco —</span>
<span class="chain-block mono" id="electrum-status">Electrum —</span>
<button class="secondary" style="margin:8px 0 8px 14px" onclick="adminLogout()">Esci</button>
</nav>
@@ -153,7 +154,17 @@
<div class="view" id="view-audit">
<h2 class="section-title">Audit log</h2>
<p class="hint">Ultimi eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.).</p>
<p class="hint">Eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.), dal più recente. I filtri si combinano; "Carica altri" prosegue dall'ultimo evento mostrato.</p>
<div class="card">
<div class="row-between" style="flex-wrap:wrap;gap:8px">
<input id="audit-f-type" placeholder="Tipo (es. payout_failed)" style="width:200px" />
<input id="audit-f-round" placeholder="Round" inputmode="numeric" style="width:90px" />
<input id="audit-f-user" placeholder="Utente" inputmode="numeric" style="width:90px" />
<input id="audit-f-from" type="datetime-local" title="Da" />
<input id="audit-f-to" type="datetime-local" title="A" />
<button class="secondary" style="width:auto;margin-top:0" onclick="loadAuditLog()">Filtra</button>
</div>
</div>
<div class="card">
<div class="table-wrap">
@@ -164,6 +175,7 @@
<tbody id="audit-tbody"></tbody>
</table>
</div>
<button id="audit-more" class="secondary hidden" style="width:auto" onclick="loadAuditLog(true)">Carica altri</button>
</div>
</div>
+45 -5
View File
@@ -76,6 +76,26 @@ async function refreshChainStatus() {
} catch (e) {
// leave the last-known status on screen rather than blanking it out
}
refreshElectrumStatus();
}
// B-92 (API-47): which server the platform is talking to and how fresh its last block is.
async function refreshElectrumStatus() {
const el = document.getElementById('electrum-status');
if (!adminToken) return;
try {
const s = await callAdmin('GET', '/admin/electrum');
const age = s.last_header_age_seconds;
el.textContent = s.connected
? `Electrum ${s.endpoint}${age != null ? ' · ultimo blocco ' + age + 's fa' : ''}`
: 'Electrum non connesso';
el.title = s.last_rotation
? `Ultimo cambio server: ${s.last_rotation.from} (${s.last_rotation.reason}) alle ${fmtDate(s.last_rotation.at)}`
: '';
if (!s.quorum_enforced) el.title += ' · quorum non imposto (ALLOW_NO_QUORUM)';
} catch (e) {
// keep the last text
}
}
function startChainStatusPolling() {
@@ -90,7 +110,7 @@ function stopChainStatusPolling() {
}
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: () => loadAuditLog(), bugreports: loadBugReports };
let currentAdminView = 'parametri';
function switchView(name) {
@@ -345,11 +365,29 @@ async function loadPending() {
}
}
async function loadAuditLog() {
let auditLastId = null;
const AUDIT_PAGE = 200;
async function loadAuditLog(more = false) {
try {
const entries = await callAdmin('GET', '/admin/audit-log');
// B-92 (API-37): filters combine; "more" continues from the last id shown.
const params = new URLSearchParams({ limit: String(AUDIT_PAGE) });
const type = document.getElementById('audit-f-type').value.trim();
if (type) params.append('event_type', type);
for (const [id, key] of [['audit-f-round', 'round_id'], ['audit-f-user', 'user_id']]) {
const v = document.getElementById(id).value.trim();
if (v) params.append(key, v);
}
for (const [id, key] of [['audit-f-from', 'from'], ['audit-f-to', 'to']]) {
const v = document.getElementById(id).value;
if (v) params.append(key, new Date(v).toISOString());
}
if (more === true && auditLastId !== null) params.append('before_id', String(auditLastId));
const entries = await callAdmin('GET', '/admin/audit-log?' + params);
auditLastId = entries.length ? entries[entries.length - 1].id : auditLastId;
document.getElementById('audit-more').classList.toggle('hidden', entries.length < AUDIT_PAGE);
const tbody = document.getElementById('audit-tbody');
tbody.innerHTML = entries.map((e) => `
const rows = entries.map((e) => `
<tr>
<td>${e.id}</td>
<td>${escapeHtml(e.event_type)}</td>
@@ -358,7 +396,9 @@ async function loadAuditLog() {
<td>${e.round_id ?? '—'}</td>
<td>${fmtDate(e.created_at)}</td>
</tr>
`).join('') || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
`).join('');
if (more === true) tbody.insertAdjacentHTML('beforeend', rows);
else tbody.innerHTML = rows || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
} catch (e) {
toast('Errore nel caricamento audit log: ' + e.message, 'error');
}
+25
View File
@@ -20,6 +20,9 @@ services:
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
- XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key
# B-92 (ARC-02): uvicorn trusts X-Forwarded-For from Caddy's fixed address only, so the access
# log records the client's IP instead of Caddy's.
- FORWARDED_ALLOW_IPS=172.28.0.10
secrets:
- xprv_encryption_key
volumes:
@@ -28,6 +31,18 @@ services:
- ./data/logs:/app/logs
expose:
- "8123"
# B-92 (ARC-61): ready = Electrum connected, a recent header, a live scheduler. Docker does not
# restart an unhealthy container (the process exiting does); the state is for monitoring.
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request as u; u.urlopen('http://127.0.0.1:8123/health/ready', timeout=4)"
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
depends_on:
data-permissions:
condition: service_completed_successfully
@@ -44,9 +59,19 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
default:
ipv4_address: 172.28.0.10
depends_on:
- app
# A fixed subnet and a fixed address for Caddy, so uvicorn can trust exactly that one proxy (ARC-02).
networks:
default:
ipam:
config:
- subnet: 172.28.0.0/24
# The Fernet key that protects the master key lives in its own file, outside ./data (a backup of
# the data does not carry both) and in no environment variable (SEC-53).
secrets:
+5 -13
View File
@@ -50,9 +50,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) |
| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) |
| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) |
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) — *da implementare (B-92)* |
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) |
| `GET` | `/health` | nessuna | [API-41](#get-health) |
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* |
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) |
| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) |
| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) |
@@ -314,7 +314,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
[registrazione-e-depositi.md](registrazione-e-depositi.md).
*Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py`
*Stato:* da implementare (B-92) · *Oggi:* non scrive audit.
#### POST /auth/login
@@ -886,10 +885,7 @@ dei parametri operativi in
- **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto),
"user_id", "round_id", "created_at"}`, per `id` decrescente.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
solo `limit`, quindi oltre le ultime 500 voci il registro non è
consultabile dall'API.
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_observability.py`
#### GET /admin/pending-transactions
@@ -918,9 +914,7 @@ dei parametri operativi in
`last_rotation` (motivo e ora dell'ultimo cambio di server, ad esempio punta
ferma, [connessione-electrum.md](connessione-electrum.md) ELC-21).
*Codice:* `app/api/routes/admin.py`, `app/electrum/listener.py:ElectrumListener.current_endpoint` ·
*Test:* —
*Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste;
`/admin` mostra solo l'altezza della catena letta da `GET /rounds/current`.
*Test:* `tests/unit/test_observability.py`
#### GET /admin/bug-reports
@@ -975,9 +969,7 @@ dei parametri operativi in
`scheduler_heartbeat_age_seconds` (intero o `null`). Nessuna autenticazione,
nessun dato sensibile: non espone né il server attivo né l'altezza. È il
`healthcheck` del container.
*Codice:* `app/main.py:health` · *Test:* —
*Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste; esiste solo
`GET /health` ([API-41]).
*Codice:* `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
#### Pagine e file statici
+7 -15
View File
@@ -19,7 +19,7 @@ flowchart LR
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
A -- "legge" --> K["./data/keys/master.xprv.enc"]
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout, B-92)"]
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout)"]
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
```
@@ -44,9 +44,8 @@ flowchart LR
stato in memoria descritto in
[Assunzioni di processo singolo](#assunzioni-di-processo-singolo) dipende da
questo.
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml`
*Stato:* da implementare (B-92) · *Oggi:* uvicorn non legge gli header del
proxy e il log di accesso registra l'IP di Caddy.
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml` ·
*Test:* `tests/unit/test_observability.py`
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
@@ -94,9 +93,7 @@ flowchart LR
([operativita.md](operativita.md#segnali-da-tenere-docchio)), e il riavvio
resta affidato a `restart: unless-stopped` quando il processo esce.
*Codice:* `docker-compose.yml` · `app/main.py:health` ·
*Test:* —
*Stato:* da implementare (B-92) · *Oggi:* nessun `healthcheck`; esiste solo
`/health`, che risponde sempre `ok` ([ARC-17]).
*Test:* `tests/unit/test_observability.py`
### Database
@@ -138,10 +135,8 @@ flowchart LR
`docker compose logs` li mostra senza entrare nel volume. La configurazione
avviene all'import di `app/main.py`, prima di qualunque altro modulo
applicativo.
*Codice:* `app/logging_config.py:setup_logging`
*Stato:* da implementare (B-92) · *Oggi:* i log dell'applicazione vanno solo
nel file; su console arrivano solo quelli di uvicorn, per la sua
configurazione predefinita.
*Codice:* `app/logging_config.py:setup_logging` ·
*Test:* `tests/unit/test_observability.py`
## Mappa del codice
@@ -771,13 +766,10 @@ flowchart LR
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
filtri dell'audit log (B-86, B-92).
filtri dell'audit log (B-92).
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
*Stato:* da implementare (B-92), solo per lo stato Electrum e i
filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
e l'audit log le ultime righe senza filtri.
### `/report-bug` e `/guida`
+3 -12
View File
@@ -81,9 +81,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
[api.md](api.md#get-adminaudit-log) (API-37), che ne è il proprietario.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
solo `limit`; le righe più vecchie delle ultime 500 si leggono solo
direttamente dal database.
- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza
parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la
@@ -256,9 +253,7 @@ flowchart LR
`user_id` sì (il nuovo utente), `round_id` no. Stessa transazione che crea
l'utente. È ciò che, dopo un ripristino, permette di ricostruire quali
indici erano già assegnati. Nessuna logica rilegge la riga.
*Codice:* `app/auth/routes.py:register` · *Test:* —
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
evento.
*Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
- **[AUD-42]** `password_changed` — un utente ha cambiato la propria password
con `POST /users/me/change-password`
@@ -615,7 +610,6 @@ flowchart LR
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/confirmation.py:_on_payout_confirmed`,
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
*Stato:* da implementare (B-92), per `user_registered`
## Log applicativo
@@ -638,11 +632,8 @@ va letto sul server.
container, quindi `docker compose logs app` li mostra insieme all'output di
avvio del container (controllo della chiave, migrazioni Alembic). La regola
è di [ARC-09](architettura.md#log), che ne è il proprietario.
*Codice:* `app/logging_config.py:setup_logging`
*Stato:* da implementare (B-92) · *Oggi:* il logger radice ha solo
l'handler su file: `docker compose logs app` mostra solo l'output di avvio
del container e ciò che uvicorn scrive con la propria configurazione
(avvio, richieste).
*Codice:* `app/logging_config.py:setup_logging` ·
*Test:* `tests/unit/test_observability.py`
- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata
per intero nel log (`Unhandled error on <metodo> <percorso>`) e al client
+2 -5
View File
@@ -348,10 +348,7 @@ B-83).
una soglia di 360 s scatterebbe a vuoto ogni ventina di blocchi,
e^-3 ≈ 5%; la rotazione di [ELC-21] resta a 360 s perché agisce solo se un
altro server ha davvero una punta più alta),
[api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza
[api.md](api.md) API-46) e l'indicatore Electrum di `/admin`. L'altezza
corrente resta visibile anche nella barra di stato della catena di `/` (da
`chain_tip_height` di `GET /rounds/current`).
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* —
*Stato:* da implementare (B-92) · *Oggi:* `/health` risponde sempre
`ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin`
mostra solo l'altezza.
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
+3 -3
View File
@@ -165,9 +165,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
(le scrive solo il reset da parte dell'admin).
- *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia
avvenuto l'accesso.
- *Stato:* da chiudere in parte con B-92 (`user_registered`, con indice di
derivazione e indirizzo); chiusa da B-94 la parte `password_changed`. Login
riusciti e falliti restano senza voce di audit dopo il piano.
- *Stato:* chiuso in parte da B-92 (`user_registered`, con indice di
derivazione e indirizzo) e da B-94 (`password_changed`). Login riusciti e
falliti restano senza voce di audit.
- *Per chiuderlo:* eventi di audit per questi casi (vedi
[audit-log.md](audit-log.md)).
+7 -7
View File
@@ -83,7 +83,7 @@ dovranno allora rispettare quel proprietario.
[Segnali da tenere d'occhio](#segnali-da-tenere-docchio)).
*Codice:* `app/main.py:health`
Dopo B-92 esiste anche `GET /health/ready`, che risponde 503 se la
Esiste anche `GET /health/ready`, che risponde 503 se la
connessione Electrum manca, l'ultimo header è vecchio o lo scheduler non gira
([api.md](api.md) API-46), ed è il `healthcheck` del container
([architettura.md](architettura.md) ARC-61). Un container `unhealthy` **non**
@@ -389,7 +389,7 @@ container (controllo della chiave, migrazioni) e ciò che uvicorn scrive per
conto suo (avvio, richieste, errori di avvio). Vedi
[audit-log.md](audit-log.md#log-applicativo).
Dopo B-92 il log applicativo va anche sull'output del container e si legge
Il log applicativo va anche sull'output del container e si legge
con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
### Electrum irraggiungibile
@@ -849,17 +849,17 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
| Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` |
| Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB |
| Container | `docker compose ps` | `app` che si riavvia in continuazione: rifiuto all'avvio ([OPS-02]) |
| Prontezza (dopo B-92) | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
| Prontezza | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
| Catena e sistema (dopo B-78, B-82, B-85, B-87, B-90) | Audit: `chain_reorg_detected`, `pending_tx_resurrected`, `payout_blocked`, `participant_reorged_out`, `rbf_bump_failed`, `restore_gap_detected`; `paused_reason` diverso da `operator` | Qualunque occorrenza ([OPS-30], [OPS-28]) |
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
`app/logging_config.py:setup_logging`
Dopo B-92 l'indicatore Electrum di `/admin` mostra lo stato della
L'indicatore Electrum di `/admin` mostra lo stato della
connessione ([ELC-29](connessione-electrum.md#stato-della-connessione)):
connessa o no, server attivo, altezza, età dell'ultimo header. Oggi mostra
solo l'ultima altezza nota ("Blocco #N"), che resta visibile anche a
connessione caduta.
connessa o no, server attivo, età dell'ultimo header e, al passaggio del
mouse, l'ultimo cambio di server con il motivo; la barra mostra anche l'ultima
altezza nota ("Blocco #N"), che resta visibile a connessione caduta.
## Prova su rete reale
+1 -3
View File
@@ -101,9 +101,7 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
- **[REG-16]** Ogni registrazione riuscita scrive l'evento `user_registered`
con l'indice di derivazione e l'indirizzo assegnati, nella stessa
transazione che crea l'utente.
*Codice:* `app/auth/routes.py:register` · *Test:* —
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
evento di audit.
*Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
### Sessione e password
+2 -4
View File
@@ -789,10 +789,8 @@ La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
`decrypt_master_key.py`, per scelta e dopo conferma. WIF e password
generate dal reset compaiono solo nella risposta HTTP all'operatore.
*Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`,
`app/api/routes/admin.py:reset_user_password`
*Stato:* da implementare (B-92), per stdout e IP del client · *Oggi:* il
log va solo nel file, e il log di accesso registra l'indirizzo di Caddy,
non quello del client ([ARC-02](architettura.md#panoramica-del-deployment)).
`app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_observability.py`
## Rischi accettati
+4 -4
View File
@@ -240,7 +240,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | *da registrare* | `test_container_hardening.py`, `test_config.py` | SEC, OPS |
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | *da registrare* | `test_observability.py` | OPS, AUD, API |
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API |
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET |
@@ -254,8 +254,8 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-94, B-95, B-96, B-98, B-99, B-100.
- **Aperti:** dalla revisione del 2026-09-28, B-92 e B-93 (0 critici,
0 alti, 2 medi), da implementare nell'ordine di
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-92, B-94, B-95, B-96, B-98, B-99, B-100.
- **Aperti:** dalla revisione del 2026-09-28, B-93 (0 critici,
0 alti, 1 medio), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-101`.
+195
View File
@@ -0,0 +1,195 @@
"""B-92 (API-37, API-46, API-47, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29): readiness, Electrum
status, audit browsing, the registration row and the logs."""
import json
import logging
import sys
from datetime import datetime, timedelta, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from tests.unit.test_abuse_limits import ADMIN, _register, client # noqa: F401 (fixture + helper)
@pytest.fixture
def listener(client):
from app.main import app
return app.state.electrum_listener
# --- /health/ready -----------------------------------------------------------------
def _ready_app(listener, *, header_age, heartbeat_age, connected=True):
from types import SimpleNamespace
from app.main import app
listener.client = object() if connected else None
listener.last_header_age_seconds = lambda: header_age
app.state.scheduler = SimpleNamespace(heartbeat_age_seconds=lambda: heartbeat_age)
async def test_ready_only_when_connected_fresh_and_alive(client, listener):
_ready_app(listener, header_age=100, heartbeat_age=5)
ok = await client.get("/health/ready")
assert ok.status_code == 200 and ok.json() == {
"status": "ready",
"checks": {"electrum_connected": True, "last_header_age_seconds": 100, "scheduler_heartbeat_age_seconds": 5},
}
for kwargs in (
{"header_age": 100, "heartbeat_age": 5, "connected": False},
{"header_age": 1800, "heartbeat_age": 5},
{"header_age": None, "heartbeat_age": 5},
{"header_age": 100, "heartbeat_age": 30},
{"header_age": 100, "heartbeat_age": None},
):
_ready_app(listener, **kwargs)
bad = await client.get("/health/ready")
assert bad.status_code == 503 and bad.json()["status"] == "not_ready", kwargs
async def test_a_360_second_gap_between_blocks_is_not_unreadiness(client, listener):
"""With 120 s blocks, 360 s of silence happens ~5% of the time by chance."""
_ready_app(listener, header_age=400, heartbeat_age=5)
assert (await client.get("/health/ready")).status_code == 200
def test_the_scheduler_beats_on_every_tick(monkeypatch):
import asyncio
from app.rounds.scheduler import RoundScheduler
scheduler = RoundScheduler(None, None)
assert scheduler.heartbeat_age_seconds() is None
async def one_tick():
async def fake_tick():
raise asyncio.CancelledError
scheduler._tick = fake_tick
with pytest.raises(asyncio.CancelledError):
await scheduler.run()
asyncio.run(one_tick())
assert scheduler.heartbeat_age_seconds() == 0
# --- /admin/electrum ---------------------------------------------------------------
async def test_the_electrum_status_endpoint(client, listener):
from app.electrum.client import ElectrumEndpoint
listener._endpoints = [ElectrumEndpoint("a.example", 50002, True), ElectrumEndpoint("b.example", 50002, True)]
listener.client = object()
listener.tip_height = 538900
listener.last_rotation = {"reason": "stalled_tip", "from": "x:1", "at": "2026-10-18T00:00:00+00:00"}
body = (await client.get("/admin/electrum", headers=ADMIN)).json()
assert body["connected"] is True and body["endpoint"] == str(listener.current_endpoint)
assert body["tip_height"] == 538900 and body["fallback_count"] == 1 and body["quorum_enforced"] is True
assert body["last_rotation"]["reason"] == "stalled_tip"
listener.client = None
assert (await client.get("/admin/electrum", headers=ADMIN)).json()["endpoint"] is None
assert (await client.get("/admin/electrum")).status_code == 403
async def test_the_listener_records_why_it_moved_to_another_server():
from app.electrum.listener import ServerStalledError, _rotation_reason
from app.rounds.draw import HeaderValidationError
assert _rotation_reason(ServerStalledError("x")) == "stalled_tip"
assert _rotation_reason(HeaderValidationError("x")) == "invalid_header"
assert _rotation_reason(RuntimeError("x")) == "error"
# --- /admin/audit-log ---------------------------------------------------------------
async def _seed_audit(rows):
from app.db import base as db_base
from app.db.models import AuditLog
async with db_base.AsyncSessionLocal() as session:
for event, user_id, round_id, at in rows:
session.add(AuditLog(event_type=event, payload_json="{}", user_id=user_id, round_id=round_id, created_at=at))
await session.commit()
async def test_the_audit_log_is_filtered_and_paged(client):
now = datetime(2026, 10, 1, 12, tzinfo=timezone.utc)
await _seed_audit(
[(f"event_{i % 3}", i % 2, 10 + i % 2, now + timedelta(hours=i)) for i in range(12)]
)
async def ids(**params):
r = await client.get("/admin/audit-log", headers=ADMIN, params=params)
assert r.status_code == 200, r.text
return [e["id"] for e in r.json()]
everything = await ids(limit=500)
assert everything == sorted(everything, reverse=True) and len(everything) == 12
first = await ids(limit=5)
assert first == everything[:5]
assert await ids(limit=5, before_id=first[-1]) == everything[5:10]
r = await client.get("/admin/audit-log", headers=ADMIN, params=[("event_type", "event_0"), ("event_type", "event_1")])
assert {e["event_type"] for e in r.json()} == {"event_0", "event_1"}
assert await ids(event_type="nope") == []
assert len(await ids(round_id=10, user_id=0)) == 6
window = await ids(**{"from": "2026-10-01T14:00:00+00:00", "to": "2026-10-01T17:00:00+00:00"})
assert len(window) == 3 # 14:00, 15:00, 16:00 — `to` is exclusive
for bad in ({"from": "2026-10-01T14:00:00"}, {"before_id": "x"}, {"to": "yesterday"}):
r = await client.get("/admin/audit-log", headers=ADMIN, params=bad)
assert r.status_code == 422 and r.json()["detail"]["code"] == "invalid_request"
# --- user_registered -------------------------------------------------------------------
async def test_a_registration_writes_user_registered_in_its_transaction(client):
from app.db import base as db_base
from app.db.models import AuditLog, User
await _register(client, username="carol")
async with db_base.AsyncSessionLocal() as session:
user = (await session.scalars(select(User))).one()
row = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "user_registered"))).one()
assert row.user_id == user.id and row.round_id is None
assert json.loads(row.payload_json) == {"derivation_index": 0, "address": user.address}
assert "carol" not in row.payload_json
# --- logs and proxy -----------------------------------------------------------------------
def test_the_app_logs_also_go_to_stdout(monkeypatch, tmp_path):
from app import logging_config
root = logging.getLogger()
before = list(root.handlers)
monkeypatch.setattr(logging_config, "LOG_DIR", tmp_path)
monkeypatch.setattr(logging_config, "LOG_FILE", tmp_path / "app.log")
try:
logging_config.setup_logging()
added = [h for h in root.handlers if h not in before]
assert any(isinstance(h, logging.StreamHandler) and h.stream is sys.stdout for h in added)
finally:
for h in [h for h in root.handlers if h not in before]:
root.removeHandler(h)
h.close()
def test_uvicorn_trusts_only_the_proxy_and_the_container_is_checked():
from pathlib import Path
root = Path(__file__).resolve().parents[2]
dockerfile, compose = (root / "Dockerfile").read_text(), (root / "docker-compose.yml").read_text()
assert "--proxy-headers" in dockerfile and "--forwarded-allow-ips" in dockerfile
assert "FORWARDED_ALLOW_IPS=172.28.0.10" in compose and "ipv4_address: 172.28.0.10" in compose
assert "subnet: 172.28.0.0/24" in compose and "/health/ready" in compose