diff --git a/.dockerignore b/.dockerignore index 532a6313..4c83a30f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,12 +23,10 @@ nosetests.xml coverage.xml *.cover *.log -.git .gitignore .mypy_cache .pytest_cache .hypothesis -.env .venv env/ venv/ @@ -43,8 +41,12 @@ venv.bak/ *~ docs/ tests/ -*.md docker-compose*.yml Dockerfile* .dockerignore pipeline/built_assets/ +nginx/ +*.pem +*.key +.env.* +scripts/ diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..384258ee --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +SECRET_KEY= +DEBUG=false +STAGING=false +DJANGO_ALLOWED_HOSTS= +DOMAIN= +SESSION_COOKIE_SECURE_ENABLED=true +CSRF_COOKIE_SECURE_ENABLED=true + +DATABASE_ENGINE=postgresql +DATABASE_NAME=rigs +DATABASE_USERNAME=rigs +DATABASE_PASSWORD= +DATABASE_HOST=db +DATABASE_PORT=5432 + +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS= +EMAIL_USE_SSL= +EMAIL_FROM= + +HCAPTCHA_SITEKEY= +HCAPTCHA_SECRET= +SENTRY_DSN= diff --git a/.gitignore b/.gitignore index 854d4196..96c60b84 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,13 @@ screenshots/ # Virutal Environments .venv/ -/.env + +# Environment variable files (keep .env.example) +.env +.env.* +!.env.example + +nginx/certs/ +nginx/certbot/ +*.pem +*.key \ No newline at end of file diff --git a/RIGS/management/commands/send_reminders.py b/RIGS/management/commands/send_reminders.py index 9157c694..a5a4bcad 100644 --- a/RIGS/management/commands/send_reminders.py +++ b/RIGS/management/commands/send_reminders.py @@ -13,8 +13,7 @@ from RIGS import models class Command(BaseCommand): - # FIXME This needs a different implementation when moved off heroku - help = 'Sends email reminders as required. Triggered daily through heroku-scheduler in production.' + help = 'Sends email reminders as required. Triggered daily through sys cron in production.' def handle(self, *args, **options): events = models.Event.objects.current_events().select_related('riskassessment') diff --git a/compose.yml b/compose.yml index 8c6ae4e7..ba56f290 100644 --- a/compose.yml +++ b/compose.yml @@ -1,41 +1,67 @@ services: db: - image: postgres:17 + image: postgres:18.6 + restart: unless-stopped environment: POSTGRES_DB: ${DATABASE_NAME} POSTGRES_USER: ${DATABASE_USERNAME} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} - ports: - - "5432:5432" + volumes: - postgres_data:/var/lib/postgresql/data - env_file: - - .env + + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] + interval: 10s + timeout: 5s + retries: 5 pyrigs: build: . - container_name: pyrigs - ports: - - "8000:8000" - depends_on: - - db + restart: unless-stopped environment: - DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} - DEBUG: ${DEBUG} - DJANGO_LOGLEVEL: ${DJANGO_LOGLEVEL} + SECRET_KEY: ${SECRET_KEY} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS} - DATABASE_ENGINE: ${DATABASE_ENGINE} + DOMAIN: ${DOMAIN:-example.com} + DEBUG: ${DEBUG:-false} + STAGING: ${STAGING:-false} + SESSION_COOKIE_SECURE_ENABLED: ${SESSION_COOKIE_SECURE_ENABLED:-true} + CSRF_COOKIE_SECURE_ENABLED: ${CSRF_COOKIE_SECURE_ENABLED:-true} + DATABASE_ENGINE: ${DATABASE_ENGINE:-postgresql} DATABASE_NAME: ${DATABASE_NAME} DATABASE_USERNAME: ${DATABASE_USERNAME} - DATABASE_PASSWORD: ${DATABASE_PASSWORD} - DATABASE_HOST: ${DATABASE_HOST} - DATABASE_PORT: ${DATABASE_PORT} - env_file: - - .env + DATABASE_HOST: ${DATABASE_HOST:-db} + DATABASE_PORT: ${DATABASE_PORT:-5432} + EMAIL_HOST: ${EMAIL_HOST} + EMAIL_PORT: ${EMAIL_PORT:-25} + EMAIL_HOST_USER: ${EMAIL_HOST_USER} + EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} + EMAIL_USE_TLS: ${EMAIL_USE_TLS:-false} + EMAIL_USE_SSL: ${EMAIL_USE_SSL:-false} + EMAIL_FROM: ${EMAIL_FROM} + HCAPTCHA_SITEKEY: ${HCAPTCHA_SITEKEY:-10000000-ffff-ffff-ffff-000000000001} + HCAPTCHA_SECRET: ${HCAPTCHA_SECRET:-0x0000000000000000000000000000000000000000} + SENTRY_DSN: ${SENTRY_DSN:-} + expose: + - "8000" + + depends_on: + db: + condition: service_healthy + + command: + - sh + - -c + - | + python manage.py migrate --noinput && + gunicorn \ + --bind 0.0.0.0:8000 \ + --workers 2 \ + PyRIGS.wsgi + develop: # Create a `watch` configuration to update the app - # watch: # Sync the working directory with the `/app` directory in the container - action: sync @@ -48,5 +74,21 @@ services: # Rebuild the image on changes to the `pyproject.toml` - action: rebuild path: ./pyproject.toml + + nginx: + image: nginx:stable-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/certs:/etc/nginx/certs:ro + - ./nginx/certbot:/var/www/certbot:ro + + depends_on: + - pyrigs + volumes: postgres_data: diff --git a/nginx/default.conf.example b/nginx/default.conf.example new file mode 100644 index 00000000..993fe1fb --- /dev/null +++ b/nginx/default.conf.example @@ -0,0 +1,38 @@ +upstream pyrigs { + server pyrigs:8000; +} + +server { + listen 80; + server_name _; + + # Let's Encrypt HTTP-01 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name _; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 20m; + + location / { + proxy_pass http://pyrigs; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_redirect off; + } +} diff --git a/scripts/cert-selfsign.sh b/scripts/cert-selfsign.sh new file mode 100644 index 00000000..4224ff1b --- /dev/null +++ b/scripts/cert-selfsign.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Usage: ./scripts/cert-selfsign.sh [domain] (no root required) +set -euo pipefail + +DOMAIN="${1:-localhost}" +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CERTS_DIR="$REPO_DIR/nginx/certs" + +mkdir -p "$CERTS_DIR" + +if [ -f "$CERTS_DIR/fullchain.pem" ]; then + read -rp "$CERTS_DIR/fullchain.pem already exists, overwrite? [y/N] " ans + case "$ans" in + y|Y) ;; + *) echo "Aborted"; exit 0 ;; + esac +fi + +openssl req -x509 -nodes -newkey rsa:2048 -days 30 \ + -keyout "$CERTS_DIR/privkey.pem" \ + -out "$CERTS_DIR/fullchain.pem" \ + -subj "/CN=$DOMAIN" \ + -addext "subjectAltName=DNS:$DOMAIN" \ + 2>/dev/null +chmod 600 "$CERTS_DIR/privkey.pem" + +echo "Done: self-signed certificate (valid for 30 days) generated in $CERTS_DIR" +echo "Next: start the stack with 'docker compose up -d', then run scripts/certbot-issue.sh to switch to the real certificate" diff --git a/scripts/certbot-issue.sh b/scripts/certbot-issue.sh new file mode 100644 index 00000000..1acf9bc1 --- /dev/null +++ b/scripts/certbot-issue.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Issue the initial certificate (run as root on the deployment server) +# Usage: sudo ./scripts/certbot-issue.sh +set -euo pipefail + +DOMAIN="${1:?Usage: $0 }" +EMAIL="${2:?Usage: $0 }" + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WEBROOT="$REPO_DIR/nginx/certbot" +CERTS_DIR="$REPO_DIR/nginx/certs" +HOOK_PATH="/etc/letsencrypt/renewal-hooks/deploy/pyrigs-deploy.sh" + +if ! command -v certbot >/dev/null 2>&1; then + echo "certbot not found. Install it first: apt install certbot (or snap install certbot --classic)" >&2 + exit 1 +fi + +mkdir -p "$WEBROOT" "$CERTS_DIR" /etc/letsencrypt/renewal-hooks/deploy + +# 1. Initial issuance (certbot skips if the cert already exists and is not due for renewal) +certbot certonly \ + --webroot -w "$WEBROOT" \ + -d "$DOMAIN" \ + -m "$EMAIL" \ + --agree-tos --no-eff-email --non-interactive + +# 2. Install deploy-hook (placed in the official dir, triggered automatically on successful renewal) +cat > "$HOOK_PATH" < "$CERTBOT_CRON" < "$DJANGO_CRON" <> /var/log/pyrigs-cleanup.log 2>&1 +0 8 * * * root docker compose -f $REPO_DIR/compose.yml exec -T pyrigs python manage.py send_reminders >> /var/log/pyrigs-reminders.log 2>&1 +EOF + +chmod 644 "$CERTBOT_CRON" "$DJANGO_CRON" + +echo "Installed:" +echo " $CERTBOT_CRON" +echo " $DJANGO_CRON" diff --git a/scripts/cron-uninstall.sh b/scripts/cron-uninstall.sh new file mode 100644 index 00000000..9347bd9d --- /dev/null +++ b/scripts/cron-uninstall.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Uninstall the scheduled tasks installed by cron-install.sh (run as root on the deployment server) +# Usage: sudo ./scripts/cron-uninstall.sh +set -euo pipefail + +CERTBOT_CRON="/etc/cron.d/pyrigs-certbot" +DJANGO_CRON="/etc/cron.d/pyrigs-django" +HOOK_PATH="/etc/letsencrypt/renewal-hooks/deploy/pyrigs-deploy.sh" + +rm -f "$CERTBOT_CRON" "$DJANGO_CRON" +echo "Removed scheduled tasks: $CERTBOT_CRON $DJANGO_CRON" + +if [ -f "$HOOK_PATH" ]; then + read -rp "Also remove the deploy-hook ($HOOK_PATH)? [y/N] " ans + case "$ans" in + y|Y) rm -f "$HOOK_PATH"; echo "Deploy-hook removed" ;; + *) echo "Deploy-hook kept" ;; + esac +fi