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:
1 parent
9520c80dcf
commit
575f1a5354
23 files changed
+478
-91
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}\""]
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
Reference in new issue
Block a user