diff --git a/README.md b/README.md index f8505911b..bfde49ad7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster. - SMTP server (default `0.0.0.0:1025`) - Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`) - Real-time web UI updates using web sockets for new mail +- Optional basic authentication for web UI (see [wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication)) - Email storage in either memory or disk (using [CloverDB](https://github.com/ostafen/clover)) - note that in-memory has a physical limit of 1MB per email - Configurable automatic email pruning (default keeps the most recent 500 emails) - Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size @@ -22,8 +23,6 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster. ## Planned features - Optional HTTPS for web UI -- Optional basic authentication for web UI -- Optional authentication for SMTP - Browser notifications for new mail (HTTPS only) - Docker container diff --git a/cmd/root.go b/cmd/root.go index 5039746e0..8b662bd24 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,10 +77,14 @@ func init() { if len(os.Getenv("MP_MAX_MESSAGES")) > 0 { config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES")) } + if len(os.Getenv("MP_AUTH_FILE")) > 0 { + config.AuthFile = os.Getenv("MP_AUTH_FILE") + } rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data") rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port") rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI") rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages per mailbox") + rootCmd.Flags().StringVarP(&config.AuthFile, "-auth-file", "a", config.AuthFile, "A username:bcryptpw mapping file") rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging") } diff --git a/config/config.go b/config/config.go index c2eeb4dbc..de03eeab8 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,8 @@ package config import ( "errors" "regexp" + + "github.com/tg123/go-htpasswd" ) var ( @@ -21,13 +23,19 @@ var ( // VerboseLogging for console output VerboseLogging = false - // NoLogging for testing + // NoLogging for tests NoLogging = false // SSLCert @TODO SSLCert string // SSLKey @TODO SSLKey string + + // AuthFile for basic authentication + AuthFile string + + // Auth used for euthentication + Auth *htpasswd.File ) // VerifyConfig wil do some basic checking @@ -40,5 +48,13 @@ func VerifyConfig() error { return errors.New("HTTP bind should be in the format of :") } + if AuthFile != "" { + a, err := htpasswd.New(AuthFile, htpasswd.DefaultSystems, nil) + if err != nil { + return err + } + Auth = a + } + return nil } diff --git a/go.mod b/go.mod index 3f666fe8a..bdf7a077d 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,11 @@ require ( github.com/sirupsen/logrus v1.9.0 github.com/spf13/cobra v1.5.0 github.com/spf13/pflag v1.0.5 + github.com/tg123/go-htpasswd v1.2.0 ) require ( + github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect @@ -45,8 +47,9 @@ require ( github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/net v0.0.0-20220728211354-c7608f3a8462 // indirect - golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect + golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect + golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b // indirect + golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/protobuf v1.28.1 // indirect ) diff --git a/go.sum b/go.sum index a9d33be0f..ad6e1e6a5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= +github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -175,6 +177,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0= +github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= @@ -187,9 +191,12 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -206,8 +213,9 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20220728211354-c7608f3a8462 h1:UreQrH7DbFXSi9ZFox6FNT3WBooWmdANpU+IfkT1T4I= golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b h1:3ogNYyK4oIQdIKzTu68hQrr4iuVxF3AxKl9Aj/eDrw0= +golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -226,8 +234,9 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 h1:Y7NOhdqIOU8kYI7BxsgL38d0ot0raxvcW+EMQU2QrT4= +golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/server/server.go b/server/server.go index cd83c674f..a6bb4c9ec 100644 --- a/server/server.go +++ b/server/server.go @@ -34,18 +34,17 @@ func Listen() { go websockets.MessageHub.Run() r := mux.NewRouter() - r.HandleFunc("/api/mailboxes", gzipHandlerFunc(apiListMailboxes)) - r.HandleFunc("/api/{mailbox}/messages", gzipHandlerFunc(apiListMailbox)) - r.HandleFunc("/api/{mailbox}/search", gzipHandlerFunc(apiSearchMailbox)) - r.HandleFunc("/api/{mailbox}/delete", gzipHandlerFunc(apiDeleteAll)) + r.HandleFunc("/api/mailboxes", middleWareFunc(apiListMailboxes)) + r.HandleFunc("/api/{mailbox}/messages", middleWareFunc(apiListMailbox)) + r.HandleFunc("/api/{mailbox}/search", middleWareFunc(apiSearchMailbox)) + r.HandleFunc("/api/{mailbox}/delete", middleWareFunc(apiDeleteAll)) r.HandleFunc("/api/{mailbox}/events", apiWebsocket) - r.HandleFunc("/api/{mailbox}/{id}/source", gzipHandlerFunc(apiDownloadSource)) - r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", gzipHandlerFunc(apiDownloadAttachment)) - r.HandleFunc("/api/{mailbox}/{id}/delete", gzipHandlerFunc(apiDeleteOne)) - r.HandleFunc("/api/{mailbox}/{id}/unread", gzipHandlerFunc(apiUnreadOne)) - r.HandleFunc("/api/{mailbox}/{id}", gzipHandlerFunc(apiOpenMessage)) - r.HandleFunc("/api/{mailbox}/search", gzipHandlerFunc(apiSearchMailbox)) - r.PathPrefix("/").Handler(gzipHandler(http.FileServer(http.FS(serverRoot)))) + r.HandleFunc("/api/{mailbox}/{id}/source", middleWareFunc(apiDownloadSource)) + r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)) + r.HandleFunc("/api/{mailbox}/{id}/delete", middleWareFunc(apiDeleteOne)) + r.HandleFunc("/api/{mailbox}/{id}/unread", middleWareFunc(apiUnreadOne)) + r.HandleFunc("/api/{mailbox}/{id}", middleWareFunc(apiOpenMessage)) + r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot)))) http.Handle("/", r) if config.SSLCert != "" && config.SSLKey != "" { @@ -55,7 +54,13 @@ func Listen() { logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen) log.Fatal(http.ListenAndServe(config.HTTPListen, nil)) } +} +// BasicAuthResponse returns an basic auth response to the browser +func basicAuthResponse(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorised.\n")) } type gzipResponseWriter struct { @@ -67,9 +72,24 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) { return w.Writer.Write(b) } -// GzipHandlerFunc http middleware -func gzipHandlerFunc(fn http.HandlerFunc) http.HandlerFunc { +// MiddleWareFunc http middleware adds optional basic authentication +// and gzip compression. +func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if config.AuthFile != "" { + user, pass, ok := r.BasicAuth() + + if !ok { + basicAuthResponse(w) + return + } + + if !config.Auth.Match(user, pass) { + basicAuthResponse(w) + return + } + } + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { fn(w, r) return @@ -82,8 +102,25 @@ func gzipHandlerFunc(fn http.HandlerFunc) http.HandlerFunc { } } -func gzipHandler(h http.Handler) http.Handler { +// MiddlewareHandler http middleware adds optional basic authentication +// and gzip compression +func middlewareHandler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + if config.AuthFile != "" { + user, pass, ok := r.BasicAuth() + + if !ok { + basicAuthResponse(w) + return + } + + if !config.Auth.Match(user, pass) { + basicAuthResponse(w) + return + } + } + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { h.ServeHTTP(w, r) return @@ -95,14 +132,14 @@ func gzipHandler(h http.Handler) http.Handler { }) } -// FourOFour returns a standard 404 meesage +// FourOFour returns a basic 404 message func fourOFour(w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound) w.Header().Set("Content-Type", "text/plain") fmt.Fprint(w, "404 page not found") } -// HTTPError returns a standard 404 meesage +// HTTPError returns a basic error message (400 response) func httpError(w http.ResponseWriter, msg string) { w.WriteHeader(http.StatusBadRequest) w.Header().Set("Content-Type", "text/plain") diff --git a/server/websockets/client.go b/server/websockets/client.go index 43f4e3ff4..19efedef1 100644 --- a/server/websockets/client.go +++ b/server/websockets/client.go @@ -9,6 +9,7 @@ import ( "net/http" "time" + "github.com/axllent/mailpit/config" "github.com/gorilla/websocket" ) @@ -52,32 +53,6 @@ type Client struct { send chan []byte } -// // readPump pumps messages from the websocket connection to the hub. -// // -// // The application runs readPump in a per-connection goroutine. The application -// // ensures that there is at most one reader on a connection by executing all -// // reads from this goroutine. -// func (c *Client) readPump() { -// defer func() { -// c.hub.unregister <- c -// c.conn.Close() -// }() -// c.conn.SetReadLimit(maxMessageSize) -// c.conn.SetReadDeadline(time.Now().Add(pongWait)) -// c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) -// for { -// _, message, err := c.conn.ReadMessage() -// if err != nil { -// if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { -// log.Printf("error: %v", err) -// } -// break -// } -// message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) -// c.hub.Broadcast <- message -// } -// } - // writePump pumps messages from the hub to the websocket connection. // // A goroutine running writePump is started for each connection. The @@ -124,16 +99,39 @@ func (c *Client) writePump() { // ServeWs handles websocket requests from the peer. func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { + if config.AuthFile != "" { + if config.AuthFile != "" { + user, pass, ok := r.BasicAuth() + + if !ok { + basicAuthResponse(w) + return + } + + if !config.Auth.Match(user, pass) { + basicAuthResponse(w) + return + } + } + } + conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return } + client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} client.hub.register <- client // Allow collection of memory referenced by the caller by doing all work in // new goroutines. go client.writePump() - // go client.readPump() +} + +// BasicAuthResponse returns an basic auth response to the browser +func basicAuthResponse(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorised.\n")) }