mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
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:
parent
eda907fc86
commit
92e018e463
37 changed files with 841 additions and 435 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -8,7 +8,7 @@
|
||||||
/testdata/httpaccount/data/
|
/testdata/httpaccount/data/
|
||||||
/testdata/imap/data/
|
/testdata/imap/data/
|
||||||
/testdata/imaptest/data/
|
/testdata/imaptest/data/
|
||||||
/testdata/integration/run
|
/testdata/integration/data/
|
||||||
/testdata/junk/*.bloom
|
/testdata/junk/*.bloom
|
||||||
/testdata/junk/*.db
|
/testdata/junk/*.db
|
||||||
/testdata/queue/data/
|
/testdata/queue/data/
|
||||||
|
|
|
@ -8,11 +8,6 @@ FROM alpine:latest
|
||||||
WORKDIR /mox
|
WORKDIR /mox
|
||||||
COPY --from=build /build/mox /bin/mox
|
COPY --from=build /build/mox /bin/mox
|
||||||
|
|
||||||
RUN apk add --no-cache libcap-utils
|
|
||||||
|
|
||||||
# Allow binding to privileged ports, <1024.
|
|
||||||
RUN setcap 'cap_net_bind_service=+ep' /bin/mox
|
|
||||||
|
|
||||||
# SMTP for incoming message delivery.
|
# SMTP for incoming message delivery.
|
||||||
EXPOSE 25/tcp
|
EXPOSE 25/tcp
|
||||||
# SMTP/submission with TLS.
|
# SMTP/submission with TLS.
|
||||||
|
|
29
Dockerfile.release
Normal file
29
Dockerfile.release
Normal 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"]
|
16
Makefile
16
Makefile
|
@ -44,21 +44,22 @@ integration-build:
|
||||||
docker-compose -f docker-compose-integration.yml build --no-cache moxmail
|
docker-compose -f docker-compose-integration.yml build --no-cache moxmail
|
||||||
|
|
||||||
integration-start:
|
integration-start:
|
||||||
-MOX_UID=$$(id -u) MOX_GID=$$(id -g) docker-compose -f docker-compose-integration.yml run moxmail /bin/bash
|
-rm -r testdata/integration/data
|
||||||
MOX_UID= MOX_GID= docker-compose -f docker-compose-integration.yml down
|
-docker-compose -f docker-compose-integration.yml run moxmail /bin/bash
|
||||||
|
docker-compose -f docker-compose-integration.yml down
|
||||||
|
|
||||||
# run from within "make integration-start"
|
# run from within "make integration-start"
|
||||||
integration-test:
|
integration-test:
|
||||||
CGO_ENABLED=0 go test -tags integration
|
CGO_ENABLED=0 go test -tags integration
|
||||||
|
|
||||||
imaptest-build:
|
imaptest-build:
|
||||||
-MOX_UID=$$(id -u) MOX_GID=$$(id -g) docker-compose -f docker-compose-imaptest.yml build --no-cache mox
|
-docker-compose -f docker-compose-imaptest.yml build --no-cache mox
|
||||||
|
|
||||||
imaptest-run:
|
imaptest-run:
|
||||||
-rm -r testdata/imaptest/data
|
-rm -r testdata/imaptest/data
|
||||||
mkdir testdata/imaptest/data
|
mkdir testdata/imaptest/data
|
||||||
MOX_UID=$$(id -u) MOX_GID=$$(id -g) docker-compose -f docker-compose-imaptest.yml run --entrypoint /usr/local/bin/imaptest imaptest host=mox port=1143 user=mjl@mox.example pass=testtest mbox=imaptest.mbox
|
docker-compose -f docker-compose-imaptest.yml run --entrypoint /usr/local/bin/imaptest imaptest host=mox port=1143 user=mjl@mox.example pass=testtest mbox=imaptest.mbox
|
||||||
MOX_UID= MOX_GID= docker-compose -f docker-compose-imaptest.yml down
|
docker-compose -f docker-compose-imaptest.yml down
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
|
@ -72,4 +73,7 @@ jsinstall:
|
||||||
npm install jshint@2.13.2
|
npm install jshint@2.13.2
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t mox:latest .
|
docker build -t mox:dev .
|
||||||
|
|
||||||
|
docker-release:
|
||||||
|
./docker-release.sh
|
||||||
|
|
20
README.md
20
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.
|
Note: Mox only compiles/works on unix systems, not on Plan 9 or Windows.
|
||||||
|
|
||||||
You can also run mox with docker image "moxmail/mox" on hub.docker.com, with
|
You can also run mox with docker image "docker.io/moxmail/mox", with tags like
|
||||||
tags like "latest", "0.0.1", etc. See docker-compose.yml in this repository.
|
"latest", "0.0.1" and "0.0.1-go1.20.1-alpine3.17.2", etc. See docker-compose.yml
|
||||||
|
in this repository for instructions on starting.
|
||||||
|
|
||||||
|
|
||||||
# Quickstart
|
# Quickstart
|
||||||
|
|
||||||
The easiest way to get started with serving email for your domain is to get a
|
The easiest way to get started with serving email for your domain is to get a
|
||||||
vm/machine dedicated to serving email, name it [host].[domain], login as an
|
vm/machine dedicated to serving email, name it [host].[domain] (e.g.
|
||||||
admin user, e.g. /home/service, download mox, and generate a configuration for
|
mail.example.com), login as root, create user "mox" and its homedir by running
|
||||||
your desired email address at your domain:
|
"useradd -d /home/mox mox && mkdir /home/mox", download mox to that directory,
|
||||||
|
and generate a configuration for your desired email address at your domain:
|
||||||
|
|
||||||
./mox quickstart you@example.com
|
./mox quickstart you@example.com
|
||||||
|
|
||||||
This creates an account, generates a password and configuration files, prints
|
This creates an account, generates a password and configuration files, prints
|
||||||
the DNS records you need to manually create and prints commands to set
|
the DNS records you need to manually create and prints commands to start mox and
|
||||||
permissions and install mox as a service.
|
optionally install mox as a service.
|
||||||
|
|
||||||
If you already have email configured for your domain, or if you are already
|
If you already have email configured for your domain, or if you are already
|
||||||
sending email for your domain from other machines/services, you should modify
|
sending email for your domain from other machines/services, you should modify
|
||||||
the suggested configuration and/or DNS records.
|
the suggested configuration and/or DNS records.
|
||||||
|
|
||||||
A dedicated machine is highly recommended because modern email requires HTTPS,
|
A dedicated machine is highly recommended because modern email requires HTTPS,
|
||||||
also for automatic TLS. You can combine mox with an existing webserver, but it
|
and mox currently needs it for automatic TLS. You can combine mox with an
|
||||||
requires more configuration.
|
existing webserver, but it requires more configuration.
|
||||||
|
|
||||||
After starting, you can access the admin web interface on internal IPs.
|
After starting, you can access the admin web interface on internal IPs.
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,8 @@ type Static struct {
|
||||||
DataDir string `sconf-doc:"Directory where all data is stored, e.g. queue, accounts and messages, ACME TLS certs/keys. If this is a relative path, it is relative to the directory of mox.conf."`
|
DataDir string `sconf-doc:"Directory where all data is stored, e.g. queue, accounts and messages, ACME TLS certs/keys. If this is a relative path, it is relative to the directory of mox.conf."`
|
||||||
LogLevel string `sconf-doc:"Default log level, one of: error, info, debug, trace, traceauth, tracedata. Trace logs SMTP and IMAP protocol transcripts, with traceauth also messages with passwords, and tracedata on top of that also the full data exchanges (full messages), which can be a large amount of data."`
|
LogLevel string `sconf-doc:"Default log level, one of: error, info, debug, trace, traceauth, tracedata. Trace logs SMTP and IMAP protocol transcripts, with traceauth also messages with passwords, and tracedata on top of that also the full data exchanges (full messages), which can be a large amount of data."`
|
||||||
PackageLogLevels map[string]string `sconf:"optional" sconf-doc:"Overrides of log level per package (e.g. queue, smtpclient, smtpserver, imapserver, spf, dkim, dmarc, dmarcdb, autotls, junk, mtasts, tlsrpt)."`
|
PackageLogLevels map[string]string `sconf:"optional" sconf-doc:"Overrides of log level per package (e.g. queue, smtpclient, smtpserver, imapserver, spf, dkim, dmarc, dmarcdb, autotls, junk, mtasts, tlsrpt)."`
|
||||||
|
User string `sconf:"optional" sconf-doc:"User to switch to after binding to all sockets as root. Default: mox. If the value is not a known user, it is parsed as integer and used as uid and gid."`
|
||||||
|
NoFixPermissions bool `sconf:"optional" sconf-doc:"If true, do not automatically fix file permissions when starting up. By default, mox will ensure reasonable owner/permissions on the working, data and config directories (and files), and mox binary (if present)."`
|
||||||
Hostname string `sconf-doc:"Full hostname of system, e.g. mail.<domain>"`
|
Hostname string `sconf-doc:"Full hostname of system, e.g. mail.<domain>"`
|
||||||
HostnameDomain dns.Domain `sconf:"-" json:"-"` // Parsed form of hostname.
|
HostnameDomain dns.Domain `sconf:"-" json:"-"` // Parsed form of hostname.
|
||||||
CheckUpdates bool `sconf:"optional" sconf-doc:"If enabled, a single DNS TXT lookup of _updates.xmox.nl is done every 24h to check for a new release. Each time a new release is found, a changelog is fetched from https://updates.xmox.nl and delivered to the postmaster mailbox."`
|
CheckUpdates bool `sconf:"optional" sconf-doc:"If enabled, a single DNS TXT lookup of _updates.xmox.nl is done every 24h to check for a new release. Each time a new release is found, a changelog is fetched from https://updates.xmox.nl and delivered to the postmaster mailbox."`
|
||||||
|
@ -59,6 +61,10 @@ type Static struct {
|
||||||
// family, outgoing connections with the other address family are still made if
|
// family, outgoing connections with the other address family are still made if
|
||||||
// possible.
|
// possible.
|
||||||
SpecifiedSMTPListenIPs []net.IP `sconf:"-" json:"-"`
|
SpecifiedSMTPListenIPs []net.IP `sconf:"-" json:"-"`
|
||||||
|
|
||||||
|
// To switch to after initialization as root.
|
||||||
|
UID uint32 `sconf:"-" json:"-"`
|
||||||
|
GID uint32 `sconf:"-" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed.
|
// Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed.
|
||||||
|
|
|
@ -25,6 +25,16 @@ describe-static" and "mox config describe-domains":
|
||||||
PackageLogLevels:
|
PackageLogLevels:
|
||||||
x:
|
x:
|
||||||
|
|
||||||
|
# User to switch to after binding to all sockets as root. Default: mox. If the
|
||||||
|
# value is not a known user, it is parsed as integer and used as uid and gid.
|
||||||
|
# (optional)
|
||||||
|
User:
|
||||||
|
|
||||||
|
# If true, do not automatically fix file permissions when starting up. By default,
|
||||||
|
# mox will ensure reasonable owner/permissions on the working, data and config
|
||||||
|
# directories (and files), and mox binary (if present). (optional)
|
||||||
|
NoFixPermissions: false
|
||||||
|
|
||||||
# Full hostname of system, e.g. mail.<domain>
|
# Full hostname of system, e.g. mail.<domain>
|
||||||
Hostname:
|
Hostname:
|
||||||
|
|
||||||
|
|
54
ctl.go
54
ctl.go
|
@ -9,12 +9,10 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mjl-/bstore"
|
"github.com/mjl-/bstore"
|
||||||
|
@ -301,58 +299,6 @@ func servectlcmd(ctx context.Context, log *mlog.Log, ctl *ctl, xcmd *string, shu
|
||||||
shutdown()
|
shutdown()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|
||||||
case "restart":
|
|
||||||
// First test the config.
|
|
||||||
_, errs := mox.ParseConfig(ctx, mox.ConfigStaticPath, true)
|
|
||||||
if len(errs) > 1 {
|
|
||||||
log.Error("multiple configuration errors before restart")
|
|
||||||
for _, err := range errs {
|
|
||||||
log.Errorx("config error", err)
|
|
||||||
}
|
|
||||||
ctl.xerror("restart aborted")
|
|
||||||
} else if len(errs) == 1 {
|
|
||||||
log.Errorx("configuration error, restart aborted", errs[0])
|
|
||||||
ctl.xerror(errs[0].Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
binary, err := os.Executable()
|
|
||||||
ctl.xcheck(err, "finding executable")
|
|
||||||
cfr, ok := ctl.conn.(interface{ File() (*os.File, error) })
|
|
||||||
if !ok {
|
|
||||||
ctl.xerror("cannot dup ctl socket")
|
|
||||||
}
|
|
||||||
cf, err := cfr.File()
|
|
||||||
ctl.xcheck(err, "dup ctl socket")
|
|
||||||
defer cf.Close()
|
|
||||||
_, _, err = syscall.Syscall(syscall.SYS_FCNTL, cf.Fd(), syscall.F_SETFD, 0)
|
|
||||||
if err != syscall.Errno(0) {
|
|
||||||
ctl.xcheck(err, "clear close-on-exec on ctl socket")
|
|
||||||
}
|
|
||||||
ctl.xwriteok()
|
|
||||||
|
|
||||||
shutdown()
|
|
||||||
|
|
||||||
// todo future: we could gather all listen fd's, keep them open, passing them to the new process and indicate (in env var or cli flag) for which addresses they are, then exec and have the new process pick them up. not worth the trouble at the moment, our shutdown is typically quick enough.
|
|
||||||
// todo future: does this actually cleanup all M's on all platforms?
|
|
||||||
|
|
||||||
env := os.Environ()
|
|
||||||
var found bool
|
|
||||||
envv := fmt.Sprintf("MOX_RESTART_CTL_SOCKET=%d", cf.Fd())
|
|
||||||
for i, s := range env {
|
|
||||||
if strings.HasPrefix(s, "MOX_RESTART_CTL_SOCKET=") {
|
|
||||||
found = true
|
|
||||||
env[i] = envv
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
env = append(env, envv)
|
|
||||||
}
|
|
||||||
// On success, we never get here and "serve" will write the OK on the MOX_RESTART_CTL_SOCKET and close it.
|
|
||||||
err = syscall.Exec(binary, os.Args, env)
|
|
||||||
runtime.KeepAlive(cf)
|
|
||||||
ctl.xcheck(err, "exec")
|
|
||||||
|
|
||||||
case "deliver":
|
case "deliver":
|
||||||
/* The protocol, double quoted are literals.
|
/* The protocol, double quoted are literals.
|
||||||
|
|
||||||
|
|
38
doc.go
38
doc.go
|
@ -14,8 +14,7 @@ low-maintenance self-hosted email.
|
||||||
|
|
||||||
mox [-config config/mox.conf] ...
|
mox [-config config/mox.conf] ...
|
||||||
mox serve
|
mox serve
|
||||||
mox quickstart user@domain
|
mox quickstart user@domain [user | uid]
|
||||||
mox restart
|
|
||||||
mox stop
|
mox stop
|
||||||
mox setaccountpassword address
|
mox setaccountpassword address
|
||||||
mox setadminpassword
|
mox setadminpassword
|
||||||
|
@ -41,6 +40,7 @@ low-maintenance self-hosted email.
|
||||||
mox config domain add domain account [localpart]
|
mox config domain add domain account [localpart]
|
||||||
mox config domain rm domain
|
mox config domain rm domain
|
||||||
mox config describe-sendmail >/etc/moxsubmit.conf
|
mox config describe-sendmail >/etc/moxsubmit.conf
|
||||||
|
mox config printservice >mox.service
|
||||||
mox checkupdate
|
mox checkupdate
|
||||||
mox cid cid
|
mox cid cid
|
||||||
mox clientconfig domain
|
mox clientconfig domain
|
||||||
|
@ -83,26 +83,14 @@ requested, other TLS certificates are requested on demand.
|
||||||
|
|
||||||
Quickstart generates configuration files and prints instructions to quickly set up a mox instance.
|
Quickstart generates configuration files and prints instructions to quickly set up a mox instance.
|
||||||
|
|
||||||
Quickstart prints initial admin and account passwords, configuration files, DNS
|
Quickstart writes configuration files, prints initial admin and account
|
||||||
records you should create, instructions for setting correct user/group and
|
passwords, DNS records you should create. If you run it on Linux it writes a
|
||||||
permissions, and if you run it on Linux it prints a systemd service file.
|
systemd service file and prints commands to enable and start mox as service.
|
||||||
|
|
||||||
usage: mox quickstart user@domain
|
The user or uid is optional, defaults to "mox", and is the user or uid/gid mox
|
||||||
|
will run as after initialization.
|
||||||
|
|
||||||
# mox restart
|
usage: mox quickstart user@domain [user | uid]
|
||||||
|
|
||||||
Restart mox after validating the configuration file.
|
|
||||||
|
|
||||||
Restart execs the mox binary, which have been updated. Restart returns after
|
|
||||||
the restart has finished. If you update the mox binary, keep in mind that the
|
|
||||||
validation of the configuration file is done by the old process with the old
|
|
||||||
binary. The new binary may report a syntax error. If you update the binary, you
|
|
||||||
should use the "config test" command with the new binary to validate the
|
|
||||||
configuration file.
|
|
||||||
|
|
||||||
Like stop, existing connections get a 3 second period for graceful shutdown.
|
|
||||||
|
|
||||||
usage: mox restart
|
|
||||||
|
|
||||||
# mox stop
|
# mox stop
|
||||||
|
|
||||||
|
@ -390,6 +378,16 @@ Describe configuration for mox when invoked as sendmail.
|
||||||
|
|
||||||
usage: mox config describe-sendmail >/etc/moxsubmit.conf
|
usage: mox config describe-sendmail >/etc/moxsubmit.conf
|
||||||
|
|
||||||
|
# mox config printservice
|
||||||
|
|
||||||
|
Prints a systemd unit service file for mox.
|
||||||
|
|
||||||
|
This is the same file as generated using quickstart. If the systemd service file
|
||||||
|
has changed with a newer version of mox, use this command to generate an up to
|
||||||
|
date version.
|
||||||
|
|
||||||
|
usage: mox config printservice >mox.service
|
||||||
|
|
||||||
# mox checkupdate
|
# mox checkupdate
|
||||||
|
|
||||||
Check if a newer version of mox is available.
|
Check if a newer version of mox is available.
|
||||||
|
|
|
@ -4,15 +4,13 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.moximaptest
|
dockerfile: Dockerfile.moximaptest
|
||||||
user: ${MOX_UID}:${MOX_GID}
|
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./testdata/imaptest/config:/mox/config
|
||||||
- ./testdata/imaptest/data:/mox/data
|
- ./testdata/imaptest/data:/mox/data
|
||||||
- ./testdata/imaptest/mox.conf:/mox/mox.conf
|
|
||||||
- ./testdata/imaptest/domains.conf:/mox/domains.conf
|
|
||||||
- ./testdata/imaptest/imaptest.mbox:/mox/imaptest.mbox
|
- ./testdata/imaptest/imaptest.mbox:/mox/imaptest.mbox
|
||||||
working_dir: /mox
|
working_dir: /mox
|
||||||
tty: true # For job control
|
tty: true # For job control with set -m.
|
||||||
command: sh -c 'export MOXCONF=mox.conf; set -m; mox serve & sleep 1; echo testtest | mox setaccountpassword mjl@mox.example; fg'
|
command: sh -c 'set -m; mox serve & sleep 1; echo testtest | mox setaccountpassword mjl@mox.example; fg'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: netstat -nlt | grep ':1143 '
|
test: netstat -nlt | grep ':1143 '
|
||||||
interval: 1s
|
interval: 1s
|
||||||
|
@ -27,7 +25,6 @@ services:
|
||||||
working_dir: /imaptest
|
working_dir: /imaptest
|
||||||
volumes:
|
volumes:
|
||||||
- ./testdata/imaptest:/imaptest
|
- ./testdata/imaptest:/imaptest
|
||||||
user: ${MOX_UID}:${MOX_GID}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mox:
|
mox:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
@ -7,14 +7,12 @@ services:
|
||||||
build:
|
build:
|
||||||
dockerfile: Dockerfile.moxmail
|
dockerfile: Dockerfile.moxmail
|
||||||
context: testdata/integration
|
context: testdata/integration
|
||||||
user: ${MOX_UID}:${MOX_GID}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./.go:/.go
|
- ./.go:/.go
|
||||||
- ./testdata/integration/resolv.conf:/etc/resolv.conf
|
- ./testdata/integration/resolv.conf:/etc/resolv.conf
|
||||||
- .:/mox
|
- .:/mox
|
||||||
environment:
|
environment:
|
||||||
GOCACHE: /.go/.cache/go-build
|
GOCACHE: /.go/.cache/go-build
|
||||||
command: ["make", "test-postfix"]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: netstat -nlt | grep ':25 '
|
test: netstat -nlt | grep ':25 '
|
||||||
interval: 1s
|
interval: 1s
|
||||||
|
|
|
@ -1,23 +1,22 @@
|
||||||
# Before launching mox, run the quickstart to create config files:
|
# Before launching mox, run the quickstart to create config files for running as
|
||||||
|
# user the mox user (create it on the host system first, e.g. "useradd -d $PWD mox"):
|
||||||
#
|
#
|
||||||
# MOX_UID=0 MOX_GID=0 docker-compose run mox mox quickstart you@yourdomain.example
|
# docker-compose run mox mox quickstart you@yourdomain.example $(id -u mox)
|
||||||
#
|
#
|
||||||
# After following the instructions, start mox as the newly created mox user:
|
# After following the quickstart instructions you can start mox:
|
||||||
#
|
#
|
||||||
# MOX_UID=$(id -u mox) MOX_GID=$(id -g mox) docker-compose up
|
# docker-compose up
|
||||||
|
|
||||||
version: '3.7'
|
version: '3.7'
|
||||||
services:
|
services:
|
||||||
mox:
|
mox:
|
||||||
# Replace latest with the version you want to run.
|
# Replace latest with the version you want to run.
|
||||||
image: moxmail/mox:latest
|
image: moxmail/mox:latest
|
||||||
user: ${MOX_UID}:${MOX_GID}
|
|
||||||
environment:
|
environment:
|
||||||
- MOX_DOCKER=... # Quickstart won't try to write systemd service file.
|
- MOX_DOCKER=... # Quickstart won't try to write systemd service file.
|
||||||
# Mox needs host networking because it needs access to the IPs of the
|
# Mox needs host networking because it needs access to the IPs of the
|
||||||
# machine, and the IPs of incoming connections for spam filtering.
|
# machine, and the IPs of incoming connections for spam filtering.
|
||||||
network_mode: 'host'
|
network_mode: 'host'
|
||||||
command: sh -c "umask 007 && exec mox serve"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/mox/config
|
- ./config:/mox/config
|
||||||
- ./data:/mox/data
|
- ./data:/mox/data
|
||||||
|
|
49
docker-release.sh
Executable file
49
docker-release.sh
Executable 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
|
|
@ -69,6 +69,8 @@ func TestAccount(t *testing.T) {
|
||||||
_, dests := Account{}.Destinations(authCtx)
|
_, dests := Account{}.Destinations(authCtx)
|
||||||
Account{}.DestinationSave(authCtx, "mjl", dests["mjl"], dests["mjl"]) // todo: save modified value and compare it afterwards
|
Account{}.DestinationSave(authCtx, "mjl", dests["mjl"], dests["mjl"]) // todo: save modified value and compare it afterwards
|
||||||
|
|
||||||
|
go importManage()
|
||||||
|
|
||||||
// Import mbox/maildir tgz/zip.
|
// Import mbox/maildir tgz/zip.
|
||||||
testImport := func(filename string, expect int) {
|
testImport := func(filename string, expect int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
|
@ -94,8 +94,9 @@ var authCache struct {
|
||||||
lastSuccessHash, lastSuccessAuth string
|
lastSuccessHash, lastSuccessAuth string
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
// started when we start serving. not at package init time, because we don't want
|
||||||
go func() {
|
// to make goroutines that early.
|
||||||
|
func manageAuthCache() {
|
||||||
for {
|
for {
|
||||||
authCache.Lock()
|
authCache.Lock()
|
||||||
authCache.lastSuccessHash = ""
|
authCache.lastSuccessHash = ""
|
||||||
|
@ -103,7 +104,6 @@ func init() {
|
||||||
authCache.Unlock()
|
authCache.Unlock()
|
||||||
time.Sleep(15 * time.Minute)
|
time.Sleep(15 * time.Minute)
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check whether authentication from the config (passwordfile with bcrypt hash)
|
// check whether authentication from the config (passwordfile with bcrypt hash)
|
||||||
|
|
|
@ -16,6 +16,10 @@ import (
|
||||||
"github.com/mjl-/mox/mox-"
|
"github.com/mjl-/mox/mox-"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
mox.LimitersInit()
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminAuth(t *testing.T) {
|
func TestAdminAuth(t *testing.T) {
|
||||||
test := func(passwordfile, authHdr string, expect bool) {
|
test := func(passwordfile, authHdr string, expect bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
|
@ -59,10 +59,7 @@ var importers = struct {
|
||||||
make(chan importAbortRequest),
|
make(chan importAbortRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
// manage imports, run in a goroutine before serving.
|
||||||
go importManage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func importManage() {
|
func importManage() {
|
||||||
log := mlog.New("httpimport")
|
log := mlog.New("httpimport")
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
46
http/web.go
46
http/web.go
|
@ -9,6 +9,7 @@ import (
|
||||||
golog "log"
|
golog "log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -36,9 +37,9 @@ func safeHeaders(fn http.HandlerFunc) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenAndServe starts listeners for HTTP, including those required for ACME to
|
// Listen binds to sockets for HTTP listeners, including those required for ACME to
|
||||||
// generate TLS certificates.
|
// generate TLS certificates. It stores the listeners so Serve can start serving them.
|
||||||
func ListenAndServe() {
|
func Listen() {
|
||||||
type serve struct {
|
type serve struct {
|
||||||
kinds []string
|
kinds []string
|
||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
|
@ -179,7 +180,7 @@ func ListenAndServe() {
|
||||||
|
|
||||||
for port, srv := range portServe {
|
for port, srv := range portServe {
|
||||||
for _, ip := range l.IPs {
|
for _, ip := range l.IPs {
|
||||||
listenAndServe(ip, port, srv.tlsConfig, name, srv.kinds, srv.mux)
|
listen1(ip, port, srv.tlsConfig, name, srv.kinds, srv.mux)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -198,7 +199,11 @@ func adminIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) {
|
// functions to be launched in goroutine that will serve on a listener.
|
||||||
|
var servers []func()
|
||||||
|
|
||||||
|
// listen prepares a listener, and adds it to "servers", to be launched (if not running as root) through Serve.
|
||||||
|
func listen1(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) {
|
||||||
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
||||||
|
|
||||||
var protocol string
|
var protocol string
|
||||||
|
@ -206,18 +211,23 @@ func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kin
|
||||||
var err error
|
var err error
|
||||||
if tlsConfig == nil {
|
if tlsConfig == nil {
|
||||||
protocol = "http"
|
protocol = "http"
|
||||||
|
if os.Getuid() == 0 {
|
||||||
xlog.Print("http listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
|
xlog.Print("http listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
|
||||||
ln, err = net.Listen(mox.Network(ip), addr)
|
}
|
||||||
|
ln, err = mox.Listen(mox.Network(ip), addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Fatalx("http: listen"+mox.LinuxSetcapHint(err), err, mlog.Field("addr", addr))
|
xlog.Fatalx("http: listen", err, mlog.Field("addr", addr))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
protocol = "https"
|
protocol = "https"
|
||||||
|
if os.Getuid() == 0 {
|
||||||
xlog.Print("https listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
|
xlog.Print("https listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
|
||||||
ln, err = tls.Listen(mox.Network(ip), addr, tlsConfig)
|
|
||||||
if err != nil {
|
|
||||||
xlog.Fatalx("https: listen"+mox.LinuxSetcapHint(err), err, mlog.Field("addr", addr))
|
|
||||||
}
|
}
|
||||||
|
ln, err = mox.Listen(mox.Network(ip), addr)
|
||||||
|
if err != nil {
|
||||||
|
xlog.Fatalx("https: listen", err, mlog.Field("addr", addr))
|
||||||
|
}
|
||||||
|
ln = tls.NewListener(ln, tlsConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
@ -225,8 +235,20 @@ func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kin
|
||||||
TLSConfig: tlsConfig,
|
TLSConfig: tlsConfig,
|
||||||
ErrorLog: golog.New(mlog.ErrWriter(xlog.Fields(mlog.Field("pkg", "net/http")), mlog.LevelInfo, protocol+" error"), "", 0),
|
ErrorLog: golog.New(mlog.ErrWriter(xlog.Fields(mlog.Field("pkg", "net/http")), mlog.LevelInfo, protocol+" error"), "", 0),
|
||||||
}
|
}
|
||||||
go func() {
|
serve := func() {
|
||||||
err := server.Serve(ln)
|
err := server.Serve(ln)
|
||||||
xlog.Fatalx(protocol+": serve", err)
|
xlog.Fatalx(protocol+": serve", err)
|
||||||
}()
|
}
|
||||||
|
servers = append(servers, serve)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts serving on the initialized listeners.
|
||||||
|
func Serve() {
|
||||||
|
go manageAuthCache()
|
||||||
|
go importManage()
|
||||||
|
|
||||||
|
for _, serve := range servers {
|
||||||
|
go serve()
|
||||||
|
}
|
||||||
|
servers = nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -300,8 +300,8 @@ func (c *conn) xsanity(err error, format string, args ...any) {
|
||||||
|
|
||||||
type msgseq uint32
|
type msgseq uint32
|
||||||
|
|
||||||
// ListenAndServe starts all imap listeners for the configuration, in new goroutines.
|
// Listen initializes all imap listeners for the configuration, and stores them for Serve to start them.
|
||||||
func ListenAndServe() {
|
func Listen() {
|
||||||
for name, listener := range mox.Conf.Static.Listeners {
|
for name, listener := range mox.Conf.Static.Listeners {
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
if listener.TLS != nil {
|
if listener.TLS != nil {
|
||||||
|
@ -311,34 +311,36 @@ func ListenAndServe() {
|
||||||
if listener.IMAP.Enabled {
|
if listener.IMAP.Enabled {
|
||||||
port := config.Port(listener.IMAP.Port, 143)
|
port := config.Port(listener.IMAP.Port, 143)
|
||||||
for _, ip := range listener.IPs {
|
for _, ip := range listener.IPs {
|
||||||
go listenServe("imap", name, ip, port, tlsConfig, false, listener.IMAP.NoRequireSTARTTLS)
|
listen1("imap", name, ip, port, tlsConfig, false, listener.IMAP.NoRequireSTARTTLS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if listener.IMAPS.Enabled {
|
if listener.IMAPS.Enabled {
|
||||||
port := config.Port(listener.IMAPS.Port, 993)
|
port := config.Port(listener.IMAPS.Port, 993)
|
||||||
for _, ip := range listener.IPs {
|
for _, ip := range listener.IPs {
|
||||||
go listenServe("imaps", name, ip, port, tlsConfig, true, false)
|
listen1("imaps", name, ip, port, tlsConfig, true, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listenServe(protocol, listenerName, ip string, port int, tlsConfig *tls.Config, xtls, noRequireSTARTTLS bool) {
|
var servers []func()
|
||||||
|
|
||||||
|
func listen1(protocol, listenerName, ip string, port int, tlsConfig *tls.Config, xtls, noRequireSTARTTLS bool) {
|
||||||
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
||||||
|
if os.Getuid() == 0 {
|
||||||
xlog.Print("listening for imap", mlog.Field("listener", listenerName), mlog.Field("addr", addr), mlog.Field("protocol", protocol))
|
xlog.Print("listening for imap", mlog.Field("listener", listenerName), mlog.Field("addr", addr), mlog.Field("protocol", protocol))
|
||||||
network := mox.Network(ip)
|
|
||||||
var ln net.Listener
|
|
||||||
var err error
|
|
||||||
if xtls {
|
|
||||||
ln, err = tls.Listen(network, addr, tlsConfig)
|
|
||||||
} else {
|
|
||||||
ln, err = net.Listen(network, addr)
|
|
||||||
}
|
}
|
||||||
|
network := mox.Network(ip)
|
||||||
|
ln, err := mox.Listen(network, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Fatalx("imap: listen for imap"+mox.LinuxSetcapHint(err), err, mlog.Field("protocol", protocol), mlog.Field("listener", listenerName))
|
xlog.Fatalx("imap: listen for imap", err, mlog.Field("protocol", protocol), mlog.Field("listener", listenerName))
|
||||||
|
}
|
||||||
|
if xtls {
|
||||||
|
ln = tls.NewListener(ln, tlsConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serve := func() {
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -349,6 +351,17 @@ func listenServe(protocol, listenerName, ip string, port int, tlsConfig *tls.Con
|
||||||
metricIMAPConnection.WithLabelValues(protocol).Inc()
|
metricIMAPConnection.WithLabelValues(protocol).Inc()
|
||||||
go serve(listenerName, mox.Cid(), tlsConfig, conn, xtls, noRequireSTARTTLS)
|
go serve(listenerName, mox.Cid(), tlsConfig, conn, xtls, noRequireSTARTTLS)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servers = append(servers, serve)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts serving on all listeners, launching a goroutine per listener.
|
||||||
|
func Serve() {
|
||||||
|
for _, serve := range servers {
|
||||||
|
go serve()
|
||||||
|
}
|
||||||
|
servers = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns whether this connection accepts utf-8 in strings.
|
// returns whether this connection accepts utf-8 in strings.
|
||||||
|
|
|
@ -43,11 +43,15 @@ func TestDeliver(t *testing.T) {
|
||||||
mox.Shutdown, mox.ShutdownCancel = context.WithCancel(context.Background())
|
mox.Shutdown, mox.ShutdownCancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
// Remove state.
|
// Remove state.
|
||||||
os.RemoveAll("testdata/integration/run")
|
os.RemoveAll("testdata/integration/data")
|
||||||
os.MkdirAll("testdata/integration/run", 0750)
|
os.MkdirAll("testdata/integration/data", 0750)
|
||||||
|
|
||||||
|
// Cleanup afterwards, these are owned by root, annoying to have around due to
|
||||||
|
// permission errors.
|
||||||
|
defer os.RemoveAll("testdata/integration/data")
|
||||||
|
|
||||||
// Load mox config.
|
// Load mox config.
|
||||||
mox.ConfigStaticPath = "testdata/integration/mox.conf"
|
mox.ConfigStaticPath = "testdata/integration/config/mox.conf"
|
||||||
filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
|
filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
|
||||||
if errs := mox.LoadConfig(mox.Context); len(errs) > 0 {
|
if errs := mox.LoadConfig(mox.Context); len(errs) > 0 {
|
||||||
t.Fatalf("loading mox config: %v", errs)
|
t.Fatalf("loading mox config: %v", errs)
|
||||||
|
@ -69,14 +73,15 @@ func TestDeliver(t *testing.T) {
|
||||||
createAccount("moxtest3@mox3.example", "pass1234")
|
createAccount("moxtest3@mox3.example", "pass1234")
|
||||||
|
|
||||||
// Start mox.
|
// Start mox.
|
||||||
mtastsdbRefresher := false
|
const mtastsdbRefresher = false
|
||||||
err := start(mtastsdbRefresher)
|
const skipForkExec = true
|
||||||
|
err := start(mtastsdbRefresher, skipForkExec)
|
||||||
tcheck(t, err, "starting mox")
|
tcheck(t, err, "starting mox")
|
||||||
|
|
||||||
// todo: we should probably hook store.Comm to get updates.
|
// todo: we should probably hook store.Comm to get updates.
|
||||||
latestMsgID := func(username string) int64 {
|
latestMsgID := func(username string) int64 {
|
||||||
// We open the account index database created by mox for the test user. And we keep looking for the email we sent.
|
// We open the account index database created by mox for the test user. And we keep looking for the email we sent.
|
||||||
dbpath := fmt.Sprintf("testdata/integration/run/accounts/%s/index.db", username)
|
dbpath := fmt.Sprintf("testdata/integration/data/accounts/%s/index.db", username)
|
||||||
db, err := bstore.Open(dbpath, &bstore.Options{Timeout: 3 * time.Second}, store.Message{}, store.Recipient{}, store.Mailbox{}, store.Password{})
|
db, err := bstore.Open(dbpath, &bstore.Options{Timeout: 3 * time.Second}, store.Message{}, store.Recipient{}, store.Mailbox{}, store.Password{})
|
||||||
if err != nil && errors.Is(err, bolt.ErrTimeout) {
|
if err != nil && errors.Is(err, bolt.ErrTimeout) {
|
||||||
log.Printf("db open timeout (normal delay for new sender with account and db file kept open)")
|
log.Printf("db open timeout (normal delay for new sender with account and db file kept open)")
|
||||||
|
|
59
main.go
59
main.go
|
@ -78,7 +78,6 @@ var commands = []struct {
|
||||||
}{
|
}{
|
||||||
{"serve", cmdServe},
|
{"serve", cmdServe},
|
||||||
{"quickstart", cmdQuickstart},
|
{"quickstart", cmdQuickstart},
|
||||||
{"restart", cmdRestart},
|
|
||||||
{"stop", cmdStop},
|
{"stop", cmdStop},
|
||||||
{"setaccountpassword", cmdSetaccountpassword},
|
{"setaccountpassword", cmdSetaccountpassword},
|
||||||
{"setadminpassword", cmdSetadminpassword},
|
{"setadminpassword", cmdSetadminpassword},
|
||||||
|
@ -105,6 +104,7 @@ var commands = []struct {
|
||||||
{"config domain add", cmdConfigDomainAdd},
|
{"config domain add", cmdConfigDomainAdd},
|
||||||
{"config domain rm", cmdConfigDomainRemove},
|
{"config domain rm", cmdConfigDomainRemove},
|
||||||
{"config describe-sendmail", cmdConfigDescribeSendmail},
|
{"config describe-sendmail", cmdConfigDescribeSendmail},
|
||||||
|
{"config printservice", cmdConfigPrintservice},
|
||||||
|
|
||||||
{"checkupdate", cmdCheckupdate},
|
{"checkupdate", cmdCheckupdate},
|
||||||
{"cid", cmdCid},
|
{"cid", cmdCid},
|
||||||
|
@ -530,6 +530,27 @@ needs modifications to make it valid.
|
||||||
xcheckf(err, "describing config")
|
xcheckf(err, "describing config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cmdConfigPrintservice(c *cmd) {
|
||||||
|
c.params = ">mox.service"
|
||||||
|
c.help = `Prints a systemd unit service file for mox.
|
||||||
|
|
||||||
|
This is the same file as generated using quickstart. If the systemd service file
|
||||||
|
has changed with a newer version of mox, use this command to generate an up to
|
||||||
|
date version.
|
||||||
|
`
|
||||||
|
if len(c.Parse()) != 0 {
|
||||||
|
c.Usage()
|
||||||
|
}
|
||||||
|
|
||||||
|
pwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("current working directory: %v", err)
|
||||||
|
pwd = "/home/mox"
|
||||||
|
}
|
||||||
|
service := strings.ReplaceAll(moxService, "/home/mox", pwd)
|
||||||
|
fmt.Print(service)
|
||||||
|
}
|
||||||
|
|
||||||
func cmdConfigDomainAdd(c *cmd) {
|
func cmdConfigDomainAdd(c *cmd) {
|
||||||
c.params = "domain account [localpart]"
|
c.params = "domain account [localpart]"
|
||||||
c.help = `Adds a new domain to the configuration and reloads the configuration.
|
c.help = `Adds a new domain to the configuration and reloads the configuration.
|
||||||
|
@ -802,42 +823,6 @@ new mail deliveries.
|
||||||
fmt.Println("mox stopped")
|
fmt.Println("mox stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdRestart(c *cmd) {
|
|
||||||
c.help = `Restart mox after validating the configuration file.
|
|
||||||
|
|
||||||
Restart execs the mox binary, which have been updated. Restart returns after
|
|
||||||
the restart has finished. If you update the mox binary, keep in mind that the
|
|
||||||
validation of the configuration file is done by the old process with the old
|
|
||||||
binary. The new binary may report a syntax error. If you update the binary, you
|
|
||||||
should use the "config test" command with the new binary to validate the
|
|
||||||
configuration file.
|
|
||||||
|
|
||||||
Like stop, existing connections get a 3 second period for graceful shutdown.
|
|
||||||
`
|
|
||||||
if len(c.Parse()) != 0 {
|
|
||||||
c.Usage()
|
|
||||||
}
|
|
||||||
mustLoadConfig()
|
|
||||||
|
|
||||||
ctl := xctl()
|
|
||||||
ctl.xwrite("restart")
|
|
||||||
line := ctl.xread()
|
|
||||||
if line != "ok" {
|
|
||||||
log.Fatalf("restart failed: %s", line)
|
|
||||||
}
|
|
||||||
// Server is now restarting. It will write ok when it is back online again. If it fails, our connection will be closed.
|
|
||||||
buf := make([]byte, 128)
|
|
||||||
n, err := ctl.conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("restart failed: %s", err)
|
|
||||||
}
|
|
||||||
s := strings.TrimSuffix(string(buf[:n]), "\n")
|
|
||||||
if s != "ok" {
|
|
||||||
log.Fatalf("restart failed: %s", s)
|
|
||||||
}
|
|
||||||
fmt.Println("mox restarted")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdSetadminpassword(c *cmd) {
|
func cmdSetadminpassword(c *cmd) {
|
||||||
c.help = `Set a new admin password, for the web interface.
|
c.help = `Set a new admin password, for the web interface.
|
||||||
|
|
||||||
|
|
|
@ -138,7 +138,7 @@ func MakeAccountConfig(addr smtp.Address) config.Account {
|
||||||
|
|
||||||
// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
|
// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
|
||||||
// accountName for DMARC and TLS reports.
|
// accountName for DMARC and TLS reports.
|
||||||
func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string) (config.Domain, []string, error) {
|
func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) {
|
||||||
log := xlog.WithContext(ctx)
|
log := xlog.WithContext(ctx)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
@ -242,14 +242,6 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
|
||||||
Localpart: "dmarc-reports",
|
Localpart: "dmarc-reports",
|
||||||
Mailbox: "DMARC",
|
Mailbox: "DMARC",
|
||||||
},
|
},
|
||||||
MTASTS: &config.MTASTS{
|
|
||||||
PolicyID: time.Now().UTC().Format("20060102T150405"),
|
|
||||||
Mode: mtasts.ModeEnforce,
|
|
||||||
// We start out with 24 hour, and warn in the admin interface that users should
|
|
||||||
// increase it to weeks. Once the setup works.
|
|
||||||
MaxAge: 24 * time.Hour,
|
|
||||||
MX: []string{hostname.ASCII},
|
|
||||||
},
|
|
||||||
TLSRPT: &config.TLSRPT{
|
TLSRPT: &config.TLSRPT{
|
||||||
Account: accountName,
|
Account: accountName,
|
||||||
Localpart: "tls-reports",
|
Localpart: "tls-reports",
|
||||||
|
@ -257,6 +249,17 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if withMTASTS {
|
||||||
|
confDomain.MTASTS = &config.MTASTS{
|
||||||
|
PolicyID: time.Now().UTC().Format("20060102T150405"),
|
||||||
|
Mode: mtasts.ModeEnforce,
|
||||||
|
// We start out with 24 hour, and warn in the admin interface that users should
|
||||||
|
// increase it to weeks once the setup works.
|
||||||
|
MaxAge: 24 * time.Hour,
|
||||||
|
MX: []string{hostname.ASCII},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rpaths := paths
|
rpaths := paths
|
||||||
paths = nil
|
paths = nil
|
||||||
|
|
||||||
|
@ -293,7 +296,16 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
|
||||||
nc.Domains[name] = d
|
nc.Domains[name] = d
|
||||||
}
|
}
|
||||||
|
|
||||||
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName)
|
// Only enable mta-sts for domain if there is a listener with mta-sts.
|
||||||
|
var withMTASTS bool
|
||||||
|
for _, l := range Conf.Static.Listeners {
|
||||||
|
if l.MTASTSHTTPS.Enabled {
|
||||||
|
withMTASTS = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("preparing domain config: %v", err)
|
return fmt.Errorf("preparing domain config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,9 +12,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
@ -390,6 +392,35 @@ func PrepareStaticConfig(ctx context.Context, configFile string, config *Config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.User == "" {
|
||||||
|
c.User = "mox"
|
||||||
|
}
|
||||||
|
u, err := user.Lookup(c.User)
|
||||||
|
var userErr user.UnknownUserError
|
||||||
|
if err != nil && errors.As(err, &userErr) {
|
||||||
|
uid, err := strconv.ParseUint(c.User, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
addErrorf("parsing unknown user %s as uid: %v", c.User, err)
|
||||||
|
} else {
|
||||||
|
// We assume the same gid as uid.
|
||||||
|
c.UID = uint32(uid)
|
||||||
|
c.GID = uint32(uid)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
addErrorf("looking up user: %v", err)
|
||||||
|
} else {
|
||||||
|
if uid, err := strconv.ParseUint(u.Uid, 10, 32); err != nil {
|
||||||
|
addErrorf("parsing uid %s: %v", u.Uid, err)
|
||||||
|
} else {
|
||||||
|
c.UID = uint32(uid)
|
||||||
|
}
|
||||||
|
if gid, err := strconv.ParseUint(u.Gid, 10, 32); err != nil {
|
||||||
|
addErrorf("parsing gid %s: %v", u.Gid, err)
|
||||||
|
} else {
|
||||||
|
c.GID = uint32(gid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hostname, err := dns.ParseDomain(c.Hostname)
|
hostname, err := dns.ParseDomain(c.Hostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
addErrorf("parsing hostname: %s", err)
|
addErrorf("parsing hostname: %s", err)
|
||||||
|
|
|
@ -2,15 +2,151 @@ package mox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/mlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// We start up as root, bind to sockets, and fork and exec as unprivileged user.
|
||||||
|
// During startup as root, we gather the fd's for the listen addresses in listens,
|
||||||
|
// and pass their addresses in an environment variable to the new process.
|
||||||
|
var listens = map[string]*os.File{}
|
||||||
|
|
||||||
|
// RestorePassedSockets reads addresses from $MOX_SOCKETS and prepares an os.File
|
||||||
|
// for each file descriptor, which are used by later calls of Listen.
|
||||||
|
func RestorePassedSockets() {
|
||||||
|
s := os.Getenv("MOX_SOCKETS")
|
||||||
|
if s == "" {
|
||||||
|
var linuxhint string
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
linuxhint = " If you updated from v0.0.1, update the mox.service file to start as root (privileges are dropped): ./mox config printservice >mox.service && sudo systemctl daemon-reload && sudo systemctl restart mox."
|
||||||
|
}
|
||||||
|
xlog.Fatal("mox must be started as root, and will drop privileges after binding required sockets (missing environment variable MOX_SOCKETS)." + linuxhint)
|
||||||
|
}
|
||||||
|
addrs := strings.Split(s, ",")
|
||||||
|
for i, addr := range addrs {
|
||||||
|
// 0,1,2 are stdin,stdout,stderr, 3 is the network/address fd.
|
||||||
|
f := os.NewFile(3+uintptr(i), addr)
|
||||||
|
listens[addr] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fork and exec as unprivileged user.
|
||||||
|
//
|
||||||
|
// We don't use just setuid because it is hard to guarantee that no other
|
||||||
|
// privileged go worker processes have been started before we get here. E.g. init
|
||||||
|
// functions in packages can start goroutines.
|
||||||
|
func ForkExecUnprivileged() {
|
||||||
|
prog, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
xlog.Fatalx("finding executable for exec", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []*os.File{os.Stdin, os.Stdout, os.Stderr}
|
||||||
|
var addrs []string
|
||||||
|
for addr, f := range listens {
|
||||||
|
files = append(files, f)
|
||||||
|
addrs = append(addrs, addr)
|
||||||
|
}
|
||||||
|
env := os.Environ()
|
||||||
|
env = append(env, "MOX_SOCKETS="+strings.Join(addrs, ","))
|
||||||
|
|
||||||
|
p, err := os.StartProcess(prog, os.Args, &os.ProcAttr{
|
||||||
|
Env: env,
|
||||||
|
Files: files,
|
||||||
|
Sys: &syscall.SysProcAttr{
|
||||||
|
Credential: &syscall.Credential{
|
||||||
|
Uid: Conf.Static.UID,
|
||||||
|
Gid: Conf.Static.GID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
xlog.Fatalx("fork and exec", err)
|
||||||
|
}
|
||||||
|
for _, f := range listens {
|
||||||
|
err := f.Close()
|
||||||
|
xlog.Check(err, "closing socket after passing to unprivileged child")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get a interrupt/terminate signal, pass it on to the child. For interrupt,
|
||||||
|
// the child probably already got it.
|
||||||
|
// todo: see if we tie up child and root process so a kill -9 of the root process
|
||||||
|
// kills the child process too.
|
||||||
|
sigc := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig := <-sigc
|
||||||
|
p.Signal(sig)
|
||||||
|
}()
|
||||||
|
|
||||||
|
st, err := p.Wait()
|
||||||
|
if err != nil {
|
||||||
|
xlog.Fatalx("wait", err)
|
||||||
|
}
|
||||||
|
code := st.ExitCode()
|
||||||
|
xlog.Print("stopping after child exit", mlog.Field("exitcode", code))
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupPassedSockets closes the listening socket file descriptors passed in by
|
||||||
|
// the parent process. To be called after listeners have been recreated (they dup
|
||||||
|
// the file descriptor).
|
||||||
|
func CleanupPassedSockets() {
|
||||||
|
for _, f := range listens {
|
||||||
|
err := f.Close()
|
||||||
|
xlog.Check(err, "closing listener socket file descriptor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen returns a newly created network listener when starting as root, and
|
||||||
|
// otherwise (not root) returns a network listener from a file descriptor that was
|
||||||
|
// passed by the parent root process.
|
||||||
|
func Listen(network, addr string) (net.Listener, error) {
|
||||||
|
if os.Getuid() != 0 {
|
||||||
|
f, ok := listens[addr]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no file descriptor for listener %s", addr)
|
||||||
|
}
|
||||||
|
ln, err := net.FileListener(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("making network listener from file descriptor for address %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
return ln, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := listens[addr]; ok {
|
||||||
|
return nil, fmt.Errorf("duplicate listener: %s", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen(network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tcpln, ok := ln.(*net.TCPListener)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("listener not a tcp listener, but %T, for network %s, address %s", ln, network, addr)
|
||||||
|
}
|
||||||
|
f, err := tcpln.File()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dup listener: %v", err)
|
||||||
|
}
|
||||||
|
listens[addr] = f
|
||||||
|
return ln, err
|
||||||
|
}
|
||||||
|
|
||||||
// Shutdown is canceled when a graceful shutdown is initiated. SMTP, IMAP, periodic
|
// Shutdown is canceled when a graceful shutdown is initiated. SMTP, IMAP, periodic
|
||||||
// processes should check this before starting a new operation. If true, the
|
// processes should check this before starting a new operation. If true, the
|
||||||
// operation should be aborted, and new connections should receive a message that
|
// operation should be aborted, and new connections should receive a message that
|
||||||
|
|
|
@ -8,10 +8,6 @@ import (
|
||||||
|
|
||||||
var LimiterFailedAuth *ratelimit.Limiter
|
var LimiterFailedAuth *ratelimit.Limiter
|
||||||
|
|
||||||
func init() {
|
|
||||||
LimitersInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// LimitesrsInit initializes the failed auth rate limiter.
|
// LimitesrsInit initializes the failed auth rate limiter.
|
||||||
func LimitersInit() {
|
func LimitersInit() {
|
||||||
LimiterFailedAuth = &ratelimit.Limiter{
|
LimiterFailedAuth = &ratelimit.Limiter{
|
||||||
|
|
|
@ -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 ""
|
|
||||||
}
|
|
19
mox.service
19
mox.service
|
@ -7,27 +7,22 @@ Wants=network-online.target
|
||||||
UMask=007
|
UMask=007
|
||||||
LimitNOFILE=65535
|
LimitNOFILE=65535
|
||||||
Type=simple
|
Type=simple
|
||||||
User=mox
|
# Mox starts as root, but drops privileges after binding network addresses.
|
||||||
Group=mox
|
WorkingDirectory=/home/mox
|
||||||
Environment="MOXCONF=/home/service/mox/config/mox.conf"
|
ExecStart=/home/mox/mox serve
|
||||||
WorkingDirectory=/home/service/mox
|
|
||||||
ExecStart=/home/service/mox/mox serve
|
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
Restart=always
|
Restart=always
|
||||||
ExecStop=/home/service/mox/mox stop
|
ExecStop=/home/mox/mox stop
|
||||||
# Restart does shut down existing smtp/imap connections (gracefully), but first
|
|
||||||
# verifies the config file, and it returns after restart was complete.
|
|
||||||
ExecReload=/home/service/mox/mox restart
|
|
||||||
|
|
||||||
# Isolate process, reducing attack surface.
|
# Isolate process, reducing attack surface.
|
||||||
PrivateDevices=yes
|
PrivateDevices=yes
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths=/home/service/mox/config /home/service/mox/data
|
ReadWritePaths=/home/mox/config /home/mox/data
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
AmbientCapabilities=
|
||||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_NET_BIND_SERVICE CAP_CHOWN
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||||
ProtectProc=invisible
|
ProtectProc=invisible
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -41,15 +41,18 @@ func pwgen() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdQuickstart(c *cmd) {
|
func cmdQuickstart(c *cmd) {
|
||||||
c.params = "user@domain"
|
c.params = "user@domain [user | uid]"
|
||||||
c.help = `Quickstart generates configuration files and prints instructions to quickly set up a mox instance.
|
c.help = `Quickstart generates configuration files and prints instructions to quickly set up a mox instance.
|
||||||
|
|
||||||
Quickstart prints initial admin and account passwords, configuration files, DNS
|
Quickstart writes configuration files, prints initial admin and account
|
||||||
records you should create, instructions for setting correct user/group and
|
passwords, DNS records you should create. If you run it on Linux it writes a
|
||||||
permissions, and if you run it on Linux it prints a systemd service file.
|
systemd service file and prints commands to enable and start mox as service.
|
||||||
|
|
||||||
|
The user or uid is optional, defaults to "mox", and is the user or uid/gid mox
|
||||||
|
will run as after initialization.
|
||||||
`
|
`
|
||||||
args := c.Parse()
|
args := c.Parse()
|
||||||
if len(args) != 1 {
|
if len(args) != 1 && len(args) != 2 {
|
||||||
c.Usage()
|
c.Usage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -342,19 +345,27 @@ This likely means one of two things:
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
|
user := "mox"
|
||||||
|
if len(args) == 2 {
|
||||||
|
user = args[1]
|
||||||
|
}
|
||||||
|
|
||||||
dc := config.Dynamic{}
|
dc := config.Dynamic{}
|
||||||
sc := config.Static{DataDir: "../data"}
|
sc := config.Static{
|
||||||
dataDir := "data" // ../data is relative to config/
|
DataDir: "../data",
|
||||||
os.MkdirAll(dataDir, 0770)
|
User: user,
|
||||||
sc.LogLevel = "info"
|
LogLevel: "info",
|
||||||
sc.Hostname = hostname.Name()
|
Hostname: hostname.Name(),
|
||||||
sc.ACME = map[string]config.ACME{
|
ACME: map[string]config.ACME{
|
||||||
"letsencrypt": {
|
"letsencrypt": {
|
||||||
DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory",
|
DirectoryURL: "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
ContactEmail: args[0], // todo: let user specify an alternative fallback address?
|
ContactEmail: args[0], // todo: let user specify an alternative fallback address?
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
AdminPasswordFile: "adminpasswd",
|
||||||
}
|
}
|
||||||
sc.AdminPasswordFile = "adminpasswd"
|
dataDir := "data" // ../data is relative to config/
|
||||||
|
os.MkdirAll(dataDir, 0770)
|
||||||
adminpw := pwgen()
|
adminpw := pwgen()
|
||||||
adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
|
adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -399,7 +410,8 @@ This likely means one of two things:
|
||||||
mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below.
|
mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below.
|
||||||
|
|
||||||
accountConf := mox.MakeAccountConfig(addr)
|
accountConf := mox.MakeAccountConfig(addr)
|
||||||
confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, hostname, username)
|
const withMTASTS = true
|
||||||
|
confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, hostname, username, withMTASTS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("making domain config: %s", err)
|
fatalf("making domain config: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -529,47 +541,20 @@ or if you are sending email for your domain from other machines/services, you
|
||||||
should understand the consequences of the DNS records above before
|
should understand the consequences of the DNS records above before
|
||||||
continuing!
|
continuing!
|
||||||
|
|
||||||
You can now start mox with "mox serve", but see below for recommended ownership
|
You can now start mox with "./mox serve", as root. File ownership and
|
||||||
and permissions.
|
permissions are automatically set correctly by mox when starting up. On linux,
|
||||||
|
you may want to enable mox as a systemd service.
|
||||||
|
|
||||||
`)
|
`)
|
||||||
|
|
||||||
if os.Getenv("MOX_DOCKER") == "" {
|
|
||||||
fmt.Printf(`Assuming the mox binary is in the current directory, and you will run mox under
|
|
||||||
user name "mox", and the admin user is the current user, the following commands
|
|
||||||
set the correct permissions:
|
|
||||||
|
|
||||||
sudo useradd -d $PWD mox
|
|
||||||
sudo chown $(id -nu):mox . mox
|
|
||||||
sudo chown -R mox:$(id -ng) config data
|
|
||||||
sudo chmod 751 .
|
|
||||||
sudo chmod 750 mox
|
|
||||||
sudo chmod -R u=rwX,g=rwX,o= config data
|
|
||||||
sudo chmod g+s $(find . -type d)
|
|
||||||
|
|
||||||
`)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(`Assuming you will run mox under user name "mox", and the admin user is the
|
|
||||||
current user, the following commands set the correct permissions:
|
|
||||||
|
|
||||||
sudo useradd -d $PWD mox
|
|
||||||
sudo chown $(id -nu):mox .
|
|
||||||
sudo chown -R mox:$(id -ng) config data
|
|
||||||
sudo chmod 751 .
|
|
||||||
sudo chmod -R u=rwX,g=rwX,o= config data
|
|
||||||
sudo chmod g+s $(find . -type d)
|
|
||||||
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, we only give service config instructions for linux when not running in docker.
|
// For now, we only give service config instructions for linux when not running in docker.
|
||||||
if runtime.GOOS == "linux" && os.Getenv("MOX_DOCKER") == "" {
|
if runtime.GOOS == "linux" && os.Getenv("MOX_DOCKER") == "" {
|
||||||
pwd, err := os.Getwd()
|
pwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("current working directory: %v", err)
|
log.Printf("current working directory: %v", err)
|
||||||
pwd = "/home/service/mox"
|
pwd = "/home/mox"
|
||||||
}
|
}
|
||||||
service := strings.ReplaceAll(moxService, "/home/service/mox", pwd)
|
service := strings.ReplaceAll(moxService, "/home/mox", pwd)
|
||||||
xwritefile("mox.service", []byte(service), 0644)
|
xwritefile("mox.service", []byte(service), 0644)
|
||||||
cleanupPaths = append(cleanupPaths, "mox.service")
|
cleanupPaths = append(cleanupPaths, "mox.service")
|
||||||
fmt.Printf(`See mox.service for a systemd service file. To enable and start:
|
fmt.Printf(`See mox.service for a systemd service file. To enable and start:
|
||||||
|
|
339
serve.go
339
serve.go
|
@ -4,12 +4,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
cryptorand "crypto/rand"
|
cryptorand "crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
@ -24,7 +25,6 @@ import (
|
||||||
"github.com/mjl-/mox/metrics"
|
"github.com/mjl-/mox/metrics"
|
||||||
"github.com/mjl-/mox/mlog"
|
"github.com/mjl-/mox/mlog"
|
||||||
"github.com/mjl-/mox/mox-"
|
"github.com/mjl-/mox/mox-"
|
||||||
"github.com/mjl-/mox/moxio"
|
|
||||||
"github.com/mjl-/mox/moxvar"
|
"github.com/mjl-/mox/moxvar"
|
||||||
"github.com/mjl-/mox/store"
|
"github.com/mjl-/mox/store"
|
||||||
"github.com/mjl-/mox/updates"
|
"github.com/mjl-/mox/updates"
|
||||||
|
@ -138,60 +138,75 @@ requested, other TLS certificates are requested on demand.
|
||||||
log := mlog.New("serve")
|
log := mlog.New("serve")
|
||||||
|
|
||||||
if os.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
log.Fatal("refusing to run as root, please start mox as unprivileged user")
|
// No need to potentially start and keep multiple processes. As root, we just need
|
||||||
|
// to start the child process.
|
||||||
|
runtime.GOMAXPROCS(1)
|
||||||
|
|
||||||
|
log.Print("starting as root, initializing network listeners", mlog.Field("version", moxvar.Version), mlog.Field("pid", os.Getpid()))
|
||||||
|
if os.Getenv("MOX_SOCKETS") != "" {
|
||||||
|
log.Fatal("refusing to start as root with $MOX_SOCKETS set")
|
||||||
}
|
}
|
||||||
|
|
||||||
if fds := os.Getenv("MOX_RESTART_CTL_SOCKET"); fds != "" {
|
if !mox.Conf.Static.NoFixPermissions {
|
||||||
log.Print("restarted")
|
// Fix permissions now that we have privilege to do so. Useful for update of v0.0.1
|
||||||
|
// that was running directly as mox-user.
|
||||||
fd, err := strconv.ParseUint(fds, 10, 32)
|
workdir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalx("restart with invalid ctl socket", err, mlog.Field("fd", fds))
|
log.Printx("get working dir, continuing without potentially fixing up permissions", err)
|
||||||
}
|
} else {
|
||||||
f := os.NewFile(uintptr(fd), "restartctl")
|
configdir := filepath.Dir(mox.ConfigStaticPath)
|
||||||
if _, err := fmt.Fprint(f, "ok\n"); err != nil {
|
datadir := mox.DataDirPath(".")
|
||||||
log.Infox("writing ok to restart ctl socket", err)
|
err := fixperms(log, workdir, configdir, datadir, mox.Conf.Static.UID, mox.Conf.Static.GID)
|
||||||
}
|
if err != nil {
|
||||||
err = f.Close()
|
log.Fatalx("fixing permissions", err)
|
||||||
log.Check(err, "closing restart ctl socket")
|
|
||||||
}
|
|
||||||
log.Print("starting up", mlog.Field("version", moxvar.Version))
|
|
||||||
|
|
||||||
shutdown := func() {
|
|
||||||
// We indicate we are shutting down. Causes new connections and new SMTP commands
|
|
||||||
// to be rejected. Should stop active connections pretty quickly.
|
|
||||||
mox.ShutdownCancel()
|
|
||||||
|
|
||||||
// Now we are going to wait for all connections to be gone, up to a timeout.
|
|
||||||
done := mox.Connections.Done()
|
|
||||||
second := time.Tick(time.Second)
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
log.Print("connections shutdown, waiting until 1 second passed")
|
|
||||||
<-second
|
|
||||||
|
|
||||||
case <-time.Tick(3 * time.Second):
|
|
||||||
// We now cancel all pending operations, and set an immediate deadline on sockets.
|
|
||||||
// Should get us a clean shutdown relatively quickly.
|
|
||||||
mox.ContextCancel()
|
|
||||||
mox.Connections.Shutdown()
|
|
||||||
|
|
||||||
second := time.Tick(time.Second)
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
log.Print("no more connections, shutdown is clean, waiting until 1 second passed")
|
|
||||||
<-second // Still wait for second, giving processes like imports a chance to clean up.
|
|
||||||
case <-second:
|
|
||||||
log.Print("shutting down with pending sockets")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err := os.Remove(mox.DataDirPath("ctl"))
|
}
|
||||||
log.Check(err, "removing ctl unix domain socket during shutdown")
|
} else {
|
||||||
|
log.Print("starting as unprivileged user", mlog.Field("user", mox.Conf.Static.User), mlog.Field("uid", mox.Conf.Static.UID), mlog.Field("gid", mox.Conf.Static.GID), mlog.Field("pid", os.Getpid()))
|
||||||
|
mox.RestorePassedSockets()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := moxio.CheckUmask(); err != nil {
|
syscall.Umask(syscall.Umask(007) | 007)
|
||||||
log.Errorx("bad umask", err)
|
|
||||||
|
// Initialize key and random buffer for creating opaque SMTP
|
||||||
|
// transaction IDs based on "cid"s.
|
||||||
|
recvidpath := mox.DataDirPath("receivedid.key")
|
||||||
|
recvidbuf, err := os.ReadFile(recvidpath)
|
||||||
|
if err != nil || len(recvidbuf) != 16+8 {
|
||||||
|
recvidbuf = make([]byte, 16+8)
|
||||||
|
if _, err := cryptorand.Read(recvidbuf); err != nil {
|
||||||
|
log.Fatalx("reading random recvid data", err)
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(recvidpath, recvidbuf, 0660); err != nil {
|
||||||
|
log.Fatalx("writing recvidpath", err, mlog.Field("path", recvidpath))
|
||||||
|
}
|
||||||
|
err := os.Chown(recvidpath, int(mox.Conf.Static.UID), 0)
|
||||||
|
log.Check(err, "chown receveidid.key", mlog.Field("path", recvidpath), mlog.Field("uid", mox.Conf.Static.UID), mlog.Field("gid", 0))
|
||||||
|
err = os.Chmod(recvidpath, 0640)
|
||||||
|
log.Check(err, "chmod receveidid.key to 0640", mlog.Field("path", recvidpath))
|
||||||
|
}
|
||||||
|
if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
|
||||||
|
log.Fatalx("init receivedid", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start mox. If running as root, this will bind/listen on network sockets, and
|
||||||
|
// fork and exec itself as unprivileged user, then waits for the child to stop and
|
||||||
|
// exit. When running as root, this function never returns. But the new
|
||||||
|
// unprivileged user will get here again, with network sockets prepared.
|
||||||
|
//
|
||||||
|
// We listen to the unix domain ctl socket afterwards, which we always remove
|
||||||
|
// before listening. We need to do that because we may not have cleaned up our
|
||||||
|
// control socket during unexpected shutdown. We don't want to remove and listen on
|
||||||
|
// the unix domain socket first. If we would, we would make the existing instance
|
||||||
|
// unreachable over its ctl socket, and then fail because the network addresses are
|
||||||
|
// taken.
|
||||||
|
const mtastsdbRefresher = true
|
||||||
|
const skipForkExec = false
|
||||||
|
if err := start(mtastsdbRefresher, skipForkExec); err != nil {
|
||||||
|
log.Fatalx("start", err)
|
||||||
|
}
|
||||||
|
log.Print("ready to serve")
|
||||||
|
|
||||||
if mox.Conf.Static.CheckUpdates {
|
if mox.Conf.Static.CheckUpdates {
|
||||||
checkUpdates := func() time.Duration {
|
checkUpdates := func() time.Duration {
|
||||||
|
@ -281,37 +296,40 @@ requested, other TLS certificates are requested on demand.
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize key and random buffer for creating opaque SMTP
|
|
||||||
// transaction IDs based on "cid"s.
|
|
||||||
recvidpath := mox.DataDirPath("receivedid.key")
|
|
||||||
recvidbuf, err := os.ReadFile(recvidpath)
|
|
||||||
if err != nil || len(recvidbuf) != 16+8 {
|
|
||||||
recvidbuf = make([]byte, 16+8)
|
|
||||||
if _, err := cryptorand.Read(recvidbuf); err != nil {
|
|
||||||
log.Fatalx("reading random recvid data", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(recvidpath, recvidbuf, 0660); err != nil {
|
|
||||||
log.Fatalx("writing recvidpath", err, mlog.Field("path", recvidpath))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
|
|
||||||
log.Fatalx("init receivedid", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We start the network listeners first. If an instance is already running, we'll
|
|
||||||
// get errors about address being in use. We listen to the unix domain socket
|
|
||||||
// afterwards, which we always remove before listening. We need to do that because
|
|
||||||
// we may not have cleaned up our control socket during unexpected shutdown. We
|
|
||||||
// don't want to remove and listen on the unix domain socket first. If we would, we
|
|
||||||
// would make the existing instance unreachable over its ctl socket, and then fail
|
|
||||||
// because the network addresses are taken.
|
|
||||||
mtastsdbRefresher := true
|
|
||||||
if err := start(mtastsdbRefresher); err != nil {
|
|
||||||
log.Fatalx("start", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
go monitorDNSBL(log)
|
go monitorDNSBL(log)
|
||||||
|
|
||||||
|
shutdown := func() {
|
||||||
|
// We indicate we are shutting down. Causes new connections and new SMTP commands
|
||||||
|
// to be rejected. Should stop active connections pretty quickly.
|
||||||
|
mox.ShutdownCancel()
|
||||||
|
|
||||||
|
// Now we are going to wait for all connections to be gone, up to a timeout.
|
||||||
|
done := mox.Connections.Done()
|
||||||
|
second := time.Tick(time.Second)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
log.Print("connections shutdown, waiting until 1 second passed")
|
||||||
|
<-second
|
||||||
|
|
||||||
|
case <-time.Tick(3 * time.Second):
|
||||||
|
// We now cancel all pending operations, and set an immediate deadline on sockets.
|
||||||
|
// Should get us a clean shutdown relatively quickly.
|
||||||
|
mox.ContextCancel()
|
||||||
|
mox.Connections.Shutdown()
|
||||||
|
|
||||||
|
second := time.Tick(time.Second)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
log.Print("no more connections, shutdown is clean, waiting until 1 second passed")
|
||||||
|
<-second // Still wait for second, giving processes like imports a chance to clean up.
|
||||||
|
case <-second:
|
||||||
|
log.Print("shutting down with pending sockets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := os.Remove(mox.DataDirPath("ctl"))
|
||||||
|
log.Check(err, "removing ctl unix domain socket during shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
ctlpath := mox.DataDirPath("ctl")
|
ctlpath := mox.DataDirPath("ctl")
|
||||||
_ = os.Remove(ctlpath)
|
_ = os.Remove(ctlpath)
|
||||||
ctl, err := net.Listen("unix", ctlpath)
|
ctl, err := net.Listen("unix", ctlpath)
|
||||||
|
@ -359,4 +377,171 @@ requested, other TLS certificates are requested on demand.
|
||||||
sig := <-sigc
|
sig := <-sigc
|
||||||
log.Print("shutting down, waiting max 3s for existing connections", mlog.Field("signal", sig))
|
log.Print("shutting down, waiting max 3s for existing connections", mlog.Field("signal", sig))
|
||||||
shutdown()
|
shutdown()
|
||||||
|
if num, ok := sig.(syscall.Signal); ok {
|
||||||
|
os.Exit(int(num))
|
||||||
|
} else {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set correct permissions for mox working directory, binary, config and data and service file.
|
||||||
|
//
|
||||||
|
// We require being able to stat the basic non-optional paths. Then we'll try to
|
||||||
|
// fix up permissions. If an error occurs when fixing permissions, we log and
|
||||||
|
// continue (could not be an actual problem).
|
||||||
|
func fixperms(log *mlog.Log, workdir, configdir, datadir string, moxuid, moxgid uint32) (rerr error) {
|
||||||
|
type fserr struct{ Err error }
|
||||||
|
defer func() {
|
||||||
|
x := recover()
|
||||||
|
if x == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e, ok := x.(fserr)
|
||||||
|
if ok {
|
||||||
|
rerr = e.Err
|
||||||
|
} else {
|
||||||
|
panic(x)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
checkf := func(err error, format string, args ...any) {
|
||||||
|
if err != nil {
|
||||||
|
panic(fserr{fmt.Errorf(format, args...)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes we have to make. We collect them first, then apply.
|
||||||
|
type change struct {
|
||||||
|
path string
|
||||||
|
uid, gid *uint32
|
||||||
|
olduid, oldgid uint32
|
||||||
|
mode *fs.FileMode
|
||||||
|
oldmode fs.FileMode
|
||||||
|
}
|
||||||
|
var changes []change
|
||||||
|
|
||||||
|
ensure := func(p string, uid, gid uint32, perm fs.FileMode) bool {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
checkf(err, "stat %s", p)
|
||||||
|
|
||||||
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
checkf(fmt.Errorf("got %T", st), "stat sys, expected syscall.Stat_t")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ch change
|
||||||
|
if st.Uid != uid || st.Gid != gid {
|
||||||
|
ch.uid = &uid
|
||||||
|
ch.gid = &gid
|
||||||
|
ch.olduid = st.Uid
|
||||||
|
ch.oldgid = st.Gid
|
||||||
|
}
|
||||||
|
if perm != fi.Mode()&(fs.ModeSetgid|0777) {
|
||||||
|
ch.mode = &perm
|
||||||
|
ch.oldmode = fi.Mode() & (fs.ModeSetgid | 0777)
|
||||||
|
}
|
||||||
|
var zerochange change
|
||||||
|
if ch == zerochange {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ch.path = p
|
||||||
|
changes = append(changes, ch)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
xexists := func(p string) bool {
|
||||||
|
_, err := os.Stat(p)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
checkf(err, "stat %s", p)
|
||||||
|
}
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// We ensure these permissions:
|
||||||
|
//
|
||||||
|
// $workdir root:mox 0751
|
||||||
|
// $configdir mox:root 0750 + setgid, and recursively (but files 0640)
|
||||||
|
// $datadir mox:root 0750 + setgid, and recursively (but files 0640)
|
||||||
|
// $workdir/mox (binary, optional) root:mox 0750
|
||||||
|
// $workdir/mox.service (systemd service file, optional) root:root 0644
|
||||||
|
|
||||||
|
const root = 0
|
||||||
|
ensure(workdir, root, moxgid, 0751)
|
||||||
|
fixconfig := ensure(configdir, moxuid, 0, fs.ModeSetgid|0750)
|
||||||
|
fixdata := ensure(datadir, moxuid, 0, fs.ModeSetgid|0750)
|
||||||
|
|
||||||
|
// Binary and systemd service file do not exist (there) when running under docker.
|
||||||
|
binary := filepath.Join(workdir, "mox")
|
||||||
|
if xexists(binary) {
|
||||||
|
ensure(binary, root, moxgid, 0750)
|
||||||
|
}
|
||||||
|
svc := filepath.Join(workdir, "mox.service")
|
||||||
|
if xexists(svc) {
|
||||||
|
ensure(svc, root, root, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply changes.
|
||||||
|
log.Print("fixing up permissions, will continue on errors")
|
||||||
|
for _, ch := range changes {
|
||||||
|
if ch.uid != nil {
|
||||||
|
err := os.Chown(ch.path, int(*ch.uid), int(*ch.gid))
|
||||||
|
log.Printx("chown, fixing uid/gid", err, mlog.Field("path", ch.path), mlog.Field("olduid", ch.olduid), mlog.Field("oldgid", ch.oldgid), mlog.Field("newuid", *ch.uid), mlog.Field("newgid", *ch.gid))
|
||||||
|
}
|
||||||
|
if ch.mode != nil {
|
||||||
|
err := os.Chmod(ch.path, *ch.mode)
|
||||||
|
log.Printx("chmod, fixing permissions", err, mlog.Field("path", ch.path), mlog.Field("oldmode", fmt.Sprintf("%03o", ch.oldmode)), mlog.Field("newmode", fmt.Sprintf("%03o", *ch.mode)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkchange := func(dir string) {
|
||||||
|
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
log.Printx("walk error, continuing", err, mlog.Field("path", path))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fi, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
log.Printx("stat during walk, continuing", err, mlog.Field("path", path))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
log.Printx("syscall stat during walk, continuing", err, mlog.Field("path", path))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if st.Uid != moxuid || st.Gid != root {
|
||||||
|
err := os.Chown(path, int(moxuid), root)
|
||||||
|
log.Printx("walk chown, fixing uid/gid", err, mlog.Field("path", path), mlog.Field("olduid", st.Uid), mlog.Field("oldgid", st.Gid), mlog.Field("newuid", moxuid), mlog.Field("newgid", root))
|
||||||
|
}
|
||||||
|
omode := fi.Mode() & (fs.ModeSetgid | 0777)
|
||||||
|
var nmode fs.FileMode
|
||||||
|
if fi.IsDir() {
|
||||||
|
nmode = fs.ModeSetgid | 0750
|
||||||
|
} else {
|
||||||
|
nmode = 0640
|
||||||
|
}
|
||||||
|
if omode != nmode {
|
||||||
|
err := os.Chmod(path, nmode)
|
||||||
|
log.Printx("walk chmod, fixing permissions", err, mlog.Field("path", path), mlog.Field("oldmode", fmt.Sprintf("%03o", omode)), mlog.Field("newmode", fmt.Sprintf("%03o", nmode)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
log.Check(err, "walking dir to fix permissions", mlog.Field("dir", dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If config or data dir needed fixing, also set uid/gid and mode and files/dirs
|
||||||
|
// inside, recursively. We don't always recurse, data probably contains many files.
|
||||||
|
if fixconfig {
|
||||||
|
log.Print("fixing permissions in config dir", mlog.Field("configdir", configdir))
|
||||||
|
walkchange(configdir)
|
||||||
|
}
|
||||||
|
if fixdata {
|
||||||
|
log.Print("fixing permissions in data dir", mlog.Field("configdir", configdir))
|
||||||
|
walkchange(datadir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -161,8 +161,9 @@ var (
|
||||||
|
|
||||||
var jitterRand = mox.NewRand()
|
var jitterRand = mox.NewRand()
|
||||||
|
|
||||||
// ListenAndServe starts network listeners that serve incoming SMTP connection.
|
// Listen initializes network listeners for incoming SMTP connection.
|
||||||
func ListenAndServe() {
|
// The listeners are stored for a later call to Serve.
|
||||||
|
func Listen() {
|
||||||
for name, listener := range mox.Conf.Static.Listeners {
|
for name, listener := range mox.Conf.Static.Listeners {
|
||||||
var tlsConfig *tls.Config
|
var tlsConfig *tls.Config
|
||||||
if listener.TLS != nil {
|
if listener.TLS != nil {
|
||||||
|
@ -181,7 +182,7 @@ func ListenAndServe() {
|
||||||
}
|
}
|
||||||
port := config.Port(listener.SMTP.Port, 25)
|
port := config.Port(listener.SMTP.Port, 25)
|
||||||
for _, ip := range listener.IPs {
|
for _, ip := range listener.IPs {
|
||||||
go listenServe("smtp", name, ip, port, hostname, tlsConfig, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, listener.SMTP.DNSBLZones)
|
listen1("smtp", name, ip, port, hostname, tlsConfig, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, listener.SMTP.DNSBLZones)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if listener.Submission.Enabled {
|
if listener.Submission.Enabled {
|
||||||
|
@ -191,7 +192,7 @@ func ListenAndServe() {
|
||||||
}
|
}
|
||||||
port := config.Port(listener.Submission.Port, 587)
|
port := config.Port(listener.Submission.Port, 587)
|
||||||
for _, ip := range listener.IPs {
|
for _, ip := range listener.IPs {
|
||||||
go listenServe("submission", name, ip, port, hostname, tlsConfig, true, false, maxMsgSize, !listener.Submission.NoRequireSTARTTLS, !listener.Submission.NoRequireSTARTTLS, nil)
|
listen1("submission", name, ip, port, hostname, tlsConfig, true, false, maxMsgSize, !listener.Submission.NoRequireSTARTTLS, !listener.Submission.NoRequireSTARTTLS, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,26 +203,29 @@ func ListenAndServe() {
|
||||||
}
|
}
|
||||||
port := config.Port(listener.Submissions.Port, 465)
|
port := config.Port(listener.Submissions.Port, 465)
|
||||||
for _, ip := range listener.IPs {
|
for _, ip := range listener.IPs {
|
||||||
go listenServe("submissions", name, ip, port, hostname, tlsConfig, true, true, maxMsgSize, true, true, nil)
|
listen1("submissions", name, ip, port, hostname, tlsConfig, true, true, maxMsgSize, true, true, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listenServe(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig *tls.Config, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery bool, dnsBLs []dns.Domain) {
|
var servers []func()
|
||||||
|
|
||||||
|
func listen1(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig *tls.Config, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery bool, dnsBLs []dns.Domain) {
|
||||||
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
||||||
|
if os.Getuid() == 0 {
|
||||||
xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", addr), mlog.Field("protocol", protocol))
|
xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", addr), mlog.Field("protocol", protocol))
|
||||||
|
}
|
||||||
network := mox.Network(ip)
|
network := mox.Network(ip)
|
||||||
var ln net.Listener
|
ln, err := mox.Listen(network, addr)
|
||||||
var err error
|
|
||||||
if xtls {
|
|
||||||
ln, err = tls.Listen(network, addr, tlsConfig)
|
|
||||||
} else {
|
|
||||||
ln, err = net.Listen(network, addr)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Fatalx("smtp: listen for smtp"+mox.LinuxSetcapHint(err), err, mlog.Field("protocol", protocol), mlog.Field("listener", name))
|
xlog.Fatalx("smtp: listen for smtp", err, mlog.Field("protocol", protocol), mlog.Field("listener", name))
|
||||||
}
|
}
|
||||||
|
if xtls {
|
||||||
|
ln = tls.NewListener(ln, tlsConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
serve := func() {
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -231,6 +235,16 @@ func listenServe(protocol, name, ip string, port int, hostname dns.Domain, tlsCo
|
||||||
resolver := dns.StrictResolver{} // By leaving Pkg empty, it'll be set by each package that uses the resolver, e.g. spf/dkim/dmarc.
|
resolver := dns.StrictResolver{} // By leaving Pkg empty, it'll be set by each package that uses the resolver, e.g. spf/dkim/dmarc.
|
||||||
go serve(name, mox.Cid(), hostname, tlsConfig, conn, resolver, submission, xtls, maxMessageSize, requireTLSForAuth, requireTLSForDelivery, dnsBLs)
|
go serve(name, mox.Cid(), hostname, tlsConfig, conn, resolver, submission, xtls, maxMessageSize, requireTLSForAuth, requireTLSForDelivery, dnsBLs)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servers = append(servers, serve)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts serving on all listeners, launching a goroutine per listener.
|
||||||
|
func Serve() {
|
||||||
|
for _, serve := range servers {
|
||||||
|
go serve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type conn struct {
|
type conn struct {
|
||||||
|
|
28
start.go
28
start.go
|
@ -2,11 +2,13 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/mjl-/mox/dmarcdb"
|
"github.com/mjl-/mox/dmarcdb"
|
||||||
"github.com/mjl-/mox/dns"
|
"github.com/mjl-/mox/dns"
|
||||||
"github.com/mjl-/mox/http"
|
"github.com/mjl-/mox/http"
|
||||||
"github.com/mjl-/mox/imapserver"
|
"github.com/mjl-/mox/imapserver"
|
||||||
|
"github.com/mjl-/mox/mox-"
|
||||||
"github.com/mjl-/mox/mtastsdb"
|
"github.com/mjl-/mox/mtastsdb"
|
||||||
"github.com/mjl-/mox/queue"
|
"github.com/mjl-/mox/queue"
|
||||||
"github.com/mjl-/mox/smtpserver"
|
"github.com/mjl-/mox/smtpserver"
|
||||||
|
@ -16,7 +18,23 @@ import (
|
||||||
|
|
||||||
// start initializes all packages, starts all listeners and the switchboard
|
// start initializes all packages, starts all listeners and the switchboard
|
||||||
// goroutine, then returns.
|
// goroutine, then returns.
|
||||||
func start(mtastsdbRefresher bool) error {
|
func start(mtastsdbRefresher, skipForkExec bool) error {
|
||||||
|
smtpserver.Listen()
|
||||||
|
imapserver.Listen()
|
||||||
|
http.Listen()
|
||||||
|
|
||||||
|
if !skipForkExec {
|
||||||
|
// If we were just launched as root, fork and exec as unprivileged user, handing
|
||||||
|
// over the bound sockets to the new process. We'll get to this same code path
|
||||||
|
// again, skipping this if block, continuing below with the actual serving.
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
mox.ForkExecUnprivileged()
|
||||||
|
panic("cannot happen")
|
||||||
|
} else {
|
||||||
|
mox.CleanupPassedSockets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := dmarcdb.Init(); err != nil {
|
if err := dmarcdb.Init(); err != nil {
|
||||||
return fmt.Errorf("dmarc init: %s", err)
|
return fmt.Errorf("dmarc init: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -34,9 +52,11 @@ func start(mtastsdbRefresher bool) error {
|
||||||
return fmt.Errorf("queue start: %s", err)
|
return fmt.Errorf("queue start: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpserver.ListenAndServe()
|
store.StartAuthCache()
|
||||||
imapserver.ListenAndServe()
|
smtpserver.Serve()
|
||||||
http.ListenAndServe()
|
imapserver.Serve()
|
||||||
|
http.Serve()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-store.Switchboard()
|
<-store.Switchboard()
|
||||||
}()
|
}()
|
||||||
|
|
|
@ -1184,25 +1184,29 @@ func (a *Account) RejectsRemove(log *mlog.Log, rejectsMailbox, messageID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We keep a cache of recent successful authentications, so we don't have to bcrypt successful calls each time.
|
// We keep a cache of recent successful authentications, so we don't have to bcrypt successful calls each time.
|
||||||
var authCache struct {
|
var authCache = struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
success map[authKey]string
|
success map[authKey]string
|
||||||
|
}{
|
||||||
|
success: map[authKey]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
type authKey struct {
|
type authKey struct {
|
||||||
email, hash string
|
email, hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
// StartAuthCache starts a goroutine that regularly clears the auth cache.
|
||||||
authCache.success = map[authKey]string{}
|
func StartAuthCache() {
|
||||||
go func() {
|
go manageAuthCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageAuthCache() {
|
||||||
for {
|
for {
|
||||||
authCache.Lock()
|
authCache.Lock()
|
||||||
authCache.success = map[authKey]string{}
|
authCache.success = map[authKey]string{}
|
||||||
authCache.Unlock()
|
authCache.Unlock()
|
||||||
time.Sleep(15 * time.Minute)
|
time.Sleep(15 * time.Minute)
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenEmailAuth opens an account given an email address and password.
|
// OpenEmailAuth opens an account given an email address and password.
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
DataDir: data
|
DataDir: ../data
|
||||||
LogLevel: trace
|
LogLevel: trace
|
||||||
Hostname: mox.example
|
Hostname: mox.example
|
||||||
|
User: 1000
|
||||||
Listeners:
|
Listeners:
|
||||||
local:
|
local:
|
||||||
IPs:
|
IPs:
|
|
@ -14,7 +14,7 @@ Domains:
|
||||||
- From
|
- From
|
||||||
- To
|
- To
|
||||||
- Subject
|
- Subject
|
||||||
PrivateKeyFile: dkim/mox2dkim0-key.pem
|
PrivateKeyFile: ../dkim/mox2dkim0-key.pem
|
||||||
Sign:
|
Sign:
|
||||||
- mox2dkim0
|
- mox2dkim0
|
||||||
# todo: DMARC:
|
# todo: DMARC:
|
||||||
|
@ -32,7 +32,7 @@ Domains:
|
||||||
- From
|
- From
|
||||||
- To
|
- To
|
||||||
- Subject
|
- Subject
|
||||||
PrivateKeyFile: dkim/mox3dkim0-key.pem
|
PrivateKeyFile: ../dkim/mox3dkim0-key.pem
|
||||||
Sign:
|
Sign:
|
||||||
- mox3dkim0
|
- mox3dkim0
|
||||||
Accounts:
|
Accounts:
|
|
@ -1,10 +1,12 @@
|
||||||
DataDir: ./run
|
DataDir: ../data
|
||||||
LogLevel: trace
|
LogLevel: trace
|
||||||
Hostname: moxmail1.mox1.example
|
Hostname: moxmail1.mox1.example
|
||||||
|
# only for integration test, where fork & exec is skipped
|
||||||
|
User: 0
|
||||||
TLS:
|
TLS:
|
||||||
CA:
|
CA:
|
||||||
CertFiles:
|
CertFiles:
|
||||||
- tls/ca.pem
|
- ../tls/ca.pem
|
||||||
Listeners:
|
Listeners:
|
||||||
mox1:
|
mox1:
|
||||||
IPs:
|
IPs:
|
||||||
|
@ -23,8 +25,8 @@ Listeners:
|
||||||
TLS:
|
TLS:
|
||||||
KeyCerts:
|
KeyCerts:
|
||||||
-
|
-
|
||||||
CertFile: tls/moxmail2.pem
|
CertFile: ../tls/moxmail2.pem
|
||||||
KeyFile: tls/moxmail2-key.pem
|
KeyFile: ../tls/moxmail2-key.pem
|
||||||
SMTP:
|
SMTP:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Submission:
|
Submission:
|
||||||
|
@ -53,8 +55,8 @@ Listeners:
|
||||||
TLS:
|
TLS:
|
||||||
KeyCerts:
|
KeyCerts:
|
||||||
-
|
-
|
||||||
CertFile: tls/moxmail3.pem
|
CertFile: ../tls/moxmail3.pem
|
||||||
KeyFile: tls/moxmail3-key.pem
|
KeyFile: ../tls/moxmail3-key.pem
|
||||||
SMTP:
|
SMTP:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Submission:
|
Submission:
|
Loading…
Reference in a new issue