change mox to start as root, bind to network sockets, then drop to regular unprivileged mox user

makes it easier to run on bsd's, where you cannot (easily?) let non-root users
bind to ports <1024. starting as root also paves the way for future improvements
with privilege separation.

unfortunately, this requires changes to how you start mox. though mox will help
by automatically fix up dir/file permissions/ownership.

if you start mox from the systemd unit file, you should update it so it starts
as root and adds a few additional capabilities:

        # first update the mox binary, then, as root:
        ./mox config printservice >mox.service
        systemctl daemon-reload
        systemctl restart mox
        journalctl -f -u mox &
        # you should see mox start up, with messages about fixing permissions on dirs/files.

if you used the recommended config/ and data/ directory, in a directory just for
mox, and with the mox user called "mox", this should be enough.

if you don't want mox to modify dir/file permissions, set "NoFixPermissions:
true" in mox.conf.

if you named the mox user something else than mox, e.g. "_mox", add "User: _mox"
to mox.conf.

if you created a shared service user as originally suggested, you may want to
get rid of that as it is no longer useful and may get in the way. e.g. if you
had /home/service/mox with a "service" user, that service user can no longer
access any files: only mox and root can.

this also adds scripts for building mox docker images for alpine-supported
platforms.

the "restart" subcommand has been removed. it wasn't all that useful and got in
the way.

and another change: when adding a domain while mtasts isn't enabled, don't add
the per-domain mtasts config, as it would cause failure to add the domain.

based on report from setting up mox on openbsd from mteege.
and based on issue #3. thanks for the feedback!
This commit is contained in:
Mechiel Lukkien 2023-02-27 12:19:55 +01:00
parent eda907fc86
commit 92e018e463
No known key found for this signature in database
37 changed files with 841 additions and 435 deletions

2
.gitignore vendored
View file

@ -8,7 +8,7 @@
/testdata/httpaccount/data/ /testdata/httpaccount/data/
/testdata/imap/data/ /testdata/imap/data/
/testdata/imaptest/data/ /testdata/imaptest/data/
/testdata/integration/run /testdata/integration/data/
/testdata/junk/*.bloom /testdata/junk/*.bloom
/testdata/junk/*.db /testdata/junk/*.db
/testdata/queue/data/ /testdata/queue/data/

View file

@ -8,11 +8,6 @@ FROM alpine:latest
WORKDIR /mox WORKDIR /mox
COPY --from=build /build/mox /bin/mox COPY --from=build /build/mox /bin/mox
RUN apk add --no-cache libcap-utils
# Allow binding to privileged ports, <1024.
RUN setcap 'cap_net_bind_service=+ep' /bin/mox
# SMTP for incoming message delivery. # SMTP for incoming message delivery.
EXPOSE 25/tcp EXPOSE 25/tcp
# SMTP/submission with TLS. # SMTP/submission with TLS.

29
Dockerfile.release Normal file
View file

@ -0,0 +1,29 @@
FROM --platform=linux/amd64 docker.io/golang:1-alpine AS build
WORKDIR /
ARG moxversion
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go install -mod mod -trimpath github.com/mjl-/mox@$moxversion
RUN test -f /go/bin/mox && cp /go/bin/mox /bin/mox || cp /go/bin/${TARGETOS}_${TARGETARCH}/mox /bin/mox
# Using latest may break at some point, but will hopefully be convenient most of the time.
FROM --platform=$TARGETPLATFORM docker.io/alpine:latest
WORKDIR /mox
COPY --from=build /bin/mox /bin/mox
# SMTP for incoming message delivery.
EXPOSE 25/tcp
# SMTP/submission with TLS.
EXPOSE 465/tcp
# SMTP/submission without initial TLS.
EXPOSE 587/tcp
# HTTP for internal account and admin pages.
EXPOSE 80/tcp
# HTTPS for ACME (Let's Encrypt), MTA-STS and autoconfig.
EXPOSE 443/tcp
# IMAP with TLS.
EXPOSE 993/tcp
# IMAP without initial TLS.
EXPOSE 143/tcp
# Prometheus metrics.
EXPOSE 8010/tcp
CMD ["/bin/mox", "serve"]

View file

@ -44,21 +44,22 @@ integration-build:
docker-compose -f docker-compose-integration.yml build --no-cache moxmail docker-compose -f docker-compose-integration.yml build --no-cache moxmail
integration-start: integration-start:
-MOX_UID=$$(id -u) MOX_GID=$$(id -g) docker-compose -f docker-compose-integration.yml run moxmail /bin/bash -rm -r testdata/integration/data
MOX_UID= MOX_GID= docker-compose -f docker-compose-integration.yml down -docker-compose -f docker-compose-integration.yml run moxmail /bin/bash
docker-compose -f docker-compose-integration.yml down
# run from within "make integration-start" # run from within "make integration-start"
integration-test: integration-test:
CGO_ENABLED=0 go test -tags integration CGO_ENABLED=0 go test -tags integration
imaptest-build: imaptest-build:
-MOX_UID=$$(id -u) MOX_GID=$$(id -g) docker-compose -f docker-compose-imaptest.yml build --no-cache mox -docker-compose -f docker-compose-imaptest.yml build --no-cache mox
imaptest-run: imaptest-run:
-rm -r testdata/imaptest/data -rm -r testdata/imaptest/data
mkdir testdata/imaptest/data mkdir testdata/imaptest/data
MOX_UID=$$(id -u) MOX_GID=$$(id -g) docker-compose -f docker-compose-imaptest.yml run --entrypoint /usr/local/bin/imaptest imaptest host=mox port=1143 user=mjl@mox.example pass=testtest mbox=imaptest.mbox docker-compose -f docker-compose-imaptest.yml run --entrypoint /usr/local/bin/imaptest imaptest host=mox port=1143 user=mjl@mox.example pass=testtest mbox=imaptest.mbox
MOX_UID= MOX_GID= docker-compose -f docker-compose-imaptest.yml down docker-compose -f docker-compose-imaptest.yml down
fmt: fmt:
go fmt ./... go fmt ./...
@ -72,4 +73,7 @@ jsinstall:
npm install jshint@2.13.2 npm install jshint@2.13.2
docker: docker:
docker build -t mox:latest . docker build -t mox:dev .
docker-release:
./docker-release.sh

View file

@ -56,30 +56,32 @@ Verify you have a working mox binary:
Note: Mox only compiles/works on unix systems, not on Plan 9 or Windows. Note: Mox only compiles/works on unix systems, not on Plan 9 or Windows.
You can also run mox with docker image "moxmail/mox" on hub.docker.com, with You can also run mox with docker image "docker.io/moxmail/mox", with tags like
tags like "latest", "0.0.1", etc. See docker-compose.yml in this repository. "latest", "0.0.1" and "0.0.1-go1.20.1-alpine3.17.2", etc. See docker-compose.yml
in this repository for instructions on starting.
# Quickstart # Quickstart
The easiest way to get started with serving email for your domain is to get a The easiest way to get started with serving email for your domain is to get a
vm/machine dedicated to serving email, name it [host].[domain], login as an vm/machine dedicated to serving email, name it [host].[domain] (e.g.
admin user, e.g. /home/service, download mox, and generate a configuration for mail.example.com), login as root, create user "mox" and its homedir by running
your desired email address at your domain: "useradd -d /home/mox mox && mkdir /home/mox", download mox to that directory,
and generate a configuration for your desired email address at your domain:
./mox quickstart you@example.com ./mox quickstart you@example.com
This creates an account, generates a password and configuration files, prints This creates an account, generates a password and configuration files, prints
the DNS records you need to manually create and prints commands to set the DNS records you need to manually create and prints commands to start mox and
permissions and install mox as a service. optionally install mox as a service.
If you already have email configured for your domain, or if you are already If you already have email configured for your domain, or if you are already
sending email for your domain from other machines/services, you should modify sending email for your domain from other machines/services, you should modify
the suggested configuration and/or DNS records. the suggested configuration and/or DNS records.
A dedicated machine is highly recommended because modern email requires HTTPS, A dedicated machine is highly recommended because modern email requires HTTPS,
also for automatic TLS. You can combine mox with an existing webserver, but it and mox currently needs it for automatic TLS. You can combine mox with an
requires more configuration. existing webserver, but it requires more configuration.
After starting, you can access the admin web interface on internal IPs. After starting, you can access the admin web interface on internal IPs.

View file

@ -32,6 +32,8 @@ type Static struct {
DataDir string `sconf-doc:"Directory where all data is stored, e.g. queue, accounts and messages, ACME TLS certs/keys. If this is a relative path, it is relative to the directory of mox.conf."` DataDir string `sconf-doc:"Directory where all data is stored, e.g. queue, accounts and messages, ACME TLS certs/keys. If this is a relative path, it is relative to the directory of mox.conf."`
LogLevel string `sconf-doc:"Default log level, one of: error, info, debug, trace, traceauth, tracedata. Trace logs SMTP and IMAP protocol transcripts, with traceauth also messages with passwords, and tracedata on top of that also the full data exchanges (full messages), which can be a large amount of data."` LogLevel string `sconf-doc:"Default log level, one of: error, info, debug, trace, traceauth, tracedata. Trace logs SMTP and IMAP protocol transcripts, with traceauth also messages with passwords, and tracedata on top of that also the full data exchanges (full messages), which can be a large amount of data."`
PackageLogLevels map[string]string `sconf:"optional" sconf-doc:"Overrides of log level per package (e.g. queue, smtpclient, smtpserver, imapserver, spf, dkim, dmarc, dmarcdb, autotls, junk, mtasts, tlsrpt)."` PackageLogLevels map[string]string `sconf:"optional" sconf-doc:"Overrides of log level per package (e.g. queue, smtpclient, smtpserver, imapserver, spf, dkim, dmarc, dmarcdb, autotls, junk, mtasts, tlsrpt)."`
User string `sconf:"optional" sconf-doc:"User to switch to after binding to all sockets as root. Default: mox. If the value is not a known user, it is parsed as integer and used as uid and gid."`
NoFixPermissions bool `sconf:"optional" sconf-doc:"If true, do not automatically fix file permissions when starting up. By default, mox will ensure reasonable owner/permissions on the working, data and config directories (and files), and mox binary (if present)."`
Hostname string `sconf-doc:"Full hostname of system, e.g. mail.<domain>"` Hostname string `sconf-doc:"Full hostname of system, e.g. mail.<domain>"`
HostnameDomain dns.Domain `sconf:"-" json:"-"` // Parsed form of hostname. HostnameDomain dns.Domain `sconf:"-" json:"-"` // Parsed form of hostname.
CheckUpdates bool `sconf:"optional" sconf-doc:"If enabled, a single DNS TXT lookup of _updates.xmox.nl is done every 24h to check for a new release. Each time a new release is found, a changelog is fetched from https://updates.xmox.nl and delivered to the postmaster mailbox."` CheckUpdates bool `sconf:"optional" sconf-doc:"If enabled, a single DNS TXT lookup of _updates.xmox.nl is done every 24h to check for a new release. Each time a new release is found, a changelog is fetched from https://updates.xmox.nl and delivered to the postmaster mailbox."`
@ -59,6 +61,10 @@ type Static struct {
// family, outgoing connections with the other address family are still made if // family, outgoing connections with the other address family are still made if
// possible. // possible.
SpecifiedSMTPListenIPs []net.IP `sconf:"-" json:"-"` SpecifiedSMTPListenIPs []net.IP `sconf:"-" json:"-"`
// To switch to after initialization as root.
UID uint32 `sconf:"-" json:"-"`
GID uint32 `sconf:"-" json:"-"`
} }
// Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed. // Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed.

View file

@ -25,6 +25,16 @@ describe-static" and "mox config describe-domains":
PackageLogLevels: PackageLogLevels:
x: x:
# User to switch to after binding to all sockets as root. Default: mox. If the
# value is not a known user, it is parsed as integer and used as uid and gid.
# (optional)
User:
# If true, do not automatically fix file permissions when starting up. By default,
# mox will ensure reasonable owner/permissions on the working, data and config
# directories (and files), and mox binary (if present). (optional)
NoFixPermissions: false
# Full hostname of system, e.g. mail.<domain> # Full hostname of system, e.g. mail.<domain>
Hostname: Hostname:

54
ctl.go
View file

@ -9,12 +9,10 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"runtime/debug" "runtime/debug"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
"github.com/mjl-/bstore" "github.com/mjl-/bstore"
@ -301,58 +299,6 @@ func servectlcmd(ctx context.Context, log *mlog.Log, ctl *ctl, xcmd *string, shu
shutdown() shutdown()
os.Exit(0) os.Exit(0)
case "restart":
// First test the config.
_, errs := mox.ParseConfig(ctx, mox.ConfigStaticPath, true)
if len(errs) > 1 {
log.Error("multiple configuration errors before restart")
for _, err := range errs {
log.Errorx("config error", err)
}
ctl.xerror("restart aborted")
} else if len(errs) == 1 {
log.Errorx("configuration error, restart aborted", errs[0])
ctl.xerror(errs[0].Error())
}
binary, err := os.Executable()
ctl.xcheck(err, "finding executable")
cfr, ok := ctl.conn.(interface{ File() (*os.File, error) })
if !ok {
ctl.xerror("cannot dup ctl socket")
}
cf, err := cfr.File()
ctl.xcheck(err, "dup ctl socket")
defer cf.Close()
_, _, err = syscall.Syscall(syscall.SYS_FCNTL, cf.Fd(), syscall.F_SETFD, 0)
if err != syscall.Errno(0) {
ctl.xcheck(err, "clear close-on-exec on ctl socket")
}
ctl.xwriteok()
shutdown()
// todo future: we could gather all listen fd's, keep them open, passing them to the new process and indicate (in env var or cli flag) for which addresses they are, then exec and have the new process pick them up. not worth the trouble at the moment, our shutdown is typically quick enough.
// todo future: does this actually cleanup all M's on all platforms?
env := os.Environ()
var found bool
envv := fmt.Sprintf("MOX_RESTART_CTL_SOCKET=%d", cf.Fd())
for i, s := range env {
if strings.HasPrefix(s, "MOX_RESTART_CTL_SOCKET=") {
found = true
env[i] = envv
break
}
}
if !found {
env = append(env, envv)
}
// On success, we never get here and "serve" will write the OK on the MOX_RESTART_CTL_SOCKET and close it.
err = syscall.Exec(binary, os.Args, env)
runtime.KeepAlive(cf)
ctl.xcheck(err, "exec")
case "deliver": case "deliver":
/* The protocol, double quoted are literals. /* The protocol, double quoted are literals.

38
doc.go
View file

@ -14,8 +14,7 @@ low-maintenance self-hosted email.
mox [-config config/mox.conf] ... mox [-config config/mox.conf] ...
mox serve mox serve
mox quickstart user@domain mox quickstart user@domain [user | uid]
mox restart
mox stop mox stop
mox setaccountpassword address mox setaccountpassword address
mox setadminpassword mox setadminpassword
@ -41,6 +40,7 @@ low-maintenance self-hosted email.
mox config domain add domain account [localpart] mox config domain add domain account [localpart]
mox config domain rm domain mox config domain rm domain
mox config describe-sendmail >/etc/moxsubmit.conf mox config describe-sendmail >/etc/moxsubmit.conf
mox config printservice >mox.service
mox checkupdate mox checkupdate
mox cid cid mox cid cid
mox clientconfig domain mox clientconfig domain
@ -83,26 +83,14 @@ requested, other TLS certificates are requested on demand.
Quickstart generates configuration files and prints instructions to quickly set up a mox instance. Quickstart generates configuration files and prints instructions to quickly set up a mox instance.
Quickstart prints initial admin and account passwords, configuration files, DNS Quickstart writes configuration files, prints initial admin and account
records you should create, instructions for setting correct user/group and passwords, DNS records you should create. If you run it on Linux it writes a
permissions, and if you run it on Linux it prints a systemd service file. systemd service file and prints commands to enable and start mox as service.
usage: mox quickstart user@domain The user or uid is optional, defaults to "mox", and is the user or uid/gid mox
will run as after initialization.
# mox restart usage: mox quickstart user@domain [user | uid]
Restart mox after validating the configuration file.
Restart execs the mox binary, which have been updated. Restart returns after
the restart has finished. If you update the mox binary, keep in mind that the
validation of the configuration file is done by the old process with the old
binary. The new binary may report a syntax error. If you update the binary, you
should use the "config test" command with the new binary to validate the
configuration file.
Like stop, existing connections get a 3 second period for graceful shutdown.
usage: mox restart
# mox stop # mox stop
@ -390,6 +378,16 @@ Describe configuration for mox when invoked as sendmail.
usage: mox config describe-sendmail >/etc/moxsubmit.conf usage: mox config describe-sendmail >/etc/moxsubmit.conf
# mox config printservice
Prints a systemd unit service file for mox.
This is the same file as generated using quickstart. If the systemd service file
has changed with a newer version of mox, use this command to generate an up to
date version.
usage: mox config printservice >mox.service
# mox checkupdate # mox checkupdate
Check if a newer version of mox is available. Check if a newer version of mox is available.

View file

@ -4,15 +4,13 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.moximaptest dockerfile: Dockerfile.moximaptest
user: ${MOX_UID}:${MOX_GID}
volumes: volumes:
- ./testdata/imaptest/config:/mox/config
- ./testdata/imaptest/data:/mox/data - ./testdata/imaptest/data:/mox/data
- ./testdata/imaptest/mox.conf:/mox/mox.conf
- ./testdata/imaptest/domains.conf:/mox/domains.conf
- ./testdata/imaptest/imaptest.mbox:/mox/imaptest.mbox - ./testdata/imaptest/imaptest.mbox:/mox/imaptest.mbox
working_dir: /mox working_dir: /mox
tty: true # For job control tty: true # For job control with set -m.
command: sh -c 'export MOXCONF=mox.conf; set -m; mox serve & sleep 1; echo testtest | mox setaccountpassword mjl@mox.example; fg' command: sh -c 'set -m; mox serve & sleep 1; echo testtest | mox setaccountpassword mjl@mox.example; fg'
healthcheck: healthcheck:
test: netstat -nlt | grep ':1143 ' test: netstat -nlt | grep ':1143 '
interval: 1s interval: 1s
@ -27,7 +25,6 @@ services:
working_dir: /imaptest working_dir: /imaptest
volumes: volumes:
- ./testdata/imaptest:/imaptest - ./testdata/imaptest:/imaptest
user: ${MOX_UID}:${MOX_GID}
depends_on: depends_on:
mox: mox:
condition: service_healthy condition: service_healthy

View file

@ -7,14 +7,12 @@ services:
build: build:
dockerfile: Dockerfile.moxmail dockerfile: Dockerfile.moxmail
context: testdata/integration context: testdata/integration
user: ${MOX_UID}:${MOX_GID}
volumes: volumes:
- ./.go:/.go - ./.go:/.go
- ./testdata/integration/resolv.conf:/etc/resolv.conf - ./testdata/integration/resolv.conf:/etc/resolv.conf
- .:/mox - .:/mox
environment: environment:
GOCACHE: /.go/.cache/go-build GOCACHE: /.go/.cache/go-build
command: ["make", "test-postfix"]
healthcheck: healthcheck:
test: netstat -nlt | grep ':25 ' test: netstat -nlt | grep ':25 '
interval: 1s interval: 1s

View file

@ -1,23 +1,22 @@
# Before launching mox, run the quickstart to create config files: # Before launching mox, run the quickstart to create config files for running as
# user the mox user (create it on the host system first, e.g. "useradd -d $PWD mox"):
# #
# MOX_UID=0 MOX_GID=0 docker-compose run mox mox quickstart you@yourdomain.example # docker-compose run mox mox quickstart you@yourdomain.example $(id -u mox)
# #
# After following the instructions, start mox as the newly created mox user: # After following the quickstart instructions you can start mox:
# #
# MOX_UID=$(id -u mox) MOX_GID=$(id -g mox) docker-compose up # docker-compose up
version: '3.7' version: '3.7'
services: services:
mox: mox:
# Replace latest with the version you want to run. # Replace latest with the version you want to run.
image: moxmail/mox:latest image: moxmail/mox:latest
user: ${MOX_UID}:${MOX_GID}
environment: environment:
- MOX_DOCKER=... # Quickstart won't try to write systemd service file. - MOX_DOCKER=... # Quickstart won't try to write systemd service file.
# Mox needs host networking because it needs access to the IPs of the # Mox needs host networking because it needs access to the IPs of the
# machine, and the IPs of incoming connections for spam filtering. # machine, and the IPs of incoming connections for spam filtering.
network_mode: 'host' network_mode: 'host'
command: sh -c "umask 007 && exec mox serve"
volumes: volumes:
- ./config:/mox/config - ./config:/mox/config
- ./data:/mox/data - ./data:/mox/data

49
docker-release.sh Executable file
View file

@ -0,0 +1,49 @@
#!/bin/sh
# Abort on error.
set -e
# We are using podman because docker generates errors when it's in the second
# stage and copies a non-linux/amd64 binary from the first stage that is
# linux/amd64.
# The platforms we build for (what alpine supports).
platforms=linux/amd64,linux/arm64,linux/arm,linux/386,linux/ppc64le,linux/s390x
# todo: linux/riscv64 currently absent for alpine:latest, only at alpine:edge
# We are building by "go install github.com/mjl-/mox@$moxversion", to ensure the
# binary gets a proper version stamped into its buildinfo. It also helps to ensure
# there is no accidental local change in the image.
moxversion=$(go list -mod mod -m github.com/mjl-/mox@$(git rev-parse HEAD) | cut -f2 -d' ')
echo Building mox $moxversion for $platforms, without local/uncommitted changes
# Ensure latest golang and alpine docker images.
podman image pull --quiet docker.io/golang:1-alpine
for i in $(echo $platforms | sed 's/,/ /g'); do
podman image pull --quiet --platform $i docker.io/alpine:latest
done
# "Last pulled" apparently is the one used for "podman run" below, not the one
# that matches the platform. So pull for current platform again.
podman image pull --quiet docker.io/alpine:latest
# Get the goland and alpine versions from the docker images.
goversion=$(podman run golang:1-alpine go version | cut -f3 -d' ')
alpineversion=alpine$(podman run alpine:latest cat /etc/alpine-release)
# We assume the alpines for all platforms have the same version...
echo Building with $goversion and $alpineversion
test -d empty || mkdir empty
podman build --platform $platforms -f Dockerfile.release -v $HOME/go/pkg/sumdb:/go/pkg/sumbd:ro --build-arg moxversion=$moxversion --manifest docker.io/moxmail/mox:$moxversion-$goversion-$alpineversion empty
cat <<EOF
# Suggested commands to push images:
podman manifest push --all docker.io/moxmail/mox:$moxversion-$goversion-$alpineversion
podman tag docker.io/moxmail/mox:$moxversion-$goversion-$alpineversion docker.io/moxmail/mox:$moxversion
podman manifest push --all docker.io/moxmail/mox:$moxversion
podman tag docker.io/moxmail/mox:$moxversion docker.io/moxmail/mox:latest
podman manifest push --all docker.io/moxmail/mox:latest
EOF

View file

@ -69,6 +69,8 @@ func TestAccount(t *testing.T) {
_, dests := Account{}.Destinations(authCtx) _, dests := Account{}.Destinations(authCtx)
Account{}.DestinationSave(authCtx, "mjl", dests["mjl"], dests["mjl"]) // todo: save modified value and compare it afterwards Account{}.DestinationSave(authCtx, "mjl", dests["mjl"], dests["mjl"]) // todo: save modified value and compare it afterwards
go importManage()
// Import mbox/maildir tgz/zip. // Import mbox/maildir tgz/zip.
testImport := func(filename string, expect int) { testImport := func(filename string, expect int) {
t.Helper() t.Helper()

View file

@ -94,8 +94,9 @@ var authCache struct {
lastSuccessHash, lastSuccessAuth string lastSuccessHash, lastSuccessAuth string
} }
func init() { // started when we start serving. not at package init time, because we don't want
go func() { // to make goroutines that early.
func manageAuthCache() {
for { for {
authCache.Lock() authCache.Lock()
authCache.lastSuccessHash = "" authCache.lastSuccessHash = ""
@ -103,7 +104,6 @@ func init() {
authCache.Unlock() authCache.Unlock()
time.Sleep(15 * time.Minute) time.Sleep(15 * time.Minute)
} }
}()
} }
// check whether authentication from the config (passwordfile with bcrypt hash) // check whether authentication from the config (passwordfile with bcrypt hash)

View file

@ -16,6 +16,10 @@ import (
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
) )
func init() {
mox.LimitersInit()
}
func TestAdminAuth(t *testing.T) { func TestAdminAuth(t *testing.T) {
test := func(passwordfile, authHdr string, expect bool) { test := func(passwordfile, authHdr string, expect bool) {
t.Helper() t.Helper()

View file

@ -59,10 +59,7 @@ var importers = struct {
make(chan importAbortRequest), make(chan importAbortRequest),
} }
func init() { // manage imports, run in a goroutine before serving.
go importManage()
}
func importManage() { func importManage() {
log := mlog.New("httpimport") log := mlog.New("httpimport")
defer func() { defer func() {

View file

@ -9,6 +9,7 @@ import (
golog "log" golog "log"
"net" "net"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@ -36,9 +37,9 @@ func safeHeaders(fn http.HandlerFunc) http.HandlerFunc {
} }
} }
// ListenAndServe starts listeners for HTTP, including those required for ACME to // Listen binds to sockets for HTTP listeners, including those required for ACME to
// generate TLS certificates. // generate TLS certificates. It stores the listeners so Serve can start serving them.
func ListenAndServe() { func Listen() {
type serve struct { type serve struct {
kinds []string kinds []string
tlsConfig *tls.Config tlsConfig *tls.Config
@ -179,7 +180,7 @@ func ListenAndServe() {
for port, srv := range portServe { for port, srv := range portServe {
for _, ip := range l.IPs { for _, ip := range l.IPs {
listenAndServe(ip, port, srv.tlsConfig, name, srv.kinds, srv.mux) listen1(ip, port, srv.tlsConfig, name, srv.kinds, srv.mux)
} }
} }
} }
@ -198,7 +199,11 @@ func adminIndex(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther) http.Redirect(w, r, "/admin/", http.StatusSeeOther)
} }
func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) { // functions to be launched in goroutine that will serve on a listener.
var servers []func()
// listen prepares a listener, and adds it to "servers", to be launched (if not running as root) through Serve.
func listen1(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port)) addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
var protocol string var protocol string
@ -206,18 +211,23 @@ func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kin
var err error var err error
if tlsConfig == nil { if tlsConfig == nil {
protocol = "http" protocol = "http"
if os.Getuid() == 0 {
xlog.Print("http listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr)) xlog.Print("http listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
ln, err = net.Listen(mox.Network(ip), addr) }
ln, err = mox.Listen(mox.Network(ip), addr)
if err != nil { if err != nil {
xlog.Fatalx("http: listen"+mox.LinuxSetcapHint(err), err, mlog.Field("addr", addr)) xlog.Fatalx("http: listen", err, mlog.Field("addr", addr))
} }
} else { } else {
protocol = "https" protocol = "https"
if os.Getuid() == 0 {
xlog.Print("https listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr)) xlog.Print("https listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
ln, err = tls.Listen(mox.Network(ip), addr, tlsConfig)
if err != nil {
xlog.Fatalx("https: listen"+mox.LinuxSetcapHint(err), err, mlog.Field("addr", addr))
} }
ln, err = mox.Listen(mox.Network(ip), addr)
if err != nil {
xlog.Fatalx("https: listen", err, mlog.Field("addr", addr))
}
ln = tls.NewListener(ln, tlsConfig)
} }
server := &http.Server{ server := &http.Server{
@ -225,8 +235,20 @@ func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kin
TLSConfig: tlsConfig, TLSConfig: tlsConfig,
ErrorLog: golog.New(mlog.ErrWriter(xlog.Fields(mlog.Field("pkg", "net/http")), mlog.LevelInfo, protocol+" error"), "", 0), ErrorLog: golog.New(mlog.ErrWriter(xlog.Fields(mlog.Field("pkg", "net/http")), mlog.LevelInfo, protocol+" error"), "", 0),
} }
go func() { serve := func() {
err := server.Serve(ln) err := server.Serve(ln)
xlog.Fatalx(protocol+": serve", err) xlog.Fatalx(protocol+": serve", err)
}() }
servers = append(servers, serve)
}
// Serve starts serving on the initialized listeners.
func Serve() {
go manageAuthCache()
go importManage()
for _, serve := range servers {
go serve()
}
servers = nil
} }

View file

@ -300,8 +300,8 @@ func (c *conn) xsanity(err error, format string, args ...any) {
type msgseq uint32 type msgseq uint32
// ListenAndServe starts all imap listeners for the configuration, in new goroutines. // Listen initializes all imap listeners for the configuration, and stores them for Serve to start them.
func ListenAndServe() { func Listen() {
for name, listener := range mox.Conf.Static.Listeners { for name, listener := range mox.Conf.Static.Listeners {
var tlsConfig *tls.Config var tlsConfig *tls.Config
if listener.TLS != nil { if listener.TLS != nil {
@ -311,34 +311,36 @@ func ListenAndServe() {
if listener.IMAP.Enabled { if listener.IMAP.Enabled {
port := config.Port(listener.IMAP.Port, 143) port := config.Port(listener.IMAP.Port, 143)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
go listenServe("imap", name, ip, port, tlsConfig, false, listener.IMAP.NoRequireSTARTTLS) listen1("imap", name, ip, port, tlsConfig, false, listener.IMAP.NoRequireSTARTTLS)
} }
} }
if listener.IMAPS.Enabled { if listener.IMAPS.Enabled {
port := config.Port(listener.IMAPS.Port, 993) port := config.Port(listener.IMAPS.Port, 993)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
go listenServe("imaps", name, ip, port, tlsConfig, true, false) listen1("imaps", name, ip, port, tlsConfig, true, false)
} }
} }
} }
} }
func listenServe(protocol, listenerName, ip string, port int, tlsConfig *tls.Config, xtls, noRequireSTARTTLS bool) { var servers []func()
func listen1(protocol, listenerName, ip string, port int, tlsConfig *tls.Config, xtls, noRequireSTARTTLS bool) {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port)) addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
if os.Getuid() == 0 {
xlog.Print("listening for imap", mlog.Field("listener", listenerName), mlog.Field("addr", addr), mlog.Field("protocol", protocol)) xlog.Print("listening for imap", mlog.Field("listener", listenerName), mlog.Field("addr", addr), mlog.Field("protocol", protocol))
network := mox.Network(ip)
var ln net.Listener
var err error
if xtls {
ln, err = tls.Listen(network, addr, tlsConfig)
} else {
ln, err = net.Listen(network, addr)
} }
network := mox.Network(ip)
ln, err := mox.Listen(network, addr)
if err != nil { if err != nil {
xlog.Fatalx("imap: listen for imap"+mox.LinuxSetcapHint(err), err, mlog.Field("protocol", protocol), mlog.Field("listener", listenerName)) xlog.Fatalx("imap: listen for imap", err, mlog.Field("protocol", protocol), mlog.Field("listener", listenerName))
}
if xtls {
ln = tls.NewListener(ln, tlsConfig)
} }
serve := func() {
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
if err != nil { if err != nil {
@ -349,6 +351,17 @@ func listenServe(protocol, listenerName, ip string, port int, tlsConfig *tls.Con
metricIMAPConnection.WithLabelValues(protocol).Inc() metricIMAPConnection.WithLabelValues(protocol).Inc()
go serve(listenerName, mox.Cid(), tlsConfig, conn, xtls, noRequireSTARTTLS) go serve(listenerName, mox.Cid(), tlsConfig, conn, xtls, noRequireSTARTTLS)
} }
}
servers = append(servers, serve)
}
// Serve starts serving on all listeners, launching a goroutine per listener.
func Serve() {
for _, serve := range servers {
go serve()
}
servers = nil
} }
// returns whether this connection accepts utf-8 in strings. // returns whether this connection accepts utf-8 in strings.

View file

@ -43,11 +43,15 @@ func TestDeliver(t *testing.T) {
mox.Shutdown, mox.ShutdownCancel = context.WithCancel(context.Background()) mox.Shutdown, mox.ShutdownCancel = context.WithCancel(context.Background())
// Remove state. // Remove state.
os.RemoveAll("testdata/integration/run") os.RemoveAll("testdata/integration/data")
os.MkdirAll("testdata/integration/run", 0750) os.MkdirAll("testdata/integration/data", 0750)
// Cleanup afterwards, these are owned by root, annoying to have around due to
// permission errors.
defer os.RemoveAll("testdata/integration/data")
// Load mox config. // Load mox config.
mox.ConfigStaticPath = "testdata/integration/mox.conf" mox.ConfigStaticPath = "testdata/integration/config/mox.conf"
filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
if errs := mox.LoadConfig(mox.Context); len(errs) > 0 { if errs := mox.LoadConfig(mox.Context); len(errs) > 0 {
t.Fatalf("loading mox config: %v", errs) t.Fatalf("loading mox config: %v", errs)
@ -69,14 +73,15 @@ func TestDeliver(t *testing.T) {
createAccount("moxtest3@mox3.example", "pass1234") createAccount("moxtest3@mox3.example", "pass1234")
// Start mox. // Start mox.
mtastsdbRefresher := false const mtastsdbRefresher = false
err := start(mtastsdbRefresher) const skipForkExec = true
err := start(mtastsdbRefresher, skipForkExec)
tcheck(t, err, "starting mox") tcheck(t, err, "starting mox")
// todo: we should probably hook store.Comm to get updates. // todo: we should probably hook store.Comm to get updates.
latestMsgID := func(username string) int64 { latestMsgID := func(username string) int64 {
// We open the account index database created by mox for the test user. And we keep looking for the email we sent. // We open the account index database created by mox for the test user. And we keep looking for the email we sent.
dbpath := fmt.Sprintf("testdata/integration/run/accounts/%s/index.db", username) dbpath := fmt.Sprintf("testdata/integration/data/accounts/%s/index.db", username)
db, err := bstore.Open(dbpath, &bstore.Options{Timeout: 3 * time.Second}, store.Message{}, store.Recipient{}, store.Mailbox{}, store.Password{}) db, err := bstore.Open(dbpath, &bstore.Options{Timeout: 3 * time.Second}, store.Message{}, store.Recipient{}, store.Mailbox{}, store.Password{})
if err != nil && errors.Is(err, bolt.ErrTimeout) { if err != nil && errors.Is(err, bolt.ErrTimeout) {
log.Printf("db open timeout (normal delay for new sender with account and db file kept open)") log.Printf("db open timeout (normal delay for new sender with account and db file kept open)")

59
main.go
View file

@ -78,7 +78,6 @@ var commands = []struct {
}{ }{
{"serve", cmdServe}, {"serve", cmdServe},
{"quickstart", cmdQuickstart}, {"quickstart", cmdQuickstart},
{"restart", cmdRestart},
{"stop", cmdStop}, {"stop", cmdStop},
{"setaccountpassword", cmdSetaccountpassword}, {"setaccountpassword", cmdSetaccountpassword},
{"setadminpassword", cmdSetadminpassword}, {"setadminpassword", cmdSetadminpassword},
@ -105,6 +104,7 @@ var commands = []struct {
{"config domain add", cmdConfigDomainAdd}, {"config domain add", cmdConfigDomainAdd},
{"config domain rm", cmdConfigDomainRemove}, {"config domain rm", cmdConfigDomainRemove},
{"config describe-sendmail", cmdConfigDescribeSendmail}, {"config describe-sendmail", cmdConfigDescribeSendmail},
{"config printservice", cmdConfigPrintservice},
{"checkupdate", cmdCheckupdate}, {"checkupdate", cmdCheckupdate},
{"cid", cmdCid}, {"cid", cmdCid},
@ -530,6 +530,27 @@ needs modifications to make it valid.
xcheckf(err, "describing config") xcheckf(err, "describing config")
} }
func cmdConfigPrintservice(c *cmd) {
c.params = ">mox.service"
c.help = `Prints a systemd unit service file for mox.
This is the same file as generated using quickstart. If the systemd service file
has changed with a newer version of mox, use this command to generate an up to
date version.
`
if len(c.Parse()) != 0 {
c.Usage()
}
pwd, err := os.Getwd()
if err != nil {
log.Printf("current working directory: %v", err)
pwd = "/home/mox"
}
service := strings.ReplaceAll(moxService, "/home/mox", pwd)
fmt.Print(service)
}
func cmdConfigDomainAdd(c *cmd) { func cmdConfigDomainAdd(c *cmd) {
c.params = "domain account [localpart]" c.params = "domain account [localpart]"
c.help = `Adds a new domain to the configuration and reloads the configuration. c.help = `Adds a new domain to the configuration and reloads the configuration.
@ -802,42 +823,6 @@ new mail deliveries.
fmt.Println("mox stopped") fmt.Println("mox stopped")
} }
func cmdRestart(c *cmd) {
c.help = `Restart mox after validating the configuration file.
Restart execs the mox binary, which have been updated. Restart returns after
the restart has finished. If you update the mox binary, keep in mind that the
validation of the configuration file is done by the old process with the old
binary. The new binary may report a syntax error. If you update the binary, you
should use the "config test" command with the new binary to validate the
configuration file.
Like stop, existing connections get a 3 second period for graceful shutdown.
`
if len(c.Parse()) != 0 {
c.Usage()
}
mustLoadConfig()
ctl := xctl()
ctl.xwrite("restart")
line := ctl.xread()
if line != "ok" {
log.Fatalf("restart failed: %s", line)
}
// Server is now restarting. It will write ok when it is back online again. If it fails, our connection will be closed.
buf := make([]byte, 128)
n, err := ctl.conn.Read(buf)
if err != nil {
log.Fatalf("restart failed: %s", err)
}
s := strings.TrimSuffix(string(buf[:n]), "\n")
if s != "ok" {
log.Fatalf("restart failed: %s", s)
}
fmt.Println("mox restarted")
}
func cmdSetadminpassword(c *cmd) { func cmdSetadminpassword(c *cmd) {
c.help = `Set a new admin password, for the web interface. c.help = `Set a new admin password, for the web interface.

View file

@ -138,7 +138,7 @@ func MakeAccountConfig(addr smtp.Address) config.Account {
// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using // MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
// accountName for DMARC and TLS reports. // accountName for DMARC and TLS reports.
func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string) (config.Domain, []string, error) { func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) {
log := xlog.WithContext(ctx) log := xlog.WithContext(ctx)
now := time.Now() now := time.Now()
@ -242,14 +242,6 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
Localpart: "dmarc-reports", Localpart: "dmarc-reports",
Mailbox: "DMARC", Mailbox: "DMARC",
}, },
MTASTS: &config.MTASTS{
PolicyID: time.Now().UTC().Format("20060102T150405"),
Mode: mtasts.ModeEnforce,
// We start out with 24 hour, and warn in the admin interface that users should
// increase it to weeks. Once the setup works.
MaxAge: 24 * time.Hour,
MX: []string{hostname.ASCII},
},
TLSRPT: &config.TLSRPT{ TLSRPT: &config.TLSRPT{
Account: accountName, Account: accountName,
Localpart: "tls-reports", Localpart: "tls-reports",
@ -257,6 +249,17 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
}, },
} }
if withMTASTS {
confDomain.MTASTS = &config.MTASTS{
PolicyID: time.Now().UTC().Format("20060102T150405"),
Mode: mtasts.ModeEnforce,
// We start out with 24 hour, and warn in the admin interface that users should
// increase it to weeks once the setup works.
MaxAge: 24 * time.Hour,
MX: []string{hostname.ASCII},
}
}
rpaths := paths rpaths := paths
paths = nil paths = nil
@ -293,7 +296,16 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
nc.Domains[name] = d nc.Domains[name] = d
} }
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName) // Only enable mta-sts for domain if there is a listener with mta-sts.
var withMTASTS bool
for _, l := range Conf.Static.Listeners {
if l.MTASTSHTTPS.Enabled {
withMTASTS = true
break
}
}
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS)
if err != nil { if err != nil {
return fmt.Errorf("preparing domain config: %v", err) return fmt.Errorf("preparing domain config: %v", err)
} }

View file

@ -12,9 +12,11 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"os/user"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -390,6 +392,35 @@ func PrepareStaticConfig(ctx context.Context, configFile string, config *Config,
} }
} }
if c.User == "" {
c.User = "mox"
}
u, err := user.Lookup(c.User)
var userErr user.UnknownUserError
if err != nil && errors.As(err, &userErr) {
uid, err := strconv.ParseUint(c.User, 10, 32)
if err != nil {
addErrorf("parsing unknown user %s as uid: %v", c.User, err)
} else {
// We assume the same gid as uid.
c.UID = uint32(uid)
c.GID = uint32(uid)
}
} else if err != nil {
addErrorf("looking up user: %v", err)
} else {
if uid, err := strconv.ParseUint(u.Uid, 10, 32); err != nil {
addErrorf("parsing uid %s: %v", u.Uid, err)
} else {
c.UID = uint32(uid)
}
if gid, err := strconv.ParseUint(u.Gid, 10, 32); err != nil {
addErrorf("parsing gid %s: %v", u.Gid, err)
} else {
c.GID = uint32(gid)
}
}
hostname, err := dns.ParseDomain(c.Hostname) hostname, err := dns.ParseDomain(c.Hostname)
if err != nil { if err != nil {
addErrorf("parsing hostname: %s", err) addErrorf("parsing hostname: %s", err)

View file

@ -2,15 +2,151 @@ package mox
import ( import (
"context" "context"
"fmt"
"net" "net"
"os"
"os/signal"
"runtime"
"runtime/debug" "runtime/debug"
"strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/mjl-/mox/mlog"
) )
// We start up as root, bind to sockets, and fork and exec as unprivileged user.
// During startup as root, we gather the fd's for the listen addresses in listens,
// and pass their addresses in an environment variable to the new process.
var listens = map[string]*os.File{}
// RestorePassedSockets reads addresses from $MOX_SOCKETS and prepares an os.File
// for each file descriptor, which are used by later calls of Listen.
func RestorePassedSockets() {
s := os.Getenv("MOX_SOCKETS")
if s == "" {
var linuxhint string
if runtime.GOOS == "linux" {
linuxhint = " If you updated from v0.0.1, update the mox.service file to start as root (privileges are dropped): ./mox config printservice >mox.service && sudo systemctl daemon-reload && sudo systemctl restart mox."
}
xlog.Fatal("mox must be started as root, and will drop privileges after binding required sockets (missing environment variable MOX_SOCKETS)." + linuxhint)
}
addrs := strings.Split(s, ",")
for i, addr := range addrs {
// 0,1,2 are stdin,stdout,stderr, 3 is the network/address fd.
f := os.NewFile(3+uintptr(i), addr)
listens[addr] = f
}
}
// Fork and exec as unprivileged user.
//
// We don't use just setuid because it is hard to guarantee that no other
// privileged go worker processes have been started before we get here. E.g. init
// functions in packages can start goroutines.
func ForkExecUnprivileged() {
prog, err := os.Executable()
if err != nil {
xlog.Fatalx("finding executable for exec", err)
}
files := []*os.File{os.Stdin, os.Stdout, os.Stderr}
var addrs []string
for addr, f := range listens {
files = append(files, f)
addrs = append(addrs, addr)
}
env := os.Environ()
env = append(env, "MOX_SOCKETS="+strings.Join(addrs, ","))
p, err := os.StartProcess(prog, os.Args, &os.ProcAttr{
Env: env,
Files: files,
Sys: &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: Conf.Static.UID,
Gid: Conf.Static.GID,
},
},
})
if err != nil {
xlog.Fatalx("fork and exec", err)
}
for _, f := range listens {
err := f.Close()
xlog.Check(err, "closing socket after passing to unprivileged child")
}
// If we get a interrupt/terminate signal, pass it on to the child. For interrupt,
// the child probably already got it.
// todo: see if we tie up child and root process so a kill -9 of the root process
// kills the child process too.
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
go func() {
sig := <-sigc
p.Signal(sig)
}()
st, err := p.Wait()
if err != nil {
xlog.Fatalx("wait", err)
}
code := st.ExitCode()
xlog.Print("stopping after child exit", mlog.Field("exitcode", code))
os.Exit(code)
}
// CleanupPassedSockets closes the listening socket file descriptors passed in by
// the parent process. To be called after listeners have been recreated (they dup
// the file descriptor).
func CleanupPassedSockets() {
for _, f := range listens {
err := f.Close()
xlog.Check(err, "closing listener socket file descriptor")
}
}
// Listen returns a newly created network listener when starting as root, and
// otherwise (not root) returns a network listener from a file descriptor that was
// passed by the parent root process.
func Listen(network, addr string) (net.Listener, error) {
if os.Getuid() != 0 {
f, ok := listens[addr]
if !ok {
return nil, fmt.Errorf("no file descriptor for listener %s", addr)
}
ln, err := net.FileListener(f)
if err != nil {
return nil, fmt.Errorf("making network listener from file descriptor for address %s: %v", addr, err)
}
return ln, nil
}
if _, ok := listens[addr]; ok {
return nil, fmt.Errorf("duplicate listener: %s", addr)
}
ln, err := net.Listen(network, addr)
if err != nil {
return nil, err
}
tcpln, ok := ln.(*net.TCPListener)
if !ok {
return nil, fmt.Errorf("listener not a tcp listener, but %T, for network %s, address %s", ln, network, addr)
}
f, err := tcpln.File()
if err != nil {
return nil, fmt.Errorf("dup listener: %v", err)
}
listens[addr] = f
return ln, err
}
// Shutdown is canceled when a graceful shutdown is initiated. SMTP, IMAP, periodic // Shutdown is canceled when a graceful shutdown is initiated. SMTP, IMAP, periodic
// processes should check this before starting a new operation. If true, the // processes should check this before starting a new operation. If true, the
// operation should be aborted, and new connections should receive a message that // operation should be aborted, and new connections should receive a message that

View file

@ -8,10 +8,6 @@ import (
var LimiterFailedAuth *ratelimit.Limiter var LimiterFailedAuth *ratelimit.Limiter
func init() {
LimitersInit()
}
// LimitesrsInit initializes the failed auth rate limiter. // LimitesrsInit initializes the failed auth rate limiter.
func LimitersInit() { func LimitersInit() {
LimiterFailedAuth = &ratelimit.Limiter{ LimiterFailedAuth = &ratelimit.Limiter{

View file

@ -1,18 +0,0 @@
package mox
import (
"errors"
"os"
"runtime"
)
// todo: perhaps find and document the recommended way to get this on other platforms?
// LinuxSetcapHint returns a hint about using setcap for binding to privileged
// ports, only if relevant the error and GOOS (Linux).
func LinuxSetcapHint(err error) string {
if runtime.GOOS == "linux" && errors.Is(err, os.ErrPermission) {
return " (privileged port? try again after: sudo setcap 'cap_net_bind_service=+ep' mox)"
}
return ""
}

View file

@ -7,27 +7,22 @@ Wants=network-online.target
UMask=007 UMask=007
LimitNOFILE=65535 LimitNOFILE=65535
Type=simple Type=simple
User=mox # Mox starts as root, but drops privileges after binding network addresses.
Group=mox WorkingDirectory=/home/mox
Environment="MOXCONF=/home/service/mox/config/mox.conf" ExecStart=/home/mox/mox serve
WorkingDirectory=/home/service/mox
ExecStart=/home/service/mox/mox serve
RestartSec=5s RestartSec=5s
Restart=always Restart=always
ExecStop=/home/service/mox/mox stop ExecStop=/home/mox/mox stop
# Restart does shut down existing smtp/imap connections (gracefully), but first
# verifies the config file, and it returns after restart was complete.
ExecReload=/home/service/mox/mox restart
# Isolate process, reducing attack surface. # Isolate process, reducing attack surface.
PrivateDevices=yes PrivateDevices=yes
PrivateTmp=yes PrivateTmp=yes
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=/home/service/mox/config /home/service/mox/data ReadWritePaths=/home/mox/config /home/mox/data
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectControlGroups=yes ProtectControlGroups=yes
AmbientCapabilities=CAP_NET_BIND_SERVICE AmbientCapabilities=
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_NET_BIND_SERVICE CAP_CHOWN
NoNewPrivileges=yes NoNewPrivileges=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
ProtectProc=invisible ProtectProc=invisible

View file

@ -1,18 +0,0 @@
package moxio
import (
"fmt"
"syscall"
)
// CheckUmask checks that the umask is 7 for "other". Because files written
// should not be world-accessible. E.g. database files, and the control unix
// domain socket.
func CheckUmask() error {
old := syscall.Umask(007)
syscall.Umask(old)
if old&7 != 7 {
return fmt.Errorf(`umask must have 7 for world/other, e.g. 007, not current %03o`, old)
}
return nil
}

View file

@ -41,15 +41,18 @@ func pwgen() string {
} }
func cmdQuickstart(c *cmd) { func cmdQuickstart(c *cmd) {
c.params = "user@domain" c.params = "user@domain [user | uid]"
c.help = `Quickstart generates configuration files and prints instructions to quickly set up a mox instance. c.help = `Quickstart generates configuration files and prints instructions to quickly set up a mox instance.
Quickstart prints initial admin and account passwords, configuration files, DNS Quickstart writes configuration files, prints initial admin and account
records you should create, instructions for setting correct user/group and passwords, DNS records you should create. If you run it on Linux it writes a
permissions, and if you run it on Linux it prints a systemd service file. systemd service file and prints commands to enable and start mox as service.
The user or uid is optional, defaults to "mox", and is the user or uid/gid mox
will run as after initialization.
` `
args := c.Parse() args := c.Parse()
if len(args) != 1 { if len(args) != 1 && len(args) != 2 {
c.Usage() c.Usage()
} }
@ -342,19 +345,27 @@ This likely means one of two things:
} }
cancel() cancel()
user := "mox"
if len(args) == 2 {
user = args[1]
}
dc := config.Dynamic{} dc := config.Dynamic{}
sc := config.Static{DataDir: "../data"} sc := config.Static{
dataDir := "data" // ../data is relative to config/ DataDir: "../data",
os.MkdirAll(dataDir, 0770) User: user,
sc.LogLevel = "info" LogLevel: "info",
sc.Hostname = hostname.Name() Hostname: hostname.Name(),
sc.ACME = map[string]config.ACME{ ACME: map[string]config.ACME{
"letsencrypt": { "letsencrypt": {
DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory", DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory",
ContactEmail: args[0], // todo: let user specify an alternative fallback address? ContactEmail: args[0], // todo: let user specify an alternative fallback address?
}, },
},
AdminPasswordFile: "adminpasswd",
} }
sc.AdminPasswordFile = "adminpasswd" dataDir := "data" // ../data is relative to config/
os.MkdirAll(dataDir, 0770)
adminpw := pwgen() adminpw := pwgen()
adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost) adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
if err != nil { if err != nil {
@ -399,7 +410,8 @@ This likely means one of two things:
mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below. mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below.
accountConf := mox.MakeAccountConfig(addr) accountConf := mox.MakeAccountConfig(addr)
confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, hostname, username) const withMTASTS = true
confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, hostname, username, withMTASTS)
if err != nil { if err != nil {
fatalf("making domain config: %s", err) fatalf("making domain config: %s", err)
} }
@ -529,47 +541,20 @@ or if you are sending email for your domain from other machines/services, you
should understand the consequences of the DNS records above before should understand the consequences of the DNS records above before
continuing! continuing!
You can now start mox with "mox serve", but see below for recommended ownership You can now start mox with "./mox serve", as root. File ownership and
and permissions. permissions are automatically set correctly by mox when starting up. On linux,
you may want to enable mox as a systemd service.
`) `)
if os.Getenv("MOX_DOCKER") == "" {
fmt.Printf(`Assuming the mox binary is in the current directory, and you will run mox under
user name "mox", and the admin user is the current user, the following commands
set the correct permissions:
sudo useradd -d $PWD mox
sudo chown $(id -nu):mox . mox
sudo chown -R mox:$(id -ng) config data
sudo chmod 751 .
sudo chmod 750 mox
sudo chmod -R u=rwX,g=rwX,o= config data
sudo chmod g+s $(find . -type d)
`)
} else {
fmt.Printf(`Assuming you will run mox under user name "mox", and the admin user is the
current user, the following commands set the correct permissions:
sudo useradd -d $PWD mox
sudo chown $(id -nu):mox .
sudo chown -R mox:$(id -ng) config data
sudo chmod 751 .
sudo chmod -R u=rwX,g=rwX,o= config data
sudo chmod g+s $(find . -type d)
`)
}
// For now, we only give service config instructions for linux when not running in docker. // For now, we only give service config instructions for linux when not running in docker.
if runtime.GOOS == "linux" && os.Getenv("MOX_DOCKER") == "" { if runtime.GOOS == "linux" && os.Getenv("MOX_DOCKER") == "" {
pwd, err := os.Getwd() pwd, err := os.Getwd()
if err != nil { if err != nil {
log.Printf("current working directory: %v", err) log.Printf("current working directory: %v", err)
pwd = "/home/service/mox" pwd = "/home/mox"
} }
service := strings.ReplaceAll(moxService, "/home/service/mox", pwd) service := strings.ReplaceAll(moxService, "/home/mox", pwd)
xwritefile("mox.service", []byte(service), 0644) xwritefile("mox.service", []byte(service), 0644)
cleanupPaths = append(cleanupPaths, "mox.service") cleanupPaths = append(cleanupPaths, "mox.service")
fmt.Printf(`See mox.service for a systemd service file. To enable and start: fmt.Printf(`See mox.service for a systemd service file. To enable and start:

339
serve.go
View file

@ -4,12 +4,13 @@ import (
"context" "context"
cryptorand "crypto/rand" cryptorand "crypto/rand"
"fmt" "fmt"
"io/fs"
"net" "net"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime"
"runtime/debug" "runtime/debug"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@ -24,7 +25,6 @@ import (
"github.com/mjl-/mox/metrics" "github.com/mjl-/mox/metrics"
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
"github.com/mjl-/mox/updates" "github.com/mjl-/mox/updates"
@ -138,60 +138,75 @@ requested, other TLS certificates are requested on demand.
log := mlog.New("serve") log := mlog.New("serve")
if os.Getuid() == 0 { if os.Getuid() == 0 {
log.Fatal("refusing to run as root, please start mox as unprivileged user") // No need to potentially start and keep multiple processes. As root, we just need
// to start the child process.
runtime.GOMAXPROCS(1)
log.Print("starting as root, initializing network listeners", mlog.Field("version", moxvar.Version), mlog.Field("pid", os.Getpid()))
if os.Getenv("MOX_SOCKETS") != "" {
log.Fatal("refusing to start as root with $MOX_SOCKETS set")
} }
if fds := os.Getenv("MOX_RESTART_CTL_SOCKET"); fds != "" { if !mox.Conf.Static.NoFixPermissions {
log.Print("restarted") // Fix permissions now that we have privilege to do so. Useful for update of v0.0.1
// that was running directly as mox-user.
fd, err := strconv.ParseUint(fds, 10, 32) workdir, err := os.Getwd()
if err != nil { if err != nil {
log.Fatalx("restart with invalid ctl socket", err, mlog.Field("fd", fds)) log.Printx("get working dir, continuing without potentially fixing up permissions", err)
} } else {
f := os.NewFile(uintptr(fd), "restartctl") configdir := filepath.Dir(mox.ConfigStaticPath)
if _, err := fmt.Fprint(f, "ok\n"); err != nil { datadir := mox.DataDirPath(".")
log.Infox("writing ok to restart ctl socket", err) err := fixperms(log, workdir, configdir, datadir, mox.Conf.Static.UID, mox.Conf.Static.GID)
} if err != nil {
err = f.Close() log.Fatalx("fixing permissions", err)
log.Check(err, "closing restart ctl socket")
}
log.Print("starting up", mlog.Field("version", moxvar.Version))
shutdown := func() {
// We indicate we are shutting down. Causes new connections and new SMTP commands
// to be rejected. Should stop active connections pretty quickly.
mox.ShutdownCancel()
// Now we are going to wait for all connections to be gone, up to a timeout.
done := mox.Connections.Done()
second := time.Tick(time.Second)
select {
case <-done:
log.Print("connections shutdown, waiting until 1 second passed")
<-second
case <-time.Tick(3 * time.Second):
// We now cancel all pending operations, and set an immediate deadline on sockets.
// Should get us a clean shutdown relatively quickly.
mox.ContextCancel()
mox.Connections.Shutdown()
second := time.Tick(time.Second)
select {
case <-done:
log.Print("no more connections, shutdown is clean, waiting until 1 second passed")
<-second // Still wait for second, giving processes like imports a chance to clean up.
case <-second:
log.Print("shutting down with pending sockets")
} }
} }
err := os.Remove(mox.DataDirPath("ctl")) }
log.Check(err, "removing ctl unix domain socket during shutdown") } else {
log.Print("starting as unprivileged user", mlog.Field("user", mox.Conf.Static.User), mlog.Field("uid", mox.Conf.Static.UID), mlog.Field("gid", mox.Conf.Static.GID), mlog.Field("pid", os.Getpid()))
mox.RestorePassedSockets()
} }
if err := moxio.CheckUmask(); err != nil { syscall.Umask(syscall.Umask(007) | 007)
log.Errorx("bad umask", err)
// Initialize key and random buffer for creating opaque SMTP
// transaction IDs based on "cid"s.
recvidpath := mox.DataDirPath("receivedid.key")
recvidbuf, err := os.ReadFile(recvidpath)
if err != nil || len(recvidbuf) != 16+8 {
recvidbuf = make([]byte, 16+8)
if _, err := cryptorand.Read(recvidbuf); err != nil {
log.Fatalx("reading random recvid data", err)
} }
if err := os.WriteFile(recvidpath, recvidbuf, 0660); err != nil {
log.Fatalx("writing recvidpath", err, mlog.Field("path", recvidpath))
}
err := os.Chown(recvidpath, int(mox.Conf.Static.UID), 0)
log.Check(err, "chown receveidid.key", mlog.Field("path", recvidpath), mlog.Field("uid", mox.Conf.Static.UID), mlog.Field("gid", 0))
err = os.Chmod(recvidpath, 0640)
log.Check(err, "chmod receveidid.key to 0640", mlog.Field("path", recvidpath))
}
if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
log.Fatalx("init receivedid", err)
}
// Start mox. If running as root, this will bind/listen on network sockets, and
// fork and exec itself as unprivileged user, then waits for the child to stop and
// exit. When running as root, this function never returns. But the new
// unprivileged user will get here again, with network sockets prepared.
//
// We listen to the unix domain ctl socket afterwards, which we always remove
// before listening. We need to do that because we may not have cleaned up our
// control socket during unexpected shutdown. We don't want to remove and listen on
// the unix domain socket first. If we would, we would make the existing instance
// unreachable over its ctl socket, and then fail because the network addresses are
// taken.
const mtastsdbRefresher = true
const skipForkExec = false
if err := start(mtastsdbRefresher, skipForkExec); err != nil {
log.Fatalx("start", err)
}
log.Print("ready to serve")
if mox.Conf.Static.CheckUpdates { if mox.Conf.Static.CheckUpdates {
checkUpdates := func() time.Duration { checkUpdates := func() time.Duration {
@ -281,37 +296,40 @@ requested, other TLS certificates are requested on demand.
}() }()
} }
// Initialize key and random buffer for creating opaque SMTP
// transaction IDs based on "cid"s.
recvidpath := mox.DataDirPath("receivedid.key")
recvidbuf, err := os.ReadFile(recvidpath)
if err != nil || len(recvidbuf) != 16+8 {
recvidbuf = make([]byte, 16+8)
if _, err := cryptorand.Read(recvidbuf); err != nil {
log.Fatalx("reading random recvid data", err)
}
if err := os.WriteFile(recvidpath, recvidbuf, 0660); err != nil {
log.Fatalx("writing recvidpath", err, mlog.Field("path", recvidpath))
}
}
if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
log.Fatalx("init receivedid", err)
}
// We start the network listeners first. If an instance is already running, we'll
// get errors about address being in use. We listen to the unix domain socket
// afterwards, which we always remove before listening. We need to do that because
// we may not have cleaned up our control socket during unexpected shutdown. We
// don't want to remove and listen on the unix domain socket first. If we would, we
// would make the existing instance unreachable over its ctl socket, and then fail
// because the network addresses are taken.
mtastsdbRefresher := true
if err := start(mtastsdbRefresher); err != nil {
log.Fatalx("start", err)
}
go monitorDNSBL(log) go monitorDNSBL(log)
shutdown := func() {
// We indicate we are shutting down. Causes new connections and new SMTP commands
// to be rejected. Should stop active connections pretty quickly.
mox.ShutdownCancel()
// Now we are going to wait for all connections to be gone, up to a timeout.
done := mox.Connections.Done()
second := time.Tick(time.Second)
select {
case <-done:
log.Print("connections shutdown, waiting until 1 second passed")
<-second
case <-time.Tick(3 * time.Second):
// We now cancel all pending operations, and set an immediate deadline on sockets.
// Should get us a clean shutdown relatively quickly.
mox.ContextCancel()
mox.Connections.Shutdown()
second := time.Tick(time.Second)
select {
case <-done:
log.Print("no more connections, shutdown is clean, waiting until 1 second passed")
<-second // Still wait for second, giving processes like imports a chance to clean up.
case <-second:
log.Print("shutting down with pending sockets")
}
}
err := os.Remove(mox.DataDirPath("ctl"))
log.Check(err, "removing ctl unix domain socket during shutdown")
}
ctlpath := mox.DataDirPath("ctl") ctlpath := mox.DataDirPath("ctl")
_ = os.Remove(ctlpath) _ = os.Remove(ctlpath)
ctl, err := net.Listen("unix", ctlpath) ctl, err := net.Listen("unix", ctlpath)
@ -359,4 +377,171 @@ requested, other TLS certificates are requested on demand.
sig := <-sigc sig := <-sigc
log.Print("shutting down, waiting max 3s for existing connections", mlog.Field("signal", sig)) log.Print("shutting down, waiting max 3s for existing connections", mlog.Field("signal", sig))
shutdown() shutdown()
if num, ok := sig.(syscall.Signal); ok {
os.Exit(int(num))
} else {
os.Exit(1)
}
}
// Set correct permissions for mox working directory, binary, config and data and service file.
//
// We require being able to stat the basic non-optional paths. Then we'll try to
// fix up permissions. If an error occurs when fixing permissions, we log and
// continue (could not be an actual problem).
func fixperms(log *mlog.Log, workdir, configdir, datadir string, moxuid, moxgid uint32) (rerr error) {
type fserr struct{ Err error }
defer func() {
x := recover()
if x == nil {
return
}
e, ok := x.(fserr)
if ok {
rerr = e.Err
} else {
panic(x)
}
}()
checkf := func(err error, format string, args ...any) {
if err != nil {
panic(fserr{fmt.Errorf(format, args...)})
}
}
// Changes we have to make. We collect them first, then apply.
type change struct {
path string
uid, gid *uint32
olduid, oldgid uint32
mode *fs.FileMode
oldmode fs.FileMode
}
var changes []change
ensure := func(p string, uid, gid uint32, perm fs.FileMode) bool {
fi, err := os.Stat(p)
checkf(err, "stat %s", p)
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
checkf(fmt.Errorf("got %T", st), "stat sys, expected syscall.Stat_t")
}
var ch change
if st.Uid != uid || st.Gid != gid {
ch.uid = &uid
ch.gid = &gid
ch.olduid = st.Uid
ch.oldgid = st.Gid
}
if perm != fi.Mode()&(fs.ModeSetgid|0777) {
ch.mode = &perm
ch.oldmode = fi.Mode() & (fs.ModeSetgid | 0777)
}
var zerochange change
if ch == zerochange {
return false
}
ch.path = p
changes = append(changes, ch)
return true
}
xexists := func(p string) bool {
_, err := os.Stat(p)
if err != nil && !os.IsNotExist(err) {
checkf(err, "stat %s", p)
}
return err == nil
}
// We ensure these permissions:
//
// $workdir root:mox 0751
// $configdir mox:root 0750 + setgid, and recursively (but files 0640)
// $datadir mox:root 0750 + setgid, and recursively (but files 0640)
// $workdir/mox (binary, optional) root:mox 0750
// $workdir/mox.service (systemd service file, optional) root:root 0644
const root = 0
ensure(workdir, root, moxgid, 0751)
fixconfig := ensure(configdir, moxuid, 0, fs.ModeSetgid|0750)
fixdata := ensure(datadir, moxuid, 0, fs.ModeSetgid|0750)
// Binary and systemd service file do not exist (there) when running under docker.
binary := filepath.Join(workdir, "mox")
if xexists(binary) {
ensure(binary, root, moxgid, 0750)
}
svc := filepath.Join(workdir, "mox.service")
if xexists(svc) {
ensure(svc, root, root, 0644)
}
if len(changes) == 0 {
return
}
// Apply changes.
log.Print("fixing up permissions, will continue on errors")
for _, ch := range changes {
if ch.uid != nil {
err := os.Chown(ch.path, int(*ch.uid), int(*ch.gid))
log.Printx("chown, fixing uid/gid", err, mlog.Field("path", ch.path), mlog.Field("olduid", ch.olduid), mlog.Field("oldgid", ch.oldgid), mlog.Field("newuid", *ch.uid), mlog.Field("newgid", *ch.gid))
}
if ch.mode != nil {
err := os.Chmod(ch.path, *ch.mode)
log.Printx("chmod, fixing permissions", err, mlog.Field("path", ch.path), mlog.Field("oldmode", fmt.Sprintf("%03o", ch.oldmode)), mlog.Field("newmode", fmt.Sprintf("%03o", *ch.mode)))
}
}
walkchange := func(dir string) {
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printx("walk error, continuing", err, mlog.Field("path", path))
return nil
}
fi, err := d.Info()
if err != nil {
log.Printx("stat during walk, continuing", err, mlog.Field("path", path))
return nil
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
log.Printx("syscall stat during walk, continuing", err, mlog.Field("path", path))
return nil
}
if st.Uid != moxuid || st.Gid != root {
err := os.Chown(path, int(moxuid), root)
log.Printx("walk chown, fixing uid/gid", err, mlog.Field("path", path), mlog.Field("olduid", st.Uid), mlog.Field("oldgid", st.Gid), mlog.Field("newuid", moxuid), mlog.Field("newgid", root))
}
omode := fi.Mode() & (fs.ModeSetgid | 0777)
var nmode fs.FileMode
if fi.IsDir() {
nmode = fs.ModeSetgid | 0750
} else {
nmode = 0640
}
if omode != nmode {
err := os.Chmod(path, nmode)
log.Printx("walk chmod, fixing permissions", err, mlog.Field("path", path), mlog.Field("oldmode", fmt.Sprintf("%03o", omode)), mlog.Field("newmode", fmt.Sprintf("%03o", nmode)))
}
return nil
})
log.Check(err, "walking dir to fix permissions", mlog.Field("dir", dir))
}
// If config or data dir needed fixing, also set uid/gid and mode and files/dirs
// inside, recursively. We don't always recurse, data probably contains many files.
if fixconfig {
log.Print("fixing permissions in config dir", mlog.Field("configdir", configdir))
walkchange(configdir)
}
if fixdata {
log.Print("fixing permissions in data dir", mlog.Field("configdir", configdir))
walkchange(datadir)
}
return nil
} }

View file

@ -161,8 +161,9 @@ var (
var jitterRand = mox.NewRand() var jitterRand = mox.NewRand()
// ListenAndServe starts network listeners that serve incoming SMTP connection. // Listen initializes network listeners for incoming SMTP connection.
func ListenAndServe() { // The listeners are stored for a later call to Serve.
func Listen() {
for name, listener := range mox.Conf.Static.Listeners { for name, listener := range mox.Conf.Static.Listeners {
var tlsConfig *tls.Config var tlsConfig *tls.Config
if listener.TLS != nil { if listener.TLS != nil {
@ -181,7 +182,7 @@ func ListenAndServe() {
} }
port := config.Port(listener.SMTP.Port, 25) port := config.Port(listener.SMTP.Port, 25)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
go listenServe("smtp", name, ip, port, hostname, tlsConfig, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, listener.SMTP.DNSBLZones) listen1("smtp", name, ip, port, hostname, tlsConfig, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, listener.SMTP.DNSBLZones)
} }
} }
if listener.Submission.Enabled { if listener.Submission.Enabled {
@ -191,7 +192,7 @@ func ListenAndServe() {
} }
port := config.Port(listener.Submission.Port, 587) port := config.Port(listener.Submission.Port, 587)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
go listenServe("submission", name, ip, port, hostname, tlsConfig, true, false, maxMsgSize, !listener.Submission.NoRequireSTARTTLS, !listener.Submission.NoRequireSTARTTLS, nil) listen1("submission", name, ip, port, hostname, tlsConfig, true, false, maxMsgSize, !listener.Submission.NoRequireSTARTTLS, !listener.Submission.NoRequireSTARTTLS, nil)
} }
} }
@ -202,26 +203,29 @@ func ListenAndServe() {
} }
port := config.Port(listener.Submissions.Port, 465) port := config.Port(listener.Submissions.Port, 465)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
go listenServe("submissions", name, ip, port, hostname, tlsConfig, true, true, maxMsgSize, true, true, nil) listen1("submissions", name, ip, port, hostname, tlsConfig, true, true, maxMsgSize, true, true, nil)
} }
} }
} }
} }
func listenServe(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig *tls.Config, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery bool, dnsBLs []dns.Domain) { var servers []func()
func listen1(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig *tls.Config, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery bool, dnsBLs []dns.Domain) {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port)) addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
if os.Getuid() == 0 {
xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", addr), mlog.Field("protocol", protocol)) xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", addr), mlog.Field("protocol", protocol))
}
network := mox.Network(ip) network := mox.Network(ip)
var ln net.Listener ln, err := mox.Listen(network, addr)
var err error
if xtls {
ln, err = tls.Listen(network, addr, tlsConfig)
} else {
ln, err = net.Listen(network, addr)
}
if err != nil { if err != nil {
xlog.Fatalx("smtp: listen for smtp"+mox.LinuxSetcapHint(err), err, mlog.Field("protocol", protocol), mlog.Field("listener", name)) xlog.Fatalx("smtp: listen for smtp", err, mlog.Field("protocol", protocol), mlog.Field("listener", name))
} }
if xtls {
ln = tls.NewListener(ln, tlsConfig)
}
serve := func() {
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
if err != nil { if err != nil {
@ -231,6 +235,16 @@ func listenServe(protocol, name, ip string, port int, hostname dns.Domain, tlsCo
resolver := dns.StrictResolver{} // By leaving Pkg empty, it'll be set by each package that uses the resolver, e.g. spf/dkim/dmarc. resolver := dns.StrictResolver{} // By leaving Pkg empty, it'll be set by each package that uses the resolver, e.g. spf/dkim/dmarc.
go serve(name, mox.Cid(), hostname, tlsConfig, conn, resolver, submission, xtls, maxMessageSize, requireTLSForAuth, requireTLSForDelivery, dnsBLs) go serve(name, mox.Cid(), hostname, tlsConfig, conn, resolver, submission, xtls, maxMessageSize, requireTLSForAuth, requireTLSForDelivery, dnsBLs)
} }
}
servers = append(servers, serve)
}
// Serve starts serving on all listeners, launching a goroutine per listener.
func Serve() {
for _, serve := range servers {
go serve()
}
} }
type conn struct { type conn struct {

View file

@ -2,11 +2,13 @@ package main
import ( import (
"fmt" "fmt"
"os"
"github.com/mjl-/mox/dmarcdb" "github.com/mjl-/mox/dmarcdb"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/http" "github.com/mjl-/mox/http"
"github.com/mjl-/mox/imapserver" "github.com/mjl-/mox/imapserver"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtastsdb" "github.com/mjl-/mox/mtastsdb"
"github.com/mjl-/mox/queue" "github.com/mjl-/mox/queue"
"github.com/mjl-/mox/smtpserver" "github.com/mjl-/mox/smtpserver"
@ -16,7 +18,23 @@ import (
// start initializes all packages, starts all listeners and the switchboard // start initializes all packages, starts all listeners and the switchboard
// goroutine, then returns. // goroutine, then returns.
func start(mtastsdbRefresher bool) error { func start(mtastsdbRefresher, skipForkExec bool) error {
smtpserver.Listen()
imapserver.Listen()
http.Listen()
if !skipForkExec {
// If we were just launched as root, fork and exec as unprivileged user, handing
// over the bound sockets to the new process. We'll get to this same code path
// again, skipping this if block, continuing below with the actual serving.
if os.Getuid() == 0 {
mox.ForkExecUnprivileged()
panic("cannot happen")
} else {
mox.CleanupPassedSockets()
}
}
if err := dmarcdb.Init(); err != nil { if err := dmarcdb.Init(); err != nil {
return fmt.Errorf("dmarc init: %s", err) return fmt.Errorf("dmarc init: %s", err)
} }
@ -34,9 +52,11 @@ func start(mtastsdbRefresher bool) error {
return fmt.Errorf("queue start: %s", err) return fmt.Errorf("queue start: %s", err)
} }
smtpserver.ListenAndServe() store.StartAuthCache()
imapserver.ListenAndServe() smtpserver.Serve()
http.ListenAndServe() imapserver.Serve()
http.Serve()
go func() { go func() {
<-store.Switchboard() <-store.Switchboard()
}() }()

View file

@ -1184,25 +1184,29 @@ func (a *Account) RejectsRemove(log *mlog.Log, rejectsMailbox, messageID string)
} }
// We keep a cache of recent successful authentications, so we don't have to bcrypt successful calls each time. // We keep a cache of recent successful authentications, so we don't have to bcrypt successful calls each time.
var authCache struct { var authCache = struct {
sync.Mutex sync.Mutex
success map[authKey]string success map[authKey]string
}{
success: map[authKey]string{},
} }
type authKey struct { type authKey struct {
email, hash string email, hash string
} }
func init() { // StartAuthCache starts a goroutine that regularly clears the auth cache.
authCache.success = map[authKey]string{} func StartAuthCache() {
go func() { go manageAuthCache()
}
func manageAuthCache() {
for { for {
authCache.Lock() authCache.Lock()
authCache.success = map[authKey]string{} authCache.success = map[authKey]string{}
authCache.Unlock() authCache.Unlock()
time.Sleep(15 * time.Minute) time.Sleep(15 * time.Minute)
} }
}()
} }
// OpenEmailAuth opens an account given an email address and password. // OpenEmailAuth opens an account given an email address and password.

View file

@ -1,6 +1,7 @@
DataDir: data DataDir: ../data
LogLevel: trace LogLevel: trace
Hostname: mox.example Hostname: mox.example
User: 1000
Listeners: Listeners:
local: local:
IPs: IPs:

View file

@ -14,7 +14,7 @@ Domains:
- From - From
- To - To
- Subject - Subject
PrivateKeyFile: dkim/mox2dkim0-key.pem PrivateKeyFile: ../dkim/mox2dkim0-key.pem
Sign: Sign:
- mox2dkim0 - mox2dkim0
# todo: DMARC: # todo: DMARC:
@ -32,7 +32,7 @@ Domains:
- From - From
- To - To
- Subject - Subject
PrivateKeyFile: dkim/mox3dkim0-key.pem PrivateKeyFile: ../dkim/mox3dkim0-key.pem
Sign: Sign:
- mox3dkim0 - mox3dkim0
Accounts: Accounts:

View file

@ -1,10 +1,12 @@
DataDir: ./run DataDir: ../data
LogLevel: trace LogLevel: trace
Hostname: moxmail1.mox1.example Hostname: moxmail1.mox1.example
# only for integration test, where fork & exec is skipped
User: 0
TLS: TLS:
CA: CA:
CertFiles: CertFiles:
- tls/ca.pem - ../tls/ca.pem
Listeners: Listeners:
mox1: mox1:
IPs: IPs:
@ -23,8 +25,8 @@ Listeners:
TLS: TLS:
KeyCerts: KeyCerts:
- -
CertFile: tls/moxmail2.pem CertFile: ../tls/moxmail2.pem
KeyFile: tls/moxmail2-key.pem KeyFile: ../tls/moxmail2-key.pem
SMTP: SMTP:
Enabled: true Enabled: true
Submission: Submission:
@ -53,8 +55,8 @@ Listeners:
TLS: TLS:
KeyCerts: KeyCerts:
- -
CertFile: tls/moxmail3.pem CertFile: ../tls/moxmail3.pem
KeyFile: tls/moxmail3-key.pem KeyFile: ../tls/moxmail3-key.pem
SMTP: SMTP:
Enabled: true Enabled: true
Submission: Submission: