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:
parent
5d2a4ab430
commit
d4fe8614c2
5 changed files with 117 additions and 9 deletions
|
|
@ -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` (1–3 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é
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.6.0
|
1.6.1
|
||||||
|
|
|
||||||
108
app/app.py
108
app/app.py
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue