Skip to content

Commit 6abee87

Browse files
committed
improve webserver, add domain redirects (aliases), add tests and admin page ui to manage the config
- make builtin http handlers serve on specific domains, such as for mta-sts, so e.g. /.well-known/mta-sts.txt isn't served on all domains. - add logging of a few more fields in access logging. - small tweaks/bug fixes in webserver request handling. - add config option for redirecting entire domains to another (common enough). - split httpserver metric into two: one for duration until writing header (i.e. performance of server), another for duration until full response is sent to client (i.e. performance as perceived by users). - add admin ui, a new page for managing the configs. after making changes and hitting "save", the changes take effect immediately. the page itself doesn't look very well-designed (many input fields, makes it look messy). i have an idea to improve it (explained in admin.html as todo) by making the layout look just like the config file. not urgent though. i've already changed my websites/webapps over. the idea of adding a webserver is to take away a (the) reason for folks to want to complicate their mox setup by running an other webserver on the same machine. i think the current webserver implementation can already serve most common use cases. with a few more tweaks (feedback needed!) we should be able to get to 95% of the use cases. the reverse proxy can take care of the remaining 5%. nevertheless, a next step is still to change the quickstart to make it easier for folks to run with an existing webserver, with existing tls certs/keys. that's how this relates to issue #5.
1 parent 6706c5c commit 6abee87

24 files changed

+1545
-144
lines changed

.jshintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"asi": true,
44
"strict": "implied",
55
"globals": {
6+
"self": true,
67
"window": true,
78
"console": true,
89
"document": true,

README.md

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ See Quickstart below to get started.
3131
accounts/domains, and modifying the configuration file.
3232
- Autodiscovery (with SRV records, Microsoft-style and Thunderbird-style) for
3333
easy account setup (though not many clients support it).
34+
- Webserver with serving static files and forwarding requests (reverse
35+
proxy), so port 443 can also be used to serve websites.
3436
- Prometheus metrics and structured logging for operational insight.
3537

3638
Mox is available under the MIT-license and was created by Mechiel Lukkien,
@@ -54,7 +56,7 @@ Verify you have a working mox binary:
5456

5557
./mox version
5658

57-
Note: Mox only compiles/works on unix systems, not on Plan 9 or Windows.
59+
Note: Mox only compiles for/works on unix systems, not on Plan 9 or Windows.
5860

5961
You can also run mox with docker image "docker.io/moxmail/mox", with tags like
6062
"latest", "0.0.1" and "0.0.1-go1.20.1-alpine3.17.2", etc. See docker-compose.yml
@@ -66,22 +68,20 @@ in this repository for instructions on starting.
6668
The easiest way to get started with serving email for your domain is to get a
6769
vm/machine dedicated to serving email, name it [host].[domain] (e.g.
6870
mail.example.com), login as root, create user "mox" and its homedir by running
69-
"useradd -d /home/mox mox && mkdir /home/mox", download mox to that directory,
70-
and generate a configuration for your desired email address at your domain:
71+
`useradd -d /home/mox mox && mkdir /home/mox` (or pick another directory),
72+
download mox to that directory, and generate a configuration for your desired
73+
email address at your domain:
7174

7275
./mox quickstart [email protected]
7376

7477
This creates an account, generates a password and configuration files, prints
7578
the DNS records you need to manually create and prints commands to start mox and
7679
optionally install mox as a service.
7780

78-
If you already have email configured for your domain, or if you are already
79-
sending email for your domain from other machines/services, you should modify
80-
the suggested configuration and/or DNS records.
81-
8281
A dedicated machine is highly recommended because modern email requires HTTPS,
83-
and mox currently needs it for automatic TLS. You can combine mox with an
84-
existing webserver, but it requires more configuration.
82+
and mox currently needs it for automatic TLS. You could combine mox with an
83+
existing webserver, but it requires more configuration. If you want to serve
84+
websites on the same machine, use the webserver built into mox.
8585

8686
After starting, you can access the admin web interface on internal IPs.
8787

@@ -109,7 +109,6 @@ The code is heavily cross-referenced with the RFCs for readability/maintainabili
109109
- DANE and DNSSEC.
110110
- Sending DMARC and TLS reports (currently only receiving).
111111
- OAUTH2 support, for single sign on.
112-
- Basic reverse proxy, so port 443 can be used for regular web serving too.
113112
- Using mox as backup MX.
114113
- ACME verification over HTTP (in addition to current tls-alpn01).
115114
- Add special IMAP mailbox ("Queue?") that contains queued but
@@ -182,7 +181,7 @@ and receive emails through it with your favourite email clients, and file an
182181
issue if you encounter a problem or would like to see a feature/functionality
183182
implemented.
184183

185-
Instead of switching your email for your domain over to mox, you could simply
184+
Instead of switching email for your domain over to mox, you could simply
186185
configure mox for a subdomain, e.g. [you]@moxtest.[yourdomain].
187186

188187
If you have experience with how the email protocols are used in the wild, e.g.
@@ -212,17 +211,17 @@ The admin password can be changed with "mox setadminpassword".
212211
Unfortunately, mox does not yet provide an option for that. Mox does spam
213212
filtering based on reputation of received messages. It will take a good amount
214213
of work to share that information with a backup MX. Without that information,
215-
spammer could use a backup MX to get their spam accepted. Until mox has a
214+
spammers could use a backup MX to get their spam accepted. Until mox has a
216215
proper solution, you can simply run a single SMTP server.
217216

218217
## How do I stay up to date?
219218

220-
Please set "CheckUpdates: true" in mox.conf. It will check for a new version
221-
through a DNS TXT request at `_updates.xmox.nl` once per 24h. Only if a new
222-
version is published, will the changelog be fetched and delivered to the
219+
Please set "CheckUpdates: true" in mox.conf. Mox will check for a new version
220+
through a DNS TXT request for `_updates.xmox.nl` once per 24h. Only if a new
221+
version is published will the changelog be fetched and delivered to the
223222
postmaster mailbox.
224223

225-
The changelog is at https://updates.xmox.nl/changelog
224+
The changelog is at https://updates.xmox.nl/changelog.
226225

227226
You could also monitor newly added tags on this repository, or for the docker
228227
image, but update instructions are in the changelog.
@@ -241,6 +240,6 @@ to [email protected].
241240

242241
## I'm now running an email server, but how does email work?
243242

244-
Congrats and welcome to the club! Running an email server brings some
245-
responsibility so you should understand how it works. See
243+
Congrats and welcome to the club! Running an email server on the internet comes
244+
with some responsibilities so you should understand how it works. See
246245
https://explained-from-first-principles.com/email/ for a thorough explanation.

config/config.go

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,12 @@ type Static struct {
7070

7171
// Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed.
7272
type Dynamic struct {
73-
Domains map[string]Domain `sconf-doc:"Domains for which email is accepted. For internationalized domains, use their IDNA names in UTF-8."`
74-
Accounts map[string]Account `sconf-doc:"Accounts to which email can be delivered. An account can accept email for multiple domains, for multiple localparts, and deliver to multiple mailboxes."`
75-
WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting or reverse-proxying HTTP(s). The first matching WebHandler will handle the request. Built-in handlers for autoconfig and mta-sts always run first. If no handler matches, the response status code is file not found (404). If functionality you need is missng, simply forward the requests to an application that can provide the needed functionality."`
73+
Domains map[string]Domain `sconf-doc:"Domains for which email is accepted. For internationalized domains, use their IDNA names in UTF-8."`
74+
Accounts map[string]Account `sconf-doc:"Accounts to which email can be delivered. An account can accept email for multiple domains, for multiple localparts, and deliver to multiple mailboxes."`
75+
WebDomainRedirects map[string]string `sconf:"optional" sconf-doc:"Redirect all requests from domain (key) to domain (value). Always redirects to HTTPS. For plain HTTP redirects, use a WebHandler with a WebRedirect."`
76+
WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting or reverse-proxying HTTP(s). The first matching WebHandler will handle the request. Built-in handlers for autoconfig and mta-sts always run first. If no handler matches, the response status code is file not found (404). If functionality you need is missng, simply forward the requests to an application that can provide the needed functionality."`
77+
78+
WebDNSDomainRedirects map[dns.Domain]dns.Domain `sconf:"-"`
7679
}
7780

7881
type ACME struct {
@@ -308,10 +311,9 @@ type TLS struct {
308311
}
309312

310313
type WebHandler struct {
311-
LogName string `sconf:"optional" sconf-doc:"Name to use in logging and metrics."`
312-
Domain string `sconf-doc:"Both Domain and PathRegexp must match for this WebHandler to match a request. Exactly one of WebStatic, WebRedirect, WebForward must be set."`
313-
PathRegexp string `sconf-doc:"Regular expression matched against request path, must always start with ^ to ensure matching from the start of the path. The matching prefix can optionally be stripped by WebForward. The regular expression does not have to end with $."`
314-
314+
LogName string `sconf:"optional" sconf-doc:"Name to use in logging and metrics."`
315+
Domain string `sconf-doc:"Both Domain and PathRegexp must match for this WebHandler to match a request. Exactly one of WebStatic, WebRedirect, WebForward must be set."`
316+
PathRegexp string `sconf-doc:"Regular expression matched against request path, must always start with ^ to ensure matching from the start of the path. The matching prefix can optionally be stripped by WebForward. The regular expression does not have to end with $."`
315317
DontRedirectPlainHTTP bool `sconf:"optional" sconf-doc:"If set, plain HTTP requests are not automatically permanently redirected (308) to HTTPS. If you don't have a HTTPS webserver configured, set this to true."`
316318
WebStatic *WebStatic `sconf:"optional" sconf-doc:"Serve static files."`
317319
WebRedirect *WebRedirect `sconf:"optional" sconf-doc:"Redirect requests to configured URL."`
@@ -322,6 +324,37 @@ type WebHandler struct {
322324
Path *regexp.Regexp `sconf:"-" json:"-"`
323325
}
324326

327+
// Equal returns if wh and o are equal, only looking at fields in the configuration file, not the derived fields.
328+
func (wh WebHandler) Equal(o WebHandler) bool {
329+
clean := func(x WebHandler) WebHandler {
330+
x.Name = ""
331+
x.DNSDomain = dns.Domain{}
332+
x.Path = nil
333+
x.WebStatic = nil
334+
x.WebRedirect = nil
335+
x.WebForward = nil
336+
return x
337+
}
338+
cwh := clean(wh)
339+
co := clean(o)
340+
if cwh != co {
341+
return false
342+
}
343+
if (wh.WebStatic == nil) != (o.WebStatic == nil) || (wh.WebRedirect == nil) != (o.WebRedirect == nil) || (wh.WebForward == nil) != (o.WebForward == nil) {
344+
return false
345+
}
346+
if wh.WebStatic != nil {
347+
return reflect.DeepEqual(wh.WebStatic, o.WebStatic)
348+
}
349+
if wh.WebRedirect != nil {
350+
return wh.WebRedirect.equal(*o.WebRedirect)
351+
}
352+
if wh.WebForward != nil {
353+
return wh.WebForward.equal(*o.WebForward)
354+
}
355+
return true
356+
}
357+
325358
type WebStatic struct {
326359
StripPrefix string `sconf:"optional" sconf-doc:"Path to strip from the request URL before evaluating to a local path. If the requested URL path does not start with this prefix and ContinueNotFound it is considered non-matching and next WebHandlers are tried. If ContinueNotFound is not set, a file not found (404) is returned in that case."`
327360
Root string `sconf-doc:"Directory to serve files from for this handler. Keep in mind that relative paths are relative to the working directory of mox."`
@@ -331,7 +364,7 @@ type WebStatic struct {
331364
}
332365

333366
type WebRedirect struct {
334-
BaseURL string `sconf:"optional" sconf-doc:"Base URL to redirect to. The path must be empty and will be replaced, either by the request URL path, or byOrigPathRegexp/ReplacePath. Scheme, host, port and fragment stay intact, and query strings are combined. If empty, the response redirects to a different path through OrigPathRegexp and ReplacePath, which must then be set. Use a URL without scheme to redirect without changing the protocol, e.g. //newdomain/."`
367+
BaseURL string `sconf:"optional" sconf-doc:"Base URL to redirect to. The path must be empty and will be replaced, either by the request URL path, or by OrigPathRegexp/ReplacePath. Scheme, host, port and fragment stay intact, and query strings are combined. If empty, the response redirects to a different path through OrigPathRegexp and ReplacePath, which must then be set. Use a URL without scheme to redirect without changing the protocol, e.g. //newdomain/."`
335368
OrigPathRegexp string `sconf:"optional" sconf-doc:"Regular expression for matching path. If set and path does not match, a 404 is returned. The HTTP path used for matching always starts with a slash."`
336369
ReplacePath string `sconf:"optional" sconf-doc:"Replacement path for destination URL based on OrigPathRegexp. Implemented with Go's Regexp.ReplaceAllString: $1 is replaced with the text of the first submatch, etc. If both OrigPathRegexp and ReplacePath are empty, BaseURL must be set and all paths are redirected unaltered."`
337370
StatusCode int `sconf:"optional" sconf-doc:"Status code to use in redirect, e.g. 307. By default, a permanent redirect (308) is returned."`
@@ -340,10 +373,24 @@ type WebRedirect struct {
340373
OrigPath *regexp.Regexp `sconf:"-" json:"-"`
341374
}
342375

376+
func (wr WebRedirect) equal(o WebRedirect) bool {
377+
wr.URL = nil
378+
wr.OrigPath = nil
379+
o.URL = nil
380+
o.OrigPath = nil
381+
return reflect.DeepEqual(wr, o)
382+
}
383+
343384
type WebForward struct {
344385
StripPath bool `sconf:"optional" sconf-doc:"Strip the matching WebHandler path from the WebHandler before forwarding the request."`
345386
URL string `sconf-doc:"URL to forward HTTP requests to, e.g. http://127.0.0.1:8123/base. If StripPath is false the full request path is added to the URL. Host headers are sent unmodified. New X-Forwarded-{For,Host,Proto} headers are set. Any query string in the URL is ignored. Requests are made using Go's net/http.DefaultTransport that takes environment variables HTTP_PROXY and HTTPS_PROXY into account."`
346387
ResponseHeaders map[string]string `sconf:"optional" sconf-doc:"Headers to add to the response. Useful for adding security- and cache-related headers."`
347388

348389
TargetURL *url.URL `sconf:"-" json:"-"`
349390
}
391+
392+
func (wf WebForward) equal(o WebForward) bool {
393+
wf.TargetURL = nil
394+
o.TargetURL = nil
395+
return reflect.DeepEqual(wf, o)
396+
}

config/doc.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,11 @@ describe-static" and "mox config describe-domains":
557557
# in calculating probability reduced. E.g. 1 or 2. (optional)
558558
RareWords: 0
559559
560+
# Redirect all requests from domain (key) to domain (value). Always redirects to
561+
# HTTPS. For plain HTTP redirects, use a WebHandler with a WebRedirect. (optional)
562+
WebDomainRedirects:
563+
x:
564+
560565
# Handle webserver requests by serving static files, redirecting or
561566
# reverse-proxying HTTP(s). The first matching WebHandler will handle the request.
562567
# Built-in handlers for autoconfig and mta-sts always run first. If no handler
@@ -622,7 +627,7 @@ describe-static" and "mox config describe-domains":
622627
WebRedirect:
623628
624629
# Base URL to redirect to. The path must be empty and will be replaced, either by
625-
# the request URL path, or byOrigPathRegexp/ReplacePath. Scheme, host, port and
630+
# the request URL path, or by OrigPathRegexp/ReplacePath. Scheme, host, port and
626631
# fragment stay intact, and query strings are combined. If empty, the response
627632
# redirects to a different path through OrigPathRegexp and ReplacePath, which must
628633
# then be set. Use a URL without scheme to redirect without changing the protocol,
@@ -670,14 +675,20 @@ examples with "mox examples", and print a specific example with "mox examples
670675
671676
# Example webhandlers
672677
673-
# Snippet of domains.conf to configure WebHandlers.
678+
# Snippet of domains.conf to configure WebDomainRedirects and WebHandlers.
679+
680+
# Redirect all requests for mox.example to https://www.mox.example.
681+
WebDomainRedirects:
682+
mox.example: www.mox.example
683+
684+
# Each request is matched against these handlers until one matches and serves it.
674685
WebHandlers:
675686
-
676687
# The name of the handler, used in logging and metrics.
677688
LogName: staticmjl
678689
# With ACME configured, each configured domain will automatically get a TLS
679690
# certificate on first request.
680-
Domain: mox.example
691+
Domain: www.mox.example
681692
PathRegexp: ^/who/mjl/
682693
WebStatic:
683694
StripPrefix: /who/mjl
@@ -690,7 +701,7 @@ examples with "mox examples", and print a specific example with "mox examples
690701
X-Mox: hi
691702
-
692703
LogName: redir
693-
Domain: mox.example
704+
Domain: www.mox.example
694705
PathRegexp: ^/redir/a/b/c
695706
# Don't redirect from plain HTTP to HTTPS.
696707
DontRedirectPlainHTTP: true
@@ -703,15 +714,15 @@ examples with "mox examples", and print a specific example with "mox examples
703714
StatusCode: 307
704715
-
705716
LogName: oldnew
706-
Domain: mox.example
717+
Domain: www.mox.example
707718
PathRegexp: ^/old/
708719
WebRedirect:
709720
# Replace path, leaving rest of URL intact.
710721
OrigPathRegexp: ^/old/(.*)
711722
ReplacePath: /new/$1
712723
-
713724
LogName: app
714-
Domain: mox.example
725+
Domain: www.mox.example
715726
PathRegexp: ^/app/
716727
WebForward:
717728
# Strip the path matched by PathRegexp before forwarding the request. So original

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ services:
2020
volumes:
2121
- ./config:/mox/config
2222
- ./data:/mox/data
23+
# web is optional but recommended to bind in, useful for serving static files with
24+
# the webserver.
25+
- ./web:/mox/web
2326
working_dir: /mox
2427
restart: on-failure
2528
healthcheck:

http/account.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter,
7676
}
7777
if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) {
7878
metrics.AuthenticationRatelimitedInc("httpaccount")
79-
http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests)
79+
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
8080
return ""
8181
}
8282

http/admin.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"net"
1717
"net/http"
1818
"os"
19+
"reflect"
1920
"runtime/debug"
2021
"sort"
2122
"strings"
@@ -31,6 +32,7 @@ import (
3132
"github.com/mjl-/sherpadoc"
3233
"github.com/mjl-/sherpaprom"
3334

35+
"github.com/mjl-/mox/config"
3436
"github.com/mjl-/mox/dkim"
3537
"github.com/mjl-/mox/dmarc"
3638
"github.com/mjl-/mox/dmarcdb"
@@ -136,7 +138,7 @@ func checkAdminAuth(ctx context.Context, passwordfile string, w http.ResponseWri
136138
}
137139
if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) {
138140
metrics.AuthenticationRatelimitedInc("httpadmin")
139-
http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests)
141+
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
140142
return false
141143
}
142144

@@ -1525,3 +1527,73 @@ func (Admin) LogLevelRemove(ctx context.Context, pkg string) {
15251527
func (Admin) CheckUpdatesEnabled(ctx context.Context) bool {
15261528
return mox.Conf.Static.CheckUpdates
15271529
}
1530+
1531+
// WebserverConfig is the combination of WebDomainRedirects and WebHandlers
1532+
// from the domains.conf configuration file.
1533+
type WebserverConfig struct {
1534+
WebDNSDomainRedirects [][2]dns.Domain // From server to frontend.
1535+
WebDomainRedirects [][2]string // From frontend to server, it's not convenient to create dns.Domain in the frontend.
1536+
WebHandlers []config.WebHandler
1537+
}
1538+
1539+
// WebserverConfig returns the current webserver config
1540+
func (Admin) WebserverConfig(ctx context.Context) (conf WebserverConfig) {
1541+
conf = webserverConfig()
1542+
conf.WebDomainRedirects = nil
1543+
return conf
1544+
}
1545+
1546+
func webserverConfig() WebserverConfig {
1547+
r, l := mox.Conf.WebServer()
1548+
x := make([][2]dns.Domain, 0, len(r))
1549+
xs := make([][2]string, 0, len(r))
1550+
for k, v := range r {
1551+
x = append(x, [2]dns.Domain{k, v})
1552+
xs = append(xs, [2]string{k.Name(), v.Name()})
1553+
}
1554+
sort.Slice(x, func(i, j int) bool {
1555+
return x[i][0].ASCII < x[j][0].ASCII
1556+
})
1557+
sort.Slice(xs, func(i, j int) bool {
1558+
return xs[i][0] < xs[j][0]
1559+
})
1560+
return WebserverConfig{x, xs, l}
1561+
}
1562+
1563+
// WebserverConfigSave saves a new webserver config. If oldConf is not equal to
1564+
// the current config, an error is returned.
1565+
func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf WebserverConfig) (savedConf WebserverConfig) {
1566+
current := webserverConfig()
1567+
webhandlersEqual := func() bool {
1568+
if len(current.WebHandlers) != len(oldConf.WebHandlers) {
1569+
return false
1570+
}
1571+
for i, wh := range current.WebHandlers {
1572+
if !wh.Equal(oldConf.WebHandlers[i]) {
1573+
return false
1574+
}
1575+
}
1576+
return true
1577+
}
1578+
if !reflect.DeepEqual(oldConf.WebDNSDomainRedirects, current.WebDNSDomainRedirects) || !webhandlersEqual() {
1579+
xcheckf(ctx, errors.New("config has changed"), "comparing old/current config")
1580+
}
1581+
1582+
// Convert to map, check that there are no duplicates here. The canonicalized
1583+
// dns.Domain are checked again for uniqueness when parsing the config before
1584+
// storing.
1585+
domainRedirects := map[string]string{}
1586+
for _, x := range newConf.WebDomainRedirects {
1587+
if _, ok := domainRedirects[x[0]]; ok {
1588+
xcheckf(ctx, errors.New("already present"), "checking redirect %s", x[0])
1589+
}
1590+
domainRedirects[x[0]] = x[1]
1591+
}
1592+
1593+
err := mox.WebserverConfigSet(ctx, domainRedirects, newConf.WebHandlers)
1594+
xcheckf(ctx, err, "saving webserver config")
1595+
1596+
savedConf = webserverConfig()
1597+
savedConf.WebDomainRedirects = nil
1598+
return savedConf
1599+
}

0 commit comments

Comments
 (0)