From 4ae963f81abf2889628b7d47dae833626fd0664a Mon Sep 17 00:00:00 2001 From: Aleksandr Nogikh Date: Thu, 3 Jul 2025 15:22:09 +0200 Subject: syz-cluster: support dashapi sender Refactor the configuration to support both SMTP and dashapi-based email sending functionality. --- syz-cluster/email-reporter/dashapi_sender.go | 37 ++++++ syz-cluster/email-reporter/handler.go | 31 ++--- syz-cluster/email-reporter/handler_test.go | 1 - syz-cluster/email-reporter/main.go | 49 +++++++- syz-cluster/email-reporter/sender.go | 146 ----------------------- syz-cluster/email-reporter/sender_test.go | 47 -------- syz-cluster/email-reporter/smtp_sender.go | 126 +++++++++++++++++++ syz-cluster/email-reporter/smtp_sender_test.go | 47 ++++++++ syz-cluster/overlays/minikube/global-config.yaml | 4 +- syz-cluster/pkg/app/config.go | 70 ++++++++++- 10 files changed, 340 insertions(+), 218 deletions(-) create mode 100644 syz-cluster/email-reporter/dashapi_sender.go delete mode 100644 syz-cluster/email-reporter/sender.go delete mode 100644 syz-cluster/email-reporter/sender_test.go create mode 100644 syz-cluster/email-reporter/smtp_sender.go create mode 100644 syz-cluster/email-reporter/smtp_sender_test.go diff --git a/syz-cluster/email-reporter/dashapi_sender.go b/syz-cluster/email-reporter/dashapi_sender.go new file mode 100644 index 000000000..e342084e1 --- /dev/null +++ b/syz-cluster/email-reporter/dashapi_sender.go @@ -0,0 +1,37 @@ +// 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 ( + "context" + + "github.com/google/syzkaller/dashboard/dashapi" + "github.com/google/syzkaller/pkg/email" + "github.com/google/syzkaller/syz-cluster/pkg/app" +) + +func makeDashapiSender(cfg *app.EmailConfig) (SendEmailCb, error) { + dash, err := dashapi.New(cfg.Dashapi.Client, cfg.Dashapi.Addr, "") + if err != nil { + return nil, err + } + return func(_ context.Context, item *EmailToSend) (string, error) { + sender := cfg.Dashapi.From + if item.BugID != "" { + var err error + sender, err = email.AddAddrContext(sender, cfg.Dashapi.ContextPrefix+item.BugID) + if err != nil { + return "", err + } + } + return "", dash.SendEmail(&dashapi.SendEmailReq{ + Sender: sender, + To: item.To, + Cc: item.Cc, + Subject: cfg.SubjectPrefix + item.Subject, + InReplyTo: item.InReplyTo, + Body: string(item.Body), + }) + }, nil +} diff --git a/syz-cluster/email-reporter/handler.go b/syz-cluster/email-reporter/handler.go index 8b0ff6a6c..0f2d33a19 100644 --- a/syz-cluster/email-reporter/handler.go +++ b/syz-cluster/email-reporter/handler.go @@ -15,14 +15,6 @@ import ( "github.com/google/syzkaller/syz-cluster/pkg/report" ) -type EmailToSend struct { - To []string - Cc []string - Subject string - InReplyTo string - Body []byte -} - type SendEmailCb func(context.Context, *EmailToSend) (string, error) type Handler struct { @@ -93,16 +85,19 @@ func (h *Handler) report(ctx context.Context, rep *api.SessionReport) error { if err != nil { return fmt.Errorf("failed to send: %w", err) } - // Record MessageID so that we could later trace user replies back to it. - _, err = h.apiClient.RecordReply(ctx, &api.RecordReplyReq{ - // TODO: for Lore emails, set Link = lore.Link(msgID). - MessageID: msgID, - Time: time.Now(), - ReportID: rep.ID, - Reporter: h.reporter, - }) - if err != nil { - return fmt.Errorf("failed to update: %w", err) + // Senders may not always know the MessageID of the newly sent messages (that's the case of dashapi). + if msgID != "" { + // Record MessageID so that we could later trace user replies back to it. + _, err = h.apiClient.RecordReply(ctx, &api.RecordReplyReq{ + // TODO: for Lore emails, set Link = lore.Link(msgID). + MessageID: msgID, + Time: time.Now(), + ReportID: rep.ID, + Reporter: h.reporter, + }) + if err != nil { + return fmt.Errorf("failed to record the reply: %w", err) + } } return nil } diff --git a/syz-cluster/email-reporter/handler_test.go b/syz-cluster/email-reporter/handler_test.go index c2eabcaca..b01cae14b 100644 --- a/syz-cluster/email-reporter/handler_test.go +++ b/syz-cluster/email-reporter/handler_test.go @@ -163,7 +163,6 @@ func (f *fakeSender) email() *EmailToSend { var testEmailConfig = &app.EmailConfig{ Name: "name", DocsLink: "docs", - Sender: "a@b.com", ModerationList: "moderation@list.com", ArchiveList: "archive@list.com", } diff --git a/syz-cluster/email-reporter/main.go b/syz-cluster/email-reporter/main.go index 999d70cfc..da1ebb755 100644 --- a/syz-cluster/email-reporter/main.go +++ b/syz-cluster/email-reporter/main.go @@ -7,6 +7,7 @@ package main import ( "context" + "fmt" "log" "time" @@ -36,16 +37,16 @@ func main() { if cfg.EmailReporting == nil { app.Fatalf("reporting is not configured: %v", err) } - sender, err := newSender(ctx, cfg.EmailReporting) + sender, err := makeSender(ctx, cfg.EmailReporting) if err != nil { - app.Fatalf("failed to create an SMTP sender: %s", err) + app.Fatalf("failed to create a sender: %s", err) } reporterClient := app.DefaultReporterClient() handler := &Handler{ reporter: api.LKMLReporter, apiClient: reporterClient, emailConfig: cfg.EmailReporting, - sender: sender.Send, + sender: sender, } msgCh := make(chan *email.Email, 16) eg, loopCtx := errgroup.WithContext(ctx) @@ -78,3 +79,45 @@ func main() { }) eg.Wait() } + +func makeSender(ctx context.Context, cfg *app.EmailConfig) (SendEmailCb, error) { + if cfg.Sender == app.SenderSMTP { + sender, err := newSMTPSender(ctx, cfg) + if err != nil { + return nil, err + } + return sender.Send, nil + } else if cfg.Sender == app.SenderDashapi { + return makeDashapiSender(cfg) + } + return nil, fmt.Errorf("unsupported sender type: %q", cfg.Sender) +} + +type EmailToSend struct { + To []string + Cc []string + Subject string + InReplyTo string + Body []byte + BugID string // In case it's to be included into Sender. +} + +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 +} diff --git a/syz-cluster/email-reporter/sender.go b/syz-cluster/email-reporter/sender.go deleted file mode 100644 index e19066ed2..000000000 --- a/syz-cluster/email-reporter/sender.go +++ /dev/null @@ -1,146 +0,0 @@ -// 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 ( - "bytes" - "context" - "fmt" - "net/smtp" - "strconv" - "strings" - - "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) { - 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 deleted file mode 100644 index 184753a18..000000000 --- a/syz-cluster/email-reporter/sender_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// 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)) - }) - } -} diff --git a/syz-cluster/email-reporter/smtp_sender.go b/syz-cluster/email-reporter/smtp_sender.go new file mode 100644 index 000000000..76bc4b1c9 --- /dev/null +++ b/syz-cluster/email-reporter/smtp_sender.go @@ -0,0 +1,126 @@ +// 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 ( + "bytes" + "context" + "fmt" + "net/smtp" + "strconv" + "strings" + + "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 newSMTPSender(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) { + 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.SMTP.From, item.recipients(), msg) +} + +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.SMTP.From) + 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/smtp_sender_test.go b/syz-cluster/email-reporter/smtp_sender_test.go new file mode 100644 index 000000000..184753a18 --- /dev/null +++ b/syz-cluster/email-reporter/smtp_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)) + }) + } +} diff --git a/syz-cluster/overlays/minikube/global-config.yaml b/syz-cluster/overlays/minikube/global-config.yaml index ff2f5051f..c0cfa0078 100644 --- a/syz-cluster/overlays/minikube/global-config.yaml +++ b/syz-cluster/overlays/minikube/global-config.yaml @@ -14,7 +14,9 @@ data: emailReporting: name: test-name docs: http://docs/ - sender: sender@email.com + sender: smtp supportEmail: name@email.com archiveList: archive@list.com moderationList: moderation@list.com + smtpConfig: + from: sender@email.com diff --git a/syz-cluster/pkg/app/config.go b/syz-cluster/pkg/app/config.go index 66a30bd23..92a3535e0 100644 --- a/syz-cluster/pkg/app/config.go +++ b/syz-cluster/pkg/app/config.go @@ -23,6 +23,11 @@ type AppConfig struct { EmailReporting *EmailConfig `yaml:"emailReporting"` } +const ( + SenderSMTP = "smtp" + SenderDashapi = "dashapi" +) + type EmailConfig struct { // The public name of the system. Name string `yaml:"name"` @@ -30,14 +35,37 @@ type EmailConfig struct { DocsLink string `yaml:"docs"` // Contact email. SupportEmail string `yaml:"supportEmail"` - // The email from which to send the reports. + // The means to send the emails ("smtp", "dashapi"). Sender string `yaml:"sender"` + // Will be used if Sender is "smtp". + SMTP *SMTPConfig `yaml:"smtpConfig"` + // Will be used if Sender is "dashapi". + Dashapi *DashapiConfig `yaml:"dashapiConfig"` // Moderation requests will be sent there. ModerationList string `yaml:"moderationList"` // The list we listen on. ArchiveList string `yaml:"archiveList"` // Lore git archive to poll for incoming messages. LoreArchiveURL string `yaml:"loreArchiveURL"` + // The prefix which will be added to all reports' titles. + SubjectPrefix string `yaml:"subjectPrefix"` +} + +type SMTPConfig struct { + // The email from which to send the reports. + From string `yaml:"from"` +} + +type DashapiConfig struct { + // The URI at which the dashboard is accessible. + Addr string `yaml:"addr"` + // Client name to be used for authorization. + // OAuth will be used instead of a key. + Client string `yaml:"client"` + // The email from which to send the reports. + From string `yaml:"from"` + // The emails will be sent from "name+" + contextPrefix + ID + "@domain". + ContextPrefix string `yaml:"contextPrefix"` } // The project configuration is expected to be mounted at /config/config.yaml. @@ -92,7 +120,6 @@ func (c EmailConfig) Validate() error { for _, err := range []error{ ensureNonEmpty("name", c.Name), ensureEmail("supportEmail", c.SupportEmail), - ensureEmail("sender", c.Sender), ensureEmail("moderationList", c.ModerationList), ensureEmail("archiveList", c.ArchiveList), } { @@ -100,6 +127,45 @@ func (c EmailConfig) Validate() error { return err } } + if c.SMTP != nil { + if err := c.SMTP.Validate(); err != nil { + return err + } + } + if c.Dashapi != nil { + if err := c.Dashapi.Validate(); err != nil { + return err + } + } + switch c.Sender { + case SenderSMTP: + if c.SMTP == nil { + return fmt.Errorf("sender is %q, but smtpConfig is empty", SenderSMTP) + } + case SenderDashapi: + if c.Dashapi == nil { + return fmt.Errorf("sender is %q, but dashapiConfig is empty", SenderDashapi) + } + default: + return fmt.Errorf("invalid sender value, must be %q or %q", SenderSMTP, SenderDashapi) + } + return nil +} + +func (c SMTPConfig) Validate() error { + return ensureEmail("from", c.From) +} + +func (c DashapiConfig) Validate() error { + for _, err := range []error{ + ensureNonEmpty("addr", c.Addr), + ensureNonEmpty("client", c.Client), + ensureEmail("from", c.From), + } { + if err != nil { + return err + } + } return nil } -- cgit mrf-deployment