diff --git a/.env.example b/.env.example index 283d73b..bc171bd 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,12 @@ +# --- Container images --- +# Published to GHCR by .github/workflows/release.yml, multi-architecture +# (linux/amd64 and linux/arm64). The deploy host pulls these instead of +# compiling: `docker compose pull && docker compose up -d`. Running +# `docker compose build` locally still overrides them with a local build. +# Pin IMAGE_TAG to a released version (e.g. 1.2.3) or a sha- tag to roll back. +IMAGE_NAMESPACE=ghcr.io/lucasrguerra/lambdamail +IMAGE_TAG=latest + # --- Postgres --- POSTGRES_DB=lambdamail POSTGRES_USER=lambdamail diff --git a/config/rspamd/ratelimit.conf b/config/rspamd/ratelimit.conf new file mode 100644 index 0000000..c158782 --- /dev/null +++ b/config/rspamd/ratelimit.conf @@ -0,0 +1,41 @@ +# Rate limiting of PLAN.md section 5.2. +# +# This file exists because the module ships enabled but with no rates defined, +# which means it accepts everything: "rspamadm configdump ratelimit" on a +# stock image returns only whitelisted_rcpts. Without explicit buckets there +# is no limit at all, and the defence reads as present while doing nothing. +# +# Each rate is " / ". The bucket leaks over the period, so the +# burst is what a sender may do at once and the period sets the refill rate. + +rates { + # Per connecting IP: generous enough for a busy legitimate MTA relaying a + # mailing list, tight enough to matter for a spam cannon. + to_ip = { + bucket = [ + { burst = 100; rate = "60 / 1min"; } + ]; + } + + # Per authenticated sender. This is the containment for a compromised + # account, and it backs up the per-mailbox max_recipients_per_hour enforced + # in ProcessOutboundEmailUseCase - that one is authoritative, this one + # catches the same abuse earlier and without a database round trip. + user = { + bucket = [ + { burst = 50; rate = "30 / 1min"; } + ]; + } + + # Per recipient, to blunt a dictionary attack enumerating local parts. + to = { + bucket = [ + { burst = 20; rate = "10 / 1min"; } + ]; + } +} + +# Mail we authenticated ourselves is not what this limit is aimed at, but a +# compromised account is - so authenticated users stay limited, and only the +# addresses RFC 2142 requires to always be reachable are exempt. +whitelisted_rcpts = "postmaster,mailer-daemon,abuse"; diff --git a/docker-compose.yaml b/docker-compose.yaml index 06f50c4..b173c22 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -71,15 +71,28 @@ services: # ------------------------------------------------------------ RSPAMD rspamd: - image: ghcr.io/rspamd/rspamd:3.9 + image: rspamd/rspamd:3.9 restart: *restart logging: *default-logging environment: - RSPAMD_REDIS_SERVERS: redis - RSPAMD_REDIS_PASSWORD: ${REDIS_PASSWORD} + REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: - ./config/rspamd:/etc/rspamd/local.d:ro - rspamd_data:/var/lib/rspamd + # Rspamd does NOT expand environment variables in its UCL config - a + # "${REDIS_PASSWORD}" written into a .conf reaches Redis as that literal + # string. The credential is therefore rendered at startup into override.d, + # which is a tmpfs so the secret never touches the image or a volume. + # Without this, greylisting and ratelimit silently do nothing: both keep + # their state in Redis. + tmpfs: + - /etc/rspamd/override.d:uid=11333,gid=11333,mode=0700 + entrypoint: + - sh + - -c + - >- + printf 'servers = "redis:6379";\npassword = "%s";\n' "$$REDIS_PASSWORD" + > /etc/rspamd/override.d/redis.conf && exec /usr/bin/rspamd -f depends_on: redis: { condition: service_healthy } healthcheck: @@ -91,7 +104,7 @@ services: # ------------------------------------------------------------ CLAMAV clamav: - image: clamav/clamav:1.3 + image: clamav/clamav:1.4 restart: *restart logging: *default-logging volumes: [clamav_data:/var/lib/clamav] @@ -105,6 +118,7 @@ services: # -------------------------------------------------------------- AUTH auth: + image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/auth:${IMAGE_TAG:-latest} build: { context: ./services/auth } restart: *restart logging: *default-logging @@ -134,6 +148,7 @@ services: # ----------------------------------------------------------- STORAGE storage: + image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/storage:${IMAGE_TAG:-latest} build: { context: ./services/storage } restart: *restart logging: *default-logging @@ -163,6 +178,7 @@ services: # --------------------------------------------------------- PROTOCOLS protocols: + image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/protocols:${IMAGE_TAG:-latest} build: { context: ./services/protocols } restart: *restart logging: *default-logging @@ -238,6 +254,7 @@ services: # -------------------------------------------------------------- WEB web: + image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/webmail:${IMAGE_TAG:-latest} build: { context: ./services/webmail } restart: *restart logging: *default-logging