From 92e018e46371c071e38a3ef2e9213b28c74ca6d6 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Mon, 27 Feb 2023 12:19:55 +0100 Subject: [PATCH] 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! --- .gitignore | 2 +- Dockerfile | 5 - Dockerfile.release | 29 ++ Makefile | 16 +- README.md | 20 +- config/config.go | 6 + config/doc.go | 10 + ctl.go | 54 --- doc.go | 38 +- docker-compose-imaptest.yml | 9 +- docker-compose-integration.yml | 2 - docker-compose.yml | 11 +- docker-release.sh | 49 +++ http/account_test.go | 2 + http/admin.go | 20 +- http/admin_test.go | 4 + http/import.go | 5 +- http/web.go | 50 ++- imapserver/server.go | 57 +-- integration_test.go | 17 +- main.go | 59 ++- mox-/admin.go | 32 +- mox-/config.go | 31 ++ mox-/lifecycle.go | 136 +++++++ mox-/limitauth.go | 4 - mox-/setcaphint.go | 18 - mox.service | 19 +- moxio/umask.go | 18 - quickstart.go | 81 ++--- serve.go | 339 ++++++++++++++---- smtpserver/server.go | 58 +-- start.go | 28 +- store/account.go | 26 +- testdata/imaptest/{ => config}/domains.conf | 0 testdata/imaptest/{ => config}/mox.conf | 3 +- .../integration/{ => config}/domains.conf | 4 +- testdata/integration/{ => config}/mox.conf | 14 +- 37 files changed, 841 insertions(+), 435 deletions(-) create mode 100644 Dockerfile.release create mode 100755 docker-release.sh delete mode 100644 mox-/setcaphint.go delete mode 100644 moxio/umask.go rename testdata/imaptest/{ => config}/domains.conf (100%) rename testdata/imaptest/{ => config}/mox.conf (87%) rename testdata/integration/{ => config}/domains.conf (93%) rename testdata/integration/{ => config}/mox.conf (78%) diff --git a/.gitignore b/.gitignore index db7fb95..f1a45f5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ /testdata/httpaccount/data/ /testdata/imap/data/ /testdata/imaptest/data/ -/testdata/integration/run +/testdata/integration/data/ /testdata/junk/*.bloom /testdata/junk/*.db /testdata/queue/data/ diff --git a/Dockerfile b/Dockerfile index 301b85a..0633750 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,6 @@ FROM alpine:latest WORKDIR /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. EXPOSE 25/tcp # SMTP/submission with TLS. diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..35b8f7b --- /dev/null +++ b/Dockerfile.release @@ -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"] diff --git a/Makefile b/Makefile index 0875217..11134e8 100644 --- a/Makefile +++ b/Makefile @@ -44,21 +44,22 @@ integration-build: docker-compose -f docker-compose-integration.yml build --no-cache moxmail integration-start: - -MOX_UID=$$(id -u) MOX_GID=$$(id -g) docker-compose -f docker-compose-integration.yml run moxmail /bin/bash - MOX_UID= MOX_GID= docker-compose -f docker-compose-integration.yml down + -rm -r testdata/integration/data + -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" integration-test: CGO_ENABLED=0 go test -tags integration 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: -rm -r 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 - MOX_UID= MOX_GID= docker-compose -f docker-compose-imaptest.yml down + 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 down fmt: go fmt ./... @@ -72,4 +73,7 @@ jsinstall: npm install jshint@2.13.2 docker: - docker build -t mox:latest . + docker build -t mox:dev . + +docker-release: + ./docker-release.sh diff --git a/README.md b/README.md index 05e1d3d..00c9d99 100644 --- a/README.md +++ b/README.md @@ -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. -You can also run mox with docker image "moxmail/mox" on hub.docker.com, with -tags like "latest", "0.0.1", etc. See docker-compose.yml in this repository. +You can also run mox with docker image "docker.io/moxmail/mox", with tags like +"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 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 -admin user, e.g. /home/service, download mox, and generate a configuration for -your desired email address at your domain: +vm/machine dedicated to serving email, name it [host].[domain] (e.g. +mail.example.com), login as root, create user "mox" and its homedir by running +"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 This creates an account, generates a password and configuration files, prints -the DNS records you need to manually create and prints commands to set -permissions and install mox as a service. +the DNS records you need to manually create and prints commands to start mox and +optionally install mox as a service. 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 the suggested configuration and/or DNS records. 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 -requires more configuration. +and mox currently needs it for automatic TLS. You can combine mox with an +existing webserver, but it requires more configuration. After starting, you can access the admin web interface on internal IPs. diff --git a/config/config.go b/config/config.go index 4198b3d..ade7717 100644 --- a/config/config.go +++ b/config/config.go @@ -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."` 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)."` + 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."` 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."` @@ -59,6 +61,10 @@ type Static struct { // family, outgoing connections with the other address family are still made if // possible. 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. diff --git a/config/doc.go b/config/doc.go index 2fcfc0f..e4e4f13 100644 --- a/config/doc.go +++ b/config/doc.go @@ -25,6 +25,16 @@ describe-static" and "mox config describe-domains": PackageLogLevels: 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. Hostname: diff --git a/ctl.go b/ctl.go index d29b53e..c17fefd 100644 --- a/ctl.go +++ b/ctl.go @@ -9,12 +9,10 @@ import ( "net" "os" "path/filepath" - "runtime" "runtime/debug" "sort" "strconv" "strings" - "syscall" "time" "github.com/mjl-/bstore" @@ -301,58 +299,6 @@ func servectlcmd(ctx context.Context, log *mlog.Log, ctl *ctl, xcmd *string, shu shutdown() 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": /* The protocol, double quoted are literals. diff --git a/doc.go b/doc.go index b39a1c1..18b3d85 100644 --- a/doc.go +++ b/doc.go @@ -14,8 +14,7 @@ low-maintenance self-hosted email. mox [-config config/mox.conf] ... mox serve - mox quickstart user@domain - mox restart + mox quickstart user@domain [user | uid] mox stop mox setaccountpassword address mox setadminpassword @@ -41,6 +40,7 @@ low-maintenance self-hosted email. mox config domain add domain account [localpart] mox config domain rm domain mox config describe-sendmail >/etc/moxsubmit.conf + mox config printservice >mox.service mox checkupdate mox cid cid 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 prints initial admin and account passwords, configuration files, DNS -records you should create, instructions for setting correct user/group and -permissions, and if you run it on Linux it prints a systemd service file. +Quickstart writes configuration files, prints initial admin and account +passwords, DNS records you should create. If you run it on Linux it writes a +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 - -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 + usage: mox quickstart user@domain [user | uid] # mox stop @@ -390,6 +378,16 @@ Describe configuration for mox when invoked as sendmail. 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 Check if a newer version of mox is available. diff --git a/docker-compose-imaptest.yml b/docker-compose-imaptest.yml index a967b95..38a7a30 100644 --- a/docker-compose-imaptest.yml +++ b/docker-compose-imaptest.yml @@ -4,15 +4,13 @@ services: build: context: . dockerfile: Dockerfile.moximaptest - user: ${MOX_UID}:${MOX_GID} volumes: + - ./testdata/imaptest/config:/mox/config - ./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 working_dir: /mox - tty: true # For job control - command: sh -c 'export MOXCONF=mox.conf; set -m; mox serve & sleep 1; echo testtest | mox setaccountpassword mjl@mox.example; fg' + tty: true # For job control with set -m. + command: sh -c 'set -m; mox serve & sleep 1; echo testtest | mox setaccountpassword mjl@mox.example; fg' healthcheck: test: netstat -nlt | grep ':1143 ' interval: 1s @@ -27,7 +25,6 @@ services: working_dir: /imaptest volumes: - ./testdata/imaptest:/imaptest - user: ${MOX_UID}:${MOX_GID} depends_on: mox: condition: service_healthy diff --git a/docker-compose-integration.yml b/docker-compose-integration.yml index e8f6cfd..52ff11f 100644 --- a/docker-compose-integration.yml +++ b/docker-compose-integration.yml @@ -7,14 +7,12 @@ services: build: dockerfile: Dockerfile.moxmail context: testdata/integration - user: ${MOX_UID}:${MOX_GID} volumes: - ./.go:/.go - ./testdata/integration/resolv.conf:/etc/resolv.conf - .:/mox environment: GOCACHE: /.go/.cache/go-build - command: ["make", "test-postfix"] healthcheck: test: netstat -nlt | grep ':25 ' interval: 1s diff --git a/docker-compose.yml b/docker-compose.yml index 8fc4568..fdd1c3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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' services: mox: # Replace latest with the version you want to run. image: moxmail/mox:latest - user: ${MOX_UID}:${MOX_GID} environment: - MOX_DOCKER=... # Quickstart won't try to write systemd service file. # Mox needs host networking because it needs access to the IPs of the # machine, and the IPs of incoming connections for spam filtering. network_mode: 'host' - command: sh -c "umask 007 && exec mox serve" volumes: - ./config:/mox/config - ./data:/mox/data diff --git a/docker-release.sh b/docker-release.sh new file mode 100755 index 0000000..1f0e42c --- /dev/null +++ b/docker-release.sh @@ -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 < 0 { t.Fatalf("loading mox config: %v", errs) @@ -69,14 +73,15 @@ func TestDeliver(t *testing.T) { createAccount("moxtest3@mox3.example", "pass1234") // Start mox. - mtastsdbRefresher := false - err := start(mtastsdbRefresher) + const mtastsdbRefresher = false + const skipForkExec = true + err := start(mtastsdbRefresher, skipForkExec) tcheck(t, err, "starting mox") // todo: we should probably hook store.Comm to get updates. 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. - 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{}) 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)") diff --git a/main.go b/main.go index 190052d..b1d5a05 100644 --- a/main.go +++ b/main.go @@ -78,7 +78,6 @@ var commands = []struct { }{ {"serve", cmdServe}, {"quickstart", cmdQuickstart}, - {"restart", cmdRestart}, {"stop", cmdStop}, {"setaccountpassword", cmdSetaccountpassword}, {"setadminpassword", cmdSetadminpassword}, @@ -105,6 +104,7 @@ var commands = []struct { {"config domain add", cmdConfigDomainAdd}, {"config domain rm", cmdConfigDomainRemove}, {"config describe-sendmail", cmdConfigDescribeSendmail}, + {"config printservice", cmdConfigPrintservice}, {"checkupdate", cmdCheckupdate}, {"cid", cmdCid}, @@ -530,6 +530,27 @@ needs modifications to make it valid. 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) { c.params = "domain account [localpart]" c.help = `Adds a new domain to the configuration and reloads the configuration. @@ -802,42 +823,6 @@ new mail deliveries. 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) { c.help = `Set a new admin password, for the web interface. diff --git a/mox-/admin.go b/mox-/admin.go index b8de874..6207526 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -138,7 +138,7 @@ func MakeAccountConfig(addr smtp.Address) config.Account { // MakeDomainConfig makes a new config for a domain, creating DKIM keys, using // 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) now := time.Now() @@ -242,14 +242,6 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN Localpart: "dmarc-reports", 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{ Account: accountName, 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 paths = nil @@ -293,7 +296,16 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local 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 { return fmt.Errorf("preparing domain config: %v", err) } diff --git a/mox-/config.go b/mox-/config.go index c9d9963..9e8e878 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -12,9 +12,11 @@ import ( "fmt" "net" "os" + "os/user" "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" "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) if err != nil { addErrorf("parsing hostname: %s", err) diff --git a/mox-/lifecycle.go b/mox-/lifecycle.go index 698aa63..e22f7ea 100644 --- a/mox-/lifecycle.go +++ b/mox-/lifecycle.go @@ -2,15 +2,151 @@ package mox import ( "context" + "fmt" "net" + "os" + "os/signal" + "runtime" "runtime/debug" + "strings" "sync" + "syscall" "time" "github.com/prometheus/client_golang/prometheus" "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 // processes should check this before starting a new operation. If true, the // operation should be aborted, and new connections should receive a message that diff --git a/mox-/limitauth.go b/mox-/limitauth.go index 8f95cdb..e2d6618 100644 --- a/mox-/limitauth.go +++ b/mox-/limitauth.go @@ -8,10 +8,6 @@ import ( var LimiterFailedAuth *ratelimit.Limiter -func init() { - LimitersInit() -} - // LimitesrsInit initializes the failed auth rate limiter. func LimitersInit() { LimiterFailedAuth = &ratelimit.Limiter{ diff --git a/mox-/setcaphint.go b/mox-/setcaphint.go deleted file mode 100644 index 259dc1e..0000000 --- a/mox-/setcaphint.go +++ /dev/null @@ -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 "" -} diff --git a/mox.service b/mox.service index a0a1b76..5cd50ba 100644 --- a/mox.service +++ b/mox.service @@ -7,27 +7,22 @@ Wants=network-online.target UMask=007 LimitNOFILE=65535 Type=simple -User=mox -Group=mox -Environment="MOXCONF=/home/service/mox/config/mox.conf" -WorkingDirectory=/home/service/mox -ExecStart=/home/service/mox/mox serve +# Mox starts as root, but drops privileges after binding network addresses. +WorkingDirectory=/home/mox +ExecStart=/home/mox/mox serve RestartSec=5s Restart=always -ExecStop=/home/service/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 +ExecStop=/home/mox/mox stop # Isolate process, reducing attack surface. PrivateDevices=yes PrivateTmp=yes ProtectSystem=strict -ReadWritePaths=/home/service/mox/config /home/service/mox/data +ReadWritePaths=/home/mox/config /home/mox/data ProtectKernelTunables=yes ProtectControlGroups=yes -AmbientCapabilities=CAP_NET_BIND_SERVICE -CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities= +CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_NET_BIND_SERVICE CAP_CHOWN NoNewPrivileges=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK ProtectProc=invisible diff --git a/moxio/umask.go b/moxio/umask.go deleted file mode 100644 index 9fd7edb..0000000 --- a/moxio/umask.go +++ /dev/null @@ -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 -} diff --git a/quickstart.go b/quickstart.go index 0b3b4a3..a961df9 100644 --- a/quickstart.go +++ b/quickstart.go @@ -41,15 +41,18 @@ func pwgen() string { } 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. -Quickstart prints initial admin and account passwords, configuration files, DNS -records you should create, instructions for setting correct user/group and -permissions, and if you run it on Linux it prints a systemd service file. +Quickstart writes configuration files, prints initial admin and account +passwords, DNS records you should create. If you run it on Linux it writes a +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() - if len(args) != 1 { + if len(args) != 1 && len(args) != 2 { c.Usage() } @@ -342,19 +345,27 @@ This likely means one of two things: } cancel() + user := "mox" + if len(args) == 2 { + user = args[1] + } + dc := config.Dynamic{} - sc := config.Static{DataDir: "../data"} + sc := config.Static{ + DataDir: "../data", + User: user, + LogLevel: "info", + Hostname: hostname.Name(), + ACME: map[string]config.ACME{ + "letsencrypt": { + DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory", + ContactEmail: args[0], // todo: let user specify an alternative fallback address? + }, + }, + AdminPasswordFile: "adminpasswd", + } dataDir := "data" // ../data is relative to config/ os.MkdirAll(dataDir, 0770) - sc.LogLevel = "info" - sc.Hostname = hostname.Name() - sc.ACME = map[string]config.ACME{ - "letsencrypt": { - DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory", - ContactEmail: args[0], // todo: let user specify an alternative fallback address? - }, - } - sc.AdminPasswordFile = "adminpasswd" adminpw := pwgen() adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost) 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. 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 { 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 continuing! -You can now start mox with "mox serve", but see below for recommended ownership -and permissions. +You can now start mox with "./mox serve", as root. File ownership and +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. if runtime.GOOS == "linux" && os.Getenv("MOX_DOCKER") == "" { pwd, err := os.Getwd() if err != nil { 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) cleanupPaths = append(cleanupPaths, "mox.service") fmt.Printf(`See mox.service for a systemd service file. To enable and start: diff --git a/serve.go b/serve.go index 53fffa9..959c92e 100644 --- a/serve.go +++ b/serve.go @@ -4,12 +4,13 @@ import ( "context" cryptorand "crypto/rand" "fmt" + "io/fs" "net" "os" "os/signal" "path/filepath" + "runtime" "runtime/debug" - "strconv" "strings" "sync" "syscall" @@ -24,7 +25,6 @@ import ( "github.com/mjl-/mox/metrics" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" - "github.com/mjl-/mox/moxio" "github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/store" "github.com/mjl-/mox/updates" @@ -138,60 +138,75 @@ requested, other TLS certificates are requested on demand. log := mlog.New("serve") 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) - if fds := os.Getenv("MOX_RESTART_CTL_SOCKET"); fds != "" { - log.Print("restarted") - - fd, err := strconv.ParseUint(fds, 10, 32) - if err != nil { - log.Fatalx("restart with invalid ctl socket", err, mlog.Field("fd", fds)) + 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") } - f := os.NewFile(uintptr(fd), "restartctl") - if _, err := fmt.Fprint(f, "ok\n"); err != nil { - log.Infox("writing ok to restart ctl socket", err) - } - err = f.Close() - 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") + if !mox.Conf.Static.NoFixPermissions { + // Fix permissions now that we have privilege to do so. Useful for update of v0.0.1 + // that was running directly as mox-user. + workdir, err := os.Getwd() + if err != nil { + log.Printx("get working dir, continuing without potentially fixing up permissions", err) + } else { + configdir := filepath.Dir(mox.ConfigStaticPath) + datadir := mox.DataDirPath(".") + err := fixperms(log, workdir, configdir, datadir, mox.Conf.Static.UID, mox.Conf.Static.GID) + if err != nil { + log.Fatalx("fixing permissions", err) + } } } - 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 { - log.Errorx("bad umask", err) + syscall.Umask(syscall.Umask(007) | 007) + + // 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 { 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) + 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") _ = os.Remove(ctlpath) ctl, err := net.Listen("unix", ctlpath) @@ -359,4 +377,171 @@ requested, other TLS certificates are requested on demand. sig := <-sigc log.Print("shutting down, waiting max 3s for existing connections", mlog.Field("signal", sig)) 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 } diff --git a/smtpserver/server.go b/smtpserver/server.go index 35568e2..753683d 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -161,8 +161,9 @@ var ( var jitterRand = mox.NewRand() -// ListenAndServe starts network listeners that serve incoming SMTP connection. -func ListenAndServe() { +// Listen initializes network listeners for incoming SMTP connection. +// The listeners are stored for a later call to Serve. +func Listen() { for name, listener := range mox.Conf.Static.Listeners { var tlsConfig *tls.Config if listener.TLS != nil { @@ -181,7 +182,7 @@ func ListenAndServe() { } port := config.Port(listener.SMTP.Port, 25) 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 { @@ -191,7 +192,7 @@ func ListenAndServe() { } port := config.Port(listener.Submission.Port, 587) 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,34 +203,47 @@ func ListenAndServe() { } port := config.Port(listener.Submissions.Port, 465) 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)) - xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", addr), mlog.Field("protocol", protocol)) + if os.Getuid() == 0 { + xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", 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) - } + ln, err := mox.Listen(network, addr) 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)) } - for { - conn, err := ln.Accept() - if err != nil { - xlog.Infox("smtp: accept", err, mlog.Field("protocol", protocol), mlog.Field("listener", name)) - continue + if xtls { + ln = tls.NewListener(ln, tlsConfig) + } + + serve := func() { + for { + conn, err := ln.Accept() + if err != nil { + xlog.Infox("smtp: accept", err, mlog.Field("protocol", protocol), mlog.Field("listener", name)) + continue + } + 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) } - 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) + } + + servers = append(servers, serve) +} + +// Serve starts serving on all listeners, launching a goroutine per listener. +func Serve() { + for _, serve := range servers { + go serve() } } diff --git a/start.go b/start.go index 57b52c0..c36dd8e 100644 --- a/start.go +++ b/start.go @@ -2,11 +2,13 @@ package main import ( "fmt" + "os" "github.com/mjl-/mox/dmarcdb" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/http" "github.com/mjl-/mox/imapserver" + "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mtastsdb" "github.com/mjl-/mox/queue" "github.com/mjl-/mox/smtpserver" @@ -16,7 +18,23 @@ import ( // start initializes all packages, starts all listeners and the switchboard // 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 { return fmt.Errorf("dmarc init: %s", err) } @@ -34,9 +52,11 @@ func start(mtastsdbRefresher bool) error { return fmt.Errorf("queue start: %s", err) } - smtpserver.ListenAndServe() - imapserver.ListenAndServe() - http.ListenAndServe() + store.StartAuthCache() + smtpserver.Serve() + imapserver.Serve() + http.Serve() + go func() { <-store.Switchboard() }() diff --git a/store/account.go b/store/account.go index 9dacbe3..77eb036 100644 --- a/store/account.go +++ b/store/account.go @@ -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. -var authCache struct { +var authCache = struct { sync.Mutex success map[authKey]string +}{ + success: map[authKey]string{}, } type authKey struct { email, hash string } -func init() { - authCache.success = map[authKey]string{} - go func() { - for { - authCache.Lock() - authCache.success = map[authKey]string{} - authCache.Unlock() - time.Sleep(15 * time.Minute) - } - }() +// StartAuthCache starts a goroutine that regularly clears the auth cache. +func StartAuthCache() { + go manageAuthCache() +} + +func manageAuthCache() { + for { + authCache.Lock() + authCache.success = map[authKey]string{} + authCache.Unlock() + time.Sleep(15 * time.Minute) + } } // OpenEmailAuth opens an account given an email address and password. diff --git a/testdata/imaptest/domains.conf b/testdata/imaptest/config/domains.conf similarity index 100% rename from testdata/imaptest/domains.conf rename to testdata/imaptest/config/domains.conf diff --git a/testdata/imaptest/mox.conf b/testdata/imaptest/config/mox.conf similarity index 87% rename from testdata/imaptest/mox.conf rename to testdata/imaptest/config/mox.conf index 1df5357..e3bbc08 100644 --- a/testdata/imaptest/mox.conf +++ b/testdata/imaptest/config/mox.conf @@ -1,6 +1,7 @@ -DataDir: data +DataDir: ../data LogLevel: trace Hostname: mox.example +User: 1000 Listeners: local: IPs: diff --git a/testdata/integration/domains.conf b/testdata/integration/config/domains.conf similarity index 93% rename from testdata/integration/domains.conf rename to testdata/integration/config/domains.conf index 245bfe4..00838c3 100644 --- a/testdata/integration/domains.conf +++ b/testdata/integration/config/domains.conf @@ -14,7 +14,7 @@ Domains: - From - To - Subject - PrivateKeyFile: dkim/mox2dkim0-key.pem + PrivateKeyFile: ../dkim/mox2dkim0-key.pem Sign: - mox2dkim0 # todo: DMARC: @@ -32,7 +32,7 @@ Domains: - From - To - Subject - PrivateKeyFile: dkim/mox3dkim0-key.pem + PrivateKeyFile: ../dkim/mox3dkim0-key.pem Sign: - mox3dkim0 Accounts: diff --git a/testdata/integration/mox.conf b/testdata/integration/config/mox.conf similarity index 78% rename from testdata/integration/mox.conf rename to testdata/integration/config/mox.conf index 3abab19..ec0c4f4 100644 --- a/testdata/integration/mox.conf +++ b/testdata/integration/config/mox.conf @@ -1,10 +1,12 @@ -DataDir: ./run +DataDir: ../data LogLevel: trace Hostname: moxmail1.mox1.example +# only for integration test, where fork & exec is skipped +User: 0 TLS: CA: CertFiles: - - tls/ca.pem + - ../tls/ca.pem Listeners: mox1: IPs: @@ -23,8 +25,8 @@ Listeners: TLS: KeyCerts: - - CertFile: tls/moxmail2.pem - KeyFile: tls/moxmail2-key.pem + CertFile: ../tls/moxmail2.pem + KeyFile: ../tls/moxmail2-key.pem SMTP: Enabled: true Submission: @@ -53,8 +55,8 @@ Listeners: TLS: KeyCerts: - - CertFile: tls/moxmail3.pem - KeyFile: tls/moxmail3-key.pem + CertFile: ../tls/moxmail3.pem + KeyFile: ../tls/moxmail3-key.pem SMTP: Enabled: true Submission: