diff --git a/README.md b/README.md index a77688a..535f593 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ Docker Compose configuration for self-hosting Shroud.email. Please read our [deployment documentation](https://shroud.email/docs/deployment/self-host) on our website. -If you just want to get up and running with Shroud.email quickly, you can sign up for our hosted version [here](https://app.shroud.email/users/register). +If you want to get up and running with Shroud.email quickly, and don't want to maintain your own mailserver, you can sign up for our hosted version [here](https://app.shroud.email/users/register). ## Living on the edge -The committed `docker-compose.yaml` tracks the stable `:1` image. If you'd rather +The committed `docker-compose.yaml` tracks the stable image. If you'd rather run the latest `:edge` build (rebuilt on every push to `main`) and have it auto-update, copy the example override and bring the stack up: @@ -19,4 +19,36 @@ docker compose up -d This points the `web` service at `:edge` and adds [Watchtower](https://containrrr.dev/watchtower/), which polls every 5 minutes and auto-recreates `web` (and only `web`) when a new -image is published. \ No newline at end of file +image is published. + +## Cap CAPTCHA + +The compose file includes a [Cap](https://trycap.dev) self-hosted CAPTCHA +instance. It is **opt-in at the application level**: the +services run by default, but the widget is not rendered and verification +is not performed until you set all three `CAP_*` variables on the `web` +service. + +> **Public ingress required.** `CAP_INSTANCE_URL` must be a URL a user's +> browser can reach over HTTPS. + +### Setup + +1. Generate an admin key and set `CAP_ADMIN_KEY` in `.env`: + ```bash + openssl rand -hex 32 + ``` + +2. Start the services: + ```bash + docker compose up -d cap valkey + ``` + +3. Create a site key. Cap authenticates with a + session token issued by logging in with the `ADMIN_KEY. Create a `siteKey` and `secretKey` in the Cap UI. + +4. Set `CAP_INSTANCE_URL`, `CAP_SITE_KEY`, and `CAP_SECRET_KEY` in `.env`, then + restart `web`: + ```bash + docker compose restart web + ``` diff --git a/cron/Dockerfile b/cron/Dockerfile index 2c0a5db..cfa6a26 100644 --- a/cron/Dockerfile +++ b/cron/Dockerfile @@ -5,11 +5,11 @@ RUN apk update && \ WORKDIR /workdir -COPY lets-encrypt-r4.pem /workdir/lets-encrypt-r4.pem -COPY bundle_certs.sh /etc/periodic/daily/bundle_certs - -RUN chmod +x /etc/periodic/daily/bundle_certs +COPY bundle_certs.sh /usr/local/bin/bundle_certs +RUN chmod +x /usr/local/bin/bundle_certs VOLUME /pem -CMD ["crond", "-f", "-l", "0"] +# Run bundle once at boot (populate pem_certs before Haraka's first STARTTLS), +# then keep the daily crond for renewals. Caddy's fullchain is copied verbatim. +CMD ["sh", "-c", "bundle_certs; exec crond -f -l 0"] diff --git a/cron/bundle_certs.sh b/cron/bundle_certs.sh index 02e3cc8..917d72c 100644 --- a/cron/bundle_certs.sh +++ b/cron/bundle_certs.sh @@ -3,9 +3,18 @@ set -e if [ -z "$EMAIL_DOMAIN" ]; then echo "EMAIL_DOMAIN is not set"; exit 1; fi -cd /workdir +CERT_DIR="/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$EMAIL_DOMAIN" +LEAF="$CERT_DIR/${EMAIL_DOMAIN}.crt" +KEY="$CERT_DIR/${EMAIL_DOMAIN}.key" + +if [ ! -s "$LEAF" ] || [ ! -s "$KEY" ]; then + echo "Caddy cert not ready yet ($LEAF); skipping" + exit 0 +fi + echo "Copying Caddy certs to Haraka..." -cd "/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$EMAIL_DOMAIN" -cp "${EMAIL_DOMAIN}.key" /pem/tls_key.pem -cat "${EMAIL_DOMAIN}.crt" /workdir/lets-encrypt-r4.pem > /pem/tls_cert.pem -echo "Copied Caddy certs to Haraka." \ No newline at end of file +# Caddy's {domain}.crt is already the full chain (leaf + intermediates). +# Copy it verbatim — appending a separate intermediate would duplicate/break the chain. +cp "$KEY" /pem/tls_key.pem +cp "$LEAF" /pem/tls_cert.pem +echo "Copied Caddy certs to Haraka." diff --git a/cron/lets-encrypt-r4.pem b/cron/lets-encrypt-r4.pem deleted file mode 100644 index 578b3bd..0000000 --- a/cron/lets-encrypt-r4.pem +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFFjCCAv6gAwIBAgIRAIp5IlCr5SxSbO7Pf8lC3WIwDQYJKoZIhvcNAQELBQAw -TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh -cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw -WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg -RW5jcnlwdDELMAkGA1UEAxMCUjQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK -AoIBAQCzKNx3KdPnkb7ztwoAx/vyVQslImNTNq/pCCDfDa8oPs3Gq1e2naQlGaXS -Mm1Jpgi5xy+hm5PFIEBrhDEgoo4wYCVg79kaiT8faXGy2uo/c0HEkG9m/X2eWNh3 -z81ZdUTJoQp7nz8bDjpmb7Z1z4vLr53AcMX/0oIKr13N4uichZSk5gA16H5OOYHH -IYlgd+odlvKLg3tHxG0ywFJ+Ix5FtXHuo+8XwgOpk4nd9Z/buvHa4H6Xh3GBHhqC -VuQ+fBiiCOUWX6j6qOBIUU0YFKAMo+W2yrO1VRJrcsdafzuM+efZ0Y4STTMzAyrx -E+FCPMIuWWAubeAHRzNl39Jnyk2FAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC -AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB -Af8CAQAwHQYDVR0OBBYEFDadPuCxQPYnLHy/jZ0xivZUpkYmMB8GA1UdIwQYMBaA -FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw -AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw -Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB -gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCJbu5CalWO+H+Az0lmIG14DXmlYHQE -k26umjuCyioWs2icOlZznPTcZvbfq02YPHGTCu3ctggVDULJ+fwOxKekzIqeyLNk -p8dyFwSAr23DYBIVeXDpxHhShvv0MLJzqqDFBTHYe1X5X2Y7oogy+UDJxV2N24/g -Z8lxG4Vr2/VEfUOrw4Tosl5Z+1uzOdvTyBcxD/E5rGgTLczmulctHy3IMTmdTFr0 -FnU0/HMQoquWQuODhFqzMqNcsdbjANUBwOEQrKI8Sy6+b84kHP7PtO+S4Ik8R2k7 -ZeMlE1JmxBi/PZU860YlwT8/qOYToCHVyDjhv8qutbf2QnUl3SV86th2I1QQE14s -0y7CdAHcHkw3sAEeYGkwCA74MO+VFtnYbf9B2JBOhyyWb5087rGzitu5MTAW41X9 -DwTeXEg+a24tAeht+Y1MionHUwa4j7FB/trN3Fnb/r90+4P66ZETVIEcjseUSMHO -w6yqv10/H/dw/8r2EDUincBBX3o9DL3SadqragkKy96HtMiLcqMMGAPm0gti1b6f -bnvOdr0mrIVIKX5nzOeGZORaYLoSD4C8qvFT7U+Um6DMo36cVDNsPmkF575/s3C2 -CxGiCPQqVxPgfNSh+2CPd2Xv04lNeuw6gG89DlOhHuoFKRlmPnom+gwqhz3ZXMfz -TfmvjrBokzCICA== ------END CERTIFICATE----- diff --git a/docker-compose.yaml b/docker-compose.yaml index ce4bb65..3286c48 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -43,6 +43,23 @@ services: - ./haraka/haraka_config:/app/haraka_config - pem_certs:/app/haraka_config/config/certs + cap: + image: tiago2/cap:3 + restart: unless-stopped + depends_on: + - valkey + environment: + - ADMIN_KEY=${CAP_ADMIN_KEY} + - REDIS_URL=redis://valkey:6379 + - CORS_ORIGIN=https://${APP_DOMAIN} + + valkey: + image: valkey/valkey:9-alpine + restart: unless-stopped + command: valkey-server --save 60 1 --loglevel warning --maxmemory 256mb --maxmemory-policy noeviction + volumes: + - valkey_data:/data + web: image: ghcr.io/shroud-email/shroud.email:1 restart: unless-stopped @@ -74,6 +91,9 @@ services: - S3_HOST=${S3_HOST} - LOOPS_API_KEY=${LOOPS_API_KEY} - LOOPS_ACTIVE_USERS_LIST_ID=${LOOPS_ACTIVE_USERS_LIST_ID} + - CAP_INSTANCE_URL=${CAP_INSTANCE_URL} + - CAP_SITE_KEY=${CAP_SITE_KEY} + - CAP_SECRET_KEY=${CAP_SECRET_KEY} caddy: image: ghcr.io/shroud-email/caddy-permissive-file-storage:main @@ -102,3 +122,4 @@ volumes: db_data: caddy_data: pem_certs: + valkey_data: diff --git a/example.env b/example.env index f4c06fc..bb8b24e 100644 --- a/example.env +++ b/example.env @@ -34,3 +34,29 @@ S3_HOST=replace DB_USER=postgres DB_DATABASE=shroud + +## Cap CAPTCHA (optional but included in the default compose). +## Set all three to enable Cap on the signup/login/reset forms. +## CAP_ADMIN_KEY: dashboard password. Generate with: openssl rand -hex 32 +CAP_ADMIN_KEY= +## CAP_INSTANCE_URL: the PUBLIC, browser-reachable HTTPS URL of your Cap +## instance. The widget renders this into data-cap-api-endpoint, so a user's +## browser must be able to reach it over HTTPS (http:// will be blocked as +## mixed content on your https://APP_DOMAIN pages). Cap is internal-only in +## this compose (no host port mapping), so point this at whatever public +## ingress fronts the `cap` service, e.g. https://cap.yourdomain.com or +## https://yourdomain.com/cap/. Leave unset (with the other two) to disable. +CAP_INSTANCE_URL= +## Create a site key (rsw + instrumentation) by logging in with ADMIN_KEY +## first (Cap's Bot scheme is for API keys, not the admin key): +## RESP=$(curl -s -X POST http://localhost:3000/auth/login \ +## -H "Content-Type: application/json" \ +## -d "{\"admin_key\":\"$CAP_ADMIN_KEY\"}") +## BEARER=$(printf '{"token":"%s","hash":"%s"}' \ +## $(echo "$RESP" | jq -r .session_token) \ +## $(echo "$RESP" | jq -r .hashed_token) | base64 -w0) +## curl -X POST http://localhost:3000/server/keys \ +## -H "Authorization: Bearer $BEARER" \ +## -d '{"name":"shroud-email","instrumentation":true,"rsw":true}' +CAP_SITE_KEY= +CAP_SECRET_KEY= diff --git a/haraka/haraka_config/config/tls.ini b/haraka/haraka_config/config/tls.ini index 15041fb..48b41d7 100644 --- a/haraka/haraka_config/config/tls.ini +++ b/haraka/haraka_config/config/tls.ini @@ -1,2 +1,7 @@ key=certs/tls_key.pem cert=certs/tls_cert.pem + +; Disable client-initiated TLS renegotiation (DoS via repeated handshakes). +; Value is crypto.constants.SSL_OP_NO_RENEGOTIATION (0x40000000), passed +; through to tls.createSecureContext as secureOptions by Haraka's tls_socket. +secureOptions=1073741824