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
## [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
### Ajouté

View file

@ -1 +1 @@
1.6.0
1.6.1

View file

@ -9,10 +9,18 @@ import subprocess
import threading
import time
import urllib.request
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timedelta
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 flask import (Flask, redirect, url_for, session, request,
@ -71,6 +79,10 @@ _BANNED_CACHE: tuple = (set(), [])
_BANNED_CACHE_TS: float = 0
_BANNED_CACHE_TTL = 60 # 1 min
_pg_dsn = os.environ.get("DATABASE_URL", "")
_pg_pool_obj = None
_pg_schema_ok = False
_HIDDEN = frozenset({
".git", "scripts", "app",
@ -616,22 +628,108 @@ def _ip_is_banned(ip: str) -> bool:
except ValueError:
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:
"""Returns {asn, name, country} via ip-api.com."""
url = f"http://ip-api.com/json/{ip}?fields=as,org,countryCode"
"""Returns {asn, name, country} — PostgreSQL cache (30 j) puis ip-api.com."""
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:
req = urllib.request.Request(url, headers={"User-Agent": "alpinux-static/1.0"})
with urllib.request.urlopen(req, timeout=5) as resp:
d = json.loads(resp.read())
as_field = d.get("as", "") # "AS12345 Some Name"
as_field = d.get("as", "")
asn, name = "", as_field
if as_field.startswith("AS"):
parts = as_field.split(" ", 1)
asn = parts[0][2:]
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:
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:
"""Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days."""

View file

@ -4,3 +4,4 @@ requests>=2.31
gunicorn>=21.0
python-dotenv>=1.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'
set -e
cd /opt/static-cdn
[ ! -d venv ] && python3 -m venv venv
venv/bin/pip install --quiet --upgrade pip
venv/bin/pip install --quiet -r requirements.txt
[ ! -d venv ] && sudo -u static-cdn python3 -m venv venv
sudo -u static-cdn venv/bin/pip install --quiet --upgrade pip
sudo -u static-cdn venv/bin/pip install --quiet -r requirements.txt
ENDSSH
echo -e " ${GREEN}✓ Dépendances installées${RESET}"