12 htmltemplate "html/template"
29 "github.com/mjl-/mox/config"
30 "github.com/mjl-/mox/dns"
31 "github.com/mjl-/mox/mlog"
32 "github.com/mjl-/mox/mox-"
33 "github.com/mjl-/mox/moxio"
36func recvid(r *http.Request) string {
37 cid := mox.CidFromCtx(r.Context())
41 return " (id " + mox.ReceivedID(cid) + ")"
44// WebHandle serves an HTTP request by going through the list of WebHandlers,
45// check if there is a domain+path match, and running the handler if so.
46// WebHandle runs after the built-in handlers for mta-sts, autoconfig, etc.
47// If no handler matched, false is returned.
48// WebHandle sets w.Name to that of the matching handler.
49func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool) {
50 conf := mox.Conf.DynamicConfig()
51 redirects := conf.WebDNSDomainRedirects
52 handlers := conf.WebHandlers
54 for from, to := range redirects {
61 w.Handler = "(domainredirect)"
62 http.Redirect(w, r, u.String(), http.StatusPermanentRedirect)
66 for _, h := range handlers {
67 if host != h.DNSDomain {
70 loc := h.Path.FindStringIndex(r.URL.Path)
76 path := r.URL.Path[s:e]
78 if r.TLS == nil && !h.DontRedirectPlainHTTP {
81 u.Host = h.DNSDomain.Name()
83 w.Compress = h.Compress
84 http.Redirect(w, r, u.String(), http.StatusPermanentRedirect)
88 // We don't want the loggingWriter to override the static handler's decisions to compress.
89 w.Compress = h.Compress
90 if h.WebStatic != nil && HandleStatic(h.WebStatic, h.Compress, w, r) {
94 if h.WebRedirect != nil && HandleRedirect(h.WebRedirect, w, r) {
98 if h.WebForward != nil && HandleForward(h.WebForward, w, r, path) {
107var lsTemplate = htmltemplate.Must(htmltemplate.New("ls").Parse(`<!doctype html>
110 <meta charset="utf-8" />
111 <meta name="viewport" content="width=device-width, initial-scale=1" />
114body, html { padding: 1em; font-size: 16px; }
115* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
116h1 { margin-bottom: 1ex; font-size: 1.2rem; }
117table td, table th { padding: .2em .5em; }
118table > tbody > tr:nth-child(odd) { background-color: #f8f8f8; }
119[title] { text-decoration: underline; text-decoration-style: dotted; }
128 <th>Modified (UTC)</th>
134 <tr><td colspan="3">No files.</td></tr>
138 <td title="{{ .Size }} bytes" style="text-align: right">{{ .SizeReadable }}{{ if .SizePad }}<span style="visibility:hidden">. </span>{{ end }}</td>
139 <td>{{ .Modified }}</td>
140 <td><a style="display: block" href="{{ .Name }}">{{ .Name }}</a></td>
149// HandleStatic serves static files. If a directory is requested and the URL
150// path doesn't end with a slash, a response with a redirect to the URL path with trailing
151// slash is written. If a directory is requested and an index.html exists, that
152// file is returned. Otherwise, for directories with ListFiles configured, a
153// directory listing is returned.
154func HandleStatic(h *config.WebStatic, compress bool, w http.ResponseWriter, r *http.Request) (handled bool) {
155 log := func() mlog.Log {
156 return pkglog.WithContext(r.Context())
158 if r.Method != "GET" && r.Method != "HEAD" {
159 if h.ContinueNotFound {
160 // Give another handler that is presumbly configured, for the same path, a chance.
161 // E.g. an app that may generate this file for future requests to pick up.
164 http.Error(w, "405 - method not allowed", http.StatusMethodNotAllowed)
169 if h.StripPrefix != "" {
170 if !strings.HasPrefix(r.URL.Path, h.StripPrefix) {
171 if h.ContinueNotFound {
172 // We haven't handled this request, try a next WebHandler in the list.
178 fspath = filepath.Join(h.Root, strings.TrimPrefix(r.URL.Path, h.StripPrefix))
180 fspath = filepath.Join(h.Root, r.URL.Path)
182 // fspath will not have a trailing slash anymore, we'll correct for it
183 // later when the path turns out to be file instead of a directory.
185 serveFile := func(name string, fi fs.FileInfo, content *os.File) {
186 // ServeContent only sets a content-type if not already present in the response headers.
188 for k, v := range h.ResponseHeaders {
191 // We transparently compress here, but still use ServeContent, because it handles
192 // conditional requests, range requests. It's a bit of a hack, but on first write
193 // to staticgzcacheReplacer where we are compressing, we write the full compressed
194 // file instead, and return an error to ServeContent so it stops. We still have all
195 // the useful behaviour (status code and headers) from ServeContent.
197 if compress && acceptsGzip(r) && compressibleContent(content) {
198 xw = &staticgzcacheReplacer{w, r, content.Name(), content, fi.ModTime(), fi.Size(), 0, false}
200 w.(*loggingWriter).Compress = false
202 http.ServeContent(xw, r, name, fi.ModTime(), content)
205 f, err := os.Open(fspath)
207 if os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR) {
208 if h.ContinueNotFound {
209 // We haven't handled this request, try a next WebHandler in the list.
214 } else if os.IsPermission(err) {
215 // If we tried opening a directory, we may not have permission to read it, but
216 // still access files inside it (execute bit), such as index.html. So try to serve it.
217 index, err := os.Open(filepath.Join(fspath, "index.html"))
221 ifi, err = index.Stat()
223 log().Errorx("stat index.html in directory we cannot list", err, slog.Any("url", r.URL), slog.String("fspath", fspath))
224 http.Error(w, "500 - internal server error"+recvid(r), http.StatusInternalServerError)
227 w.Header().Set("Content-Type", "text/html; charset=utf-8")
228 serveFile("index.html", ifi, index)
231 http.Error(w, "403 - permission denied", http.StatusForbidden)
234 log().Errorx("open file for static file serving", err, slog.Any("url", r.URL), slog.String("fspath", fspath))
235 http.Error(w, "500 - internal server error"+recvid(r), http.StatusInternalServerError)
242 log().Errorx("stat file for static file serving", err, slog.Any("url", r.URL), slog.String("fspath", fspath))
243 http.Error(w, "500 - internal server error"+recvid(r), http.StatusInternalServerError)
246 // Redirect if the local path is a directory.
247 if fi.IsDir() && !strings.HasSuffix(r.URL.Path, "/") {
248 http.Redirect(w, r, r.URL.Path+"/", http.StatusTemporaryRedirect)
250 } else if !fi.IsDir() && strings.HasSuffix(r.URL.Path, "/") {
251 if h.ContinueNotFound {
259 index, err := os.Open(filepath.Join(fspath, "index.html"))
260 if err != nil && os.IsPermission(err) {
261 http.Error(w, "403 - permission denied", http.StatusForbidden)
263 } else if err != nil && os.IsNotExist(err) && !h.ListFiles {
264 if h.ContinueNotFound {
267 http.Error(w, "403 - permission denied", http.StatusForbidden)
269 } else if err == nil {
272 ifi, err = index.Stat()
274 w.Header().Set("Content-Type", "text/html; charset=utf-8")
275 serveFile("index.html", ifi, index)
279 if !os.IsNotExist(err) {
280 log().Errorx("stat for static file serving", err, slog.Any("url", r.URL), slog.String("fspath", fspath))
281 http.Error(w, "500 - internal server error"+recvid(r), http.StatusInternalServerError)
289 SizePad bool // Whether the size needs padding because it has no decimal point.
293 if r.URL.Path != "/" {
294 files = append(files, File{"..", 0, "", false, ""})
297 l, err := f.Readdir(1000)
298 for _, e := range l {
299 mb := float64(e.Size()) / (1024 * 1024)
304 size = fmt.Sprintf("%d", int64(mb))
307 size = fmt.Sprintf("%.2f", mb)
310 const dateTime = "2006-01-02 15:04:05" // time.DateTime, but only since go1.20.
311 modified := e.ModTime().UTC().Format(dateTime)
312 f := File{e.Name(), e.Size(), size, sizepad, modified}
316 files = append(files, f)
320 } else if err != nil {
321 log().Errorx("reading directory for file listing", err, slog.Any("url", r.URL), slog.String("fspath", fspath))
322 http.Error(w, "500 - internal server error"+recvid(r), http.StatusInternalServerError)
326 sort.Slice(files, func(i, j int) bool {
327 return files[i].Name < files[j].Name
330 hdr.Set("Content-Type", "text/html; charset=utf-8")
331 for k, v := range h.ResponseHeaders {
332 if !strings.EqualFold(k, "content-type") {
336 err = lsTemplate.Execute(w, map[string]any{"Files": files})
337 if err != nil && !moxio.IsClosed(err) {
338 log().Errorx("executing directory listing template", err)
343 serveFile(fspath, fi, f)
347// HandleRedirect writes a response with an HTTP redirect.
348func HandleRedirect(h *config.WebRedirect, w http.ResponseWriter, r *http.Request) (handled bool) {
350 if h.OrigPath == nil {
351 // No path rewrite necessary.
353 } else if !h.OrigPath.MatchString(r.URL.Path) {
357 dstpath = h.OrigPath.ReplaceAllString(r.URL.Path, h.ReplacePath)
365 u.Scheme = h.URL.Scheme
367 u.ForceQuery = h.URL.ForceQuery
368 u.RawQuery = h.URL.RawQuery
369 u.Fragment = h.URL.Fragment
370 if r.URL.RawQuery != "" {
371 if u.RawQuery != "" {
374 u.RawQuery += r.URL.RawQuery
378 code := http.StatusPermanentRedirect
379 if h.StatusCode != 0 {
383 // If we would be redirecting to the same scheme,host,path, we would get here again
384 // causing a redirect loop. Instead, this causes this redirect to not match,
385 // allowing to try the next WebHandler. This can be used to redirect all plain http
386 // requests to https.
391 if reqscheme == u.Scheme && r.Host == u.Host && r.URL.Path == u.Path {
395 http.Redirect(w, r, u.String(), code)
399// HandleForward handles a request by forwarding it to another webserver and
400// passing the response on. I.e. a reverse proxy. It handles websocket
401// connections by monitoring the websocket handshake and then just passing along the
403func HandleForward(h *config.WebForward, w http.ResponseWriter, r *http.Request, path string) (handled bool) {
404 log := func() mlog.Log {
405 return pkglog.WithContext(r.Context())
412 u.Path = r.URL.Path[len(path):]
413 if !strings.HasPrefix(u.Path, "/") {
414 u.Path = "/" + u.Path
420 // Remove any forwarded headers passed in by client.
422 for k, vl := range r.Header {
423 if k == "Forwarded" || k == "X-Forwarded" || strings.HasPrefix(k, "X-Forwarded-") {
430 // Add our own X-Forwarded headers. ReverseProxy will add X-Forwarded-For.
431 r.Header["X-Forwarded-Host"] = []string{r.Host}
436 r.Header["X-Forwarded-Proto"] = []string{proto}
437 // note: We are not using "ws" or "wss" for websocket. The request we are
438 // forwarding is http(s), and we don't yet know if the backend even supports
441 // todo: add Forwarded header? is anyone using it?
443 // If we see an Upgrade: websocket, we're going to assume the client needs
444 // websocket and only attempt to talk websocket with the backend. If the backend
445 // doesn't do websocket, we'll send back a "bad request" response. For other values
446 // of Upgrade, we don't do anything special.
447 // https://www.iana.org/assignments/http-upgrade-tokens/http-upgrade-tokens.xhtml
451 upgrade := r.Header.Get("Upgrade")
452 if upgrade != "" && !(r.ProtoMajor == 1 && r.ProtoMinor == 0) {
453 // Websockets have case-insensitive string "websocket".
454 for _, s := range strings.Split(upgrade, ",") {
455 if strings.EqualFold(textproto.TrimString(s), "websocket") {
456 forwardWebsocket(h, w, r, path)
462 // ReverseProxy will append any remaining path to the configured target URL.
463 proxy := httputil.NewSingleHostReverseProxy(h.TargetURL)
464 proxy.FlushInterval = time.Duration(-1) // Flush after each write.
465 proxy.ErrorLog = golog.New(mlog.LogWriter(mlog.New("net/http/httputil", nil).WithContext(r.Context()), mlog.LevelDebug, "reverseproxy error"), "", 0)
466 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
467 if errors.Is(err, context.Canceled) {
468 log().Debugx("forwarding request to backend webserver", err, slog.Any("url", r.URL))
471 log().Errorx("forwarding request to backend webserver", err, slog.Any("url", r.URL))
472 if os.IsTimeout(err) {
473 http.Error(w, "504 - gateway timeout"+recvid(r), http.StatusGatewayTimeout)
475 http.Error(w, "502 - bad gateway"+recvid(r), http.StatusBadGateway)
479 for k, v := range h.ResponseHeaders {
482 proxy.ServeHTTP(w, r)
486var errResponseNotWebsocket = errors.New("not a valid websocket response to request")
487var errNotImplemented = errors.New("functionality not yet implemented")
489// Request has an Upgrade: websocket header. Check more websocketiness about the
490// request. If it looks good, we forward it to the backend. If the backend responds
491// with a valid websocket response, indicating it is indeed a websocket server, we
492// pass the response along and start copying data between the client and the
493// backend. We don't look at the frames and payloads. The backend already needs to
494// know enough websocket to handle the frames. It wouldn't necessarily hurt to
495// monitor the frames too, and check if they are valid, but it's quite a bit of
496// work for little benefit. Besides, the whole point of websockets is to exchange
497// bytes without HTTP being in the way, so let's do that.
498func forwardWebsocket(h *config.WebForward, w http.ResponseWriter, r *http.Request, path string) (handled bool) {
499 log := func() mlog.Log {
500 return pkglog.WithContext(r.Context())
503 lw := w.(*loggingWriter)
504 lw.WebsocketRequest = true // For correct protocol in metrics.
506 // We check the requested websocket version first. A future websocket version may
507 // have different request requirements.
509 wsversion := r.Header.Get("Sec-WebSocket-Version")
510 if wsversion != "13" {
511 // Indicate we only support version 13. Should get a client from the future to fall back to version 13.
513 w.Header().Set("Sec-WebSocket-Version", "13")
514 http.Error(w, "400 - bad request - websockets only supported with version 13"+recvid(r), http.StatusBadRequest)
515 lw.error(fmt.Errorf("Sec-WebSocket-Version %q not supported", wsversion))
520 if r.Method != "GET" {
521 http.Error(w, "400 - bad request - websockets only allowed with method GET"+recvid(r), http.StatusBadRequest)
522 lw.error(fmt.Errorf("websocket request only allowed with method GET"))
527 var connectionUpgrade bool
528 for _, s := range strings.Split(r.Header.Get("Connection"), ",") {
529 if strings.EqualFold(textproto.TrimString(s), "upgrade") {
530 connectionUpgrade = true
534 if !connectionUpgrade {
535 http.Error(w, "400 - bad request - connection header must be \"upgrade\""+recvid(r), http.StatusBadRequest)
536 lw.error(fmt.Errorf(`connection header is %q, must be "upgrade"`, r.Header.Get("Connection")))
541 wskey := r.Header.Get("Sec-WebSocket-Key")
542 key, err := base64.StdEncoding.DecodeString(wskey)
543 if err != nil || len(key) != 16 {
544 http.Error(w, "400 - bad request - websockets requires Sec-WebSocket-Key with 16 bytes base64-encoded value"+recvid(r), http.StatusBadRequest)
545 lw.error(fmt.Errorf("bad Sec-WebSocket-Key %q, must be 16 byte base64-encoded value", wskey))
550 // We don't look at the origin header. The backend needs to handle it, if it thinks
552 // We also don't look at Sec-WebSocket-Protocol and Sec-WebSocket-Extensions. The
553 // backend can set them, but it doesn't influence our forwarding of the data.
555 // If this is not a hijacker, there is not point in connecting to the backend.
556 hj, ok := lw.W.(http.Hijacker)
557 var cbr *bufio.ReadWriter
559 log().Info("cannot turn http connection into tcp connection (http.Hijacker)")
560 http.Error(w, "501 - not implemented - cannot turn this connection into websocket"+recvid(r), http.StatusNotImplemented)
561 lw.error(fmt.Errorf("connection not a http.Hijacker (%T)", lw.W))
566 freq.Proto = "HTTP/1.1"
569 fresp, beconn, err := websocketTransact(r.Context(), h.TargetURL, &freq)
571 if errors.Is(err, errResponseNotWebsocket) {
572 http.Error(w, "400 - bad request - websocket not supported"+recvid(r), http.StatusBadRequest)
573 } else if errors.Is(err, errNotImplemented) {
574 http.Error(w, "501 - not implemented - "+err.Error()+recvid(r), http.StatusNotImplemented)
575 } else if os.IsTimeout(err) {
576 http.Error(w, "504 - gateway timeout"+recvid(r), http.StatusGatewayTimeout)
578 http.Error(w, "502 - bad gateway"+recvid(r), http.StatusBadGateway)
589 // Hijack the client connection so we can write the response ourselves, and start
590 // copying the websocket frames.
592 cconn, cbr, err = hj.Hijack()
594 log().Debugx("cannot turn http transaction into websocket connection", err)
595 http.Error(w, "501 - not implemented - cannot turn this connection into websocket"+recvid(r), http.StatusNotImplemented)
605 // Below this point, we can no longer write to the ResponseWriter.
607 // Mark as websocket response, for logging.
608 lw.WebsocketResponse = true
609 lw.setStatusCode(fresp.StatusCode)
611 for k, v := range h.ResponseHeaders {
612 fresp.Header.Add(k, v)
615 // Write the response to the client, completing its websocket handshake.
616 if err := fresp.Write(cconn); err != nil {
617 lw.error(fmt.Errorf("writing websocket response to client: %w", err))
621 errc := make(chan error, 1)
623 // Copy from client to backend.
625 buf, err := cbr.Peek(cbr.Reader.Buffered())
631 n, err := beconn.Write(buf)
636 lw.SizeFromClient += int64(n)
638 n, err := io.Copy(beconn, cconn)
639 lw.SizeFromClient += n
643 // Copy from backend to client.
645 n, err := io.Copy(cconn, beconn)
650 // Stop and close connection on first error from either size, typically a closed
651 // connection whose closing was already announced with a websocket frame.
653 // Close connections so other goroutine stops as well.
656 // Wait for goroutine so it has updated the logWriter.Size*Client fields before we
657 // continue with logging.
663func websocketTransact(ctx context.Context, targetURL *url.URL, r *http.Request) (rresp *http.Response, rconn net.Conn, rerr error) {
664 log := func() mlog.Log {
665 return pkglog.WithContext(r.Context())
668 // Dial the backend, possibly doing TLS. We assume the net/http DefaultTransport is
670 transport := http.DefaultTransport.(*http.Transport)
672 // We haven't implemented using a proxy for websocket requests yet. If we need one,
673 // return an error instead of trying to connect directly, which would be a
674 // potential security issue.
677 if purl, err := transport.Proxy(&treq); err != nil {
678 return nil, nil, fmt.Errorf("determining proxy for websocket backend connection: %w", err)
679 } else if purl != nil {
680 return nil, nil, fmt.Errorf("%w: proxy required for websocket connection to backend", errNotImplemented) // todo: implement?
683 host, port, err := net.SplitHostPort(targetURL.Host)
685 host = targetURL.Host
686 if targetURL.Scheme == "https" {
692 addr := net.JoinHostPort(host, port)
693 conn, err := transport.DialContext(r.Context(), "tcp", addr)
695 return nil, nil, fmt.Errorf("dial: %w", err)
697 if targetURL.Scheme == "https" {
698 tlsconn := tls.Client(conn, transport.TLSClientConfig)
699 ctx, cancel := context.WithTimeout(r.Context(), transport.TLSHandshakeTimeout)
701 if err := tlsconn.HandshakeContext(ctx); err != nil {
702 return nil, nil, fmt.Errorf("tls handshake: %w", err)
712 // todo: make timeout configurable?
713 if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
714 log().Check(err, "set deadline for websocket request to backend")
717 // Set clean connection headers.
718 removeHopByHopHeaders(r.Header)
719 r.Header.Set("Connection", "Upgrade")
720 r.Header.Set("Upgrade", "websocket")
722 // Write the websocket request to the backend.
723 if err := r.Write(conn); err != nil {
724 return nil, nil, fmt.Errorf("writing request to backend: %w", err)
727 // Read response from backend.
728 br := bufio.NewReader(conn)
729 resp, err := http.ReadResponse(br, r)
731 return nil, nil, fmt.Errorf("reading response from backend: %w", err)
738 if err := conn.SetDeadline(time.Time{}); err != nil {
739 log().Check(err, "clearing deadline on websocket connection to backend")
742 // Check that the response from the backend server indicates it is websocket. If
743 // not, don't pass the backend response, but an error that websocket is not
745 if err := checkWebsocketResponse(resp, r); err != nil {
746 return resp, nil, err
749 // note: net/http.Response.Body documents that it implements io.Writer for a
750 // status: 101 response. But that's not the case when the response has been read
751 // with http.ReadResponse. We'll write to the connection directly.
753 buf, err := br.Peek(br.Buffered())
755 return resp, nil, fmt.Errorf("peek at buffered data written by backend: %w", err)
757 return resp, websocketConn{io.MultiReader(bytes.NewReader(buf), conn), conn}, nil
760// A net.Conn but with reads coming from an io multireader (due to buffered reader
761// needed for http.ReadResponse).
762type websocketConn struct {
767func (c websocketConn) Read(buf []byte) (int, error) {
771// Check that an HTTP response (from a backend) is a valid websocket response, i.e.
772// that it accepts the WebSocket "upgrade".
774func checkWebsocketResponse(resp *http.Response, req *http.Request) error {
775 if resp.StatusCode != 101 {
776 return fmt.Errorf("%w: response http status not 101 but %s", errResponseNotWebsocket, resp.Status)
778 if upgrade := resp.Header.Get("Upgrade"); !strings.EqualFold(upgrade, "websocket") {
779 return fmt.Errorf(`%w: response http status is 101, but Upgrade header is %q, should be "websocket"`, errResponseNotWebsocket, upgrade)
781 if connection := resp.Header.Get("Connection"); !strings.EqualFold(connection, "upgrade") {
782 return fmt.Errorf(`%w: response http status is 101, Upgrade is websocket, but Connection header is %q, should be "Upgrade"`, errResponseNotWebsocket, connection)
784 accept, err := base64.StdEncoding.DecodeString(resp.Header.Get("Sec-WebSocket-Accept"))
786 return fmt.Errorf(`%w: response http status, Upgrade and Connection header are websocket, but Sec-WebSocket-Accept header is not valid base64: %v`, errResponseNotWebsocket, err)
788 exp := sha1.Sum([]byte(req.Header.Get("Sec-WebSocket-Key") + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
789 if !bytes.Equal(accept, exp[:]) {
790 return fmt.Errorf(`%w: response http status, Upgrade and Connection header are websocket, but backend Sec-WebSocket-Accept value does not match`, errResponseNotWebsocket)
796// From Go 1.20.4 src/net/http/httputil/reverseproxy.go:
797// Hop-by-hop headers. These are removed when sent to the backend.
798// As of RFC 7230, hop-by-hop headers are required to appear in the
799// Connection header field. These are the headers defined by the
800// obsoleted RFC 2616 (section 13.5.1) and are used for backward
803var hopHeaders = []string{
805 "Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google
807 "Proxy-Authenticate",
808 "Proxy-Authorization",
809 "Te", // canonicalized version of "TE"
810 "Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522
815// From Go 1.20.4 src/net/http/httputil/reverseproxy.go:
816// removeHopByHopHeaders removes hop-by-hop headers.
817func removeHopByHopHeaders(h http.Header) {
818 // RFC 7230, section 6.1: Remove headers listed in the "Connection" header.
820 for _, f := range h["Connection"] {
821 for _, sf := range strings.Split(f, ",") {
822 if sf = textproto.TrimString(sf); sf != "" {
827 // RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers.
828 // This behavior is superseded by the RFC 7230 Connection header, but
829 // preserve it for backwards compatibility.
831 for _, f := range hopHeaders {