From cf8e6bcc077ef1018fc4fa8cd4b73a52333d97c4 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Tue, 7 Jul 2026 19:52:04 +0300 Subject: [PATCH] Fail-fast secrets in stage/prod via EVENTHUB_ENV. Refs EventHub/EventHubBack#25 --- docker/.env.example | 25 +- docker/.env.stage | 26 +- docker/docker-compose.stage.yml | 166 +++++------ docker/docker-compose.swarm.yml | 454 +++++++++++++++--------------- docker/docker-compose.yml | 430 ++++++++++++++-------------- src/config/sys.config | 45 +-- src/eventhub_app.erl | 81 ++++-- src/infra/eventhub_auth.erl | 12 +- src/infra/infra_secrets.erl | 137 +++++++++ test/unit/infra_secrets_tests.erl | 62 ++++ 10 files changed, 855 insertions(+), 583 deletions(-) create mode 100644 src/infra/infra_secrets.erl create mode 100644 test/unit/infra_secrets_tests.erl diff --git a/docker/.env.example b/docker/.env.example index e97d064..cfb8e31 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,4 +1,21 @@ -# .env.example — закоммитьте этот файл, а реальный .env держите в .gitignore -RELEASE_COOKIE=ваш-очень-длинный-секретный-куки -GRAFANA_ADMIN_PASSWORD=сложный-уникальный-пароль -JWT_SECRET=суперсекрет \ No newline at end of file +# Скопируйте в .env (файл .env в .gitignore). Для локальной разработки: EVENTHUB_ENV=dev + +# Режим: dev | stage | prod (stage/prod — обязательные сильные секреты) +EVENTHUB_ENV=dev + +RELEASE_COOKIE=ваш-очень-длинный-секретный-куки-минимум-32-символа +GRAFANA_ADMIN_PASSWORD=сложный-уникальный-пароль + +# JWT (минимум 32 символа, не из denylist — см. infra_secrets) +JWT_SECRET=замените-на-случайную-строку-минимум-32-символа-для-user-jwt +ADMIN_JWT_SECRET=замените-на-другую-случайную-строку-минимум-32-символа + +# Seed админов (обязательны в stage/prod при пустой таблице admin; в dev — опционально) +ADMIN_SUPER_EMAIL=superadmin@eventhub.local +ADMIN_SUPER_PASSWORD=замените-сильный-пароль-суперадмина +ADMIN_EMAIL=admin@eventhub.local +ADMIN_PASSWORD=замените-сильный-пароль-админа +ADMIN_MODER_EMAIL=moderator@eventhub.local +ADMIN_MODER_PASSWORD=замените-сильный-пароль-модератора +ADMIN_SUPPORT_EMAIL=support@eventhub.local +ADMIN_SUPPORT_PASSWORD=замените-сильный-пароль-поддержки diff --git a/docker/.env.stage b/docker/.env.stage index 8bcefc2..7493557 100644 --- a/docker/.env.stage +++ b/docker/.env.stage @@ -1,12 +1,14 @@ -RELEASE_COOKIE=FuNHSU3SIiW8ICu2XIbl0pvpxAhpBvO2Wki7CT6q2bpHDSlQ8p7gtUgjnyXpPJU5 -GRAFANA_ADMIN_PASSWORD=admin -JWT_SECRET=IDhKNFRLZFDYgPvVXwg4T7hAviQ2I1VGeEn5vXufQXK3AAErZuhMWy8J1uxJ0kGF -TRAEFIK_LOG_PATH=/app/traefik/logs/access.log -ADMIN_SUPER_EMAIL=superadmin@eventhub.local -ADMIN_SUPER_PASSWORD=123456 -ADMIN_EMAIL=admin@eventhub.local -ADMIN_PASSWORD=123456 -ADMIN_MODER_EMAIL=moderator@eventhub.local -ADMIN_MODER_PASSWORD=123456 -ADMIN_SUPPORT_EMAIL=support@eventhub.local -ADMIN_SUPPORT_PASSWORD=123456 \ No newline at end of file +EVENTHUB_ENV=stage +RELEASE_COOKIE=FuNHSU3SIiW8ICu2XIbl0pvpxAhpBvO2Wki7CT6q2bpHDSlQ8p7gtUgjnyXpPJU5 +GRAFANA_ADMIN_PASSWORD=замените-сильный-пароль-grafana +JWT_SECRET=IDhKNFRLZFDYgPvVXwg4T7hAviQ2I1VGeEn5vXufQXK3AAErZuhMWy8J1uxJ0kGF +ADMIN_JWT_SECRET=K3AAErZuhMWy8J1uxJ0kGFIDhKNFRLZFDYgPvVXwg4T7hAviQ2I1VGeEn5vXufQ +TRAEFIK_LOG_PATH=/app/traefik/logs/access.log +ADMIN_SUPER_EMAIL=superadmin@eventhub.local +ADMIN_SUPER_PASSWORD=замените-сильный-пароль-суперадмина-мин32 +ADMIN_EMAIL=admin@eventhub.local +ADMIN_PASSWORD=замените-сильный-пароль-админа-минимум32 +ADMIN_MODER_EMAIL=moderator@eventhub.local +ADMIN_MODER_PASSWORD=замените-сильный-пароль-модератора-мин32 +ADMIN_SUPPORT_EMAIL=support@eventhub.local +ADMIN_SUPPORT_PASSWORD=замените-сильный-пароль-поддержки-мин32 diff --git a/docker/docker-compose.stage.yml b/docker/docker-compose.stage.yml index df54c51..d317006 100644 --- a/docker/docker-compose.stage.yml +++ b/docker/docker-compose.stage.yml @@ -1,81 +1,87 @@ -version: "3.8" - -services: - # ================== Балансировщик ================== - traefik: - image: traefik:latest - command: - - "--log.level=DEBUG" - - "--api.insecure=true" - - "--providers.docker=true" - - "--providers.docker.exposedbydefault=false" - - "--entrypoints.web.address=:80" - ports: - - "80:80" - volumes: - - "/var/run/docker.sock:/var/run/docker.sock:ro" - networks: - - eventhub-net - deploy: - replicas: 1 - restart_policy: - condition: any - - # ================== Кластер EventHub ================== - eventhub: - image: git.sabilin.com/eventhub/eventhub:stage - hostname: "eventhub-node{{.Task.Slot}}" - environment: - - NODE_NAME=eventhub-node{{.Task.Slot}} - - RELEASE_COOKIE=${RELEASE_COOKIE} - - JWT_SECRET=${JWT_SECRET} - - ADMIN_SUPER_EMAIL=${ADMIN_SUPER_EMAIL} - - ADMIN_SUPER_PASSWORD=${ADMIN_SUPER_PASSWORD} - - ADMIN_EMAIL=${ADMIN_EMAIL} - - ADMIN_PASSWORD=${ADMIN_PASSWORD} - - CLUSTER_MODE=true - - DNS_NAME=eventhub-node - networks: - eventhub-net: - aliases: - - eventhub-node - volumes: - - eventhub-data:/app/data - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 30s - deploy: - replicas: 1 - endpoint_mode: dnsrr - restart_policy: - condition: any - - # ================== Эмулятор ботов ================== - bot-emulator-users: - image: git.sabilin.com/eventhub/bot-emulator-users:stage - environment: - - ADMIN_API_HOST=http://eventhub-node:8445 - - CLIENT_API_HOST=http://eventhub-node:8080 - - ADMIN_EMAIL=${ADMIN_EMAIL} - - ADMIN_PASSWORD=${ADMIN_PASSWORD} - - BOT_PASSWORD=${BOT_PASSWORD} - - STARTUP_DELAY=30 - - DEBUG=true - networks: - - eventhub-net - depends_on: - - eventhub - deploy: - replicas: 0 - restart_policy: - condition: any - -networks: - eventhub-net: - driver: overlay - -volumes: +version: "3.8" + +services: + # ================== Балансировщик ================== + traefik: + image: traefik:latest + command: + - "--log.level=DEBUG" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + ports: + - "80:80" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + networks: + - eventhub-net + deploy: + replicas: 1 + restart_policy: + condition: any + + # ================== Кластер EventHub ================== + eventhub: + image: git.sabilin.com/eventhub/eventhub:stage + hostname: "eventhub-node{{.Task.Slot}}" + environment: + - EVENTHUB_ENV=stage + - NODE_NAME=eventhub-node{{.Task.Slot}} + - RELEASE_COOKIE=${RELEASE_COOKIE} + - JWT_SECRET=${JWT_SECRET} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET} + - ADMIN_SUPER_EMAIL=${ADMIN_SUPER_EMAIL} + - ADMIN_SUPER_PASSWORD=${ADMIN_SUPER_PASSWORD} + - ADMIN_EMAIL=${ADMIN_EMAIL} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ADMIN_MODER_EMAIL=${ADMIN_MODER_EMAIL} + - ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD} + - ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL} + - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD} + - CLUSTER_MODE=true + - DNS_NAME=eventhub-node + networks: + eventhub-net: + aliases: + - eventhub-node + volumes: + - eventhub-data:/app/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + deploy: + replicas: 1 + endpoint_mode: dnsrr + restart_policy: + condition: any + + # ================== Эмулятор ботов ================== + bot-emulator-users: + image: git.sabilin.com/eventhub/bot-emulator-users:stage + environment: + - ADMIN_API_HOST=http://eventhub-node:8445 + - CLIENT_API_HOST=http://eventhub-node:8080 + - ADMIN_EMAIL=${ADMIN_EMAIL} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - BOT_PASSWORD=${BOT_PASSWORD} + - STARTUP_DELAY=30 + - DEBUG=true + networks: + - eventhub-net + depends_on: + - eventhub + deploy: + replicas: 0 + restart_policy: + condition: any + +networks: + eventhub-net: + driver: overlay + +volumes: eventhub-data: \ No newline at end of file diff --git a/docker/docker-compose.swarm.yml b/docker/docker-compose.swarm.yml index 326e793..a3d1a79 100644 --- a/docker/docker-compose.swarm.yml +++ b/docker/docker-compose.swarm.yml @@ -1,226 +1,230 @@ -# docker/docker-compose.swarm.yml -version: "3.8" - -services: - # ================== Балансировщик ================== - traefik: - image: traefik:latest - command: -# - "--log.level=DEBUG" - - "--api.insecure=true" - - "--providers.docker=true" -# - "--providers.swarm.endpoint=unix:///var/run/docker.sock" # провайдер Swarm - - "--providers.docker.exposedbydefault=false" - - "--providers.file.filename=/etc/traefik/dynamic_conf.yml" - - "--entrypoints.web.address=:80" - - "--entrypoints.websecure.address=:443" - - "--metrics.prometheus=true" - - "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0" - - "--metrics.prometheus.addEntryPointsLabels=true" - - "--metrics.prometheus.addServicesLabels=true" - - "--accesslog=true" - - "--accesslog.filepath=/var/log/traefik/access.log" - - "--accesslog.format=json" - - "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" - - "--experimental.plugins.coraza.version=v0.3.0" - ports: - - "80:80" - - "443:443" - - "8080:8080" - volumes: - - "/var/run/docker.sock:/var/run/docker.sock:ro" - - "./traefik/certs:/etc/traefik/certs:ro" - - "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro" - - "traefik-logs:/var/log/traefik" - - "traefik-plugins:/plugins-storage" - networks: - - eventhub-net - deploy: - replicas: 1 - restart_policy: - condition: any - - # ================== Сервис заглушка ================== - fallback: - image: fallback:latest - networks: - - eventhub-net - deploy: - replicas: 0 - restart_policy: - condition: any - - # ================== Кластер EventHub ================== - eventhub: - image: eventhub:latest - hostname: "eventhub-node{{.Task.Slot}}" - environment: - - NODE_NAME=eventhub-node{{.Task.Slot}} - - RELEASE_COOKIE=${RELEASE_COOKIE:-eventhub_cookie} - - JWT_SECRET=${JWT_SECRET:-eventhub_top_secret} - - ADMIN_SUPER_EMAIL=${ADMIN_SUPER_EMAIL:-superadmin@eventhub.local} - - ADMIN_SUPER_PASSWORD=${ADMIN_SUPER_PASSWORD:-123456} - - ADMIN_MODER_EMAIL=${ADMIN_MODER_EMAIL:-moderator@eventhub.local} - - ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD:-123456} - - ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL:-support@eventhub.local} - - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-123456} - - CLUSTER_MODE=true - - DNS_NAME=eventhub-node - networks: - eventhub-net: - aliases: - - eventhub-node - volumes: - - type: volume - source: eventhub-data - target: /app/data - deploy: - replicas: 1 - endpoint_mode: dnsrr - restart_policy: - condition: any - labels: - - "traefik.enable=true" - - # ================== Admin UI ================== - admin-ui: - image: eventhub-admin-ui:latest - networks: - - eventhub-net - depends_on: - - eventhub - deploy: - replicas: 1 - restart_policy: - condition: any - labels: - - "traefik.enable=true" - - # ================== Мониторинг ================== - prometheus: - image: prom/prometheus:latest - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.console.libraries=/usr/share/prometheus/console_libraries' - - '--web.console.templates=/usr/share/prometheus/consoles' - - '--storage.tsdb.retention.time=30d' - - '--storage.tsdb.retention.size=15GB' - volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus-data:/prometheus - networks: - - eventhub-net - ports: - - "9090:9090" - deploy: - replicas: 0 - restart_policy: - condition: any - - grafana: - image: grafana/grafana:latest - environment: - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} - - GF_SECURITY_DISABLE_INITIAL_ADMIN_PASSWORD_CHANGE=false - - GF_USERS_ALLOW_SIGN_UP=false - - GF_AUTH_ANONYMOUS_ENABLED=false - volumes: - - ./grafana/provisioning:/etc/grafana/provisioning - - ./grafana/dashboards:/etc/grafana/dashboards - - grafana-data:/var/lib/grafana - networks: - - eventhub-net - ports: - - "3000:3000" - deploy: - replicas: 0 - restart_policy: - condition: any - - # ================== Аналитика логов ================== - loglynx: - image: k0lin/loglynx:latest - user: root - ports: - - "6123:6123" - volumes: - - traefik-logs:/app/traefik/logs:ro - - loglynx-data:/app/data - environment: - - TRAEFIK_LOG_PATH=${TRAEFIK_LOG_PATH} - - SERVER_PORT=6123 - - DATABASE_PATH=/app/data/loglynx.db - networks: - - eventhub-net - deploy: - replicas: 0 - restart_policy: - condition: any - - # ================== Инструмент отладки ================== - observer_web: - image: observer_web:latest - environment: - - RELEASE_COOKIE=${RELEASE_COOKIE} - networks: - - eventhub-net - ports: - - "4000:4000" - deploy: - replicas: 0 - restart_policy: - condition: any - - # ================== Ротация логов ================== - logrotate: - image: logrotate:latest - volumes: - - traefik-logs:/var/log/traefik:rw - networks: - - eventhub-net - deploy: - replicas: 0 - restart_policy: - condition: any - - bot-emulator-users: - image: bot-emulator-users:latest - environment: - - ADMIN_API_HOST=http://eventhub-node:8445 - - CLIENT_API_HOST=http://eventhub-node:8080 - - ADMIN_EMAIL=admin@eventhub.local - - ADMIN_PASSWORD=123456 - - BOT_PASSWORD=botpass123 - - MIN_DELAY=0.5 - - MAX_DELAY=3.0 - - LOOP_FOREVER=true - - BOT_REFRESH_INTERVAL=300 - - TICKET_CHANCE=0.07 - - STARTUP_DELAY=30 - - DEBUG=false - networks: - - eventhub-net - depends_on: - - eventhub - deploy: - mode: replicated - replicas: 1 - restart_policy: - condition: any - delay: 5s - max_attempts: 3 - window: 120s - -networks: - eventhub-net: - driver: overlay - -volumes: - eventhub-data: -# name: 'eventhub-data-{{.Task.Slot}}' - prometheus-data: - grafana-data: - traefik-logs: - loglynx-data: +# docker/docker-compose.swarm.yml +version: "3.8" + +services: + # ================== Балансировщик ================== + traefik: + image: traefik:latest + command: +# - "--log.level=DEBUG" + - "--api.insecure=true" + - "--providers.docker=true" +# - "--providers.swarm.endpoint=unix:///var/run/docker.sock" # провайдер Swarm + - "--providers.docker.exposedbydefault=false" + - "--providers.file.filename=/etc/traefik/dynamic_conf.yml" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--metrics.prometheus=true" + - "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0" + - "--metrics.prometheus.addEntryPointsLabels=true" + - "--metrics.prometheus.addServicesLabels=true" + - "--accesslog=true" + - "--accesslog.filepath=/var/log/traefik/access.log" + - "--accesslog.format=json" + - "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" + - "--experimental.plugins.coraza.version=v0.3.0" + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./traefik/certs:/etc/traefik/certs:ro" + - "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro" + - "traefik-logs:/var/log/traefik" + - "traefik-plugins:/plugins-storage" + networks: + - eventhub-net + deploy: + replicas: 1 + restart_policy: + condition: any + + # ================== Сервис заглушка ================== + fallback: + image: fallback:latest + networks: + - eventhub-net + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Кластер EventHub ================== + eventhub: + image: eventhub:latest + hostname: "eventhub-node{{.Task.Slot}}" + environment: + - EVENTHUB_ENV=prod + - NODE_NAME=eventhub-node{{.Task.Slot}} + - RELEASE_COOKIE=${RELEASE_COOKIE} + - JWT_SECRET=${JWT_SECRET} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET} + - ADMIN_SUPER_EMAIL=${ADMIN_SUPER_EMAIL} + - ADMIN_SUPER_PASSWORD=${ADMIN_SUPER_PASSWORD} + - ADMIN_EMAIL=${ADMIN_EMAIL} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ADMIN_MODER_EMAIL=${ADMIN_MODER_EMAIL} + - ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD} + - ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL} + - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD} + - CLUSTER_MODE=true + - DNS_NAME=eventhub-node + networks: + eventhub-net: + aliases: + - eventhub-node + volumes: + - type: volume + source: eventhub-data + target: /app/data + deploy: + replicas: 1 + endpoint_mode: dnsrr + restart_policy: + condition: any + labels: + - "traefik.enable=true" + + # ================== Admin UI ================== + admin-ui: + image: eventhub-admin-ui:latest + networks: + - eventhub-net + depends_on: + - eventhub + deploy: + replicas: 1 + restart_policy: + condition: any + labels: + - "traefik.enable=true" + + # ================== Мониторинг ================== + prometheus: + image: prom/prometheus:latest + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + - '--storage.tsdb.retention.time=30d' + - '--storage.tsdb.retention.size=15GB' + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + networks: + - eventhub-net + ports: + - "9090:9090" + deploy: + replicas: 0 + restart_policy: + condition: any + + grafana: + image: grafana/grafana:latest + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_SECURITY_DISABLE_INITIAL_ADMIN_PASSWORD_CHANGE=false + - GF_USERS_ALLOW_SIGN_UP=false + - GF_AUTH_ANONYMOUS_ENABLED=false + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/etc/grafana/dashboards + - grafana-data:/var/lib/grafana + networks: + - eventhub-net + ports: + - "3000:3000" + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Аналитика логов ================== + loglynx: + image: k0lin/loglynx:latest + user: root + ports: + - "6123:6123" + volumes: + - traefik-logs:/app/traefik/logs:ro + - loglynx-data:/app/data + environment: + - TRAEFIK_LOG_PATH=${TRAEFIK_LOG_PATH} + - SERVER_PORT=6123 + - DATABASE_PATH=/app/data/loglynx.db + networks: + - eventhub-net + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Инструмент отладки ================== + observer_web: + image: observer_web:latest + environment: + - RELEASE_COOKIE=${RELEASE_COOKIE} + networks: + - eventhub-net + ports: + - "4000:4000" + deploy: + replicas: 0 + restart_policy: + condition: any + + # ================== Ротация логов ================== + logrotate: + image: logrotate:latest + volumes: + - traefik-logs:/var/log/traefik:rw + networks: + - eventhub-net + deploy: + replicas: 0 + restart_policy: + condition: any + + bot-emulator-users: + image: bot-emulator-users:latest + environment: + - ADMIN_API_HOST=http://eventhub-node:8445 + - CLIENT_API_HOST=http://eventhub-node:8080 + - ADMIN_EMAIL=admin@eventhub.local + - ADMIN_PASSWORD=123456 + - BOT_PASSWORD=botpass123 + - MIN_DELAY=0.5 + - MAX_DELAY=3.0 + - LOOP_FOREVER=true + - BOT_REFRESH_INTERVAL=300 + - TICKET_CHANCE=0.07 + - STARTUP_DELAY=30 + - DEBUG=false + networks: + - eventhub-net + depends_on: + - eventhub + deploy: + mode: replicated + replicas: 1 + restart_policy: + condition: any + delay: 5s + max_attempts: 3 + window: 120s + +networks: + eventhub-net: + driver: overlay + +volumes: + eventhub-data: +# name: 'eventhub-data-{{.Task.Slot}}' + prometheus-data: + grafana-data: + traefik-logs: + loglynx-data: traefik-plugins: \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3438283..645a26a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,213 +1,219 @@ -# docker/docker-compose.yml -services: - # ================== Балансировщик (HTTPS/WSS, WAF, логи) ================== - traefik: - image: traefik:latest - user: "0:1001" # группа docker на хосте - command: - - "--api.insecure=true" - - "--providers.docker=true" - - "--providers.docker.exposedbydefault=false" - - "--providers.file.filename=/etc/traefik/dynamic_conf.yml" - - "--entrypoints.web.address=:80" - - "--entrypoints.websecure.address=:443" - # Метрики - - "--metrics.prometheus=true" - - "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0" - - "--metrics.prometheus.addEntryPointsLabels=true" - - "--metrics.prometheus.addServicesLabels=true" - # Логи доступа (JSON) - - "--accesslog=true" - - "--accesslog.filepath=/var/log/traefik/access.log" - - "--accesslog.format=json" - # Coraza WAF - - "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" - - "--experimental.plugins.coraza.version=v0.2.0" - ports: - - "80:80" - - "443:443" - # порт дашборда (опционально) - - "8080:8080" - volumes: - - "/var/run/docker.sock:/var/run/docker.sock:ro" - - "./traefik/certs:/etc/traefik/certs:ro" - - "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro" - - "traefik-logs:/var/log/traefik" # для LogLynx - networks: - - eventhub-net - restart: unless-stopped - - # ================== Сервис-заглушка (Failover) ================== - fallback: - build: - context: ./fallback - dockerfile: Dockerfile - networks: - - eventhub-net - restart: unless-stopped - profiles: ['ci'] - - # ================== Кластер EventHub (3 ноды) ================== - eventhub-node1: - build: - context: .. - dockerfile: docker/Dockerfile - hostname: eventhub-node1.local - environment: - - NODE_NAME=eventhub-node1@eventhub-node1.local - - RELEASE_COOKIE=${RELEASE_COOKIE} - - JWT_SECRET=${JWT_SECRET} - networks: - eventhub-net: - aliases: - - eventhub-node # ← общий алиас для DNS-лукапа - volumes: - - eventhub-node1-data:/app/data - labels: - - "traefik.enable=true" - restart: unless-stopped - - eventhub-node2: - build: - context: .. - dockerfile: docker/Dockerfile - hostname: eventhub-node2.local - environment: - - NODE_NAME=eventhub-node2@eventhub-node2.local - - RELEASE_COOKIE=${RELEASE_COOKIE} - - JWT_SECRET=${JWT_SECRET} - networks: - eventhub-net: - aliases: - - eventhub-node # ← общий алиас для DNS-лукапа - volumes: - - eventhub-node2-data:/app/data - labels: - - "traefik.enable=true" - restart: unless-stopped - - eventhub-node3: - build: - context: .. - dockerfile: docker/Dockerfile - hostname: eventhub-node3.local - environment: - - NODE_NAME=eventhub-node3@eventhub-node3.local - - RELEASE_COOKIE=${RELEASE_COOKIE} - - JWT_SECRET=${JWT_SECRET} - networks: - eventhub-net: - aliases: - - eventhub-node # ← общий алиас для DNS-лукапа - volumes: - - eventhub-node3-data:/app/data - labels: - - "traefik.enable=true" - restart: unless-stopped - - # ================== Admin ui ================== - admin-ui: - build: - context: ../../EventHubFrontAdmin # путь к проекту админки - dockerfile: Dockerfile - networks: - - eventhub-net -# restart: unless-stopped - restart: no - labels: - - "traefik.enable=true" - profiles: ['ui'] - # ================== Мониторинг ================== - prometheus: - image: prom/prometheus:latest - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.console.libraries=/usr/share/prometheus/console_libraries' - - '--web.console.templates=/usr/share/prometheus/consoles' - - '--storage.tsdb.retention.time=30d' - - '--storage.tsdb.retention.size=15GB' - volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus-data:/prometheus - networks: - - eventhub-net - ports: - - "9090:9090" - restart: unless-stopped - profiles: ['ci'] - - grafana: - image: grafana/grafana:latest - environment: - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} - - GF_SECURITY_DISABLE_INITIAL_ADMIN_PASSWORD_CHANGE=false - - GF_USERS_ALLOW_SIGN_UP=false - - GF_AUTH_ANONYMOUS_ENABLED=false - volumes: - - ./grafana/provisioning:/etc/grafana/provisioning - - ./grafana/dashboards:/etc/grafana/dashboards - - grafana-data:/var/lib/grafana - networks: - - eventhub-net - ports: - - "3000:3000" - restart: unless-stopped - profiles: ['ci'] - - # ================== Аналитика логов ================== - loglynx: - image: k0lin/loglynx:latest - user: root - restart: unless-stopped - ports: - - "6123:6123" - volumes: - - traefik-logs:/app/traefik/logs:ro - - loglynx-data:/app/data - environment: - - TRAEFIK_LOG_PATH=${TRAEFIK_LOG_PATH} - - SERVER_PORT=6123 - - DATABASE_PATH=/app/data/loglynx.db - networks: - - eventhub-net - profiles: ['ci'] - - # ================== Инструмент отладки ================== - observer_web: - build: - context: .. - dockerfile: docker/ObserverWeb.Dockerfile - environment: - - RELEASE_COOKIE=${RELEASE_COOKIE} - networks: - - eventhub-net - ports: - - "4000:4000" - restart: unless-stopped - profiles: ['ci'] - - # ================== Ротация логов Traefik ================== - logrotate: - build: - context: ./logrotate - dockerfile: Dockerfile - volumes: - - traefik-logs:/var/log/traefik:rw - networks: - - eventhub-net - restart: unless-stopped - profiles: ['ci'] - -networks: - eventhub-net: - driver: bridge - -volumes: - eventhub-node1-data: - eventhub-node2-data: - eventhub-node3-data: - prometheus-data: - grafana-data: - traefik-logs: +# docker/docker-compose.yml +services: + # ================== Балансировщик (HTTPS/WSS, WAF, логи) ================== + traefik: + image: traefik:latest + user: "0:1001" # группа docker на хосте + command: + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.file.filename=/etc/traefik/dynamic_conf.yml" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + # Метрики + - "--metrics.prometheus=true" + - "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0" + - "--metrics.prometheus.addEntryPointsLabels=true" + - "--metrics.prometheus.addServicesLabels=true" + # Логи доступа (JSON) + - "--accesslog=true" + - "--accesslog.filepath=/var/log/traefik/access.log" + - "--accesslog.format=json" + # Coraza WAF + - "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" + - "--experimental.plugins.coraza.version=v0.2.0" + ports: + - "80:80" + - "443:443" + # порт дашборда (опционально) + - "8080:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./traefik/certs:/etc/traefik/certs:ro" + - "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro" + - "traefik-logs:/var/log/traefik" # для LogLynx + networks: + - eventhub-net + restart: unless-stopped + + # ================== Сервис-заглушка (Failover) ================== + fallback: + build: + context: ./fallback + dockerfile: Dockerfile + networks: + - eventhub-net + restart: unless-stopped + profiles: ['ci'] + + # ================== Кластер EventHub (3 ноды) ================== + eventhub-node1: + build: + context: .. + dockerfile: docker/Dockerfile + hostname: eventhub-node1.local + environment: + - EVENTHUB_ENV=dev + - NODE_NAME=eventhub-node1@eventhub-node1.local + - RELEASE_COOKIE=${RELEASE_COOKIE} + - JWT_SECRET=${JWT_SECRET} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET} + networks: + eventhub-net: + aliases: + - eventhub-node # ← общий алиас для DNS-лукапа + volumes: + - eventhub-node1-data:/app/data + labels: + - "traefik.enable=true" + restart: unless-stopped + + eventhub-node2: + build: + context: .. + dockerfile: docker/Dockerfile + hostname: eventhub-node2.local + environment: + - EVENTHUB_ENV=dev + - NODE_NAME=eventhub-node2@eventhub-node2.local + - RELEASE_COOKIE=${RELEASE_COOKIE} + - JWT_SECRET=${JWT_SECRET} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET} + networks: + eventhub-net: + aliases: + - eventhub-node # ← общий алиас для DNS-лукапа + volumes: + - eventhub-node2-data:/app/data + labels: + - "traefik.enable=true" + restart: unless-stopped + + eventhub-node3: + build: + context: .. + dockerfile: docker/Dockerfile + hostname: eventhub-node3.local + environment: + - EVENTHUB_ENV=dev + - NODE_NAME=eventhub-node3@eventhub-node3.local + - RELEASE_COOKIE=${RELEASE_COOKIE} + - JWT_SECRET=${JWT_SECRET} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET} + networks: + eventhub-net: + aliases: + - eventhub-node # ← общий алиас для DNS-лукапа + volumes: + - eventhub-node3-data:/app/data + labels: + - "traefik.enable=true" + restart: unless-stopped + + # ================== Admin ui ================== + admin-ui: + build: + context: ../../EventHubFrontAdmin # путь к проекту админки + dockerfile: Dockerfile + networks: + - eventhub-net +# restart: unless-stopped + restart: no + labels: + - "traefik.enable=true" + profiles: ['ui'] + # ================== Мониторинг ================== + prometheus: + image: prom/prometheus:latest + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + - '--storage.tsdb.retention.time=30d' + - '--storage.tsdb.retention.size=15GB' + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + networks: + - eventhub-net + ports: + - "9090:9090" + restart: unless-stopped + profiles: ['ci'] + + grafana: + image: grafana/grafana:latest + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_SECURITY_DISABLE_INITIAL_ADMIN_PASSWORD_CHANGE=false + - GF_USERS_ALLOW_SIGN_UP=false + - GF_AUTH_ANONYMOUS_ENABLED=false + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/etc/grafana/dashboards + - grafana-data:/var/lib/grafana + networks: + - eventhub-net + ports: + - "3000:3000" + restart: unless-stopped + profiles: ['ci'] + + # ================== Аналитика логов ================== + loglynx: + image: k0lin/loglynx:latest + user: root + restart: unless-stopped + ports: + - "6123:6123" + volumes: + - traefik-logs:/app/traefik/logs:ro + - loglynx-data:/app/data + environment: + - TRAEFIK_LOG_PATH=${TRAEFIK_LOG_PATH} + - SERVER_PORT=6123 + - DATABASE_PATH=/app/data/loglynx.db + networks: + - eventhub-net + profiles: ['ci'] + + # ================== Инструмент отладки ================== + observer_web: + build: + context: .. + dockerfile: docker/ObserverWeb.Dockerfile + environment: + - RELEASE_COOKIE=${RELEASE_COOKIE} + networks: + - eventhub-net + ports: + - "4000:4000" + restart: unless-stopped + profiles: ['ci'] + + # ================== Ротация логов Traefik ================== + logrotate: + build: + context: ./logrotate + dockerfile: Dockerfile + volumes: + - traefik-logs:/var/log/traefik:rw + networks: + - eventhub-net + restart: unless-stopped + profiles: ['ci'] + +networks: + eventhub-net: + driver: bridge + +volumes: + eventhub-node1-data: + eventhub-node2-data: + eventhub-node3-data: + prometheus-data: + grafana-data: + traefik-logs: loglynx-data: \ No newline at end of file diff --git a/src/config/sys.config b/src/config/sys.config index 1ca851f..0b2fba1 100644 --- a/src/config/sys.config +++ b/src/config/sys.config @@ -1,23 +1,24 @@ -[ - {eventhub, [ - {http_port, "${HTTP_PORT:-8080}"}, - {ws_port, "${WS_PORT:-8081}"}, - {admin_http_port, "${ADMIN_HTTP_PORT:-8445}"}, - {admin_ws_port, "${ADMIN_WS_PORT:-8446}"}, - {jwt_secret, <<"${JWT_SECRET:-change_me_in_production}">>} - ]}, - {kernel, [ - {logger_level, info}, - {logger, [ - {handler, default, logger_std_h, - #{formatter => {logger_formatter, #{template => [time, " ", level, ": ", msg, "\n"]}}} - } - ]} - ]}, - {mnesia, [ - {dir, "data/Mnesia.eventhub@${NODE_NAME}"} - ]}, - { cowboy_swagger, [ - { static_files, "./_build/default/lib/cowboy_swagger/priv/swagger" } - ]} +[ + {eventhub, [ + {http_port, "${HTTP_PORT:-8080}"}, + {ws_port, "${WS_PORT:-8081}"}, + {admin_http_port, "${ADMIN_HTTP_PORT:-8445}"}, + {admin_ws_port, "${ADMIN_WS_PORT:-8446}"}, + {jwt_secret, <<"${JWT_SECRET:-change_me_in_production}">>}, + {admin_jwt_secret, <<"${ADMIN_JWT_SECRET:-change_me_in_production}">>} + ]}, + {kernel, [ + {logger_level, info}, + {logger, [ + {handler, default, logger_std_h, + #{formatter => {logger_formatter, #{template => [time, " ", level, ": ", msg, "\n"]}}} + } + ]} + ]}, + {mnesia, [ + {dir, "data/Mnesia.eventhub@${NODE_NAME}"} + ]}, + { cowboy_swagger, [ + { static_files, "./_build/default/lib/cowboy_swagger/priv/swagger" } + ]} ]. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index fb9a914..201c476 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -3,6 +3,15 @@ -export([start/2, stop/1]). start(_StartType, _StartArgs) -> + case infra_secrets:validate_startup() of + ok -> + start_application(); + {error, Reason} -> + io:format("~nFATAL: secret validation failed: ~p~n", [Reason]), + {error, Reason} + end. + +start_application() -> case infra_sup:start_link() of {ok, Pid} -> % Определяем список узлов кластера, если режим CLUSTER_MODE=true @@ -48,8 +57,10 @@ start(_StartType, _StartArgs) -> start_swagger_http(), % Swagger UI и спецификация (8447) application:ensure_all_started(prometheus), application:ensure_all_started(prometheus_cowboy), - init_default_admins(), - {ok, Pid}; + case init_default_admins() of + ok -> {ok, Pid}; + {error, Reason} -> {error, Reason} + end; Error -> Error end. @@ -199,31 +210,49 @@ start_swagger_http() -> init_default_admins() -> case core_admin:list_all() of [] -> - % Суперадмин - SuperEmail = list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")), - SuperPass = list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")), - {ok, _} = core_admin:create(SuperEmail, SuperPass, superadmin), - io:format("Default superadmin created: ~s~n", [SuperEmail]), - - % Админ - AdminEmail = list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")), - AdminPass = list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")), - {ok, _} = core_admin:create(AdminEmail, AdminPass, admin), - io:format("Default admin created: ~s~n", [AdminEmail]), - - % Модератор - ModerEmail = list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")), - ModerPass = list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")), - {ok, _} = core_admin:create(ModerEmail, ModerPass, moderator), - io:format("Default moderator created: ~s~n", [ModerEmail]), - - % Поддержка - SupportEmail = list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")), - SupportPass = list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")), - {ok, _} = core_admin:create(SupportEmail, SupportPass, support), - io:format("Default support created: ~s~n", [SupportEmail]); + seed_default_admins(); _ -> - io:format("Admins already exist. Skipping creation.~n") + io:format("Admins already exist. Skipping creation.~n"), + ok + end. + +seed_default_admins() -> + Admins = [ + {superadmin, "ADMIN_SUPER_EMAIL", "superadmin@eventhub.local", + "ADMIN_SUPER_PASSWORD", "123456"}, + {admin, "ADMIN_EMAIL", "admin@eventhub.local", + "ADMIN_PASSWORD", "123456"}, + {moderator, "ADMIN_MODER_EMAIL", "moderator@eventhub.local", + "ADMIN_MODER_PASSWORD", "123456"}, + {support, "ADMIN_SUPPORT_EMAIL", "support@eventhub.local", + "ADMIN_SUPPORT_PASSWORD", "123456"} + ], + lists:foldl(fun seed_admin/2, ok, Admins). + +seed_admin({Role, EmailKey, DefaultEmail, PassKey, DefaultPass}, ok) -> + case admin_credentials(EmailKey, DefaultEmail, PassKey, DefaultPass) of + {error, _} = Err -> Err; + {ok, {Email, Pass}} -> + {ok, _} = core_admin:create(Email, Pass, Role), + io:format("Default ~p created: ~s~n", [Role, Email]), + ok + end; +seed_admin(_, Err) -> Err. + +admin_credentials(EmailKey, DefaultEmail, PassKey, DefaultPass) -> + case infra_secrets:is_dev() of + true -> + {ok, {list_to_binary(os:getenv(EmailKey, DefaultEmail)), + list_to_binary(os:getenv(PassKey, DefaultPass))}}; + false -> + case infra_secrets:require_admin_seed_email(EmailKey) of + {error, _} = Err -> Err; + {ok, Email} -> + case infra_secrets:require_admin_seed_password(PassKey) of + {error, _} = Err -> Err; + {ok, Pass} -> {ok, {Email, Pass}} + end + end end. get_env_int(Key, Default) -> diff --git a/src/infra/eventhub_auth.erl b/src/infra/eventhub_auth.erl index d224c20..14a18f8 100644 --- a/src/infra/eventhub_auth.erl +++ b/src/infra/eventhub_auth.erl @@ -24,7 +24,11 @@ get_user_secret() -> get_user_secret_from_env() -> case os:getenv("JWT_SECRET") of - false -> <<"user-secret-key-32-bytes-minimum!">>; + false -> + case infra_secrets:is_dev() of + true -> <<"user-secret-key-32-bytes-minimum!">>; + false -> error({missing_secret, jwt_secret}) + end; S -> list_to_binary(S) end. @@ -37,7 +41,11 @@ get_admin_secret() -> get_admin_secret_from_env() -> case os:getenv("ADMIN_JWT_SECRET") of - false -> <<"admin-secret-key-32-bytes-minimum!">>; + false -> + case infra_secrets:is_dev() of + true -> <<"admin-secret-key-32-bytes-minimum!">>; + false -> error({missing_secret, admin_jwt_secret}) + end; S -> list_to_binary(S) end. diff --git a/src/infra/infra_secrets.erl b/src/infra/infra_secrets.erl new file mode 100644 index 0000000..6fe7fd3 --- /dev/null +++ b/src/infra/infra_secrets.erl @@ -0,0 +1,137 @@ +%%%------------------------------------------------------------------- +%%% @doc Валидация секретов и паролей при старте (EVENTHUB_ENV). +%%% dev — допускаются documented fallbacks; stage/prod — fail-fast. +%%% @end +%%%------------------------------------------------------------------- +-module(infra_secrets). +-export([mode/0, is_dev/0, validate_startup/0, + require_admin_seed_password/1, require_admin_seed_email/1]). + +-define(MIN_SECRET_LEN, 32). + +%%%------------------------------------------------------------------- +%%% API +%%%------------------------------------------------------------------- + +-spec mode() -> dev | stage | prod. +mode() -> + case os:getenv("EVENTHUB_ENV") of + false -> dev; + Env -> + case string:lowercase(Env) of + "dev" -> dev; + "stage" -> stage; + "prod" -> prod; + "production" -> prod; + _ -> stage + end + end. + +-spec is_dev() -> boolean(). +is_dev() -> mode() =:= dev. + +%% @doc Проверка JWT-секретов до поднятия Mnesia/HTTP. +-spec validate_startup() -> ok | {error, {missing_secret, atom()} | {weak_secret, atom()}}. +validate_startup() -> + case mode() of + dev -> + dev_warn_if_weak(jwt_secret, effective_jwt_secret()), + dev_warn_if_weak(admin_jwt_secret, effective_admin_jwt_secret()), + ok; + _ -> + case require_strong_secret(jwt_secret, effective_jwt_secret()) of + ok -> require_strong_secret(admin_jwt_secret, effective_admin_jwt_secret()); + Err -> Err + end + end. + +-spec require_admin_seed_email(string()) -> + {ok, binary()} | {error, {missing_secret, atom()}}. +require_admin_seed_email(EnvKey) -> + case os:getenv(EnvKey) of + false -> {error, {missing_secret, list_to_atom(EnvKey)}}; + "" -> {error, {missing_secret, list_to_atom(EnvKey)}}; + Email -> {ok, list_to_binary(Email)} + end. + +-spec require_admin_seed_password(string()) -> + {ok, binary()} | {error, {missing_secret, atom()} | {weak_secret, atom()}}. +require_admin_seed_password(EnvKey) -> + case os:getenv(EnvKey) of + false -> {error, {missing_secret, list_to_atom(EnvKey)}}; + "" -> {error, {missing_secret, list_to_atom(EnvKey)}}; + Pass -> + Bin = list_to_binary(Pass), + case is_strong_secret(Bin) of + true -> {ok, Bin}; + false -> {error, {weak_secret, list_to_atom(EnvKey)}} + end + end. + +%%%------------------------------------------------------------------- +%%% Effective values (порядок как в eventhub_auth) +%%%------------------------------------------------------------------- + +effective_jwt_secret() -> + effective_secret(jwt_secret, "JWT_SECRET"). + +effective_admin_jwt_secret() -> + effective_secret(admin_jwt_secret, "ADMIN_JWT_SECRET"). + +effective_secret(AppKey, OsKey) -> + case application:get_env(eventhub, AppKey) of + {ok, Secret} when is_binary(Secret), byte_size(Secret) > 0 -> + case is_placeholder(Secret) of + true -> os_getenv_binary(OsKey); + false -> Secret + end; + _ -> + os_getenv_binary(OsKey) + end. + +%%%------------------------------------------------------------------- +%%% Internal +%%%------------------------------------------------------------------- + +require_strong_secret(Name, undefined) -> + {error, {missing_secret, Name}}; +require_strong_secret(Name, Secret) -> + case is_strong_secret(Secret) of + true -> ok; + false -> {error, {weak_secret, Name}} + end. + +is_strong_secret(Secret) when is_binary(Secret) -> + byte_size(Secret) >= ?MIN_SECRET_LEN andalso not lists:member(Secret, denylist()). + +is_placeholder(Secret) -> + lists:member(Secret, [ + <<"change_me_in_production">>, + <<"${JWT_SECRET:-change_me_in_production}">>, + <<"${ADMIN_JWT_SECRET:-change_me_in_production}">> + ]). + +denylist() -> [ + <<"123456">>, + <<"change_me_in_production">>, + <<"eventhub_top_secret">>, + <<"user-secret-key-32-bytes-minimum!">>, + <<"admin-secret-key-32-bytes-minimum!">>, + <<"botpass123">> +]. + +os_getenv_binary(Key) -> + case os:getenv(Key) of + false -> undefined; + "" -> undefined; + Val -> list_to_binary(Val) + end. + +dev_warn_if_weak(_Name, undefined) -> ok; +dev_warn_if_weak(Name, Secret) -> + case is_strong_secret(Secret) of + true -> ok; + false -> + io:format("~n[infra_secrets] WARNING: ~p is weak or missing in dev mode; " + "runtime fallbacks may apply.~n", [Name]) + end. diff --git a/test/unit/infra_secrets_tests.erl b/test/unit/infra_secrets_tests.erl new file mode 100644 index 0000000..61e4263 --- /dev/null +++ b/test/unit/infra_secrets_tests.erl @@ -0,0 +1,62 @@ +-module(infra_secrets_tests). +-include_lib("eunit/include/eunit.hrl"). + +-define(STRONG_JWT, <<"this-is-a-strong-user-jwt-secret-32b">>). +-define(STRONG_ADMIN, <<"this-is-a-strong-admin-jwt-secret32">>). + +setup() -> + application:load(eventhub), + ok. + +cleanup(_) -> + application:unset_env(eventhub, jwt_secret), + application:unset_env(eventhub, admin_jwt_secret), + os:unsetenv("EVENTHUB_ENV"), + os:unsetenv("JWT_SECRET"), + os:unsetenv("ADMIN_JWT_SECRET"), + ok. + +with_env(Key, Val, Fun) -> + os:putenv(Key, Val), + try Fun() after os:unsetenv(Key) end. + +infra_secrets_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"dev mode allows missing secrets", fun test_dev_missing_ok/0}, + {"stage rejects missing JWT_SECRET", fun test_stage_missing_jwt/0}, + {"stage rejects weak JWT_SECRET", fun test_stage_weak_jwt/0}, + {"stage accepts strong secrets", fun test_stage_strong_ok/0}, + {"require_admin_seed_password rejects 123456", fun test_weak_admin_password/0} + ]}. + +test_dev_missing_ok() -> + with_env("EVENTHUB_ENV", "dev", fun() -> + ?assertEqual(dev, infra_secrets:mode()), + ?assertEqual(ok, infra_secrets:validate_startup()) + end). + +test_stage_missing_jwt() -> + with_env("EVENTHUB_ENV", "stage", fun() -> + ?assertMatch({error, {missing_secret, jwt_secret}}, + infra_secrets:validate_startup()) + end). + +test_stage_weak_jwt() -> + with_env("EVENTHUB_ENV", "stage", fun() -> + os:putenv("JWT_SECRET", "123456"), + os:putenv("ADMIN_JWT_SECRET", binary_to_list(?STRONG_ADMIN)), + ?assertMatch({error, {weak_secret, jwt_secret}}, + infra_secrets:validate_startup()) + end). + +test_stage_strong_ok() -> + with_env("EVENTHUB_ENV", "stage", fun() -> + os:putenv("JWT_SECRET", binary_to_list(?STRONG_JWT)), + os:putenv("ADMIN_JWT_SECRET", binary_to_list(?STRONG_ADMIN)), + ?assertEqual(ok, infra_secrets:validate_startup()) + end). + +test_weak_admin_password() -> + os:putenv("ADMIN_PASSWORD", "123456"), + ?assertMatch({error, {weak_secret, 'ADMIN_PASSWORD'}}, + infra_secrets:require_admin_seed_password("ADMIN_PASSWORD")).