feat: add Docker containerization with GitHub Actions CI/CD

- Multi-stage Dockerfile with Python 3.12 + Ofelia scheduler
- docker-compose.yml for production (ghcr.io image)
- docker-compose.development.yml for local builds
- GitHub Actions workflow for multi-arch builds (amd64/arm64)
- Security hardening: non-root user, cap_drop, read_only filesystem
- Trivy vulnerability scanning and SBOM generation
- Nightly rebuilds for OS security patches

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn Schrijvershof
2026-01-05 07:12:59 +01:00
parent 2bf04ce3f7
commit 7a181e4b1a
10 changed files with 897 additions and 3 deletions

42
.dockerignore Normal file
View File

@@ -0,0 +1,42 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv
ENV
env
# IDE
.idea
.vscode
*.swp
*.swo
*~
# Project directories (generated/runtime)
data/
out/
# Configuration (use environment variables in Docker)
.envrc
.direnv
meshcore.conf
# Documentation
docs/
*.md
!README.md
# Development files
.claude/
*.log
# macOS
.DS_Store

233
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,233 @@
# Build and publish Docker images to GitHub Container Registry
#
# Triggers:
# - On release: Build with version tags (X.Y.Z, X.Y, latest)
# - On schedule: Rebuild all tags with fresh base image (OS patches)
# - Manual: For testing, optional push
#
# Security:
# - All actions pinned by SHA
# - Vulnerability scanning with Trivy
# - SBOM and provenance attestation
name: Docker Build and Publish
on:
release:
types: [published]
schedule:
# Daily at 4 AM UTC - rebuild with fresh base image
- cron: "0 4 * * *"
workflow_dispatch:
inputs:
push:
description: "Push image to registry"
required: false
default: false
type: boolean
permissions:
contents: read
packages: write
id-token: write
attestations: write
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# For nightly builds, get the latest release version
- name: Get latest release version
id: get-version
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get latest release tag
LATEST_TAG=$(gh release view --json tagName -q '.tagName' 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
echo "No releases found, skipping nightly build"
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi
# Strip 'v' prefix if present
VERSION=$(echo "$LATEST_TAG" | sed 's/^v//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
- name: Skip if no releases
if: github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true'
run: |
echo "No releases found, skipping nightly build"
exit 0
- name: Set up QEMU
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to Container Registry
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Generate tags based on event type
- name: Extract metadata (release)
id: meta-release
if: github.event_name == 'release'
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# X.Y.Z
type=semver,pattern={{version}}
# X.Y
type=semver,pattern={{major}}.{{minor}}
# latest
type=raw,value=latest
- name: Extract metadata (nightly)
id: meta-nightly
if: github.event_name == 'schedule' && steps.get-version.outputs.skip != 'true'
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Rebuild version tags with OS patches
type=raw,value=${{ steps.get-version.outputs.version }}
# Nightly tags
type=raw,value=nightly
type=raw,value=nightly-{{date 'YYYYMMDD'}}
# Also update latest with security patches
type=raw,value=latest
- name: Extract metadata (manual)
id: meta-manual
if: github.event_name == 'workflow_dispatch'
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
# Build image (release - with cache)
- name: Build and push (release)
id: build-release
if: github.event_name == 'release'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-release.outputs.tags }}
labels: ${{ steps.meta-release.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
# Build image (nightly - no cache, fresh base)
- name: Build and push (nightly)
id: build-nightly
if: github.event_name == 'schedule' && steps.get-version.outputs.skip != 'true'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-nightly.outputs.tags }}
labels: ${{ steps.meta-nightly.outputs.labels }}
pull: true
no-cache: true
provenance: true
sbom: true
# Build image (manual)
- name: Build and push (manual)
id: build-manual
if: github.event_name == 'workflow_dispatch'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ inputs.push }}
tags: ${{ steps.meta-manual.outputs.tags }}
labels: ${{ steps.meta-manual.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
# Determine image tag for scanning and testing
- name: Determine image tag
id: image-tag
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
run: |
if [ "${{ github.event_name }}" = "release" ]; then
# Strip 'v' prefix to match semver tag format from metadata-action
echo "tag=$(echo '${{ github.event.release.tag_name }}' | sed 's/^v//')" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" = "schedule" ]; then
echo "tag=nightly" >> $GITHUB_OUTPUT
else
echo "tag=sha-${{ github.sha }}" >> $GITHUB_OUTPUT
fi
# Vulnerability scanning
- name: Run Trivy vulnerability scanner
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }}
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
continue-on-error: true
- name: Upload Trivy scan results
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
uses: github/codeql-action/upload-sarif@6e4b8622b82fab3c6ad2a7814fad1effc7615bc8 # v3.28.4
with:
sarif_file: "trivy-results.sarif"
continue-on-error: true
# Smoke test - verify image runs correctly
- name: Smoke test
if: "!(github.event_name == 'schedule' && steps.get-version.outputs.skip == 'true')"
run: |
IMAGE_TAG="${{ steps.image-tag.outputs.tag }}"
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG}
# Test that Python and key modules are available
docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${IMAGE_TAG} \
python -c "from meshmon.db import init_db; from meshmon.env import get_config; print('Smoke test passed')"
# Attestation (releases only)
- name: Generate attestation
if: github.event_name == 'release'
uses: actions/attest-build-provenance@46a583fd92dfbf46b772907a9740f888f4324bb9 # v3.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build-release.outputs.digest }}
push-to-registry: true

View File

@@ -171,6 +171,101 @@ Phase 3: Render → Static HTML site (inline SVG)
Phase 4: Render → Reports (monthly/yearly statistics)
```
## Docker Architecture
The project provides Docker containerization for easy deployment. Two containers work together:
```
┌─────────────────────────────────────────────────────────────────┐
│ Docker Compose │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ meshcore-stats │ │ nginx │ │
│ │ ┌───────────────┐ │ │ │ │
│ │ │ Ofelia │ │ │ Serves static site on :8080 │ │
│ │ │ (scheduler) │ │ │ │ │
│ │ └───────┬───────┘ │ └──────────────▲──────────────────┘ │
│ │ │ │ │ │
│ │ ┌──────▼──────┐ │ ┌─────────┴─────────┐ │
│ │ │ Python │ │ │ output_data │ │
│ │ │ Scripts │───┼────────►│ (named volume) │ │
│ │ └─────────────┘ │ └───────────────────┘ │
│ │ │ │ │
│ └──────────┼──────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ ./data/state │ │
│ │ (bind mount) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Container Files
| File | Purpose |
|------|---------|
| `Dockerfile` | Multi-stage build: Python + Ofelia scheduler |
| `docker-compose.yml` | Production deployment using published ghcr.io image |
| `docker-compose.development.yml` | Development override for local builds |
| `docker/ofelia.ini` | Scheduler configuration (cron jobs) |
| `docker/nginx.conf` | nginx configuration for static site serving |
| `.dockerignore` | Files excluded from Docker build context |
### docker-compose.yml vs docker-compose.development.yml
**Production** (`docker-compose.yml`):
- Uses published image from `ghcr.io/jorijn/meshcore-stats`
- Image version managed by release-please via `x-release-please-version` placeholder
- Suitable for end users
**Development** (`docker-compose.development.yml`):
- Override file that builds locally instead of pulling from registry
- Mounts `src/` and `scripts/` for live code changes
- Usage: `docker compose -f docker-compose.yml -f docker-compose.development.yml up --build`
### Ofelia Scheduler
[Ofelia](https://github.com/mcuadros/ofelia) is a lightweight job scheduler designed for Docker. It replaces cron for container environments.
Jobs configured in `docker/ofelia.ini`:
- `collect-companion`: Every minute (with `no-overlap=true`)
- `collect-repeater`: Every 15 minutes at :01, :16, :31, :46 (with `no-overlap=true`)
- `render-charts`: Every 5 minutes
- `render-site`: Every 5 minutes
- `render-reports`: Daily at midnight
- `db-maintenance`: Monthly at 3 AM on the 1st
### GitHub Actions Workflow
`.github/workflows/docker-publish.yml` builds and publishes Docker images:
| Trigger | Tags Created |
|---------|--------------|
| Release | `X.Y.Z`, `X.Y`, `latest` |
| Nightly (4 AM UTC) | Rebuilds all version tags + `nightly`, `nightly-YYYYMMDD` |
| Manual | `sha-xxxxxx` |
**Nightly rebuilds** ensure version tags always include the latest OS security patches. This is a common pattern used by official Docker images (nginx, postgres, node). Users needing reproducibility should pin by SHA digest or use dated nightly tags.
All GitHub Actions are pinned by full SHA for security. Dependabot can be configured to update these automatically.
### Version Placeholder
The version in `docker-compose.yml` uses release-please's placeholder syntax:
```yaml
image: ghcr.io/jorijn/meshcore-stats:0.3.0 # x-release-please-version
```
This is automatically updated when a new release is created.
### Agent Review Guidelines
When reviewing Docker-related changes, always provide the **full plan or implementation** to review agents. Do not summarize or abbreviate - agents need complete context to provide accurate feedback.
Relevant agents for Docker reviews:
- **k8s-security-reviewer**: Container security, RBAC, secrets handling
- **cicd-pipeline-specialist**: GitHub Actions workflows, build pipelines
- **python-code-reviewer**: Dockerfile Python-specific issues (venv PATH, runtime libs)
## Directory Structure
```

115
Dockerfile Normal file
View File

@@ -0,0 +1,115 @@
# =============================================================================
# Stage 1: Build dependencies
# =============================================================================
FROM python:3.12-slim-bookworm AS builder
# Ofelia version and checksums (verified from GitHub releases)
ARG OFELIA_VERSION=0.3.12
ARG TARGETARCH
ARG OFELIA_SHA256_AMD64=cf06d2199abafbd3aa5afe0f8266e478818faacd11555b99200707321035c931
ARG OFELIA_SHA256_ARM64=57760ef7f17a2cd55b5b1e1946f79b91b24bde40d47e81a0d75fd1470d883c1a
# Install build dependencies for Python packages
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libfreetype6-dev \
libpng-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Download and verify Ofelia binary in builder stage (keeps curl out of runtime)
RUN set -ex; \
if [ "$TARGETARCH" = "amd64" ]; then \
OFELIA_SHA256="$OFELIA_SHA256_AMD64"; \
elif [ "$TARGETARCH" = "arm64" ]; then \
OFELIA_SHA256="$OFELIA_SHA256_ARM64"; \
else \
echo "Unsupported architecture: $TARGETARCH" && exit 1; \
fi; \
curl -fsSL "https://github.com/mcuadros/ofelia/releases/download/v${OFELIA_VERSION}/ofelia_${OFELIA_VERSION}_linux_${TARGETARCH}.tar.gz" -o /tmp/ofelia.tar.gz \
&& echo "${OFELIA_SHA256} /tmp/ofelia.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/ofelia.tar.gz -C /usr/local/bin ofelia \
&& rm /tmp/ofelia.tar.gz \
&& chmod +x /usr/local/bin/ofelia
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# =============================================================================
# Stage 2: Runtime
# =============================================================================
FROM python:3.12-slim-bookworm
# OCI Labels
LABEL org.opencontainers.image.source="https://github.com/jorijn/meshcore-stats"
LABEL org.opencontainers.image.description="MeshCore Stats - LoRa mesh network monitoring"
LABEL org.opencontainers.image.licenses="MIT"
# Install runtime dependencies
# - tini: init system for proper signal handling
# - libfreetype6, libpng16-16: matplotlib runtime libraries
# - fontconfig, fonts-dejavu-core: fonts for chart text rendering
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
libfreetype6 \
libpng16-16 \
fontconfig \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* \
# Build font cache for matplotlib
&& fc-cache -f \
# Remove setuid/setgid binaries for security
&& find / -perm /6000 -type f -exec chmod a-s {} \; 2>/dev/null || true
# Create non-root user with dialout group for serial access
RUN groupadd -g 1000 meshmon \
&& useradd -u 1000 -g meshmon -G dialout -s /sbin/nologin meshmon \
&& mkdir -p /data/state /out /tmp/matplotlib \
&& chown -R meshmon:meshmon /data /out /tmp/matplotlib
# Copy Ofelia binary from builder (keeps curl out of runtime image)
COPY --from=builder /usr/local/bin/ofelia /usr/local/bin/ofelia
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
# Copy application code
COPY --chown=meshmon:meshmon src/ /app/src/
COPY --chown=meshmon:meshmon scripts/ /app/scripts/
COPY --chown=meshmon:meshmon docker/ofelia.ini /app/ofelia.ini
# Environment configuration
# - PATH: Include venv so Ofelia can run Python
# - PYTHONPATH: Allow imports from src/meshmon
# - PYTHONUNBUFFERED: Ensure logs are output immediately
# - PYTHONDONTWRITEBYTECODE: Don't create .pyc files
# - MPLCONFIGDIR: Matplotlib font cache directory
# - STATE_DIR/OUT_DIR: Default paths for Docker volumes
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONPATH=/app/src \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
MPLCONFIGDIR=/tmp/matplotlib \
STATE_DIR=/data/state \
OUT_DIR=/out
WORKDIR /app
# Run as non-root user
USER meshmon
# Use tini as init system for proper signal handling
ENTRYPOINT ["/usr/bin/tini", "--"]
# Run Ofelia scheduler
CMD ["ofelia", "daemon", "--config=/app/ofelia.ini"]
# Health check - verify database is accessible
HEALTHCHECK --interval=5m --timeout=30s --start-period=60s --retries=3 \
CMD python -c "import sqlite3; sqlite3.connect('/data/state/metrics.db').execute('SELECT 1')" || exit 1

121
README.md
View File

@@ -112,14 +112,129 @@ MESHCORE=/home/user/meshcore-stats
- `cd $MESHCORE` is required because paths in the config are relative to the project root
- `flock` prevents USB serial conflicts when companion and repeater collection overlap
### Docker / Container Usage
### Docker Installation
When running in Docker, you can skip the config file and pass environment variables directly:
The recommended way to run MeshCore Stats is with Docker Compose. This provides automatic scheduling of all collection and rendering tasks.
#### Quick Start
```bash
docker run -e MESH_SERIAL_PORT=/dev/ttyUSB0 -e REPEATER_NAME="My Repeater" ...
# Clone the repository
git clone https://github.com/jorijn/meshcore-stats.git
cd meshcore-stats
# Create configuration
cp meshcore.conf.example meshcore.conf
# Edit meshcore.conf with your settings
# Create data directory
mkdir -p data/state
# Start the containers
docker compose up -d
# View logs
docker compose logs -f
```
The web interface will be available at `http://localhost:8080`.
#### Architecture
The Docker setup uses two containers:
| Container | Purpose |
|-----------|---------|
| `meshcore-stats` | Runs Ofelia scheduler for data collection and rendering |
| `nginx` | Serves the static website |
#### Configuration
Configuration is loaded from `meshcore.conf` via the `env_file` directive. Key settings:
```bash
# Required: Serial device for companion node
MESH_SERIAL_PORT=/dev/ttyUSB0 # Adjust for your system
# Required: Repeater identity
REPEATER_NAME="Your Repeater Name"
REPEATER_PASSWORD="your-password"
# Display names (shown in UI)
REPEATER_DISPLAY_NAME="My Repeater"
COMPANION_DISPLAY_NAME="My Companion"
```
See `meshcore.conf.example` for all available options.
#### Serial Device Access
The container needs access to your USB serial device. Update `docker-compose.yml` if your device path differs:
```yaml
devices:
- /dev/ttyACM0:/dev/ttyACM0:rwm # Adjust path as needed
```
On the host, ensure the device is accessible:
```bash
# Add user to dialout group (Linux)
sudo usermod -a -G dialout $USER
# Log out and back in for changes to take effect
```
#### Development Mode
For local development with live code changes:
```bash
docker compose -f docker-compose.yml -f docker-compose.development.yml up --build
```
This mounts `src/` and `scripts/` into the container, so changes take effect immediately without rebuilding.
#### Image Tags
Images are published to `ghcr.io/jorijn/meshcore-stats`:
| Tag | Description |
|-----|-------------|
| `X.Y.Z` | Specific version (e.g., `0.3.0`) |
| `latest` | Latest release |
| `nightly` | Latest release rebuilt with OS patches |
| `nightly-YYYYMMDD` | Dated nightly build |
Version tags are rebuilt nightly to include OS security patches. For reproducible deployments, pin by SHA digest:
```yaml
image: ghcr.io/jorijn/meshcore-stats@sha256:abc123...
```
#### Volumes
| Path | Purpose |
|------|---------|
| `./data/state` | SQLite database and circuit breaker state |
| `output_data` | Generated static site (shared with nginx) |
#### Resource Limits
Default resource limits in `docker-compose.yml`:
| Container | CPU | Memory |
|-----------|-----|--------|
| meshcore-stats | 1.0 | 512MB |
| nginx | 0.5 | 64MB |
Adjust in `docker-compose.yml` if needed.
#### Important Notes
- **Single instance only**: SQLite uses WAL mode which requires exclusive access. Do not run multiple container instances.
- **Persistent storage**: Mount `./data/state` to preserve your database across container restarts.
- **Health checks**: Both containers have health checks. Use `docker compose ps` to verify status.
Environment variables always take precedence over `meshcore.conf`.
### Serving the Site

View File

@@ -0,0 +1,29 @@
# MeshCore Stats - Development Override
#
# Use this file for local development with live code changes.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.development.yml up --build
#
# This override:
# - Builds the image locally instead of pulling from ghcr.io
# - Mounts src/ and scripts/ for live code changes (no rebuild needed)
services:
meshcore-stats:
# Build locally instead of using published image
build:
context: .
dockerfile: Dockerfile
# Remove the image reference (use built image)
image: meshcore-stats:development
# Mount source code for live development
# Changes to Python files take effect immediately (no rebuild needed)
volumes:
- ./data/state:/data/state
- output_data:/out
# Development mounts (read-only to prevent accidental writes)
- ./src:/app/src:ro
- ./scripts:/app/scripts:ro

138
docker-compose.yml Normal file
View File

@@ -0,0 +1,138 @@
# MeshCore Stats - Docker Compose Configuration
#
# Production deployment using published container image.
# For local development, use: docker compose -f docker-compose.yml -f docker-compose.development.yml up
#
# Prerequisites:
# 1. Copy meshcore.conf.example to meshcore.conf and configure
# 2. Ensure your user has access to the serial device (dialout group)
# 3. Create data directory: mkdir -p ./data/state
services:
# ==========================================================================
# MeshCore Stats - Data collection and rendering
# ==========================================================================
meshcore-stats:
image: ghcr.io/jorijn/meshcore-stats:0.3.0 # x-release-please-version
container_name: meshcore-stats
restart: unless-stopped
# Load configuration from meshcore.conf
env_file:
- meshcore.conf
# Serial device for companion node communication
devices:
# Update to match your serial device (e.g., /dev/ttyACM0, /dev/cu.usbserial-*)
- /dev/ttyUSB0:/dev/ttyUSB0:rw
volumes:
# Persistent storage for SQLite database and circuit breaker state
- ./data/state:/data/state
# Shared volume for generated static site (used by nginx)
- output_data:/out
# Run as meshmon user (UID 1000)
user: "1000:1000"
# Add dialout group for serial port access
group_add:
- dialout
# Security hardening
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=64m
# Resource limits
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.1"
memory: 128M
# Logging limits to prevent disk exhaustion
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Health check
healthcheck:
test: ["CMD", "python", "-c", "import sqlite3; sqlite3.connect('/data/state/metrics.db').execute('SELECT 1')"]
interval: 5m
timeout: 30s
start_period: 60s
retries: 3
# ==========================================================================
# nginx - Static site server
# ==========================================================================
nginx:
image: nginx:1.27-alpine
container_name: meshcore-stats-nginx
restart: unless-stopped
# Run as nginx user (UID 101 in Alpine nginx image)
user: "101:101"
ports:
- "8080:8080"
volumes:
# Mount generated static site from meshcore-stats container
- output_data:/usr/share/nginx/html:ro
# Custom nginx configuration
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
# Security hardening
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# NET_BIND_SERVICE not needed for port 8080 (unprivileged)
read_only: true
tmpfs:
- /var/cache/nginx:noexec,nosuid,size=16m
- /var/run:noexec,nosuid,size=1m
# Resource limits
deploy:
resources:
limits:
cpus: "0.5"
memory: 64M
reservations:
cpus: "0.05"
memory: 16M
# Logging limits
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Health check
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
start_period: 5s
retries: 3
depends_on:
meshcore-stats:
condition: service_healthy
# Named volume for sharing generated site between containers
volumes:
output_data:

67
docker/nginx.conf Normal file
View File

@@ -0,0 +1,67 @@
# nginx configuration for MeshCore Stats static site
# This file is used by the nginx container in docker-compose.yml
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index day.html index.html;
# UTF-8 charset for all text files
charset utf-8;
charset_types text/plain text/css text/javascript application/json image/svg+xml;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/json image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# HTML, JSON, TXT files - no cache (frequently updated)
location ~* \.(html|json|txt)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
# Re-add security headers (add_header in location blocks replaces parent)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# PNG files - no cache (charts are regenerated frequently)
location ~* \.png$ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# CSS, JS, SVG files - short cache (5 minutes)
location ~* \.(css|js|svg)$ {
expires 5m;
add_header Cache-Control "public, max-age=300" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# Default location
location / {
try_files $uri $uri/ =404;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
}

55
docker/ofelia.ini Normal file
View File

@@ -0,0 +1,55 @@
# Ofelia Job Scheduler Configuration
# https://github.com/mcuadros/ofelia
#
# This file defines the cron-like schedule for all MeshCore Stats tasks.
# Jobs run inside the same container (job-local).
[global]
# Save last run state for job status
save = true
# =============================================================================
# Data Collection Jobs
# =============================================================================
[job-local "collect-companion"]
# Collect metrics from companion node (USB serial)
schedule = @every 1m
command = python /app/scripts/collect_companion.py
no-overlap = true
[job-local "collect-repeater"]
# Collect metrics from repeater node (via LoRa)
# Offset by 1 second to avoid USB serial conflicts with companion collection
schedule = 1 1,16,31,46 * * * *
command = python /app/scripts/collect_repeater.py
no-overlap = true
# =============================================================================
# Rendering Jobs
# =============================================================================
[job-local "render-charts"]
# Generate SVG charts from database
schedule = @every 5m
command = python /app/scripts/render_charts.py
[job-local "render-site"]
# Generate static HTML site
schedule = @every 5m
command = python /app/scripts/render_site.py
[job-local "render-reports"]
# Generate monthly/yearly statistics reports
schedule = @daily
command = python /app/scripts/render_reports.py
# =============================================================================
# Maintenance Jobs
# =============================================================================
[job-local "db-maintenance"]
# Database VACUUM and ANALYZE for optimal performance
# Runs at 3 AM on the 1st of each month
schedule = 0 3 1 * *
command = python -c "import sqlite3; db=sqlite3.connect('/data/state/metrics.db'); db.execute('VACUUM'); db.execute('ANALYZE'); db.close(); print('Database maintenance complete')"

View File

@@ -9,6 +9,11 @@
"type": "generic",
"path": "src/meshmon/__init__.py",
"glob": false
},
{
"type": "generic",
"path": "docker-compose.yml",
"glob": false
}
],
"changelog-sections": [