Skip to content

Commit f278933

Browse files
committed
Merge branch 'feature/chaos' into develop
2 parents 168049f + 4d86297 commit f278933

File tree

12 files changed

+641
-60
lines changed

12 files changed

+641
-60
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
4848
- [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
4949
- 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,
5050
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
51+
- [Chaos](ttps://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
5152
- `List-Unsubscribe` syntax validation
5253
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
5354

cmd/root.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/axllent/mailpit/internal/auth"
1111
"github.com/axllent/mailpit/internal/logger"
1212
"github.com/axllent/mailpit/internal/smtpd"
13+
"github.com/axllent/mailpit/internal/smtpd/chaos"
1314
"github.com/axllent/mailpit/internal/storage"
1415
"github.com/axllent/mailpit/internal/tools"
1516
"github.com/axllent/mailpit/server"
@@ -122,6 +123,10 @@ func init() {
122123
rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
123124
rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
124125

126+
// Chaos
127+
rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)")
128+
rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server")
129+
125130
// POP3 server
126131
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
127132
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() {
281286
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
282287
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
283288

289+
// Chaos
290+
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
291+
config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS")
292+
284293
// POP3 server
285294
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
286295
config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR")

config/config.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/axllent/mailpit/internal/auth"
1717
"github.com/axllent/mailpit/internal/logger"
18+
"github.com/axllent/mailpit/internal/smtpd/chaos"
1819
"github.com/axllent/mailpit/internal/spamassassin"
1920
"github.com/axllent/mailpit/internal/tools"
2021
"gopkg.in/yaml.v3"
@@ -176,6 +177,9 @@ var (
176177
// RepoBinaryName on Github for updater
177178
RepoBinaryName = "mailpit"
178179

180+
// ChaosTriggers are parsed and set in the chaos module
181+
ChaosTriggers string
182+
179183
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
180184
DisableHTMLCheck = false
181185

@@ -344,6 +348,14 @@ func VerifyConfig() error {
344348
return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
345349
}
346350

351+
if err := parseChaosTriggers(); err != nil {
352+
return fmt.Errorf("[chaos] %s", err.Error())
353+
}
354+
355+
if chaos.Enabled {
356+
logger.Log().Info("[chaos] is enabled")
357+
}
358+
347359
// POP3 server
348360
if POP3TLSCert != "" {
349361
POP3TLSCert = filepath.Clean(POP3TLSCert)
@@ -602,6 +614,39 @@ func validateRelayConfig() error {
602614
return nil
603615
}
604616

617+
func parseChaosTriggers() error {
618+
if ChaosTriggers == "" {
619+
return nil
620+
}
621+
622+
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
623+
624+
parts := strings.Split(ChaosTriggers, ",")
625+
for _, p := range parts {
626+
p = strings.TrimSpace(p)
627+
if !re.MatchString(p) {
628+
return fmt.Errorf("invalid argument: %s", p)
629+
}
630+
631+
matches := re.FindAllStringSubmatch(p, 1)
632+
key := matches[0][1]
633+
errorCode, err := strconv.Atoi(matches[0][2])
634+
if err != nil {
635+
return err
636+
}
637+
probability, err := strconv.Atoi(matches[0][3])
638+
if err != nil {
639+
return err
640+
}
641+
642+
if err := chaos.Set(key, errorCode, probability); err != nil {
643+
return err
644+
}
645+
}
646+
647+
return nil
648+
}
649+
605650
// IsFile returns whether a file exists and is readable
606651
func isFile(path string) bool {
607652
f, err := os.Open(filepath.Clean(path))

internal/smtpd/chaos/chaos.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server.
2+
// See https://en.wikipedia.org/wiki/Chaos_engineering
3+
// See https://mailpit.axllent.org/docs/integration/chaos/
4+
package chaos
5+
6+
import (
7+
"crypto/rand"
8+
"fmt"
9+
"math/big"
10+
"strings"
11+
12+
"github.com/axllent/mailpit/internal/logger"
13+
)
14+
15+
var (
16+
// Enabled is a flag to enable or disable support for chaos
17+
Enabled = false
18+
19+
// Config is the global Chaos configuration
20+
Config = Triggers{
21+
Sender: Trigger{ErrorCode: 451, Probability: 0},
22+
Recipient: Trigger{ErrorCode: 451, Probability: 0},
23+
Authentication: Trigger{ErrorCode: 535, Probability: 0},
24+
}
25+
)
26+
27+
// Triggers for the Chaos configuration
28+
//
29+
// swagger:model Triggers
30+
type Triggers struct {
31+
// Sender trigger to fail on From, Sender
32+
Sender Trigger
33+
// Recipient trigger to fail on To, Cc, Bcc
34+
Recipient Trigger
35+
// Authentication trigger to fail while authenticating (auth must be configured)
36+
Authentication Trigger
37+
}
38+
39+
// Trigger for Chaos
40+
type Trigger struct {
41+
// SMTP error code to return. The value must range from 400 to 599.
42+
// required: true
43+
// example: 451
44+
ErrorCode int
45+
46+
// Probability (chance) of triggering the error. The value must range from 0 to 100.
47+
// required: true
48+
// example: 5
49+
Probability int
50+
}
51+
52+
// SetFromStruct will set a whole map of chaos configurations (ie: API)
53+
func SetFromStruct(c Triggers) error {
54+
if c.Sender.ErrorCode == 0 {
55+
c.Sender.ErrorCode = 451 // default
56+
}
57+
58+
if c.Recipient.ErrorCode == 0 {
59+
c.Recipient.ErrorCode = 451 // default
60+
}
61+
62+
if c.Authentication.ErrorCode == 0 {
63+
c.Authentication.ErrorCode = 535 // default
64+
}
65+
66+
if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil {
67+
return err
68+
}
69+
if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil {
70+
return err
71+
}
72+
if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil {
73+
return err
74+
}
75+
76+
return nil
77+
}
78+
79+
// Set will set the chaos configuration for the given key (CLI & setMap())
80+
func Set(key string, errorCode int, probability int) error {
81+
Enabled = true
82+
if errorCode < 400 || errorCode > 599 {
83+
return fmt.Errorf("error code must be between 400 and 599")
84+
}
85+
86+
if probability > 100 || probability < 0 {
87+
return fmt.Errorf("probability must be between 0 and 100")
88+
}
89+
90+
key = strings.ToLower(key)
91+
92+
switch key {
93+
case "sender":
94+
Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability}
95+
logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability)
96+
case "recipient", "recipients":
97+
Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability}
98+
logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability)
99+
case "auth", "authentication":
100+
Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability}
101+
logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability)
102+
default:
103+
return fmt.Errorf("unknown key %s", key)
104+
}
105+
106+
return nil
107+
}
108+
109+
// Trigger will return whether the Chaos rule is triggered based on the configuration
110+
// and a randomly-generated percentage value.
111+
func (c Trigger) Trigger() (bool, int) {
112+
if !Enabled || c.Probability == 0 {
113+
return false, 0
114+
}
115+
116+
nBig, _ := rand.Int(rand.Reader, big.NewInt(100))
117+
118+
// rand.IntN(100) will return 0-99, whereas probability is 1-100,
119+
// so value must be less than (not <=) to the probability to trigger
120+
return int(nBig.Int64()) < c.Probability, c.ErrorCode
121+
}

internal/smtpd/smtpd.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Package smtpd implements a basic SMTP server.
22
//
33
// This is a modified version of https://github.com/mhale/smtpd to
4-
// add optional support for unix sockets.
4+
// add support for unix sockets and Mailpit Chaos.
55
package smtpd
66

77
import (
@@ -22,6 +22,8 @@ import (
2222
"sync"
2323
"sync/atomic"
2424
"time"
25+
26+
"github.com/axllent/mailpit/internal/smtpd/chaos"
2527
)
2628

2729
var (
@@ -390,7 +392,7 @@ loop:
390392
buffer.Reset()
391393
case "EHLO":
392394
s.remoteName = args
393-
s.writef(s.makeEHLOResponse())
395+
s.writef("%s", s.makeEHLOResponse())
394396

395397
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
396398
from = ""
@@ -411,6 +413,12 @@ loop:
411413
if match == nil {
412414
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
413415
} else {
416+
// Mailpit Chaos
417+
if fail, code := chaos.Config.Sender.Trigger(); fail {
418+
s.writef("%d Chaos sender error", code)
419+
break
420+
}
421+
414422
// Validate the SIZE parameter if one was sent.
415423
if len(match[2]) > 0 { // A parameter is present
416424
sizeMatch := mailFromSizeRE.FindStringSubmatch(match[3])
@@ -439,6 +447,7 @@ loop:
439447
s.writef("250 2.1.0 Ok")
440448
}
441449
}
450+
442451
to = nil
443452
buffer.Reset()
444453
case "RCPT":
@@ -459,10 +468,17 @@ loop:
459468
if match == nil {
460469
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
461470
} else {
471+
// Mailpit Chaos
472+
if fail, code := chaos.Config.Recipient.Trigger(); fail {
473+
s.writef("%d Chaos recipient error", code)
474+
break
475+
}
476+
462477
// RFC 5321 specifies support for minimum of 100 recipients is required.
463478
if s.srv.MaxRecipients == 0 {
464479
s.srv.MaxRecipients = 100
465480
}
481+
466482
if len(to) == s.srv.MaxRecipients {
467483
s.writef("452 4.5.3 Too many recipients")
468484
} else {
@@ -685,6 +701,12 @@ loop:
685701
break
686702
}
687703

704+
// Mailpit Chaos
705+
if fail, code := chaos.Config.Authentication.Trigger(); fail {
706+
s.writef("%d Chaos authentication error", code)
707+
break
708+
}
709+
688710
// RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned
689711
// when attempting to use an unsupported authentication type.
690712
// Many servers return 5.7.4 ("Security features not supported") instead.

server/apiv1/application.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77

88
"github.com/axllent/mailpit/config"
9+
"github.com/axllent/mailpit/internal/smtpd/chaos"
910
"github.com/axllent/mailpit/internal/stats"
1011
)
1112

@@ -67,6 +68,9 @@ type webUIConfiguration struct {
6768
// Whether SpamAssassin is enabled
6869
SpamAssassin bool
6970

71+
// Whether Chaos support is enabled at runtime
72+
ChaosEnabled bool
73+
7074
// Whether messages with duplicate IDs are ignored
7175
DuplicatesIgnored bool
7276
}
@@ -112,6 +116,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
112116
}
113117

114118
conf.SpamAssassin = config.EnableSpamAssassin != ""
119+
conf.ChaosEnabled = chaos.Enabled
115120
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
116121

117122
w.Header().Add("Content-Type", "application/json")

0 commit comments

Comments
 (0)