feat(cache): IP→ASN cache PostgreSQL + fix deploy venv permissions

- psycopg2-binary ajouté aux dépendances
- Cache ip-api.com dans table ip_asn_cache (PostgreSQL, TTL 30 j)
- ThreadedConnectionPool partagé par worker, schéma auto-créé au démarrage
- Graceful degradation si DATABASE_URL absent
- deploy-app.sh : pip tourne sous static-cdn (sudo -u) pour respecter les droits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 13:39:14 +02:00
parent 5d2a4ab430
commit d4fe8614c2
5 changed files with 117 additions and 9 deletions

View file

@ -1,5 +1,14 @@
# Changelog — Alpinux Static # Changelog — Alpinux Static
## [1.6.1] — 2026-05-06
### Modifié
- Cache IP→ASN migré de la mémoire process vers **PostgreSQL** (`ip_asn_cache`) — TTL 30 jours, partagé entre tous les workers gunicorn, pas de lock SQLite
- Pool de connexions `ThreadedConnectionPool` (13 conns par worker), schéma créé automatiquement au premier démarrage
- Graceful degradation : si `DATABASE_URL` absent ou PostgreSQL indisponible, l'app appelle ip-api.com sans cache
---
## [1.6.0] — 2026-05-06 ## [1.6.0] — 2026-05-06
### Ajouté ### Ajouté

View file

@ -1 +1 @@
1.6.0 1.6.1

View file

@ -9,10 +9,18 @@ import subprocess
import threading import threading
import time import time
import urllib.request import urllib.request
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import urlencode from urllib.parse import urlencode
try:
import psycopg2
import psycopg2.pool as _pg_pool_lib
_HAS_PG = True
except ImportError:
_HAS_PG = False
from PIL import Image from PIL import Image
from flask import (Flask, redirect, url_for, session, request, from flask import (Flask, redirect, url_for, session, request,
@ -71,6 +79,10 @@ _BANNED_CACHE: tuple = (set(), [])
_BANNED_CACHE_TS: float = 0 _BANNED_CACHE_TS: float = 0
_BANNED_CACHE_TTL = 60 # 1 min _BANNED_CACHE_TTL = 60 # 1 min
_pg_dsn = os.environ.get("DATABASE_URL", "")
_pg_pool_obj = None
_pg_schema_ok = False
_HIDDEN = frozenset({ _HIDDEN = frozenset({
".git", "scripts", "app", ".git", "scripts", "app",
@ -616,22 +628,108 @@ def _ip_is_banned(ip: str) -> bool:
except ValueError: except ValueError:
return False return False
def _pg_init_pool() -> bool:
global _pg_pool_obj
if _pg_pool_obj is not None:
return True
if not _HAS_PG or not _pg_dsn:
return False
try:
_pg_pool_obj = _pg_pool_lib.ThreadedConnectionPool(1, 3, _pg_dsn)
return True
except Exception:
return False
def _pg_ensure_schema(conn) -> None:
global _pg_schema_ok
if _pg_schema_ok:
return
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS ip_asn_cache (
ip TEXT PRIMARY KEY,
asn TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
conn.commit()
_pg_schema_ok = True
@contextmanager
def _pg():
"""Yields a psycopg2 connection from the pool, or None if unavailable."""
if not _pg_init_pool():
yield None
return
conn = None
try:
conn = _pg_pool_obj.getconn()
_pg_ensure_schema(conn)
except Exception:
if conn:
try: _pg_pool_obj.putconn(conn)
except: pass
yield None
return
try:
yield conn
finally:
try: _pg_pool_obj.putconn(conn)
except: pass
def _lookup_ip_asn(ip: str) -> dict: def _lookup_ip_asn(ip: str) -> dict:
"""Returns {asn, name, country} via ip-api.com.""" """Returns {asn, name, country} — PostgreSQL cache (30 j) puis ip-api.com."""
url = f"http://ip-api.com/json/{ip}?fields=as,org,countryCode" with _pg() as conn:
if conn:
try:
with conn.cursor() as cur:
cur.execute(
"SELECT asn, name, country FROM ip_asn_cache "
"WHERE ip = %s AND fetched_at > now() - interval '30 days'",
(ip,)
)
row = cur.fetchone()
if row:
return {"asn": row[0], "name": row[1], "country": row[2]}
except Exception:
try: conn.rollback()
except: pass
url = f"http://ip-api.com/json/{ip}?fields=as,countryCode"
result: dict = {"asn": "", "name": "", "country": ""}
try: try:
req = urllib.request.Request(url, headers={"User-Agent": "alpinux-static/1.0"}) req = urllib.request.Request(url, headers={"User-Agent": "alpinux-static/1.0"})
with urllib.request.urlopen(req, timeout=5) as resp: with urllib.request.urlopen(req, timeout=5) as resp:
d = json.loads(resp.read()) d = json.loads(resp.read())
as_field = d.get("as", "") # "AS12345 Some Name" as_field = d.get("as", "")
asn, name = "", as_field asn, name = "", as_field
if as_field.startswith("AS"): if as_field.startswith("AS"):
parts = as_field.split(" ", 1) parts = as_field.split(" ", 1)
asn = parts[0][2:] asn = parts[0][2:]
name = parts[1] if len(parts) > 1 else "" name = parts[1] if len(parts) > 1 else ""
return {"asn": asn, "name": name, "country": d.get("countryCode", "")} result = {"asn": asn, "name": name, "country": d.get("countryCode", "")}
except Exception as e: except Exception as e:
return {"asn": "", "name": "", "country": "", "error": str(e)} result["error"] = str(e)
with _pg() as conn:
if conn:
try:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO ip_asn_cache (ip, asn, name, country)
VALUES (%s, %s, %s, %s)
ON CONFLICT (ip) DO UPDATE SET
asn = EXCLUDED.asn, name = EXCLUDED.name,
country = EXCLUDED.country, fetched_at = now()
""", (ip, result["asn"], result["name"], result["country"]))
conn.commit()
except Exception:
try: conn.rollback()
except: pass
return result
def _lookup_as_prefixes(asn: str) -> list: def _lookup_as_prefixes(asn: str) -> list:
"""Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days.""" """Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days."""

View file

@ -4,3 +4,4 @@ requests>=2.31
gunicorn>=21.0 gunicorn>=21.0
python-dotenv>=1.0 python-dotenv>=1.0
Pillow>=10.0 Pillow>=10.0
psycopg2-binary>=2.9

View file

@ -58,9 +58,9 @@ echo -e "${CYAN}[2/4] Mise à jour de l'environnement Python…${RESET}"
ssh "$REMOTE_HOST" bash <<'ENDSSH' ssh "$REMOTE_HOST" bash <<'ENDSSH'
set -e set -e
cd /opt/static-cdn cd /opt/static-cdn
[ ! -d venv ] && python3 -m venv venv [ ! -d venv ] && sudo -u static-cdn python3 -m venv venv
venv/bin/pip install --quiet --upgrade pip sudo -u static-cdn venv/bin/pip install --quiet --upgrade pip
venv/bin/pip install --quiet -r requirements.txt sudo -u static-cdn venv/bin/pip install --quiet -r requirements.txt
ENDSSH ENDSSH
echo -e " ${GREEN}✓ Dépendances installées${RESET}" echo -e " ${GREEN}✓ Dépendances installées${RESET}"