diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 87ded0b2..7bc33199 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -97,7 +97,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: docker/backend/Dockerfile + file: docker-prod/backend/Dockerfile target: runtime push: true tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }} diff --git a/.github/workflows/frontend-web-build.yml b/.github/workflows/frontend-web-build.yml index ea9e7889..e5c3158e 100644 --- a/.github/workflows/frontend-web-build.yml +++ b/.github/workflows/frontend-web-build.yml @@ -86,7 +86,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: docker/frontend/Dockerfile + file: docker-prod/frontend/Dockerfile push: true tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }} build-args: | diff --git a/docker-prod/.env.example b/docker-prod/.env.example new file mode 100644 index 00000000..389dfacb --- /dev/null +++ b/docker-prod/.env.example @@ -0,0 +1,26 @@ +DB_HOST=postgres +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=Ia3JWjw3Y0HzlEXH6QH3pqEu09Fap5C420 +DB_NAME=gestion_db +DB_SSLMODE=disable +SESSION_SECRET=GwDgqYn7Tn4x6Hs9ZjUD6HP8B7pQWK +USER_JWT_SECRET=69F5ujM1YZ6JBh3pXczc3j0JzBuAvU +ADMIN_JWT_SECRET=RwPxdzSzAR7HcrufA6kEXHFdIiEX87 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=m3hQyr4BgF0Paer1H4a5iUnzXqjUji +TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB +TOMTOM_API_KEY_1=6F7HHk8GT6WGlZ22W4gfAbRiQk5lJoGV +TELEGRAM_BOT_TOKEN=7419967935:AAEeNIzlK6DqcQTL8q63zQ-Ted5W5VOd-LI +TELEGRAM_BOT_USERNAME=rezsssnfdsjfdsfbot +TELEGRAM_WEBHOOK_SECRET=vGB8n5H2fJTUx6jy6iYgYlqLz1mfSv9htF +TELEGRAM_WEBHOOK_URL=https://uber-demo.club/webhook/telegram +BACKEND_LINK_SECRET=QxAEEGUGRMtWbNvC2REo27haN78Rl5c5EQ +LBTELEGRAM_URL=http://lbtelegram:8081 +LBTELEGRAM_BOT1_USERNAME=GetRezStealer_bot +LBTELEGRAM_BOT2_USERNAME=rezDJDFJSFUltraFast_bot +BACKEND_LINK_SECRET=change_me_internal_secret +API_PORT=8080 +FRONTEND_PORT=5173 +GIN_MODE=release diff --git a/docker-prod/.env.lbtelegram b/docker-prod/.env.lbtelegram new file mode 100644 index 00000000..c6af6483 --- /dev/null +++ b/docker-prod/.env.lbtelegram @@ -0,0 +1,30 @@ +# Gateway +PORT=8081 +ENV=production +BOT_COUNT=2 + +# Bots Telegram +BOT1_TOKEN=8336841145:AAHPfHdgqLctEC_Zet5mT8D7ZXiwp8BQ1io +BOT1_USERNAME=GetRezStealer_bot +BOT1_WEBHOOK_SECRET=cVxqtea9s078ozDSlX57MWe6bjoLAK3ra7Zq + +BOT2_TOKEN=8325503969:AAGffm9Q-oYr5ySf8cR4Wr-e2CA2p_xOrbg +BOT2_USERNAME=rezDJDFJSFUltraFast_bot +BOT2_WEBHOOK_SECRET=591aVEu1kj3YUVCNWAOU2xGdFNCVWqElzXGi + +# URL publique de la gateway (pour setWebhook Telegram) +GATEWAY_URL=https://demo-uber.club + +# JWT +JWT_SECRET=IxGF36s14J0ZNeQCF2Of0APc4kpNd5PlsJ +JWT_TTL_SECONDS=300 + +# Load balancer: roundrobin | leastconn | failover +LB_STRATEGY=failover + +# Health check interval en secondes +HEALTH_CHECK_INTERVAL=30 + +# URL interne du backend pour valider les tokens de liaison +BACKEND_LINK_URL=http://backend:8080/api/internal/telegram/link +BACKEND_LINK_SECRET=QxAEEGUGRMtWbNvC2REo27haN78Rl5c5EQ diff --git a/docker-prod/backend/Dockerfile b/docker-prod/backend/Dockerfile new file mode 100644 index 00000000..6464ae4f --- /dev/null +++ b/docker-prod/backend/Dockerfile @@ -0,0 +1,76 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /app + +# Installer les dépendances système +RUN apk add --no-cache git ca-certificates tzdata gcc musl-dev + +# Copier go mod files +COPY backend/gestion/go.mod backend/gestion/go.sum ./ + +# Configurer Go et télécharger les dépendances +ENV GOPROXY=https://proxy.golang.org,direct +ENV GOSUMDB=sum.golang.org +ENV CGO_ENABLED=0 + +# Télécharger les dépendances +RUN go mod download + +# Copier tout le code source +COPY backend/gestion/ . + +# Build le binaire +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-w -s -X main.Version=1.0.0 -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o /app/server . + +# ========================================================= +# Stage 2: Runtime +# ========================================================= +FROM alpine:latest AS runtime + +# Installer les dépendances runtime +RUN apk --no-cache add ca-certificates tzdata wget + +# Créer l'utilisateur avec UID/GID fixes +RUN addgroup -g 101 app && adduser -u 101 -S app -G app + + +WORKDIR /app + +# Copier le binaire et les fichiers nécessaires +COPY --from=builder /app/server . +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo + +# Copier l'entrypoint +COPY docker/backend/entrypoint.sh . +RUN chmod +x entrypoint.sh + +RUN mkdir -p /app/uploads/images /app/uploads/videos && \ + chown -R app:app /app + +# Passer à l'utilisateur non-root +USER app + +EXPOSE 8080 + +ENTRYPOINT ["./entrypoint.sh"] + +# ========================================================= +# Stage 3: WAF (Nginx + ModSecurity) +# ========================================================= +FROM owasp/modsecurity-crs:nginx-alpine AS waf + +# Toutes les opérations privilégiées en root +USER root + +RUN mkdir -p /var/log/modsec /etc/nginx/certs && \ + chown -R nginx:nginx /var/log/modsec /etc/nginx/certs /usr/share/nginx/html + +COPY docker/backend/nginx.conf /etc/nginx/conf.d/app.conf +COPY docker/backend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf +RUN echo "Include /etc/nginx/modsec/custom-rules.conf" > /etc/nginx/modsec/custom-includes.conf && \ + rm -f /etc/nginx/templates/conf.d/default.conf.template || true + +USER nginx +EXPOSE 80 443 +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-prod/backend/custom-rules.conf b/docker-prod/backend/custom-rules.conf new file mode 100644 index 00000000..c2aaeb37 --- /dev/null +++ b/docker-prod/backend/custom-rules.conf @@ -0,0 +1,77 @@ +# Exclure le corps des requêtes/réponses de l'audit log pour garder les lignes < 6KB (limite Wazuh) +SecAuditLogParts ABIFHZ + +SecRuleRemoveById 932235 +SecRuleRemoveById 911100 + +SecRule REQUEST_URI "@streq /api/v2/admin/protected/products" \ + "id:399002,phase:2,nolog,pass,\ + ctl:ruleRemoveById=920120,\ + ctl:ruleRemoveById=920121" + +SecRule REQUEST_URI "@beginsWith /uploads/" \ + "id:1000,\ + phase:1,\ + pass,\ + nolog,\ + ctl:ruleEngine=Off" + +SecRule IP:BANNED "@eq 1" \ + "id:100000,phase:1,deny,status:403,log,\ + msg:'IP is banned'" + +SecRule IP:REPUTATION_SCORE "@ge 100" \ + "id:100099,phase:1,deny,status:403,log,\ + msg:'Critical reputation score',\ + setvar:'ip.blocked=1',expirevar:'ip.blocked=86400'" + +SecRule TX:SQL_INJECTION_SCORE "@ge 5" \ + "id:100001,phase:2,deny,status:403,log,\ + msg:'SQL Injection detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + +SecRule TX:XSS_SCORE "@ge 5" \ + "id:100010,phase:2,deny,status:403,log,\ + msg:'XSS detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + +SecRule TX:RCE_SCORE "@ge 5" \ + "id:100020,phase:2,deny,status:403,log,\ + msg:'RCE detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=259200'" + +SecRule TX:LFI_SCORE "@ge 5" \ + "id:100030,phase:2,deny,status:403,log,\ + msg:'LFI detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + +SecRule TX:INBOUND_ANOMALY_SCORE "@ge 20" \ + "id:100060,phase:2,deny,status:403,log,\ + msg:'Critical anomaly score',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + +SecRule REQUEST_URI "@streq /api/v1/panier/remove" \ + "id:399010,phase:1,nolog,pass,\ + ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350" + +SecRule REQUEST_URI "@streq /api/v1/panier/clear" \ + "id:399011,phase:1,nolog,pass,\ + ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350" + +SecRule REQUEST_URI "@streq /api/v2/admin/protected/products" \ + "id:399001,phase:2,nolog,pass,\ + ctl:ruleRemoveById=932235" + +SecAction \ + "id:400161,phase:1,nolog,pass,\ + setvar:'ip.request_window_1sec=+1',\ + expirevar:'ip.request_window_1sec=1'" + +SecRule IP:REQUEST_WINDOW_1SEC "@gt 20" \ + "id:400160,phase:1,deny,status:429,log,\ + msg:'Too many requests'" + +SecRule IP:REPUTATION_SCORE "@ge 100" \ + "id:409999,phase:1,deny,status:403,log,\ + msg:'Critical reputation score',\ + setvar:'ip.blocked=1',expirevar:'ip.blocked=86400'" diff --git a/docker-prod/backend/entrypoint.sh b/docker-prod/backend/entrypoint.sh new file mode 100755 index 00000000..4c1b857e --- /dev/null +++ b/docker-prod/backend/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# Créer le dossier uploads s'il n'existe pas (le volume le crée en root) +# Puis créer les sous-dossiers +if [ ! -d "/app/uploads" ]; then + mkdir -p /app/uploads +fi + +# Créer les sous-répertoires +mkdir -p /app/uploads/images /app/uploads/videos 2>/dev/null || true + +echo "✅ Répertoires uploads prêts" + +# Lancer l'application +exec ./server diff --git a/docker-prod/backend/nginx.conf b/docker-prod/backend/nginx.conf new file mode 100644 index 00000000..557db88f --- /dev/null +++ b/docker-prod/backend/nginx.conf @@ -0,0 +1,215 @@ +# ========================================================= +# Request ID correlation +# ========================================================= +map $http_x_request_id $req_id { + default $http_x_request_id; + "" $request_id; +} + +# ========================================================= +# HTTP → HTTPS redirect +# ========================================================= +server { + listen 80; + listen [::]:80; + server_name _; + + return 301 https://$host$request_uri; +} + +# ========================================================= +# HTTPS — Hardened +# ========================================================= +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name _; + + server_tokens off; + + # --------------------------------------------------- + # TLS + # --------------------------------------------------- + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + ssl_stapling on; + ssl_stapling_verify on; + ssl_trusted_certificate /etc/nginx/certs/fullchain.pem; + resolver 127.0.0.11 valid=10s ipv6=off; + resolver_timeout 5s; + + # --------------------------------------------------- + # En-têtes de sécurité + # --------------------------------------------------- + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "0" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'self';" always; + + # --------------------------------------------------- + # Limites et timeouts + # --------------------------------------------------- + client_max_body_size 100M; + client_body_buffer_size 128k; + client_header_buffer_size 1k; + large_client_header_buffers 4 8k; + + client_body_timeout 30s; + client_header_timeout 30s; + send_timeout 30s; + keepalive_timeout 65s; + + # --------------------------------------------------- + # Gzip (statique uniquement — évite BREACH sur l'API) + # --------------------------------------------------- + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1000; + gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml; + + # --------------------------------------------------- + # ModSecurity WAF + # --------------------------------------------------- + modsecurity on; + modsecurity_rules_file /etc/nginx/modsec/custom-rules.conf; + + # --------------------------------------------------- + # API backend Go + # --------------------------------------------------- + location /api/ { + limit_except GET POST PUT PATCH DELETE OPTIONS { deny all; } + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' "$http_origin" always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Request-ID' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Length' 0; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + return 204; + } + + set $upstream_backend http://backend:8080; + proxy_pass $upstream_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $req_id; + proxy_hide_header X-Powered-By; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + # --------------------------------------------------- + # Webhook Telegram principal → backend Go + # --------------------------------------------------- + location = /webhook/telegram { + limit_except POST { deny all; } + + set $upstream_backend http://backend:8080; + proxy_pass $upstream_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --------------------------------------------------- + # Webhooks LBTelegram (/webhook/bot1, /webhook/bot2…) + # --------------------------------------------------- + location /webhook/ { + limit_except POST { deny all; } + + set $upstream_lbtelegram http://lbtelegram:8081; + proxy_pass $upstream_lbtelegram; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --------------------------------------------------- + # Fichiers uploadés (images / vidéos) + # --------------------------------------------------- + location /uploads/ { + alias /usr/share/nginx/html/uploads/; + expires 7d; + add_header Cache-Control "public, no-transform" always; + add_header X-Content-Type-Options "nosniff" always; + + location ~* \.(php|pl|py|cgi|sh|rb|exe)$ { + deny all; + } + } + + # --------------------------------------------------- + # Frontend React SPA + # --------------------------------------------------- + location / { + set $upstream_frontend http://frontend:80; + proxy_pass $upstream_frontend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --------------------------------------------------- + # Blocage fichiers sensibles + # --------------------------------------------------- + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + location ~* \.(env|git|sql|bak|log|conf|ini|sh)$ { + deny all; + access_log off; + log_not_found off; + } + + location ~* (package\.json|package-lock\.json|yarn\.lock|Dockerfile|docker-compose)$ { + deny all; + access_log off; + log_not_found off; + } + + # Bloquer scans WordPress / PHP courants + location ~* (wp-admin|wp-login|wp-content|xmlrpc|\.php)$ { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/docker-prod/docker-compose-prod.yml b/docker-prod/docker-compose-prod.yml new file mode 100644 index 00000000..d62c40bd --- /dev/null +++ b/docker-prod/docker-compose-prod.yml @@ -0,0 +1,212 @@ +services: + # ========================================================= + # Backend Go + # ========================================================= + backend: + image: xor1234/backend-mln:latest + container_name: gestion-backend + restart: unless-stopped + environment: + - DB_HOST=${DB_HOST:-postgres} + - DB_PORT=${DB_PORT:-5432} + - DB_USER=${DB_USER:-postgres} + - DB_PASSWORD=${DB_PASSWORD} + - DB_NAME=${DB_NAME:-gestion_db} + - DB_SSLMODE=${DB_SSLMODE:-disable} + - SESSION_SECRET=${SESSION_SECRET} + - USER_JWT_SECRET=${USER_JWT_SECRET} + - USER_JWT_SECRET_OLD=${USER_JWT_SECRET_OLD} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET} + - ADMIN_JWT_SECRET_OLD=${ADMIN_JWT_SECRET_OLD} + - REDIS_HOST=${REDIS_HOST:-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_PASSWORD=${REDIS_PASSWORD} + - TOMTOM_API_KEY=${TOMTOM_API_KEY} + - TOMTOM_API_KEY_1=${TOMTOM_API_KEY_1} + - TOMTOM_API_KEY_2=${TOMTOM_API_KEY_2} + - TOMTOM_API_KEY_3=${TOMTOM_API_KEY_3} + - API_PORT=${API_PORT:-8080} + - NOWPAYMENTS_IPN_SECRET=${NOWPAYMENTS_IPN_SECRET} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME} + - TELEGRAM_WEBHOOK_SECRET=${TELEGRAM_WEBHOOK_SECRET} + - TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL} + - LBTELEGRAM_URL=http://lbtelegram:8081 + - LBTELEGRAM_BOT1_USERNAME=${LBTELEGRAM_BOT1_USERNAME:-GetRezStealer_bot} + - LBTELEGRAM_BOT2_USERNAME=${LBTELEGRAM_BOT2_USERNAME:-rezDJDFJSFUltraFast_bot} + - BACKEND_LINK_SECRET=${BACKEND_LINK_SECRET:-change_me_internal_secret} + volumes: + - backend_uploads:/app/uploads + networks: + - gestion-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + # ========================================================= + # Frontend Web (React/Vite — servi en HTTP interne) + # ========================================================= + frontend: + image: xor1234/frontend-mln:latest + container_name: gestion-frontend + restart: unless-stopped + networks: + - gestion-network + depends_on: + - backend + + waf: + image: xor1234/backend-mln:waf + container_name: gestion-waf + restart: unless-stopped + environment: + - DISABLE_MODSEC_ENV_SUBST=true + - PARANOIA=2 + - ANOMALY_INBOUND=5 + - ANOMALY_OUTBOUND=4 + - MODSEC_AUDIT_LOG=/var/log/modsec/modsec_audit.log + ports: + - "80:80" + - "443:443" + volumes: + - backend_uploads:/usr/share/nginx/html/uploads:ro + - ./certs:/etc/nginx/certs:ro + - ./backend/nginx.conf:/etc/nginx/conf.d/app.conf:ro + - ./backend/custom-rules.conf:/etc/nginx/modsec/custom-rules.conf:ro + - /var/log/waf/nginx:/var/log/nginx + - /var/log/waf/modsec:/var/log/modsec + networks: + - gestion-network + depends_on: + - backend + - frontend + + # ========================================================= + # PostgreSQL + # ========================================================= + postgres: + image: postgres:16-alpine + container_name: gestion-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=${DB_USER:-postgres} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME:-gestion_db} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - gestion-network + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-gestion_db}", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + # ========================================================= + # Redis + # ========================================================= + redis: + image: redis:7-alpine + container_name: gestion-redis + restart: unless-stopped + command: > + redis-server + --requirepass ${REDIS_PASSWORD} + --appendonly yes + --appendfsync everysec + --maxmemory 256mb + --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + networks: + - gestion-network + healthcheck: + test: + [ + "CMD", + "redis-cli", + "--no-auth-warning", + "-a", + "${REDIS_PASSWORD}", + "ping", + ] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s + + # ========================================================= + # LBTelegram — Gateway Telegram load balancer + # ========================================================= + lbtelegram: + image: xor1234/load-balancer-tlg:latest + container_name: gestion-lbtelegram + restart: unless-stopped + env_file: ./.env.lbtelegram + environment: + - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}?sslmode=disable + networks: + - gestion-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + backend: + condition: service_started + + clamav: + deploy: + resources: + limits: + memory: 1g + cpus: 0.5 + image: clamav/clamav:latest + container_name: gestion-clamav + restart: unless-stopped + volumes: + - backend_uploads:/app/uploads:ro + - clamav_data:/var/lib/clamav + networks: + - gestion-network + healthcheck: + test: ["CMD", "clamdcheck.sh"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 120s + + dozzle-agent: + image: amir20/dozzle:latest + command: agent + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + ports: + - "7007:7007" + restart: unless-stopped + +networks: + gestion-network: + driver: bridge + internal: false + driver_opts: + com.docker.network.bridge.name: gestion-br0 + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + backend_uploads: + driver: local + clamav_data: + driver: local diff --git a/docker-prod/frontend/Dockerfile b/docker-prod/frontend/Dockerfile new file mode 100644 index 00000000..8594b8ff --- /dev/null +++ b/docker-prod/frontend/Dockerfile @@ -0,0 +1,34 @@ +# ========================================================= +# Stage 1: Build React/Vite +# ========================================================= +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY frontend-prep/package.json frontend-prep/package-lock.json* ./ +RUN npm ci --ignore-scripts + +COPY frontend-prep/ . +RUN npm run build + +# ========================================================= +# Stage 2: Nginx HTTP (TLS terminé par le WAF) +# ========================================================= +FROM nginx:alpine AS runtime + +COPY docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=builder /app/dist /usr/share/nginx/html + +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + mkdir -p /var/cache/nginx && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + touch /var/run/nginx.pid && \ + chown nginx:nginx /var/run/nginx.pid + +USER nginx + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-prod/frontend/nginx.conf b/docker-prod/frontend/nginx.conf new file mode 100644 index 00000000..6d95b8c4 --- /dev/null +++ b/docker-prod/frontend/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/scripts/eas_cache.py b/scripts/eas_cache.py new file mode 100644 index 00000000..b22f6ad9 --- /dev/null +++ b/scripts/eas_cache.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +eas_cache.py — Cache manager for EAS local builds stored on RustFS/S3 + +Reproduit le système de cache d'Expo cloud pour les builds locaux : + 1. restore : télécharge le cache Gradle depuis S3 et l'extrait dans ~/.gradle + 2. save : compresse ~/.gradle et l'upload dans S3 + +Clé de cache = sha256(package-lock.json + app.json) → premier appel exact, + sha256(package-lock.json) → fallback si app.json change + +Usage: + python scripts/eas_cache.py restore --app mobile + python scripts/eas_cache.py save --app mobile + +Variables d'environnement requises: + AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + S3_ENDPOINT ex: https://s3.uber-stup.club + S3_BUCKET ex: build-cache (défaut: build-cache) +""" + +import argparse +import hashlib +import os +import sys +import tarfile +import time +from pathlib import Path + +# ── Dirs à cacher ────────────────────────────────────────────────────────────── +CACHE_DIRS = [ + Path.home() / ".gradle" / "caches" / "modules-2", # dépendances Maven/Gradle + Path.home() / ".gradle" / "caches" / "transforms-3", # artefacts transformés + Path.home() / ".gradle" / "wrapper" / "dists", # distribution Gradle +] + +# Fichiers utilisés pour calculer la clé de cache par app +HASH_FILES = { + "mobile": ["mobile/package-lock.json", "mobile/app.json"], + "frontend-admin": ["frontend-admin/package-lock.json", "frontend-admin/app.json"], +} + + +# ── Clé de cache ─────────────────────────────────────────────────────────────── + +def compute_key(app: str, repo_root: Path, full: bool = True) -> str: + """Calcule la clé de cache (sha256 tronqué à 16 chars).""" + files = HASH_FILES[app] + if not full: + files = files[:1] # fallback : seulement package-lock.json + + h = hashlib.sha256() + for rel in files: + path = repo_root / rel + if path.exists(): + h.update(path.read_bytes()) + else: + h.update(rel.encode()) # fichier absent → on hash le nom pour différencier + return h.hexdigest()[:16] + + +def s3_key(app: str, cache_key: str) -> str: + return f"gradle-cache/{app}/{cache_key}.tar.gz" + +BUCKET_DEFAULT = "apk-builds" + + +# ── Helpers S3 ───────────────────────────────────────────────────────────────── + +def _boto3_client(): + try: + import boto3 + from botocore.config import Config + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + print("❌ boto3 non installé — lance : pip install boto3") + sys.exit(1) + + endpoint = os.environ.get("S3_ENDPOINT", "").rstrip("/") + if not endpoint: + print("❌ Variable S3_ENDPOINT manquante") + sys.exit(1) + + return boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"], + region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"), + config=Config( + s3={"addressing_style": "path"}, + signature_version="s3v4", + ), + verify=False, + ) + + +def ensure_bucket(s3, bucket: str): + """Crée le bucket s'il n'existe pas.""" + try: + s3.head_bucket(Bucket=bucket) + except Exception: + try: + s3.create_bucket(Bucket=bucket) + print(f"🪣 Bucket '{bucket}' créé") + except Exception as e: + print(f"❌ Impossible de créer le bucket '{bucket}': {e}") + sys.exit(1) + + +class _Progress: + """Affiche la progression upload/download en MB.""" + + def __init__(self, total: int, label: str): + self.total = total + self.done = 0 + self.label = label + self.start = time.time() + + def __call__(self, chunk: int): + self.done += chunk + pct = self.done * 100 // self.total if self.total else 0 + mb_done = self.done / 1_048_576 + mb_total = self.total / 1_048_576 + elapsed = time.time() - self.start + speed = (self.done / elapsed / 1_048_576) if elapsed > 0 else 0 + print( + f"\r {self.label} {mb_done:.1f}/{mb_total:.1f} MB {pct}% {speed:.1f} MB/s", + end="", + flush=True, + ) + if self.done >= self.total: + print() + + +def object_exists(s3, bucket: str, key: str) -> bool: + try: + s3.head_object(Bucket=bucket, Key=key) + return True + except Exception: + return False + + +def object_size(s3, bucket: str, key: str) -> int: + try: + resp = s3.head_object(Bucket=bucket, Key=key) + return resp["ContentLength"] + except Exception: + return 0 + + +# ── Commande restore ─────────────────────────────────────────────────────────── + +def restore(app: str, repo_root: Path, bucket: str): + s3 = _boto3_client() + ensure_bucket(s3, bucket) + + full_key = compute_key(app, repo_root, full=True) + fallback_key = compute_key(app, repo_root, full=False) + + hit_key = None + for label, ck in [("exact", full_key), ("fallback", fallback_key)]: + obj = s3_key(app, ck) + if object_exists(s3, bucket, obj): + print(f"✅ Cache {label} trouvé → {obj}") + hit_key = ck + break + + if hit_key is None: + print("ℹ️ Aucun cache trouvé (premier build ou clé inconnue)") + return + + obj = s3_key(app, hit_key) + size = object_size(s3, bucket, obj) + progress = _Progress(size, "⬇️ download") + + import tempfile + with tempfile.TemporaryFile() as tmp: + s3.download_fileobj(bucket, obj, tmp, Callback=progress) + tmp.seek(0) + print("📦 Extraction du cache…") + with tarfile.open(fileobj=tmp, mode="r:gz") as tf: + tf.extractall(path=Path.home()) + + print(f"✅ Cache restauré ({size / 1_048_576:.1f} MB)") + + +# ── Commande save ────────────────────────────────────────────────────────────── + +def save(app: str, repo_root: Path, bucket: str): + s3 = _boto3_client() + ensure_bucket(s3, bucket) + cache_key = compute_key(app, repo_root, full=True) + obj = s3_key(app, cache_key) + + # Pas besoin de re-uploader si la clé existe déjà + if object_exists(s3, bucket, obj): + print(f"ℹ️ Cache déjà présent pour cette clé ({cache_key}), skip upload") + return + + dirs_to_cache = [d for d in CACHE_DIRS if d.exists()] + if not dirs_to_cache: + print("⚠️ Aucun répertoire Gradle à cacher (~/.gradle introuvable)") + return + + print(f"📦 Compression de {len(dirs_to_cache)} répertoires…") + for d in dirs_to_cache: + print(f" {d}") + + import io + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz", compresslevel=6) as tf: + for d in dirs_to_cache: + # Chemin relatif depuis home pour restaurer au bon endroit + arcname = str(d.relative_to(Path.home())) + tf.add(d, arcname=arcname) + + size = buf.tell() + buf.seek(0) + + print(f"⬆️ Upload {obj} ({size / 1_048_576:.1f} MB)…") + progress = _Progress(size, "⬆️ upload ") + s3.upload_fileobj(buf, bucket, obj, Callback=progress) + + print(f"✅ Cache sauvegardé ({cache_key})") + + +# ── Commande clean (optionnel) ───────────────────────────────────────────────── + +def clean(app: str, bucket: str, keep: int): + """Supprime les anciennes entrées de cache (garde les `keep` plus récentes).""" + s3 = _boto3_client() + prefix = f"gradle-cache/{app}/" + resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix) + objs = sorted( + resp.get("Contents", []), + key=lambda o: o["LastModified"], + reverse=True, + ) + to_delete = objs[keep:] + if not to_delete: + print(f"ℹ️ Rien à supprimer (≤ {keep} entrées)") + return + s3.delete_objects( + Bucket=bucket, + Delete={"Objects": [{"Key": o["Key"]} for o in to_delete]}, + ) + print(f"🗑 {len(to_delete)} ancienne(s) entrée(s) supprimée(s)") + + +# ── CLI ──────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="EAS build cache manager — RustFS/S3") + parser.add_argument("command", choices=["restore", "save", "clean"]) + parser.add_argument("--app", required=True, choices=list(HASH_FILES.keys())) + parser.add_argument( + "--bucket", + default=os.environ.get("S3_BUCKET", "apk-builds"), + help="Nom du bucket S3 (défaut: apk-builds)", + ) + parser.add_argument( + "--repo-root", + default=os.environ.get("GITHUB_WORKSPACE", "."), + help="Racine du repo (défaut: GITHUB_WORKSPACE ou répertoire courant)", + ) + parser.add_argument( + "--keep", + type=int, + default=5, + help="[clean] Nombre d'entrées à conserver par app (défaut: 5)", + ) + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + print(f"🔧 eas_cache | app={args.app} commande={args.command} bucket={args.bucket}") + + if args.command == "restore": + restore(args.app, repo_root, args.bucket) + elif args.command == "save": + save(args.app, repo_root, args.bucket) + elif args.command == "clean": + clean(args.app, args.bucket, args.keep) + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 00000000..011ba23b --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +boto3>=1.34.0