diff --git a/.env.example b/.env.example index 63464b8..5f10809 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,14 @@ MAIL_FROM=your-email@gmail.com DAYS_BEFORE_EXPIRY=7 # User/Group IDs for file permissions (optional) +# Get your IDs with: id -u (for PUID) and id -g (for GUID) PUID=1000 -PGID=1000 +GUID=1000 + +# Performance Settings +CURRENCY_REFRESH_MINUTES=1440 +CURRENCY_PROVIDER_PRIORITY=frankfurter,floatrates,erapi_open +PERFORMANCE_LOGGING=true # Optional: For production deployment # FLASK_ENV=production diff --git a/Dockerfile b/Dockerfile index a130a2d..8d281d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,18 +32,33 @@ RUN apt-get update \ curl \ && rm -rf /var/lib/apt/lists/* +# Create application user and group at build time for security hardening +# This supports read-only filesystems and user: directives +RUN groupadd -r -g 1000 appgroup \ + && useradd -r -u 1000 -g appgroup -m -s /bin/bash appuser \ + && mkdir -p /app/instance \ + && chown -R appuser:appgroup /app + # Copy installed packages from builder stage COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages COPY --from=builder /usr/local/bin /usr/local/bin -COPY . . +# Copy application files and set ownership +COPY --chown=appuser:appgroup . . -# Entrypoint will create/update user/group according to PUID/PGID +# Make entrypoint executable RUN chmod +x /app/docker-entrypoint.sh +# Create writable directories for read-only filesystem compatibility +RUN mkdir -p /tmp/app-runtime /var/tmp/app \ + && chown -R appuser:appgroup /tmp/app-runtime /var/tmp/app \ + && chmod 755 /tmp/app-runtime /var/tmp/app + ENV FLASK_APP=run.py \ - PUID=1000 \ - PGID=1000 + USER=appuser \ + GROUP=appgroup \ + UID=1000 \ + GID=1000 # Health check configuration HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ diff --git a/PUID_GUID_GUIDE.md b/PUID_GUID_GUIDE.md new file mode 100644 index 0000000..71a8e56 --- /dev/null +++ b/PUID_GUID_GUIDE.md @@ -0,0 +1,318 @@ +# PUID/GUID Configuration Guide + +This document explains how to use custom User ID (PUID) and Group ID (GUID) with the Subscription Tracker, including compatibility with security-hardened deployments. + +## Overview + +The Subscription Tracker supports multiple user/group configuration approaches: + +1. **Build-time users** (security-hardened, read-only compatible) +2. **Runtime PUID/GUID** (traditional approach) +3. **Docker user directive** (Kubernetes/security-conscious environments) +4. **Hybrid approach** (best of both worlds) + +## Configuration Methods + +### Method 1: Traditional PUID/GUID (Full Compatibility) + +**Standard Docker Compose:** +```yaml +version: '3.8' +services: + subscription-tracker: + build: . + environment: + - PUID=1001 + - GUID=1001 + volumes: + - ./data:/app/instance:rw +``` + +**Docker Command:** +```bash +docker run -d \ + -e PUID=1001 \ + -e GUID=1001 \ + -v ./data:/app/instance:rw \ + subscription-tracker +``` + +**What happens:** +- Container starts as root +- Entrypoint creates/modifies user to match PUID/GUID +- Privileges are dropped to the custom user +- Full file permission control + +### Method 2: Docker User Directive (Kubernetes Compatible) + +**Security-Hardened Docker Compose:** +```yaml +version: '3.8' +services: + subscription-tracker: + build: . + user: "1001:1001" # PUID:GUID directly + volumes: + - ./data:/app/instance:rw +``` + +**Docker Command:** +```bash +docker run -d \ + --user 1001:1001 \ + -v ./data:/app/instance:rw \ + subscription-tracker +``` + +**What happens:** +- Container starts directly as specified user +- No privilege escalation needed +- Compatible with read-only filesystems +- Kubernetes/security-compliant + +### Method 3: Hybrid Approach (Recommended) + +**Enhanced Docker Compose:** +```yaml +version: '3.8' +services: + subscription-tracker: + build: . + environment: + - PUID=1001 + - GUID=1001 + # Fallback user directive for security environments + user: "${PUID:-1000}:${GUID:-1000}" + volumes: + - ./data:/app/instance:rw +``` + +**What happens:** +- Tries PUID/GUID environment variables first +- Falls back to user directive if environment doesn't allow user creation +- Works in both traditional and security-hardened environments + +## Read-Only Filesystem Compatibility + +### Problem +With read-only filesystems, the container cannot modify `/etc/passwd` or `/etc/group` to create custom users. + +### Solutions + +**Option A: Pre-set User Directive** +```yaml +services: + subscription-tracker: + user: "1001:1001" + read_only: true + tmpfs: + - /tmp:size=100M,mode=1777 + - /var/tmp:size=10M,mode=1777 +``` + +**Option B: Mount Writable User Files** +```yaml +services: + subscription-tracker: + environment: + - PUID=1001 + - GUID=1001 + read_only: true + tmpfs: + - /tmp:size=100M,mode=1777 + - /var/tmp:size=10M,mode=1777 + - /etc/passwd:size=1M,mode=0644 + - /etc/group:size=1M,mode=0644 +``` + +## File Permissions + +### Data Directory Ownership + +**Before Starting Container:** +```bash +# Create data directory with correct ownership +mkdir -p ./data +sudo chown -R 1001:1001 ./data +chmod -R 755 ./data +``` + +**Docker Compose with Init:** +```yaml +services: + subscription-tracker: + environment: + - PUID=1001 + - GUID=1001 + volumes: + - ./data:/app/instance:rw + # Fix permissions on startup + command: > + sh -c " + chown -R 1001:1001 /app/instance && + exec python run.py + " +``` + +## Troubleshooting + +### Issue: Permission Denied + +**Symptoms:** +``` +PermissionError: [Errno 13] Permission denied: '/app/instance/app.db' +``` + +**Solutions:** +1. Check data directory ownership: + ```bash + ls -la ./data + # Should show: drwxr-xr-x 2 1001 1001 + ``` + +2. Fix permissions: + ```bash + sudo chown -R 1001:1001 ./data + ``` + +3. Verify PUID/GUID in container: + ```bash + docker exec -it container_name id + # Should show: uid=1001 gid=1001 + ``` + +### Issue: User Creation Failed + +**Symptoms:** +``` +WARNING: Cannot modify users in read-only filesystem +``` + +**Solutions:** +1. Use user directive instead of PUID/GUID: + ```yaml + user: "1001:1001" + ``` + +2. Mount writable user files: + ```yaml + tmpfs: + - /etc/passwd:size=1M,mode=0644 + - /etc/group:size=1M,mode=0644 + ``` + +### Issue: Security Policy Violations + +**Symptoms:** +- Container fails to start in Kubernetes +- Security scanner flags privilege escalation + +**Solutions:** +1. Use security-hardened compose: + ```bash + docker-compose -f docker-compose.security.yml up + ``` + +2. Set security context in Kubernetes: + ```yaml + securityContext: + runAsUser: 1001 + runAsGroup: 1001 + runAsNonRoot: true + ``` + +## Best Practices + +### For Development +- Use traditional PUID/GUID environment variables +- Mount local directories with proper ownership +- Use standard docker-compose.yml + +### For Production +- Use user directive or security-hardened compose +- Implement proper volume management +- Consider using named volumes with init containers + +### For Kubernetes +- Always use securityContext +- Never run as root (uid 0) +- Use read-only root filesystem when possible + +## Migration Guide + +### From PUID/GUID to User Directive + +**Old Configuration:** +```yaml +environment: + - PUID=1001 + - GUID=1001 +``` + +**New Configuration:** +```yaml +user: "1001:1001" +# Remove PUID/GUID environment variables +``` + +### From Root to Non-Root + +**Old Dockerfile:** +```dockerfile +USER root +``` + +**New Dockerfile:** +```dockerfile +RUN groupadd -g 1000 appgroup && \ + useradd -u 1000 -g appgroup -d /app appuser +USER appuser +``` + +## Environment Variables Reference + +| Variable | Default | Description | +|----------|---------|-------------| +| `PUID` | `1000` | User ID for application | +| `GUID` | `1000` | Group ID for application | +| `USER` | `appuser` | Username (build-time) | +| `GROUP` | `appgroup` | Group name (build-time) | + +## Compatibility Matrix + +| Deployment Method | Read-Only FS | Kubernetes | Security Scanning | Performance | +|-------------------|-------------|------------|------------------|-------------| +| PUID/GUID Env | ❌ | ⚠️ | ❌ | ✅ | +| User Directive | ✅ | ✅ | ✅ | ✅ | +| Hybrid Approach | ✅ | ✅ | ✅ | ✅ | +| Build-time User | ✅ | ✅ | ✅ | ✅ | + +**Legend:** +- ✅ Full support +- ⚠️ Partial support +- ❌ Not supported + +## Quick Reference + +**Standard Development:** +```bash +PUID=1001 GUID=1001 docker-compose up +``` + +**Security-Hardened:** +```bash +docker-compose -f docker-compose.security.yml up +``` + +**Custom User ID:** +```bash +docker run --user 1001:1001 -v ./data:/app/instance subscription-tracker +``` + +**Kubernetes:** +```yaml +securityContext: + runAsUser: 1001 + runAsGroup: 1001 + runAsNonRoot: true +``` \ No newline at end of file diff --git a/docker-compose.security.yml b/docker-compose.security.yml new file mode 100644 index 0000000..e1d5a3b --- /dev/null +++ b/docker-compose.security.yml @@ -0,0 +1,170 @@ +version: '3.8' + +# Security-hardened docker-compose configuration +# Compatible with read-only filesystems and heightened security measures + +services: + web: + build: . + ports: + - "5000:5000" + + # Security hardening: run as non-root user + user: "1000:1000" # appuser:appgroup (created at build time) + + # Security hardening: read-only root filesystem + read_only: true + + # Security hardening: limit capabilities + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE # Allow binding to port 5000 + + # Security hardening: no new privileges + security_opt: + - no-new-privileges:true + + # Security hardening: limit resources + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 256M + cpus: '0.5' + + # Writable temporary directories for read-only filesystem + tmpfs: + - /tmp:size=100M,mode=1777 + - /var/tmp:size=10M,mode=1777 + - /tmp/app-runtime:size=50M,uid=1000,gid=1000,mode=0755 + + environment: + - SECRET_KEY=${SECRET_KEY} + - DATABASE_URL=${DATABASE_URL:-sqlite:///app/instance/subscriptions.db} + - MAIL_SERVER=${MAIL_SERVER} + - MAIL_PORT=${MAIL_PORT} + - MAIL_USE_TLS=${MAIL_USE_TLS} + - MAIL_USERNAME=${MAIL_USERNAME} + - MAIL_PASSWORD=${MAIL_PASSWORD} + - MAIL_FROM=${MAIL_FROM} + - DAYS_BEFORE_EXPIRY=${DAYS_BEFORE_EXPIRY} + # Timeout optimization settings + - CURRENCY_REFRESH_MINUTES=${CURRENCY_REFRESH_MINUTES:-1440} + - CURRENCY_PROVIDER_PRIORITY=${CURRENCY_PROVIDER_PRIORITY:-frankfurter,floatrates,erapi_open} + - PERFORMANCE_LOGGING=${PERFORMANCE_LOGGING:-true} + + # Writable volumes (only what's necessary) + volumes: + - app_data:/app/instance:rw # Application data (writable) + - /etc/passwd:/etc/passwd:ro # Optional: for user resolution + - /etc/group:/etc/group:ro # Optional: for group resolution + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + restart: unless-stopped + + # Security hardening: network isolation + networks: + - app_network +###Database options######## + # PostgreSQL with security hardening + postgres: + image: postgres:15-alpine + user: "999:999" # postgres user + read_only: true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=100M,mode=1777 + - /var/run/postgresql:size=10M,uid=999,gid=999,mode=0755 + environment: + - POSTGRES_DB=${POSTGRES_DB:-subscription_tracker} + - POSTGRES_USER=${POSTGRES_USER:-subscription_tracker} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-subscription_tracker} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - postgres_data:/var/lib/postgresql/data:rw + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-subscription_tracker} -d ${POSTGRES_DB:-subscription_tracker}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + profiles: + - postgres + restart: unless-stopped + networks: + - app_network + + # MariaDB with security hardening + mariadb: + image: mariadb:10.11 + user: "999:999" # mysql user + read_only: true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=100M,mode=1777 + - /var/run/mysqld:size=10M,uid=999,gid=999,mode=0755 + environment: + - MYSQL_DATABASE=${MYSQL_DATABASE:-subscription_tracker} + - MYSQL_USER=${MYSQL_USER:-subscription_tracker} + - MYSQL_PASSWORD=${MYSQL_PASSWORD:-subscription_tracker} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-root_password} + volumes: + - mariadb_data:/var/lib/mysql:rw + healthcheck: + test: ["CMD", "/usr/local/bin/healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + profiles: + - mariadb + restart: unless-stopped + networks: + - app_network + +# Isolated network for security +networks: + app_network: + driver: bridge + internal: false # Set to true for complete isolation + +# Named volumes for persistent data +volumes: + app_data: + driver: local + driver_opts: + type: none + o: bind + device: ${PWD}/data + postgres_data: + driver: local + mariadb_data: + + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index f883c9a..f12c551 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ services: ports: - "5000:5000" environment: + # Custom User/Group IDs (optional) + - PUID=${PUID:-1000} + - GUID=${GUID:-1000} + # Application configuration - SECRET_KEY=${SECRET_KEY} - DATABASE_URL=${DATABASE_URL:-sqlite:///subscriptions.db} - MAIL_SERVER=${MAIL_SERVER} @@ -15,14 +19,12 @@ services: - MAIL_PASSWORD=${MAIL_PASSWORD} - MAIL_FROM=${MAIL_FROM} - DAYS_BEFORE_EXPIRY=${DAYS_BEFORE_EXPIRY} - - PUID=${PUID:-1000} - - PGID=${PGID:-1000} # Timeout optimization settings - CURRENCY_REFRESH_MINUTES=${CURRENCY_REFRESH_MINUTES:-1440} - CURRENCY_PROVIDER_PRIORITY=${CURRENCY_PROVIDER_PRIORITY:-frankfurter,floatrates,erapi_open} - PERFORMANCE_LOGGING=${PERFORMANCE_LOGGING:-true} volumes: - - YOURDATAVOLUME:/app/instance + - ./data:/app/instance healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5000/health"] interval: 30s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index d80ffcc..32690da 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,35 +1,126 @@ #!/usr/bin/env bash set -e -# Environment variables: PUID, PGID +# Security-hardened entrypoint with PUID/GUID support +# Supports both build-time users and runtime PUID/GUID configuration + +# PUID/GUID support (legacy compatibility) PUID=${PUID:-1000} -PGID=${PGID:-1000} -APP_USER=appuser -APP_GROUP=appgroup - -# Create group if it does not exist or adjust gid -if getent group ${APP_GROUP} >/dev/null 2>&1; then - EXISTING_GID=$(getent group ${APP_GROUP} | cut -d: -f3) - if [ "$EXISTING_GID" != "$PGID" ]; then - groupmod -o -g "$PGID" "$APP_GROUP" || true +GUID=${GUID:-1000} + +# Build-time user/group names (for security hardening) +APP_USER=${USER:-appuser} +APP_GROUP=${GROUP:-appgroup} + +# Function to check if running with read-only filesystem +is_readonly_fs() { + # Try to create a test file in /tmp to check if filesystem is writable + touch /tmp/.write-test 2>/dev/null && rm -f /tmp/.write-test 2>/dev/null + return $? +} + +# Function to handle PUID/GUID configuration +setup_user_mapping() { + # If we're running as root and PUID/GUID are specified, handle user mapping + if [ "$(id -u)" = "0" ] && [ -n "$PUID" ] && [ -n "$GUID" ]; then + + # Check if we can modify /etc/passwd (not read-only filesystem) + if ! is_readonly_fs && [ -w /etc/passwd ]; then + echo "Setting up PUID/GUID mapping: $PUID:$GUID" + + # Create or modify user/group to match PUID/GUID + if ! getent group "$GUID" >/dev/null 2>&1; then + groupadd -g "$GUID" "$APP_GROUP" 2>/dev/null || true + fi + + if ! getent passwd "$PUID" >/dev/null 2>&1; then + useradd -u "$PUID" -g "$GUID" -d /app -s /bin/bash "$APP_USER" 2>/dev/null || true + else + # User exists, modify it + usermod -u "$PUID" -g "$GUID" "$APP_USER" 2>/dev/null || true + fi + + # Set APP_USER and APP_GROUP to the PUID/GUID values for gosu + APP_USER="$PUID" + APP_GROUP="$GUID" + else + echo "WARNING: Cannot modify users in read-only filesystem. Using build-time user." + echo "To use custom PUID/GUID, either:" + echo " 1. Use --user $PUID:$GUID with Docker" + echo " 2. Mount writable /etc/passwd and /etc/group" + fi fi -else - groupadd -o -g "$PGID" "$APP_GROUP" -fi - -# Create user if it does not exist or adjust uid -if id -u ${APP_USER} >/dev/null 2>&1; then - EXISTING_UID=$(id -u ${APP_USER}) - if [ "$EXISTING_UID" != "$PUID" ]; then - usermod -o -u "$PUID" ${APP_USER} 2>/dev/null || true +} + +# Ensure writable directories exist for application data +# These should be mounted as volumes in production +ensure_writable_dirs() { + # Only attempt directory creation if we can write + if is_readonly_fs; then + # For read-only filesystem, only check that required dirs exist + if [ ! -d "/app/instance" ]; then + echo "ERROR: /app/instance directory does not exist. Please mount it as a volume." + exit 1 + fi + else + # For writable filesystem, create directories if needed + mkdir -p /app/instance + # Only attempt chown if we're running as root + if [ "$(id -u)" = "0" ]; then + # Use PUID:GUID if available, otherwise use build-time user + local owner="${PUID:-$APP_USER}" + local group="${GUID:-$APP_GROUP}" + chown "$owner:$group" /app/instance 2>/dev/null || true + fi fi -else - useradd -o -m -u "$PUID" -g "$PGID" -s /bin/bash ${APP_USER} 2>/dev/null || true -fi +} -# Ensure instance and other writable dirs exist & permissions -mkdir -p /app/instance -chown -R ${APP_USER}:${APP_GROUP} /app/instance +# Set up temporary directories for application runtime +setup_temp_dirs() { + # Use /tmp for temporary files (usually writable even with read-only root) + export TMPDIR="/tmp/app-runtime" + export TEMP="/tmp/app-runtime" + export TMP="/tmp/app-runtime" + + # Create temp directories if they don't exist and filesystem is writable + if ! is_readonly_fs; then + mkdir -p "$TMPDIR" 2>/dev/null || true + if [ "$(id -u)" = "0" ]; then + # Use PUID:GUID if available, otherwise use build-time user + local owner="${PUID:-$APP_USER}" + local group="${GUID:-$APP_GROUP}" + chown "$owner:$group" "$TMPDIR" 2>/dev/null || true + fi + fi +} + +# Check if we need to drop privileges +should_drop_privileges() { + # Only drop privileges if we're running as root + [ "$(id -u)" = "0" ] +} + +# Main execution +main() { + echo "Starting Subscription Tracker..." + echo "Running as user: $(id -u):$(id -g)" + + # Handle PUID/GUID mapping first + setup_user_mapping + + # Set up required directories and environment + ensure_writable_dirs + setup_temp_dirs + + # Drop privileges if running as root, otherwise run directly + if should_drop_privileges; then + echo "Dropping privileges to ${APP_USER}:${APP_GROUP}" + exec gosu ${APP_USER}:${APP_GROUP} "$@" + else + echo "Running with current user privileges" + exec "$@" + fi +} -# Drop privileges and execute -exec gosu ${APP_USER}:${APP_GROUP} "$@" +# Run main function +main "$@"