Skip to content

Commit

Permalink
Merge branch 'feature/chaos' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
axllent committed Jan 24, 2025
2 parents 168049f + 4d86297 commit f278933
Show file tree
Hide file tree
Showing 12 changed files with 641 additions and 60 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
121 changes: 121 additions & 0 deletions internal/smtpd/chaos/chaos.go
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 24 additions & 2 deletions internal/smtpd/smtpd.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -22,6 +22,8 @@ import (
"sync"
"sync/atomic"
"time"

"github.com/axllent/mailpit/internal/smtpd/chaos"
)

var (
Expand Down Expand Up @@ -390,7 +392,7 @@ loop:
buffer.Reset()
case "EHLO":
s.remoteName = args
s.writef(s.makeEHLOResponse())
s.writef("%s", s.makeEHLOResponse())

// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
from = ""
Expand All @@ -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])
Expand Down Expand Up @@ -439,6 +447,7 @@ loop:
s.writef("250 2.1.0 Ok")
}
}

to = nil
buffer.Reset()
case "RCPT":
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions server/apiv1/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down
Loading

0 comments on commit f278933

Please sign in to comment.