diff --git a/.env.example b/.env.example index 0ab3247..a414d63 100644 --- a/.env.example +++ b/.env.example @@ -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 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= diff --git a/Dockerfile b/Dockerfile index e6c64eb..ab32adf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app/config.py b/app/config.py index 5444871..ce5ea70 100644 --- a/app/config.py +++ b/app/config.py @@ -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 din settings.xml (dev local / probe test). diff --git a/app/email.py b/app/email.py index cf5bd4d..5e451d9 100644 --- a/app/email.py +++ b/app/email.py @@ -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 diff --git a/app/main.py b/app/main.py index cd5feda..a41e895 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/web/auth_routes.py b/app/web/auth_routes.py index beff76a..22a62ac 100644 --- a/app/web/auth_routes.py +++ b/app/web/auth_routes.py @@ -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: diff --git a/app/web/body_cap.py b/app/web/body_cap.py new file mode 100644 index 0000000..c1edd3c --- /dev/null +++ b/app/web/body_cap.py @@ -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()}) diff --git a/app/web/middleware.py b/app/web/middleware.py index b7c9035..7a1c6e4 100644 --- a/app/web/middleware.py +++ b/app/web/middleware.py @@ -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 diff --git a/app/web/ratelimit.py b/app/web/ratelimit.py index 02bb914..caaea44 100644 --- a/app/web/ratelimit.py +++ b/app/web/ratelimit.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 2eebb63..8d6b3fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/backup.md b/docs/backup.md new file mode 100644 index 0000000..8bf6b79 --- /dev/null +++ b/docs/backup.md @@ -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. diff --git a/tests/test_body_cap.py b/tests/test_body_cap.py new file mode 100644 index 0000000..6106c1e --- /dev/null +++ b/tests/test_body_cap.py @@ -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" diff --git a/tests/test_integrare_api.py b/tests/test_integrare_api.py index f18f7fa..c0530ee 100644 --- a/tests/test_integrare_api.py +++ b/tests/test_integrare_api.py @@ -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 diff --git a/tests/test_ratelimit_cleanup.py b/tests/test_ratelimit_cleanup.py new file mode 100644 index 0000000..d8fb303 --- /dev/null +++ b/tests/test_ratelimit_cleanup.py @@ -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 diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py new file mode 100644 index 0000000..18baa3c --- /dev/null +++ b/tests/test_security_headers.py @@ -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) diff --git a/tests/test_signup_notify.py b/tests/test_signup_notify.py index 1a0e938..c74f053 100644 --- a/tests/test_signup_notify.py +++ b/tests/test_signup_notify.py @@ -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): diff --git a/tests/test_startup_invariant.py b/tests/test_startup_invariant.py new file mode 100644 index 0000000..05be4d3 --- /dev/null +++ b/tests/test_startup_invariant.py @@ -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() diff --git a/tools/backup_db.sh b/tools/backup_db.sh new file mode 100755 index 0000000..2fba36e --- /dev/null +++ b/tools/backup_db.sh @@ -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." diff --git a/tools/restore_check.sh b/tools/restore_check.sh new file mode 100755 index 0000000..fc2c2d0 --- /dev/null +++ b/tools/restore_check.sh @@ -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."