From d180cb700c77d4b021cfa70fc3b2a4dce74fc37e Mon Sep 17 00:00:00 2001 From: Maxime Killinger Date: Wed, 31 Dec 2025 18:46:21 +0100 Subject: [PATCH] refactor: optimize docker, robust config & country handling - optimize(docker): use distroless/python3-debian12 & multi-stage build - feat(app): replace Gunicorn with Waitress (pure python) - feat(countries): replace static list with pycountry (dynamic & complete) - feat(config): enforce mandatory APPRISE_URL (fatal if missing) - feat(logging): add structured logging and graceful shutdown signal handling - docs: update README with required env var and correct usage --- Dockerfile | 31 ++++++++++++++-- README.md | 5 ++- app.py | 96 +++++++++++++++++++++++++++++++++++++----------- requirements.txt | 3 +- 4 files changed, 106 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index 92598b1..943054b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,35 @@ -FROM python:3.11-alpine +# ------------------------------------ +# Stage 1: Build dependencies +# ------------------------------------ +FROM python:3.11-slim-bookworm AS builder + +WORKDIR /build +COPY requirements.txt . + +# Install dependencies to a specific directory +RUN pip install --no-cache-dir --target=/build/packages -r requirements.txt + +# ------------------------------------ +# Stage 2: Final distroless image +# ------------------------------------ +# Using Debian 12 (Bookworm) based distroless to match builder +FROM gcr.io/distroless/python3-debian12 WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Add our packages to python path +ENV PYTHONPATH=/app/packages +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +# Copy dependencies +COPY --from=builder /build/packages /app/packages + +# Copy application code COPY app.py . +# Expose port (metadata only, distroless doesn't enforce) EXPOSE 5000 -CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"] +# Run app.py directly (which uses waitress with signal handling) +CMD ["app.py"] diff --git a/README.md b/README.md index f1427d2..40a7c5e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ L'image Docker est automatiquement construite et poussée sur `gitea.killinger.f | Variable | Description | Défaut | |----------|-------------|--------| -| `APPRISE_URL` | URL du endpoint Apprise | `http://192.168.1.118:8000/notify/telegram_alerts` | +| `APPRISE_URL` | URL du endpoint Apprise | **Requis** | ### Docker Run @@ -25,8 +25,9 @@ L'image Docker est automatiquement construite et poussée sur `gitea.killinger.f docker run -d \ --name crowdsec-notify \ --restart unless-stopped \ + -e APPRISE_URL=http://your-apprise-url/notify/my_alert_config \ -p 5000:5000 \ - gitea.killinger.fr/services/crowdsec-notify:latest + crowdsec-notify:latest ``` ## Configuration CrowdSec diff --git a/app.py b/app.py index d3b870b..42b1687 100644 --- a/app.py +++ b/app.py @@ -6,34 +6,55 @@ Author: Maxime Killinger """ import os +import logging from flask import Flask, request, jsonify import requests +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + app = Flask(__name__) -APPRISE_URL = os.environ.get("APPRISE_URL", "http://192.168.1.118:8000/notify/telegram_alerts") +import pycountry -# Country codes to full names -COUNTRIES = { - "CN": "Chine", "RU": "Russie", "US": "États-Unis", "FR": "France", - "DE": "Allemagne", "GB": "Royaume-Uni", "NL": "Pays-Bas", "BR": "Brésil", - "IN": "Inde", "KR": "Corée du Sud", "JP": "Japon", "VN": "Vietnam", - "ID": "Indonésie", "TW": "Taïwan", "HK": "Hong Kong", "SG": "Singapour", - "TH": "Thaïlande", "PH": "Philippines", "MY": "Malaisie", "PK": "Pakistan", - "BD": "Bangladesh", "IR": "Iran", "TR": "Turquie", "UA": "Ukraine", - "PL": "Pologne", "RO": "Roumanie", "IT": "Italie", "ES": "Espagne", - "AR": "Argentine", "MX": "Mexique", "CO": "Colombie", "CL": "Chili", - "ZA": "Afrique du Sud", "EG": "Égypte", "NG": "Nigeria", "KE": "Kenya", - "AU": "Australie", "NZ": "Nouvelle-Zélande", "CA": "Canada", "SE": "Suède", - "NO": "Norvège", "FI": "Finlande", "DK": "Danemark", "BE": "Belgique", - "CH": "Suisse", "AT": "Autriche", "CZ": "Tchéquie", "HU": "Hongrie", - "BG": "Bulgarie", "GR": "Grèce", "PT": "Portugal", "IE": "Irlande", -} +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +APPRISE_URL = os.environ.get("APPRISE_URL") def get_country_name(code): - if not code: - return "Inconnu" - return COUNTRIES.get(code.upper(), code) + if not code or code.lower() == 'unknown': + logger.warning(f"Country code is unknown or empty: {code}") + return "Unknown" + try: + # Standardize code to alpha_2 + # Clean string just in case + clean_code = code.strip().upper() + if len(clean_code) != 2: + logger.warning(f"Invalid country code format: {code}") + return clean_code + + country = pycountry.countries.get(alpha_2=clean_code) + if country: + return country.name + + logger.warning(f"Country code not found in pycountry: {clean_code}") + return clean_code + except Exception as e: + logger.warning(f"Error resolving country code {code}: {e}") + return code def format_scenario(scenario): if not scenario: @@ -45,6 +66,7 @@ def handle_crowdsec(): try: alerts = request.json if not alerts: + logger.debug("Received empty alert payload") return jsonify({"status": "no data"}), 200 # Group by IP @@ -74,10 +96,19 @@ def handle_crowdsec(): body = "\n".join(lines).strip() title = f"🛡️ CrowdSec - {num_ips} IP{'s' if num_ips > 1 else ''} bannie{'s' if num_ips > 1 else ''}" - requests.post(APPRISE_URL, json={"title": title, "body": body}, timeout=10) + # Send to Apprise + response = requests.post(APPRISE_URL, json={"title": title, "body": body}, timeout=10) + + if response.ok: + ips_list = ", ".join(ip_groups.keys()) + logger.info(f"Notification sent: {num_ips} IP(s) banned [{ips_list}]") + else: + logger.error(f"Apprise error: {response.status_code} - {response.text}") + return jsonify({"status": "sent", "ips": num_ips}), 200 except Exception as e: + logger.exception(f"Error processing CrowdSec alert: {e}") return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/health', methods=['GET']) @@ -85,4 +116,25 @@ def health(): return jsonify({"status": "ok"}), 200 if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000) + from waitress import serve + import sys + import signal + + if not APPRISE_URL: + logger.critical("APPRISE_URL environment variable is not set") + sys.exit(1) + + def handle_signal(signum, frame): + logger.info(f"Received signal {signum}. Shutting down gracefully...") + # Waitress does not have a public stop method when run this way easily, + # but sys.exit(0) from a signal handler usually works to terminate. + # However, waitress catches signals if passed to serve? + # Actually waitress handles signals by itself if running in main. + # But we want to LOG it. + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + logger.info(f"Starting CrowdSec Notify - Apprise URL: {APPRISE_URL}") + serve(app, host='0.0.0.0', port=5000) diff --git a/requirements.txt b/requirements.txt index 482c80a..001d714 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flask>=2.0 requests>=2.25 -gunicorn>=20.0 +waitress>=2.1 +pycountry>=22.3.5