From 0d98d92642a810c0c262f1fe9e9e8c690e2d51d2 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Mon, 17 Aug 2026 23:17:30 +0300 Subject: [PATCH] Add optional inbound relay (backup-MX) behind INBOUND_RELAY_ENABLE. Port 25 accepts only configured domains and listed recipients, then forwards to an upstream; the outbound path is unchanged when the flag is off. Co-authored-by: Cursor --- CHANGELOG.md | 27 ++ README.md | 19 +- build/Dockerfile | 8 +- build/entrypoint.sh | 7 + build/postfix-config.sh | 69 ++++ cmd/panel/envdoc_test.go | 19 ++ cmd/panel/httpserver.go | 22 +- cmd/panel/main.go | 4 + deploy/.env.example | 15 + deploy/antispam/docker-compose.antispam.yml | 29 ++ deploy/caddy/docker-compose.caddy.yml | 1 + deploy/docker-compose.yml | 10 +- deploy/nginx/docker-compose.nginx.yml | 1 + deploy/traefik/docker-compose.traefik.yml | 1 + docs/architecture.md | 42 ++- docs/guide.md | 87 ++++- docs/plans/inbound-relay.md | 28 +- docs/product.md | 17 +- docs/roadmap.md | 2 +- docs/security.md | 15 +- internal/dnscheck/dnscheck.go | 71 ++++ internal/dnscheck/dnscheck_test.go | 34 ++ internal/inbound/service.go | 156 +++++++++ internal/inbound/service_test.go | 111 ++++++ internal/inbound/validate.go | 131 +++++++ internal/postfix/inbound.go | 130 +++++++ internal/postfix/inbound_test.go | 119 +++++++ internal/store/inbound.go | 249 ++++++++++++++ internal/store/inbound_test.go | 101 ++++++ .../store/migrations/0006_inbound_relay.sql | 26 ++ internal/store/store.go | 6 +- internal/web/handlers/authz_test.go | 9 + internal/web/handlers/handlers.go | 7 + internal/web/handlers/handlers_domains.go | 7 + internal/web/handlers/handlers_inbound.go | 319 ++++++++++++++++++ .../web/handlers/handlers_inbound_test.go | 160 +++++++++ .../web/handlers/handlers_monitor_test.go | 8 +- internal/web/validate/validate.go | 103 ++++++ internal/web/validate/validate_test.go | 66 ++++ internal/web/view/templates/inbound.html | 50 +++ .../web/view/templates/inbound_delete.html | 20 ++ .../web/view/templates/inbound_domain.html | 118 +++++++ internal/web/view/templates/layout.html | 4 + internal/web/view/templates_test.go | 47 ++- internal/web/view/view.go | 47 ++- internal/web/web.go | 20 +- test/e2e/compose.override.yml | 1 + test/e2e/main_test.go | 23 ++ test/e2e/process_check.go | 15 + 49 files changed, 2495 insertions(+), 86 deletions(-) create mode 100644 deploy/antispam/docker-compose.antispam.yml create mode 100644 internal/inbound/service.go create mode 100644 internal/inbound/service_test.go create mode 100644 internal/inbound/validate.go create mode 100644 internal/postfix/inbound.go create mode 100644 internal/postfix/inbound_test.go create mode 100644 internal/store/inbound.go create mode 100644 internal/store/inbound_test.go create mode 100644 internal/store/migrations/0006_inbound_relay.sql create mode 100644 internal/web/handlers/handlers_inbound.go create mode 100644 internal/web/handlers/handlers_inbound_test.go create mode 100644 internal/web/view/templates/inbound.html create mode 100644 internal/web/view/templates/inbound_delete.html create mode 100644 internal/web/view/templates/inbound_domain.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e44de..2f65012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +Optional inbound relay (backup-MX / forwarder) on port 25, off by default. +Mail is accepted only for domains configured in the panel and forwarded to +an upstream; there are still no mailboxes. Security review of the inbound +path is pending (Fable). + +### Added + +- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on + port 25 without SASL, only for `relay_domains` + known recipients, and + forwards via `transport_maps`. Empty upstream host is omitted from the + maps so mail is never accepted with nowhere to send it. +- Panel **Inbound** pages (global administrator): domains, upstream host/port + and TLS, recipient list or any-at-domain, MX check against + `SELFPOST_HOSTNAME`. +- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus + [deploy/antispam/docker-compose.antispam.yml](deploy/antispam/docker-compose.antispam.yml). + Default milter action is fail-open (`accept`). +- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and + `INBOUND_MESSAGE_SIZE_LIMIT` (default 25 MiB) on smtpd port 25. + +### Changed + +- Compose publishes host port 25 even when the flag is off (nothing listens + until it is on), the same pattern as 587 / `SUBMISSION_ENABLE`. +- Full-backup restore Resync also rebuilds inbound maps when the flag is on. + Single-domain export/import is still sending domains only. + ## [1.3.1] - 2026-08-17 Retry policy in the panel, persistent Postfix queue, and self-contained diff --git a/README.md b/README.md index 1b91301..e1ec575 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Docker image. Postfix, OpenDKIM, and a small Go panel run together under then point your apps at the SMTP endpoint. SelfPost sends mail straight to the internet from **your own IP**, with per-domain -DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail. +DKIM signing. It is outbound by default — no mailboxes or webmail. An optional +inbound relay (backup-MX / forwarder on port 25) can be turned on; it forwards +to an upstream, it does not store mail. **For:** operators who run their own VPS or home server and want a simple relay they control, without a third-party SMTP provider. @@ -21,6 +23,7 @@ send log and DNS checks in the panel, encrypted backups. ## Features - Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing +- Optional inbound relay on port 25 (backup-MX / forwarder; off by default) - Web panel — domains, applications, deliveries, mail queue, system log, backup - Multi-domain relay — each SASL application is bound to one sending domain - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check @@ -78,6 +81,15 @@ For every domain you add in the panel: See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide. +### Per inbound domain (optional) + +Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`): + +- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX + if this host is backup-MX) + +See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide. + ## Quick start > **First boot — create the admin account.** On a fresh container SelfPost prints @@ -136,8 +148,9 @@ DNS — lives in the operator guide's [Full deployment](docs/guide.md#full-deployment) section, with proxy-specific commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). -The compose file always publishes **465** and **587**; Postfix listens on 587 -only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the +The compose file always publishes **465**, **587**, and **25**; Postfix listens +on 587 only when `SUBMISSION_ENABLE=true`, and on 25 only when +`INBOUND_RELAY_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment variables](docs/guide.md#environment-variables). diff --git a/build/Dockerfile b/build/Dockerfile index 6173b5f..177b6d6 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -107,10 +107,10 @@ COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf -# Published submission ports: 465 (smtps, primary) and 587 (submission, optional) -# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client, -# which needs no inbound listener or EXPOSE. -EXPOSE 8080 465 587 +# Published submission ports: 465 (smtps, primary) and 587 (submission, optional), +# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080. +# Outbound delivery dials remote MXs on 25 as a client even when inbound is off. +EXPOSE 8080 465 587 25 # Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix). # Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations. diff --git a/build/entrypoint.sh b/build/entrypoint.sh index eb5674e..1155e4f 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -103,6 +103,13 @@ chmod 2750 /data/sasl # writes sender_login_maps; Postfix owns the on-disk queue tree under queue/. mkdir -p /data/postfix/queue [ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps +# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true). +# Empty files keep postfix check happy if the flag is on before any domain exists. +for f in relay_domains transport relay_recipients tls_policy; do + [ -e "/data/postfix/$f" ] || : > "/data/postfix/$f" + chown panel:selfpost "/data/postfix/$f" + chmod 0640 "/data/postfix/$f" +done chown panel:selfpost /data/postfix chmod 2750 /data/postfix chown panel:selfpost /data/postfix/sender_login_maps diff --git a/build/postfix-config.sh b/build/postfix-config.sh index d596015..adf98e2 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -66,6 +66,20 @@ SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}" # when a client library needs STARTTLS on 587 instead of implicit TLS on 465). SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" +# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does +# not accept mail, Postfix inbound maps are not referenced, and the panel UI is +# absent. When true, smtp inet on 25 accepts only relay_domains + known +# recipients (docs/plans/inbound-relay.md). +INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}" +INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}" +INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}" +INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" +INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" +RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}" +TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}" +RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}" +TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}" + # Delivery log, written by postlogd and read by the panel's log-tailer. It lives # under the persistent /data (not the ephemeral /var/log) so the delivery lines # for messages still marked "queued" survive a container recreate — without @@ -197,6 +211,61 @@ else postconf -MX "submission/inet" 2>/dev/null || true fi +# --- inbound smtpd on port 25 (optional backup-MX / forwarder) --------------- +# Debian's stock master.cf enables smtp/inet. When the flag is off, remove that +# listener so port 25 is not an inbound smtpd (outbound delivery uses smtp/unix). +# When on: no SASL, no OpenDKIM, accept only relay_domains + listed recipients. +if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then + for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do + [ -e "$f" ] || : > "$f" + done + postconf -e \ + "relay_domains=texthash:${RELAY_DOMAINS_MAP}" \ + "transport_maps=texthash:${TRANSPORT_MAP}" \ + "relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \ + "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" \ + "smtpd_reject_unlisted_recipient=yes" + + INBOUND_MILTERS="" + if [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then + case "${INBOUND_ANTISPAM_MILTER}" in + inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;; + *) + echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2 + exit 1 + ;; + esac + case "${INBOUND_ANTISPAM_MILTER_ACTION}" in + accept|tempfail) ;; + *) + echo "FATAL: INBOUND_ANTISPAM_MILTER_ACTION must be accept or tempfail, got: ${INBOUND_ANTISPAM_MILTER_ACTION}" >&2 + exit 1 + ;; + esac + INBOUND_MILTERS="{ ${INBOUND_ANTISPAM_MILTER}, default_action=${INBOUND_ANTISPAM_MILTER_ACTION} }" + fi + + postconf -M "smtp/inet=smtp inet n - n - - smtpd" + postconf -P \ + "smtp/inet/smtpd_sasl_auth_enable=no" \ + "smtp/inet/smtpd_tls_auth_only=no" \ + "smtp/inet/smtpd_sender_login_maps=" \ + "smtp/inet/smtpd_sender_restrictions=" \ + "smtp/inet/smtpd_client_restrictions=" \ + "smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \ + "smtp/inet/smtpd_recipient_restrictions=reject_unauth_destination, reject_unlisted_recipient" \ + "smtp/inet/smtpd_milters=${INBOUND_MILTERS}" \ + "smtp/inet/smtpd_client_message_rate_limit=${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" \ + "smtp/inet/message_size_limit=${INBOUND_MESSAGE_SIZE_LIMIT}" +else + postconf -MX "smtp/inet" 2>/dev/null || true + postconf -e \ + "relay_domains=" \ + "transport_maps=" \ + "relay_recipient_maps=" \ + "smtp_tls_policy_maps=" +fi + # Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery # agent and others chrooted to /var/spool/postfix, where they cannot read # /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail diff --git a/cmd/panel/envdoc_test.go b/cmd/panel/envdoc_test.go index edccb8c..666a41c 100644 --- a/cmd/panel/envdoc_test.go +++ b/cmd/panel/envdoc_test.go @@ -15,6 +15,11 @@ var documentedPublic = []string{ "PANEL_SESSION_IDLE_DAYS", "SELFPOST_DNS_RESOLVERS", "TRUSTED_PROXY_CIDR", + "INBOUND_RELAY_ENABLE", + "INBOUND_ANTISPAM_MILTER", + "INBOUND_ANTISPAM_MILTER_ACTION", + "INBOUND_RATE_LIMIT_MESSAGES_PER_IP", + "INBOUND_MESSAGE_SIZE_LIMIT", } // documentedInternal matches architecture.md § Configuration "Internal env vars". @@ -34,6 +39,10 @@ var documentedInternal = []string{ "POSTFIX_DIR", "POSTFIX_SENDER_LOGIN_MAPS", "POSTFIX_QUEUE_DIR", + "POSTFIX_RELAY_DOMAINS", + "POSTFIX_TRANSPORT_MAPS", + "POSTFIX_RELAY_RECIPIENTS", + "POSTFIX_TLS_POLICY_MAPS", "SELFPOST_DEPLOY_ROOT", "MILTER_CONNECT_TIMEOUT", "MILTER_COMMAND_TIMEOUT", @@ -73,6 +82,7 @@ var loadConfigKeys = []string{ "SASL_REALM", "POSTFIX_DIR", "SELFPOST_DEPLOY_ROOT", + "INBOUND_RELAY_ENABLE", } // buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh @@ -90,6 +100,15 @@ var buildScriptKeys = []string{ "POSTFIX_QUEUE_DIR", "SASL_DB_PATH", "SUBMISSION_ENABLE", + "INBOUND_RELAY_ENABLE", + "INBOUND_ANTISPAM_MILTER", + "INBOUND_ANTISPAM_MILTER_ACTION", + "INBOUND_RATE_LIMIT_MESSAGES_PER_IP", + "INBOUND_MESSAGE_SIZE_LIMIT", + "POSTFIX_RELAY_DOMAINS", + "POSTFIX_TRANSPORT_MAPS", + "POSTFIX_RELAY_RECIPIENTS", + "POSTFIX_TLS_POLICY_MAPS", "MILTER_CONNECT_TIMEOUT", "MILTER_COMMAND_TIMEOUT", "MILTER_CONTENT_TIMEOUT", diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 7c44b49..f760b3d 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -11,6 +11,7 @@ import ( "github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/buildinfo" "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web" @@ -19,10 +20,12 @@ import ( // mailStack is the panel's domain and application services plus the on-disk // mail-path adapters they write through. type mailStack struct { - Domains *domain.Service - Apps *app.Service - pf *postfix.Postfix - odk *domain.OpenDKIM + Domains *domain.Service + Apps *app.Service + Inbound *inbound.Service + pf *postfix.Postfix + odk *domain.OpenDKIM + inboundEnabled bool } func newMailStack(cfg config, st *store.Store) *mailStack { @@ -30,7 +33,8 @@ func newMailStack(cfg config, st *store.Store) *mailStack { odk := domain.NewOpenDKIM(cfg.opendkimDir) apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) - return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk} + inb := inbound.NewService(st, pf) + return &mailStack{Domains: domains, Apps: apps, Inbound: inb, pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled} } // Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and @@ -42,6 +46,11 @@ func (m *mailStack) Resync() error { if err := m.Apps.Resync(); err != nil { return fmt.Errorf("postfix resync: %w", err) } + if m.inboundEnabled { + if err := m.Inbound.Resync(); err != nil { + return fmt.Errorf("inbound maps resync: %w", err) + } + } return nil } @@ -70,7 +79,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { // effective config, including a manual override; the panel keeps this // snapshot for the process lifetime (architecture.md). retryPolicy := postfix.LoadRetryPolicy() - return web.New(st, ms.Domains, ms.Apps, web.Config{ + return web.New(st, ms.Domains, ms.Apps, ms.Inbound, web.Config{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, SubmissionEnabled: cfg.submissionEnabled, @@ -88,6 +97,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RetryPolicy: retryPolicy, + InboundEnabled: cfg.inboundEnabled, }, cfg.setupTokenPath) } diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 065670a..a0bd5d5 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -79,6 +79,8 @@ type config struct { saslRealm string postfixDir string deployRoot string + + inboundEnabled bool } func loadConfig() config { @@ -140,6 +142,8 @@ func loadConfig() config { saslRealm: saslRealm(), postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), + // Optional inbound relay (backup-MX / forwarder). Off unless exactly "true". + inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true", } } diff --git a/deploy/.env.example b/deploy/.env.example index 48a9c1c..2121190 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -11,6 +11,21 @@ SELFPOST_HOSTNAME=mail.example.com # primary 465/smtps listener. SUBMISSION_ENABLE=false +# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts +# mail on port 25 only for domains configured in the panel and forwards them +# to the upstream you set. Off by default — the outbound path is unchanged. +INBOUND_RELAY_ENABLE=false + +# Optional milter on the inbound listener only (not 465/587). Empty = off. +# Example with deploy/antispam/docker-compose.antispam.yml: +# INBOUND_ANTISPAM_MILTER=inet:antispam:11332 +# INBOUND_ANTISPAM_MILTER_ACTION=accept + +# Coarse per-client-IP limit and message size on inbound smtpd (port 25). +# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20 +# INBOUND_MESSAGE_SIZE_LIMIT=26214400 + + # Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting". RATE_LIMIT_MESSAGES_PER_IP=100 RATE_LIMIT_WINDOW_SECONDS=3600 diff --git a/deploy/antispam/docker-compose.antispam.yml b/deploy/antispam/docker-compose.antispam.yml new file mode 100644 index 0000000..c3f94f7 --- /dev/null +++ b/deploy/antispam/docker-compose.antispam.yml @@ -0,0 +1,29 @@ +# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md). +# +# SelfPost does not ship or start an anti-spam engine. This fragment brings up +# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it +# only when you want filtering on port 25: +# +# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d +# +# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client +# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address. +# +# Pin the rspamd tag the same way the SelfPost image is pinned; bump it +# deliberately. Default milter action is fail-open (accept) so a down sidecar +# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to +# defer instead. + +services: + selfpost: + environment: + INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}" + INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}" + INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}" + depends_on: + - antispam + + antispam: + image: rspamd/rspamd:3.11 + restart: unless-stopped + # No host ports: only SelfPost talks to the milter on the compose network. diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml index fd0b5ed..4ea8377 100644 --- a/deploy/caddy/docker-compose.caddy.yml +++ b/deploy/caddy/docker-compose.caddy.yml @@ -23,6 +23,7 @@ services: ports: !override - "465:465" - "587:587" + - "25:25" environment: # Caddy names certificate files after the hostname, not # fullchain.pem/privkey.pem like certbot — override the base file's diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 71a855f..433a9fd 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -33,6 +33,11 @@ services: # Set to true to also publish RFC 6409 submission (587/STARTTLS) # alongside the primary 465/smtps listener (spec 5). SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" + INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}" + INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}" + INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}" + INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" + INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" # Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app # limits (level 2) are configured later from the panel itself. RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}" @@ -76,6 +81,9 @@ services: # 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only # when the variable is true — see docs/guide.md "Published ports". - "587:587" + # 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only + # when the variable is true. + - "25:25" - "127.0.0.1:8080:8080" # Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as # root very briefly to fix /data ownership and normalise permissions @@ -85,7 +93,7 @@ services: # self-healing. What IS applied: no privilege escalation past what the # image already grants, and every Linux capability dropped except the # small set the root startup phase and Postfix/OpenDKIM genuinely need: - # - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024; + # - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024; # - CHOWN — entrypoint re-owns /data (bind mount) to `panel`; # - FOWNER — entrypoint then chmods those now panel-owned /data # dirs/files while still root (owner-check bypass); diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml index bb57d86..07bc567 100644 --- a/deploy/nginx/docker-compose.nginx.yml +++ b/deploy/nginx/docker-compose.nginx.yml @@ -23,6 +23,7 @@ services: ports: !override - "465:465" - "587:587" + - "25:25" # No host publish for 8080 here: nginx reaches it over the compose # network at selfpost:8080 instead (see nginx.conf.example). volumes: !override diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml index fa4de14..7674ebb 100644 --- a/deploy/traefik/docker-compose.traefik.yml +++ b/deploy/traefik/docker-compose.traefik.yml @@ -15,6 +15,7 @@ services: ports: !override - "465:465" - "587:587" + - "25:25" volumes: !override - ./data:/data - .:/selfpost-deploy:ro diff --git a/docs/architecture.md b/docs/architecture.md index daeeab6..afbc435 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,13 +53,31 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission) ├─► OpenDKIM milter (sign, tempfail on failure) ├─► journal-milter (send log + L2 rate limits, fail-open) └─► outbound MX delivery (port 25 client) + +Internet ──► Postfix smtp inet :25 (only when INBOUND_RELAY_ENABLE=true) + │ + ├─► optional antispam milter (inbound only) + └─► smtp:[upstream]:port (transport_maps; no local delivery) ``` +The inbound listener is **absent** when the flag is off (`postconf -MX smtp/inet` +removes Debian's stock smtpd). Outbound delivery still uses the `smtp unix` +client; it is not the same service. + ### Postfix ([build/postfix-config.sh](../build/postfix-config.sh)) - **465/smtps** — implicit TLS, SASL required; primary listener. - **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with `smtpd_tls_security_level=encrypt`. +- **25/smtp inet** — only when `INBOUND_RELAY_ENABLE=true`. No SASL, no + OpenDKIM, no journal-milter. Accepts only `relay_domains` + + `relay_recipient_maps` (`reject_unauth_destination`, + `reject_unlisted_recipient`). Maps under `/data/postfix/` + (`relay_domains`, `transport`, `relay_recipients`, `tls_policy`), written + atomically by [internal/postfix/inbound.go](../internal/postfix/inbound.go). + Domains with an empty upstream host are omitted from the maps. Optional + `INBOUND_ANTISPAM_MILTER` on this listener only; default + `milter_default_action` is fail-open (`accept`). - **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`; `smtpd_sender_login_maps` + `reject_sender_login_mismatch`. - **Level-1 rate limit** — `smtpd_client_message_rate_limit` / @@ -186,6 +204,7 @@ below is a summary — HTMX fragment endpoints | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | | `/settings` | Username/password for any user; DMARC report default is **global** only | | `/users`, `/users/*` | **Global.** Panel user CRUD | +| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. | HTMX polling refreshes monitoring fragments (5 s while the operator is active on the page, 30 s when the tab is visible but idle, none when hidden — scheduled in @@ -251,6 +270,7 @@ flowchart TB subgraph services ["Services — multi-store operations + rollback"] domainSvc["internal/domain"] appSvc["internal/app"] + inboundSvc["internal/inbound"] end subgraph persistence ["Persistence"] store["internal/store — SQLite, embedded migrations"] @@ -272,16 +292,19 @@ flowchart TB web --> store web --> domainSvc web --> appSvc + web --> inboundSvc web --> backupPkg web --> dnscheck web --> health web --> secretfile domainSvc --> store appSvc --> store + inboundSvc --> store milterPkg --> store logtail --> store domainSvc --> postfix appSvc --> postfix + inboundSvc --> postfix ``` The three roles inside the `panel` process (HTTP server, journal-milter, @@ -295,11 +318,15 @@ single-connection trade-off that follows from it. | Path | Contents | |---|---| -| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset | +| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset, inbound relay domains | | `setup-token` | First-run setup token file | | `opendkim/` | DKIM keys + tables | | `sasl/sasldb2` | Application SASL credentials | | `postfix/sender_login_maps` | Login → From binding | +| `postfix/relay_domains` | Inbound domains accepted on port 25 | +| `postfix/transport` | Inbound next-hop `smtp:[host]:port` | +| `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all | +| `postfix/tls_policy` | TLS policy for inbound next hops | | `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate | | `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) | | `manifest.json` | Backup version stamp (consumed on restore) | @@ -317,9 +344,10 @@ under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present; version check on restore. Requires the project directory mounted read-only at `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the first successful boot after restore, the panel runs one **Resync** — OpenDKIM's -tables and Postfix's sender map are re-derived from SQLite and both daemons are -reloaded, so drift between the extracted archive and the database is healed -before mail flows (same step as `POST /reload` on demand). Stopped-container +tables, Postfix's sender map, and (when `INBOUND_RELAY_ENABLE=true`) inbound +relay maps are re-derived from SQLite and both daemons are reloaded, so drift +between the extracted archive and the database is healed before mail flows +(same step as `POST /reload` on demand). Stopped-container `tar` of `./data` alone remains possible for state-only copies (see guide). **Optional encryption** of the two secret-bearing downloads @@ -364,6 +392,12 @@ unsupported rather than as a missing doc: (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel always writes `/sender_login_maps`, so overriding this env alone desyncs the map Postfix reads from the file the panel maintains), + `POSTFIX_RELAY_DOMAINS` (`/data/postfix/relay_domains`), + `POSTFIX_TRANSPORT_MAPS` (`/data/postfix/transport`), + `POSTFIX_RELAY_RECIPIENTS` (`/data/postfix/relay_recipients`), + `POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if + overridden without matching the panel writer in + [internal/postfix/inbound.go](../internal/postfix/inbound.go), `POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`), `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for full backups; mount `.:/selfpost-deploy:ro` in compose). diff --git a/docs/guide.md b/docs/guide.md index f5fbbfe..99a41f2 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -32,6 +32,7 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications). - [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop) - [Full backup and restore](#full-backup-and-restore) - [Encrypting a backup or export](#encrypting-a-backup-or-export) + - [Inbound relay](#inbound-relay) - [Domain administration](#domain-administration) - [Domains page](#domains-page) - [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc) @@ -44,10 +45,13 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications). ### Ports -`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465 -(smtps) is always active. Port **587** is published even when -`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless, -but it can look like an open port in external scans. +`deploy/docker-compose.yml` maps **465**, **587**, and **25** to the host. Port +465 (smtps) is always active. Port **587** is published even when +`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port +**25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not +accept inbound mail until you set it to `true` (see [Inbound +relay](#inbound-relay)). Harmless extra publishes can look like open ports in +external scans. ### Local trial @@ -185,6 +189,11 @@ expected to set; defaults match the code exactly. |---|---|---|---| | `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` | | `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` | +| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` | +| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound listener only (not 465/587). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` | +| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` | +| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` | +| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). | `26214400` (25 MiB) | `.env` | | `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` | | `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` | | `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` | @@ -313,8 +322,9 @@ against the PTR record the internet publishes for this server's IP (forward-confirmed reverse DNS) — see [Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing DNS. The **Reload configuration** button re-applies OpenDKIM tables and the -Postfix sender map from the database — use it if daemons drifted from what -the panel shows after manual edits under `/data`. +Postfix sender map from the database (and inbound relay maps when +`INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel +shows after manual edits under `/data`. ### Mail queue and System log @@ -346,12 +356,13 @@ are separate and are not changed here. There are two roles: - **Global administrator** — full access to every page and every domain, - including Users, Backup, Status, Mail queue, and System log. + including Users, Backup, Status, Mail queue, System log, and Inbound (when + the inbound relay flag is on). - **Domain-admin** — scoped to one or more domains assigned by a global administrator. Sees only those domains' pages, applications, and Deliveries rows; cannot add or delete domains. `/users`, `/backup`, - `/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not - reachable (404). A domain-admin can *export* the + `/status`, `/mail-queue`, `/system-log`, `/inbound`, and `POST /reload` are + not reachable (404). A domain-admin can *export* the domains assigned to them — see [Exporting and importing a single domain](#exporting-and-importing-a-single-domain). @@ -443,9 +454,10 @@ which tag to use. On the first successful start after restore, `data/manifest.js from the archive is **deleted** — it guards only that one boot, so a later in-place upgrade is not blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from -SQLite and both daemons are reloaded, healing any drift between the extracted -files and the database (the Status page's *Reload configuration* button runs -the same step on demand). This is why the compose file pins a fixed tag rather +SQLite (and inbound relay maps when `INBOUND_RELAY_ENABLE=true`) and both +daemons are reloaded, healing any drift between the extracted files and the +database (the Status page's *Reload configuration* button runs the same step on +demand). This is why the compose file pins a fixed tag rather than `:latest`: without a known version, there'd be no way to tell which image restoring a given backup actually requires (see [Fixed image tag](#fixed-image-tag)). @@ -579,6 +591,53 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" selfpost-backup > back With no password set, the CLI keeps writing the plain `.tar.gz` it always has. +### Inbound relay + +Optional backup-MX / forwarder: Postfix accepts mail on port **25** for +domains you list under *Inbound* and hands each message to the upstream host +you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never +stores the message locally. + +**Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the +container. Until then there is no `smtp inet` listener, no Inbound item in +the nav, and `/inbound` is 404. Outbound 465/587 is unchanged. + +**Panel** (`/inbound`, global administrator only): add a domain, set the +upstream host/port and TLS to that hop (opportunistic / required / off), and +choose recipients — an allow-list, or any address at that domain. A domain +with an empty upstream is kept in the database but is **not** published into +Postfix maps, so mail is never accepted with nowhere to send it. + +**DNS.** Unlike sending domains, an inbound domain needs an **MX** record that +points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish +(`10 .`) and a check that succeeds when *any* MX host matches this +server — other MX targets (a primary mail server) are fine; this is how +backup-MX is meant to work. Use *Re-check* after publishing. + +**Not an open relay.** The inbound smtpd offers no SASL. It accepts only +domains in `relay_domains` and only listed recipients (`relay_recipient_maps`); +everything else is `reject_unauth_destination` / `reject_unlisted_recipient`. +Prefer an explicit recipient list so unknown addresses are refused at RCPT +and never generate a bounce (backscatter). + +**Anti-spam.** SelfPost does not ship a filter. To attach one, set +`INBOUND_ANTISPAM_MILTER` (inbound listener only) and merge +[deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml) +the same way as the nginx/Caddy fragments: + +```sh +docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d +``` + +The milter sees the real client IP, HELO and PTR — unlike the upstream, which +only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar +does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to +defer instead. + +Inbound configuration lives in SQLite and `/data/postfix/` map files, so it +is included in a [full backup](#full-backup-and-restore). Single-domain +export/import is sending domains only. + ## Domain administration ### Domains page @@ -595,7 +654,9 @@ domain](#exporting-and-importing-a-single-domain). ### Domain-level DNS (SPF, DKIM, DMARC) -For *every* sending domain you add in the panel: +For *every* sending domain you add in the panel (outbound). An inbound +forwarding domain is a different object — it needs an MX, not these TXT +records; see [Inbound relay](#inbound-relay). - **SPF** — a TXT record on the domain authorizing this server to send on its behalf (e.g. `v=spf1 a mx ip4: -all`, adjusted to your setup). diff --git a/docs/plans/inbound-relay.md b/docs/plans/inbound-relay.md index 5d643ae..b66241d 100644 --- a/docs/plans/inbound-relay.md +++ b/docs/plans/inbound-relay.md @@ -175,18 +175,18 @@ Target version cut: **`1.4.0`** (MINOR). One commit per step; see [development.md](../development.md) § Plan checklists. UI reference: [panel-ui inbound mockups](../assets/panel-ui/inbound.html). -- [ ] Migration: inbound domain / recipient / transport tables under `/data` — **Opus** -- [ ] `INBOUND_RELAY_ENABLE` (default false) in entrypoint + `postfix-config.sh` — **Opus** -- [ ] `master.cf`: inbound `smtp inet` on 25; separate from 465/587 — **Opus** -- [ ] Generate `relay_domains`, `transport_maps`, `relay_recipient_maps` (injection-safe) — **Opus** -- [ ] `smtpd_relay_restrictions` / recipient maps — no open relay, no backscatter — **Opus** -- [ ] `internal/store` CRUD + validation (domain, host, port) — **Opus** -- [ ] Panel: list, add, domain detail, recipients, danger zone (mockups) — **Sonnet** -- [ ] Rate limit + `message_size_limit` on inbound smtpd — **Opus** -- [ ] Optional `INBOUND_ANTISPAM_MILTER` + compose fragment — **Opus** -- [ ] DNS MX copy in README/guide; `.env.example` — **Sonnet** -- [ ] Backup/export inbound config (per plan optional flag) — **Opus** -- [ ] Unit + handler tests; image build and container smoke — **Opus** -- [ ] [guide.md](../guide.md) and [security.md](../security.md) — **Sonnet** +- [x] Migration: inbound domain / recipient / transport tables under `/data` — **Opus** +- [x] `INBOUND_RELAY_ENABLE` (default false) in entrypoint + `postfix-config.sh` — **Opus** +- [x] `master.cf`: inbound `smtp inet` on 25; separate from 465/587 — **Opus** +- [x] Generate `relay_domains`, `transport_maps`, `relay_recipient_maps` (injection-safe) — **Opus** +- [x] `smtpd_relay_restrictions` / recipient maps — no open relay, no backscatter — **Opus** +- [x] `internal/store` CRUD + validation (domain, host, port) — **Opus** +- [x] Panel: list, add, domain detail, recipients, danger zone (mockups) — **Sonnet** +- [x] Rate limit + `message_size_limit` on inbound smtpd — **Opus** +- [x] Optional `INBOUND_ANTISPAM_MILTER` + compose fragment — **Opus** +- [x] DNS MX copy in README/guide; `.env.example` — **Sonnet** +- [x] Backup/export inbound config (per plan optional flag) — **Opus** +- [x] Unit + handler tests; image build and container smoke — **Opus** +- [x] [guide.md](../guide.md) and [security.md](../security.md) — **Sonnet** - [ ] Security review inbound path — **Fable** -- [ ] `go vet`, `go test`, e2e if applicable — **Haiku** +- [x] `go vet`, `go test`, e2e if applicable — **Haiku** diff --git a/docs/product.md b/docs/product.md index fe8ba1a..ece9c5e 100644 --- a/docs/product.md +++ b/docs/product.md @@ -50,18 +50,19 @@ Explicitly excluded to prevent scope creep: - Organisations / tenancy beyond global + domain-admin roles; managing **multiple sending domains** under one global administrator is in scope (see below) -- Inbound antispam/antivirus (rspamd, ClamAV, etc.) +- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may + expose a milter hook; it does not ship or start a filter - A custom MTA — Postfix is used as-is - Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only The **domain-admin** role ships in the current line (global administrator plus -domain administrators with assigned domains). The optional **inbound relay** is -the main agreed **1.x+** extension still on the -[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major -remains possible pending implementation. Items marked *candidate* in the -roadmap require explicit approval before coding (including **send-log retention -in Settings**, **30-day send statistics**, and **auto level-2 rate limits** — -see linked plans there). +domain administrators with assigned domains). The optional **inbound relay** +(backup-MX / forwarder on port 25) is an agreed 1.x extension — off by default +behind `INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. Items +marked *candidate* in the +[roadmap](roadmap.md) require explicit approval before coding (including +**send-log retention in Settings**, **30-day send statistics**, and **auto +level-2 rate limits** — see linked plans there). --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 5f4aadd..f889b37 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,7 +28,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | ID | Topic | Status | Progress | Plan | |---|---|---|---|---| -| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 0/15 | [plans/inbound-relay.md](plans/inbound-relay.md) | +| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 14/15 | [plans/inbound-relay.md](plans/inbound-relay.md) | | send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) | | domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) | | contributing | `CONTRIBUTING.md` | candidate | — | — | diff --git a/docs/security.md b/docs/security.md index 32e8bbf..0d9f962 100644 --- a/docs/security.md +++ b/docs/security.md @@ -10,7 +10,9 @@ login in the `saslpasswd2` argv 2026-08-13 full-tree review against this document — send-log authorization for domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed session creation, and application-delete ordering: no findings, and nothing -needed adding to the accepted risks. (2) **Accepted risks** — +needed adding to the accepted risks. The inbound-relay path (port 25, maps, +panel) is described under Mail path below and is **not yet** in that review +history — it awaits a separate Fable pass before 1.4.0. (2) **Accepted risks** — deliberate departures beyond the mandatory, recorded so the decision is not lost. @@ -77,8 +79,17 @@ The panel is exposed to the internet — the items below are **not optional**. ### Mail path (security-relevant) -- **Not an open relay** — SASL only; `reject_unauth_destination`; +- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`; `smtpd_sender_login_maps` + `reject_sender_login_mismatch`. +- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an + open relay either: SASL is off; `smtpd_relay_restrictions` / + `smtpd_recipient_restrictions` are `reject_unauth_destination` and + `reject_unlisted_recipient`; maps list only configured domains and + recipients. Domains with no upstream host are omitted from the maps so mail + is never accepted with nowhere to send it. Prefer recipient mode `list` to + refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached + on inbound. An optional antispam milter is inbound-only; default action is + fail-open (`accept`). - TLS is mandatory before credentials are transmitted (465 wrapper / 587 `encrypt`). - `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go index 3781664..5f1d99f 100644 --- a/internal/dnscheck/dnscheck.go +++ b/internal/dnscheck/dnscheck.go @@ -13,6 +13,7 @@ package dnscheck import ( "context" "errors" + "fmt" "net" "strings" "sync" @@ -98,6 +99,7 @@ type Checker struct { mu sync.Mutex servers map[string]cached[Server] domains map[string]cached[Domain] + inbound map[string]cached[Result] } type cached[T any] struct { @@ -120,6 +122,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker { domainTTL: domTTL, servers: make(map[string]cached[Server]), domains: make(map[string]cached[Domain]), + inbound: make(map[string]cached[Result]), } } @@ -172,9 +175,77 @@ func (c *Checker) Domain(q Query, force bool) Domain { func (c *Checker) Forget(domainName string) { c.mu.Lock() delete(c.domains, domainName) + delete(c.inbound, domainName) c.mu.Unlock() } +// InboundMX reports whether any MX for name points at this server's hostname +// (the inbound-relay check). Other MX values are the domain's own primaries +// and are not an error. force skips the cache. +func (c *Checker) InboundMX(name, hostname string, force bool) Result { + key := name + "\x00" + hostname + if !force { + c.mu.Lock() + entry, ok := c.inbound[key] + c.mu.Unlock() + if ok && time.Now().Before(entry.expires) { + return entry.value + } + } + + ctx, cancel := context.WithTimeout(context.Background(), c.timeout) + defer cancel() + result := c.checkInboundMX(ctx, name, hostname) + + c.mu.Lock() + c.inbound[key] = cached[Result]{value: result, expires: time.Now().Add(c.domainTTL)} + c.mu.Unlock() + return result +} + +func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result { + mxs, err := c.resolver.LookupMX(ctx, name) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return Result{ + Status: health.StatusError, + Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname), + } + } + return lookupFailed("the MX record", err) + } + if len(mxs) == 0 { + return Result{ + Status: health.StatusError, + Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname), + } + } + + want := normalizeName(hostname) + var records []string + matched := false + for _, mx := range mxs { + host := normalizeName(mx.Host) + records = append(records, fmt.Sprintf("%d %s.", mx.Pref, host)) + if host == want { + matched = true + } + } + if matched { + return Result{ + Status: health.StatusOK, + Detail: fmt.Sprintf("An MX points at %s (this server). Other MX values are the domain's own primaries — they are not an error.", hostname), + Records: records, + } + } + return Result{ + Status: health.StatusError, + Detail: fmt.Sprintf("No MX points at %s (this server). Publish the record below, or wait for DNS to propagate and Re-check.", hostname), + Records: records, + } +} + // checkDomain runs the three record checks concurrently: they are independent, // and in series three timeouts would stack up into a page that looks hung. func (c *Checker) checkDomain(ctx context.Context, q Query) Domain { diff --git a/internal/dnscheck/dnscheck_test.go b/internal/dnscheck/dnscheck_test.go index 9aae461..14fcdd2 100644 --- a/internal/dnscheck/dnscheck_test.go +++ b/internal/dnscheck/dnscheck_test.go @@ -374,3 +374,37 @@ func TestReportAuth(t *testing.T) { t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample()) } } + +func TestInboundMXPointsAtServer(t *testing.T) { + f := &fakeResolver{ + mx: map[string][]*net.MX{ + "lists.example.com": { + {Host: "mail.primary.example.net.", Pref: 10}, + {Host: "mail.example.org.", Pref: 20}, + }, + }, + } + got := newTestChecker(f).InboundMX("lists.example.com", "mail.example.org", false) + if got.Status != health.StatusOK { + t.Fatalf("status = %q (%s)", got.Status, got.Detail) + } +} + +func TestInboundMXMissingThisServer(t *testing.T) { + f := &fakeResolver{ + mx: map[string][]*net.MX{ + "backup.example.net": {{Host: "mail.primary.example.net.", Pref: 10}}, + }, + } + got := newTestChecker(f).InboundMX("backup.example.net", "mail.example.org", false) + if got.Status != health.StatusError { + t.Fatalf("status = %q, want error", got.Status) + } +} + +func TestInboundMXAbsent(t *testing.T) { + got := newTestChecker(&fakeResolver{}).InboundMX("none.example", "mail.example.org", false) + if got.Status != health.StatusError { + t.Fatalf("status = %q, want error", got.Status) + } +} diff --git a/internal/inbound/service.go b/internal/inbound/service.go new file mode 100644 index 0000000..bafc678 --- /dev/null +++ b/internal/inbound/service.go @@ -0,0 +1,156 @@ +// Package inbound owns backup-MX / relay-forwarder domains: the SQLite +// registry and the Postfix lookup tables (relay_domains, transport_maps, +// relay_recipient_maps, smtp_tls_policy_maps). It does not listen on port 25 +// itself — postfix-config.sh does that when INBOUND_RELAY_ENABLE is true. +package inbound + +import ( + "fmt" + "strings" + + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" +) + +// Maps is the slice of the Postfix manager the inbound service needs. +type Maps interface { + RebuildInboundMaps(routes []postfix.InboundRoute) error +} + +// Service coordinates inbound-domain state across SQLite and the Postfix maps. +type Service struct { + store *store.Store + maps Maps +} + +// NewService builds the inbound service over the shared store and Postfix maps. +func NewService(st *store.Store, maps Maps) *Service { + return &Service{store: st, maps: maps} +} + +// List returns every inbound domain. +func (s *Service) List() ([]store.InboundDomain, error) { + return s.store.ListInboundDomains() +} + +// Get returns one inbound domain by id. +func (s *Service) Get(id int64) (store.InboundDomain, error) { + return s.store.GetInboundDomain(id) +} + +// Add validates the domain name, inserts it, and rebuilds the maps. +func (s *Service) Add(name string) (store.InboundDomain, error) { + name = normalizeDomain(name) + if err := checkDomain(name); err != nil { + return store.InboundDomain{}, err + } + d, err := s.store.AddInboundDomain(name) + if err != nil { + return store.InboundDomain{}, err + } + if err := s.Resync(); err != nil { + _ = s.store.DeleteInboundDomain(d.ID) + return store.InboundDomain{}, err + } + return d, nil +} + +// SetTransport validates and saves the upstream, then rebuilds the maps. +func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error { + if _, err := s.store.GetInboundDomain(id); err != nil { + return err + } + host = normalizeHost(host) + if err := checkHost(host); err != nil { + return err + } + port, err := parsePort(portRaw) + if err != nil { + return err + } + if err := checkTLSMode(tlsMode); err != nil { + return err + } + if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil { + return err + } + return s.Resync() +} + +// SetRecipients validates the mode and, in list mode, every address, then +// rebuilds the maps. +func (s *Service) SetRecipients(id int64, mode string, rawAddresses []string) error { + d, err := s.store.GetInboundDomain(id) + if err != nil { + return err + } + if err := checkRecipientMode(mode); err != nil { + return err + } + var addrs []string + if mode == store.RecipientModeList { + addrs, err = parseRecipientAddresses(rawAddresses, d.Name) + if err != nil { + return err + } + } + if err := s.store.UpdateInboundRecipients(id, mode, addrs); err != nil { + return err + } + return s.Resync() +} + +// Delete removes the domain and rebuilds the maps. +func (s *Service) Delete(id int64) error { + if err := s.store.DeleteInboundDomain(id); err != nil { + return err + } + return s.Resync() +} + +// Resync rebuilds the inbound Postfix maps from SQLite. +func (s *Service) Resync() error { + list, err := s.store.ListInboundDomains() + if err != nil { + return err + } + routes := make([]postfix.InboundRoute, 0, len(list)) + for _, d := range list { + full, err := s.store.GetInboundDomain(d.ID) + if err != nil { + return err + } + routes = append(routes, postfix.InboundRoute{ + Domain: full.Name, + Host: full.Host, + Port: full.Port, + TLSMode: full.TLSMode, + RecipientMode: full.RecipientMode, + Recipients: full.Recipients, + }) + } + return s.maps.RebuildInboundMaps(routes) +} + +func parseRecipientAddresses(raw []string, domain string) ([]string, error) { + seen := make(map[string]bool) + out := make([]string, 0, len(raw)) + for _, r := range raw { + addr := strings.ToLower(strings.TrimSpace(r)) + if addr == "" { + continue + } + if err := checkMailbox(addr, domain); err != nil { + return nil, err + } + if seen[addr] { + continue + } + seen[addr] = true + out = append(out, addr) + } + if len(out) == 0 { + return nil, fmt.Errorf("listed-recipients mode requires at least one address") + } + return out, nil +} diff --git a/internal/inbound/service_test.go b/internal/inbound/service_test.go new file mode 100644 index 0000000..e970041 --- /dev/null +++ b/internal/inbound/service_test.go @@ -0,0 +1,111 @@ +package inbound + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" +) + +type fakeMaps struct { + routes []postfix.InboundRoute + err error + rebuild int +} + +func (f *fakeMaps) RebuildInboundMaps(routes []postfix.InboundRoute) error { + f.rebuild++ + f.routes = append([]postfix.InboundRoute(nil), routes...) + return f.err +} + +func testService(t *testing.T) (*Service, *store.Store, *fakeMaps) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + m := &fakeMaps{} + return NewService(st, m), st, m +} + +func TestAddAndSetTransport(t *testing.T) { + s, _, m := testService(t) + d, err := s.Add("Lists.Example.COM") + if err != nil { + t.Fatal(err) + } + if d.Name != "lists.example.com" { + t.Fatalf("name = %q", d.Name) + } + if m.rebuild != 1 { + t.Fatalf("rebuild after add = %d", m.rebuild) + } + // Empty host is omitted from maps. + if len(m.routes) != 1 || m.routes[0].Host != "" { + t.Fatalf("routes after add: %+v", m.routes) + } + + if err := s.SetTransport(d.ID, "10.0.0.8", "25", store.TLSModeEncrypt); err != nil { + t.Fatal(err) + } + if m.routes[0].Host != "10.0.0.8" || m.routes[0].TLSMode != store.TLSModeEncrypt { + t.Fatalf("routes after transport: %+v", m.routes[0]) + } +} + +func TestSetRecipientsValidatesDomain(t *testing.T) { + s, _, _ := testService(t) + d, err := s.Add("lists.example.com") + if err != nil { + t.Fatal(err) + } + err = s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@other.com"}) + if err == nil { + t.Fatal("expected foreign-domain error") + } + err = s.SetRecipients(d.ID, store.RecipientModeList, nil) + if err == nil { + t.Fatal("expected empty-list error") + } + if err := s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@lists.example.com"}); err != nil { + t.Fatal(err) + } +} + +func TestRejectsUnsafeHost(t *testing.T) { + s, _, _ := testService(t) + d, err := s.Add("lists.example.com") + if err != nil { + t.Fatal(err) + } + if err := s.SetTransport(d.ID, "10.0.0.8; rm", "25", store.TLSModeMay); err == nil { + t.Fatal("expected unsafe host to be rejected") + } +} + +func TestDeleteResyncs(t *testing.T) { + s, _, m := testService(t) + d, err := s.Add("lists.example.com") + if err != nil { + t.Fatal(err) + } + if err := s.Delete(d.ID); err != nil { + t.Fatal(err) + } + if !errors.Is(mustGet(t, s, d.ID), store.ErrInboundDomainNotFound) { + t.Fatal("domain still present") + } + if len(m.routes) != 0 { + t.Fatalf("maps after delete: %+v", m.routes) + } +} + +func mustGet(t *testing.T, s *Service, id int64) error { + t.Helper() + _, err := s.Get(id) + return err +} diff --git a/internal/inbound/validate.go b/internal/inbound/validate.go new file mode 100644 index 0000000..eb4139a --- /dev/null +++ b/internal/inbound/validate.go @@ -0,0 +1,131 @@ +package inbound + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +func normalizeDomain(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func normalizeHost(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + return host +} + +func checkDomain(name string) error { + if name == "" { + return fmt.Errorf("domain is required") + } + if len(name) > 253 { + return fmt.Errorf("domain must be at most 253 characters") + } + labels := strings.Split(name, ".") + if len(labels) < 2 { + return fmt.Errorf("domain must include at least one dot (e.g. example.com)") + } + for _, label := range labels { + if err := checkLabel(label); err != nil { + return err + } + } + return nil +} + +func checkHost(host string) error { + if host == "" { + return fmt.Errorf("host is required") + } + if len(host) > 253 { + return fmt.Errorf("host must be at most 253 characters") + } + if ip := net.ParseIP(host); ip != nil { + return nil + } + for _, label := range strings.Split(host, ".") { + if err := checkLabel(label); err != nil { + return fmt.Errorf("host is invalid: %w", err) + } + } + return nil +} + +func checkLabel(label string) error { + if len(label) == 0 { + return fmt.Errorf("must not contain an empty label") + } + if len(label) > 63 { + return fmt.Errorf("each label must be at most 63 characters") + } + if label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("labels must not start or end with '-'") + } + for i := 0; i < len(label); i++ { + c := label[i] + lower := c >= 'a' && c <= 'z' + digit := c >= '0' && c <= '9' + if !lower && !digit && c != '-' { + return fmt.Errorf("may contain only lower-case letters, digits, '.' and '-'") + } + } + return nil +} + +func parsePort(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, fmt.Errorf("port is required") + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 65535 { + return 0, fmt.Errorf("port must be between 1 and 65535") + } + return n, nil +} + +func checkTLSMode(mode string) error { + switch mode { + case "may", "encrypt", "none": + return nil + default: + return fmt.Errorf("invalid TLS mode") + } +} + +func checkRecipientMode(mode string) error { + switch mode { + case "list", "any": + return nil + default: + return fmt.Errorf("invalid recipient mode") + } +} + +func checkMailbox(addr, domain string) error { + at := strings.LastIndexByte(addr, '@') + if at <= 0 || at >= len(addr)-1 { + return fmt.Errorf("%q is not a valid email address", addr) + } + local, host := addr[:at], addr[at+1:] + if host != domain { + return fmt.Errorf("%q does not belong to domain %s", addr, domain) + } + if local == "" || local[0] == '.' || local[len(local)-1] == '.' { + return fmt.Errorf("%q: invalid local part", addr) + } + for i := 0; i < len(local); i++ { + c := local[i] + lower := c >= 'a' && c <= 'z' + digit := c >= '0' && c <= '9' + if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' { + return fmt.Errorf("%q: local part contains invalid characters", addr) + } + } + return nil +} diff --git a/internal/postfix/inbound.go b/internal/postfix/inbound.go new file mode 100644 index 0000000..680f2ac --- /dev/null +++ b/internal/postfix/inbound.go @@ -0,0 +1,130 @@ +package postfix + +import ( + "fmt" + "net" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// InboundRoute is one inbound domain's Postfix map material: the relay +// domain, the next-hop transport, the TLS policy for that hop, and the +// recipient list (or a domain catch-all). +type InboundRoute struct { + Domain string + Host string + Port int + TLSMode string + RecipientMode string + Recipients []string +} + +func (p *Postfix) inboundMapPaths() (relayDomains, transport, recipients, tlsPolicy string) { + dir := filepath.Dir(p.senderLoginMapsPath) + return filepath.Join(dir, "relay_domains"), + filepath.Join(dir, "transport"), + filepath.Join(dir, "relay_recipients"), + filepath.Join(dir, "tls_policy") +} + +// RebuildInboundMaps regenerates the inbound relay lookup tables from the full +// set of configured routes and reloads Postfix. Domains with an empty host are +// omitted so mail is never accepted with nowhere to send it. Full regeneration +// keeps the files a pure function of the registry (security.md). +func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error { + relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes) + if err != nil { + return err + } + rd, tr, rc, tl := p.inboundMapPaths() + if err := writeFileAtomic(rd, relay, 0o640); err != nil { + return err + } + if err := writeFileAtomic(tr, transport, 0o640); err != nil { + return err + } + if err := writeFileAtomic(rc, recipients, 0o640); err != nil { + return err + } + if err := writeFileAtomic(tl, tlsPolicy, 0o640); err != nil { + return err + } + return p.reload() +} + +func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) { + sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain }) + + var relayB, transportB, recipB, tlsB strings.Builder + for _, r := range routes { + if strings.TrimSpace(r.Host) == "" { + continue + } + if err := assertInboundRouteSafe(r); err != nil { + return nil, nil, nil, nil, err + } + nexthop := inboundNexthop(r.Host, r.Port) + fmt.Fprintf(&relayB, "%s OK\n", r.Domain) + fmt.Fprintf(&transportB, "%s smtp:%s\n", r.Domain, nexthop) + fmt.Fprintf(&tlsB, "%s %s\n", nexthop, r.TLSMode) + switch r.RecipientMode { + case "any": + fmt.Fprintf(&recipB, "@%s OK\n", r.Domain) + default: + addrs := append([]string(nil), r.Recipients...) + sort.Strings(addrs) + for _, addr := range addrs { + fmt.Fprintf(&recipB, "%s OK\n", addr) + } + } + } + return []byte(relayB.String()), []byte(transportB.String()), []byte(recipB.String()), []byte(tlsB.String()), nil +} + +// inboundNexthop is the Postfix next-hop [host]:port form that disables MX +// lookup for the explicit upstream. +func inboundNexthop(host string, port int) string { + if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { + return "[" + host + "]:" + strconv.Itoa(port) + } + return "[" + host + "]:" + strconv.Itoa(port) +} + +func assertInboundRouteSafe(r InboundRoute) error { + if err := assertMapToken(r.Domain, "domain"); err != nil { + return err + } + if err := assertMapToken(r.Host, "host"); err != nil { + return err + } + if r.Port < 1 || r.Port > 65535 { + return fmt.Errorf("postfix: invalid inbound port %d", r.Port) + } + switch r.TLSMode { + case "may", "encrypt", "none": + default: + return fmt.Errorf("postfix: invalid tls mode %q", r.TLSMode) + } + if r.RecipientMode != "list" && r.RecipientMode != "any" { + return fmt.Errorf("postfix: invalid recipient mode %q", r.RecipientMode) + } + for _, addr := range r.Recipients { + if err := assertMapToken(addr, "recipient"); err != nil { + return err + } + } + return nil +} + +// assertMapToken rejects values that could break out of a texthash line. +func assertMapToken(v, what string) error { + if v == "" { + return fmt.Errorf("postfix: empty %s", what) + } + if strings.ContainsAny(v, " \t\r\n,\\") { + return fmt.Errorf("postfix: unsafe character in %s %q", what, v) + } + return nil +} diff --git a/internal/postfix/inbound_test.go b/internal/postfix/inbound_test.go new file mode 100644 index 0000000..ea26d0d --- /dev/null +++ b/internal/postfix/inbound_test.go @@ -0,0 +1,119 @@ +package postfix + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRenderInboundMaps(t *testing.T) { + routes := []InboundRoute{ + { + Domain: "zeta.example", Host: "192.0.2.20", Port: 25, + TLSMode: "none", RecipientMode: "any", + }, + { + Domain: "lists.example.com", Host: "10.0.0.8", Port: 25, + TLSMode: "encrypt", RecipientMode: "list", + Recipients: []string{"staff@lists.example.com", "abuse@lists.example.com"}, + }, + { + Domain: "pending.example", Host: "", Port: 25, + TLSMode: "may", RecipientMode: "list", + }, + } + relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes) + if err != nil { + t.Fatal(err) + } + wantRelay := "lists.example.com OK\nzeta.example OK\n" + if string(relay) != wantRelay { + t.Errorf("relay_domains =\n%q\nwant\n%q", relay, wantRelay) + } + wantTransport := "lists.example.com smtp:[10.0.0.8]:25\nzeta.example smtp:[192.0.2.20]:25\n" + if string(transport) != wantTransport { + t.Errorf("transport =\n%q\nwant\n%q", transport, wantTransport) + } + wantRecipients := "abuse@lists.example.com OK\nstaff@lists.example.com OK\n@zeta.example OK\n" + if string(recipients) != wantRecipients { + t.Errorf("relay_recipients =\n%q\nwant\n%q", recipients, wantRecipients) + } + wantTLS := "[10.0.0.8]:25 encrypt\n[192.0.2.20]:25 none\n" + if string(tlsPolicy) != wantTLS { + t.Errorf("tls_policy =\n%q\nwant\n%q", tlsPolicy, wantTLS) + } +} + +func TestRenderInboundMapsIPv6(t *testing.T) { + routes := []InboundRoute{{ + Domain: "v6.example", Host: "2001:db8::1", Port: 25, + TLSMode: "may", RecipientMode: "any", + }} + _, transport, _, tlsPolicy, err := renderInboundMaps(routes) + if err != nil { + t.Fatal(err) + } + if string(transport) != "v6.example smtp:[2001:db8::1]:25\n" { + t.Errorf("transport = %q", transport) + } + if string(tlsPolicy) != "[2001:db8::1]:25 may\n" { + t.Errorf("tls_policy = %q", tlsPolicy) + } +} + +func TestRenderInboundMapsRejectsInjection(t *testing.T) { + bad := []InboundRoute{ + {Domain: "ex ample.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "any"}, + {Domain: "example.com", Host: "10.0.0.1\nrelay", Port: 25, TLSMode: "may", RecipientMode: "any"}, + {Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "evil", RecipientMode: "any"}, + {Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "list", + Recipients: []string{"a@example.com OK\nb@evil.com"}}, + } + for i, r := range bad { + if _, _, _, _, err := renderInboundMaps([]InboundRoute{r}); err == nil { + t.Errorf("case %d: expected injection rejection", i) + } + } +} + +func TestRebuildInboundMapsWritesAndReloads(t *testing.T) { + p, reloads := newTestPostfix(t) + err := p.RebuildInboundMaps([]InboundRoute{{ + Domain: "lists.example.com", Host: "10.0.0.8", Port: 25, + TLSMode: "encrypt", RecipientMode: "any", + }}) + if err != nil { + t.Fatal(err) + } + if *reloads != 1 { + t.Errorf("reload called %d times, want 1", *reloads) + } + rd, _, _, _ := p.inboundMapPaths() + data, err := os.ReadFile(rd) + if err != nil { + t.Fatal(err) + } + if string(data) != "lists.example.com OK\n" { + t.Errorf("relay_domains file = %q", data) + } + if filepath.Base(rd) != "relay_domains" { + t.Errorf("unexpected path %s", rd) + } +} + +func TestRebuildInboundMapsEmptyOmitsPending(t *testing.T) { + p, _ := newTestPostfix(t) + if err := p.RebuildInboundMaps(nil); err != nil { + t.Fatal(err) + } + rd, tr, rc, tl := p.inboundMapPaths() + for _, path := range []string{rd, tr, rc, tl} { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(data) != 0 { + t.Errorf("%s not empty: %q", path, data) + } + } +} diff --git a/internal/store/inbound.go b/internal/store/inbound.go new file mode 100644 index 0000000..b8ad492 --- /dev/null +++ b/internal/store/inbound.go @@ -0,0 +1,249 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// Recipient modes for an inbound domain. Kept in sync with the CHECK constraint. +const ( + RecipientModeList = "list" // only explicitly listed addresses + RecipientModeAny = "any" // any address at the domain +) + +// TLS modes for the hand-off to the upstream. Values are Postfix +// smtp_tls_policy_maps levels: may (opportunistic), encrypt (required), none. +const ( + TLSModeMay = "may" + TLSModeEncrypt = "encrypt" + TLSModeNone = "none" +) + +// ErrInboundDomainExists is returned when the inbound domain is already configured. +var ErrInboundDomainExists = errors.New("inbound domain already exists") + +// ErrInboundDomainNotFound is returned when an inbound domain id/name does not exist. +var ErrInboundDomainNotFound = errors.New("inbound domain not found") + +// InboundDomain is a backup-MX / forwarder domain. Host may be empty until the +// operator saves an upstream; map generation skips those rows so mail is never +// accepted with nowhere to send it. RecipientCount is populated by listing +// queries; Recipients is populated by Get. +type InboundDomain struct { + ID int64 + Name string + RecipientMode string + Host string + Port int + TLSMode string + CreatedAt time.Time + RecipientCount int + Recipients []string +} + +// AddInboundDomain inserts a new inbound domain with a default transport +// (empty host, port 25, opportunistic TLS) and listed-recipients mode. The +// caller must have validated name (security.md). +func (s *Store) AddInboundDomain(name string) (InboundDomain, error) { + now := time.Now().UTC() + tx, err := s.db.Begin() + if err != nil { + return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec( + "INSERT INTO inbound_domains (name, recipient_mode, created_at) VALUES (?, ?, ?)", + name, RecipientModeList, now.Format(time.RFC3339), + ) + if err != nil { + if isUniqueViolation(err) { + return InboundDomain{}, ErrInboundDomainExists + } + return InboundDomain{}, fmt.Errorf("insert inbound domain: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return InboundDomain{}, fmt.Errorf("inbound domain id: %w", err) + } + if _, err := tx.Exec( + "INSERT INTO inbound_transports (inbound_domain_id, host, port, tls_mode) VALUES (?, '', 25, ?)", + id, TLSModeMay, + ); err != nil { + return InboundDomain{}, fmt.Errorf("insert inbound transport: %w", err) + } + if err := tx.Commit(); err != nil { + return InboundDomain{}, fmt.Errorf("commit add inbound domain: %w", err) + } + return InboundDomain{ + ID: id, + Name: name, + RecipientMode: RecipientModeList, + Port: 25, + TLSMode: TLSModeMay, + CreatedAt: now, + }, nil +} + +// ListInboundDomains returns every inbound domain with its transport and +// recipient count, ordered by name. +func (s *Store) ListInboundDomains() ([]InboundDomain, error) { + rows, err := s.db.Query(` + SELECT d.id, d.name, d.recipient_mode, d.created_at, + t.host, t.port, t.tls_mode, + (SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id) + FROM inbound_domains d + INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id + ORDER BY d.name`) + if err != nil { + return nil, fmt.Errorf("list inbound domains: %w", err) + } + defer rows.Close() + + var out []InboundDomain + for rows.Next() { + d, err := scanInboundDomain(rows) + if err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// GetInboundDomain returns one inbound domain with its recipient list, or +// ErrInboundDomainNotFound. +func (s *Store) GetInboundDomain(id int64) (InboundDomain, error) { + row := s.db.QueryRow(` + SELECT d.id, d.name, d.recipient_mode, d.created_at, + t.host, t.port, t.tls_mode, + (SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id) + FROM inbound_domains d + INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id + WHERE d.id = ?`, id) + d, err := scanInboundDomain(row) + if errors.Is(err, sql.ErrNoRows) { + return InboundDomain{}, ErrInboundDomainNotFound + } + if err != nil { + return InboundDomain{}, err + } + addrs, err := s.listInboundRecipients(id) + if err != nil { + return InboundDomain{}, err + } + d.Recipients = addrs + return d, nil +} + +// UpdateInboundTransport sets the upstream host, port and TLS mode. +func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode string) error { + res, err := s.db.Exec( + "UPDATE inbound_transports SET host = ?, port = ?, tls_mode = ? WHERE inbound_domain_id = ?", + host, port, tlsMode, id, + ) + if err != nil { + return fmt.Errorf("update inbound transport: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update inbound transport rows: %w", err) + } + if n == 0 { + return ErrInboundDomainNotFound + } + return nil +} + +// UpdateInboundRecipients replaces the recipient mode and, in list mode, the +// address list. In any mode the stored list is cleared. +func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin update inbound recipients: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec("UPDATE inbound_domains SET recipient_mode = ? WHERE id = ?", mode, id) + if err != nil { + return fmt.Errorf("update inbound recipient mode: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update inbound recipient mode rows: %w", err) + } + if n == 0 { + return ErrInboundDomainNotFound + } + if _, err := tx.Exec("DELETE FROM inbound_recipients WHERE inbound_domain_id = ?", id); err != nil { + return fmt.Errorf("clear inbound recipients: %w", err) + } + if mode == RecipientModeList { + for _, addr := range addresses { + if _, err := tx.Exec( + "INSERT INTO inbound_recipients (inbound_domain_id, address) VALUES (?, ?)", + id, addr, + ); err != nil { + return fmt.Errorf("insert inbound recipient: %w", err) + } + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit update inbound recipients: %w", err) + } + return nil +} + +// DeleteInboundDomain removes an inbound domain and its transport/recipients +// (ON DELETE CASCADE). Returns ErrInboundDomainNotFound if no such row existed. +func (s *Store) DeleteInboundDomain(id int64) error { + res, err := s.db.Exec("DELETE FROM inbound_domains WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete inbound domain: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("delete inbound domain rows: %w", err) + } + if n == 0 { + return ErrInboundDomainNotFound + } + return nil +} + +func (s *Store) listInboundRecipients(id int64) ([]string, error) { + rows, err := s.db.Query( + "SELECT address FROM inbound_recipients WHERE inbound_domain_id = ? ORDER BY address", + id, + ) + if err != nil { + return nil, fmt.Errorf("list inbound recipients: %w", err) + } + defer rows.Close() + var out []string + for rows.Next() { + var addr string + if err := rows.Scan(&addr); err != nil { + return nil, err + } + out = append(out, addr) + } + return out, rows.Err() +} + +func scanInboundDomain(r scanRow) (InboundDomain, error) { + var ( + d InboundDomain + createdAt string + ) + if err := r.Scan( + &d.ID, &d.Name, &d.RecipientMode, &createdAt, + &d.Host, &d.Port, &d.TLSMode, &d.RecipientCount, + ); err != nil { + return InboundDomain{}, err + } + d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + return d, nil +} diff --git a/internal/store/inbound_test.go b/internal/store/inbound_test.go new file mode 100644 index 0000000..71de744 --- /dev/null +++ b/internal/store/inbound_test.go @@ -0,0 +1,101 @@ +package store + +import ( + "errors" + "testing" +) + +func TestInboundDomainCRUD(t *testing.T) { + st := openTestStore(t) + + d, err := st.AddInboundDomain("lists.example.com") + if err != nil { + t.Fatalf("AddInboundDomain: %v", err) + } + if d.ID == 0 || d.Name != "lists.example.com" || d.RecipientMode != RecipientModeList { + t.Fatalf("unexpected domain: %+v", d) + } + if d.Port != 25 || d.TLSMode != TLSModeMay || d.Host != "" { + t.Fatalf("unexpected default transport: %+v", d) + } + + if _, err := st.AddInboundDomain("lists.example.com"); !errors.Is(err, ErrInboundDomainExists) { + t.Fatalf("duplicate error = %v, want ErrInboundDomainExists", err) + } + + if err := st.UpdateInboundTransport(d.ID, "10.0.0.8", 25, TLSModeEncrypt); err != nil { + t.Fatalf("UpdateInboundTransport: %v", err) + } + addrs := []string{"staff@lists.example.com", "postmaster@lists.example.com"} + if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, addrs); err != nil { + t.Fatalf("UpdateInboundRecipients: %v", err) + } + + got, err := st.GetInboundDomain(d.ID) + if err != nil { + t.Fatalf("GetInboundDomain: %v", err) + } + if got.Host != "10.0.0.8" || got.TLSMode != TLSModeEncrypt || got.RecipientCount != 2 { + t.Fatalf("get after update: %+v", got) + } + if len(got.Recipients) != 2 || got.Recipients[0] != "postmaster@lists.example.com" { + t.Fatalf("recipients not sorted: %v", got.Recipients) + } + + list, err := st.ListInboundDomains() + if err != nil { + t.Fatalf("ListInboundDomains: %v", err) + } + if len(list) != 1 || list[0].RecipientCount != 2 { + t.Fatalf("list: %+v", list) + } + + if err := st.UpdateInboundRecipients(d.ID, RecipientModeAny, nil); err != nil { + t.Fatalf("switch to any: %v", err) + } + got, err = st.GetInboundDomain(d.ID) + if err != nil { + t.Fatal(err) + } + if got.RecipientMode != RecipientModeAny || got.RecipientCount != 0 || len(got.Recipients) != 0 { + t.Fatalf("any mode should clear the list: %+v", got) + } + + if err := st.DeleteInboundDomain(d.ID); err != nil { + t.Fatalf("DeleteInboundDomain: %v", err) + } + assertCount(t, st, "inbound_domains", 0) + assertCount(t, st, "inbound_transports", 0) + assertCount(t, st, "inbound_recipients", 0) + if _, err := st.GetInboundDomain(d.ID); !errors.Is(err, ErrInboundDomainNotFound) { + t.Fatalf("Get after delete = %v, want ErrInboundDomainNotFound", err) + } +} + +func TestInboundDomainNotFound(t *testing.T) { + st := openTestStore(t) + if _, err := st.GetInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) { + t.Fatalf("GetInboundDomain(missing) = %v", err) + } + if err := st.UpdateInboundTransport(99, "10.0.0.1", 25, TLSModeNone); !errors.Is(err, ErrInboundDomainNotFound) { + t.Fatalf("UpdateInboundTransport(missing) = %v", err) + } + if err := st.DeleteInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) { + t.Fatalf("DeleteInboundDomain(missing) = %v", err) + } +} + +func TestInboundDeleteCascadesRecipients(t *testing.T) { + st := openTestStore(t) + d, err := st.AddInboundDomain("backup.example.net") + if err != nil { + t.Fatal(err) + } + if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, []string{"a@backup.example.net"}); err != nil { + t.Fatal(err) + } + if err := st.DeleteInboundDomain(d.ID); err != nil { + t.Fatal(err) + } + assertCount(t, st, "inbound_recipients", 0) +} diff --git a/internal/store/migrations/0006_inbound_relay.sql b/internal/store/migrations/0006_inbound_relay.sql new file mode 100644 index 0000000..c80f88d --- /dev/null +++ b/internal/store/migrations/0006_inbound_relay.sql @@ -0,0 +1,26 @@ +-- Optional inbound relay (backup-MX / forwarder). Separate from sending +-- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the +-- listener, Postfix maps and panel UI are generated only when that flag is on. + +CREATE TABLE inbound_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')), + created_at TEXT NOT NULL +); + +-- One upstream per inbound domain (host:port + TLS policy for the hand-off). +CREATE TABLE inbound_transports ( + inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE, + host TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none')) +); + +-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'. +CREATE TABLE inbound_recipients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE, + address TEXT NOT NULL, + UNIQUE (inbound_domain_id, address) +); diff --git a/internal/store/store.go b/internal/store/store.go index c211e97..5841ae5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,8 +1,8 @@ // Package store owns the SelfPost SQLite database: the single file under /data // that persists panel users (global administrators and domain-admins), sending -// domains and applications, the send log and rate-limit settings -// (architecture.md § Persistence). It exposes typed queries so the rest of the -// panel never builds SQL by hand. +// domains and applications, optional inbound-relay domains, the send log and +// rate-limit settings (architecture.md § Persistence). It exposes typed queries +// so the rest of the panel never builds SQL by hand. package store import ( diff --git a/internal/web/handlers/authz_test.go b/internal/web/handlers/authz_test.go index 017f771..393395f 100644 --- a/internal/web/handlers/authz_test.go +++ b/internal/web/handlers/authz_test.go @@ -49,6 +49,15 @@ var globalOnlyRoutes = []route{ {"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}}, {"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}}, {"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil}, + + {"GET", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleInboundList }, nil}, + {"POST", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil}, + {"GET", "/inbound/1", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDetail }, map[string]string{"id": "1"}}, + {"POST", "/inbound/1/dns-recheck", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDNSRecheck }, map[string]string{"id": "1"}}, + {"POST", "/inbound/1/upstream", func(h *Handlers) http.HandlerFunc { return h.HandleInboundTransport }, map[string]string{"id": "1"}}, + {"POST", "/inbound/1/recipients", func(h *Handlers) http.HandlerFunc { return h.HandleInboundRecipients }, map[string]string{"id": "1"}}, + {"GET", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}}, + {"POST", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}}, } // A domain administrator has an account on the panel, so authentication is not diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index 33bf795..db6787a 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -8,6 +8,7 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" @@ -34,6 +35,9 @@ type Config struct { // when the HTTP role starts (architecture.md). The Mail queue card and // delivery history read it from here; they never call postconf. RetryPolicy postfix.RetryPolicy + // InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and + // routes exist only when this is true. + InboundEnabled bool } // Handlers holds dependencies for authenticated panel routes. @@ -41,6 +45,7 @@ type Handlers struct { store *store.Store domains *domain.Service apps *app.Service + inbound *inbound.Service cfg Config view *view.Engine dns *dnscheck.Checker @@ -53,6 +58,7 @@ func New( st *store.Store, domains *domain.Service, apps *app.Service, + inboundSvc *inbound.Service, cfg Config, v *view.Engine, dns *dnscheck.Checker, @@ -63,6 +69,7 @@ func New( store: st, domains: domains, apps: apps, + inbound: inboundSvc, cfg: cfg, view: v, dns: dns, diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go index b3ea23e..579cf45 100644 --- a/internal/web/handlers/handlers_domains.go +++ b/internal/web/handlers/handlers_domains.go @@ -172,6 +172,13 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) { http.Error(w, "reload failed", http.StatusInternalServerError) return } + if h.cfg.InboundEnabled && h.inbound != nil { + if err := h.inbound.Resync(); err != nil { + logf("panel: manual reload (inbound): %v", err) + http.Error(w, "reload failed", http.StatusInternalServerError) + return + } + } http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther) } diff --git a/internal/web/handlers/handlers_inbound.go b/internal/web/handlers/handlers_inbound.go new file mode 100644 index 0000000..5a8d873 --- /dev/null +++ b/internal/web/handlers/handlers_inbound.go @@ -0,0 +1,319 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/validate" +) + +type inboundRow struct { + store.InboundDomain + DNS health.Status + Upstream string + TLSLabel string + RcptLabel string +} + +func (h *Handlers) requireInbound(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) { + if !h.cfg.InboundEnabled || h.inbound == nil { + http.NotFound(w, r) + return auth.Principal{}, false + } + return h.requireGlobal(w, r) +} + +func (h *Handlers) lookupInbound(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) { + if h.inbound == nil { + http.NotFound(w, r) + return store.InboundDomain{}, false + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return store.InboundDomain{}, false + } + d, err := h.inbound.Get(id) + if err != nil { + if errors.Is(err, store.ErrInboundDomainNotFound) { + http.NotFound(w, r) + return store.InboundDomain{}, false + } + logf("panel: get inbound domain %d: %v", id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return store.InboundDomain{}, false + } + return d, true +} + +// HandleInboundList is the inbound-relay domain list (global administrators only). +func (h *Handlers) HandleInboundList(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + h.renderInboundList(w, r, http.StatusOK, "", "") +} + +func (h *Handlers) renderInboundList(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) { + list, err := h.inbound.List() + if err != nil { + logf("panel: inbound list: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + data := h.pageBase(r) + data["Title"] = "SelfPost — inbound" + data["Active"] = "inbound" + data["Domains"] = h.inboundRows(list) + data["Error"] = formErr + data["FormName"] = formName + if r.URL.Query().Get("deleted") != "" { + data["Flash"] = "Inbound domain deleted." + } + h.view.Render(w, status, "inbound", data) +} + +func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow { + rows := make([]inboundRow, len(domains)) + var wg sync.WaitGroup + for i, d := range domains { + rows[i] = inboundRow{ + InboundDomain: d, + DNS: health.StatusUnknown, + Upstream: inboundUpstream(d), + TLSLabel: tlsLabel(d.TLSMode), + RcptLabel: rcptLabel(d), + } + wg.Add(1) + go func() { + defer wg.Done() + if h.dns != nil && h.cfg.Hostname != "" { + rows[i].DNS = h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status + } + }() + } + wg.Wait() + return rows +} + +func inboundUpstream(d store.InboundDomain) string { + if d.Host == "" { + return "—" + } + return fmt.Sprintf("%s:%d", d.Host, d.Port) +} + +func tlsLabel(mode string) string { + switch mode { + case store.TLSModeEncrypt: + return "required" + case store.TLSModeNone: + return "off" + default: + return "opportunistic" + } +} + +func tlsStatusClass(mode string) string { + if mode == store.TLSModeEncrypt { + return "ok" + } + return "unknown" +} + +func rcptLabel(d store.InboundDomain) string { + if d.RecipientMode == store.RecipientModeAny { + return "any" + } + n := d.RecipientCount + if n == 1 { + return "1 listed" + } + return fmt.Sprintf("%d listed", n) +} + +// HandleAddInbound validates the name, creates the inbound domain, and +// redirects to its page. +func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + if err := r.ParseForm(); err != nil { + h.renderInboundList(w, r, http.StatusBadRequest, "Invalid form submission.", "") + return + } + raw := r.PostFormValue("name") + name := validate.NormalizeDomain(raw) + if err := validate.Domain(name); err != nil { + h.renderInboundList(w, r, http.StatusBadRequest, err.Error(), raw) + return + } + d, err := h.inbound.Add(name) + if err != nil { + if errors.Is(err, store.ErrInboundDomainExists) { + h.renderInboundList(w, r, http.StatusConflict, "That inbound domain is already configured.", raw) + return + } + logf("panel: add inbound domain %q: %v", name, err) + h.renderInboundList(w, r, http.StatusInternalServerError, + "Could not add the domain. Please check the logs and try again.", raw) + return + } + http.Redirect(w, r, fmt.Sprintf("/inbound/%d", d.ID), http.StatusSeeOther) +} + +func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + d, ok := h.lookupInbound(w, r) + if !ok { + return + } + h.renderInboundDetail(w, r, http.StatusOK, d, inboundDetailView{}) +} + +type inboundDetailView struct { + FormErr string + TransportErr string + RecipientErr string +} + +func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) { + mx := dnscheck.Result{Status: health.StatusUnknown} + if h.dns != nil && h.cfg.Hostname != "" { + mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false) + } + data := h.pageBase(r) + data["Title"] = "SelfPost — " + d.Name + data["Active"] = "inbound" + data["Domain"] = d + data["MX"] = mx + data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "." + data["Hostname"] = h.cfg.Hostname + data["TLSLabel"] = tlsLabel(d.TLSMode) + data["TLSClass"] = tlsStatusClass(d.TLSMode) + data["RecipientText"] = strings.Join(d.Recipients, "\n") + data["Flash"] = inboundFlash(r) + data["FormErr"] = extra.FormErr + data["TransportErr"] = extra.TransportErr + data["RecipientErr"] = extra.RecipientErr + h.view.Render(w, status, "inbound_domain", data) +} + +func inboundFlash(r *http.Request) string { + switch { + case r.URL.Query().Get("saved") != "": + return "Upstream saved." + case r.URL.Query().Get("recipients") != "": + return "Recipients saved." + case r.URL.Query().Get("rechecked") != "": + return "DNS re-checked." + default: + return "" + } +} + +func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + d, ok := h.lookupInbound(w, r) + if !ok { + return + } + if h.dns != nil && h.cfg.Hostname != "" { + h.dns.InboundMX(d.Name, h.cfg.Hostname, true) + } + http.Redirect(w, r, fmt.Sprintf("/inbound/%d?rechecked=1", d.ID), http.StatusSeeOther) +} + +func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + d, ok := h.lookupInbound(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: "Invalid form submission."}) + return + } + host := r.PostFormValue("host") + port := r.PostFormValue("port") + tlsMode := r.PostFormValue("tls_mode") + if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil { + h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()}) + return + } + http.Redirect(w, r, fmt.Sprintf("/inbound/%d?saved=1", d.ID), http.StatusSeeOther) +} + +func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + d, ok := h.lookupInbound(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: "Invalid form submission."}) + return + } + mode := r.PostFormValue("recipient_mode") + addrs := splitAddresses(r.PostFormValue("addresses")) + if err := h.inbound.SetRecipients(d.ID, mode, addrs); err != nil { + h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()}) + return + } + http.Redirect(w, r, fmt.Sprintf("/inbound/%d?recipients=1", d.ID), http.StatusSeeOther) +} + +func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + d, ok := h.lookupInbound(w, r) + if !ok { + return + } + data := h.pageBase(r) + data["Title"] = "SelfPost — delete " + d.Name + data["Active"] = "inbound" + data["Domain"] = d + data["Upstream"] = inboundUpstream(d) + h.view.Render(w, http.StatusOK, "inbound_delete", data) +} + +func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireInbound(w, r); !ok { + return + } + d, ok := h.lookupInbound(w, r) + if !ok { + return + } + if h.dns != nil { + h.dns.Forget(d.Name) + } + if err := h.inbound.Delete(d.ID); err != nil { + if errors.Is(err, store.ErrInboundDomainNotFound) { + http.NotFound(w, r) + return + } + logf("panel: delete inbound domain %d: %v", d.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/inbound?deleted=1", http.StatusSeeOther) +} diff --git a/internal/web/handlers/handlers_inbound_test.go b/internal/web/handlers/handlers_inbound_test.go new file mode 100644 index 0000000..abf95bb --- /dev/null +++ b/internal/web/handlers/handlers_inbound_test.go @@ -0,0 +1,160 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "github.com/mixeme/selfpost/internal/inbound" + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" +) + +type recordingMaps struct { + n int +} + +func (r *recordingMaps) RebuildInboundMaps(_ []postfix.InboundRoute) error { + r.n++ + return nil +} + +func inboundHandlers(t *testing.T) (*Handlers, *store.Store) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + v := mustView(t) + v.SetInboundEnabled(true) + h := &Handlers{ + store: st, + inbound: inbound.NewService(st, &recordingMaps{}), + view: v, + cfg: Config{Version: "test", InboundEnabled: true, Hostname: "mail.example.org"}, + } + return h, st +} + +func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Principal) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + var req *http.Request + if form != nil { + req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req = httptest.NewRequest(method, target, nil) + } + req = auth.RequestWithPrincipal(req, p) + if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/"); ok { + id, _, _ := strings.Cut(rest, "/") + if id != "" && id != "delete" { + req.SetPathValue("id", id) + } + } + switch { + case method == http.MethodGet && target == "/inbound": + h.HandleInboundList(rec, req) + case method == http.MethodPost && target == "/inbound": + h.HandleAddInbound(rec, req) + case strings.HasSuffix(target, "/delete") && method == http.MethodGet: + h.HandleInboundDeleteConfirm(rec, req) + case strings.HasSuffix(target, "/delete") && method == http.MethodPost: + h.HandleInboundDelete(rec, req) + case strings.HasSuffix(target, "/upstream"): + h.HandleInboundTransport(rec, req) + case strings.HasSuffix(target, "/recipients"): + h.HandleInboundRecipients(rec, req) + default: + h.HandleInboundDetail(rec, req) + } + return rec +} + +func TestInboundListAndAdd(t *testing.T) { + h, _ := inboundHandlers(t) + rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal) + if rec.Code != http.StatusOK { + t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Add inbound domain") { + t.Fatal("list missing add form") + } + + rec = inboundCall(h, http.MethodPost, "/inbound", url.Values{"name": {"lists.example.com"}}, globalPrincipal) + if rec.Code != http.StatusSeeOther { + t.Fatalf("add = %d %s", rec.Code, rec.Body.String()) + } + + rec = inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal) + if !strings.Contains(rec.Body.String(), "lists.example.com") { + t.Fatalf("list missing domain:\n%s", rec.Body.String()) + } +} + +func TestInboundDisabledIs404(t *testing.T) { + h, _ := inboundHandlers(t) + h.cfg.InboundEnabled = false + rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal) + if rec.Code != http.StatusNotFound { + t.Fatalf("disabled inbound = %d, want 404", rec.Code) + } +} + +func TestInboundTransportAndRecipients(t *testing.T) { + h, st := inboundHandlers(t) + d, err := st.AddInboundDomain("lists.example.com") + if err != nil { + t.Fatal(err) + } + id := itoa(d.ID) + + rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/upstream", url.Values{ + "host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"encrypt"}, + }, globalPrincipal) + if rec.Code != http.StatusSeeOther { + t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String()) + } + + rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{ + "recipient_mode": {"list"}, + "addresses": {"staff@lists.example.com\nabuse@other.com"}, + }, globalPrincipal) + if rec.Code != http.StatusBadRequest { + t.Fatalf("foreign recipient = %d, want 400", rec.Code) + } + + rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{ + "recipient_mode": {"list"}, + "addresses": {"staff@lists.example.com"}, + }, globalPrincipal) + if rec.Code != http.StatusSeeOther { + t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String()) + } + + rec = inboundCall(h, http.MethodGet, "/inbound/"+id, nil, globalPrincipal) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "10.0.0.8") { + t.Fatalf("detail =\n%s", rec.Body.String()) + } +} + +func TestInboundDelete(t *testing.T) { + h, st := inboundHandlers(t) + d, err := st.AddInboundDomain("lists.example.com") + if err != nil { + t.Fatal(err) + } + id := itoa(d.ID) + rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/delete", nil, globalPrincipal) + if rec.Code != http.StatusSeeOther { + t.Fatalf("delete = %d %s", rec.Code, rec.Body.String()) + } + if _, err := st.GetInboundDomain(d.ID); err == nil { + t.Fatal("domain still present") + } +} diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go index 9e032ab..15db362 100644 --- a/internal/web/handlers/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" @@ -347,7 +348,12 @@ func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) { domains[d.name] = dom } - return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains + return &Handlers{ + store: st, + inbound: inbound.NewService(st, &recordingMaps{}), + view: mustView(t), + cfg: Config{Version: "test", InboundEnabled: true}, + }, domains } var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal} diff --git a/internal/web/validate/validate.go b/internal/web/validate/validate.go index 6ba5cde..da8b72d 100644 --- a/internal/web/validate/validate.go +++ b/internal/web/validate/validate.go @@ -3,6 +3,8 @@ package validate import ( "fmt" + "net" + "strconv" "strings" "unicode" ) @@ -146,3 +148,104 @@ func Email(addr string) error { } return nil } + +const maxHostLen = 253 + +// NormalizeHost trims, lower-cases, and strips wrapping IPv6 brackets so the +// stored value is a bare hostname or IP, safe to wrap again when writing maps. +func NormalizeHost(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + return host +} + +// Host enforces a whitelist for an upstream hostname or IP (security.md): a +// dotted domain, a single DNS label (LAN names), or an IPv4/IPv6 address. +func Host(host string) error { + if host == "" { + return fmt.Errorf("host is required") + } + if len(host) > maxHostLen { + return fmt.Errorf("host must be at most %d characters", maxHostLen) + } + if ip := net.ParseIP(host); ip != nil { + return nil + } + labels := strings.Split(host, ".") + for _, label := range labels { + if err := domainLabel(label); err != nil { + return fmt.Errorf("host is invalid: %w", err) + } + } + return nil +} + +// Port checks a TCP port number parsed from form input. +func Port(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, fmt.Errorf("port is required") + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 65535 { + return 0, fmt.Errorf("port must be between 1 and 65535") + } + return n, nil +} + +// TLSMode checks a Postfix smtp_tls_policy_maps level. +func TLSMode(mode string) error { + switch mode { + case "may", "encrypt", "none": + return nil + default: + return fmt.Errorf("invalid TLS mode") + } +} + +// RecipientMode checks an inbound-domain recipient policy. +func RecipientMode(mode string) error { + switch mode { + case "list", "any": + return nil + default: + return fmt.Errorf("invalid recipient mode") + } +} + +// MailboxInDomain checks that addr is a conservative mailbox on domain +// (security.md). domain must already be normalised. +func MailboxInDomain(addr, domain string) error { + at := strings.LastIndexByte(addr, '@') + if at <= 0 || at >= len(addr)-1 { + return fmt.Errorf("%q is not a valid email address", addr) + } + local, host := addr[:at], addr[at+1:] + if host != domain { + return fmt.Errorf("%q does not belong to domain %s", addr, domain) + } + if err := mailboxLocalPart(local); err != nil { + return fmt.Errorf("%q: %w", addr, err) + } + return nil +} + +func mailboxLocalPart(local string) error { + if local == "" { + return fmt.Errorf("missing the part before '@'") + } + if local[0] == '.' || local[len(local)-1] == '.' { + return fmt.Errorf("local part must not start or end with '.'") + } + for i := 0; i < len(local); i++ { + c := local[i] + lower := c >= 'a' && c <= 'z' + digit := c >= '0' && c <= '9' + if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' { + return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'") + } + } + return nil +} diff --git a/internal/web/validate/validate_test.go b/internal/web/validate/validate_test.go index 6a20bf4..cbd3cd8 100644 --- a/internal/web/validate/validate_test.go +++ b/internal/web/validate/validate_test.go @@ -65,3 +65,69 @@ func TestValidateDomainLongLabelRejected(t *testing.T) { t.Error("expected error for over-long label") } } + +func TestHostValid(t *testing.T) { + valid := []string{ + "10.0.0.8", + "192.0.2.20", + "2001:db8::1", + "mail.internal.example", + "mx1", + "mail-1.lan", + } + for _, h := range valid { + if err := Host(h); err != nil { + t.Errorf("Host(%q) unexpected error: %v", h, err) + } + } +} + +func TestHostInvalid(t *testing.T) { + invalid := []string{ + "", + "exa mple", + "host/name", + "host;rm", + "-bad", + "bad-", + "host\nname", + } + for _, h := range invalid { + if err := Host(h); err == nil { + t.Errorf("Host(%q) = nil, want error", h) + } + } +} + +func TestNormalizeHostStripsIPv6Brackets(t *testing.T) { + if got := NormalizeHost(" [2001:DB8::1] "); got != "2001:db8::1" { + t.Errorf("NormalizeHost IPv6 = %q", got) + } + if got := NormalizeHost("Mail.Example.COM"); got != "mail.example.com" { + t.Errorf("NormalizeHost hostname = %q", got) + } +} + +func TestPort(t *testing.T) { + n, err := Port("25") + if err != nil || n != 25 { + t.Fatalf("Port(25) = %d, %v", n, err) + } + for _, raw := range []string{"", "0", "65536", "abc", "-1"} { + if _, err := Port(raw); err == nil { + t.Errorf("Port(%q) = nil, want error", raw) + } + } +} + +func TestMailboxInDomain(t *testing.T) { + if err := MailboxInDomain("staff@lists.example.com", "lists.example.com"); err != nil { + t.Fatal(err) + } + if err := MailboxInDomain("staff@other.com", "lists.example.com"); err == nil { + t.Fatal("expected domain mismatch error") + } + if err := MailboxInDomain("bad addr@lists.example.com", "lists.example.com"); err == nil { + t.Fatal("expected local-part error") + } +} diff --git a/internal/web/view/templates/inbound.html b/internal/web/view/templates/inbound.html new file mode 100644 index 0000000..4011750 --- /dev/null +++ b/internal/web/view/templates/inbound.html @@ -0,0 +1,50 @@ +{{define "wide"}}wide{{end}} + +{{define "content"}} +

Inbound

+ +

Backup-MX / forwarder. Accepts on port 25 only for listed +domains. Recipients are either an allow-list or any address at that domain. +Off by default in Compose.

+ +{{if .Flash}}
{{.Flash}}
{{end}} + +
+

Forwarding

+
+ +
+ + +
+ {{if .Error}}

{{.Error}}

{{end}} +
+ {{if .Domains}} + + + + + + {{range .Domains}} + + + + + + + + + {{end}} + +
DomainDNSUpstreamRecipientsTLS
{{.Name}}{{.DNS}}{{.Upstream}}{{.RcptLabel}}{{.TLSLabel}}Delete
+

The DNS badge is the MX check: at least one MX must + point at this server. Results are cached for a few minutes; open a domain for + the lookup and a Re-check button.

+ {{else}} +

No inbound domains yet. Add one above, then set the upstream + on its page. Mail is not accepted until an upstream host is saved.

+ {{end}} +
+{{end}} diff --git a/internal/web/view/templates/inbound_delete.html b/internal/web/view/templates/inbound_delete.html new file mode 100644 index 0000000..d10cc7a --- /dev/null +++ b/internal/web/view/templates/inbound_delete.html @@ -0,0 +1,20 @@ +{{define "content"}} +

Delete {{.Domain.Name}}

+ +{{template "back_link" (back (printf "/inbound/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}} + +
+

Confirm deletion

+

You are about to stop accepting inbound mail for {{.Domain.Name}}. This will:

+
    +
  • remove it from relay_domains and the recipient map;
  • +
  • stop forwarding{{if ne .Upstream "—"}} to {{.Upstream}}{{end}};
  • +
  • leave outbound sending domains untouched.
  • +
+

This cannot be undone from a backup of inbound maps alone + unless you restore one. Remove the MX if you do not plan to re-add the domain.

+
+ +
+
+{{end}} diff --git a/internal/web/view/templates/inbound_domain.html b/internal/web/view/templates/inbound_domain.html new file mode 100644 index 0000000..1870f03 --- /dev/null +++ b/internal/web/view/templates/inbound_domain.html @@ -0,0 +1,118 @@ +{{define "wide"}}wide{{end}} + +{{define "content"}} +

{{.Domain.Name}}

+ +{{template "back_link" (back "/inbound" "All inbound domains")}} + +{{if .Flash}}
{{.Flash}}
{{end}} + +
+

DNS status {{.MX.Status}}

+

Cached a few minutes — use Re-check after publishing. + Unlike outbound, inbound needs an MX pointing at this server.

+ +
+
+ + {{.Domain.Name}} +
+
+ + MX +
+
+ + {{if .MX.Records}} + {{range .MX.Records}}{{.}} +{{end}} + {{else}} + No MX records found. + {{end}} + {{if eq .MX.Status "ok"}} +

{{.MX.Detail}}

+ {{else}} +

{{.MX.Detail}}

+ {{end}} +
+ +
+
+ +
+
+

Upstream

+

Where accepted mail is handed off. Not a mailbox.

+
+ + + + + + + {{if .TransportErr}}

{{.TransportErr}}

{{end}} + +
+
+
+

MX record to publish

+

Add this MX so the internet delivers here. Keep any existing + primary MX if this is backup-MX.

+
+
+ +
+ {{.Domain.Name}} + +
+
+
+ + MX +
+
+ +
+ {{.MXValue}} + +
+
+
+ +
+
+

Valid recipients

+

Who this domain accepts on port 25. Same idea as an + application's address mode: a list, or any address at the domain.

+
+ + +
+ + +

Unknown recipients are rejected at RCPT so this relay + does not generate backscatter.

+
+

Every address at this domain is accepted and forwarded when + “any recipient” is selected. Prefer a list unless the upstream rejects + unknowns — otherwise this relay may generate backscatter.

+ {{if .RecipientErr}}

{{.RecipientErr}}

{{end}} + +
+
+
+

Danger zone

+

Stops accepting mail for this domain. Does not touch + outbound sending domains.

+

Delete inbound domain

+
+
+{{end}} diff --git a/internal/web/view/templates/layout.html b/internal/web/view/templates/layout.html index 9e8593b..a331896 100644 --- a/internal/web/view/templates/layout.html +++ b/internal/web/view/templates/layout.html @@ -70,6 +70,9 @@ {{if eq .Active "status"}}{{template "icon-status"}}Status{{else}}{{template "icon-status"}}Status{{end}} {{end}} {{if eq .Active "domains"}}{{template "icon-domains"}}Domains{{else}}{{template "icon-domains"}}Domains{{end}} + {{if and .IsGlobal .InboundEnabled}} + {{if eq .Active "inbound"}}{{template "icon-inbound"}}Inbound{{else}}{{template "icon-inbound"}}Inbound{{end}} + {{end}} {{if eq .Active "deliveries"}}{{template "icon-deliveries"}}Deliveries{{else}}{{template "icon-deliveries"}}Deliveries{{end}} {{if .IsGlobal}} {{if eq .Active "mail_queue"}}{{template "icon-mail-queue"}}Mail queue{{else}}{{template "icon-mail-queue"}}Mail queue{{end}} @@ -112,6 +115,7 @@ templates so the nav above stays one readable line per page. */}} {{define "icon-status"}}{{end}} {{define "icon-domains"}}{{end}} +{{define "icon-inbound"}}{{end}} {{define "icon-deliveries"}}{{end}} {{define "icon-mail-queue"}}{{end}} {{define "icon-system-log"}}{{end}} diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index a60abf5..abe0c24 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -178,6 +178,35 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { if strings.Index(out, "Status") > strings.Index(out, "Domains") { t.Errorf("Status is not the first navigation entry:\n%s", out) } + if strings.Contains(out, `href="/inbound"`) || strings.Contains(out, "Inbound") { + t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out) + } +} + +func TestNavShowsInboundWhenEnabled(t *testing.T) { + engine, err := New("test") + if err != nil { + t.Fatalf("New: %v", err) + } + engine.SetInboundEnabled(true) + var buf bytes.Buffer + if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{ + "User": "admin", + "Active": "status", + "IsGlobal": true, + "InboundEnabled": true, + }); err != nil { + t.Fatalf("execute nav: %v", err) + } + out := buf.String() + if !strings.Contains(out, `href="/inbound"`) || !strings.Contains(out, "Inbound") { + t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out) + } + dom := strings.Index(out, `href="/domains"`) + inb := strings.Index(out, `href="/inbound"`) + if dom < 0 || inb < 0 || inb < dom { + t.Errorf("Inbound should follow Domains:\n%s", out) + } } // Whether a page takes the whole column or the reading measure is declared by @@ -190,7 +219,11 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } - wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true} + wide := map[string]bool{ + "settings": true, "deliveries": true, "delivery": true, "mail_queue": true, + "status": true, "system_log": true, "domain_detail": true, + "inbound": true, "inbound_domain": true, + } for name, page := range engine.Pages() { var buf bytes.Buffer if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil { @@ -281,11 +314,13 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) { func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) { drillDown := map[string]bool{ - "user_form.html": true, - "user_delete.html": true, - "domain_detail.html": true, - "domain_delete.html": true, - "delivery.html": true, + "user_form.html": true, + "user_delete.html": true, + "domain_detail.html": true, + "domain_delete.html": true, + "inbound_domain.html": true, + "inbound_delete.html": true, + "delivery.html": true, } forEachTemplate(t, func(name, body string) { if !drillDown[name] { diff --git a/internal/web/view/view.go b/internal/web/view/view.go index 6b24b1c..13443c0 100644 --- a/internal/web/view/view.go +++ b/internal/web/view/view.go @@ -18,9 +18,10 @@ var assetsFS embed.FS // Engine holds parsed page and fragment templates. type Engine struct { - pages map[string]*template.Template - fragments map[string]*template.Template - version string + pages map[string]*template.Template + fragments map[string]*template.Template + version string + inboundEnabled bool } // pageFiles maps a logical page name to its template files. Every page @@ -31,21 +32,24 @@ type Engine struct { // encryption fields on the two secret downloads) list that partial the same // way. var pageFiles = map[string][]string{ - "setup": {"templates/setup.html"}, - "login": {"templates/login.html"}, - "dashboard": {"templates/dashboard.html"}, - "settings": {"templates/settings.html"}, - "users": {"templates/users.html"}, - "user_form": {"templates/user_form.html"}, - "user_delete": {"templates/user_delete.html"}, - "backup": {"templates/backup.html", "templates/encrypt_fields.html"}, - "domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"}, - "domain_delete": {"templates/domain_delete.html"}, - "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"}, - "delivery": {"templates/delivery.html"}, - "mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"}, - "system_log": {"templates/system_log.html", "templates/system_log_body.html"}, - "status": {"templates/status.html", "templates/status_body.html"}, + "setup": {"templates/setup.html"}, + "login": {"templates/login.html"}, + "dashboard": {"templates/dashboard.html"}, + "settings": {"templates/settings.html"}, + "users": {"templates/users.html"}, + "user_form": {"templates/user_form.html"}, + "user_delete": {"templates/user_delete.html"}, + "backup": {"templates/backup.html", "templates/encrypt_fields.html"}, + "domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"}, + "domain_delete": {"templates/domain_delete.html"}, + "inbound": {"templates/inbound.html"}, + "inbound_domain": {"templates/inbound_domain.html"}, + "inbound_delete": {"templates/inbound_delete.html"}, + "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"}, + "delivery": {"templates/delivery.html"}, + "mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"}, + "system_log": {"templates/system_log.html", "templates/system_log_body.html"}, + "status": {"templates/status.html", "templates/status_body.html"}, } // fragmentFiles maps a fragment name (also its {{define}} block name) to its @@ -82,6 +86,12 @@ func New(version string) (*Engine, error) { return e, nil } +// SetInboundEnabled controls whether the Inbound nav item is shown. The +// listener and routes are gated the same way (INBOUND_RELAY_ENABLE). +func (e *Engine) SetInboundEnabled(v bool) { + e.inboundEnabled = v +} + // templateFuncs supplies helpers shared across page templates. func templateFuncs() template.FuncMap { return template.FuncMap{ @@ -124,6 +134,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any m["Version"] = e.version m["Copyright"] = legal.CopyrightLine m["SourceURL"] = legal.SourceURL + m["InboundEnabled"] = e.inboundEnabled } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { diff --git a/internal/web/web.go b/internal/web/web.go index 6250981..3dd0a9f 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -12,6 +12,7 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/legal" "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" @@ -82,6 +83,8 @@ type Config struct { // when the HTTP role starts. Handlers read the cache; they never call // postconf (architecture.md). RetryPolicy postfix.RetryPolicy + // InboundEnabled mirrors INBOUND_RELAY_ENABLE. + InboundEnabled bool } // Server is the panel HTTP application. @@ -96,18 +99,19 @@ type Server struct { // that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); // apps owns application SASL accounts and the Postfix sender map // (architecture.md § Mail path). -func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { +func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) { v, err := view.New(cfg.Version) if err != nil { return nil, err } + v.SetInboundEnabled(cfg.InboundEnabled) a := auth.New(st, auth.Config{ CookieSecure: cfg.CookieSecure, Hostname: cfg.Hostname, SessionIdleDays: cfg.SessionIdleDays, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, }, v, setupTokenPath) - h := handlers.New(st, domains, apps, handlers.Config{ + h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{ Hostname: cfg.Hostname, SubmissionEnabled: cfg.SubmissionEnabled, MailLogPath: cfg.MailLogPath, @@ -121,6 +125,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, RetryPolicy: cfg.RetryPolicy, + InboundEnabled: cfg.InboundEnabled, }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) return &Server{cfg: cfg, auth: a, handlers: h}, nil } @@ -167,6 +172,17 @@ func (s *Server) Handler() http.Handler { authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) authed.HandleFunc("POST /reload", h.HandleReload) + if s.cfg.InboundEnabled { + authed.HandleFunc("GET /inbound", h.HandleInboundList) + authed.HandleFunc("POST /inbound", h.HandleAddInbound) + authed.HandleFunc("GET /inbound/{id}", h.HandleInboundDetail) + authed.HandleFunc("POST /inbound/{id}/dns-recheck", h.HandleInboundDNSRecheck) + authed.HandleFunc("POST /inbound/{id}/upstream", h.HandleInboundTransport) + authed.HandleFunc("POST /inbound/{id}/recipients", h.HandleInboundRecipients) + authed.HandleFunc("GET /inbound/{id}/delete", h.HandleInboundDeleteConfirm) + authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete) + } + authed.HandleFunc("/settings", h.HandleSettings) authed.HandleFunc("/account", redirectSettings) diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml index 79a38fc..583ffa5 100644 --- a/test/e2e/compose.override.yml +++ b/test/e2e/compose.override.yml @@ -47,6 +47,7 @@ services: ports: !override - "20465:465" - "20587:587" + - "20025:25" - "127.0.0.1:20080:8080" dns: - 10.77.0.10 diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 7bdac92..32cd528 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -105,6 +105,9 @@ func TestE2E(t *testing.T) { if err := checkSupervisorProcesses(h); err != nil { t.Fatal(err) } + if err := checkInboundRelayOff(h); err != nil { + t.Fatal(err) + } if err := checkLogrotateConfigMode(h); err != nil { t.Fatal(err) } @@ -134,6 +137,26 @@ func TestE2E(t *testing.T) { sc.panel = p }) + run("inbound_ui_absent_when_disabled", func(t *testing.T) { + resp, body, err := sc.panel.get("/status") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d", resp.StatusCode) + } + if strings.Contains(body, `href="/inbound"`) { + t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off") + } + resp, _, err = sc.panel.get("/inbound") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 404 { + t.Fatalf("GET /inbound = %d, want 404 with inbound relay off", resp.StatusCode) + } + }) + run("add_domain_and_publish_dkim", func(t *testing.T) { id, err := sc.panel.addDomain(senderDomain) if err != nil { diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go index 399a234..e089227 100644 --- a/test/e2e/process_check.go +++ b/test/e2e/process_check.go @@ -62,3 +62,18 @@ func parseSupervisorStatus(out string) map[string]string { } return states } + +// checkInboundRelayOff asserts the default image does not accept mail on +// port 25: smtp/inet is absent from master.cf (Debian's stock listener is +// removed when INBOUND_RELAY_ENABLE is not true). +func checkInboundRelayOff(s *stack) error { + out, err := s.execIn("selfpost", "postconf", "-M", "smtp/inet") + combined := out + if err != nil { + combined += err.Error() + } + if strings.Contains(combined, "smtpd") && !strings.Contains(combined, "warning:") && !strings.Contains(combined, "fatal:") { + return fmt.Errorf("inbound smtp/inet is present while INBOUND_RELAY_ENABLE is off:\n%s", out) + } + return nil +}