From 719cdce9c1f93f8d17a55633d40690e912f28276 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 24 Jun 2026 11:06:33 +0200 Subject: [PATCH] feat: add optional letsencrypt tls --- .env.example | 8 ++ README.md | 50 +++++++++--- backend/config/settings.py | 1 + certbot/renew.sh | 77 +++++++++++++++++++ docker-compose.yml | 43 ++++++++++- docs/adr/0002-nginx-reverse-proxy.md | 7 +- docs/adr/0003-optional-letsencrypt.md | 71 +++++++++++++++++ docs/architecture.md | 11 ++- docs/deployment.md | 67 ++++++++++++---- docs/operations.md | 28 ++++++- docs/runbook.md | 30 +++++++- docs/security.md | 19 +++-- docs/testing.md | 3 + nginx/40-configure-tls.sh | 106 ++++++++++++++++++++++++++ nginx/Dockerfile | 10 +++ nginx/default.conf | 41 +++++----- nginx/proxy-routes.conf | 20 +++++ nginx/tls.conf.template | 26 +++++++ tests/functional/tests/portal.spec.ts | 5 ++ 19 files changed, 561 insertions(+), 62 deletions(-) create mode 100644 certbot/renew.sh create mode 100644 docs/adr/0003-optional-letsencrypt.md create mode 100644 nginx/40-configure-tls.sh create mode 100644 nginx/Dockerfile create mode 100644 nginx/proxy-routes.conf create mode 100644 nginx/tls.conf.template diff --git a/.env.example b/.env.example index b7e8a4d..aac7313 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,11 @@ WAGTAILADMIN_BASE_URL=http://azionelab.org:8080 PUBLIC_CMS_API_URL=http://backend:8000 NGINX_BIND_ADDRESS=127.0.0.1 NGINX_HTTP_PORT=8080 +NGINX_HTTPS_PORT=8443 +LETSENCRYPT_ENABLED=0 +LETSENCRYPT_DOMAIN=azionelab.org +LETSENCRYPT_EMAIL=operator@example.org +LETSENCRYPT_STAGING=1 +LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200 +LETSENCRYPT_RETRY_SECONDS=300 +TLS_RELOAD_INTERVAL_SECONDS=30 diff --git a/README.md b/README.md index c01c6a5..64b88b8 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,21 @@ stores content, and Docker Compose supplies a reproducible local environment. aggregate read-only API at `GET /api/site/home/`. - `frontend/`: Astro components, responsive design system, typed API adapter, and Italian fallback content for development when the CMS is unavailable. -- `nginx/`: reverse-proxy virtual host for `azionelab.org`, routing public requests to - Astro or Wagtail. +- `nginx/`: reverse proxy for `azionelab.org`, with optional HTTPS termination and + automatic reload after certificate renewal. +- `certbot/`: optional Let's Encrypt HTTP-01 issue/renew loop for direct deployments. - `db`: persistent PostgreSQL database. -- Docker volumes: `postgres_data` for the database and `media_data` for uploads. +- Docker volumes persist PostgreSQL, media, ACME challenges, and certificates. See [the architecture document](docs/architecture.md), [ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and -[ADR-0002](docs/adr/0002-nginx-reverse-proxy.md) for the rationale. +[ADR-0002](docs/adr/0002-nginx-reverse-proxy.md), and +[ADR-0003](docs/adr/0003-optional-letsencrypt.md) for the rationale. ## Prerequisites - Docker Engine with Docker Compose v2. -- Ports `8080`, `4321`, and `8000` available on localhost. +- Ports `8080`, `8443`, `4321`, and `8000` available on localhost. - A local hosts-file entry mapping `azionelab.org` to `127.0.0.1`. No host Python or Node.js installation is required. @@ -53,7 +55,32 @@ Direct loopback ports `4321` and `8000` remain available for local diagnostics o The frontend remains usable with curated fallback content if the API is unavailable. On first startup the backend applies migrations and collects static files before -starting Gunicorn. Wait until all four services report healthy. +starting Gunicorn. Wait until the four default services report healthy. The optional +`certbot` service has zero replicas while `LETSENCRYPT_ENABLED=0`. + +## Optional HTTPS with Let's Encrypt + +Keep `LETSENCRYPT_ENABLED=0` when a load balancer terminates TLS. For direct public +exposure, first point the domain DNS records to the host and make TCP ports 80 and 443 +reachable, then set: + +```dotenv +LETSENCRYPT_ENABLED=1 +LETSENCRYPT_DOMAIN=azionelab.org +LETSENCRYPT_EMAIL=operator@example.org +LETSENCRYPT_STAGING=1 +NGINX_BIND_ADDRESS=0.0.0.0 +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 +WAGTAILADMIN_BASE_URL=https://azionelab.org +DJANGO_DEBUG=false +``` + +Start with the staging CA to validate DNS and firewall configuration without consuming +production rate limits. Then change `LETSENCRYPT_STAGING=0` and remove the staging +certificate volume before requesting the trusted certificate; staging certificates +cannot be converted in place. See [deployment](docs/deployment.md) for the exact reset +and production commands. ## Create an editor account @@ -99,7 +126,7 @@ working during the transition. ```bash # Follow application logs -docker compose logs -f db backend frontend proxy +docker compose logs -f db backend frontend proxy certbot # Run database migrations docker compose exec backend python manage.py migrate @@ -112,6 +139,7 @@ docker compose run --rm proxy nginx -t docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests docker compose config --quiet +LETSENCRYPT_ENABLED=1 docker compose config --quiet # Stop the stack without deleting content docker compose down @@ -128,8 +156,9 @@ local environment; run the seed command again after the next startup. ```text . ├── backend/ # Wagtail/Django CMS and API +├── certbot/ # Optional Let's Encrypt renewal loop ├── frontend/ # Astro public website -├── nginx/ # Reverse-proxy virtual host +├── nginx/ # Reverse proxy and dynamic TLS configuration ├── tests/functional/ # Playwright browser tests ├── docker-compose.test.yml # Functional-test Compose override ├── docs/ # Architecture, operations, security, and ADRs @@ -173,5 +202,6 @@ cautions. Do not commit database dumps, `.env`, or uploaded media. ## Production TODOs This repository deliberately targets a simple, working local deployment. Before a -public production launch, add TLS certificates, production-grade static/media -serving, off-host backups, monitoring, and a deployment-specific secret manager. +public production launch, configure either the optional direct TLS mode or TLS at the +load balancer, plus production-grade static/media serving, off-host backups, +monitoring, and a deployment-specific secret manager. diff --git a/backend/config/settings.py b/backend/config/settings.py index a96d22c..f112149 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -118,6 +118,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" WAGTAIL_SITE_NAME = "Azione!Lab" WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000") WAGTAIL_I18N_ENABLED = False +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 diff --git a/certbot/renew.sh b/certbot/renew.sh new file mode 100644 index 0000000..cf81ec9 --- /dev/null +++ b/certbot/renew.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +domain="${LETSENCRYPT_DOMAIN:-azionelab.org}" +email="${LETSENCRYPT_EMAIL:-}" +staging="${LETSENCRYPT_STAGING:-0}" +renew_interval="${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}" +retry_interval="${LETSENCRYPT_RETRY_SECONDS:-300}" + +case "$domain" in + "" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*) + echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2 + exit 1 + ;; +esac + +case "$domain" in + *.*) ;; + *) + echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2 + exit 1 + ;; +esac + +if [ -z "$email" ]; then + echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2 + exit 1 +fi + +case "$staging" in + 0 | 1) ;; + *) + echo "LETSENCRYPT_STAGING must be 0 or 1." >&2 + exit 1 + ;; +esac + +case "$renew_interval:$retry_interval" in + *[!0-9:]* | :* | *:) + echo "Certbot intervals must be positive integers." >&2 + exit 1 + ;; +esac + +if [ "$renew_interval" -eq 0 ] || [ "$retry_interval" -eq 0 ]; then + echo "Certbot intervals must be positive integers." >&2 + exit 1 +fi + +staging_argument="" +if [ "$staging" = "1" ]; then + staging_argument="--staging" +fi + +trap 'exit 0' TERM INT + +while :; do + if certbot certonly \ + --webroot \ + --webroot-path /var/www/certbot \ + --preferred-challenges http \ + --cert-name "$domain" \ + --domain "$domain" \ + --email "$email" \ + --agree-tos \ + --non-interactive \ + --keep-until-expiring \ + $staging_argument; then + delay="$renew_interval" + else + echo "Certificate request failed; retrying after ${retry_interval}s." >&2 + delay="$retry_interval" + fi + + sleep "$delay" & + wait $! || true +done diff --git a/docker-compose.yml b/docker-compose.yml index 854d6af..be038ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,13 +77,20 @@ services: - no-new-privileges:true proxy: - image: nginx:1.30.0-alpine + build: + context: ./nginx restart: unless-stopped init: true + environment: + LETSENCRYPT_ENABLED: ${LETSENCRYPT_ENABLED:-0} + LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org} + TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30} volumes: - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - letsencrypt_data:/etc/letsencrypt:ro + - certbot_challenges:/var/www/certbot:ro ports: - "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80" + - "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTPS_PORT:-8443}:443" depends_on: backend: condition: service_healthy @@ -93,7 +100,7 @@ services: test: [ "CMD-SHELL", - "wget -q -O /dev/null --header='Host: azionelab.org' http://127.0.0.1/health/ || exit 1", + "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1", ] interval: 10s timeout: 5s @@ -102,6 +109,36 @@ services: security_opt: - no-new-privileges:true + certbot: + image: certbot/certbot:v5.6.0 + restart: unless-stopped + init: true + read_only: true + environment: + LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org} + LETSENCRYPT_EMAIL: "${LETSENCRYPT_EMAIL:-}" + LETSENCRYPT_STAGING: ${LETSENCRYPT_STAGING:-1} + LETSENCRYPT_RENEW_INTERVAL_SECONDS: ${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200} + LETSENCRYPT_RETRY_SECONDS: ${LETSENCRYPT_RETRY_SECONDS:-300} + entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"] + volumes: + - ./certbot/renew.sh:/opt/certbot/renew.sh:ro + - letsencrypt_data:/etc/letsencrypt + - certbot_challenges:/var/www/certbot + tmpfs: + - /tmp + - /var/lib/letsencrypt + - /var/log/letsencrypt + depends_on: + proxy: + condition: service_healthy + deploy: + replicas: ${LETSENCRYPT_ENABLED:-0} + security_opt: + - no-new-privileges:true + volumes: postgres_data: media_data: + letsencrypt_data: + certbot_challenges: diff --git a/docs/adr/0002-nginx-reverse-proxy.md b/docs/adr/0002-nginx-reverse-proxy.md index 95e1c03..30b7bf1 100644 --- a/docs/adr/0002-nginx-reverse-proxy.md +++ b/docs/adr/0002-nginx-reverse-proxy.md @@ -4,6 +4,9 @@ Date: 2026-06-24 Status: Accepted +TLS aspects of this decision are extended by +[ADR-0003](0003-optional-letsencrypt.md). + ## Context The Compose stack exposes Astro and Wagtail on separate local ports. Azione!Lab needs @@ -35,8 +38,8 @@ TLS termination is outside this local implementation. share the public domain. - Replacing Astro or Django with static files served directly by NGINX was rejected as unnecessary for the requested local stack. -- Adding automatic certificates was deferred because DNS and certificate ownership are - not part of this task. +- Adding automatic certificates was initially deferred because DNS and certificate + ownership were not part of this task; ADR-0003 later adds an opt-in implementation. ## Security impact diff --git a/docs/adr/0003-optional-letsencrypt.md b/docs/adr/0003-optional-letsencrypt.md new file mode 100644 index 0000000..48a3a35 --- /dev/null +++ b/docs/adr/0003-optional-letsencrypt.md @@ -0,0 +1,71 @@ +# ADR-0003: Optional Let's Encrypt termination for direct deployments + +Date: 2026-06-24 + +Status: Accepted + +## Context + +Azione!Lab may be deployed either behind a load balancer that already terminates TLS or +directly on a public host. Running an ACME client unconditionally would duplicate edge +responsibilities in the first topology, while direct exposure still needs automated +certificate issuance and renewal. + +## Decision + +Add an opt-in Certbot service controlled by `LETSENCRYPT_ENABLED`. Docker Compose gives +that service zero replicas by default and one replica only when the value is `1`. +Certbot uses the HTTP-01 webroot method and shares separate challenge and certificate +volumes with NGINX. + +NGINX always serves the ACME challenge path over HTTP. When TLS is enabled but no +certificate exists, application traffic remains available over HTTP. An entrypoint +watcher detects certificate creation or renewal, renders the HTTPS virtual host, +validates the configuration, reloads NGINX, and redirects non-ACME HTTP traffic to +HTTPS. No Docker socket or container-control privilege is required. + +The direct-deployment operator is responsible for public DNS, inbound ports 80 and 443, +a valid contact email, and selecting the staging or production ACME endpoint. Behind a +load balancer, Certbot stays disabled and the load balancer owns certificates. + +## Consequences + +- One Compose definition supports both deployment topologies. +- Direct deployments gain automated issue and renewal without manual certificate copy. +- The first direct request can use HTTP until issuance completes; operators must not + publish sensitive workflows before TLS has been verified. +- Certificate and challenge volumes add persistent operational state. +- HTTP-01 cannot issue when port 80 or DNS is controlled by another edge. + +## Alternatives considered + +- Always running Certbot was rejected because it conflicts with load-balancer-managed + certificates and creates unnecessary ACME traffic. +- Separate Compose files per topology were rejected as avoidable configuration drift + for this small stack. +- Giving Certbot access to the Docker socket to reload NGINX was rejected because that + privilege is disproportionate; NGINX can safely watch its read-only certificate + volume. +- DNS-01 was not selected because it requires provider-specific credentials and + dependencies. It remains a future option for wildcard certificates or closed port 80. + +## Security impact + +The certificate private key is writable only by Certbot and read-only to NGINX. Certbot +uses a read-only root filesystem, temporary runtime mounts, `no-new-privileges`, and no +Docker socket. Production must restrict application ports and the proxy trust boundary; +Django accepts the secure forwarded-protocol header for load-balancer deployments. + +## Operational impact + +Operators must monitor both Certbot issue/renew logs and NGINX reload logs, protect the +certificate volume, and test DNS/firewall changes with the staging CA. Renewals are +checked every 12 hours and NGINX detects certificate changes every 30 seconds by +default. These intervals are configurable. + +## Rollback + +Set `LETSENCRYPT_ENABLED=0` and recreate the stack to stop Certbot while retaining +certificate state. Terminate TLS at a load balancer if public service must continue. +The certificate volumes can be removed only after confirming they are no longer needed; +database and media volumes are independent. diff --git a/docs/architecture.md b/docs/architecture.md index 16c43c5..a35d426 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,15 +1,21 @@ # Architecture -The system has four runtime components on the Docker Compose default network: +The system has four default runtime components and one optional component on the Docker +Compose network: 1. NGINX accepts requests for `azionelab.org` and routes them by path. 2. Astro renders the public single page and requests one aggregate JSON document. 3. Wagtail/Django manages editorial content, serves media in development, and exposes the read-only `/api/site/home/` endpoint. 4. PostgreSQL persists Wagtail content and metadata. +5. When explicitly enabled, Certbot obtains and renews a Let's Encrypt certificate by + writing HTTP-01 challenges and certificate files to shared named volumes. NGINX sends `/admin`, `/api`, `/documents`, `/health`, `/media`, and `/static` paths to Wagtail. All remaining paths go to Astro. Unknown virtual hosts receive a 404 response. +The ACME challenge path is served directly from its shared volume. If automatic TLS is +enabled, NGINX starts in HTTP mode, detects the first certificate, adds its HTTPS +virtual host, redirects application traffic to HTTPS, and reloads after renewals. The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`, `lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs @@ -23,4 +29,5 @@ Compose volume. The deployment topology and content model are recorded in [ADR-0001](adr/0001-headless-wagtail-astro.md) and -[ADR-0002](adr/0002-nginx-reverse-proxy.md). +[ADR-0002](adr/0002-nginx-reverse-proxy.md). Optional TLS termination is recorded in +[ADR-0003](adr/0003-optional-letsencrypt.md). diff --git a/docs/deployment.md b/docs/deployment.md index e081b3c..ca56c11 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -18,16 +18,17 @@ Add `127.0.0.1 azionelab.org` to the local hosts file, then use `http://azionelab.org:8080`. NGINX binds to loopback port `8080` by default and routes the domain to Astro or Wagtail. Their direct loopback ports `4321` and `8000` remain available for diagnostics. PostgreSQL is available only on the Compose network. -`postgres_data` and `media_data` are persistent named volumes. +`postgres_data` and `media_data` are persistent named volumes. Local HTTPS is disabled +by default; `letsencrypt_data` and `certbot_challenges` remain empty unless used. The stack uses explicit PostgreSQL 16.9, Python 3.12.12, Node.js 22.20, and NGINX 1.30.0 image versions. Containers are not privileged and use `no-new-privileges`. Required runtime variables are `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, `PUBLIC_CMS_API_URL`, -`NGINX_BIND_ADDRESS`, and `NGINX_HTTP_PORT`. PostgreSQL bootstrap variables are also -documented in `.env.example`. Do not use the example credentials outside local -development. +`NGINX_BIND_ADDRESS`, `NGINX_HTTP_PORT`, and `NGINX_HTTPS_PORT`. The optional certificate +variables and PostgreSQL bootstrap variables are documented in `.env.example`. Do not +use the example credentials outside local development. `WAGTAILADMIN_BASE_URL` must be browser-reachable because it is used for media URLs. `PUBLIC_CMS_API_URL` must be reachable by Astro; within Compose it is @@ -37,18 +38,58 @@ The PostgreSQL Compose service is `db`, so new `DATABASE_URL` values use `db:543 The internal `postgres` alias is retained only for compatibility with existing local `.env` files and should not be used in new configuration. -The local virtual host is HTTP-only. Production must publish NGINX through the intended -network interface, configure DNS for `azionelab.org`, terminate TLS, and prevent direct -external access to application ports. +## TLS deployment modes + +When an external load balancer terminates TLS, leave `LETSENCRYPT_ENABLED=0`. The +`certbot` service then has zero replicas and NGINX serves HTTP to the trusted internal +network. Configure the load balancer to set `X-Forwarded-Proto: https`, keep application +ports private, and restrict proxy access to the load balancer network. + +For direct exposure, the HTTP-01 challenge requires public DNS for +`LETSENCRYPT_DOMAIN` to resolve to this host and inbound TCP ports 80 and 443 to reach +NGINX. Use a real operator address and production-safe Django settings: + +```dotenv +LETSENCRYPT_ENABLED=1 +LETSENCRYPT_DOMAIN=azionelab.org +LETSENCRYPT_EMAIL=operator@example.org +LETSENCRYPT_STAGING=1 +NGINX_BIND_ADDRESS=0.0.0.0 +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 +WAGTAILADMIN_BASE_URL=https://azionelab.org +DJANGO_DEBUG=false +DJANGO_ALLOWED_HOSTS=azionelab.org +``` + +Then run `docker compose up --build -d` and inspect `docker compose logs certbot proxy`. +NGINX serves HTTP until a certificate exists, then reloads and redirects normal HTTP +requests to HTTPS. The ACME path remains available over HTTP for renewal. + +Use the Let's Encrypt staging CA first. Before switching to the production CA, stop the +stack and remove only the staging certificate volume after checking its exact Compose +project name: + +```bash +docker compose down +docker volume ls --filter label=com.docker.compose.volume=letsencrypt_data +docker volume rm PROJECT_letsencrypt_data +``` + +Set `LETSENCRYPT_STAGING=0`, restart, and verify the certificate issuer in a browser or +TLS inspection tool. Never use `docker compose down --volumes` on an environment whose +database, media, or certificates must be retained. ## Production boundary -The Compose stack is a local development deployment. A public environment still needs -TLS termination, production static/media serving, restricted admin access, managed -secrets, off-host backups, monitoring, and an explicit domain/allowed-host policy. +The Compose stack remains a minimal deployment base. A public environment still needs +production static/media serving, restricted admin access, managed secrets, off-host +backups, monitoring, firewall rules, and an explicit domain/allowed-host policy. ## Rollback -Revert the application commit and rebuild images. Keep the database and media volumes -unless content deletion is intentional. Schema rollback must be evaluated per Django -migration; take coordinated database and media backups first. +Set `LETSENCRYPT_ENABLED=0` to disable the certificate service without deleting +certificates, or terminate TLS at the load balancer. Revert the application commit and +rebuild images for a full rollback. Keep database, media, and certificate volumes unless +deletion is intentional. Schema rollback must be evaluated per Django migration; take +coordinated database and media backups first. diff --git a/docs/operations.md b/docs/operations.md index adff6c1..d6ff2a0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -23,8 +23,27 @@ Apply schema changes with `docker compose exec backend python manage.py migrate` Create editors with `createsuperuser`; use Wagtail permissions for later non-superuser accounts. -The backend applies migrations at container startup. To permanently reset local -state, use `docker compose down --volumes`; this deletes both database and media. +The backend applies migrations at container startup. To permanently reset local state, +use `docker compose down --volumes`; this deletes database, media, ACME challenges, and +certificate state. + +## Certificate operations + +With `LETSENCRYPT_ENABLED=1`, Certbot checks the certificate every 12 hours by default +and renews it when due. NGINX checks the read-only certificate volume every 30 seconds +and reloads only after its configuration validates. Adjust these intervals only for a +documented operational reason. + +```bash +docker compose ps -a certbot proxy +docker compose logs --tail=200 certbot proxy +docker compose run --rm --no-deps --entrypoint certbot certbot certificates +``` + +Certificate state is stored in `letsencrypt_data`; include it in protected host backups +if recovery must preserve the same private key. Never copy its content into the +repository or general application logs. When TLS is terminated by a load balancer, +keep the service disabled and manage certificates at that edge instead. ## Backup and restore @@ -48,4 +67,7 @@ Keep real backups encrypted and off-host with a defined retention policy. - Frontend fallback content can mask a CMS outage, so monitor backend health directly. - Local named volumes are not off-host backups. - Development media serving is unsuitable for production traffic. -- The local reverse proxy provides HTTP only; it does not manage certificates. +- Automatic issuance depends on public DNS, inbound port 80, Let's Encrypt + availability, and its rate limits. Test with the staging CA first. +- Deleting `letsencrypt_data` loses certificate account/key state and triggers a new + issuance attempt when the service is enabled again. diff --git a/docs/runbook.md b/docs/runbook.md index 3c321ba..bd3e96b 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -38,7 +38,33 @@ 4. Inspect `docker compose logs --tail=200 proxy backend frontend`. 5. Verify local DNS or `/etc/hosts` maps `azionelab.org` to the proxy address. +## Let's Encrypt does not issue a certificate + +1. Confirm `LETSENCRYPT_ENABLED=1` and that `docker compose ps -a certbot proxy` shows + both containers running and the proxy healthy. +2. Check that the domain's public A/AAAA records point to this host. Remove an AAAA + record if IPv6 does not actually reach it. +3. Verify inbound TCP port 80 reaches NGINX; HTTP-01 cannot use only port 443. +4. Request `http://azionelab.org/.well-known/acme-challenge/missing`: a 404 from NGINX + confirms the challenge route is reachable, while a timeout or another server does + not. +5. Inspect `docker compose logs --tail=200 certbot proxy` for ACME validation or rate + limit errors. Do not repeatedly retry the production CA; use staging while fixing + connectivity. + +## HTTPS is not activated after issuance + +1. Run `docker compose run --rm --no-deps --entrypoint certbot certbot certificates`. +2. Confirm `LETSENCRYPT_DOMAIN` exactly matches the certificate name used by both + services. +3. Wait for `TLS_RELOAD_INTERVAL_SECONDS`, then inspect proxy logs for `nginx -t` or + reload errors. +4. Run `docker compose exec proxy nginx -t` and check HTTPS locally with an explicit + DNS override. + ## Rollback -Revert the application commit and rebuild containers. Preserve database/media volumes. -Before reversing migrations or deleting volumes, make and validate coordinated backups. +Disable Certbot with `LETSENCRYPT_ENABLED=0` if TLS is moving to a load balancer, then +recreate the affected services. Revert the application commit and rebuild containers +for a full rollback. Preserve database, media, and certificate volumes. Before +reversing migrations or deleting volumes, make and validate coordinated backups. diff --git a/docs/security.md b/docs/security.md index 9361155..1e7e511 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,21 +11,26 @@ the application images run as non-root users. - `.env` is ignored. `.env.example` contains replaceable development placeholders, never production credentials. Use a deployment secret manager outside local use. -- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Add TLS - at the edge before public exposure. +- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Public + traffic must use either the optional direct TLS mode or TLS at a load balancer. - Database and uploaded media backups may contain personal data. Restrict, encrypt, retain, and delete them according to the operator's privacy policy. - Avoid placing personal phone numbers or private contact details in logs. The API legitimately exposes only contact details approved for publication. - Dependency and image versions are explicit. Operators remain responsible for patch upgrades, vulnerability scans, and production digest pinning. -- NGINX forwards the original host and standard client/protocol headers. The local - configuration is HTTP-only; production must add TLS and ensure application ports are - not externally reachable. +- NGINX forwards the original host and standard client/protocol headers. Django trusts + `X-Forwarded-Proto: https`; therefore direct proxy access must be limited to trusted + networks when a load balancer supplies that header. The NGINX mapping accepts only + the literal `https` value as secure. +- Optional Certbot uses a pinned image, a read-only root filesystem, no Docker socket, + and only the certificate/challenge volumes. NGINX reads private keys from the + certificate volume but cannot modify them. Restrict and back up that volume as + sensitive material. - The Playwright image is pinned and enabled only through the test Compose profile. It receives no credentials, publishes no host ports, and tests only the local portal. The override uses a separate PostgreSQL volume so its seed cannot overwrite normal CMS content. -Manual production hardening remains required for reverse proxy headers, TLS, media -storage, backup retention, monitoring, and admin network policy. +Manual production hardening remains required for proxy trust boundaries, media +storage, backup retention, monitoring, firewalling, and admin network policy. diff --git a/docs/testing.md b/docs/testing.md index b4081f9..6628ea8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,9 +9,11 @@ docker compose run --rm backend python manage.py test docker compose run --rm frontend npm run check docker compose run --rm frontend npm run build docker compose run --rm proxy nginx -t +docker compose run --rm --no-deps --entrypoint certbot certbot --version docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests docker compose config --quiet +LETSENCRYPT_ENABLED=1 docker compose config --quiet ``` ## Test categories @@ -22,6 +24,7 @@ The test suite covers: - Astro static type and template validation; - production frontend build; - NGINX syntax and upstream configuration validation; +- Certbot image availability/version and optional Compose service rendering; - Playwright functional browser tests through the NGINX virtual host; - Docker Compose configuration validation. diff --git a/nginx/40-configure-tls.sh b/nginx/40-configure-tls.sh new file mode 100644 index 0000000..adba892 --- /dev/null +++ b/nginx/40-configure-tls.sh @@ -0,0 +1,106 @@ +#!/bin/sh +set -eu + +enabled="${LETSENCRYPT_ENABLED:-0}" +domain="${LETSENCRYPT_DOMAIN:-azionelab.org}" +interval="${TLS_RELOAD_INTERVAL_SECONDS:-30}" +certificate="/etc/letsencrypt/live/${domain}/fullchain.pem" +private_key="/etc/letsencrypt/live/${domain}/privkey.pem" +http_config="/etc/nginx/http-enabled/site.conf" +tls_config="/etc/nginx/tls-enabled/site.conf" +tls_template="/etc/nginx/templates/tls.conf.template.source" +base_template="/etc/nginx/templates/default.conf.template.source" +base_config="/etc/nginx/conf.d/default.conf" +proxy_routes="/etc/nginx/snippets/proxy-routes.conf" + +case "$domain" in + "" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*) + echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2 + exit 1 + ;; +esac + +case "$domain" in + *.*) ;; + *) + echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2 + exit 1 + ;; +esac + +case "$enabled" in + 0 | 1) ;; + *) + echo "LETSENCRYPT_ENABLED must be 0 or 1." >&2 + exit 1 + ;; +esac + +case "$interval" in + "" | *[!0-9]*) + echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2 + exit 1 + ;; +esac + +if [ "$interval" -eq 0 ]; then + echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2 + exit 1 +fi + +certificate_state() { + if [ ! -s "$certificate" ] || [ ! -s "$private_key" ]; then + echo "missing" + return + fi + + cksum "$certificate" "$private_key" | cksum | awk '{print $1 ":" $2}' +} + +render_http_only() { + printf 'include %s;\n' "$proxy_routes" > "$http_config" + rm -f "$tls_config" +} + +render_https() { + sed "s|__DOMAIN__|${domain}|g" "$tls_template" > "${tls_config}.tmp" + mv "${tls_config}.tmp" "$tls_config" + printf 'location ^~ / { return 301 https://$host$request_uri; }\n' > "$http_config" +} + +sed "s|__DOMAIN__|${domain}|g" "$base_template" > "${base_config}.tmp" +mv "${base_config}.tmp" "$base_config" + +if [ "$enabled" != "1" ]; then + render_http_only + exit 0 +fi + +state="$(certificate_state)" +if [ "$state" = "missing" ]; then + render_http_only +else + render_https +fi + +( + while sleep "$interval"; do + next_state="$(certificate_state)" + if [ "$next_state" = "$state" ]; then + continue + fi + + if [ "$next_state" = "missing" ]; then + render_http_only + else + render_https + fi + + if nginx -t; then + nginx -s reload + state="$next_state" + else + echo "TLS configuration reload failed; retrying after ${interval}s." >&2 + fi + done +) & diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..adff938 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,10 @@ +FROM nginx:1.30.0-alpine + +COPY default.conf /etc/nginx/templates/default.conf.template.source +COPY proxy-routes.conf /etc/nginx/snippets/proxy-routes.conf +COPY tls.conf.template /etc/nginx/templates/tls.conf.template.source +COPY 40-configure-tls.sh /docker-entrypoint.d/40-configure-tls.sh + +RUN chmod 755 /docker-entrypoint.d/40-configure-tls.sh \ + && rm -f /etc/nginx/conf.d/default.conf \ + && mkdir -p /etc/nginx/http-enabled /etc/nginx/tls-enabled /var/www/certbot diff --git a/nginx/default.conf b/nginx/default.conf index b5d9d2c..781f9fa 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -3,6 +3,11 @@ map $http_upgrade $connection_upgrade { "" close; } +map $http_x_forwarded_proto $effective_forwarded_proto { + default $scheme; + https https; +} + server_tokens off; upstream wagtail_backend { @@ -19,31 +24,27 @@ server { listen 80 default_server; server_name _; - return 404; + location = /nginx-health { + access_log off; + return 200 "ok\n"; + } + + location / { + return 404; + } } server { listen 80; - server_name azionelab.org; + server_name __DOMAIN__; - client_max_body_size 10m; - - proxy_http_version 1.1; - 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 $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - - location ~ ^/(admin|api|documents|health|media|static)(/|$) { - proxy_pass http://wagtail_backend; + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type text/plain; + try_files $uri =404; } - location / { - proxy_pass http://astro_frontend; - } + include /etc/nginx/http-enabled/site.conf; } + +include /etc/nginx/tls-enabled/*.conf; diff --git a/nginx/proxy-routes.conf b/nginx/proxy-routes.conf new file mode 100644 index 0000000..cd1461a --- /dev/null +++ b/nginx/proxy-routes.conf @@ -0,0 +1,20 @@ +client_max_body_size 10m; + +proxy_http_version 1.1; +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 $effective_forwarded_proto; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $connection_upgrade; + +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; + +location ~ ^/(admin|api|documents|health|media|static)(/|$) { + proxy_pass http://wagtail_backend; +} + +location / { + proxy_pass http://astro_frontend; +} diff --git a/nginx/tls.conf.template b/nginx/tls.conf.template new file mode 100644 index 0000000..18ea713 --- /dev/null +++ b/nginx/tls.conf.template @@ -0,0 +1,26 @@ +server { + listen 443 ssl default_server; + http2 on; + server_name _; + + ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem; + ssl_reject_handshake on; +} + +server { + listen 443 ssl; + http2 on; + server_name __DOMAIN__; + + ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=15552000" always; + + include /etc/nginx/snippets/proxy-routes.conf; +} diff --git a/tests/functional/tests/portal.spec.ts b/tests/functional/tests/portal.spec.ts index 6722ada..36a96a0 100644 --- a/tests/functional/tests/portal.spec.ts +++ b/tests/functional/tests/portal.spec.ts @@ -152,4 +152,9 @@ test("routes Wagtail admin and rejects unknown virtual hosts", async ({ const invalidHostResponse = await invalidHostContext.get("/"); expect(invalidHostResponse.status()).toBe(404); await invalidHostContext.dispose(); + + const missingChallengeResponse = await page.request.get( + "/.well-known/acme-challenge/not-issued", + ); + expect(missingChallengeResponse.status()).toBe(404); });