fix(securitate): hardening prod — headere, body-cap, non-root, backup

Findings security-review P1/P2 (2026-07-03):
- headere de securitate pe toate raspunsurile (nosniff, X-Frame-Options,
  Referrer-Policy, HSTS doar pe HTTPS) + teste
- body-cap global 10MB ca middleware ASGI pur (413 inainte de parserul
  multipart/JSON; verificarea per-endpoint ramane strat 2)
- imagine Docker non-root (uid 10001), port 8010 aliniat, loguri pe
  volumul /data
- fail-fast la boot cu rar_env=prod fara AUTOPASS_REQUIRE_API_KEY sau
  AUTOPASS_SESSION_SECRET
- compose: env-uri critice obligatorii (:?) ca api/worker sa nu diverga
  tacit; FORWARDED_ALLOW_IPS ca rate-limit-ul sa vada IP-ul real dupa
  Traefik
- signup fara PII in stdout: log_event in loc de print cu email (idem
  notify degradat)
- ratelimit: sterge cheile fara timestamp-uri valide (crestere monotona
  a memoriei pe IP-uri reale)
- backup criptat SQLite (backup online API, gpg AES256) + verificare
  restore + docs/backup.md

Suita completa verde: 1557 passed, 1 skipped (live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-06 12:46:49 +00:00
parent fa52468a80
commit 63b6cbc01d
19 changed files with 957 additions and 29 deletions

View File

@@ -8,20 +8,55 @@ AUTOPASS_CREDS_KEY=
# --- Auth API-key ---
# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1).
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
AUTOPASS_REQUIRE_API_KEY=false
# --- Worker ---
# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end.
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
AUTOPASS_WORKER_SEND_ENABLED=false
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
AUTOPASS_WORKER_USE_TEST_CREDS=false
# --- RAR ---
# test | prod
# test | prod. Ancora globala, NU tinta trimiterilor: dupa PRD 5.20 mediul RAR e
# per submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila asta
# e doar fallback API pentru conturi fara medii disponibile, backfill la migrare
# si afisaj dashboard. OBLIGATORIE in docker-compose.yml (:?) — vezi nota din compose.
AUTOPASS_RAR_ENV=test
# --- Sesiuni web ---
# Secret semnat cookie sesiune, PARTAJAT intre restart-uri (fara el, fiecare
# redeploy delogheaza toti userii). Genereaza:
# python3 -c "import secrets; print(secrets.token_hex(32))"
# OBLIGATORIU in docker-compose.yml (:?).
AUTOPASS_SESSION_SECRET=
# True (prod, implicit in compose): cookie de sesiune cu flag Secure (necesita HTTPS).
# False: doar pentru smoke-test local pe HTTP simplu (browserul dropeaza cookie-ul
# Secure pe HTTP, deci login-ul ar esua silentios cu true).
AUTOPASS_SESSION_HTTPS_ONLY=true
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
# true = sugestii semantice. Prima cerere /mapari lazy-load-eaza modelul fastembed/ONNX
# (~230MB pe disc) sincron -> hang la prima cerere. Doar API-ul il incarca.
AUTOPASS_EMBEDDINGS_ENABLED=false
# --- Backup SQLite criptat (T2/P0-4, vezi docs/backup.md) ---
# TRIGGER DUR: backup-ul trebuie configurat INAINTE de prima declaratie reala in
# prod. Baza e sistemul legal de evidenta (L.142/2023); FINALIZATA e terminal la
# RAR, deci pierderea volumului fara backup produce duplicate necorectabile.
#
# Parola de criptare (gpg AES256) a backup-urilor — obligatorie, tools/backup_db.sh
# refuza sa scrie un backup necriptat. Foloseste fisierul (secret montat), NU variabila
# inline, in prod:
AUTOPASS_BACKUP_PASSPHRASE_FILE=
# Alternativa (ex. teste locale) — parola inline, evita in prod:
AUTOPASS_BACKUP_PASSPHRASE=
# Director destinatie backup-uri (pe volumul persistent, NU efemer).
AUTOPASS_BACKUP_DIR=/data/backups
# Cate backup-uri locale criptate se pastreaza (cele mai vechi se sterg).
AUTOPASS_BACKUP_KEEP=14
# Tinta rclone optionala pentru upload off-site (ex. remote:bucket/autopass). Fara ea,
# backup-ul ramane doar local pe volum — sarit explicit cu log, nu esec silentios.
AUTOPASS_BACKUP_RCLONE_REMOTE=

View File

@@ -11,9 +11,19 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
# gnupg = criptare backup SQLite (tools/backup_db.sh, T2/P0-4) — NU e inclus implicit
# in imaginea slim (doar gzip e pachet esential Debian). rclone NU e instalat aici
# intentionat: upload-ul remote e optional (AUTOPASS_BACKUP_RCLONE_REMOTE) si scriptul
# sare peste el explicit daca binarul lipseste.
RUN apt-get update && apt-get install -y --no-install-recommends tzdata gnupg \
&& rm -rf /var/lib/apt/lists/*
# User neprivilegiat (uid/gid stabil): orice RCE viitor are raza de explozie
# limitata, nu root in container. HOME e necesar explicit — fastembed descarca
# modelul (~230MB) in $HOME la prima folosire (AUTOPASS_EMBEDDINGS_ENABLED=true).
RUN groupadd -g 10001 app && useradd -u 10001 -g app -d /home/app -m app
ENV HOME=/home/app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -22,9 +32,19 @@ COPY tools ./tools
# Date persistente (SQLite WAL) pe volum montat.
ENV AUTOPASS_DB_PATH=/data/autopass.db
# Log text rotativ (app/config.py:log_dir, RotatingFileHandler) pe acelasi volum
# persistent, NU in /app/.run — altfel non-root n-are voie sa scrie in /app.
ENV AUTOPASS_LOG_DIR=/data/logs
# chown INAINTE de VOLUME — schimbarile de proprietar dupa directiva VOLUME se
# pierd la build. Migrare one-off pentru un volum EXISTENT deja detinut de root:
# docker compose run --rm --user root api chown -R app:app /data
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
EXPOSE 8000
USER app
EXPOSE 8010
# Default = API. Worker-ul suprascrie command in compose.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]

View File

@@ -139,6 +139,12 @@ class Settings(BaseSettings):
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
seed_operatii_enabled: bool = True
# --- Limita globala corp cerere (P1-4/P1-5, hardening 2026-07-03) ---
# Plafon aplicat de un middleware ASGI pur (app/web/body_cap.py) pe TOATE rutele,
# INAINTE ca FastAPI sa parseze body-ul (multipart e spooled pe disc altfel).
# Verificarea per-endpoint existenta (import_parse.MAX_BYTES, 5MB) ramane strat 2.
max_request_bytes: int = 10 * 1024 * 1024 # 10 MB
@property
def rar_base_url(self) -> str:
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
@@ -149,6 +155,32 @@ def get_settings() -> Settings:
return Settings()
def validate_prod_invariants(settings: Settings) -> None:
"""Fail-fast la startup: refuza boot-ul in productie cand lipsesc invariantele
de securitate obligatorii, in loc sa lase aplicatia sa porneasca intr-o
configuratie nesigura descoperita abia dupa deploy (mirror `crypto.validate_creds_key`).
Conditionat STRICT pe `rar_env=="prod"` — altfel ar opri suita de teste, care
ruleaza implicit cu `rar_env="test"` si fara `session_secret`/`require_api_key`.
"""
if settings.rar_env != "prod":
return
if not settings.require_api_key:
raise RuntimeError(
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_REQUIRE_API_KEY nu e activat: in "
"productie cheia API e obligatorie pe /v1/*, altfel orice cerere "
"neautentificata ajunge pe contul implicit id=1. Seteaza "
"AUTOPASS_REQUIRE_API_KEY=true."
)
if not settings.session_secret:
raise RuntimeError(
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_SESSION_SECRET nu este setat: fara el, "
"secretul de sesiune e efemer per restart si toti userii sunt delogati la "
"fiecare redeploy. Genereaza unul persistent cu:\n"
" python3 -c \"import secrets; print(secrets.token_hex(32))\""
)
def load_test_credentials(settings_xml: Path | None = None) -> dict | None:
"""Citeste credentialele <test> din settings.xml (dev local / probe test).

View File

@@ -11,6 +11,7 @@ import textwrap
from email.message import EmailMessage
from .config import get_settings
from .observ import log_event
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
@@ -23,10 +24,11 @@ def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
settings = get_settings()
if not settings.smtp_host or not admin_emails:
print(
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
f"email={email} admins={len(admin_emails)}",
flush=True,
log_event(
"signup_notify_degradat",
account_id=account_id,
mesaj="fara SMTP configurat",
context={"admins": len(admin_emails)},
)
return

View File

@@ -25,12 +25,13 @@ from . import errors
from .api.v1.import_router import router as import_v1_router
from .api.v1.integrare_router import router as integrare_v1_router
from .api.v1.router import router as api_v1_router
from .config import get_settings
from .config import get_settings, validate_prod_invariants
from .crypto import validate_creds_key
from .db import get_connection, init_db, queue_depth, read_heartbeat
from .observ import log_event, request_id_var
from .security import install_log_redaction, scrub_text
from .web.middleware import RequestIDMiddleware
from .web.body_cap import BodyCapMiddleware
from .web.middleware import RequestIDMiddleware, SecurityHeadersMiddleware
from .web.routes import router as web_router
from .web.auth_routes import router as auth_router
from .web.admin_routes import router as admin_router
@@ -44,6 +45,9 @@ async def lifespan(app: FastAPI):
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
# in loc de 500 brut la primul POST /v1/prezentari.
validate_creds_key()
# Fail-fast (prod-only): elimina clasa "am uitat env var in Dokploy" pentru
# cheia API si secretul de sesiune, in loc de o instanta descoperita post-deploy.
validate_prod_invariants(get_settings())
init_db()
yield
@@ -63,6 +67,12 @@ app.add_middleware(
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
# inclusiv 401/404/422/500 produse mai in interior.
app.add_middleware(RequestIDMiddleware)
# Headere de securitate (P1-4) pe TOATE raspunsurile.
app.add_middleware(SecurityHeadersMiddleware)
# Body-cap (P1-5): ADAUGAT ULTIMUL -> ruleaza CEL MAI OUTERMOST (add_middleware
# prepend), deci intercepteaza `receive` inaintea oricarui parser FastAPI/Starlette
# (multipart, JSON) — vezi docstring body_cap.py pentru motivul ASGI-pur.
app.add_middleware(BodyCapMiddleware)
@app.exception_handler(LoginRequired)

View File

@@ -15,6 +15,7 @@ from ..auth import create_api_key
from ..config import get_settings
from ..db import get_connection
from ..email import notify_signup
from ..observ import log_event
from ..users import count_admins, create_user, list_admin_emails, verify_password
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.ratelimit import check_rate_limit
@@ -157,7 +158,7 @@ async def signup_post(
conn.close()
set_session(request, account_id, user_id)
print(f"SIGNUP cont={account_id} email={email}", flush=True)
log_event("signup", account_id=account_id, mesaj="cont nou creat")
# Notificare email admin (best-effort, nu blocheaza signup-ul)
try:

93
app/web/body_cap.py Normal file
View File

@@ -0,0 +1,93 @@
"""Middleware ASGI PUR: plafon global pe dimensiunea corpului cererii (P1-5).
`BaseHTTPMiddleware` nu poate refuza devreme: pentru a construi un `Request`
reconstituie intregul flux `receive()`, iar FastAPI parseaza deja COMPLET
multipart-ul (spooled pe disc peste prag) INAINTE sa ruleze endpointul — o
verificare per-endpoint (chiar si citire pe chunk-uri) nu repara cauza, doar
simptomul. Un middleware ASGI pur intercepteaza `receive` inaintea oricarui
parser: Content-Length peste limita -> 413 fara sa citeasca nimic din body;
altfel numara cumulativ octetii mesajelor `http.request` si intrerupe la
depasire. Cererile fara body (GET etc.) trec netaxate.
Verificarea per-endpoint existenta (`import_router.py`, `import_parse.MAX_BYTES`)
ramane NEATINSA ca strat 2 — acopera orice cale care ar ocoli acest middleware.
"""
from __future__ import annotations
import json
from starlette.datastructures import Headers
from .. import errors
from ..config import get_settings
class _BodyTooLarge(Exception):
pass
def _error_body() -> bytes:
payload = errors.eroare(
"CERERE_PREA_MARE",
cauza="Corpul cererii depaseste limita permisa.",
)
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
class BodyCapMiddleware:
"""Limiteaza corpul cererii la `settings.max_request_bytes`, pe TOATE rutele.
Limita se citeste din `get_settings()` LA FIECARE CERERE (nu o data la
construirea middleware-ului): `app.add_middleware` ruleaza o singura data per
proces (modulul `app.main` e importat o singura data si in teste), deci un
`max_bytes` fixat in `__init__` ar ramane inghetat la valoarea de la primul
import si ar ignora `AUTOPASS_MAX_REQUEST_BYTES` schimbat ulterior (teste).
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
max_bytes = get_settings().max_request_bytes
content_length = Headers(scope=scope).get("content-length")
if content_length is not None:
try:
declared = int(content_length)
except ValueError:
declared = None
if declared is not None and declared > max_bytes:
await self._send_413(send)
return
total = 0
async def capped_receive():
nonlocal total
message = await receive()
if message["type"] == "http.request":
total += len(message.get("body") or b"")
if total > max_bytes:
raise _BodyTooLarge()
return message
try:
await self.app(scope, capped_receive, send)
except _BodyTooLarge:
await self._send_413(send)
@staticmethod
async def _send_413(send) -> None:
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [(b"content-type", b"application/json")],
}
)
await send({"type": "http.response.body", "body": _error_body()})

View File

@@ -31,3 +31,23 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
request_id_var.reset(token)
response.headers["X-Request-ID"] = request_id
return response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Headere de securitate pe TOATE raspunsurile (P1-4, hardening 2026-07-03).
HSTS se pune DOAR pe HTTPS: pe HTTP browserul l-ar ignora oricum, dar l-am
omis explicit ca sa nu sugereze o garantie falsa in dev/smoke pe HTTP simplu.
Nu suprascrie un header deja setat explicit de raspuns (setdefault).
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
if request.url.scheme == "https":
response.headers.setdefault(
"Strict-Transport-Security", "max-age=31536000; includeSubDomains"
)
return response

View File

@@ -7,10 +7,9 @@ Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (conf
from __future__ import annotations
import time
from collections import defaultdict
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
_hits: dict[str, list[float]] = defaultdict(list)
_hits: dict[str, list[float]] = {}
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
@@ -19,13 +18,21 @@ def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
suficient pentru un singur proces uvicorn.
Cheile fara timestamp-uri valide sunt sterse din `_hits` (nu doar golite), altfel
dictionarul creste monoton pe viata procesului odata ce cheia devine IP-uri reale
de internet (F5). Foloseste `.get` in loc de acces direct pe dict pentru a nu
reintroduce o cheie doar prin citire.
"""
now = time.monotonic()
cutoff = now - window_s
timestamps = _hits[key]
# Sterge intrari expirate
_hits[key] = [t for t in timestamps if t > cutoff]
if len(_hits[key]) >= max_hits:
filtered = [t for t in _hits.get(key, []) if t > cutoff]
if len(filtered) >= max_hits:
if filtered:
_hits[key] = filtered
else:
_hits.pop(key, None)
return False
_hits[key].append(now)
filtered.append(now)
_hits[key] = filtered
return True

View File

@@ -13,15 +13,35 @@ services:
- autopass-data:/data
environment:
AUTOPASS_DB_PATH: /data/autopass.db
# Override din environment (Dokploy) pentru staging; default = prod.
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
# Ancora globala, NU tinta trimiterilor: dupa PRD 5.20 mediul RAR e per
# submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila
# asta serveste doar ca fallback API pentru conturi fara medii disponibile,
# backfill la migrare (db.py) si afisaj dashboard (routes.py). Obligatorie
# ca sa nu difere tacit intre api si worker (vezi worker mai jos).
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
TZ: ${TZ:-Europe/Bucharest}
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:?seteaza AUTOPASS_REQUIRE_API_KEY (true in prod) in .env (vezi .env.example)}
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
# uvicorn 0.30.0 are proxy_headers=True implicit; lipsea doar increderea in
# sursa headerelor, altfel request.client.host = IP-ul Traefik pentru toti
# vizitatorii (rate-limit login/signup devine o galeata GLOBALA). Uvicorn
# citeste FORWARDED_ALLOW_IPS direct din mediu (fara flag CLI necesar).
# ATENTIE, doua invariante trebuie sa ramana adevarate impreuna cu "*":
# (1) serviciul api NU publica `ports:` pe host aici — accesibil DOAR prin Traefik;
# (2) Traefik NU e configurat cu forwardedHeaders.insecure=true.
# Daca (2) e incalcat, un atacator poate falsifica X-Forwarded-For si ocoli
# rate-limit-ul (galeata noua per cerere = brute-force nelimitat).
FORWARDED_ALLOW_IPS: "*"
# Fara secret persistent, fiecare redeploy delogheaza toti userii (main.py
# genereaza unul efemer la runtime). HTTPS_ONLY=true e corect pentru prod,
# dar inseamna ca un smoke-test simplu pe HTTP NU poate face login (browserul
# dropeaza cookie-ul Secure) — pe HTTP local seteaza explicit false.
AUTOPASS_SESSION_SECRET: ${AUTOPASS_SESSION_SECRET:?seteaza AUTOPASS_SESSION_SECRET in .env (vezi .env.example)}
AUTOPASS_SESSION_HTTPS_ONLY: ${AUTOPASS_SESSION_HTTPS_ONLY:-true}
restart: always
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]
@@ -36,11 +56,15 @@ services:
- autopass-data:/data
environment:
AUTOPASS_DB_PATH: /data/autopass.db
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
# Vezi comentariul de la serviciul api: ancora globala, NU tinta trimiterilor
# (worker-ul trimite dupa submissions.rar_env, per cont). Aceeasi variabila
# obligatorie AICI, ca sa nu diverga tacit fata de api (era prod/test split-brain).
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
# Send catre RAR. Obligatoriu explicit — fara ea, un misconfig ar putea lasa
# worker-ul sa trimita declaratii reale neintentionat (Legea 142/2023).
# false = nu trimite (sigur pentru probe). true = end-to-end.
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:?seteaza AUTOPASS_WORKER_SEND_ENABLED (true|false) in .env (vezi .env.example)}
restart: always
depends_on:
- api
@@ -68,5 +92,33 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Backup SQLite criptat (T2/P0-4, evidenta legala L.142/2023) — vezi docs/backup.md.
# Serviciu OPTIONAL, profil "backup": nu porneste la `docker compose up` normal.
# Activare: `docker compose --profile backup up -d backup` (sau COMPOSE_PROFILES=backup
# in mediul Dokploy). Alternativa fara profil compose: cron pe host care ruleaza
# `docker compose exec api bash tools/backup_db.sh` — mai simpla daca ai deja acces SSH
# la host, dar in Dokploy hostul poate fi gestionat/efemer, de aceea preferam varianta
# de mai jos (calatoreste cu image-ul, nu depinde de un cron configurat manual pe VM).
# backup:
# build: .
# profiles: ["backup"]
# entrypoint: ["bash", "-c"]
# command:
# - |
# while true; do
# bash tools/backup_db.sh || echo "[backup] rulare esuata, reincerc la urmatorul ciclu"
# sleep 86400
# done
# volumes:
# - autopass-data:/data
# environment:
# AUTOPASS_DB_PATH: /data/autopass.db
# AUTOPASS_BACKUP_DIR: ${AUTOPASS_BACKUP_DIR:-/data/backups}
# AUTOPASS_BACKUP_KEEP: ${AUTOPASS_BACKUP_KEEP:-14}
# AUTOPASS_BACKUP_PASSPHRASE_FILE: ${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}
# AUTOPASS_BACKUP_PASSPHRASE: ${AUTOPASS_BACKUP_PASSPHRASE:-}
# AUTOPASS_BACKUP_RCLONE_REMOTE: ${AUTOPASS_BACKUP_RCLONE_REMOTE:-}
# restart: always
volumes:
autopass-data:

99
docs/backup.md Normal file
View File

@@ -0,0 +1,99 @@
# Backup SQLite (T2/P0-4)
## Ce se salveaza si de ce
Baza SQLite (`AUTOPASS_DB_PATH`, prod `/data/autopass.db`) e sistemul legal de evidenta
al declaratiilor RAR AUTOPASS (Legea 142/2023). `FINALIZATA` e **terminal** la RAR — nu
exista anulare/corectie prin API. Pierderea volumului fara backup inseamna, la restaurare
dintr-un mediu gol, retrimiterea acelorasi randuri si duplicate pe care RAR le accepta
fara sa poata fi corectate. De aceea backup-ul e trigger dur: **configureaza-l inainte
de prima declaratie reala in prod**, nu dupa.
Backup-urile sunt criptate (gpg AES256) pentru ca snapshot-ul contine PII criptat Fernet
(nume, VIN, date client) plus metadate — un backup necriptat pe disc/remote ar fi un al
doilea loc unde datele astea pot scapa.
## Rulare manuala
```bash
# in container (api sau worker, acelasi image + volum):
docker compose exec api bash tools/backup_db.sh
# local (dev), cu parola de test:
AUTOPASS_DB_PATH=./data/autopass.db \
AUTOPASS_BACKUP_DIR=./data/backups \
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola \
bash tools/backup_db.sh
```
Scriptul foloseste `sqlite3.Connection.backup` (online, sigur cu WAL activ — NU `cp`),
comprima (gzip) si cripteaza (gpg simetric). Fara `AUTOPASS_BACKUP_PASSPHRASE_FILE` sau
`AUTOPASS_BACKUP_PASSPHRASE` seteaza, scriptul **refuza** sa scrie un backup necriptat si
iese cu cod diferit de 0. Pastreaza ultimele `AUTOPASS_BACKUP_KEEP` (implicit 14) fisiere
in `AUTOPASS_BACKUP_DIR` (implicit `/data/backups`, pe volumul persistent). Daca
`AUTOPASS_BACKUP_RCLONE_REMOTE` e setat si `rclone` e disponibil, urca automat
backup-ul nou catre remote (off-site); altfel sare peste, cu log explicit.
## Programare
Doua variante, alege una:
1. **Serviciu compose optional** (`docker-compose.yml`, serviciu `backup`, profil
`backup`, comentat implicit) — ruleaza scriptul intr-o bucla cu `sleep 86400` in
propriul container, pe acelasi volum `autopass-data`. Activare:
```bash
docker compose --profile backup up -d backup
```
sau, in Dokploy, seteaza `COMPOSE_PROFILES=backup` in mediul aplicatiei. Preferata
pentru un deploy Dokploy: calatoreste cu image-ul si nu depinde de acces SSH separat
la hostul care ruleaza containerele (host care in Dokploy poate fi gestionat/efemer).
2. **Cron pe host** — daca ai acces SSH direct la masina care ruleaza containerele:
```cron
0 3 * * * cd /cale/catre/autopass && docker compose exec -T api bash tools/backup_db.sh
```
In ambele cazuri, parola de criptare trebuie sa fie accesibila containerului (secret
montat, indicat prin `AUTOPASS_BACKUP_PASSPHRASE_FILE`) — nu doar in mediul shell al
cron-ului de pe host.
## Restaurare (pas cu pas)
1. **Opreste worker-ul** (`docker compose stop worker`) — altfel worker-ul poate scrie
in baza in timp ce o inlocuiesti, sau poate re-prelua randuri `sending` in timp ce
restaurarea e pe jumatate facuta.
2. Alege backup-ul de restaurat (fisier `autopass-YYYYmmdd-HHMMSS.db.gz.gpg` din
`AUTOPASS_BACKUP_DIR`).
3. Decripteaza + dezarhiveaza intr-un fisier temporar (NU suprascrie direct baza vie):
```bash
gpg --batch --decrypt --passphrase-file /cale/catre/parola \
--output /tmp/restore.db.gz autopass-20260706-030000.db.gz.gpg
gzip -d /tmp/restore.db.gz
```
4. Verifica integritatea inainte sa promovezi fisierul (vezi sectiunea urmatoare).
5. Muta fisierul verificat peste `AUTOPASS_DB_PATH` (backup-uieste intai baza curenta
daca mai exista, chiar corupta — pastreaz-o pentru investigare).
6. Porneste din nou worker-ul (`docker compose start worker`).
## Verificare (restore_check)
Un backup neverificat nu e backup. `tools/restore_check.sh` decripteaza + dezarhiveaza
cel mai recent backup (sau unul explicit, ca argument) intr-un fisier temporar si ruleaza
`PRAGMA integrity_check` + `SELECT count(*) FROM submissions`. Iese cu cod diferit de 0
daca decriptarea, integritatea sau interogarea esueaza.
```bash
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola tools/restore_check.sh
# sau explicit:
tools/restore_check.sh /data/backups/autopass-20260706-030000.db.gz.gpg
```
Ruleaza-l periodic (ex. dupa fiecare backup programat) — un backup care nu se poate
restaura si verifica nu ofera nicio garantie reala.
## Unde stau parolele
Parola de criptare (`AUTOPASS_BACKUP_PASSPHRASE_FILE`) e un secret separat de
`AUTOPASS_CREDS_KEY` (Fernet, creds RAR) — nu le refolosi una pe cealalta. In prod,
monteaz-o ca fisier secret (nu variabila de mediu inline in `.env` necriptat pe disc)
si restrictioneaza accesul la fisier. `AUTOPASS_BACKUP_PASSPHRASE` (variabila inline)
e doar pentru teste locale/CI, nu pentru prod.

64
tests/test_body_cap.py Normal file
View File

@@ -0,0 +1,64 @@
"""Teste P1-5 (hardening 2026-07-03): plafon global corp cerere (BodyCapMiddleware)."""
from __future__ import annotations
import io
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "bc.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
# Prag mic ca sa nu generam MB-uri reale in test.
monkeypatch.setenv("AUTOPASS_MAX_REQUEST_BYTES", "1000")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def test_multipart_peste_limita_413(client):
data = b"x" * 5000
r = client.post(
"/v1/import",
files={"file": ("mare.csv", io.BytesIO(data), "text/csv")},
)
assert r.status_code == 413
assert r.json()["cod"] == "CERERE_PREA_MARE"
def test_json_peste_limita_413(client):
payload = {"blob": "x" * 5000}
r = client.post(
"/v1/prezentari",
content=json.dumps(payload),
headers={"content-type": "application/json"},
)
assert r.status_code == 413
assert r.json()["cod"] == "CERERE_PREA_MARE"
def test_cerere_normala_sub_limita_trece(client):
r = client.get("/healthz")
assert r.status_code == 200
def test_content_length_mare_body_mic_413_devreme(client):
"""Content-Length declarat peste limita respinge inainte sa citeasca body-ul,
chiar daca body-ul trimis efectiv e mic (client "mincinos")."""
r = client.post(
"/v1/prezentari",
content=b"{}",
headers={"content-type": "application/json", "content-length": "999999"},
)
assert r.status_code == 413
assert r.json()["cod"] == "CERERE_PREA_MARE"

View File

@@ -39,6 +39,9 @@ def client_prod(monkeypatch):
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
# rar_env=prod declanseaza invarianta de startup (validate_prod_invariants,
# hardening 2026-07-03): cere si session_secret setat, altfel boot-ul refuza.
monkeypatch.setenv("AUTOPASS_SESSION_SECRET", "x" * 32)
from app.config import get_settings
get_settings.cache_clear()
from app.main import app

View File

@@ -0,0 +1,77 @@
"""Teste F5: `_hits` nu creste monoton pe viata procesului.
Cheile ale caror timestamp-uri au expirat complet trebuie sterse din `_hits`
(nu doar golite), altfel dictionarul acumuleaza o intrare per IP vazut vreodata.
Semantica ferestrei glisante (permite/blocheaza) trebuie sa ramana neschimbata.
"""
from __future__ import annotations
import pytest
from app.web import ratelimit
from app.web.ratelimit import check_rate_limit
@pytest.fixture(autouse=True)
def _clean_hits():
ratelimit._hits.clear()
yield
ratelimit._hits.clear()
def test_cheie_blocata_fara_timestamp_uri_valide_e_stearsa(monkeypatch):
"""max_hits=0 -> orice cerere e blocata; daca fereastra a expirat complet
(filtrarea produce o lista goala), cheia nu trebuie sa ramana in `_hits`."""
now = [1000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
key = "ip_blocat_gol"
assert check_rate_limit(key, max_hits=0, window_s=60) is False
# Nimic adaugat (max_hits=0 blocheaza tot) si lista era goala -> cheia disparuta.
assert key not in ratelimit._hits
def test_cheie_activa_ramane_dupa_permis(monkeypatch):
"""O cerere permisa isi adauga timestamp-ul -> cheia activa ramane in `_hits`."""
now = [1000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
key = "ip_activ"
assert check_rate_limit(key, max_hits=2, window_s=60) is True
assert key in ratelimit._hits
assert ratelimit._hits[key] == [1000.0]
def test_semantica_max_hits_si_fereastra_neschimbata(monkeypatch):
"""max_hits atinse -> False; dupa expirarea ferestrei -> True din nou."""
now = [1000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
key = "ip_fereastra"
assert check_rate_limit(key, max_hits=2, window_s=10) is True
assert check_rate_limit(key, max_hits=2, window_s=10) is True
# A treia cerere in aceeasi fereastra -> blocata.
assert check_rate_limit(key, max_hits=2, window_s=10) is False
# Trece timpul peste fereastra -> toate timestamp-urile expira.
now[0] += 11
assert check_rate_limit(key, max_hits=2, window_s=10) is True
# Doar noua cerere ramane in lista (cele vechi, expirate, au fost curatate).
assert ratelimit._hits[key] == [1011.0]
def test_cheie_dispare_dupa_expirare_completa_si_reblocare(monkeypatch):
"""O cheie blocata, ramasa fara timestamp-uri valide dupa expirarea ferestrei
(fara sa mai apara alte cereri intre timp), nu trebuie sa ramana in `_hits`
la urmatorul apel care o gaseste goala si tot o blocheaza (max_hits=0)."""
now = [1000.0]
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
key = "ip_reblocat"
check_rate_limit(key, max_hits=0, window_s=5)
assert key not in ratelimit._hits
now[0] += 100
assert check_rate_limit(key, max_hits=0, window_s=5) is False
assert key not in ratelimit._hits

View File

@@ -0,0 +1,64 @@
"""Teste P1-4 (hardening 2026-07-03): security headers pe TOATE raspunsurile."""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "sh.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _assert_common_headers(headers) -> None:
assert headers.get("X-Content-Type-Options") == "nosniff"
assert headers.get("X-Frame-Options") == "DENY"
assert headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
def test_headere_pe_raspuns_200(client):
r = client.get("/healthz")
assert r.status_code == 200
_assert_common_headers(r.headers)
def test_headere_pe_raspuns_404(client):
r = client.get("/o/ruta/care/nu/exista")
assert r.status_code == 404
_assert_common_headers(r.headers)
def test_hsts_absent_pe_http(client):
r = client.get("/healthz")
assert "Strict-Transport-Security" not in r.headers
def test_hsts_prezent_pe_https():
tmp = tempfile.mkdtemp()
import os as _os
_os.environ["AUTOPASS_DB_PATH"] = _os.path.join(tmp, "sh_https.db")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, base_url="https://testserver") as c:
r = c.get("/healthz")
assert r.status_code == 200
assert r.headers.get("Strict-Transport-Security") == (
"max-age=31536000; includeSubDomains"
)
get_settings.cache_clear()
_os.environ.pop("AUTOPASS_DB_PATH", None)

View File

@@ -155,16 +155,33 @@ def test_primul_signup_devine_admin(client):
# Teste C16 (log SIGNUP pastrat) si best-effort E2E
# ---------------------------------------------------------------------------
def test_signup_inca_logheaza_si_notifica(client, capsys):
"""Signup reusit -> stdout contine 'SIGNUP cont=' (C16 pastrat)."""
def test_signup_logheaza_fara_pii_in_stdout(client, capsys):
"""Signup reusit -> NU se mai printeaza email-ul in clar in stdout (P2-7).
C16 (jurnalizarea signup-ului) e pastrata, dar prin `log_event` (app_events +
log text redactat), nu prin print() cu PII."""
resp = _do_signup(client, "Service Log Test", "log@test.com")
assert resp.status_code == 200
assert "rfak_" in resp.text
captured = capsys.readouterr()
assert "SIGNUP cont=" in captured.out, (
f"Linia de log C16 'SIGNUP cont=' lipseste din stdout. Capturat: {captured.out!r}"
assert "log@test.com" not in captured.out, (
f"Emailul nu trebuie sa apara in clar in stdout. Capturat: {captured.out!r}"
)
assert "SIGNUP cont=" not in captured.out, (
"Print-ul vechi cu PII nu mai trebuie sa existe."
)
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute(
"SELECT account_id, tip FROM app_events WHERE tip='signup' ORDER BY id DESC LIMIT 1"
).fetchone()
finally:
conn.close()
assert row is not None, "Trebuie sa existe un eveniment 'signup' in app_events"
assert row["tip"] == "signup"
def test_signup_neblocat_de_notify(monkeypatch, client):

View File

@@ -0,0 +1,86 @@
"""Teste E1 (hardening 2026-07-03): invarianta de startup fail-fast, prod-only.
`validate_prod_invariants` (app/config.py) trebuie sa refuze boot-ul cand
`rar_env=="prod"` si lipseste `require_api_key` sau `session_secret` — dar sa
ramana un no-op pe `rar_env` implicit ("test"), altfel ar opri toata suita.
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
def _clear(monkeypatch):
# NU delenv REQUIRE_API_KEY: `.env` real (dev) are AUTOPASS_REQUIRE_API_KEY=true,
# iar conftest il neutralizeaza cu os.environ.setdefault("...", "false") — un delenv
# aici ar re-expune valoarea din `.env` (precedenta pydantic-settings: env var > .env
# file). Fiecare test seteaza explicit ce valoare vrea pentru acest flag.
monkeypatch.delenv("AUTOPASS_RAR_ENV", raising=False)
monkeypatch.delenv("AUTOPASS_SESSION_SECRET", raising=False)
def test_prod_fara_require_api_key_refuza_boot(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
monkeypatch.setenv("AUTOPASS_SESSION_SECRET", "x" * 32)
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with pytest.raises(RuntimeError, match="AUTOPASS_REQUIRE_API_KEY"):
with TestClient(app):
pass
get_settings.cache_clear()
def test_prod_fara_session_secret_refuza_boot(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with pytest.raises(RuntimeError, match="AUTOPASS_SESSION_SECRET"):
with TestClient(app):
pass
get_settings.cache_clear()
def test_prod_cu_invarianta_satisfacuta_boot_ok(monkeypatch):
_clear(monkeypatch)
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "inv.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
monkeypatch.setenv("AUTOPASS_SESSION_SECRET", "x" * 32)
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
r = c.get("/healthz")
assert r.status_code == 200
get_settings.cache_clear()
def test_mediu_test_nu_declanseaza_invarianta(monkeypatch):
"""Default rar_env="test" -> no-op, indiferent de require_api_key/session_secret."""
_clear(monkeypatch)
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "inv2.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
r = c.get("/healthz")
assert r.status_code == 200
get_settings.cache_clear()

134
tools/backup_db.sh Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Backup criptat SQLite pentru gateway RAR AUTOPASS (T2/P0-4, evidenta legala L.142/2023).
#
# Foloseste API-ul de backup online SQLite (sqlite3.Connection.backup, stdlib python3) —
# NU `cp`/`rsync`: baza ruleaza in WAL, iar o copie simpla a fisierului .db poate prinde
# tranzactii necheckpoint-ate (copie inconsistenta). python3 e ales ca prim mijloc (nu
# binarul CLI `sqlite3`) pentru ca e garantat prezent in imaginea Docker si pe masina de
# dezvoltare; binarul `sqlite3` e folosit doar daca python3 lipseste (caz neasteptat).
#
# Rezultatul e comprimat (gzip) si criptat simetric (gpg AES256) — snapshot-ul contine
# PII criptat Fernet plus metadate, un backup NECRIPTAT nu e permis.
#
# Utilizare:
# AUTOPASS_BACKUP_PASSPHRASE_FILE=/run/secrets/backup_pass tools/backup_db.sh
# AUTOPASS_BACKUP_PASSPHRASE=parola-de-test tools/backup_db.sh
#
# Variabile de mediu:
# AUTOPASS_DB_PATH cale baza sursa (default /data/autopass.db)
# AUTOPASS_BACKUP_DIR director destinatie backup-uri (default /data/backups)
# AUTOPASS_BACKUP_KEEP cate backup-uri locale pastreaza (default 14)
# AUTOPASS_BACKUP_PASSPHRASE_FILE fisier cu parola de criptare (preferat)
# AUTOPASS_BACKUP_PASSPHRASE parola de criptare inline (fallback, ex. teste)
# AUTOPASS_BACKUP_RCLONE_REMOTE tinta rclone optionala (ex. remote:bucket/autopass)
set -euo pipefail
DB_PATH="${AUTOPASS_DB_PATH:-/data/autopass.db}"
BACKUP_DIR="${AUTOPASS_BACKUP_DIR:-/data/backups}"
KEEP="${AUTOPASS_BACKUP_KEEP:-14}"
RCLONE_REMOTE="${AUTOPASS_BACKUP_RCLONE_REMOTE:-}"
log() { echo "[backup_db] $*"; }
err() { echo "[backup_db] EROARE: $*" >&2; }
CLEANUP_PATHS=()
cleanup() {
local p
for p in "${CLEANUP_PATHS[@]:-}"; do
[[ -n "$p" ]] && rm -rf "$p"
done
}
trap cleanup EXIT
# --- parola de criptare: fara ea, refuzam sa scriem un backup necriptat ---
if [[ -n "${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}" ]]; then
if [[ ! -f "$AUTOPASS_BACKUP_PASSPHRASE_FILE" ]]; then
err "AUTOPASS_BACKUP_PASSPHRASE_FILE indica un fisier inexistent: $AUTOPASS_BACKUP_PASSPHRASE_FILE"
exit 1
fi
PASSPHRASE_FILE="$AUTOPASS_BACKUP_PASSPHRASE_FILE"
elif [[ -n "${AUTOPASS_BACKUP_PASSPHRASE:-}" ]]; then
PASSPHRASE_FILE="$(mktemp)"
chmod 600 "$PASSPHRASE_FILE"
CLEANUP_PATHS+=("$PASSPHRASE_FILE")
printf '%s' "$AUTOPASS_BACKUP_PASSPHRASE" > "$PASSPHRASE_FILE"
else
err "lipseste parola de criptare a backup-ului."
err "seteaza AUTOPASS_BACKUP_PASSPHRASE_FILE (recomandat) sau AUTOPASS_BACKUP_PASSPHRASE."
err "backup necriptat NU este permis (contine PII criptat Fernet si metadate)."
exit 1
fi
if [[ ! -f "$DB_PATH" ]]; then
err "baza de date nu exista: $DB_PATH"
exit 1
fi
mkdir -p "$BACKUP_DIR"
STAMP="$(date -u +%Y%m%d-%H%M%S)"
TMP_DIR="$(mktemp -d)"
CLEANUP_PATHS+=("$TMP_DIR")
SNAPSHOT="$TMP_DIR/autopass-$STAMP.db"
log "snapshot online din $DB_PATH"
if command -v python3 >/dev/null 2>&1; then
python3 - "$DB_PATH" "$SNAPSHOT" <<'PYEOF'
import sqlite3
import sys
src = sqlite3.connect(sys.argv[1])
dst = sqlite3.connect(sys.argv[2])
try:
with dst:
src.backup(dst)
finally:
src.close()
dst.close()
PYEOF
elif command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$DB_PATH" ".backup '$SNAPSHOT'"
else
err "nici python3, nici binarul sqlite3 nu sunt disponibile - nu pot face backup online"
exit 1
fi
if [[ ! -s "$SNAPSHOT" ]]; then
err "snapshot-ul rezultat e gol sau lipseste: $SNAPSHOT"
exit 1
fi
log "comprim (gzip)"
gzip -9 "$SNAPSHOT"
SNAPSHOT_GZ="$SNAPSHOT.gz"
FINAL="$BACKUP_DIR/autopass-$STAMP.db.gz.gpg"
log "criptez simetric (gpg AES256) -> $FINAL"
gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-file "$PASSPHRASE_FILE" \
--output "$FINAL" \
"$SNAPSHOT_GZ"
log "backup scris: $FINAL ($(du -h "$FINAL" | cut -f1))"
# --- retentie locala: pastreaza ultimele $KEEP, sterge restul ---
mapfile -t OLD_BACKUPS < <(ls -1t "$BACKUP_DIR"/autopass-*.db.gz.gpg 2>/dev/null | tail -n "+$((KEEP + 1))")
if [[ "${#OLD_BACKUPS[@]}" -gt 0 ]]; then
log "sterg ${#OLD_BACKUPS[@]} backup(uri) vechi (pastrez ultimele $KEEP)"
rm -f "${OLD_BACKUPS[@]}"
fi
# --- upload optional remote ---
if [[ -n "$RCLONE_REMOTE" ]]; then
if command -v rclone >/dev/null 2>&1; then
log "upload rclone -> $RCLONE_REMOTE"
rclone copy "$FINAL" "$RCLONE_REMOTE"
else
log "AUTOPASS_BACKUP_RCLONE_REMOTE setat dar rclone nu e instalat - sar peste upload remote"
fi
else
log "AUTOPASS_BACKUP_RCLONE_REMOTE nesetat - sar peste upload remote (doar local)"
fi
log "gata."

112
tools/restore_check.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Verificare de restaurare pentru backup-urile SQLite criptate (T2/P0-4).
#
# Un backup neverificat nu e backup: decripteaza + dezarhiveaza (intr-un fisier temporar,
# NU suprascrie baza vie) ultimul backup (sau cel indicat explicit) si ruleaza
# PRAGMA integrity_check + un SELECT count(*) FROM submissions. Iese cu cod != 0 daca
# ceva esueaza sau daca integritatea nu e "ok".
#
# Utilizare:
# AUTOPASS_BACKUP_PASSPHRASE_FILE=/run/secrets/backup_pass tools/restore_check.sh
# tools/restore_check.sh /data/backups/autopass-20260706-120000.db.gz.gpg
set -euo pipefail
BACKUP_DIR="${AUTOPASS_BACKUP_DIR:-/data/backups}"
log() { echo "[restore_check] $*"; }
err() { echo "[restore_check] EROARE: $*" >&2; }
CLEANUP_PATHS=()
cleanup() {
local p
for p in "${CLEANUP_PATHS[@]:-}"; do
[[ -n "$p" ]] && rm -rf "$p"
done
}
trap cleanup EXIT
if [[ -n "${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}" ]]; then
if [[ ! -f "$AUTOPASS_BACKUP_PASSPHRASE_FILE" ]]; then
err "AUTOPASS_BACKUP_PASSPHRASE_FILE indica un fisier inexistent: $AUTOPASS_BACKUP_PASSPHRASE_FILE"
exit 1
fi
PASSPHRASE_FILE="$AUTOPASS_BACKUP_PASSPHRASE_FILE"
elif [[ -n "${AUTOPASS_BACKUP_PASSPHRASE:-}" ]]; then
PASSPHRASE_FILE="$(mktemp)"
chmod 600 "$PASSPHRASE_FILE"
CLEANUP_PATHS+=("$PASSPHRASE_FILE")
printf '%s' "$AUTOPASS_BACKUP_PASSPHRASE" > "$PASSPHRASE_FILE"
else
err "lipseste parola de decriptare."
err "seteaza AUTOPASS_BACKUP_PASSPHRASE_FILE (recomandat) sau AUTOPASS_BACKUP_PASSPHRASE."
exit 1
fi
if [[ "${1:-}" != "" ]]; then
TARGET="$1"
else
TARGET="$(ls -1t "$BACKUP_DIR"/autopass-*.db.gz.gpg 2>/dev/null | head -n1 || true)"
fi
if [[ -z "$TARGET" || ! -f "$TARGET" ]]; then
err "niciun backup gasit de verificat (director: $BACKUP_DIR)"
exit 1
fi
log "verific: $TARGET"
TMP_DIR="$(mktemp -d)"
CLEANUP_PATHS+=("$TMP_DIR")
GZ_FILE="$TMP_DIR/snapshot.db.gz"
DB_FILE="$TMP_DIR/snapshot.db"
log "decriptez (gpg)"
gpg --batch --yes --decrypt --passphrase-file "$PASSPHRASE_FILE" \
--output "$GZ_FILE" "$TARGET"
log "dezarhivez (gzip)"
gzip -d "$GZ_FILE"
if [[ ! -s "$DB_FILE" ]]; then
err "fisierul restaurat e gol sau lipseste: $DB_FILE"
exit 1
fi
log "PRAGMA integrity_check + count(submissions)"
set +e
RESULT="$(python3 - "$DB_FILE" <<'PYEOF'
import sqlite3
import sys
conn = sqlite3.connect(sys.argv[1])
try:
integrity = conn.execute("PRAGMA integrity_check").fetchone()[0]
try:
count = conn.execute("SELECT count(*) FROM submissions").fetchone()[0]
except sqlite3.OperationalError as exc:
print(f"integrity={integrity}")
print(f"eroare_count_submissions={exc}")
sys.exit(1)
print(f"integrity={integrity}")
print(f"count_submissions={count}")
finally:
conn.close()
PYEOF
)"
STATUS=$?
set -e
echo "$RESULT"
if [[ $STATUS -ne 0 ]]; then
err "verificare esuata (vezi mesajul de mai sus)"
exit 1
fi
if ! grep -q "^integrity=ok$" <<<"$RESULT"; then
err "PRAGMA integrity_check NU a raportat 'ok'"
exit 1
fi
log "backup valid: restaurabil, integritate ok."