From 614576e409a6e2f115bc6c11bd03a50b9b45ddec Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 11 May 2024 11:13:14 +0200 Subject: [PATCH] improve http request handling for internal services and multiple domains per listener, you could enable the admin/account/webmail/webapi handlers. but that would serve those services on their configured paths (/admin/, /, /webmail/, /webapi/) on all domains mox would be webserving, including any non-mail domains. so your www.example/admin/ would be serving the admin web interface, with no way to disabled that. with this change, the admin interface is only served on requests to (based on Host header): - ip addresses - the listener host name (explicitly configured in the listener, with fallback to global hostname) - "localhost" (for ssh tunnel/forwarding scenario's) the account/webmail/webapi interfaces are served on the same domains as the admin interface, and additionally: - the client settings domains, as optionally configured in each Domain in domains.conf. typically "mail.". this means the internal services are no longer served on other domains configured in the webserver, e.g. www.example.org/admin/ will not be handled specially. the order of evaluation of routes/services is also changed: before this change, the internal handlers would always be evaluated first. with this change, only the system handlers for MTA-STS/autoconfig/ACME-validation will be evaluated first. then the webserver handlers. and finally the internal services (admin/account/webmail/webapi). this allows an admin to configure overrides for some of the domains (per hostname-matching rules explained above) that would normally serve these services. webserver handlers can now be configured that pass the request to an internal service: in addition to the existing static/redirect/forward config options, there is now an "internal" config option, naming the service (admin/account/webmail/webapi) for handling the request. this allows enabling the internal services on custom domains. for issue #160 by TragicLifeHu, thanks for reporting! --- config/config.go | 32 +- config/doc.go | 55 ++- http/web.go | 604 ++++++++++++++++++-------------- http/web_test.go | 46 +-- http/webserver.go | 16 +- http/webserver_test.go | 5 +- mox-/config.go | 42 +++ mox-/safeheaders.go | 17 + testdata/web/domains.conf | 13 + testdata/web/mox.conf | 8 + testdata/webserver/domains.conf | 32 ++ testdata/webserver/mox.conf | 2 + webaccount/account.go | 4 + webadmin/admin.go | 4 + webadmin/admin.js | 44 ++- webadmin/admin.ts | 74 +++- webadmin/api.json | 28 ++ webadmin/api.ts | 12 +- webapisrv/server.go | 4 + webmail/webmail.go | 6 + 20 files changed, 722 insertions(+), 326 deletions(-) create mode 100644 mox-/safeheaders.go diff --git a/config/config.go b/config/config.go index 35a7f6dc91..0d2d982270 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "crypto/x509" "net" + "net/http" "net/url" "reflect" "regexp" @@ -109,12 +110,13 @@ type Dynamic struct { Domains map[string]Domain `sconf-doc:"NOTE: This config file is in 'sconf' format. Indent with tabs. Comments must be on their own line, they don't end a line. Do not escape or quote strings. Details: https://pkg.go.dev/github.com/mjl-/sconf.\n\n\nDomains for which email is accepted. For internationalized domains, use their IDNA names in UTF-8."` Accounts map[string]Account `sconf-doc:"Accounts represent mox users, each with a password and email address(es) to which email can be delivered (possibly at different domains). Each account has its own on-disk directory holding its messages and index database. An account name is not an email address."` 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."` - 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, e.g. for account, admin, 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."` + WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting, reverse-proxying HTTP(s) or passing the request to an internal service. The first matching WebHandler will handle the request. Built-in system handlers, e.g. for ACME validation, autoconfig and mta-sts always run first. Built-in handlers for admin, account, webmail and webapi are evaluated after all handlers, including webhandlers (allowing for overrides of internal services for some domains). If no handler matches, the response status code is file not found (404). If webserver features are missing, forward the requests to an application that provides the needed functionality itself."` Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, domain routes and finally these global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."` MonitorDNSBLs []string `sconf:"optional" sconf-doc:"DNS blocklists to periodically check with if IPs we send from are present, without using them for checking incoming deliveries.. Also see DNSBLs in SMTP listeners in mox.conf, which specifies DNSBLs to use both for incoming deliveries and for checking our IPs against. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net."` WebDNSDomainRedirects map[dns.Domain]dns.Domain `sconf:"-" json:"-"` MonitorDNSBLZones []dns.Domain `sconf:"-"` + ClientSettingDomains map[dns.Domain]struct{} `sconf:"-" json:"-"` } type ACME struct { @@ -138,7 +140,7 @@ type Listener struct { IPs []string `sconf-doc:"Use 0.0.0.0 to listen on all IPv4 and/or :: to listen on all IPv6 addresses, but it is better to explicitly specify the IPs you want to use for email, as mox will make sure outgoing connections will only be made from one of those IPs. If both outgoing IPv4 and IPv6 connectivity is possible, and only one family has explicitly configured addresses, both address families are still used for outgoing connections. Use the \"direct\" transport to limit address families for outgoing connections."` NATIPs []string `sconf:"optional" sconf-doc:"If set, the mail server is configured behind a NAT and field IPs are internal instead of the public IPs, while NATIPs lists the public IPs. Used during IP-related DNS self-checks, such as for iprev, mx, spf, autoconfig, autodiscover, and for autotls."` IPsNATed bool `sconf:"optional" sconf-doc:"Deprecated, use NATIPs instead. If set, IPs are not the public IPs, but are NATed. Skips IP-related DNS self-checks."` - Hostname string `sconf:"optional" sconf-doc:"If empty, the config global Hostname is used."` + Hostname string `sconf:"optional" sconf-doc:"If empty, the config global Hostname is used. The internal services webadmin, webaccount, webmail and webapi only match requests to IPs, this hostname, \"localhost\". All except webadmin also match for any client settings domain."` HostnameDomain dns.Domain `sconf:"-" json:"-"` // Set when parsing config. TLS *TLS `sconf:"optional" sconf-doc:"For SMTP/IMAP STARTTLS, direct TLS and HTTPS connections."` @@ -215,7 +217,7 @@ type Listener struct { // WebService is an internal web interface: webmail, webaccount, webadmin, webapi. type WebService struct { Enabled bool - Port int `sconf:"optional" sconf-doc:"Default 80 for HTTP and 443 for HTTPS."` + Port int `sconf:"optional" sconf-doc:"Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname matching behaviour."` Path string `sconf:"optional" sconf-doc:"Path to serve requests on."` Forwarded bool `sconf:"optional" sconf-doc:"If set, X-Forwarded-* headers are used for the remote IP address for rate limiting and for the \"secure\" status of cookies."` } @@ -521,15 +523,18 @@ type TLS struct { HostPrivateECDSAP256Keys []crypto.Signer `sconf:"-" json:"-"` } +// todo: we could implement matching WebHandler.Domain as IPs too + type WebHandler struct { LogName string `sconf:"optional" sconf-doc:"Name to use in logging and metrics."` - 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."` + Domain string `sconf-doc:"Both Domain and PathRegexp must match for this WebHandler to match a request. Exactly one of WebStatic, WebRedirect, WebForward, WebInternal must be set."` 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 $."` 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."` Compress bool `sconf:"optional" sconf-doc:"Transparently compress responses (currently with gzip) if the client supports it, the status is 200 OK, no Content-Encoding is set on the response yet and the Content-Type of the response hints that the data is compressible (text/..., specific application/... and .../...+json and .../...+xml). For static files only, a cache with compressed files is kept."` WebStatic *WebStatic `sconf:"optional" sconf-doc:"Serve static files."` WebRedirect *WebRedirect `sconf:"optional" sconf-doc:"Redirect requests to configured URL."` WebForward *WebForward `sconf:"optional" sconf-doc:"Forward requests to another webserver, i.e. reverse proxy."` + WebInternal *WebInternal `sconf:"optional" sconf-doc:"Pass request to internal service, like webmail, webapi, etc."` Name string `sconf:"-"` // Either LogName, or numeric index if LogName was empty. Used instead of LogName in logging/metrics. DNSDomain dns.Domain `sconf:"-"` @@ -545,6 +550,7 @@ func (wh WebHandler) Equal(o WebHandler) bool { x.WebStatic = nil x.WebRedirect = nil x.WebForward = nil + x.WebInternal = nil return x } cwh := clean(wh) @@ -552,7 +558,7 @@ func (wh WebHandler) Equal(o WebHandler) bool { if cwh != co { return false } - if (wh.WebStatic == nil) != (o.WebStatic == nil) || (wh.WebRedirect == nil) != (o.WebRedirect == nil) || (wh.WebForward == nil) != (o.WebForward == nil) { + if (wh.WebStatic == nil) != (o.WebStatic == nil) || (wh.WebRedirect == nil) != (o.WebRedirect == nil) || (wh.WebForward == nil) != (o.WebForward == nil) || (wh.WebInternal == nil) != (o.WebInternal == nil) { return false } if wh.WebStatic != nil { @@ -564,6 +570,9 @@ func (wh WebHandler) Equal(o WebHandler) bool { if wh.WebForward != nil { return wh.WebForward.equal(*o.WebForward) } + if wh.WebInternal != nil { + return wh.WebInternal.equal(*o.WebInternal) + } return true } @@ -606,3 +615,16 @@ func (wf WebForward) equal(o WebForward) bool { o.TargetURL = nil return reflect.DeepEqual(wf, o) } + +type WebInternal struct { + BasePath string `sconf-doc:"Path to use as root of internal service, e.g. /webmail/."` + Service string `sconf-doc:"Name of the service, values: admin, account, webmail, webapi."` + + Handler http.Handler `sconf:"-" json:"-"` +} + +func (wi WebInternal) equal(o WebInternal) bool { + wi.Handler = nil + o.Handler = nil + return reflect.DeepEqual(wi, o) +} diff --git a/config/doc.go b/config/doc.go index fdb31fe3f4..ca7cbb2409 100644 --- a/config/doc.go +++ b/config/doc.go @@ -172,7 +172,10 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. # NATed. Skips IP-related DNS self-checks. (optional) IPsNATed: false - # If empty, the config global Hostname is used. (optional) + # If empty, the config global Hostname is used. The internal services webadmin, + # webaccount, webmail and webapi only match requests to IPs, this hostname, + # "localhost". All except webadmin also match for any client settings domain. + # (optional) Hostname: # For SMTP/IMAP STARTTLS, direct TLS and HTTPS connections. (optional) @@ -303,7 +306,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. AccountHTTP: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -318,7 +322,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. AccountHTTPS: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -336,7 +341,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. AdminHTTP: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -351,7 +357,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. AdminHTTPS: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -365,7 +372,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. WebmailHTTP: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -380,7 +388,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. WebmailHTTPS: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -394,7 +403,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. WebAPIHTTP: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -409,7 +419,8 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. WebAPIHTTPS: Enabled: false - # Default 80 for HTTP and 443 for HTTPS. (optional) + # Default 80 for HTTP and 443 for HTTPS. See Hostname at Listener for hostname + # matching behaviour. (optional) Port: 0 # Path to serve requests on. (optional) @@ -1225,12 +1236,15 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. WebDomainRedirects: x: - # Handle webserver requests by serving static files, redirecting or - # reverse-proxying HTTP(s). The first matching WebHandler will handle the request. - # Built-in handlers, e.g. for account, admin, 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. (optional) + # Handle webserver requests by serving static files, redirecting, reverse-proxying + # HTTP(s) or passing the request to an internal service. The first matching + # WebHandler will handle the request. Built-in system handlers, e.g. for ACME + # validation, autoconfig and mta-sts always run first. Built-in handlers for + # admin, account, webmail and webapi are evaluated after all handlers, including + # webhandlers (allowing for overrides of internal services for some domains). If + # no handler matches, the response status code is file not found (404). If + # webserver features are missing, forward the requests to an application that + # provides the needed functionality itself. (optional) WebHandlers: - @@ -1238,7 +1252,7 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. LogName: # Both Domain and PathRegexp must match for this WebHandler to match a request. - # Exactly one of WebStatic, WebRedirect, WebForward must be set. + # Exactly one of WebStatic, WebRedirect, WebForward, WebInternal must be set. Domain: # Regular expression matched against request path, must always start with ^ to @@ -1345,6 +1359,15 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. ResponseHeaders: x: + # Pass request to internal service, like webmail, webapi, etc. (optional) + WebInternal: + + # Path to use as root of internal service, e.g. /webmail/. + BasePath: + + # Name of the service, values: admin, account, webmail, webapi. + Service: + # Routes for delivering outgoing messages through the queue. Each delivery attempt # evaluates account routes, domain routes and finally these global routes. The # transport of the first matching route is used in the delivery attempt. If no diff --git a/http/web.go b/http/web.go index ced1f3aa3d..d660e088fa 100644 --- a/http/web.go +++ b/http/web.go @@ -351,37 +351,40 @@ func (w *loggingWriter) Done() { pkglog.WithContext(w.R.Context()).Debugx("http request", err, attrs...) } -// Set some http headers that should prevent potential abuse. Better safe than sorry. -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.ServeHTTP(w, r) - }) -} - // Built-in handlers, e.g. mta-sts and autoconfig. 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. + Name string // For logging/metrics. + HostMatch func(host dns.IPDomain) bool // If not nil, called to see if domain of requests matches. Host can be zero value for invalid domain/ip. + Path string // Path to register, like on http.ServeMux. Handler http.Handler } type serve struct { - Kinds []string // Type of handler and protocol (e.g. acme-tls-alpn-01, account-http, admin-https). - TLSConfig *tls.Config - PathHandlers []pathHandler // Sorted, longest first. - Webserver bool // Whether serving WebHandler. PathHandlers are always evaluated before WebHandlers. + Kinds []string // Type of handler and protocol (e.g. acme-tls-alpn-01, account-http, admin-https). + TLSConfig *tls.Config + + // SystemHandlers are for MTA-STS, autoconfig, ACME validation. They can't be + // overridden by WebHandlers. WebHandlers are evaluated next, and the internal + // service handlers from Listeners in mox.conf (for admin, account, webmail, webapi + // interfaces) last. WebHandlers can also pass requests to the internal servers. + // This order allows admins to serve other content on domains serving the mox.conf + // internal services. + SystemHandlers []pathHandler // Sorted, longest first. + Webserver bool + ServiceHandlers []pathHandler // Sorted, longest first. } -// 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}) +// SystemHandle registers a named system 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) SystemHandle(name string, hostMatch func(dns.IPDomain) bool, path string, fn http.Handler) { + s.SystemHandlers = append(s.SystemHandlers, pathHandler{name, hostMatch, path, fn}) +} + +// Like SystemHandle, but for internal services "admin", "account", "webmail", +// "webapi" configured in the mox.conf Listener. +func (s *serve) ServiceHandle(name string, hostMatch func(dns.IPDomain) bool, path string, fn http.Handler) { + s.ServiceHandlers = append(s.ServiceHandlers, pathHandler{name, hostMatch, path, fn}) } var ( @@ -452,28 +455,44 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) { r.URL.Path += "/" } - var dom dns.Domain host := r.Host nhost, _, err := net.SplitHostPort(host) if err == nil { host = nhost } - // host could be an IP, some handles may match, not an error. - dom, domErr := dns.ParseDomain(host) + ipdom := dns.IPDomain{IP: net.ParseIP(host)} + if ipdom.IP == nil { + dom, domErr := dns.ParseDomain(host) + if domErr == nil { + ipdom = dns.IPDomain{Domain: dom} + } + } - for _, h := range s.PathHandlers { - if h.HostMatch != nil && (domErr != nil || !h.HostMatch(dom)) { - continue + handle := func(h pathHandler) bool { + if h.HostMatch != nil && !h.HostMatch(ipdom) { + return false } if r.URL.Path == h.Path || strings.HasSuffix(h.Path, "/") && strings.HasPrefix(r.URL.Path, h.Path) { nw.Handler = h.Name nw.Compress = true h.Handler.ServeHTTP(nw, r) + return true + } + return false + } + + for _, h := range s.SystemHandlers { + if handle(h) { + return + } + } + if s.Webserver { + if WebHandle(nw, r, ipdom) { return } } - if s.Webserver && domErr == nil { - if WebHandle(nw, r, dom) { + for _, h := range s.ServiceHandlers { + if handle(h) { return } } @@ -481,289 +500,330 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) { http.NotFound(nw, r) } +func redirectToTrailingSlash(srv *serve, hostMatch func(dns.IPDomain) bool, name, path string) { + // Helpfully redirect user to version with ending slash. + if path != "/" && strings.HasSuffix(path, "/") { + handler := mox.SafeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, path, http.StatusSeeOther) + })) + srv.ServiceHandle(name, hostMatch, path[:len(path)-1], handler) + } +} + // Listen binds to sockets for HTTP listeners, including those required for ACME to // generate TLS certificates. It stores the listeners so Serve can start serving them. func Listen() { - redirectToTrailingSlash := func(srv *serve, name, path string) { - // Helpfully redirect user to version with ending slash. - if path != "/" && strings.HasSuffix(path, "/") { - handler := safeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, path, http.StatusSeeOther) - })) - srv.Handle(name, nil, path[:len(path)-1], handler) - } - } - // Initialize listeners in deterministic order for the same potential error // messages. names := maps.Keys(mox.Conf.Static.Listeners) sort.Strings(names) for _, name := range names { l := mox.Conf.Static.Listeners[name] + portServe := portServes(l) - portServe := map[int]*serve{} - - var ensureServe func(https bool, port int, kind string) *serve - ensureServe = func(https bool, port int, kind string) *serve { - s := portServe[port] - if s == nil { - s = &serve{nil, nil, nil, false} - portServe[port] = s - } - s.Kinds = append(s.Kinds, kind) - if https && l.TLS.ACME != "" { - s.TLSConfig = l.TLS.ACMEConfig - } else if https { - s.TLSConfig = l.TLS.Config - if l.TLS.ACME != "" { - tlsport := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443) - ensureServe(true, tlsport, "acme-tls-alpn-01") - } + ports := maps.Keys(portServe) + sort.Ints(ports) + for _, port := range ports { + srv := portServe[port] + for _, ip := range l.IPs { + listen1(ip, port, srv.TLSConfig, name, srv.Kinds, srv) } - return s } + } +} + +func portServes(l config.Listener) map[int]*serve { + portServe := map[int]*serve{} + + // For system/services, we serve on host localhost too, for ssh tunnel scenario's. + localhost := dns.Domain{ASCII: "localhost"} - if l.TLS != nil && l.TLS.ACME != "" && (l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.IMAPS.Enabled) { - port := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443) - ensureServe(true, port, "acme-tls-alpn-01") + ldom := l.HostnameDomain + if l.Hostname == "" { + ldom = mox.Conf.Static.HostnameDomain + } + listenerHostMatch := func(host dns.IPDomain) bool { + if host.IsIP() { + return true } + return host.Domain == ldom || host.Domain == localhost + } + accountHostMatch := func(host dns.IPDomain) bool { + if listenerHostMatch(host) { + return true + } + return mox.Conf.IsClientSettingsDomain(host.Domain) + } - if l.AccountHTTP.Enabled { - port := config.Port(l.AccountHTTP.Port, 80) - path := "/" - if l.AccountHTTP.Path != "" { - path = l.AccountHTTP.Path - } - srv := ensureServe(false, port, "account-http at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTP.Forwarded)))) - srv.Handle("account", nil, path, handler) - redirectToTrailingSlash(srv, "account", path) + var ensureServe func(https bool, port int, kind string) *serve + ensureServe = func(https bool, port int, kind string) *serve { + s := portServe[port] + if s == nil { + s = &serve{nil, nil, nil, false, nil} + portServe[port] = s } - if l.AccountHTTPS.Enabled { - port := config.Port(l.AccountHTTPS.Port, 443) - path := "/" - if l.AccountHTTPS.Path != "" { - path = l.AccountHTTPS.Path + s.Kinds = append(s.Kinds, kind) + if https && l.TLS.ACME != "" { + s.TLSConfig = l.TLS.ACMEConfig + } else if https { + s.TLSConfig = l.TLS.Config + if l.TLS.ACME != "" { + tlsport := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443) + ensureServe(true, tlsport, "acme-tls-alpn-01") } - srv := ensureServe(true, port, "account-https at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTPS.Forwarded)))) - srv.Handle("account", nil, path, handler) - redirectToTrailingSlash(srv, "account", path) } + return s + } - if l.AdminHTTP.Enabled { - port := config.Port(l.AdminHTTP.Port, 80) - path := "/admin/" - if l.AdminHTTP.Path != "" { - path = l.AdminHTTP.Path - } - srv := ensureServe(false, port, "admin-http at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTP.Forwarded)))) - srv.Handle("admin", nil, path, handler) - redirectToTrailingSlash(srv, "admin", path) - } - if l.AdminHTTPS.Enabled { - port := config.Port(l.AdminHTTPS.Port, 443) - path := "/admin/" - if l.AdminHTTPS.Path != "" { - path = l.AdminHTTPS.Path - } - srv := ensureServe(true, port, "admin-https at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTPS.Forwarded)))) - srv.Handle("admin", nil, path, handler) - redirectToTrailingSlash(srv, "admin", path) + if l.TLS != nil && l.TLS.ACME != "" && (l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.IMAPS.Enabled) { + port := config.Port(mox.Conf.Static.ACME[l.TLS.ACME].Port, 443) + ensureServe(true, port, "acme-tls-alpn-01") + } + + if l.AccountHTTP.Enabled { + port := config.Port(l.AccountHTTP.Port, 80) + path := "/" + if l.AccountHTTP.Path != "" { + path = l.AccountHTTP.Path + } + srv := ensureServe(false, port, "account-http at "+path) + handler := mox.SafeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTP.Forwarded)))) + srv.ServiceHandle("account", accountHostMatch, path, handler) + redirectToTrailingSlash(srv, accountHostMatch, "account", path) + } + if l.AccountHTTPS.Enabled { + port := config.Port(l.AccountHTTPS.Port, 443) + path := "/" + if l.AccountHTTPS.Path != "" { + path = l.AccountHTTPS.Path } + srv := ensureServe(true, port, "account-https at "+path) + handler := mox.SafeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTPS.Forwarded)))) + srv.ServiceHandle("account", accountHostMatch, path, handler) + redirectToTrailingSlash(srv, accountHostMatch, "account", path) + } - maxMsgSize := l.SMTPMaxMessageSize - if maxMsgSize == 0 { - maxMsgSize = config.DefaultMaxMsgSize + if l.AdminHTTP.Enabled { + port := config.Port(l.AdminHTTP.Port, 80) + path := "/admin/" + if l.AdminHTTP.Path != "" { + path = l.AdminHTTP.Path + } + srv := ensureServe(false, port, "admin-http at "+path) + handler := mox.SafeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTP.Forwarded)))) + srv.ServiceHandle("admin", listenerHostMatch, path, handler) + redirectToTrailingSlash(srv, listenerHostMatch, "admin", path) + } + if l.AdminHTTPS.Enabled { + port := config.Port(l.AdminHTTPS.Port, 443) + path := "/admin/" + if l.AdminHTTPS.Path != "" { + path = l.AdminHTTPS.Path } + srv := ensureServe(true, port, "admin-https at "+path) + handler := mox.SafeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTPS.Forwarded)))) + srv.ServiceHandle("admin", listenerHostMatch, path, handler) + redirectToTrailingSlash(srv, listenerHostMatch, "admin", path) + } - if l.WebAPIHTTP.Enabled { - port := config.Port(l.WebAPIHTTP.Port, 80) - path := "/webapi/" - if l.WebAPIHTTP.Path != "" { - path = l.WebAPIHTTP.Path - } - srv := ensureServe(false, port, "webapi-http at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], webapisrv.NewServer(maxMsgSize, path, l.WebAPIHTTP.Forwarded))) - srv.Handle("webapi", nil, path, handler) - redirectToTrailingSlash(srv, "webapi", path) - } - if l.WebAPIHTTPS.Enabled { - port := config.Port(l.WebAPIHTTPS.Port, 443) - path := "/webapi/" - if l.WebAPIHTTPS.Path != "" { - path = l.WebAPIHTTPS.Path - } - srv := ensureServe(true, port, "webapi-https at "+path) - handler := safeHeaders(http.StripPrefix(path[:len(path)-1], webapisrv.NewServer(maxMsgSize, path, l.WebAPIHTTPS.Forwarded))) - srv.Handle("webapi", nil, path, handler) - redirectToTrailingSlash(srv, "webapi", path) + maxMsgSize := l.SMTPMaxMessageSize + if maxMsgSize == 0 { + maxMsgSize = config.DefaultMaxMsgSize + } + + if l.WebAPIHTTP.Enabled { + port := config.Port(l.WebAPIHTTP.Port, 80) + path := "/webapi/" + if l.WebAPIHTTP.Path != "" { + path = l.WebAPIHTTP.Path + } + srv := ensureServe(false, port, "webapi-http at "+path) + handler := mox.SafeHeaders(http.StripPrefix(path[:len(path)-1], webapisrv.NewServer(maxMsgSize, path, l.WebAPIHTTP.Forwarded))) + srv.ServiceHandle("webapi", accountHostMatch, path, handler) + redirectToTrailingSlash(srv, accountHostMatch, "webapi", path) + } + if l.WebAPIHTTPS.Enabled { + port := config.Port(l.WebAPIHTTPS.Port, 443) + path := "/webapi/" + if l.WebAPIHTTPS.Path != "" { + path = l.WebAPIHTTPS.Path } + srv := ensureServe(true, port, "webapi-https at "+path) + handler := mox.SafeHeaders(http.StripPrefix(path[:len(path)-1], webapisrv.NewServer(maxMsgSize, path, l.WebAPIHTTPS.Forwarded))) + srv.ServiceHandle("webapi", accountHostMatch, path, handler) + redirectToTrailingSlash(srv, accountHostMatch, "webapi", path) + } - if l.WebmailHTTP.Enabled { - port := config.Port(l.WebmailHTTP.Port, 80) - path := "/webmail/" - if l.WebmailHTTP.Path != "" { - path = l.WebmailHTTP.Path + if l.WebmailHTTP.Enabled { + port := config.Port(l.WebmailHTTP.Port, 80) + path := "/webmail/" + if l.WebmailHTTP.Path != "" { + path = l.WebmailHTTP.Path + } + srv := ensureServe(false, port, "webmail-http at "+path) + var accountPath string + if l.AccountHTTP.Enabled { + accountPath = "/" + if l.AccountHTTP.Path != "" { + accountPath = l.AccountHTTP.Path } - srv := ensureServe(false, port, "webmail-http at "+path) - var accountPath string - if l.AccountHTTP.Enabled { - accountPath = "/" - if l.AccountHTTP.Path != "" { - accountPath = l.AccountHTTP.Path - } + } + handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTP.Forwarded, accountPath))) + srv.ServiceHandle("webmail", accountHostMatch, path, handler) + redirectToTrailingSlash(srv, accountHostMatch, "webmail", path) + } + if l.WebmailHTTPS.Enabled { + port := config.Port(l.WebmailHTTPS.Port, 443) + path := "/webmail/" + if l.WebmailHTTPS.Path != "" { + path = l.WebmailHTTPS.Path + } + srv := ensureServe(true, port, "webmail-https at "+path) + var accountPath string + if l.AccountHTTPS.Enabled { + accountPath = "/" + if l.AccountHTTPS.Path != "" { + accountPath = l.AccountHTTPS.Path } - handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTP.Forwarded, accountPath))) - srv.Handle("webmail", nil, path, handler) - redirectToTrailingSlash(srv, "webmail", path) - } - if l.WebmailHTTPS.Enabled { - port := config.Port(l.WebmailHTTPS.Port, 443) - path := "/webmail/" - if l.WebmailHTTPS.Path != "" { - path = l.WebmailHTTPS.Path + } + handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTPS.Forwarded, accountPath))) + srv.ServiceHandle("webmail", accountHostMatch, path, handler) + redirectToTrailingSlash(srv, accountHostMatch, "webmail", path) + } + + if l.MetricsHTTP.Enabled { + port := config.Port(l.MetricsHTTP.Port, 8010) + srv := ensureServe(false, port, "metrics-http") + srv.SystemHandle("metrics", nil, "/metrics", mox.SafeHeaders(promhttp.Handler())) + srv.SystemHandle("metrics", nil, "/", mox.SafeHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } else if r.Method != "GET" { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return } - srv := ensureServe(true, port, "webmail-https at "+path) - var accountPath string - if l.AccountHTTPS.Enabled { - accountPath = "/" - if l.AccountHTTPS.Path != "" { - accountPath = l.AccountHTTPS.Path - } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `see metrics`) + }))) + } + if l.AutoconfigHTTPS.Enabled { + port := config.Port(l.AutoconfigHTTPS.Port, 443) + srv := ensureServe(!l.AutoconfigHTTPS.NonTLS, port, "autoconfig-https") + autoconfigMatch := func(ipdom dns.IPDomain) bool { + dom := ipdom.Domain + if dom.IsZero() { + return false } - handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTPS.Forwarded, accountPath))) - srv.Handle("webmail", nil, path, handler) - redirectToTrailingSlash(srv, "webmail", path) - } - - if l.MetricsHTTP.Enabled { - port := config.Port(l.MetricsHTTP.Port, 8010) - srv := ensureServe(false, port, "metrics-http") - 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 - } else if r.Method != "GET" { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, `see metrics`) - }))) - } - if l.AutoconfigHTTPS.Enabled { - port := config.Port(l.AutoconfigHTTPS.Port, 443) - srv := ensureServe(!l.AutoconfigHTTPS.NonTLS, port, "autoconfig-https") - autoconfigMatch := func(dom dns.Domain) bool { - // Thunderbird requests an autodiscovery URL at the email address domain name, so - // autoconfig prefix is optional. - if strings.HasPrefix(dom.ASCII, "autoconfig.") { - dom.ASCII = strings.TrimPrefix(dom.ASCII, "autoconfig.") - dom.Unicode = strings.TrimPrefix(dom.Unicode, "autoconfig.") - } - // Autodiscovery uses a SRV record. It shouldn't point to a CNAME. So we directly - // use the mail server's host name. - if dom == mox.Conf.Static.HostnameDomain || dom == mox.Conf.Static.Listeners["public"].HostnameDomain { - return true - } - dc, ok := mox.Conf.Domain(dom) - return ok && !dc.ReportsOnly + // Thunderbird requests an autodiscovery URL at the email address domain name, so + // autoconfig prefix is optional. + if strings.HasPrefix(dom.ASCII, "autoconfig.") { + dom.ASCII = strings.TrimPrefix(dom.ASCII, "autoconfig.") + dom.Unicode = strings.TrimPrefix(dom.Unicode, "autoconfig.") } - srv.Handle("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", safeHeaders(http.HandlerFunc(autoconfHandle))) - srv.Handle("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", safeHeaders(http.HandlerFunc(autodiscoverHandle))) - srv.Handle("mobileconfig", autoconfigMatch, "/profile.mobileconfig", safeHeaders(http.HandlerFunc(mobileconfigHandle))) - srv.Handle("mobileconfigqrcodepng", autoconfigMatch, "/profile.mobileconfig.qrcode.png", safeHeaders(http.HandlerFunc(mobileconfigQRCodeHandle))) - } - if l.MTASTSHTTPS.Enabled { - port := config.Port(l.MTASTSHTTPS.Port, 443) - srv := ensureServe(!l.MTASTSHTTPS.NonTLS, port, "mtasts-https") - mtastsMatch := func(dom dns.Domain) bool { - // todo: may want to check this against the configured domains, could in theory be just a webserver. - return strings.HasPrefix(dom.ASCII, "mta-sts.") + // Autodiscovery uses a SRV record. It shouldn't point to a CNAME. So we directly + // use the mail server's host name. + if dom == mox.Conf.Static.HostnameDomain || dom == mox.Conf.Static.Listeners["public"].HostnameDomain { + return true } - 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. - port := config.Port(l.PprofHTTP.Port, 8011) - if _, ok := portServe[port]; ok { - pkglog.Fatal("cannot serve pprof on same endpoint as other http services") + dc, ok := mox.Conf.Domain(dom) + return ok && !dc.ReportsOnly + } + srv.SystemHandle("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", mox.SafeHeaders(http.HandlerFunc(autoconfHandle))) + srv.SystemHandle("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", mox.SafeHeaders(http.HandlerFunc(autodiscoverHandle))) + srv.SystemHandle("mobileconfig", autoconfigMatch, "/profile.mobileconfig", mox.SafeHeaders(http.HandlerFunc(mobileconfigHandle))) + srv.SystemHandle("mobileconfigqrcodepng", autoconfigMatch, "/profile.mobileconfig.qrcode.png", mox.SafeHeaders(http.HandlerFunc(mobileconfigQRCodeHandle))) + } + if l.MTASTSHTTPS.Enabled { + port := config.Port(l.MTASTSHTTPS.Port, 443) + srv := ensureServe(!l.MTASTSHTTPS.NonTLS, port, "mtasts-https") + mtastsMatch := func(ipdom dns.IPDomain) bool { + // todo: may want to check this against the configured domains, could in theory be just a webserver. + dom := ipdom.Domain + if dom.IsZero() { + return false } - srv := &serve{[]string{"pprof-http"}, nil, nil, false} - portServe[port] = srv - srv.Handle("pprof", nil, "/", http.DefaultServeMux) + return strings.HasPrefix(dom.ASCII, "mta-sts.") } - if l.WebserverHTTP.Enabled { - port := config.Port(l.WebserverHTTP.Port, 80) - srv := ensureServe(false, port, "webserver-http") - srv.Webserver = true - } - if l.WebserverHTTPS.Enabled { - port := config.Port(l.WebserverHTTPS.Port, 443) - srv := ensureServe(true, port, "webserver-https") - srv.Webserver = true + srv.SystemHandle("mtasts", mtastsMatch, "/.well-known/mta-sts.txt", mox.SafeHeaders(http.HandlerFunc(mtastsPolicyHandle))) + } + if l.PprofHTTP.Enabled { + // Importing net/http/pprof registers handlers on the default serve mux. + port := config.Port(l.PprofHTTP.Port, 8011) + if _, ok := portServe[port]; ok { + pkglog.Fatal("cannot serve pprof on same endpoint as other http services") } + srv := &serve{[]string{"pprof-http"}, nil, nil, false, nil} + portServe[port] = srv + srv.SystemHandle("pprof", nil, "/", http.DefaultServeMux) + } + if l.WebserverHTTP.Enabled { + port := config.Port(l.WebserverHTTP.Port, 80) + srv := ensureServe(false, port, "webserver-http") + srv.Webserver = true + } + if l.WebserverHTTPS.Enabled { + port := config.Port(l.WebserverHTTPS.Port, 443) + srv := ensureServe(true, port, "webserver-https") + srv.Webserver = true + } - if l.TLS != nil && l.TLS.ACME != "" { - m := mox.Conf.Static.ACME[l.TLS.ACME].Manager + if l.TLS != nil && l.TLS.ACME != "" { + m := mox.Conf.Static.ACME[l.TLS.ACME].Manager - // If we are listening on port 80 for plain http, also register acme http-01 - // validation handler. - if srv, ok := portServe[80]; ok && srv.TLSConfig == nil { - srv.Kinds = append(srv.Kinds, "acme-http-01") - srv.Handle("acme-http-01", nil, "/.well-known/acme-challenge/", m.Manager.HTTPHandler(nil)) - } + // If we are listening on port 80 for plain http, also register acme http-01 + // validation handler. + if srv, ok := portServe[80]; ok && srv.TLSConfig == nil { + srv.Kinds = append(srv.Kinds, "acme-http-01") + srv.SystemHandle("acme-http-01", nil, "/.well-known/acme-challenge/", m.Manager.HTTPHandler(nil)) + } - hosts := map[dns.Domain]struct{}{ - mox.Conf.Static.HostnameDomain: {}, - } - if l.HostnameDomain.ASCII != "" { - hosts[l.HostnameDomain] = struct{}{} + hosts := map[dns.Domain]struct{}{ + mox.Conf.Static.HostnameDomain: {}, + } + if l.HostnameDomain.ASCII != "" { + hosts[l.HostnameDomain] = struct{}{} + } + // All domains are served on all listeners. Gather autoconfig hostnames to ensure + // presence of TLS certificates for. + for _, name := range mox.Conf.Domains() { + if dom, err := dns.ParseDomain(name); err != nil { + pkglog.Errorx("parsing domain from config", err) + } else if d, _ := mox.Conf.Domain(dom); d.ReportsOnly { + // Do not gather autoconfig name if we aren't accepting email for this domain. + continue } - // All domains are served on all listeners. Gather autoconfig hostnames to ensure - // presence of TLS certificates for. - for _, name := range mox.Conf.Domains() { - if dom, err := dns.ParseDomain(name); err != nil { - pkglog.Errorx("parsing domain from config", err) - } else if d, _ := mox.Conf.Domain(dom); d.ReportsOnly { - // Do not gather autoconfig name if we aren't accepting email for this domain. - continue - } - autoconfdom, err := dns.ParseDomain("autoconfig." + name) - if err != nil { - pkglog.Errorx("parsing domain from config for autoconfig", err) - } else { - hosts[autoconfdom] = struct{}{} - } + autoconfdom, err := dns.ParseDomain("autoconfig." + name) + if err != nil { + pkglog.Errorx("parsing domain from config for autoconfig", err) + } else { + hosts[autoconfdom] = struct{}{} } - - ensureManagerHosts[m] = hosts } - ports := maps.Keys(portServe) - sort.Ints(ports) - for _, port := range ports { - srv := portServe[port] - sort.Slice(srv.PathHandlers, func(i, j int) bool { - a := srv.PathHandlers[i].Path - b := srv.PathHandlers[j].Path - if len(a) == len(b) { - // For consistent order. - return a < b - } - // Longest paths first. - return len(a) > len(b) - }) - for _, ip := range l.IPs { - listen1(ip, port, srv.TLSConfig, name, srv.Kinds, srv) - } - } + ensureManagerHosts[m] = hosts } + + for _, srv := range portServe { + sortPathHandlers(srv.SystemHandlers) + sortPathHandlers(srv.ServiceHandlers) + } + + return portServe +} + +func sortPathHandlers(l []pathHandler) { + sort.Slice(l, func(i, j int) bool { + a := l[i].Path + b := l[j].Path + if len(a) == len(b) { + // For consistent order. + return a < b + } + // Longest paths first. + return len(a) > len(b) + }) } // functions to be launched in goroutine that will serve on a listener. diff --git a/http/web_test.go b/http/web_test.go index fb019c4a65..5a5ecca9b8 100644 --- a/http/web_test.go +++ b/http/web_test.go @@ -6,10 +6,8 @@ import ( "net/http/httptest" "os" "path/filepath" - "strings" "testing" - "github.com/mjl-/mox/dns" "github.com/mjl-/mox/mox-" ) @@ -19,20 +17,8 @@ func TestServeHTTP(t *testing.T) { mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") mox.MustLoadConfig(true, false) - srv := &serve{ - PathHandlers: []pathHandler{ - { - HostMatch: func(dom dns.Domain) bool { - return strings.HasPrefix(dom.ASCII, "mta-sts.") - }, - Path: "/.well-known/mta-sts.txt", - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("mta-sts!")) - }), - }, - }, - Webserver: true, - } + portSrvs := portServes(mox.Conf.Static.Listeners["local"]) + srv := portSrvs[80] test := func(method, target string, expCode int, expContent string, expHeaders map[string]string) { t.Helper() @@ -43,22 +29,22 @@ func TestServeHTTP(t *testing.T) { srv.ServeHTTP(rw, req) resp := rw.Result() if resp.StatusCode != expCode { - t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expCode) + t.Errorf("got statuscode %d, expected %d", resp.StatusCode, expCode) } if expContent != "" { s := rw.Body.String() if s != expContent { - t.Fatalf("got response data %q, expected %q", s, expContent) + t.Errorf("got response data %q, expected %q", s, expContent) } } for k, v := range expHeaders { if xv := resp.Header.Get(k); xv != v { - t.Fatalf("got %q for header %q, expected %q", xv, k, v) + t.Errorf("got %q for header %q, expected %q", xv, k, v) } } } - test("GET", "http://mta-sts.mox.example/.well-known/mta-sts.txt", http.StatusOK, "mta-sts!", nil) + test("GET", "http://mta-sts.mox.example/.well-known/mta-sts.txt", http.StatusOK, "version: STSv1\nmode: enforce\nmax_age: 86400\nmx: mox.example\n", nil) test("GET", "http://mox.example/.well-known/mta-sts.txt", http.StatusNotFound, "", nil) // mta-sts endpoint not in this domain. test("GET", "http://mta-sts.mox.example/static/", http.StatusNotFound, "", nil) // static not served on this domain. test("GET", "http://mta-sts.mox.example/other", http.StatusNotFound, "", nil) @@ -66,4 +52,24 @@ func TestServeHTTP(t *testing.T) { test("GET", "http://mox.example/static/index.html", http.StatusOK, "html\n", map[string]string{"X-Test": "mox"}) test("GET", "http://mox.example/static/dir/", http.StatusOK, "", map[string]string{"X-Test": "mox"}) // Dir listing. test("GET", "http://mox.example/other", http.StatusNotFound, "", nil) + + // Webmail on IP, localhost, mail host, clientsettingsdomain, not others. + test("GET", "http://127.0.0.1/webmail/", http.StatusOK, "", nil) + test("GET", "http://localhost/webmail/", http.StatusOK, "", nil) + test("GET", "http://mox.example/webmail/", http.StatusOK, "", nil) + test("GET", "http://mail.mox.example/webmail/", http.StatusOK, "", nil) + test("GET", "http://mail.other.example/webmail/", http.StatusNotFound, "", nil) + test("GET", "http://remotehost/webmail/", http.StatusNotFound, "", nil) + + // admin on IP, localhost, mail host, not clientsettingsdomain. + test("GET", "http://127.0.0.1/admin/", http.StatusOK, "", nil) + test("GET", "http://localhost/admin/", http.StatusOK, "", nil) + test("GET", "http://mox.example/admin/", http.StatusPermanentRedirect, "", nil) // Override by WebHandler. + test("GET", "http://mail.mox.example/admin/", http.StatusNotFound, "", nil) + + // account is off. + test("GET", "http://127.0.0.1/", http.StatusNotFound, "", nil) + test("GET", "http://localhost/", http.StatusNotFound, "", nil) + test("GET", "http://mox.example/", http.StatusNotFound, "", nil) + test("GET", "http://mail.mox.example/", http.StatusNotFound, "", nil) } diff --git a/http/webserver.go b/http/webserver.go index 221e31f8f5..34ce8efa7c 100644 --- a/http/webserver.go +++ b/http/webserver.go @@ -46,13 +46,13 @@ func recvid(r *http.Request) string { // WebHandle runs after the built-in handlers for mta-sts, autoconfig, etc. // If no handler matched, false is returned. // WebHandle sets w.Name to that of the matching handler. -func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool) { +func WebHandle(w *loggingWriter, r *http.Request, host dns.IPDomain) (handled bool) { conf := mox.Conf.DynamicConfig() redirects := conf.WebDNSDomainRedirects handlers := conf.WebHandlers for from, to := range redirects { - if host != from { + if host.Domain != from { continue } u := r.URL @@ -64,7 +64,7 @@ func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool } for _, h := range handlers { - if host != h.DNSDomain { + if host.Domain != h.DNSDomain { continue } loc := h.Path.FindStringIndex(r.URL.Path) @@ -99,6 +99,10 @@ func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool w.Handler = h.Name return true } + if h.WebInternal != nil && HandleInternal(h.WebInternal, w, r) { + w.Handler = h.Name + return true + } } w.Compress = false return false @@ -396,6 +400,12 @@ func HandleRedirect(h *config.WebRedirect, w http.ResponseWriter, r *http.Reques return true } +// HandleInternal passes the request to an internal service. +func HandleInternal(h *config.WebInternal, w http.ResponseWriter, r *http.Request) (handled bool) { + h.Handler.ServeHTTP(w, r) + return true +} + // HandleForward handles a request by forwarding it to another webserver and // passing the response on. I.e. a reverse proxy. It handles websocket // connections by monitoring the websocket handshake and then just passing along the diff --git a/http/webserver_test.go b/http/webserver_test.go index a230cd380a..fbd54d52a5 100644 --- a/http/webserver_test.go +++ b/http/webserver_test.go @@ -134,6 +134,10 @@ func TestWebserver(t *testing.T) { test("GET", "http://mox.example/bogus", nil, http.StatusNotFound, "", nil) // path not registered. test("GET", "http://bogus.mox.example/static/", nil, http.StatusNotFound, "", nil) // domain not registered. + test("GET", "http://mox.example/xadmin/", nil, http.StatusOK, "", nil) // internal admin service + test("GET", "http://mox.example/xaccount/", nil, http.StatusOK, "", nil) // internal account service + test("GET", "http://mox.example/xwebmail/", nil, http.StatusOK, "", nil) // internal webmail service + test("GET", "http://mox.example/xwebapi/v0/", nil, http.StatusOK, "", nil) // internal webapi service npaths := len(staticgzcache.paths) if npaths != 1 { @@ -335,5 +339,4 @@ func TestWebsocket(t *testing.T) { w.WriteHeader(http.StatusSwitchingProtocols) }) test("GET", wsreqhdrs, http.StatusSwitchingProtocols, wsresphdrs) - } diff --git a/mox-/config.go b/mox-/config.go index 7ff066b114..5c31174e73 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -63,6 +63,16 @@ var ( var ErrConfig = errors.New("config error") +// Set by packages webadmin, webaccount, webmail, webapisrv to prevent cyclic dependencies. +var NewWebadminHandler = func(basePath string, isForwarded bool) http.Handler { return nopHandler } +var NewWebaccountHandler = func(basePath string, isForwarded bool) http.Handler { return nopHandler } +var NewWebmailHandler = func(maxMsgSize int64, basePath string, isForwarded bool, accountPath string) http.Handler { + return nopHandler +} +var NewWebapiHandler = func(maxMsgSize int64, basePath string, isForwarded bool) http.Handler { return nopHandler } + +var nopHandler = http.HandlerFunc(nil) + // Config as used in the code, a processed version of what is in the config file. // // Use methods to lookup a domain/account/address in the dynamic configuration. @@ -262,6 +272,13 @@ func (c *Config) Routes(accountName string, domain dns.Domain) (accountRoutes, d return } +func (c *Config) IsClientSettingsDomain(d dns.Domain) (is bool) { + c.withDynamicLock(func() { + _, is = c.Dynamic.ClientSettingDomains[d] + }) + return +} + func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) { for _, l := range c.Static.Listeners { if l.TLS == nil || l.TLS.ACME == "" { @@ -1124,6 +1141,7 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, checkRoutes("global routes", c.Routes) // Validate domains. + c.ClientSettingDomains = map[dns.Domain]struct{}{} for d, domain := range c.Domains { dnsdomain, err := dns.ParseDomain(d) if err != nil { @@ -1140,6 +1158,7 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, addErrorf("bad client settings domain %q: %s", domain.ClientSettingsDomain, err) } domain.ClientSettingsDNSDomain = csd + c.ClientSettingDomains[csd] = struct{}{} } for _, sign := range domain.DKIM.Sign { @@ -1814,6 +1833,29 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, } } } + if wh.WebInternal != nil { + n++ + wi := wh.WebInternal + if !strings.HasPrefix(wi.BasePath, "/") || !strings.HasSuffix(wi.BasePath, "/") { + addErrorf("webinternal %s %s: base path %q must start and end with /", wh.Domain, wh.PathRegexp, wi.BasePath) + } + // todo: we could make maxMsgSize and accountPath configurable + const isForwarded = false + switch wi.Service { + case "admin": + wi.Handler = NewWebadminHandler(wi.BasePath, isForwarded) + case "account": + wi.Handler = NewWebaccountHandler(wi.BasePath, isForwarded) + case "webmail": + accountPath := "" + wi.Handler = NewWebmailHandler(config.DefaultMaxMsgSize, wi.BasePath, isForwarded, accountPath) + case "webapi": + wi.Handler = NewWebapiHandler(config.DefaultMaxMsgSize, wi.BasePath, isForwarded) + default: + addErrorf("webinternal %s %s: unknown service %q", wh.Domain, wh.PathRegexp, wi.Service) + } + wi.Handler = SafeHeaders(http.StripPrefix(wi.BasePath[:len(wi.BasePath)-1], wi.Handler)) + } if n != 1 { addErrorf("webhandler %s %s: must have exactly one handler, not %d", wh.Domain, wh.PathRegexp, n) } diff --git a/mox-/safeheaders.go b/mox-/safeheaders.go new file mode 100644 index 0000000000..fdb318a3e1 --- /dev/null +++ b/mox-/safeheaders.go @@ -0,0 +1,17 @@ +package mox + +import ( + "net/http" +) + +// Set some http headers that should prevent potential abuse. Better safe than sorry. +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.ServeHTTP(w, r) + }) +} diff --git a/testdata/web/domains.conf b/testdata/web/domains.conf index c568924d19..55e0e9fda6 100644 --- a/testdata/web/domains.conf +++ b/testdata/web/domains.conf @@ -1,6 +1,12 @@ Domains: mox.example: LocalpartCaseSensitive: false + ClientSettingsDomain: mail.mox.example + MTASTS: + PolicyID: 1 + Mode: enforce + MaxAge: 24h + other.example: nil Accounts: mjl: Domain: mox.example @@ -21,3 +27,10 @@ WebHandlers: ListFiles: true ResponseHeaders: X-Test: mox + - + LogName: adminoverride + Domain: mox.example + PathRegexp: ^/admin/ + DontRedirectPlainHTTP: true + WebRedirect: + BaseURL: http://redirect.example diff --git a/testdata/web/mox.conf b/testdata/web/mox.conf index 7187b2c425..fef8d2418c 100644 --- a/testdata/web/mox.conf +++ b/testdata/web/mox.conf @@ -8,6 +8,14 @@ Listeners: - 0.0.0.0 WebserverHTTP: Enabled: true + AdminHTTP: + Enabled: true + WebmailHTTP: + Enabled: true + MTASTSHTTPS: + Enabled: true + Port: 80 + NonTLS: true Postmaster: Account: mjl Mailbox: postmaster diff --git a/testdata/webserver/domains.conf b/testdata/webserver/domains.conf index f177ee1e7f..3ac84c9494 100644 --- a/testdata/webserver/domains.conf +++ b/testdata/webserver/domains.conf @@ -69,6 +69,38 @@ WebHandlers: BaseURL: //other.mox.example?q=1#fragment OrigPathRegexp: ^/baseurlpath/old/(.*)$ ReplacePath: /baseurlpath/new/$1 + - + LogName: xadmin + Domain: mox.example + PathRegexp: ^/xadmin/ + DontRedirectPlainHTTP: true + WebInternal: + BasePath: /xadmin/ + Service: admin + - + LogName: xaccount + Domain: mox.example + PathRegexp: ^/xaccount/ + DontRedirectPlainHTTP: true + WebInternal: + BasePath: /xaccount/ + Service: account + - + LogName: xwebmail + Domain: mox.example + PathRegexp: ^/xwebmail/ + DontRedirectPlainHTTP: true + WebInternal: + BasePath: /xwebmail/ + Service: webmail + - + LogName: xwebapi + Domain: mox.example + PathRegexp: ^/xwebapi/ + DontRedirectPlainHTTP: true + WebInternal: + BasePath: /xwebapi/ + Service: webapi # test code depends on these last two webhandlers being here. - LogName: strippath diff --git a/testdata/webserver/mox.conf b/testdata/webserver/mox.conf index 7187b2c425..832debebe6 100644 --- a/testdata/webserver/mox.conf +++ b/testdata/webserver/mox.conf @@ -8,6 +8,8 @@ Listeners: - 0.0.0.0 WebserverHTTP: Enabled: true + AdminHTTP: + Enabled: true Postmaster: Account: mjl Mailbox: postmaster diff --git a/webaccount/account.go b/webaccount/account.go index 98fbda51ba..c20b92d6e6 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -85,6 +85,10 @@ func init() { if err != nil { pkglog.Fatalx("sherpa handler", err) } + + mox.NewWebaccountHandler = func(basePath string, isForwarded bool) http.Handler { + return http.HandlerFunc(Handler(basePath, isForwarded)) + } } // Handler returns a handler for the webaccount endpoints, customized for the diff --git a/webadmin/admin.go b/webadmin/admin.go index 3ccac8f758..cd7eb02ba8 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -114,6 +114,10 @@ func init() { if err != nil { pkglog.Fatalx("sherpa handler", err) } + + mox.NewWebadminHandler = func(basePath string, isForwarded bool) http.Handler { + return http.HandlerFunc(Handler(basePath, isForwarded)) + } } // Handler returns a handler for the webadmin endpoints, customized for the diff --git a/webadmin/admin.js b/webadmin/admin.js index 7a5cb42c12..0e61bd2575 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -250,7 +250,7 @@ var api; Mode["ModeTesting"] = "testing"; Mode["ModeNone"] = "none"; })(Mode = api.Mode || (api.Mode = {})); - api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; + api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebInternal": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; api.stringsTypes = { "Align": true, "CSRFToken": true, "DMARCPolicy": true, "IP": true, "Localpart": true, "Mode": true, "RUA": true }; api.intsTypes = {}; api.types = { @@ -347,10 +347,11 @@ var api; "HookRetiredSort": { "Name": "HookRetiredSort", "Docs": "", "Fields": [{ "Name": "Field", "Docs": "", "Typewords": ["string"] }, { "Name": "LastID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Last", "Docs": "", "Typewords": ["any"] }, { "Name": "Asc", "Docs": "", "Typewords": ["bool"] }] }, "HookRetired": { "Name": "HookRetired", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "QueueMsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "FromID", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "Extra", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsIncoming", "Docs": "", "Typewords": ["bool"] }, { "Name": "OutgoingEvent", "Docs": "", "Typewords": ["string"] }, { "Name": "Payload", "Docs": "", "Typewords": ["string"] }, { "Name": "Submitted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "SupersededByID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Attempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "Results", "Docs": "", "Typewords": ["[]", "HookResult"] }, { "Name": "Success", "Docs": "", "Typewords": ["bool"] }, { "Name": "LastActivity", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "KeepUntil", "Docs": "", "Typewords": ["timestamp"] }] }, "WebserverConfig": { "Name": "WebserverConfig", "Docs": "", "Fields": [{ "Name": "WebDNSDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "Domain"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }] }, - "WebHandler": { "Name": "WebHandler", "Docs": "", "Fields": [{ "Name": "LogName", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "PathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "DontRedirectPlainHTTP", "Docs": "", "Typewords": ["bool"] }, { "Name": "Compress", "Docs": "", "Typewords": ["bool"] }, { "Name": "WebStatic", "Docs": "", "Typewords": ["nullable", "WebStatic"] }, { "Name": "WebRedirect", "Docs": "", "Typewords": ["nullable", "WebRedirect"] }, { "Name": "WebForward", "Docs": "", "Typewords": ["nullable", "WebForward"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, + "WebHandler": { "Name": "WebHandler", "Docs": "", "Fields": [{ "Name": "LogName", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "PathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "DontRedirectPlainHTTP", "Docs": "", "Typewords": ["bool"] }, { "Name": "Compress", "Docs": "", "Typewords": ["bool"] }, { "Name": "WebStatic", "Docs": "", "Typewords": ["nullable", "WebStatic"] }, { "Name": "WebRedirect", "Docs": "", "Typewords": ["nullable", "WebRedirect"] }, { "Name": "WebForward", "Docs": "", "Typewords": ["nullable", "WebForward"] }, { "Name": "WebInternal", "Docs": "", "Typewords": ["nullable", "WebInternal"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "WebStatic": { "Name": "WebStatic", "Docs": "", "Fields": [{ "Name": "StripPrefix", "Docs": "", "Typewords": ["string"] }, { "Name": "Root", "Docs": "", "Typewords": ["string"] }, { "Name": "ListFiles", "Docs": "", "Typewords": ["bool"] }, { "Name": "ContinueNotFound", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseHeaders", "Docs": "", "Typewords": ["{}", "string"] }] }, "WebRedirect": { "Name": "WebRedirect", "Docs": "", "Fields": [{ "Name": "BaseURL", "Docs": "", "Typewords": ["string"] }, { "Name": "OrigPathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "ReplacePath", "Docs": "", "Typewords": ["string"] }, { "Name": "StatusCode", "Docs": "", "Typewords": ["int32"] }] }, "WebForward": { "Name": "WebForward", "Docs": "", "Fields": [{ "Name": "StripPath", "Docs": "", "Typewords": ["bool"] }, { "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "ResponseHeaders", "Docs": "", "Typewords": ["{}", "string"] }] }, + "WebInternal": { "Name": "WebInternal", "Docs": "", "Fields": [{ "Name": "BasePath", "Docs": "", "Typewords": ["string"] }, { "Name": "Service", "Docs": "", "Typewords": ["string"] }] }, "Transport": { "Name": "Transport", "Docs": "", "Fields": [{ "Name": "Submissions", "Docs": "", "Typewords": ["nullable", "TransportSMTP"] }, { "Name": "Submission", "Docs": "", "Typewords": ["nullable", "TransportSMTP"] }, { "Name": "SMTP", "Docs": "", "Typewords": ["nullable", "TransportSMTP"] }, { "Name": "Socks", "Docs": "", "Typewords": ["nullable", "TransportSocks"] }, { "Name": "Direct", "Docs": "", "Typewords": ["nullable", "TransportDirect"] }] }, "TransportSMTP": { "Name": "TransportSMTP", "Docs": "", "Fields": [{ "Name": "Host", "Docs": "", "Typewords": ["string"] }, { "Name": "Port", "Docs": "", "Typewords": ["int32"] }, { "Name": "STARTTLSInsecureSkipVerify", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoSTARTTLS", "Docs": "", "Typewords": ["bool"] }, { "Name": "Auth", "Docs": "", "Typewords": ["nullable", "SMTPAuth"] }] }, "SMTPAuth": { "Name": "SMTPAuth", "Docs": "", "Fields": [{ "Name": "Username", "Docs": "", "Typewords": ["string"] }, { "Name": "Password", "Docs": "", "Typewords": ["string"] }, { "Name": "Mechanisms", "Docs": "", "Typewords": ["[]", "string"] }] }, @@ -468,6 +469,7 @@ var api; WebStatic: (v) => api.parse("WebStatic", v), WebRedirect: (v) => api.parse("WebRedirect", v), WebForward: (v) => api.parse("WebForward", v), + WebInternal: (v) => api.parse("WebInternal", v), Transport: (v) => api.parse("Transport", v), TransportSMTP: (v) => api.parse("TransportSMTP", v), SMTPAuth: (v) => api.parse("SMTPAuth", v), @@ -3747,6 +3749,7 @@ const webserver = async () => { let staticView = null; let redirectView = null; let forwardView = null; + let internalView = null; let moveButtons; const makeWebStatic = (ws) => { let view; @@ -3764,7 +3767,7 @@ const webserver = async () => { ResponseHeaders: responseHeaders.get(), }; }; - const root = dom.table(dom.tr(dom.td('Type'), dom.td('StripPrefix', attr.title('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.')), dom.td('Root', attr.title('Directory to serve files from for this handler. Keep in mind that relative paths are relative to the working directory of mox.')), dom.td('ListFiles', attr.title('If set, and a directory is requested, and no index.html is present that can be served, a file listing is returned. Results in 403 if ListFiles is not set. If a directory is requested and the URL does not end with a slash, the response is a redirect to the path with trailing slash.')), dom.td('ContinueNotFound', attr.title("If a requested URL does not exist, don't return a file not found (404) response, but consider this handler non-matching and continue attempts to serve with later WebHandlers, which may be a reverse proxy generating dynamic content, possibly even writing a static file for a next request to serve statically. If ContinueNotFound is set, HTTP requests other than GET and HEAD do not match. This mechanism can be used to implement the equivalent of 'try_files' in other webservers.")), dom.td(dom.span('Response headers', attr.title('Headers to add to the response. Useful for cache-control, content-type, etc. By default, Content-Type headers are automatically added for recognized file types, unless added explicitly through this setting. For directory listings, a content-type header is skipped.')), ' ', responseHeaders.add)), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static', attr.selected('')), dom.option('Redirect'), dom.option('Forward'), function change(e) { + const root = dom.table(dom.tr(dom.td('Type'), dom.td('StripPrefix', attr.title('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.')), dom.td('Root', attr.title('Directory to serve files from for this handler. Keep in mind that relative paths are relative to the working directory of mox.')), dom.td('ListFiles', attr.title('If set, and a directory is requested, and no index.html is present that can be served, a file listing is returned. Results in 403 if ListFiles is not set. If a directory is requested and the URL does not end with a slash, the response is a redirect to the path with trailing slash.')), dom.td('ContinueNotFound', attr.title("If a requested URL does not exist, don't return a file not found (404) response, but consider this handler non-matching and continue attempts to serve with later WebHandlers, which may be a reverse proxy generating dynamic content, possibly even writing a static file for a next request to serve statically. If ContinueNotFound is set, HTTP requests other than GET and HEAD do not match. This mechanism can be used to implement the equivalent of 'try_files' in other webservers.")), dom.td(dom.span('Response headers', attr.title('Headers to add to the response. Useful for cache-control, content-type, etc. By default, Content-Type headers are automatically added for recognized file types, unless added explicitly through this setting. For directory listings, a content-type header is skipped.')), ' ', responseHeaders.add)), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static', attr.selected('')), dom.option('Redirect'), dom.option('Forward'), dom.option('Internal'), function change(e) { makeType(e.target.value); })), dom.td(stripPrefix = dom.input(attr.value(ws.StripPrefix || ''))), dom.td(rootPath = dom.input(attr.required(''), attr.placeholder('web/...'), attr.value(ws.Root || ''))), dom.td(listFiles = dom.input(attr.type('checkbox'), ws.ListFiles ? attr.checked('') : [])), dom.td(continueNotFound = dom.input(attr.type('checkbox'), ws.ContinueNotFound ? attr.checked('') : [])), dom.td(responseHeaders))); view = { root: root, get: get }; @@ -3784,7 +3787,7 @@ const webserver = async () => { StatusCode: statusCode.value ? parseInt(statusCode.value) : 0, }; }; - const root = dom.table(dom.tr(dom.td('Type'), dom.td('BaseURL', attr.title('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/. If a redirect would send a request to a URL with the same scheme, host and path, the WebRedirect does not match so a next WebHandler can be tried. This can be used to redirect all plain http traffic to https.')), dom.td('OrigPathRegexp', attr.title('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.')), dom.td('ReplacePath', attr.title("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.")), dom.td('StatusCode', attr.title('Status code to use in redirect, e.g. 307. By default, a permanent redirect (308) is returned.'))), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static'), dom.option('Redirect', attr.selected('')), dom.option('Forward'), function change(e) { + const root = dom.table(dom.tr(dom.td('Type'), dom.td('BaseURL', attr.title('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/. If a redirect would send a request to a URL with the same scheme, host and path, the WebRedirect does not match so a next WebHandler can be tried. This can be used to redirect all plain http traffic to https.')), dom.td('OrigPathRegexp', attr.title('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.')), dom.td('ReplacePath', attr.title("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.")), dom.td('StatusCode', attr.title('Status code to use in redirect, e.g. 307. By default, a permanent redirect (308) is returned.'))), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static'), dom.option('Redirect', attr.selected('')), dom.option('Forward'), dom.option('Internal'), function change(e) { makeType(e.target.value); })), dom.td(baseURL = dom.input(attr.placeholder('empty or https://target/path?q=1#frag or //target/...'), attr.value(wr.BaseURL || ''))), dom.td(origPathRegexp = dom.input(attr.placeholder('^/old/(.*)'), attr.value(wr.OrigPathRegexp || ''))), dom.td(replacePath = dom.input(attr.placeholder('/new/$1'), attr.value(wr.ReplacePath || ''))), dom.td(statusCode = dom.input(style({ width: '4em' }), attr.type('number'), attr.value(wr.StatusCode ? '' + wr.StatusCode : ''), attr.min('300'), attr.max('399'))))); view = { root: root, get: get }; @@ -3802,12 +3805,28 @@ const webserver = async () => { ResponseHeaders: responseHeaders.get(), }; }; - const root = dom.table(dom.tr(dom.td('Type'), dom.td('StripPath', attr.title('Strip the matching WebHandler path from the WebHandler before forwarding the request.')), dom.td('URL', attr.title("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. Websocket connections are forwarded and data is copied between client and backend without looking at the framing. The websocket 'version' and 'key'/'accept' headers are verified during the handshake, but other websocket headers, including 'origin', 'protocol' and 'extensions' headers, are not inspected and the backend is responsible for verifying/interpreting them.")), dom.td(dom.span('Response headers', attr.title('Headers to add to the response. Useful for adding security- and cache-related headers.')), ' ', responseHeaders.add)), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static'), dom.option('Redirect'), dom.option('Forward', attr.selected('')), function change(e) { + const root = dom.table(dom.tr(dom.td('Type'), dom.td('StripPath', attr.title('Strip the matching WebHandler path from the WebHandler before forwarding the request.')), dom.td('URL', attr.title("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. Websocket connections are forwarded and data is copied between client and backend without looking at the framing. The websocket 'version' and 'key'/'accept' headers are verified during the handshake, but other websocket headers, including 'origin', 'protocol' and 'extensions' headers, are not inspected and the backend is responsible for verifying/interpreting them.")), dom.td(dom.span('Response headers', attr.title('Headers to add to the response. Useful for adding security- and cache-related headers.')), ' ', responseHeaders.add)), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static'), dom.option('Redirect'), dom.option('Forward', attr.selected('')), dom.option('Internal'), function change(e) { makeType(e.target.value); })), dom.td(stripPath = dom.input(attr.type('checkbox'), wf.StripPath || wf.StripPath === undefined ? attr.checked('') : [])), dom.td(url = dom.input(attr.required(''), attr.placeholder('http://127.0.0.1:8888'), attr.value(wf.URL || ''))), dom.td(responseHeaders))); view = { root: root, get: get }; return view; }; + const makeWebInternal = (wi) => { + let view; + let basePath; + let service; + const get = () => { + return { + BasePath: basePath.value, + Service: service.value, + }; + }; + const root = dom.table(dom.tr(dom.td('Type'), dom.td('Base path', attr.title('Path to use as root of internal service, e.g. /webmail/.')), dom.td('Service')), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static'), dom.option('Redirect'), dom.option('Forward'), dom.option('Internal', attr.selected('')), function change(e) { + makeType(e.target.value); + })), dom.td(basePath = dom.input(attr.value(wi.BasePath), attr.required(''), attr.placeholder('/.../'))), dom.td(service = dom.select(dom.option('Admin', attr.value('admin')), dom.option('Account', attr.value('account')), dom.option('Webmail', attr.value('webmail')), dom.option('Webapi', attr.value('webapi')), attr.value(wi.Service))))); + view = { root: root, get: get }; + return view; + }; let logName; let domain; let pathRegexp; @@ -3847,6 +3866,13 @@ const webserver = async () => { }); detailsRoot(forwardView.root); } + else if (s === 'Internal') { + internalView = makeWebInternal(wh.WebInternal || { + BasePath: '', + Service: 'admin', + }); + detailsRoot(internalView.root); + } else { throw new Error('unknown handler type'); } @@ -3910,6 +3936,9 @@ const webserver = async () => { else if (handlerType === 'Forward' && forwardView !== null) { wh.WebForward = forwardView.get(); } + else if (handlerType === 'Internal' && internalView !== null) { + wh.WebInternal = internalView.get(); + } else { throw new Error('unknown WebHandler type'); } @@ -3925,6 +3954,9 @@ const webserver = async () => { else if (wh.WebForward) { handlerType = 'Forward'; } + else if (wh.WebInternal) { + handlerType = 'Internal'; + } else { throw new Error('unknown WebHandler type'); } @@ -3976,7 +4008,7 @@ const webserver = async () => { const row = redirectRow([{ ASCII: '', Unicode: '' }, { ASCII: '', Unicode: '' }]); redirectsTbody.appendChild(row.root); noredirect.style.display = redirectRows.length ? 'none' : ''; - })))), redirectsTbody = dom.tbody((conf.WebDNSDomainRedirects || []).sort().map(t => redirectRow([t[0], t[1]])), noredirect = dom.tr(style({ display: redirectRows.length ? 'none' : '' }), dom.td(attr.colspan('3'), 'No redirects.')))), dom.br(), dom.h2('Handlers', attr.title('Corresponds with WebHandlers in domains.conf')), dom.p('Each incoming request is check against these handlers, in order. The first matching handler serves the request. Don\'t forget to save after making a change.'), dom.table(dom._class('long'), dom.thead(dom.tr(dom.th(), dom.th(handlerActions()))), handlersTbody = dom.tbody((conf.WebHandlers || []).map(wh => handlerRow(wh)), nohandler = dom.tr(style({ display: handlerRows.length ? 'none' : '' }), dom.td(attr.colspan('2'), 'No handlers.'))), dom.tfoot(dom.tr(dom.th(), dom.th(handlerActions())))), dom.br(), dom.submitbutton('Save', attr.title('Save config. If the configuration has changed since this page was loaded, an error will be returned. After saving, the changes take effect immediately.'))), async function submit(e) { + })))), redirectsTbody = dom.tbody((conf.WebDNSDomainRedirects || []).sort().map(t => redirectRow([t[0], t[1]])), noredirect = dom.tr(style({ display: redirectRows.length ? 'none' : '' }), dom.td(attr.colspan('3'), 'No redirects.')))), dom.br(), dom.h2('Handlers', attr.title('Corresponds with WebHandlers in domains.conf')), dom.p('Each incoming request is matched against the configured handlers, in order. The first matching handler serves the request. System handlers such as for ACME validation, MTA-STS and autoconfig, come first. Then these webserver handlers. Finally the internal service handlers for admin, account, webmail and webapi configured in mox.conf. Don\'t forget to save after making a change.'), dom.table(dom._class('long'), dom.thead(dom.tr(dom.th(), dom.th(handlerActions()))), handlersTbody = dom.tbody((conf.WebHandlers || []).map(wh => handlerRow(wh)), nohandler = dom.tr(style({ display: handlerRows.length ? 'none' : '' }), dom.td(attr.colspan('2'), 'No handlers.'))), dom.tfoot(dom.tr(dom.th(), dom.th(handlerActions())))), dom.br(), dom.submitbutton('Save', attr.title('Save config. If the configuration has changed since this page was loaded, an error will be returned. After saving, the changes take effect immediately.'))), async function submit(e) { e.preventDefault(); e.stopPropagation(); conf = await check(fieldset, client.WebserverConfigSave(conf, gatherConf())); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 1294af360a..439686e2b3 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -4537,6 +4537,10 @@ const webserver = async () => { root: HTMLElement get: () => api.WebForward } + type WebInternalView = { + root: HTMLElement + get: () => api.WebInternal + } // Make a handler row. This is more complicated, since it can be one of the three // types (static, redirect, forward), and can change between those types. @@ -4546,6 +4550,7 @@ const webserver = async () => { let staticView: WebStaticView | null = null let redirectView: WebRedirectView | null = null let forwardView: WebForwardView | null = null + let internalView: WebInternalView | null = null let moveButtons: HTMLElement @@ -4602,6 +4607,7 @@ const webserver = async () => { dom.option('Static', attr.selected('')), dom.option('Redirect'), dom.option('Forward'), + dom.option('Internal'), function change(e: MouseEvent) { makeType((e.target! as HTMLSelectElement).value) }, @@ -4671,6 +4677,7 @@ const webserver = async () => { dom.option('Static'), dom.option('Redirect', attr.selected('')), dom.option('Forward'), + dom.option('Internal'), function change(e: MouseEvent) { makeType((e.target! as HTMLSelectElement).value) }, @@ -4735,6 +4742,7 @@ const webserver = async () => { dom.option('Static', ), dom.option('Redirect'), dom.option('Forward', attr.selected('')), + dom.option('Internal'), function change(e: MouseEvent) { makeType((e.target! as HTMLSelectElement).value) }, @@ -4755,6 +4763,60 @@ const webserver = async () => { return view } + const makeWebInternal = (wi: api.WebInternal) => { + let view: WebInternalView + + let basePath: HTMLInputElement + let service: HTMLSelectElement + + const get = (): api.WebInternal => { + return { + BasePath: basePath.value, + Service: service.value, + } + } + const root = dom.table( + dom.tr( + dom.td('Type'), + dom.td( + 'Base path', + attr.title('Path to use as root of internal service, e.g. /webmail/.'), + ), + dom.td( + 'Service', + ), + ), + dom.tr( + dom.td( + dom.select( + attr.required(''), + dom.option('Static', ), + dom.option('Redirect'), + dom.option('Forward'), + dom.option('Internal', attr.selected('')), + function change(e: MouseEvent) { + makeType((e.target! as HTMLSelectElement).value) + }, + ), + ), + dom.td( + basePath=dom.input(attr.value(wi.BasePath), attr.required(''), attr.placeholder('/.../')), + ), + dom.td( + service=dom.select( + dom.option('Admin', attr.value('admin')), + dom.option('Account', attr.value('account')), + dom.option('Webmail', attr.value('webmail')), + dom.option('Webapi', attr.value('webapi')), + attr.value(wi.Service), + ), + ), + ), + ) + view = {root: root, get: get} + return view + } + let logName: HTMLInputElement let domain: HTMLInputElement let pathRegexp: HTMLInputElement @@ -4794,6 +4856,12 @@ const webserver = async () => { ResponseHeaders: {}, }) detailsRoot(forwardView.root) + } else if (s === 'Internal') { + internalView = makeWebInternal(wh.WebInternal || { + BasePath: '', + Service: 'admin', + }) + detailsRoot(internalView.root) } else { throw new Error('unknown handler type') } @@ -4901,6 +4969,8 @@ const webserver = async () => { wh.WebRedirect = redirectView.get() } else if (handlerType === 'Forward' && forwardView !== null) { wh.WebForward = forwardView.get() + } else if (handlerType === 'Internal' && internalView !== null) { + wh.WebInternal = internalView.get() } else { throw new Error('unknown WebHandler type') } @@ -4914,6 +4984,8 @@ const webserver = async () => { handlerType = 'Redirect' } else if (wh.WebForward) { handlerType = 'Forward' + } else if (wh.WebInternal) { + handlerType = 'Internal' } else { throw new Error('unknown WebHandler type') } @@ -4999,7 +5071,7 @@ const webserver = async () => { ), dom.br(), dom.h2('Handlers', attr.title('Corresponds with WebHandlers in domains.conf')), - dom.p('Each incoming request is check against these handlers, in order. The first matching handler serves the request. Don\'t forget to save after making a change.'), + dom.p('Each incoming request is matched against the configured handlers, in order. The first matching handler serves the request. System handlers such as for ACME validation, MTA-STS and autoconfig, come first. Then these webserver handlers. Finally the internal service handlers for admin, account, webmail and webapi configured in mox.conf. Don\'t forget to save after making a change.'), dom.table(dom._class('long'), dom.thead( dom.tr( diff --git a/webadmin/api.json b/webadmin/api.json index a848d8532b..9ee43e6a88 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -6528,6 +6528,14 @@ "WebForward" ] }, + { + "Name": "WebInternal", + "Docs": "", + "Typewords": [ + "nullable", + "WebInternal" + ] + }, { "Name": "Name", "Docs": "Either LogName, or numeric index if LogName was empty. Used instead of LogName in logging/metrics.", @@ -6648,6 +6656,26 @@ } ] }, + { + "Name": "WebInternal", + "Docs": "", + "Fields": [ + { + "Name": "BasePath", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Service", + "Docs": "", + "Typewords": [ + "string" + ] + } + ] + }, { "Name": "Transport", "Docs": "Transport is a method to delivery a message. At most one of the fields can\nbe non-nil. The non-nil field represents the type of transport. For a\ntransport with all fields nil, regular email delivery is done.", diff --git a/webadmin/api.ts b/webadmin/api.ts index d200f43bc6..d2768c56c2 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -901,6 +901,7 @@ export interface WebHandler { WebStatic?: WebStatic | null WebRedirect?: WebRedirect | null WebForward?: WebForward | null + WebInternal?: WebInternal | null Name: string // Either LogName, or numeric index if LogName was empty. Used instead of LogName in logging/metrics. DNSDomain: Domain } @@ -926,6 +927,11 @@ export interface WebForward { ResponseHeaders?: { [key: string]: string } } +export interface WebInternal { + BasePath: string + Service: string +} + // Transport is a method to delivery a message. At most one of the fields can // be non-nil. The non-nil field represents the type of transport. For a // transport with all fields nil, regular email delivery is done. @@ -1090,7 +1096,7 @@ export type Localpart = string // be an IPv4 address. export type IP = string -export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} +export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebInternal":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"CSRFToken":true,"DMARCPolicy":true,"IP":true,"Localpart":true,"Mode":true,"RUA":true} export const intsTypes: {[typename: string]: boolean} = {} export const types: TypenameMap = { @@ -1187,10 +1193,11 @@ export const types: TypenameMap = { "HookRetiredSort": {"Name":"HookRetiredSort","Docs":"","Fields":[{"Name":"Field","Docs":"","Typewords":["string"]},{"Name":"LastID","Docs":"","Typewords":["int64"]},{"Name":"Last","Docs":"","Typewords":["any"]},{"Name":"Asc","Docs":"","Typewords":["bool"]}]}, "HookRetired": {"Name":"HookRetired","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"QueueMsgID","Docs":"","Typewords":["int64"]},{"Name":"FromID","Docs":"","Typewords":["string"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"Extra","Docs":"","Typewords":["{}","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["bool"]},{"Name":"IsIncoming","Docs":"","Typewords":["bool"]},{"Name":"OutgoingEvent","Docs":"","Typewords":["string"]},{"Name":"Payload","Docs":"","Typewords":["string"]},{"Name":"Submitted","Docs":"","Typewords":["timestamp"]},{"Name":"SupersededByID","Docs":"","Typewords":["int64"]},{"Name":"Attempts","Docs":"","Typewords":["int32"]},{"Name":"Results","Docs":"","Typewords":["[]","HookResult"]},{"Name":"Success","Docs":"","Typewords":["bool"]},{"Name":"LastActivity","Docs":"","Typewords":["timestamp"]},{"Name":"KeepUntil","Docs":"","Typewords":["timestamp"]}]}, "WebserverConfig": {"Name":"WebserverConfig","Docs":"","Fields":[{"Name":"WebDNSDomainRedirects","Docs":"","Typewords":["[]","[]","Domain"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["[]","[]","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]}]}, - "WebHandler": {"Name":"WebHandler","Docs":"","Fields":[{"Name":"LogName","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"PathRegexp","Docs":"","Typewords":["string"]},{"Name":"DontRedirectPlainHTTP","Docs":"","Typewords":["bool"]},{"Name":"Compress","Docs":"","Typewords":["bool"]},{"Name":"WebStatic","Docs":"","Typewords":["nullable","WebStatic"]},{"Name":"WebRedirect","Docs":"","Typewords":["nullable","WebRedirect"]},{"Name":"WebForward","Docs":"","Typewords":["nullable","WebForward"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]}, + "WebHandler": {"Name":"WebHandler","Docs":"","Fields":[{"Name":"LogName","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"PathRegexp","Docs":"","Typewords":["string"]},{"Name":"DontRedirectPlainHTTP","Docs":"","Typewords":["bool"]},{"Name":"Compress","Docs":"","Typewords":["bool"]},{"Name":"WebStatic","Docs":"","Typewords":["nullable","WebStatic"]},{"Name":"WebRedirect","Docs":"","Typewords":["nullable","WebRedirect"]},{"Name":"WebForward","Docs":"","Typewords":["nullable","WebForward"]},{"Name":"WebInternal","Docs":"","Typewords":["nullable","WebInternal"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]}, "WebStatic": {"Name":"WebStatic","Docs":"","Fields":[{"Name":"StripPrefix","Docs":"","Typewords":["string"]},{"Name":"Root","Docs":"","Typewords":["string"]},{"Name":"ListFiles","Docs":"","Typewords":["bool"]},{"Name":"ContinueNotFound","Docs":"","Typewords":["bool"]},{"Name":"ResponseHeaders","Docs":"","Typewords":["{}","string"]}]}, "WebRedirect": {"Name":"WebRedirect","Docs":"","Fields":[{"Name":"BaseURL","Docs":"","Typewords":["string"]},{"Name":"OrigPathRegexp","Docs":"","Typewords":["string"]},{"Name":"ReplacePath","Docs":"","Typewords":["string"]},{"Name":"StatusCode","Docs":"","Typewords":["int32"]}]}, "WebForward": {"Name":"WebForward","Docs":"","Fields":[{"Name":"StripPath","Docs":"","Typewords":["bool"]},{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"ResponseHeaders","Docs":"","Typewords":["{}","string"]}]}, + "WebInternal": {"Name":"WebInternal","Docs":"","Fields":[{"Name":"BasePath","Docs":"","Typewords":["string"]},{"Name":"Service","Docs":"","Typewords":["string"]}]}, "Transport": {"Name":"Transport","Docs":"","Fields":[{"Name":"Submissions","Docs":"","Typewords":["nullable","TransportSMTP"]},{"Name":"Submission","Docs":"","Typewords":["nullable","TransportSMTP"]},{"Name":"SMTP","Docs":"","Typewords":["nullable","TransportSMTP"]},{"Name":"Socks","Docs":"","Typewords":["nullable","TransportSocks"]},{"Name":"Direct","Docs":"","Typewords":["nullable","TransportDirect"]}]}, "TransportSMTP": {"Name":"TransportSMTP","Docs":"","Fields":[{"Name":"Host","Docs":"","Typewords":["string"]},{"Name":"Port","Docs":"","Typewords":["int32"]},{"Name":"STARTTLSInsecureSkipVerify","Docs":"","Typewords":["bool"]},{"Name":"NoSTARTTLS","Docs":"","Typewords":["bool"]},{"Name":"Auth","Docs":"","Typewords":["nullable","SMTPAuth"]}]}, "SMTPAuth": {"Name":"SMTPAuth","Docs":"","Fields":[{"Name":"Username","Docs":"","Typewords":["string"]},{"Name":"Password","Docs":"","Typewords":["string"]},{"Name":"Mechanisms","Docs":"","Typewords":["[]","string"]}]}, @@ -1309,6 +1316,7 @@ export const parser = { WebStatic: (v: any) => parse("WebStatic", v) as WebStatic, WebRedirect: (v: any) => parse("WebRedirect", v) as WebRedirect, WebForward: (v: any) => parse("WebForward", v) as WebForward, + WebInternal: (v: any) => parse("WebInternal", v) as WebInternal, Transport: (v: any) => parse("Transport", v) as Transport, TransportSMTP: (v: any) => parse("TransportSMTP", v) as TransportSMTP, SMTPAuth: (v: any) => parse("SMTPAuth", v) as SMTPAuth, diff --git a/webapisrv/server.go b/webapisrv/server.go index dc0a9b4d86..7f0755395f 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -252,6 +252,10 @@ fieldset { border: 0; } panic("executing api docs index template: " + err.Error()) } docsIndex = b.Bytes() + + mox.NewWebapiHandler = func(maxMsgSize int64, basePath string, isForwarded bool) http.Handler { + return NewServer(maxMsgSize, basePath, isForwarded) + } } // NewServer returns a new http.Handler for a webapi server. diff --git a/webmail/webmail.go b/webmail/webmail.go index d900201ea0..b036c4b203 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -170,6 +170,12 @@ func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, http.ServeContent(w, r, "", mox.FallbackMtime(log), bytes.NewReader(fallback)) } +func init() { + mox.NewWebmailHandler = func(maxMsgSize int64, basePath string, isForwarded bool, accountPath string) http.Handler { + return http.HandlerFunc(Handler(maxMsgSize, basePath, isForwarded, accountPath)) + } +} + // Handler returns a handler for the webmail endpoints, customized for the max // message size coming from the listener and cookiePath. func Handler(maxMessageSize int64, cookiePath string, isForwarded bool, accountPath string) func(w http.ResponseWriter, r *http.Request) {