From 71758786cb93d5c2e4ac20841cf1ce84dc0e885f Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Sun, 16 Aug 2026 09:24:21 +0100 Subject: [PATCH] updated app data storage and AIO docker setup --- docker-deploy/.env.example => .env.example | 5 +- Dockerfile.aio | 79 +++++++++++++ README.md | 48 ++++---- docker-compose.yml | 33 ++++++ docker-deploy/Dockerfile | 37 +++++-- docker-deploy/Dockerfile.rspamd | 29 ++++- docker-deploy/README.md | 122 +++++++++++++++------ docker-deploy/docker-compose.yml | 49 ++++++--- docker-deploy/entrypoint-rspamd.sh | 20 +++- docker-deploy/optional-AIO-Docker.md | 18 +++ entrypoint-aio.sh | 43 ++++++++ internal/config/config.go | 4 +- internal/webui/letsencrypt.go | 2 +- internal/webui/settings.go | 4 +- internal/webui/webui.go | 14 ++- internal/webui/webui_test.go | 2 +- main.go | 15 ++- 17 files changed, 418 insertions(+), 106 deletions(-) rename docker-deploy/.env.example => .env.example (52%) create mode 100644 Dockerfile.aio create mode 100644 docker-compose.yml create mode 100644 docker-deploy/optional-AIO-Docker.md create mode 100644 entrypoint-aio.sh diff --git a/docker-deploy/.env.example b/.env.example similarity index 52% rename from docker-deploy/.env.example rename to .env.example index 3e4eb03..d5d5ee0 100644 --- a/docker-deploy/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Copy to .env (in this docker-deploy/ folder) and adjust if the defaults below clash -# with something else on the host, or if you want to run both the "standalone" and -# "with-rspamd" profiles side by side (give each its own set of host ports). +# with something else on the host, or if you want to run more than one of the +# "standalone" / "with-rspamd" / "all-in-one" profiles side by side (give each its own +# set of host ports). SMTP_PORT=25 SMTP_TLS_PORT=465 IMAP_PORT=143 diff --git a/Dockerfile.aio b/Dockerfile.aio new file mode 100644 index 0000000..b2000d9 --- /dev/null +++ b/Dockerfile.aio @@ -0,0 +1,79 @@ +# syntax=docker/dockerfile:1 +# +# All-in-one: mailgoserver + rspamd + redis, bundled in one container — the full spam- +# filtering stack, not just rspamd's SPF/DKIM/RBL/regexp scoring. Redis backs rspamd's +# Bayes classifier (learns from mail marked as spam/ham) and its greylisting module, +# neither of which work without it (see docker-deploy/README.md's original note on +# Dockerfile.rspamd, which intentionally skips redis for a lighter image — use that one +# instead if you don't want Bayes/greylisting). mailgoserver itself has no direct use +# for redis — it's a single-instance app already backed by SQLite for everything, so +# there's nothing here for redis to cache or coordinate; it exists purely to make +# rspamd's scoring meaningfully better. +# +# Build from the repo root: +# docker build -f docker-deploy/Dockerfile.aio -t mailgoserver-aio . + +FROM golang:1.26-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mailgoserver . + +FROM debian:bookworm-slim +# rspamd from its own APT repo (rspamd.com), same as Dockerfile.rspamd, for the latest +# stable release rather than Debian's older bundled version. redis-server is Debian's +# own package — no third-party repo needed there, and bookworm's version (7.0.x) is +# recent enough for everything rspamd's Bayes/greylist modules need. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates tzdata curl gnupg lsb-release libcap2-bin redis-server \ + && mkdir -p /usr/share/keyrings \ + && curl -fsSL https://rspamd.com/apt-stable/gpg.key | gpg --dearmor -o /usr/share/keyrings/rspamd.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/rspamd.gpg] https://rspamd.com/apt-stable/ $(lsb_release -cs) main" \ + > /etc/apt/sources.list.d/rspamd.list \ + && apt-get update && apt-get install -y --no-install-recommends rspamd \ + && rm -rf /var/lib/apt/lists/* + +# Point rspamd's Bayes classifier and greylisting module at the redis instance this same +# container runs (loopback-only, see entrypoint-aio.sh) — both are otherwise inert +# without a redis backend. Every other redis-capable module (ratelimit, etc.) picks up +# the same servers = ... from this one redis.conf too, rspamd's own convention for +# sharing one connection config across modules. Also redirects rspamd's own dbdir (fuzzy +# storage, DNS/maps cache) into server_data/rspamd — created/chowned at container start +# by entrypoint-aio.sh, since it lives on the volume, not the image — so this whole +# bundle only ever needs one thing mounted: server_data/. +RUN mkdir -p /etc/rspamd/local.d \ + && printf 'servers = "127.0.0.1:6379";\n' > /etc/rspamd/local.d/redis.conf \ + && printf 'backend = "redis";\nservers = "127.0.0.1:6379";\n' > /etc/rspamd/local.d/classifier-bayes.conf \ + && printf 'enabled = true;\nservers = "127.0.0.1:6379";\n' > /etc/rspamd/local.d/greylist.conf \ + && printf 'dbdir = "/app/server_data/rspamd";\n' > /etc/rspamd/local.d/options.inc + +COPY --from=build /out/mailgoserver /usr/local/bin/mailgoserver +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/mailgoserver +COPY docker-deploy/entrypoint-aio.sh /usr/local/bin/entrypoint-aio.sh +RUN chmod +x /usr/local/bin/entrypoint-aio.sh + +# rspamd and redis's own .deb postinsts already create their system users; +# mailgoserver gets one here for the same reason as the other images — none of the +# three processes run as root (see entrypoint-aio.sh for how each is dropped). +RUN useradd --system --create-home --home-dir /app --shell /usr/sbin/nologin mailgoserver \ + && mkdir -p /app/server_data \ + && chown -R mailgoserver:mailgoserver /app \ + && chmod 755 /app +WORKDIR /app +VOLUME ["/app/server_data"] +# Deliberately stays root here (unlike the standalone Dockerfile's USER mailgoserver) — +# the entrypoint script itself needs root just long enough to chown server_data's +# rspamd/redis subdirectories and drop privileges for each child individually. + +# 11334 (rspamd's own web UI, controller worker) is intentionally NOT exposed here — +# it has no authentication configured by default, and this bundle doesn't need it for +# anything mailgoserver itself uses. Add a `password` to rspamd's controller worker +# config and publish it yourself if you want it. Port 6379 (redis) is never exposed at +# all — loopback-only, see entrypoint-aio.sh. +EXPOSE 25 465 143 993 80 5000 5001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fs http://127.0.0.1:5000/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint-aio.sh"] diff --git a/README.md b/README.md index 457a4f2..0c1253f 100644 --- a/README.md +++ b/README.md @@ -59,21 +59,24 @@ the binary itself has no runtime dependencies (pure-Go SQLite driver, no cgo). ## What to expect on first run There's no config to write up front. The first time it starts in a given working -directory, it generates: +directory, it generates everything under `server_data/` — one directory to back up or +volume-mount and every persistent thing this app owns is in it: -- `settings.ini` — every setting with an inline comment explaining it (SMTP/IMAP ports, - hostname, TLS, DKIM key size, mailbox quotas, MFA enforcement, rate limits, and so - on). Regenerated only if missing — it's never overwritten or merged into on later - runs, so edits stick. -- A self-signed TLS certificate (used until you either supply your own or enable Let's - Encrypt from the admin dashboard). -- An empty SQLite database, with one seeded admin account: username `admin`, password - `Password123!`. Logging in with it **immediately forces** a username + password - change before anything else in the dashboard is reachable — the default credentials - can never be left in place. -- A mailstore master key and a CSRF-signing app secret, both generated once and reused - on every subsequent start — back these up like any other secret (losing the mailstore - master key makes all stored mail unrecoverable, even for admins). +- `server_data/settings.ini` — every setting with an inline comment explaining it + (SMTP/IMAP ports, hostname, TLS, DKIM key size, mailbox quotas, MFA enforcement, rate + limits, and so on). Regenerated only if missing — it's never overwritten or merged + into on later runs, so edits stick. Override the path with `-config` if you want it + somewhere else. +- A self-signed TLS certificate under `server_data/ssl_certs/` (used until you either + supply your own or enable Let's Encrypt from the admin dashboard). +- `server_data/smtp_server.db` — an empty SQLite database, with one seeded admin + account: username `admin`, password `Password123!`. Logging in with it **immediately + forces** a username + password change before anything else in the dashboard is + reachable — the default credentials can never be left in place. +- A mailstore master key and a CSRF-signing app secret (also under `server_data/`), + both generated once and reused on every subsequent start — back these up like any + other secret (losing the mailstore master key makes all stored mail unrecoverable, + even for admins). From there: log into `/smtp-server`, add a domain (and complete its DNS ownership verification), add a mailbox or sender, and you're sending/receiving. The bare `/` root @@ -136,13 +139,19 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE PrivateTmp=true ProtectSystem=strict -ReadWritePaths=/opt/mailgoserver +# Everything this process ever writes lives under server_data/ (settings.ini, the DB, +# mailstore, TLS certs, keys) — narrower than the whole install directory. +ReadWritePaths=/opt/mailgoserver/server_data ProtectHome=true [Install] WantedBy=multi-user.target ``` +(Widen `ReadWritePaths` if you've pointed `attachments_path`, `TLS_CERT_FILE`/ +`TLS_KEY_FILE`, or `-config` itself at somewhere outside `server_data/` — everything +else this process writes stays inside it by default.) + If you do want the SMTP and web parts as separate services (matching the Python split exactly), run two units with `--smtp-only` and `--web-only` respectively — both flags exist for this. @@ -152,13 +161,14 @@ both flags exist for this. No changes needed. `script_nginx_setup.sh` reverse-proxies to `http://127.0.0.1:5000` and terminates its own TLS for the web UI — mailgoserver listens on the same host:port by default, so the existing nginx config works unmodified. The SMTP TLS listener still -consumes `ssl_certs/server.crt`/`server.key`, same as before. +consumes `server_data/ssl_certs/server.crt`/`server.key`, same as before. ## Docker -See [`docker-deploy/`](docker-deploy/) — a standalone image and one that bundles the -latest rspamd in the same container, both via a single `docker-compose.yml` using -Compose profiles. +See [`docker-deploy/`](docker-deploy/) — a standalone image, one that bundles the +latest rspamd in the same container, and an all-in-one image that adds redis too (for +rspamd's Bayes learning and greylisting), all via a single `docker-compose.yml` using +Compose profiles. None of the three run as root. ## Certificates diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2b92b2c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + mailserver-aio: + build: + context: .. + dockerfile: Dockerfile.aio + container_name: mailgoserver + hostname: mailgoserver + restart: unless-stopped + networks: + backend: + ports: + - "${SMTP_PORT:-25}:25" + - "${SMTP_TLS_PORT:-465}:465" + - "${IMAP_PORT:-143}:143" + - "${IMAP_TLS_PORT:-993}:993" + - "${ACME_HTTP_PORT:-80}:80" + - "${WEB_HTTP_PORT:-5000}:5000" + - "${WEB_HTTPS_PORT:-5001}:5001" + volumes: + - mailserver-aio-data:/app/server_data + +volumes: + mailserver-aio-data: + driver: local + driver_opts: + o: bind + type: none + device: /opt/settings/mailgoserver/data + +# This is external network what I use, you can use your own or remove it. +networks: + backend: + external: true \ No newline at end of file diff --git a/docker-deploy/Dockerfile b/docker-deploy/Dockerfile index b8c5b3c..56fa361 100644 --- a/docker-deploy/Dockerfile +++ b/docker-deploy/Dockerfile @@ -3,7 +3,8 @@ # Standalone mailgoserver image — SMTP + IMAP + admin dashboard + webmail, no rspamd. # The built-in heuristic spam score (internal/mailstore/spam.go) always runs regardless; # this is for anyone who doesn't want rspamd's extra dependency. See Dockerfile.rspamd -# for the bundled variant. +# (mailgoserver + rspamd) and Dockerfile.aio (mailgoserver + rspamd + redis, full spam +# stack) for the bundled variants. # # Build from the repo root: # docker build -f docker-deploy/Dockerfile -t mailgoserver . @@ -22,24 +23,36 @@ FROM debian:bookworm-slim # tzdata: [Server] time_zone (e.g. "Europe/London") needs the real IANA zone # database — time.LoadLocation silently falls back to UTC without it. # curl: used by the HEALTHCHECK below. +# libcap2-bin: provides setcap, used below so the binary can bind ports 25/465/80 +# without running the process itself as root. RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates tzdata curl \ + ca-certificates tzdata curl libcap2-bin \ && rm -rf /var/lib/apt/lists/* COPY --from=build /out/mailgoserver /usr/local/bin/mailgoserver +# CAP_NET_BIND_SERVICE is one of Docker's default capabilities (no --cap-add needed at +# `docker run`/compose time) — granting it to the binary itself, rather than running the +# whole container as root, means a bug in mailgoserver can't do anything a root process +# could that an unprivileged one couldn't, beyond binding these specific low ports. +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/mailgoserver -# Everything persistent — the auto-generated settings.ini, the SQLite DB, encrypted -# mailbox storage, DKIM/mailstore master keys, TLS certs, the app secret — lives under -# whatever directory the process is started from (see config.Load / main.go's -# os.Getwd()). One volume here covers all of it; no need to enumerate subpaths. -WORKDIR /app/data -VOLUME ["/app/data"] +# Everything persistent — settings.ini, the SQLite DB, encrypted mailbox storage, +# DKIM/mailstore master keys, TLS certs, the app secret — lives under server_data/, +# relative to wherever the process is started from (see config.Load / main.go's +# os.Getwd()). One volume covers all of it; no need to enumerate subpaths, and nothing +# persistent is ever written outside it. +RUN useradd --system --create-home --home-dir /app --shell /usr/sbin/nologin mailgoserver \ + && mkdir -p /app/server_data \ + && chown -R mailgoserver:mailgoserver /app \ + && chmod 755 /app +WORKDIR /app +VOLUME ["/app/server_data"] +USER mailgoserver # Defaults from internal/config/config.go's generated settings.ini: SMTP 25, direct-TLS -# SMTP 465, IMAP 143, direct-TLS IMAP 993 (the actual standard ports — binding them -# needs no setcap/capability here since this container runs as root), admin/webmail -# HTTP 5000, HTTPS 5001 (deliberately non-privileged; put a reverse proxy or the host's -# own 80/443 in front if you want those too). Port 80 is only actually bound while +# SMTP 465, IMAP 143, direct-TLS IMAP 993, admin/webmail HTTP 5000, HTTPS 5001 +# (deliberately non-privileged; put a reverse proxy or the host's own 80/443 in front of +# those too if you want them there). Port 80 is only actually bound while # [LetsEncrypt] challenge_type=http-01 is enabled and an obtain/renew is in flight — # harmless to expose even when unused. EXPOSE 25 465 143 993 80 5000 5001 diff --git a/docker-deploy/Dockerfile.rspamd b/docker-deploy/Dockerfile.rspamd index 57be317..3257db2 100644 --- a/docker-deploy/Dockerfile.rspamd +++ b/docker-deploy/Dockerfile.rspamd @@ -5,6 +5,8 @@ # (127.0.0.1:11333, the /checkv2 scanning API — see internal/mailstore/rspamd.go) is # already exactly what [Rspamd] url defaults to in settings.ini. Just set # [Rspamd] enabled = true after first boot (see docker-deploy/README.md) and restart. +# No redis here by design — see Dockerfile.aio for the variant that adds it (Bayes +# learning + greylisting). # # Build from the repo root: # docker build -f docker-deploy/Dockerfile.rspamd -t mailgoserver-rspamd . @@ -21,7 +23,7 @@ FROM debian:bookworm-slim # package, which tends to lag several releases behind — this is genuinely the latest # stable release, matching what was asked for. RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates tzdata curl gnupg lsb-release \ + ca-certificates tzdata curl gnupg lsb-release libcap2-bin \ && mkdir -p /usr/share/keyrings \ && curl -fsSL https://rspamd.com/apt-stable/gpg.key | gpg --dearmor -o /usr/share/keyrings/rspamd.gpg \ && echo "deb [signed-by=/usr/share/keyrings/rspamd.gpg] https://rspamd.com/apt-stable/ $(lsb_release -cs) main" \ @@ -29,15 +31,30 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get update && apt-get install -y --no-install-recommends rspamd \ && rm -rf /var/lib/apt/lists/* +# Redirect rspamd's own persistent state (fuzzy storage, DNS/maps cache) into +# server_data/rspamd at container-start (see entrypoint-rspamd.sh, which creates and +# chowns that directory — it doesn't exist yet at build time, since it lives on the +# volume) rather than the package default /var/lib/rspamd, so this whole image only +# ever needs one volume mounted: server_data/, matching mailgoserver's own layout. +RUN mkdir -p /etc/rspamd/local.d && printf 'dbdir = "/app/server_data/rspamd";\n' > /etc/rspamd/local.d/options.inc + COPY --from=build /out/mailgoserver /usr/local/bin/mailgoserver +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/mailgoserver COPY docker-deploy/entrypoint-rspamd.sh /usr/local/bin/entrypoint-rspamd.sh RUN chmod +x /usr/local/bin/entrypoint-rspamd.sh -# /app/data: same as the standalone image (settings.ini, DB, mailstore, keys, certs). -# /var/lib/rspamd: rspamd's own Bayes/fuzzy-hash storage, kept persistent separately so -# rebuilding the image doesn't reset spam-learning state. -WORKDIR /app/data -VOLUME ["/app/data", "/var/lib/rspamd"] +# rspamd's own .deb postinst already creates the "rspamd" system user; mailgoserver +# gets one here for the same reason as the standalone Dockerfile — neither process runs +# as root (see entrypoint-rspamd.sh for how each is dropped to its own user). +RUN useradd --system --create-home --home-dir /app --shell /usr/sbin/nologin mailgoserver \ + && mkdir -p /app/server_data \ + && chown -R mailgoserver:mailgoserver /app \ + && chmod 755 /app +WORKDIR /app +VOLUME ["/app/server_data"] +# Deliberately stays root here (unlike the standalone Dockerfile's USER mailgoserver) — +# the entrypoint script itself needs root just long enough to chown server_data/rspamd +# and drop privileges for each child process individually; see entrypoint-rspamd.sh. # Port 80 is only actually bound while [LetsEncrypt] challenge_type=http-01 is enabled # and an obtain/renew is in flight — harmless to expose even when unused. diff --git a/docker-deploy/README.md b/docker-deploy/README.md index 5ba2548..2fe91cb 100644 --- a/docker-deploy/README.md +++ b/docker-deploy/README.md @@ -1,50 +1,68 @@ # Docker deployment -Two independent images, both built from the same source tree: +Three independent images, all built from the same source tree: - **`Dockerfile`** — mailgoserver only. - **`Dockerfile.rspamd`** — mailgoserver + the latest [rspamd](https://rspamd.com) in the - *same* container, already wired together (see below). Use this one if you want - stronger spam filtering than the built-in heuristic score alone. + *same* container, already wired together. Use this if you want stronger spam + filtering than the built-in heuristic score alone, but don't need Bayes learning or + greylisting. +- **`Dockerfile.aio`** — mailgoserver + rspamd + redis, the full spam-filtering stack: + redis backs rspamd's Bayes classifier and greylisting module, neither of which work + without it. mailgoserver itself has no direct use for redis — it's a single-instance + app already backed by SQLite for everything, so this exists purely to make rspamd's + scoring meaningfully better, not to make mailgoserver itself faster or more scalable. -`docker-compose.yml` defines both as Compose **profiles** so a plain `docker compose up` -can't accidentally start both at once: +`docker-compose.yml` defines all three as Compose **profiles** so a plain `docker compose +up` can't accidentally start more than one at once: ```bash cd docker-deploy -# mailserver only -docker compose --profile standalone up -d --build - -# mailserver + rspamd, bundled -docker compose --profile with-rspamd up -d --build +docker compose --profile standalone up -d --build # mailserver only +docker compose --profile with-rspamd up -d --build # mailserver + rspamd +docker compose --profile all-in-one up -d --build # mailserver + rspamd + redis ``` -Either way, the app itself now binds the real standard mail ports by default — 25 +Either way, the app itself binds the real standard mail ports by default — 25 (SMTP), 465 (direct-TLS SMTP), 143 (IMAP), 993 (direct-TLS IMAP) — and the admin/webmail UI on its usual non-privileged 5000/5001 (HTTP/HTTPS); put your own reverse proxy or a `80:5000`/`443:5001` port mapping in front if you want those on 80/443 too (note port 80 is already published here for Let's Encrypt HTTP-01, see below — pick a different host -port for the web UI's 80 mapping if you use both). Binding the low mail ports needs no -extra capability here since the container runs as root. Copy `.env.example` to `.env` in -this folder to change any host-side port — useful if something else on the host already -owns 25/143/etc., or if you want to run both profiles side by side. +port for the web UI's 80 mapping if you use both). Copy `.env.example` to `.env` in this +folder to change any host-side port — useful if something else on the host already owns +25/143/etc., or if you want to run more than one profile side by side. + +## Security: nothing here runs as root + +None of the three images run mailgoserver, rspamd, or redis as root. Each gets its own +unprivileged system user (rspamd's and redis's own `.deb` packages create theirs; +mailgoserver's is created in the Dockerfile), and mailgoserver's binary is granted just +the one Linux capability it actually needs — `CAP_NET_BIND_SERVICE`, to bind ports +25/465/80 — via `setcap` at build time, rather than the whole container running as root +to get the same effect. `CAP_NET_BIND_SERVICE` is one of Docker's default capabilities, +so this needs no extra `--cap-add` at `docker run`/compose time. + +The `Dockerfile.rspamd`/`Dockerfile.aio` entrypoint scripts (`entrypoint-rspamd.sh`, +`entrypoint-aio.sh`) do still start as root — only long enough to `chown` the +persistent subdirectories under `server_data/` for whichever user needs to write there, +then drop to that user (via `runuser`) for every actual process, mailgoserver included. ## What happens on first boot There's no baked-in config. On first start, the binary generates a fresh -`settings.ini` with defaults (mirroring `internal/config/config.go`), a self-signed TLS -certificate, DKIM/mailstore master keys, and an empty SQLite database — all inside the -`/app/data` volume, so it survives container restarts/rebuilds. The web UI seeds one -admin account: username `admin`, password `Password123!`, and forces an immediate -username + password change on first login — see the main [README](../README.md) for -the full first-login walkthrough. +`server_data/settings.ini` with defaults (mirroring `internal/config/config.go`), a +self-signed TLS certificate, DKIM/mailstore master keys, and an empty SQLite database — +all inside the `server_data/` volume, so it survives container restarts/rebuilds. The +web UI seeds one admin account: username `admin`, password `Password123!`, and forces an +immediate username + password change on first login — see the main +[README](../README.md) for the full first-login walkthrough. **Before using this for real mail**, exec into the container (or edit the volume from the host) and update `settings.ini`: ```bash -docker exec -it mailgoserver sh -c 'vi /app/data/settings.ini' +docker exec -it mailgoserver sh -c 'vi /app/server_data/settings.ini' docker restart mailgoserver ``` @@ -54,7 +72,7 @@ dashboard is reached at (`rp_id` can't be `localhost` once you're on a real doma see the main README's WebAuthn note). There's no environment-variable override mechanism — `settings.ini` in the volume is the one source of config truth. -## Enabling rspamd (the `with-rspamd` profile) +## Enabling rspamd (`with-rspamd` and `all-in-one`) The bundled rspamd's default config already listens on `127.0.0.1:11333` for scanning requests — exactly what `[Rspamd] url` defaults to in `settings.ini`, and since both @@ -73,9 +91,15 @@ reject_score = 15 additive, not a replacement, and if it's ever unreachable, mail still flows on the heuristic score alone (rspamd errors are logged, never fatal to delivery). -This bundle intentionally skips Redis — rspamd runs fine without it for SPF/DKIM/RBL/ -regexp-based scoring, but Bayes learning and greylisting need it. Add a `redis` service -to `docker-compose.yml` and point rspamd's `redis.conf` at it if you need those. +`with-rspamd` intentionally skips redis — rspamd runs fine without it for SPF/DKIM/RBL/ +regexp-based scoring, but Bayes learning and greylisting need it. If you want those, +use `all-in-one` instead: its rspamd is already pre-configured (`local.d/redis.conf`, +`classifier-bayes.conf`, `greylist.conf` baked into the image) to use the redis running +alongside it — nothing to turn on beyond `[Rspamd] enabled = true` above. That redis +instance is loopback-only and has no exposed port or password: the only two processes +that can ever reach it are rspamd and mailgoserver, both inside the same container's +network namespace. If you publish 6379 yourself for some other reason, add a +`requirepass` to the `redis-server` command in `entrypoint-aio.sh` first. ## Let's Encrypt HTTP-01 (no DNS provider needed) @@ -98,21 +122,51 @@ SMTP/IMAP, DNS-01 (or a real custom cert) for the web UI. ## Persistence +Every profile mounts exactly one volume: + | Volume | What's in it | |---|---| -| `mailserver-data` / `mailserver-rspamd-data` | `settings.ini`, the SQLite DB, encrypted mailbox storage, DKIM/mailstore master keys, TLS certs, the CSRF app secret — everything mailgoserver itself owns. | -| `rspamd-data` (rspamd profile only) | rspamd's own Bayes/fuzzy-hash storage, so spam-learning state survives image rebuilds. | +| `mailserver-data` (`standalone`) | `settings.ini`, the SQLite DB, encrypted mailbox storage, DKIM/mailstore master keys, TLS certs, the CSRF app secret — everything mailgoserver itself owns. | +| `mailserver-rspamd-data` (`with-rspamd`) | The above, plus `rspamd/` — rspamd's own fuzzy-hash storage and DNS/maps cache. | +| `mailserver-aio-data` (`all-in-one`) | The above, plus `redis/` — redis's RDB snapshot, so Bayes-learning and greylist state survive restarts too. | -Back up the `*-data` volume like you would the equivalent bare-metal `server_data/` -directory — losing the mailstore master key makes all stored mail unrecoverable, same -as a non-Docker install. +Back up the volume like you would the equivalent bare-metal `server_data/` directory — +losing the mailstore master key makes all stored mail unrecoverable, same as a +non-Docker install. (Redis's own state isn't precious the same way — losing it just +means rspamd re-learns Bayes classifications and greylist history from scratch, not +anything mail-data-critical.) + +## Keeping the base image and packages patched + +None of these images auto-update their own OS packages (Debian, rspamd, redis) while +running — a container's filesystem is meant to be rebuilt from a fresh base, not patched +in place, so the reliable way to pick up security fixes is rebuilding periodically: + +```bash +docker compose --profile build --pull --no-cache +docker compose --profile up -d +``` + +Put that on a host-level cron/systemd timer (weekly is reasonable), or use a tool like +[Renovate](https://docs.renovatebot.com/) or Watchtower against this repo/image if you +want it automated end-to-end. Running `apt-get upgrade` on a schedule *inside* the +running container was considered and deliberately left out — it would only patch that +one running instance until its next recreation (at which point the image's original, +un-patched packages come back anyway), doesn't rebuild the Go binary itself, and adds a +cron daemon + package-manager attack surface to a container that's otherwise trying to +run three unprivileged, single-purpose processes. Rebuilding the image is both more +thorough and simpler. ## Logs / health ```bash -docker compose --profile standalone logs -f # or --profile with-rspamd +docker compose --profile standalone logs -f # or --profile with-rspamd / all-in-one docker inspect --format '{{.State.Health.Status}}' mailgoserver ``` -Both images expose `GET /health` (used by the container `HEALTHCHECK`), matching the -JSON the admin dashboard's own health check reads. +All three images log to stdout/stderr only (no log file inside the container) — this is +the standard Docker pattern (`docker logs`, or point your log driver/aggregator at the +container) rather than something to volume-mount or rotate yourself. + +All three images expose `GET /health` (used by the container `HEALTHCHECK`), matching +the JSON the admin dashboard's own health check reads. diff --git a/docker-deploy/docker-compose.yml b/docker-deploy/docker-compose.yml index b8fa6f9..11faa8c 100644 --- a/docker-deploy/docker-compose.yml +++ b/docker-deploy/docker-compose.yml @@ -1,16 +1,22 @@ -# Two independent setups, selected with --profile so they don't both start by accident: +# Three independent setups, selected with --profile so they don't both start by accident: # # docker compose --profile standalone up -d --build # mailserver only # docker compose --profile with-rspamd up -d --build # mailserver + rspamd, same container +# docker compose --profile all-in-one up -d --build # mailserver + rspamd + redis (Bayes/greylisting) # -# Both default to the standard mail ports on the host (25/465/143/993) — the app itself -# now binds those directly, no port remapping needed — plus port 80 (only actually used -# while Let's Encrypt HTTP-01 is enabled, see internal/webui's Let's Encrypt page) and -# the app's own non-privileged web ports (5000/5001; put a reverse proxy or your own -# 80/443 mapping in front of those if you want the dashboard on standard web ports too — -# note port 80 is already claimed here for HTTP-01 if you enable it). Only run one profile at a time -# unless you've overridden the host ports for one of them (see .env.example) — they'd -# otherwise both try to bind the same host ports. +# Every profile mounts exactly one volume — server_data/ — since every persistent thing +# any of these processes own (mailgoserver's own data, rspamd's dbdir, redis's RDB +# snapshot) lives under that single directory now; see the Dockerfiles' comments. +# +# All three default to the standard mail ports on the host (25/465/143/993) — the app +# itself binds those directly (via a Linux file capability, not by running as root — see +# the Dockerfiles), no port remapping needed — plus port 80 (only actually used while +# Let's Encrypt HTTP-01 is enabled, see internal/webui's Let's Encrypt page) and the +# app's own non-privileged web ports (5000/5001; put a reverse proxy or your own 80/443 +# mapping in front of those if you want the dashboard on standard web ports too — note +# port 80 is already claimed here for HTTP-01 if you enable it). Only run one profile at +# a time unless you've overridden the host ports for it (see .env.example) — they'd +# otherwise all try to bind the same host ports. services: mailserver: build: @@ -28,7 +34,7 @@ services: - "${WEB_HTTP_PORT:-5000}:5000" - "${WEB_HTTPS_PORT:-5001}:5001" volumes: - - mailserver-data:/app/data + - mailserver-data:/app/server_data mailserver-rspamd: build: @@ -46,10 +52,27 @@ services: - "${WEB_HTTP_PORT:-5000}:5000" - "${WEB_HTTPS_PORT:-5001}:5001" volumes: - - mailserver-rspamd-data:/app/data - - rspamd-data:/var/lib/rspamd + - mailserver-rspamd-data:/app/server_data + + mailserver-aio: + build: + context: .. + dockerfile: docker-deploy/Dockerfile.aio + container_name: mailgoserver-aio + restart: unless-stopped + profiles: ["all-in-one"] + ports: + - "${SMTP_PORT:-25}:25" + - "${SMTP_TLS_PORT:-465}:465" + - "${IMAP_PORT:-143}:143" + - "${IMAP_TLS_PORT:-993}:993" + - "${ACME_HTTP_PORT:-80}:80" + - "${WEB_HTTP_PORT:-5000}:5000" + - "${WEB_HTTPS_PORT:-5001}:5001" + volumes: + - mailserver-aio-data:/app/server_data volumes: mailserver-data: mailserver-rspamd-data: - rspamd-data: + mailserver-aio-data: diff --git a/docker-deploy/entrypoint-rspamd.sh b/docker-deploy/entrypoint-rspamd.sh index 72cceda..1dcb691 100755 --- a/docker-deploy/entrypoint-rspamd.sh +++ b/docker-deploy/entrypoint-rspamd.sh @@ -1,19 +1,31 @@ #!/usr/bin/env bash set -euo pipefail -# Starts rspamd in the background, then hands off to mailgoserver as the container's -# main (foreground) process. +# Starts rspamd (under its own unprivileged system user, created by the rspamd .deb's +# own postinst) in the background, then hands off to mailgoserver — also dropped to its +# own unprivileged user — as the container's main (foreground) process. This script +# itself keeps running as root only long enough to create/chown the persistent +# directories below and drop privileges for each child; neither rspamd nor mailgoserver +# ever run as root themselves (mailgoserver's binary carries just the +# CAP_NET_BIND_SERVICE file capability it needs for ports 25/465/80 — see the +# Dockerfile — which a non-root exec of it still picks up). # +# rspamd's own persistent state (fuzzy storage, DNS/maps cache — its "dbdir") is +# redirected into server_data/rspamd so the whole container only has one thing to +# volume-mount: server_data/, matching mailgoserver's own layout. +mkdir -p /app/server_data/rspamd +chown -R rspamd:rspamd /app/server_data/rspamd + # ponytail: no supervisor / restart-on-crash for rspamd here — if it dies mid-run, mail # keeps flowing without the extra scoring rather than the container crashing (the # built-in heuristic score in internal/mailstore/spam.go always runs regardless, and # CheckRspamd swallows connection errors rather than rejecting mail over them — see # internal/mailstore/rspamd.go). Add s6-overlay or supervisord if unattended rspamd # uptime matters more than that. -rspamd -f & +runuser -u rspamd -- rspamd -f & # Give rspamd's normal worker a moment to bind :11333 before the first message can # possibly arrive — purely cosmetic, mailgoserver degrades gracefully either way. sleep 1 -exec mailgoserver --host 0.0.0.0 "$@" +exec runuser -u mailgoserver -- mailgoserver --host 0.0.0.0 "$@" diff --git a/docker-deploy/optional-AIO-Docker.md b/docker-deploy/optional-AIO-Docker.md new file mode 100644 index 0000000..1f204b8 --- /dev/null +++ b/docker-deploy/optional-AIO-Docker.md @@ -0,0 +1,18 @@ +Implemented for real 2026-08-16 — see `docker-deploy/`: + +- `Dockerfile.aio` + `entrypoint-aio.sh` — mailgoserver + rspamd + redis in one + container (redis backs rspamd's Bayes classifier + greylisting; mailgoserver itself + has no use for redis, it's a single-instance app already backed by SQLite). +- `docker-compose.yml`'s `all-in-one` profile: `docker compose --profile all-in-one up -d --build` +- Single volume everywhere now (`server_data/`) — settings.ini, TLS certs, DKIM/mailstore + keys, the SQLite DB, rspamd's dbdir, and redis's RDB snapshot all live under it (see + `internal/config/config.go` + `main.go`'s server_data-relative defaults). +- Nothing runs as root — each process (mailgoserver, rspamd, redis) has its own + unprivileged user; mailgoserver's binary gets just `CAP_NET_BIND_SERVICE` via `setcap` + instead of the whole container running as root. +- Auto-updating installed packages on a schedule *inside* the running container was + considered and left out (see `docker-deploy/README.md`'s "Keeping the base image and + packages patched" section) — rebuilding the image periodically is the recommended + approach instead; happy to build a scheduled rebuild/CI job if wanted. + +The draft supervisord/Alpine sketch that used to be here is superseded by the above. diff --git a/entrypoint-aio.sh b/entrypoint-aio.sh new file mode 100644 index 0000000..7541c44 --- /dev/null +++ b/entrypoint-aio.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +# All-in-one: redis, then rspamd (which uses that redis for Bayes classification and +# greylisting — see the local.d config baked into Dockerfile.aio), then mailgoserver as +# the container's main (foreground) process. Each runs under its own unprivileged +# system user (redis/rspamd from their own .deb postinst; mailgoserver's created in the +# Dockerfile) — this script itself keeps running as root only long enough to +# create/chown the persistent directories below and drop privileges for each child; see +# Dockerfile/Dockerfile.aio's comments on why that's still safe for the one process +# (mailgoserver) that actually needs a privileged port. +# +# Everything persistent — mailgoserver's own data, rspamd's dbdir, and redis's RDB +# snapshot — lives under server_data/, so this whole bundle only has one thing to +# volume-mount. +mkdir -p /app/server_data/rspamd /app/server_data/redis +chown -R rspamd:rspamd /app/server_data/rspamd +chown -R redis:redis /app/server_data/redis + +# Loopback-only (never published as a container port — see Dockerfile.aio) and no +# password: the only two processes that can ever reach it are rspamd and mailgoserver, +# both inside this same container's network namespace. If you publish 6379 yourself for +# some reason, add `requirepass` here first. +runuser -u redis -- redis-server --bind 127.0.0.1 -::1 --protected-mode yes \ + --dir /app/server_data/redis --daemonize no & + +# Give redis a moment to start listening before rspamd (which needs it for Bayes/ +# greylist) connects. +sleep 1 + +# ponytail: no supervisor / restart-on-crash for redis/rspamd here — if either dies +# mid-run, mail keeps flowing on the built-in heuristic score alone rather than the +# container crashing (internal/mailstore/spam.go always runs regardless, and +# CheckRspamd swallows connection errors rather than rejecting mail over them — see +# internal/mailstore/rspamd.go). Add s6-overlay or supervisord if unattended uptime for +# the scoring stack matters more than that. +runuser -u rspamd -- rspamd -f & + +# Give rspamd's normal worker a moment to bind :11333 before the first message can +# possibly arrive — purely cosmetic, mailgoserver degrades gracefully either way. +sleep 1 + +exec runuser -u mailgoserver -- mailgoserver --host 0.0.0.0 "$@" diff --git a/internal/config/config.go b/internal/config/config.go index 605fbde..a554aef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,8 +72,8 @@ var defaults = []struct { {"TLS", []defaultKV{ {"", "", "TLS/SSL certificate configuration"}, {"", "", "The 'custom' certificate: self-signed on first run, or your own uploaded cert/key"}, - {"TLS_CERT_FILE", "ssl_certs/server.crt", ""}, - {"TLS_KEY_FILE", "ssl_certs/server.key", ""}, + {"TLS_CERT_FILE", "server_data/ssl_certs/server.crt", ""}, + {"TLS_KEY_FILE", "server_data/ssl_certs/server.key", ""}, {"", "", "Which certificate each TLS-serving listener uses: custom, letsencrypt_dns"}, {"", "", "(the [LetsEncrypt] DNS-01 cert), or letsencrypt_http (the [LetsEncryptHTTP]"}, {"", "", "HTTP-01 cert). Independent per listener - e.g. run the HTTP-01 cert on"}, diff --git a/internal/webui/letsencrypt.go b/internal/webui/letsencrypt.go index 9c85624..a327017 100644 --- a/internal/webui/letsencrypt.go +++ b/internal/webui/letsencrypt.go @@ -151,7 +151,7 @@ func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Expected a .json service account key file"}) return } - acmeDir := filepath.Join(filepath.Dir(a.ConfigPath), "server_data", "acme") + acmeDir := filepath.Join(a.Root, "server_data", "acme") os.MkdirAll(acmeDir, 0o755) filePath := filepath.Join(acmeDir, fmt.Sprintf("gcloud-sa-%d.json", time.Now().Unix())) out, err := os.Create(filePath) diff --git a/internal/webui/settings.go b/internal/webui/settings.go index 38dde01..99a8e10 100644 --- a/internal/webui/settings.go +++ b/internal/webui/settings.go @@ -163,7 +163,7 @@ func (a *App) uploadTLSFile(w http.ResponseWriter, r *http.Request, field, force writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid file extension"}) return } - sslDir := filepath.Join(filepath.Dir(a.ConfigPath), "ssl_certs") + sslDir := filepath.Join(a.Root, "server_data", "ssl_certs") os.MkdirAll(sslDir, 0o755) filePath := filepath.Join(sslDir, fmt.Sprintf("server%d.%s", time.Now().Unix(), forcedExt)) out, err := os.Create(filePath) @@ -192,7 +192,7 @@ func (a *App) testAttachmentsPath(w http.ResponseWriter, r *http.Request) { return } if !filepath.IsAbs(path) { - path = filepath.Join(filepath.Dir(a.ConfigPath), path) + path = filepath.Join(a.Root, path) } if err := os.MkdirAll(path, 0o755); err != nil { writeJSON(w, http.StatusOK, M{"success": false, "message": err.Error()}) diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 442eef0..f2aa3b3 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -31,6 +31,13 @@ type App struct { Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go) Cfg *ini.File ConfigPath string + // Root is the app's working directory at startup (os.Getwd() in main.go) — every + // relative path this package resolves on its own (uploaded cert/key files, the + // ACME data dir, the attachments-path tester) is joined against this, not against + // ConfigPath's own directory, so it always matches how main.go resolves the same + // kind of path (mailstore, attachments, TLS certs) regardless of where -config + // happens to point. + Root string Logger *toolbox.Logger SMTPUp func() bool // reports whether the SMTP listeners are currently running @@ -58,12 +65,11 @@ type App struct { // filesystem (embed.go), not disk, so no directory paths are needed for them. // appSecret is loaded by the caller via LoadOrCreateAppSecret, mirroring how // mailstore's master key is loaded in main.go and threaded in rather than resolved -// internally (both are file paths relative to the app's root working directory, -// which this package doesn't otherwise know). -func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr, acmeHTTPMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) { +// internally (both are file paths relative to root — see the App.Root doc comment). +func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr, acmeHTTPMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath, root string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) { trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger) a := &App{ - DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, ACMEHTTP: acmeHTTPMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp, + DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, ACMEHTTP: acmeHTTPMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Root: root, Logger: logger, SMTPUp: smtpUp, pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret, } if err := a.loadTemplates(); err != nil { diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go index 4ef70d5..09bcbc2 100644 --- a/internal/webui/webui_test.go +++ b/internal/webui/webui_test.go @@ -140,7 +140,7 @@ func newTestApp(t *testing.T) *App { if err != nil { t.Fatalf("LoadOrCreateAppSecret: %v", err) } - app, err := New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret) + app, err := New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, dir, toolbox.GetLogger("test"), func() bool { return true }, appSecret) if err != nil { t.Fatalf("New: %v", err) } diff --git a/main.go b/main.go index ee5deb9..93ea160 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,10 @@ func main() { port := flag.Int("port", 5000, "Web server port") debug := flag.Bool("debug", false, "Enable debug mode") initData := flag.Bool("init-data", false, "Initialize sample data and exit") - configFlag := flag.String("config", "settings.ini", "Configuration file path") + // Defaults inside server_data/ (along with the DB, mailstore, keys, and certs + // generated below) so a Docker deployment only needs to volume-mount that one + // directory to persist everything — nothing is left sitting next to the binary. + configFlag := flag.String("config", "server_data/settings.ini", "Configuration file path") flag.Parse() portFlagSet := false flag.Visit(func(f *flag.Flag) { @@ -120,10 +123,10 @@ func main() { // manager has obtained anything yet. customCertFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String()) customKeyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String()) - dnsCertFile := absPath(root, "ssl_certs/letsencrypt_dns.crt") - dnsKeyFile := absPath(root, "ssl_certs/letsencrypt_dns.key") - httpCertFile := absPath(root, "ssl_certs/letsencrypt_http.crt") - httpKeyFile := absPath(root, "ssl_certs/letsencrypt_http.key") + dnsCertFile := absPath(root, "server_data/ssl_certs/letsencrypt_dns.crt") + dnsKeyFile := absPath(root, "server_data/ssl_certs/letsencrypt_dns.key") + httpCertFile := absPath(root, "server_data/ssl_certs/letsencrypt_http.crt") + httpKeyFile := absPath(root, "server_data/ssl_certs/letsencrypt_http.key") for _, pair := range [][2]string{ {customCertFile, customKeyFile}, {dnsCertFile, dnsKeyFile}, {httpCertFile, httpKeyFile}, } { @@ -300,7 +303,7 @@ func main() { os.Exit(1) } - app, err := webui.New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load, appSecret) + app, err := webui.New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, root, toolbox.GetLogger("web"), smtpRunning.Load, appSecret) if err != nil { logger.Error("init web UI: %v", err) os.Exit(1)