From 3af71fd3fd904701cb3b0d9047f2abe22458f7cf Mon Sep 17 00:00:00 2001 From: Aleksandr Nogikh Date: Wed, 7 May 2025 17:16:25 +0200 Subject: syz-cluster: implement SMTP sender Format raw emails and send them over SMTP. Take credentials from the secret storage. --- syz-cluster/email-reporter/Dockerfile | 4 + syz-cluster/email-reporter/main.go | 5 +- syz-cluster/email-reporter/sender.go | 142 ++++++++++++++++++++++++++++-- syz-cluster/email-reporter/sender_test.go | 47 ++++++++++ 4 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 syz-cluster/email-reporter/sender_test.go diff --git a/syz-cluster/email-reporter/Dockerfile b/syz-cluster/email-reporter/Dockerfile index 779544fe3..49ab103aa 100644 --- a/syz-cluster/email-reporter/Dockerfile +++ b/syz-cluster/email-reporter/Dockerfile @@ -6,7 +6,11 @@ WORKDIR /build COPY go.mod ./ COPY go.sum ./ RUN go mod download +COPY pkg/auth/ pkg/auth/ COPY pkg/gcs/ pkg/gcs/ +COPY pkg/gce/ pkg/gce/ +COPY pkg/email/ pkg/email/ +COPY dashboard/dashapi/ dashboard/dashapi/ # Build the tool. COPY syz-cluster/email-reporter/ syz-cluster/email-reporter/ diff --git a/syz-cluster/email-reporter/main.go b/syz-cluster/email-reporter/main.go index 52bc3f651..1c030679d 100644 --- a/syz-cluster/email-reporter/main.go +++ b/syz-cluster/email-reporter/main.go @@ -30,7 +30,10 @@ func main() { if cfg.EmailReporting == nil { app.Fatalf("reporting is not configured: %v", err) } - sender := &smtpSender{} + sender, err := newSender(ctx, cfg.EmailReporting) + if err != nil { + app.Fatalf("failed to create an SMTP sender: %s", err) + } handler := &Handler{ apiClient: app.DefaultReporterClient(), emailConfig: cfg.EmailReporting, diff --git a/syz-cluster/email-reporter/sender.go b/syz-cluster/email-reporter/sender.go index 31a1b3358..e19066ed2 100644 --- a/syz-cluster/email-reporter/sender.go +++ b/syz-cluster/email-reporter/sender.go @@ -3,20 +3,144 @@ package main -import "context" +import ( + "bytes" + "context" + "fmt" + "net/smtp" + "strconv" + "strings" -// TODO: how can we test it? -// Using some STMP server library is probably an overkill? + "github.com/google/syzkaller/pkg/gce" + "github.com/google/syzkaller/syz-cluster/pkg/app" + "github.com/google/uuid" +) type smtpSender struct { + cfg *app.EmailConfig + projectName string // needed for querying credentials +} + +func newSender(ctx context.Context, cfg *app.EmailConfig) (*smtpSender, error) { + project, err := gce.ProjectName(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query project name: %w", err) + } + return &smtpSender{ + cfg: cfg, + projectName: project, + }, nil } // Send constructs a raw email from EmailToSend and sends it over SMTP. func (sender *smtpSender) Send(ctx context.Context, item *EmailToSend) (string, error) { - // TODO: - // 1) Fill in email headers, including the Message ID. - // https://pkg.go.dev/github.com/emersion/go-message/mail#Header.GenerateMessageIDWithHostname - // 2) Send over STMP: - // https://pkg.go.dev/net/smtp - return "", nil + creds, err := sender.queryCredentials(ctx) + if err != nil { + return "", fmt.Errorf("failed to query credentials: %w", err) + } + msgID := fmt.Sprintf("<%s@%s>", uuid.NewString(), creds.host) + msg := rawEmail(sender.cfg, item, msgID) + auth := smtp.PlainAuth("", creds.host, creds.password, creds.host) + smtpAddr := fmt.Sprintf("%s:%d", creds.host, creds.port) + return msgID, smtp.SendMail(smtpAddr, auth, sender.cfg.Sender, item.recipients(), msg) +} + +func (item *EmailToSend) recipients() []string { + var ret []string + ret = append(ret, item.To...) + ret = append(ret, item.Cc...) + return unique(ret) +} + +func unique(list []string) []string { + var ret []string + seen := map[string]struct{}{} + for _, str := range list { + if _, ok := seen[str]; ok { + continue + } + seen[str] = struct{}{} + ret = append(ret, str) + } + return ret +} + +func rawEmail(cfg *app.EmailConfig, item *EmailToSend, id string) []byte { + var msg bytes.Buffer + + fmt.Fprintf(&msg, "From: %s <%s>\r\n", cfg.Name, cfg.Sender) + fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(item.To, ", ")) + if len(item.Cc) > 0 { + fmt.Fprintf(&msg, "Cc: %s\r\n", strings.Join(item.Cc, ", ")) + } + fmt.Fprintf(&msg, "Subject: %s\r\n", item.Subject) + if item.InReplyTo != "" { + inReplyTo := item.InReplyTo + if inReplyTo[0] != '<' { + inReplyTo = "<" + inReplyTo + ">" + } + fmt.Fprintf(&msg, "In-Reply-To: %s\r\n", inReplyTo) + } + if id != "" { + if id[0] != '<' { + id = "<" + id + ">" + } + fmt.Fprintf(&msg, "Message-ID: %s\r\n", id) + } + msg.WriteString("MIME-Version: 1.0\r\n") + msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + msg.WriteString("Content-Transfer-Encoding: 8bit\r\n") + msg.WriteString("\r\n") + msg.Write(item.Body) + return msg.Bytes() +} + +const ( + SecretSMTPHost string = "smtp_host" + SecretSMTPPort string = "smtp_port" + SecretSMTPUser string = "smtp_user" + SecretSMTPPassword string = "smtp_password" +) + +type smtpCredentials struct { + host string + port int + user string + password string +} + +func (sender *smtpSender) queryCredentials(ctx context.Context) (smtpCredentials, error) { + values := map[string]string{} + for _, key := range []string{ + SecretSMTPHost, SecretSMTPPort, SecretSMTPUser, SecretSMTPPassword, + } { + var err error + values[key], err = sender.querySecret(ctx, key) + if err != nil { + return smtpCredentials{}, err + } + } + port, err := strconv.Atoi(values[SecretSMTPPort]) + if err != nil { + return smtpCredentials{}, fmt.Errorf("failed to parse SMTP port: not a valid integer") + } + return smtpCredentials{ + host: values[SecretSMTPHost], + port: port, + user: values[SecretSMTPUser], + password: values[SecretSMTPPassword], + }, nil +} + +func (sender *smtpSender) querySecret(ctx context.Context, key string) (string, error) { + const retries = 3 + var err error + for i := 0; i < retries; i++ { + var val []byte + val, err := gce.LatestGcpSecret(ctx, sender.projectName, key) + if err == nil { + return string(val), nil + } + } + return "", fmt.Errorf("failed to query %v: %w", key, err) } diff --git a/syz-cluster/email-reporter/sender_test.go b/syz-cluster/email-reporter/sender_test.go new file mode 100644 index 000000000..184753a18 --- /dev/null +++ b/syz-cluster/email-reporter/sender_test.go @@ -0,0 +1,47 @@ +// Copyright 2025 syzkaller project authors. All rights reserved. +// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. + +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRawEmail(t *testing.T) { + tests := []struct { + item *EmailToSend + id string + result string + }{ + { + item: &EmailToSend{ + To: []string{"1@to.com", "2@to.com"}, + Cc: []string{"1@cc.com", "2@cc.com"}, + InReplyTo: "", + Subject: "subject", + Body: []byte("Email body"), + }, + id: "", + result: "From: name \r\n" + + "To: 1@to.com, 2@to.com\r\n" + + "Cc: 1@cc.com, 2@cc.com\r\n" + + "Subject: subject\r\n" + + "In-Reply-To: \r\n" + + "Message-ID: \r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n\r\n" + + "Email body", + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + ret := rawEmail(testEmailConfig, test.item, test.id) + assert.Equal(t, test.result, string(ret)) + }) + } +} -- cgit mrf-deployment