Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add built-in SMTP ingress server #3184

Merged
merged 43 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
15c709a
stashing work
Jul 3, 2023
ae04b2e
adapt getTLSConfig() for mutli use
Jul 3, 2023
19d0371
finish basic app config changes
Jul 3, 2023
a998ad9
rough outline of new smtp server integrating with main app
Jul 3, 2023
4b7bfff
working SMTP internal server, only logs message info for now
Jul 10, 2023
f57bca2
add processing for incoming emails
Jul 11, 2023
ff5766f
pass smtp email domain from App.cfg to config.Config to support integ…
Jul 12, 2023
e4facf6
re-generate schema and check for SMTPServer.EmailDomain in intkey form
Jul 12, 2023
e692857
stash changed schema
Jul 12, 2023
76e9b09
Merge remote-tracking branch 'origin' into smtp-int
Jul 12, 2023
64e9c1b
lowercased smtpserver
Jul 13, 2023
4a5479d
Merge remote-tracking branch 'upstream/int-key-type-api' into smtp-int
Jul 14, 2023
bb5c9bc
add message parsing functions
Jul 14, 2023
393e1ea
add config changes that support smtp setup
Jul 14, 2023
cee6a04
add tests (still need some work)
Jul 17, 2023
3e4121d
finish unit tests
Jul 18, 2023
f2fde15
start smtp alerts smoke test
Jul 18, 2023
c72ab5b
finish smoke tests, some cleanup
Jul 19, 2023
0aebfbc
Merge remote-tracking branch 'upstream/master' into smtp-int
Jul 19, 2023
6d31284
finish docs, fix call to CreateOrUpdate alert
Jul 19, 2023
905358c
allow for TLS and non-TLS listeners to ensure STARTTLS works
Jul 20, 2023
3733d59
update flag order
mastercactapus Jul 25, 2023
fa77b94
ignore empty values
mastercactapus Jul 25, 2023
b8b1495
use var for empty values
mastercactapus Jul 25, 2023
33c06f6
simplify/refactor getTLSConfig
mastercactapus Jul 25, 2023
4bd9533
remove unused method, organize config fields
mastercactapus Jul 25, 2023
9bb4e0e
markdown formatting
mastercactapus Jul 25, 2023
ce28395
get text even if it's not the first part of the message
mastercactapus Jul 26, 2023
cd9ab65
re-organize smtpsrv code
mastercactapus Jul 26, 2023
8f7ce6f
update unit tests
mastercactapus Jul 26, 2023
812df06
add fuzz test for parse
mastercactapus Jul 26, 2023
6c007e1
cleanup smtp init
mastercactapus Jul 26, 2023
09f739f
serve traffic in rrun context
mastercactapus Jul 26, 2023
6ab6c89
fix smtp smoketest
mastercactapus Jul 26, 2023
59ba834
Merge remote-tracking branch 'origin/master' into pr/nimjor/3184-2
mastercactapus Jul 26, 2023
718a82e
ignore test error
mastercactapus Jul 26, 2023
cd1077e
flatten override value
mastercactapus Jul 26, 2023
2b3224a
fix tls config prefix for smtp, add back additional domains flag
Jul 27, 2023
fca4a49
add email integration domain flag
mastercactapus Jul 27, 2023
b119a4f
use letters mail parser instead of writing one
Aug 3, 2023
7d0199a
Merge branch 'master' into pr/nimjor/3184-2
mastercactapus Aug 7, 2023
9548fb8
Merge branch 'smtp-int' of github.com:nimjor/goalert into pr/nimjor/3…
mastercactapus Aug 7, 2023
c45cc5b
add comments for public methods/types
mastercactapus Aug 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Procfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
build: while true; do make -qs bin/goalert || make bin/goalert || (echo '\033[0;31mBuild Failure'; sleep 3); sleep 0.1; done

@watch-file=./bin/goalert
goalert: ./bin/goalert -l=localhost:3030 --ui-dir=web/src/build --db-url=postgres://goalert@localhost --listen-sysapi=localhost:1234 --listen-prometheus=localhost:2112
goalert: ./bin/goalert -l=localhost:3030 --ui-dir=web/src/build --db-url=postgres://goalert@localhost --listen-sysapi=localhost:1234 --listen-prometheus=localhost:2112 --smtp-listen=localhost:9025

smtp: go run github.com/mailhog/MailHog -ui-bind-addr=localhost:8025 -api-bind-addr=localhost:8025 -smtp-bind-addr=localhost:1025 | grep -v KEEPALIVE
prom: bin/tools/prometheus --log.level=warn --config.file=devtools/prometheus/prometheus.yml --storage.tsdb.path=bin/prom-data/ --web.listen-address=localhost:9090
Expand Down
11 changes: 11 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import (
"github.com/target/goalert/schedule/rotation"
"github.com/target/goalert/schedule/rule"
"github.com/target/goalert/service"
"github.com/target/goalert/smtpsrv"
"github.com/target/goalert/timezone"
"github.com/target/goalert/user"
"github.com/target/goalert/user/contactmethod"
Expand Down Expand Up @@ -67,6 +68,8 @@ type App struct {
hSrv *health.Server

srv *http.Server
smtpsrv *smtpsrv.Server
smtpsrvL net.Listener
startupErr error

notificationManager *notification.Manager
Expand Down Expand Up @@ -204,3 +207,11 @@ func (a *App) DB() *sql.DB { return a.db }
func (a *App) URL() string {
return "http://" + a.l.Addr().String()
}

func (a *App) SMTPAddr() string {
if a.smtpsrvL == nil {
return ""
}

return a.smtpsrvL.Addr().String()
}
44 changes: 39 additions & 5 deletions app/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,11 @@ Migration: %s (#%d)

ctx := cmd.Context()

store, err := config.NewStore(ctx, conn, cf.EncryptionKeys, "", "")
storeCfg := config.StoreConfig{
DB: conn,
Keys: cf.EncryptionKeys,
}
store, err := config.NewStore(ctx, storeCfg)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
Expand Down Expand Up @@ -621,6 +625,12 @@ func getConfig(ctx context.Context) (Config, error) {
SysAPIKeyFile: viper.GetString("sysapi-key-file"),
SysAPICAFile: viper.GetString("sysapi-ca-file"),

SMTPListenAddr: viper.GetString("smtp-listen"),
SMTPListenAddrTLS: viper.GetString("smtp-listen-tls"),
SMTPAdditionalDomains: viper.GetString("smtp-additional-domains"),

EmailIntegrationDomain: viper.GetString("email-integration-domain"),

EngineCycleTime: viper.GetDuration("engine-cycle-time"),

HTTPPrefix: viper.GetString("http-prefix"),
Expand Down Expand Up @@ -673,15 +683,28 @@ func getConfig(ctx context.Context) (Config, error) {
}

var err error
cfg.TLSConfig, err = getTLSConfig()
cfg.TLSConfig, err = getTLSConfig("")
if err != nil {
return cfg, err
}
if cfg.TLSConfig != nil {
cfg.TLSConfig.NextProtos = []string{"h2", "http/1.1"}
}

if cfg.SMTPListenAddr != "" || cfg.SMTPListenAddrTLS != "" {
if cfg.EmailIntegrationDomain == "" {
return cfg, errors.New("email-integration-domain is required when smtp-listen or smtp-listen-tls is set")
}
}

cfg.TLSConfigSMTP, err = getTLSConfig("smtp-")
if err != nil {
return cfg, err
}

if viper.GetBool("stack-traces") {
log.FromContext(ctx).EnableStacks()
}

return cfg, nil
}

Expand All @@ -691,12 +714,12 @@ func init() {

RootCmd.Flags().StringP("listen-tls", "t", def.TLSListenAddr, "HTTPS listen address:port for the application. Requires setting --tls-cert-data and --tls-key-data OR --tls-cert-file and --tls-key-file.")

RootCmd.Flags().String("listen-sysapi", "", "(Experimental) Listen address:port for the system API (gRPC).")

RootCmd.Flags().StringSlice("experimental", nil, "Enable experimental features.")
RootCmd.Flags().Bool("list-experimental", false, "List experimental features.")
RootCmd.Flags().Bool("strict-experimental", false, "Fail to start if unknown experimental features are specified.")

RootCmd.Flags().String("listen-sysapi", "", "(Experimental) Listen address:port for the system API (gRPC).")

RootCmd.Flags().String("sysapi-cert-file", "", "(Experimental) Specifies a path to a PEM-encoded certificate to use when connecting to plugin services.")
RootCmd.Flags().String("sysapi-key-file", "", "(Experimental) Specifies a path to a PEM-encoded private key file use when connecting to plugin services.")
RootCmd.Flags().String("sysapi-ca-file", "", "(Experimental) Specifies a path to a PEM-encoded certificate(s) to authorize connections from plugin services.")
Expand All @@ -710,6 +733,17 @@ func init() {
RootCmd.Flags().String("tls-cert-data", "", "Specifies a PEM-encoded certificate. Has no effect if --listen-tls is unset.")
RootCmd.Flags().String("tls-key-data", "", "Specifies a PEM-encoded private key. Has no effect if --listen-tls is unset.")

RootCmd.Flags().String("smtp-listen", "", "Listen address:port for an internal SMTP server.")
RootCmd.Flags().String("smtp-listen-tls", "", "SMTPS listen address:port for an internal SMTP server. Requires setting --smtp-tls-cert-data and --smtp-tls-key-data OR --smtp-tls-cert-file and --smtp-tls-key-file.")
RootCmd.Flags().String("email-integration-domain", "", "This flag is required to set the domain used for email integration keys when --smtp-listen or --smtp-listen-tls are set.")

RootCmd.Flags().String("smtp-tls-cert-file", "", "Specifies a path to a PEM-encoded certificate. Has no effect if --smtp-listen-tls is unset.")
RootCmd.Flags().String("smtp-tls-key-file", "", "Specifies a path to a PEM-encoded private key file. Has no effect if --smtp-listen-tls is unset.")
RootCmd.Flags().String("smtp-tls-cert-data", "", "Specifies a PEM-encoded certificate. Has no effect if --smtp-listen-tls is unset.")
RootCmd.Flags().String("smtp-tls-key-data", "", "Specifies a PEM-encoded private key. Has no effect if --smtp-listen-tls is unset.")

RootCmd.Flags().String("smtp-additional-domains", "", "Specifies additional destination domains that are allowed for the SMTP server. For multiple domains, separate them with a comma, e.g., \"domain1.com,domain2.org,domain3.net\".")

RootCmd.Flags().Duration("engine-cycle-time", def.EngineCycleTime, "Time between engine cycles.")

RootCmd.Flags().String("http-prefix", def.HTTPPrefix, "Specify the HTTP prefix of the application.")
Expand Down
7 changes: 7 additions & 0 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ type Config struct {
SysAPIKeyFile string
SysAPICAFile string

SMTPListenAddr string
SMTPListenAddrTLS string
TLSConfigSMTP *tls.Config
SMTPAdditionalDomains string

EmailIntegrationDomain string

HTTPPrefix string

DBMaxOpen int
Expand Down
6 changes: 5 additions & 1 deletion app/getsetconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ func getSetConfig(ctx context.Context, setCfg bool, data []byte) error {
}
defer sqlutil.Rollback(ctx, "app: get/set config", tx)

s, err := config.NewStore(ctx, db, c.EncryptionKeys, "", "")
storeCfg := config.StoreConfig{
DB: db,
Keys: c.EncryptionKeys,
}
s, err := config.NewStore(ctx, storeCfg)
if err != nil {
return errors.Wrap(err, "init config store")
}
Expand Down
69 changes: 69 additions & 0 deletions app/initsmtpsrv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package app

import (
"context"
"crypto/tls"
"net"
_ "net/url"
"strings"

"github.com/target/goalert/alert"
"github.com/target/goalert/auth/authtoken"
"github.com/target/goalert/integrationkey"
"github.com/target/goalert/smtpsrv"
)

func (app *App) initSMTPServer(ctx context.Context) error {
if app.cfg.SMTPListenAddr == "" && app.cfg.SMTPListenAddrTLS == "" {
return nil
}

cfg := smtpsrv.Config{
Domain: app.cfg.EmailIntegrationDomain,
AllowedDomains: parseAllowedDomains(app.cfg.SMTPAdditionalDomains, app.cfg.EmailIntegrationDomain),
TLSConfig: app.cfg.TLSConfigSMTP,
AuthorizeFunc: func(ctx context.Context, id string) (context.Context, error) {
tok, _, err := authtoken.Parse(id, nil)
if err != nil {
return nil, err
}

ctx, err = app.IntegrationKeyStore.Authorize(ctx, *tok, integrationkey.TypeEmail)
if err != nil {
return nil, err
}

return ctx, nil
},
CreateAlertFunc: func(ctx context.Context, a *alert.Alert) error {
_, _, err := app.AlertStore.CreateOrUpdate(ctx, a)
return err
},
}

app.smtpsrv = smtpsrv.NewServer(cfg)
var err error
if app.cfg.SMTPListenAddr != "" {
app.smtpsrvL, err = net.Listen("tcp", app.cfg.SMTPListenAddr)
if err != nil {
return err
}
}

if app.cfg.SMTPListenAddrTLS != "" {
l, err := tls.Listen("tcp", app.cfg.SMTPListenAddrTLS, cfg.TLSConfig)
if err != nil {
return err
}
app.smtpsrvL = newMultiListener(app.cfg.Logger, app.smtpsrvL, l)
}

return nil
}

func parseAllowedDomains(additionalDomains string, primaryDomain string) []string {
if !strings.Contains(additionalDomains, primaryDomain) {
additionalDomains = strings.Join([]string{additionalDomains, primaryDomain}, ",")
}
return strings.Split(additionalDomains, ",")
}
9 changes: 8 additions & 1 deletion app/initstores.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,14 @@ func (app *App) initStores(ctx context.Context) error {
fallback.Scheme = "http"
fallback.Host = app.l.Addr().String()
fallback.Path = app.cfg.HTTPPrefix
app.ConfigStore, err = config.NewStore(ctx, app.db, app.cfg.EncryptionKeys, app.cfg.PublicURL, fallback.String())
storeCfg := config.StoreConfig{
DB: app.db,
Keys: app.cfg.EncryptionKeys,
FallbackURL: fallback.String(),
ExplicitURL: app.cfg.PublicURL,
IngressEmailDomain: app.cfg.EmailIntegrationDomain,
}
app.ConfigStore, err = config.NewStore(ctx, storeCfg)
}
if err != nil {
return errors.Wrap(err, "init config store")
Expand Down
8 changes: 8 additions & 0 deletions app/multilistener.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ type multiListener struct {
}

func newMultiListener(logger *log.Logger, ln ...net.Listener) *multiListener {
nonEmpty := make([]net.Listener, 0, len(ln))
for _, l := range ln {
if l != nil {
nonEmpty = append(nonEmpty, l)
}
}
ln = nonEmpty

ml := multiListener{
l: logger,
listeners: ln,
Expand Down
9 changes: 9 additions & 0 deletions app/runapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ func (app *App) _Run(ctx context.Context) error {
}()
}

if app.smtpsrv != nil {
log.Logf(log.WithField(ctx, "address", app.smtpsrvL.Addr().String()), "SMTP server started.")
go func() {
if err := app.smtpsrv.ServeSMTP(app.smtpsrvL); err != nil {
log.Log(ctx, err)
}
}()
}

log.Logf(
log.WithFields(ctx, log.Fields{
"address": app.l.Addr().String(),
Expand Down
1 change: 1 addition & 0 deletions app/shutdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func (app *App) _Shutdown(ctx context.Context) error {
// so things like message responses are handled before
// shutting down things like the engine or notification manager
// that would still need to process them.
shut(app.smtpsrv, "SMTP receiver server")
shut(app.srv, "HTTP server")
shut(app.Engine, "engine")
shut(app.events, "event listener")
Expand Down
2 changes: 2 additions & 0 deletions app/startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ func (app *App) startup(ctx context.Context) error {
app.initStartup(ctx, "Startup.HTTPServer", app.initHTTP)
app.initStartup(ctx, "Startup.SysAPI", app.initSysAPI)

app.initStartup(ctx, "Startup.SMTPServer", app.initSMTPServer)

if app.startupErr != nil {
return app.startupErr
}
Expand Down
62 changes: 32 additions & 30 deletions app/tlsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,52 @@ package app

import (
"crypto/tls"
"fmt"

"github.com/pkg/errors"
"github.com/spf13/viper"
)

type tlsFlagPrefix string

func (t tlsFlagPrefix) CertFile() string { return viper.GetString(string(t) + "tls-cert-file") }
func (t tlsFlagPrefix) KeyFile() string { return viper.GetString(string(t) + "tls-key-file") }
func (t tlsFlagPrefix) CertData() string { return viper.GetString(string(t) + "tls-cert-data") }
func (t tlsFlagPrefix) KeyData() string { return viper.GetString(string(t) + "tls-key-data") }
func (t tlsFlagPrefix) Listen() string { return viper.GetString(string(t) + "listen-tls") }

func (t tlsFlagPrefix) HasFiles() bool {
return t.CertFile() != "" || t.KeyFile() != ""
}
func (t tlsFlagPrefix) HasData() bool {
return t.CertData() != "" || t.KeyData() != ""
}
func (t tlsFlagPrefix) HasAny() bool {
return t.HasFiles() || t.HasData() || t.Listen() != ""
}

// getTLSConfig creates a static TLS config using supplied certificate values.
// Returns nil if no certificate values are set.
func getTLSConfig() (*tls.Config, error) {

var n int
if viper.GetString("tls-cert-file") != "" {
n += 0b0001
}
if viper.GetString("tls-key-file") != "" {
n += 0b0010
}
if viper.GetString("tls-cert-data") != "" {
n += 0b0100
}
if viper.GetString("tls-key-data") != "" {
n += 0b1000
func getTLSConfig(t tlsFlagPrefix) (*tls.Config, error) {
if !t.HasAny() {
return nil, nil
}

var cert tls.Certificate
var err error
switch n {
case 0b0011: // file mode
cert, err = tls.LoadX509KeyPair(viper.GetString("tls-cert-file"), viper.GetString("tls-key-file"))
switch {
case t.HasFiles() == t.HasData(): // both set or unset
return nil, fmt.Errorf("invalid tls config: exactly one of --%stls-cert-file and --%stls-key-file OR --%stls-cert-data and --%stls-key-data must be specified", t, t, t, t)
case t.HasFiles():
cert, err = tls.LoadX509KeyPair(t.CertFile(), t.KeyFile())
if err != nil {
return nil, errors.Wrap(err, "load tls cert files")
return nil, fmt.Errorf("load tls cert files: %w", err)
}
case 0b1100: // data mode
cert, err = tls.X509KeyPair([]byte(viper.GetString("tls-cert-data")), []byte(viper.GetString("tls-key-data")))
case t.HasData():
cert, err = tls.X509KeyPair([]byte(t.CertData()), []byte(t.KeyData()))
if err != nil {
return nil, errors.Wrap(err, "parse tls cert")
}
case 0: // no flags set
if viper.GetString("listen-tls") == "" {
return nil, nil
return nil, fmt.Errorf("parse tls cert: %w", err)
}
fallthrough
default:
return nil, errors.New("--tls-cert-file and --tls-key-file OR --tls-cert-data and --tls-key-data must be specified")
}

return &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{"h2", "http/1.1"}}, nil
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
}
Loading