diff options
| author | Aleksandr Nogikh <nogikh@google.com> | 2025-05-07 17:16:25 +0200 |
|---|---|---|
| committer | Taras Madan <tarasmadan@google.com> | 2025-05-09 08:56:10 +0000 |
| commit | 3af71fd3fd904701cb3b0d9047f2abe22458f7cf (patch) | |
| tree | f98b7ac6428f0139aa879da4311312ccf53205a5 | |
| parent | 8ea579ebca8bcbeb1325837666a3093e86d45fa4 (diff) | |
syz-cluster: implement SMTP sender
Format raw emails and send them over SMTP. Take credentials from the
secret storage.
| -rw-r--r-- | syz-cluster/email-reporter/Dockerfile | 4 | ||||
| -rw-r--r-- | syz-cluster/email-reporter/main.go | 5 | ||||
| -rw-r--r-- | syz-cluster/email-reporter/sender.go | 142 | ||||
| -rw-r--r-- | syz-cluster/email-reporter/sender_test.go | 47 |
4 files changed, 188 insertions, 10 deletions
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: "<reply-to@domain>", + Subject: "subject", + Body: []byte("Email body"), + }, + id: "<id@domain>", + result: "From: name <a@b.com>\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: <reply-to@domain>\r\n" + + "Message-ID: <id@domain>\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)) + }) + } +} |
