diff --git a/README.md b/README.md index e5b5f2e..11ed78b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent. - [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients - Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size, easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails) +- [Chaos](ttps://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience - `List-Unsubscribe` syntax validation - Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages diff --git a/cmd/root.go b/cmd/root.go index 4bc81de..9d87503 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/smtpd" + "github.com/axllent/mailpit/internal/smtpd/chaos" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server" @@ -122,6 +123,10 @@ func init() { rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)") rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)") + // Chaos + rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)") + rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server") + // POP3 server rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port") rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)") @@ -281,6 +286,10 @@ func initConfigFromEnv() { config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS") config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS") + // Chaos + chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS") + config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS") + // POP3 server if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 { config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR") diff --git a/config/config.go b/config/config.go index 4352286..b82afb1 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,7 @@ import ( "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/smtpd/chaos" "github.com/axllent/mailpit/internal/spamassassin" "github.com/axllent/mailpit/internal/tools" "gopkg.in/yaml.v3" @@ -176,6 +177,9 @@ var ( // RepoBinaryName on Github for updater RepoBinaryName = "mailpit" + // ChaosTriggers are parsed and set in the chaos module + ChaosTriggers string + // DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only DisableHTMLCheck = false @@ -344,6 +348,14 @@ func VerifyConfig() error { return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication") } + if err := parseChaosTriggers(); err != nil { + return fmt.Errorf("[chaos] %s", err.Error()) + } + + if chaos.Enabled { + logger.Log().Info("[chaos] is enabled") + } + // POP3 server if POP3TLSCert != "" { POP3TLSCert = filepath.Clean(POP3TLSCert) @@ -602,6 +614,39 @@ func validateRelayConfig() error { return nil } +func parseChaosTriggers() error { + if ChaosTriggers == "" { + return nil + } + + re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`) + + parts := strings.Split(ChaosTriggers, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if !re.MatchString(p) { + return fmt.Errorf("invalid argument: %s", p) + } + + matches := re.FindAllStringSubmatch(p, 1) + key := matches[0][1] + errorCode, err := strconv.Atoi(matches[0][2]) + if err != nil { + return err + } + probability, err := strconv.Atoi(matches[0][3]) + if err != nil { + return err + } + + if err := chaos.Set(key, errorCode, probability); err != nil { + return err + } + } + + return nil +} + // IsFile returns whether a file exists and is readable func isFile(path string) bool { f, err := os.Open(filepath.Clean(path)) diff --git a/internal/smtpd/chaos/chaos.go b/internal/smtpd/chaos/chaos.go new file mode 100644 index 0000000..51f1534 --- /dev/null +++ b/internal/smtpd/chaos/chaos.go @@ -0,0 +1,121 @@ +// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server. +// See https://en.wikipedia.org/wiki/Chaos_engineering +// See https://mailpit.axllent.org/docs/integration/chaos/ +package chaos + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + + "github.com/axllent/mailpit/internal/logger" +) + +var ( + // Enabled is a flag to enable or disable support for chaos + Enabled = false + + // Config is the global Chaos configuration + Config = Triggers{ + Sender: Trigger{ErrorCode: 451, Probability: 0}, + Recipient: Trigger{ErrorCode: 451, Probability: 0}, + Authentication: Trigger{ErrorCode: 535, Probability: 0}, + } +) + +// Triggers for the Chaos configuration +// +// swagger:model Triggers +type Triggers struct { + // Sender trigger to fail on From, Sender + Sender Trigger + // Recipient trigger to fail on To, Cc, Bcc + Recipient Trigger + // Authentication trigger to fail while authenticating (auth must be configured) + Authentication Trigger +} + +// Trigger for Chaos +type Trigger struct { + // SMTP error code to return. The value must range from 400 to 599. + // required: true + // example: 451 + ErrorCode int + + // Probability (chance) of triggering the error. The value must range from 0 to 100. + // required: true + // example: 5 + Probability int +} + +// SetFromStruct will set a whole map of chaos configurations (ie: API) +func SetFromStruct(c Triggers) error { + if c.Sender.ErrorCode == 0 { + c.Sender.ErrorCode = 451 // default + } + + if c.Recipient.ErrorCode == 0 { + c.Recipient.ErrorCode = 451 // default + } + + if c.Authentication.ErrorCode == 0 { + c.Authentication.ErrorCode = 535 // default + } + + if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil { + return err + } + if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil { + return err + } + if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil { + return err + } + + return nil +} + +// Set will set the chaos configuration for the given key (CLI & setMap()) +func Set(key string, errorCode int, probability int) error { + Enabled = true + if errorCode < 400 || errorCode > 599 { + return fmt.Errorf("error code must be between 400 and 599") + } + + if probability > 100 || probability < 0 { + return fmt.Errorf("probability must be between 0 and 100") + } + + key = strings.ToLower(key) + + switch key { + case "sender": + Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability} + logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability) + case "recipient", "recipients": + Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability} + logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability) + case "auth", "authentication": + Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability} + logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability) + default: + return fmt.Errorf("unknown key %s", key) + } + + return nil +} + +// Trigger will return whether the Chaos rule is triggered based on the configuration +// and a randomly-generated percentage value. +func (c Trigger) Trigger() (bool, int) { + if !Enabled || c.Probability == 0 { + return false, 0 + } + + nBig, _ := rand.Int(rand.Reader, big.NewInt(100)) + + // rand.IntN(100) will return 0-99, whereas probability is 1-100, + // so value must be less than (not <=) to the probability to trigger + return int(nBig.Int64()) < c.Probability, c.ErrorCode +} diff --git a/internal/smtpd/smtpd.go b/internal/smtpd/smtpd.go index 309b103..c1ed7ba 100644 --- a/internal/smtpd/smtpd.go +++ b/internal/smtpd/smtpd.go @@ -1,7 +1,7 @@ // Package smtpd implements a basic SMTP server. // // This is a modified version of https://github.com/mhale/smtpd to -// add optional support for unix sockets. +// add support for unix sockets and Mailpit Chaos. package smtpd import ( @@ -22,6 +22,8 @@ import ( "sync" "sync/atomic" "time" + + "github.com/axllent/mailpit/internal/smtpd/chaos" ) var ( @@ -411,6 +413,12 @@ loop: if match == nil { s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)") } else { + // Mailpit Chaos + if fail, code := chaos.Config.Sender.Trigger(); fail { + s.writef("%d Chaos sender error", code) + break + } + // Validate the SIZE parameter if one was sent. if len(match[2]) > 0 { // A parameter is present sizeMatch := mailFromSizeRE.FindStringSubmatch(match[3]) @@ -439,6 +447,7 @@ loop: s.writef("250 2.1.0 Ok") } } + to = nil buffer.Reset() case "RCPT": @@ -459,10 +468,17 @@ loop: if match == nil { s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)") } else { + // Mailpit Chaos + if fail, code := chaos.Config.Recipient.Trigger(); fail { + s.writef("%d Chaos recipient error", code) + break + } + // RFC 5321 specifies support for minimum of 100 recipients is required. if s.srv.MaxRecipients == 0 { s.srv.MaxRecipients = 100 } + if len(to) == s.srv.MaxRecipients { s.writef("452 4.5.3 Too many recipients") } else { @@ -685,6 +701,12 @@ loop: break } + // Mailpit Chaos + if fail, code := chaos.Config.Authentication.Trigger(); fail { + s.writef("%d Chaos authentication error", code) + break + } + // RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned // when attempting to use an unsupported authentication type. // Many servers return 5.7.4 ("Security features not supported") instead. diff --git a/server/apiv1/application.go b/server/apiv1/application.go index 35178ae..66a6d49 100644 --- a/server/apiv1/application.go +++ b/server/apiv1/application.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/internal/smtpd/chaos" "github.com/axllent/mailpit/internal/stats" ) @@ -67,6 +68,9 @@ type webUIConfiguration struct { // Whether SpamAssassin is enabled SpamAssassin bool + // Whether Chaos support is enabled at runtime + ChaosEnabled bool + // Whether messages with duplicate IDs are ignored DuplicatesIgnored bool } @@ -112,6 +116,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) { } conf.SpamAssassin = config.EnableSpamAssassin != "" + conf.ChaosEnabled = chaos.Enabled conf.DuplicatesIgnored = config.IgnoreDuplicateIDs w.Header().Add("Content-Type", "application/json") diff --git a/server/apiv1/chaos.go b/server/apiv1/chaos.go new file mode 100644 index 0000000..c2ff3b4 --- /dev/null +++ b/server/apiv1/chaos.go @@ -0,0 +1,112 @@ +package apiv1 + +import ( + "encoding/json" + "net/http" + + "github.com/axllent/mailpit/internal/smtpd/chaos" +) + +// ChaosTriggers is the Chaos configuration +// +// swagger:model Triggers +type ChaosTriggers chaos.Triggers + +// Response for the Chaos triggers configuration +// swagger:response ChaosResponse +type chaosResponse struct { + // The current Chaos triggers + // + // in: body + Body ChaosTriggers +} + +// GetChaos returns the current Chaos triggers +func GetChaos(w http.ResponseWriter, _ *http.Request) { + // swagger:route GET /api/v1/chaos testing getChaos + // + // # Get Chaos triggers + // + // Returns the current Chaos triggers configuration. + // This API route will return an error if Chaos is not enabled at runtime. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: ChaosResponse + // 400: ErrorResponse + + if !chaos.Enabled { + httpError(w, "Chaos is not enabled") + return + } + + conf := chaos.Config + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(conf); err != nil { + httpError(w, err.Error()) + } +} + +// swagger:parameters setChaosParams +type setChaosParams struct { + // in: body + Body ChaosTriggers +} + +// SetChaos sets the Chaos configuration. +func SetChaos(w http.ResponseWriter, r *http.Request) { + // swagger:route PUT /api/v1/chaos testing setChaosParams + // + // # Set Chaos triggers + // + // Set the Chaos triggers configuration and return the updated values. + // This API route will return an error if Chaos is not enabled at runtime. + // + // If any triggers are omitted from the request, then those are reset to their + // default values with a 0% probability (ie: disabled). + // Setting a blank `{}` will reset all triggers to their default values. + // + // Consumes: + // - application/json + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: ChaosResponse + // 400: ErrorResponse + + if !chaos.Enabled { + httpError(w, "Chaos is not enabled") + return + } + + data := chaos.Triggers{} + + decoder := json.NewDecoder(r.Body) + + err := decoder.Decode(&data) + if err != nil { + httpError(w, err.Error()) + return + } + + if err := chaos.SetFromStruct(data); err != nil { + httpError(w, err.Error()) + return + } + + conf := chaos.Config + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(conf); err != nil { + httpError(w, err.Error()) + } +} diff --git a/server/server.go b/server/server.go index c98e91c..bdd43ad 100644 --- a/server/server.go +++ b/server/server.go @@ -183,6 +183,10 @@ func apiRoutes() *mux.Router { r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET") + // Chaos + r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET") + r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT") + // web UI websocket r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET") diff --git a/server/ui-src/components/Settings.vue b/server/ui-src/components/Settings.vue index 26bfa60..44dc943 100644 --- a/server/ui-src/components/Settings.vue +++ b/server/ui-src/components/Settings.vue @@ -12,6 +12,8 @@ export default { mailbox, theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto', timezones, + chaosConfig: false, + chaosUpdated: false, } }, @@ -23,6 +25,13 @@ export default { localStorage.setItem('theme', v) } this.setTheme() + }, + + chaosConfig: { + handler() { + this.chaosUpdated = true + }, + deep: true } }, @@ -44,6 +53,24 @@ export default { document.documentElement.setAttribute('data-bs-theme', this.theme) } }, + + loadChaos() { + this.get(this.resolve('/api/v1/chaos'), null, (response) => { + this.chaosConfig = response.data + this.$nextTick(() => { + this.chaosUpdated = false + }) + }) + }, + + saveChaos() { + this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => { + this.chaosConfig = response.data + this.$nextTick(() => { + this.chaosUpdated = false + }) + }) + } } } @@ -54,64 +81,189 @@ export default {