diff --git a/config/config.go b/config/config.go index 591649e..4a1f80c 100644 --- a/config/config.go +++ b/config/config.go @@ -123,19 +123,23 @@ type Listener struct { } `sconf:"optional" sconf-doc:"IMAP over TLS for reading email, by email applications. Requires a TLS config."` AccountHTTP struct { Enabled bool - Port int `sconf:"optional" sconf-doc:"Default 80."` + Port int `sconf:"optional" sconf-doc:"Default 80."` + Path string `sconf:"optional" sconf-doc:"Path to serve account requests on, e.g. /mox/. Useful if domain serves other resources. Default is /."` } `sconf:"optional" sconf-doc:"Account web interface, for email users wanting to change their accounts, e.g. set new password, set new delivery rulesets. Served at /."` AccountHTTPS struct { Enabled bool - Port int `sconf:"optional" sconf-doc:"Default 80."` + Port int `sconf:"optional" sconf-doc:"Default 80."` + Path string `sconf:"optional" sconf-doc:"Path to serve account requests on, e.g. /mox/. Useful if domain serves other resources. Default is /."` } `sconf:"optional" sconf-doc:"Account web interface listener for HTTPS. Requires a TLS config."` AdminHTTP struct { Enabled bool - Port int `sconf:"optional" sconf-doc:"Default 80."` + Port int `sconf:"optional" sconf-doc:"Default 80."` + Path string `sconf:"optional" sconf-doc:"Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other resources. Default is /admin/."` } `sconf:"optional" sconf-doc:"Admin web interface, for managing domains, accounts, etc. Served at /admin/. Preferrably only enable on non-public IPs. Hint: use 'ssh -L 8080:localhost:80 you@yourmachine' and open http://localhost:8080/admin/, or set up a tunnel (e.g. WireGuard) and add its IP to the mox 'internal' listener."` AdminHTTPS struct { Enabled bool - Port int `sconf:"optional" sconf-doc:"Default 443."` + Port int `sconf:"optional" sconf-doc:"Default 443."` + Path string `sconf:"optional" sconf-doc:"Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other resources. Default is /admin/."` } `sconf:"optional" sconf-doc:"Admin web interface listener for HTTPS. Requires a TLS config. Preferrably only enable on non-public IPs."` MetricsHTTP struct { Enabled bool diff --git a/config/doc.go b/config/doc.go index 9f8b7d5..d424b94 100644 --- a/config/doc.go +++ b/config/doc.go @@ -214,6 +214,10 @@ describe-static" and "mox config describe-domains": # Default 80. (optional) Port: 0 + # Path to serve account requests on, e.g. /mox/. Useful if domain serves other + # resources. Default is /. (optional) + Path: + # Account web interface listener for HTTPS. Requires a TLS config. (optional) AccountHTTPS: Enabled: false @@ -221,6 +225,10 @@ describe-static" and "mox config describe-domains": # Default 80. (optional) Port: 0 + # Path to serve account requests on, e.g. /mox/. Useful if domain serves other + # resources. Default is /. (optional) + Path: + # Admin web interface, for managing domains, accounts, etc. Served at /admin/. # Preferrably only enable on non-public IPs. Hint: use 'ssh -L 8080:localhost:80 # you@yourmachine' and open http://localhost:8080/admin/, or set up a tunnel (e.g. @@ -231,6 +239,10 @@ describe-static" and "mox config describe-domains": # Default 80. (optional) Port: 0 + # Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other + # resources. Default is /admin/. (optional) + Path: + # Admin web interface listener for HTTPS. Requires a TLS config. Preferrably only # enable on non-public IPs. (optional) AdminHTTPS: @@ -239,6 +251,10 @@ describe-static" and "mox config describe-domains": # Default 443. (optional) Port: 0 + # Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other + # resources. Default is /admin/. (optional) + Path: + # Serve prometheus metrics, for monitoring. You should not enable this on a public # IP. (optional) MetricsHTTP: diff --git a/http/account.go b/http/account.go index 5fcf2f5..f26b741 100644 --- a/http/account.go +++ b/http/account.go @@ -52,7 +52,7 @@ func init() { } // Account exports web API functions for the account web interface. All its -// methods are exported under /api/. Function calls require valid HTTP +// methods are exported under api/. Function calls require valid HTTP // Authentication credentials of a user. type Account struct{} diff --git a/http/accountapi.json b/http/accountapi.json index 93858b5..50f4db4 100644 --- a/http/accountapi.json +++ b/http/accountapi.json @@ -1,6 +1,6 @@ { "Name": "Account", - "Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under /api/. Function calls require valid HTTP\nAuthentication credentials of a user.", + "Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under api/. Function calls require valid HTTP\nAuthentication credentials of a user.", "Functions": [ { "Name": "SetPassword", diff --git a/http/admin.go b/http/admin.go index 320773f..9fde851 100644 --- a/http/admin.go +++ b/http/admin.go @@ -77,14 +77,14 @@ func init() { xlog.Fatalx("creating sherpa prometheus collector", err) } - adminSherpaHandler, err = sherpa.NewHandler("/admin/api/", moxvar.Version, Admin{}, &adminDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}) + adminSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Admin{}, &adminDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}) if err != nil { xlog.Fatalx("sherpa handler", err) } } // Admin exports web API functions for the admin web interface. All its methods are -// exported under /admin/api/. Function calls require valid HTTP Authentication +// exported under api/. Function calls require valid HTTP Authentication // credentials of a user. type Admin struct{} @@ -183,7 +183,7 @@ func adminHandle(w http.ResponseWriter, r *http.Request) { return } - if r.Method == "GET" && r.URL.Path == "/admin/" { + if r.Method == "GET" && r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache; max-age=0") // We typically return the embedded admin.html, but during development it's handy diff --git a/http/adminapi.json b/http/adminapi.json index 4b1c85e..3edce5b 100644 --- a/http/adminapi.json +++ b/http/adminapi.json @@ -1,6 +1,6 @@ { "Name": "Admin", - "Docs": "Admin exports web API functions for the admin web interface. All its methods are\nexported under /admin/api/. Function calls require valid HTTP Authentication\ncredentials of a user.", + "Docs": "Admin exports web API functions for the admin web interface. All its methods are\nexported under api/. Function calls require valid HTTP Authentication\ncredentials of a user.", "Functions": [ { "Name": "CheckDomain", diff --git a/http/web.go b/http/web.go index 4b98b79..e7511ac 100644 --- a/http/web.go +++ b/http/web.go @@ -170,15 +170,15 @@ func (w *loggingWriter) Done() { } // Set some http headers that should prevent potential abuse. Better safe than sorry. -func safeHeaders(fn http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func safeHeaders(fn http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Frame-Options", "deny") h.Set("X-Content-Type-Options", "nosniff") h.Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline' data:") h.Set("Referrer-Policy", "same-origin") - fn(w, r) - } + fn.ServeHTTP(w, r) + }) } // Built-in handlers, e.g. mta-sts and autoconfig. @@ -186,7 +186,7 @@ type pathHandler struct { Name string // For logging/metrics. HostMatch func(dom dns.Domain) bool // If not nil, called to see if domain of requests matches. Only called if requested host is a valid domain. Path string // Path to register, like on http.ServeMux. - Handle http.HandlerFunc + Handler http.Handler } type serve struct { Kinds []string // Type of handler and protocol (e.g. acme-tls-alpn-01, account-http, admin-https). @@ -195,9 +195,10 @@ type serve struct { Webserver bool // Whether serving WebHandler. PathHandlers are always evaluated before WebHandlers. } -// HandleFunc registers a named handler for a path and optional host. If path ends with a slash, it -// is used as prefix match, otherwise a full path match is required. If hostOpt is set, only requests to those host are handled by this handler. -func (s *serve) HandleFunc(name string, hostMatch func(dns.Domain) bool, path string, fn http.HandlerFunc) { +// Handle registers a named handler for a path and optional host. If path ends with +// a slash, it is used as prefix match, otherwise a full path match is required. If +// hostOpt is set, only requests to those host are handled by this handler. +func (s *serve) Handle(name string, hostMatch func(dns.Domain) bool, path string, fn http.Handler) { s.PathHandlers = append(s.PathHandlers, pathHandler{name, hostMatch, path, fn}) } @@ -278,7 +279,7 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) { } if r.URL.Path == h.Path || strings.HasSuffix(h.Path, "/") && strings.HasPrefix(r.URL.Path, h.Path) { nw.Handler = h.Name - h.Handle(nw, r) + h.Handler.ServeHTTP(nw, r) return } } @@ -325,35 +326,49 @@ func Listen() { if l.AccountHTTP.Enabled { port := config.Port(l.AccountHTTP.Port, 80) srv := ensureServe(false, port, "account-http") - srv.HandleFunc("account", nil, "/", safeHeaders(accountHandle)) + path := "/" + if l.AccountHTTP.Path != "" { + path = l.AccountHTTP.Path + } + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(accountHandle))) + srv.Handle("account", nil, path, handler) } if l.AccountHTTPS.Enabled { port := config.Port(l.AccountHTTPS.Port, 443) srv := ensureServe(true, port, "account-https") - srv.HandleFunc("account", nil, "/", safeHeaders(accountHandle)) + path := "/" + if l.AccountHTTPS.Path != "" { + path = l.AccountHTTPS.Path + } + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(accountHandle))) + srv.Handle("account", nil, path, handler) } if l.AdminHTTP.Enabled { port := config.Port(l.AdminHTTP.Port, 80) srv := ensureServe(false, port, "admin-http") - if !l.AccountHTTP.Enabled { - srv.HandleFunc("admin", nil, "/", safeHeaders(adminIndex)) + path := "/admin/" + if l.AdminHTTP.Path != "" { + path = l.AdminHTTP.Path } - srv.HandleFunc("admin", nil, "/admin/", safeHeaders(adminHandle)) + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(adminHandle))) + srv.Handle("admin", nil, path, handler) } if l.AdminHTTPS.Enabled { port := config.Port(l.AdminHTTPS.Port, 443) srv := ensureServe(true, port, "admin-https") - if !l.AccountHTTPS.Enabled { - srv.HandleFunc("admin", nil, "/", safeHeaders(adminIndex)) + path := "/admin/" + if l.AdminHTTPS.Path != "" { + path = l.AdminHTTPS.Path } - srv.HandleFunc("admin", nil, "/admin/", safeHeaders(adminHandle)) + handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(adminHandle))) + srv.Handle("admin", nil, path, handler) } if l.MetricsHTTP.Enabled { port := config.Port(l.MetricsHTTP.Port, 8010) srv := ensureServe(false, port, "metrics-http") - srv.HandleFunc("metrics", nil, "/metrics", safeHeaders(promhttp.Handler().ServeHTTP)) - srv.HandleFunc("metrics", nil, "/", safeHeaders(func(w http.ResponseWriter, r *http.Request) { + srv.Handle("metrics", nil, "/metrics", safeHeaders(promhttp.Handler())) + srv.Handle("metrics", nil, "/", safeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return @@ -363,7 +378,7 @@ func Listen() { } w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, `
see /metrics`) - })) + }))) } if l.AutoconfigHTTPS.Enabled { port := config.Port(l.AutoconfigHTTPS.Port, 443) @@ -372,8 +387,8 @@ func Listen() { // todo: may want to check this against the configured domains, could in theory be just a webserver. return strings.HasPrefix(dom.ASCII, "autoconfig.") } - srv.HandleFunc("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", safeHeaders(autoconfHandle)) - srv.HandleFunc("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", safeHeaders(autodiscoverHandle)) + srv.Handle("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", safeHeaders(http.HandlerFunc(autoconfHandle))) + srv.Handle("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", safeHeaders(http.HandlerFunc(autodiscoverHandle))) } if l.MTASTSHTTPS.Enabled { port := config.Port(l.MTASTSHTTPS.Port, 443) @@ -382,7 +397,7 @@ func Listen() { // todo: may want to check this against the configured domains, could in theory be just a webserver. return strings.HasPrefix(dom.ASCII, "mta-sts.") } - srv.HandleFunc("mtasts", mtastsMatch, "/.well-known/mta-sts.txt", safeHeaders(mtastsPolicyHandle)) + srv.Handle("mtasts", mtastsMatch, "/.well-known/mta-sts.txt", safeHeaders(http.HandlerFunc(mtastsPolicyHandle))) } if l.PprofHTTP.Enabled { // Importing net/http/pprof registers handlers on the default serve mux. @@ -392,7 +407,7 @@ func Listen() { } srv := &serve{[]string{"pprof-http"}, nil, nil, false} portServe[port] = srv - srv.HandleFunc("pprof", nil, "/", http.DefaultServeMux.ServeHTTP) + srv.Handle("pprof", nil, "/", http.DefaultServeMux) } if l.WebserverHTTP.Enabled { port := config.Port(l.WebserverHTTP.Port, 80) @@ -412,7 +427,7 @@ func Listen() { // validation handler. if srv, ok := portServe[80]; ok && srv.TLSConfig == nil { srv.Kinds = append(srv.Kinds, "acme-http-01") - srv.HandleFunc("acme-http-01", nil, "/.well-known/acme-challenge/", m.Manager.HTTPHandler(nil).ServeHTTP) + srv.Handle("acme-http-01", nil, "/.well-known/acme-challenge/", m.Manager.HTTPHandler(nil)) } hosts := map[dns.Domain]struct{}{ @@ -452,19 +467,6 @@ func Listen() { } } -// Only used when the account page is not active on the same listener. -func adminIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - if r.Method != "GET" { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - http.Redirect(w, r, "/admin/", http.StatusSeeOther) -} - // functions to be launched in goroutine that will serve on a listener. var servers []func() diff --git a/http/web_test.go b/http/web_test.go index 99e0959..41266b5 100644 --- a/http/web_test.go +++ b/http/web_test.go @@ -26,9 +26,9 @@ func TestServeHTTP(t *testing.T) { return strings.HasPrefix(dom.ASCII, "mta-sts.") }, Path: "/.well-known/mta-sts.txt", - Handle: func(w http.ResponseWriter, r *http.Request) { + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("mta-sts!")) - }, + }), }, }, Webserver: true, diff --git a/localserve.go b/localserve.go index 6e21d23..3ba5fa3 100644 --- a/localserve.go +++ b/localserve.go @@ -129,15 +129,15 @@ during those commands instead of during "data". golog.Print("") golog.Printf(`if the localpart begins with "mailfrom" or "rcptto", the error is returned during those commands instead of during "data"`) golog.Print("") - golog.Print(" smtp://localhost:1025 - receive email") - golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email") - golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)") - golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email") - golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)") - golog.Print("https://mox%40localhost:moxmoxmox@localhost:1443 - account https") - golog.Print(" http://mox%40localhost:moxmoxmox@localhost:1080 - account http (without tls)") - golog.Print("https://admin:moxadmin@localhost:1443/admin/ - admin https") - golog.Print(" http://admin:moxadmin@localhost:1080/admin/ - admin http (without tls)") + golog.Print(" smtp://localhost:1025 - receive email") + golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email") + golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)") + golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email") + golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)") + golog.Print("https://mox%40localhost:moxmoxmox@localhost:1443/account/ - account https") + golog.Print(" http://mox%40localhost:moxmoxmox@localhost:1080/account/ - account http (without tls)") + golog.Print("https://admin:moxadmin@localhost:1443/admin/ - admin https") + golog.Print(" http://admin:moxadmin@localhost:1080/admin/ - admin http (without tls)") golog.Print("") golog.Printf("serving from %s", dir) @@ -275,8 +275,10 @@ func writeLocalConfig(log *mlog.Log, dir string) (rerr error) { local.IMAPS.Port = 1993 local.AccountHTTP.Enabled = true local.AccountHTTP.Port = 1080 + local.AccountHTTP.Path = "/account/" local.AccountHTTPS.Enabled = true local.AccountHTTPS.Port = 1443 + local.AccountHTTPS.Path = "/account/" local.AdminHTTP.Enabled = true local.AdminHTTP.Port = 1080 local.AdminHTTPS.Enabled = true diff --git a/mox-/config.go b/mox-/config.go index d4dd7c7..3eb826b 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -604,6 +604,15 @@ func PrepareStaticConfig(ctx context.Context, configFile string, config *Config, } l.SMTP.DNSBLZones = append(l.SMTP.DNSBLZones, d) } + checkPath := func(kind string, enabled bool, path string) { + if enabled && path != "" && !strings.HasPrefix(path, "/") { + addErrorf("listener %q has %s with path %q that must start with a slash", name, kind, path) + } + } + checkPath("AccountHTTP", l.AccountHTTP.Enabled, l.AccountHTTP.Path) + checkPath("AccountHTTPS", l.AccountHTTPS.Enabled, l.AccountHTTPS.Path) + checkPath("AdminHTTP", l.AdminHTTP.Enabled, l.AdminHTTP.Path) + checkPath("AdminHTTPS", l.AdminHTTPS.Enabled, l.AdminHTTPS.Path) c.Listeners[name] = l } if haveUnspecifiedSMTPListener {