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/imap/data/
/testdata/imaptest/data/
/testdata/integration/run
/testdata/integration/data/
/testdata/junk/*.bloom
/testdata/junk/*.db
/testdata/queue/data/

View file

@ -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.

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
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

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.
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.

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."`
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.<domain>"`
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.

View file

@ -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.<domain>
Hostname:

54
ctl.go
View file

@ -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.

38
doc.go
View file

@ -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.

View file

@ -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

View file

@ -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

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'
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

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)
Account{}.DestinationSave(authCtx, "mjl", dests["mjl"], dests["mjl"]) // todo: save modified value and compare it afterwards
go importManage()
// Import mbox/maildir tgz/zip.
testImport := func(filename string, expect int) {
t.Helper()

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import (
golog "log"
"net"
"net/http"
"os"
"strings"
"time"
@ -36,9 +37,9 @@ func safeHeaders(fn http.HandlerFunc) http.HandlerFunc {
}
}
// ListenAndServe starts listeners for HTTP, including those required for ACME to
// generate TLS certificates.
func ListenAndServe() {
// Listen binds to sockets for HTTP listeners, including those required for ACME to
// generate TLS certificates. It stores the listeners so Serve can start serving them.
func Listen() {
type serve struct {
kinds []string
tlsConfig *tls.Config
@ -179,7 +180,7 @@ func ListenAndServe() {
for port, srv := range portServe {
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)
}
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))
var protocol string
@ -206,18 +211,23 @@ func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kin
var err error
if tlsConfig == nil {
protocol = "http"
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)
if os.Getuid() == 0 {
xlog.Print("http listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
}
ln, err = mox.Listen(mox.Network(ip), addr)
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 {
protocol = "https"
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))
if os.Getuid() == 0 {
xlog.Print("https listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", 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{
@ -225,8 +235,20 @@ func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kin
TLSConfig: tlsConfig,
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)
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
// ListenAndServe starts all imap listeners for the configuration, in new goroutines.
func ListenAndServe() {
// Listen initializes all imap listeners for the configuration, and stores them for Serve to start them.
func Listen() {
for name, listener := range mox.Conf.Static.Listeners {
var tlsConfig *tls.Config
if listener.TLS != nil {
@ -311,44 +311,57 @@ func ListenAndServe() {
if listener.IMAP.Enabled {
port := config.Port(listener.IMAP.Port, 143)
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 {
port := config.Port(listener.IMAPS.Port, 993)
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))
xlog.Print("listening for imap", mlog.Field("listener", listenerName), mlog.Field("addr", addr), mlog.Field("protocol", protocol))
if os.Getuid() == 0 {
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)
}
ln, err := mox.Listen(network, addr)
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)
}
for {
conn, err := ln.Accept()
if err != nil {
xlog.Infox("imap: accept", err, mlog.Field("protocol", protocol), mlog.Field("listener", listenerName))
continue
serve := func() {
for {
conn, err := ln.Accept()
if err != nil {
xlog.Infox("imap: accept", err, mlog.Field("protocol", protocol), mlog.Field("listener", listenerName))
continue
}
metricIMAPConnection.WithLabelValues(protocol).Inc()
go serve(listenerName, mox.Cid(), tlsConfig, conn, xtls, noRequireSTARTTLS)
}
metricIMAPConnection.WithLabelValues(protocol).Inc()
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.

View file

@ -43,11 +43,15 @@ func TestDeliver(t *testing.T) {
mox.Shutdown, mox.ShutdownCancel = context.WithCancel(context.Background())
// Remove state.
os.RemoveAll("testdata/integration/run")
os.MkdirAll("testdata/integration/run", 0750)
os.RemoveAll("testdata/integration/data")
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.
mox.ConfigStaticPath = "testdata/integration/mox.conf"
mox.ConfigStaticPath = "testdata/integration/config/mox.conf"
filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
if errs := mox.LoadConfig(mox.Context); len(errs) > 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)")

59
main.go
View file

@ -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.

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
// 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)
}

View file

@ -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)

View file

@ -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

View file

@ -8,10 +8,6 @@ import (
var LimiterFailedAuth *ratelimit.Limiter
func init() {
LimitersInit()
}
// LimitesrsInit initializes the failed auth rate limiter.
func LimitersInit() {
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
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

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) {
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:

339
serve.go
View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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()
}()

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.
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.

View file

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

View file

@ -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:

View file

@ -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: