refactor: optimize docker, robust config & country handling
All checks were successful
🚀 Docker Build and Push / build-and-push (push) Successful in 59s
All checks were successful
🚀 Docker Build and Push / build-and-push (push) Successful in 59s
- 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
This commit is contained in:
31
Dockerfile
31
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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
96
app.py
96
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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
flask>=2.0
|
||||
requests>=2.25
|
||||
gunicorn>=20.0
|
||||
waitress>=2.1
|
||||
pycountry>=22.3.5
|
||||
|
||||
Reference in New Issue
Block a user