services: # ========================================================================== # MQTT Broker - MeshCore MQTT Broker (optional, use --profile mqtt) # WebSocket-only broker with MeshCore public key authentication # Most users will connect to an external MQTT broker instead # See: https://github.com/michaelhart/meshcore-mqtt-broker # ========================================================================== mqtt: image: ghcr.io/ipnet-mesh/meshcore-mqtt-broker:latest container_name: ${COMPOSE_PROJECT_NAME:-hub}-mqtt profiles: - all - mqtt restart: unless-stopped volumes: - mqtt_data:/data environment: # Broker listener - MQTT_WS_PORT=${MQTT_PORT:-1883} - MQTT_HOST=0.0.0.0 # JWT audience validation - AUTH_EXPECTED_AUDIENCE=${MQTT_TOKEN_AUDIENCE:-mqtt.localhost} # Subscriber accounts (hub connects as role 1 = admin for /internal topics) - SUBSCRIBER_MAX_CONNECTIONS_DEFAULT=5 - SUBSCRIBER_1=${MQTT_USERNAME:-admin}:${MQTT_PASSWORD:-admin}:1 # Abuse detection - ABUSE_ENFORCEMENT_ENABLED=false - ABUSE_DUPLICATE_WINDOW_SIZE=100 - ABUSE_DUPLICATE_WINDOW_MS=300000 - ABUSE_DUPLICATE_THRESHOLD=10 - ABUSE_MAX_DUPLICATES_PER_PACKET=5 - ABUSE_DUPLICATE_RATE_THRESHOLD=0.3 - ABUSE_DUPLICATE_RATE_WINDOW_MS=300000 - ABUSE_BUCKET_CAPACITY=20 - ABUSE_BUCKET_REFILL_RATE=3 - ABUSE_MAX_PACKET_SIZE=255 - ABUSE_MAX_TOPICS_PER_DAY=3 - ABUSE_ANOMALY_THRESHOLD=10 - ABUSE_MAX_IATA_CHANGES_24H=3 - ABUSE_TOPIC_HISTORY_SIZE=50 - ABUSE_TOPIC_HISTORY_WINDOW_MS=86400000 - ABUSE_PERSISTENCE_PATH=/data/abuse-detection.db - ABUSE_PERSISTENCE_INTERVAL_MS=300000 healthcheck: test: [ "CMD", "node", "-e", "const net=require('net');const s=net.createConnection(process.env.MQTT_WS_PORT||1883,'127.0.0.1',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)", ] interval: 30s timeout: 10s retries: 3 start_period: 10s # ========================================================================== # Observer - MeshCore packet capture to MQTT (serial only) # Uses ghcr.io/agessaman/meshcore-packet-capture (separate project) # Publishes to local MQTT (broker 3) for the hub collector to ingest. # Optionally publish to Let's Mesh (brokers 1 & 2) for cloud map integration. # ========================================================================== observer: image: ghcr.io/agessaman/meshcore-packet-capture:${PACKETCAPTURE_IMAGE_VERSION:-latest} container_name: ${COMPOSE_PROJECT_NAME:-hub}-observer profiles: - all - observer depends_on: mqtt: condition: service_healthy restart: unless-stopped devices: - "${SERIAL_PORT:-/dev/ttyUSB0}:${SERIAL_PORT:-/dev/ttyUSB0}" user: root environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} # Connection (serial only) - PACKETCAPTURE_CONNECTION_TYPE=serial - PACKETCAPTURE_SERIAL_PORTS=${SERIAL_PORT:-/dev/ttyUSB0} - PACKETCAPTURE_TIMEOUT=${PACKETCAPTURE_TIMEOUT:-30} - PACKETCAPTURE_MAX_CONNECTION_RETRIES=${PACKETCAPTURE_MAX_CONNECTION_RETRIES:-5} - PACKETCAPTURE_CONNECTION_RETRY_DELAY=${PACKETCAPTURE_CONNECTION_RETRY_DELAY:-5} - PACKETCAPTURE_HEALTH_CHECK_INTERVAL=${PACKETCAPTURE_HEALTH_CHECK_INTERVAL:-30} # Device - PACKETCAPTURE_IATA=${PACKETCAPTURE_IATA:-LOC} - PACKETCAPTURE_ORIGIN=${PACKETCAPTURE_ORIGIN:-} - PACKETCAPTURE_ADVERT_INTERVAL_HOURS=${PACKETCAPTURE_ADVERT_INTERVAL_HOURS:-11} - PACKETCAPTURE_RF_DATA_TIMEOUT=${PACKETCAPTURE_RF_DATA_TIMEOUT:-15.0} # MQTT Broker 1 - Let's Mesh US (opt-in) - PACKETCAPTURE_MQTT1_ENABLED=${PACKETCAPTURE_MQTT1_ENABLED:-false} - PACKETCAPTURE_MQTT1_SERVER=${PACKETCAPTURE_MQTT1_SERVER:-mqtt-us-v1.letsmesh.net} - PACKETCAPTURE_MQTT1_PORT=${PACKETCAPTURE_MQTT1_PORT:-443} - PACKETCAPTURE_MQTT1_TRANSPORT=websockets - PACKETCAPTURE_MQTT1_USE_TLS=${PACKETCAPTURE_MQTT1_USE_TLS:-true} - PACKETCAPTURE_MQTT1_USE_AUTH_TOKEN=${PACKETCAPTURE_MQTT1_USE_AUTH_TOKEN:-true} - PACKETCAPTURE_MQTT1_TOKEN_AUDIENCE=${PACKETCAPTURE_MQTT1_TOKEN_AUDIENCE:-mqtt-us-v1.letsmesh.net} - PACKETCAPTURE_MQTT1_KEEPALIVE=${PACKETCAPTURE_MQTT1_KEEPALIVE:-120} # MQTT Broker 2 - Let's Mesh EU (opt-in) - PACKETCAPTURE_MQTT2_ENABLED=${PACKETCAPTURE_MQTT2_ENABLED:-false} - PACKETCAPTURE_MQTT2_SERVER=${PACKETCAPTURE_MQTT2_SERVER:-mqtt-eu-v1.letsmesh.net} - PACKETCAPTURE_MQTT2_PORT=${PACKETCAPTURE_MQTT2_PORT:-443} - PACKETCAPTURE_MQTT2_TRANSPORT=websockets - PACKETCAPTURE_MQTT2_USE_TLS=${PACKETCAPTURE_MQTT2_USE_TLS:-true} - PACKETCAPTURE_MQTT2_USE_AUTH_TOKEN=${PACKETCAPTURE_MQTT2_USE_AUTH_TOKEN:-true} - PACKETCAPTURE_MQTT2_TOKEN_AUDIENCE=${PACKETCAPTURE_MQTT2_TOKEN_AUDIENCE:-mqtt-eu-v1.letsmesh.net} - PACKETCAPTURE_MQTT2_KEEPALIVE=${PACKETCAPTURE_MQTT2_KEEPALIVE:-120} # MQTT Broker 3 - Local MQTT (wired to hub's MQTT_* vars) - PACKETCAPTURE_MQTT3_ENABLED=${PACKETCAPTURE_MQTT3_ENABLED:-true} - PACKETCAPTURE_MQTT3_SERVER=${MQTT_HOST} - PACKETCAPTURE_MQTT3_PORT=${MQTT_PORT} - PACKETCAPTURE_MQTT3_USERNAME=${MQTT_USERNAME:-} - PACKETCAPTURE_MQTT3_PASSWORD=${MQTT_PASSWORD:-} - PACKETCAPTURE_MQTT3_TRANSPORT=websockets - PACKETCAPTURE_MQTT3_USE_TLS=${MQTT_TLS:-false} - PACKETCAPTURE_MQTT3_USE_AUTH_TOKEN=true - PACKETCAPTURE_MQTT3_TOKEN_AUDIENCE=${MQTT_TOKEN_AUDIENCE:-mqtt.localhost} - PACKETCAPTURE_MQTT3_KEEPALIVE=${PACKETCAPTURE_MQTT3_KEEPALIVE:-60} # Topics match broker's /// format - PACKETCAPTURE_MQTT3_TOPIC_STATUS=meshcore/{IATA}/{PUBLIC_KEY}/status - PACKETCAPTURE_MQTT3_TOPIC_PACKETS=meshcore/{IATA}/{PUBLIC_KEY}/packets # MQTT reconnection - PACKETCAPTURE_MAX_MQTT_RETRIES=${PACKETCAPTURE_MAX_MQTT_RETRIES:-5} - PACKETCAPTURE_MQTT_RETRY_DELAY=${PACKETCAPTURE_MQTT_RETRY_DELAY:-5} - PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL=${PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL:-true} volumes: - observer_data:/app/data # ========================================================================== # Redis Cache - Optional shared cache for API response caching # Use --profile cache to start with the bundled Redis, or point # REDIS_HOST at an external Redis instance for multi-instance setups. # ========================================================================== redis: image: redis:7-alpine container_name: ${COMPOSE_PROJECT_NAME:-hub}-redis profiles: - all - cache restart: unless-stopped command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 3 # ========================================================================== # Collector - MQTT subscriber and database storage # ========================================================================== collector: image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest} build: context: . dockerfile: Dockerfile container_name: ${COMPOSE_PROJECT_NAME:-hub}-collector profiles: - all - core depends_on: migrate: condition: service_completed_successfully restart: unless-stopped volumes: - data:/data - ${SEED_HOME:-./seed}:/seed environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} - MQTT_HOST=${MQTT_HOST:-mqtt} - MQTT_PORT=${MQTT_PORT:-1883} - MQTT_USERNAME=${MQTT_USERNAME:-} - MQTT_PASSWORD=${MQTT_PASSWORD:-} - MQTT_PREFIX=${MQTT_PREFIX:-meshcore} - MQTT_TLS=${MQTT_TLS:-false} - MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets} - MQTT_WS_PATH=${MQTT_WS_PATH:-/} - CHANNEL_REFRESH_INTERVAL_SECONDS=${CHANNEL_REFRESH_INTERVAL_SECONDS:-300} - DATA_HOME=/data - SEED_HOME=/seed # Webhook configuration - WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-} - WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-} - WEBHOOK_MESSAGE_URL=${WEBHOOK_MESSAGE_URL:-} - WEBHOOK_MESSAGE_SECRET=${WEBHOOK_MESSAGE_SECRET:-} - WEBHOOK_CHANNEL_MESSAGE_URL=${WEBHOOK_CHANNEL_MESSAGE_URL:-} - WEBHOOK_CHANNEL_MESSAGE_SECRET=${WEBHOOK_CHANNEL_MESSAGE_SECRET:-} - WEBHOOK_DIRECT_MESSAGE_URL=${WEBHOOK_DIRECT_MESSAGE_URL:-} - WEBHOOK_DIRECT_MESSAGE_SECRET=${WEBHOOK_DIRECT_MESSAGE_SECRET:-} - WEBHOOK_TIMEOUT=${WEBHOOK_TIMEOUT:-10.0} - WEBHOOK_MAX_RETRIES=${WEBHOOK_MAX_RETRIES:-3} - WEBHOOK_RETRY_BACKOFF=${WEBHOOK_RETRY_BACKOFF:-2.0} # Data retention and cleanup configuration - DATA_RETENTION_ENABLED=${DATA_RETENTION_ENABLED:-true} - DATA_RETENTION_DAYS=${DATA_RETENTION_DAYS:-30} - DATA_RETENTION_INTERVAL_HOURS=${DATA_RETENTION_INTERVAL_HOURS:-24} - NODE_CLEANUP_ENABLED=${NODE_CLEANUP_ENABLED:-true} - NODE_CLEANUP_DAYS=${NODE_CLEANUP_DAYS:-30} command: ["collector"] healthcheck: test: ["CMD", "meshcore-hub", "health", "collector"] interval: 30s timeout: 10s retries: 3 start_period: 10s # ========================================================================== # API Server - REST API for querying data # ========================================================================== api: image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest} build: context: . dockerfile: Dockerfile container_name: ${COMPOSE_PROJECT_NAME:-hub}-api profiles: - all - core restart: unless-stopped depends_on: migrate: condition: service_completed_successfully collector: condition: service_started volumes: - data:/data environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} - MQTT_HOST=${MQTT_HOST:-mqtt} - MQTT_PORT=${MQTT_PORT:-1883} - MQTT_USERNAME=${MQTT_USERNAME:-} - MQTT_PASSWORD=${MQTT_PASSWORD:-} - MQTT_PREFIX=${MQTT_PREFIX:-meshcore} - MQTT_TLS=${MQTT_TLS:-false} - MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets} - MQTT_WS_PATH=${MQTT_WS_PATH:-/} - DATA_HOME=/data - API_HOST=0.0.0.0 - API_PORT=8000 - API_READ_KEY=${API_READ_KEY:-} - API_ADMIN_KEY=${API_ADMIN_KEY:-} - METRICS_ENABLED=${METRICS_ENABLED:-true} - METRICS_CACHE_TTL=${METRICS_CACHE_TTL:-60} # Redis cache (optional — API works without Redis) - REDIS_ENABLED=${REDIS_ENABLED:-false} - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_KEY_PREFIX=${REDIS_KEY_PREFIX:-hub} - REDIS_CACHE_TTL=${REDIS_CACHE_TTL:-30} - REDIS_CACHE_TTL_DASHBOARD=${REDIS_CACHE_TTL_DASHBOARD:-30} command: ["api"] healthcheck: test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')", ] interval: 30s timeout: 10s retries: 3 start_period: 10s # ========================================================================== # Web Dashboard - Web interface for network visualization # ========================================================================== web: image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest} build: context: . dockerfile: Dockerfile container_name: ${COMPOSE_PROJECT_NAME:-hub}-web profiles: - all - core restart: unless-stopped depends_on: api: condition: service_healthy volumes: - ${CONTENT_HOME:-./content}:/content:ro environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} - API_BASE_URL=http://api:8000 # Use ADMIN key to allow write operations from admin interface # Falls back to READ key if ADMIN key is not set - API_KEY=${API_ADMIN_KEY:-${API_READ_KEY:-}} - WEB_HOST=0.0.0.0 - WEB_PORT=8080 - WEB_THEME=${WEB_THEME:-dark} - WEB_LOCALE=${WEB_LOCALE:-en} - WEB_DATETIME_LOCALE=${WEB_DATETIME_LOCALE:-en-US} - WEB_DEBUG=${WEB_DEBUG:-false} # OIDC authentication (see .env.example for details) - OIDC_ENABLED=${OIDC_ENABLED:-false} - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-} - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-} - OIDC_SCOPES=${OIDC_SCOPES:-openid email profile} - OIDC_ROLES_CLAIM=${OIDC_ROLES_CLAIM:-roles} - OIDC_ROLE_ADMIN=${OIDC_ROLE_ADMIN:-admin} - OIDC_ROLE_OPERATOR=${OIDC_ROLE_OPERATOR:-operator} - OIDC_ROLE_MEMBER=${OIDC_ROLE_MEMBER:-member} - OIDC_SESSION_SECRET=${OIDC_SESSION_SECRET:-} - OIDC_SESSION_MAX_AGE=${OIDC_SESSION_MAX_AGE:-86400} - OIDC_COOKIE_SECURE=${OIDC_COOKIE_SECURE:-false} - OIDC_POST_LOGOUT_REDIRECT_URI=${OIDC_POST_LOGOUT_REDIRECT_URI:-} - WEB_AUTO_REFRESH_SECONDS=${WEB_AUTO_REFRESH_SECONDS:-30} - NETWORK_DOMAIN=${NETWORK_DOMAIN:-} - NETWORK_NAME=${NETWORK_NAME:-MeshCore Network} - NETWORK_CITY=${NETWORK_CITY:-} - NETWORK_COUNTRY=${NETWORK_COUNTRY:-} - NETWORK_RADIO_PROFILE=${NETWORK_RADIO_PROFILE:-EU/UK Narrow} - NETWORK_RADIO_FREQUENCY=${NETWORK_RADIO_FREQUENCY:-869.618} - NETWORK_RADIO_BANDWIDTH=${NETWORK_RADIO_BANDWIDTH:-62.5} - NETWORK_RADIO_SPREADING_FACTOR=${NETWORK_RADIO_SPREADING_FACTOR:-8} - NETWORK_RADIO_CODING_RATE=${NETWORK_RADIO_CODING_RATE:-8} - NETWORK_RADIO_TX_POWER=${NETWORK_RADIO_TX_POWER:-22} - NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-} - NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-} - NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-} - NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-} - NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-} - NETWORK_ANNOUNCEMENT=${NETWORK_ANNOUNCEMENT:-} - CONTENT_HOME=/content - TZ=${TZ:-UTC} # Feature flags (set to false to disable specific pages) - FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true} - FEATURE_NODES=${FEATURE_NODES:-true} - FEATURE_ADVERTISEMENTS=${FEATURE_ADVERTISEMENTS:-true} - FEATURE_MESSAGES=${FEATURE_MESSAGES:-true} - FEATURE_MAP=${FEATURE_MAP:-true} - FEATURE_MEMBERS=${FEATURE_MEMBERS:-true} - FEATURE_PAGES=${FEATURE_PAGES:-true} - FEATURE_CHANNELS=${FEATURE_CHANNELS:-true} - FEATURE_RADIO_CONFIG=${FEATURE_RADIO_CONFIG:-true} command: ["web"] healthcheck: test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')", ] interval: 30s timeout: 10s retries: 3 start_period: 10s # ========================================================================== # Database Migrations - Run Alembic migrations # ========================================================================== migrate: image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest} build: context: . dockerfile: Dockerfile container_name: ${COMPOSE_PROJECT_NAME:-hub}-migrate profiles: - all - core - migrate restart: "no" volumes: - data:/data environment: - DATA_HOME=/data command: ["db", "upgrade"] # ========================================================================== # Seed Data - Import node_tags.yaml from SEED_HOME # NOTE: This is NOT run automatically. Use --profile seed to run explicitly. # Since tags are now managed via the admin UI, automatic seeding would # overwrite user changes. # ========================================================================== seed: image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest} build: context: . dockerfile: Dockerfile container_name: ${COMPOSE_PROJECT_NAME:-hub}-seed profiles: - seed restart: "no" volumes: - data:/data - ${SEED_HOME:-./seed}:/seed:ro environment: - DATA_HOME=/data - SEED_HOME=/seed - LOG_LEVEL=${LOG_LEVEL:-INFO} # Imports node_tags.yaml if it exists command: ["collector", "seed"] # ========================================================================== # Volumes # ========================================================================== volumes: data: name: ${COMPOSE_PROJECT_NAME:-hub}_data mqtt_data: name: ${COMPOSE_PROJECT_NAME:-hub}_mqtt_data observer_data: name: ${COMPOSE_PROJECT_NAME:-hub}_observer_data redis_data: name: ${COMPOSE_PROJECT_NAME:-hub}_redis_data