// Copyright 2017 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 dash import ( "bytes" "encoding/json" "fmt" "io/ioutil" "net/http" "net/mail" "regexp" "strconv" "strings" "text/template" "time" "github.com/google/syzkaller/dashboard/dashapi" "github.com/google/syzkaller/pkg/email" "golang.org/x/net/context" "google.golang.org/appengine" "google.golang.org/appengine/log" aemail "google.golang.org/appengine/mail" ) // Email reporting interface. func initEmailReporting() { http.HandleFunc("/email_poll", handleEmailPoll) http.HandleFunc("/_ah/mail/", handleIncomingMail) http.HandleFunc("/_ah/bounce", handleEmailBounce) mailingLists = make(map[string]bool) for _, cfg := range config.Namespaces { for _, reporting := range cfg.Reporting { if cfg, ok := reporting.Config.(*EmailConfig); ok { mailingLists[email.CanonicalEmail(cfg.Email)] = true } } } } const ( emailType = "email" // This plays an important role at least for job replies. // If we CC a kernel mailing list and it uses Patchwork, // then any emails with a patch attached create a new patch // entry pending for review. The prefix makes Patchwork // treat it as a comment for a previous patch. replySubjectPrefix = "Re: " commitHashLen = 12 commitTitleLen = 47 // so that whole line fits into 78 chars ) var mailingLists map[string]bool type EmailConfig struct { Email string Moderation bool MailMaintainers bool DefaultMaintainers []string } func (cfg *EmailConfig) Type() string { return emailType } func (cfg *EmailConfig) NeedMaintainers() bool { return cfg.MailMaintainers && len(cfg.DefaultMaintainers) == 0 } func (cfg *EmailConfig) Validate() error { if _, err := mail.ParseAddress(cfg.Email); err != nil { return fmt.Errorf("bad email address %q: %v", cfg.Email, err) } for _, email := range cfg.DefaultMaintainers { if _, err := mail.ParseAddress(email); err != nil { return fmt.Errorf("bad email address %q: %v", email, err) } } if cfg.Moderation && cfg.MailMaintainers { return fmt.Errorf("both Moderation and MailMaintainers set") } return nil } // handleEmailPoll is called by cron and sends emails for new bugs, if any. func handleEmailPoll(w http.ResponseWriter, r *http.Request) { c := appengine.NewContext(r) if err := emailPollBugs(c); err != nil { log.Errorf(c, "bug poll failed: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := emailPollJobs(c); err != nil { log.Errorf(c, "job poll failed: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Write([]byte("OK")) } func emailPollBugs(c context.Context) error { reports := reportingPollBugs(c, emailType) for _, rep := range reports { cfg := new(EmailConfig) if err := json.Unmarshal(rep.Config, cfg); err != nil { log.Errorf(c, "failed to unmarshal email config: %v", err) continue } if cfg.MailMaintainers { rep.CC = email.MergeEmailLists(rep.CC, rep.Maintainers, cfg.DefaultMaintainers) } if err := emailReport(c, rep, "mail_bug.txt"); err != nil { log.Errorf(c, "failed to report bug: %v", err) continue } cmd := &dashapi.BugUpdate{ ID: rep.ID, Status: dashapi.BugStatusOpen, ReproLevel: dashapi.ReproLevelNone, CrashID: rep.CrashID, } if len(rep.ReproC) != 0 { cmd.ReproLevel = dashapi.ReproLevelC } else if len(rep.ReproSyz) != 0 { cmd.ReproLevel = dashapi.ReproLevelSyz } ok, reason, err := incomingCommand(c, cmd) if !ok || err != nil { log.Errorf(c, "failed to update reported bug: ok=%v reason=%v err=%v", ok, reason, err) } } return nil } func emailPollJobs(c context.Context) error { jobs, err := pollCompletedJobs(c, emailType) if err != nil { return err } for _, job := range jobs { if err := emailReport(c, job, "mail_test_result.txt"); err != nil { log.Errorf(c, "failed to report job: %v", err) continue } if err := jobReported(c, job.JobID); err != nil { log.Errorf(c, "failed to mark job reported: %v", err) continue } } return nil } func emailReport(c context.Context, rep *dashapi.BugReport, templ string) error { cfg := new(EmailConfig) if err := json.Unmarshal(rep.Config, cfg); err != nil { return fmt.Errorf("failed to unmarshal email config: %v", err) } to := email.MergeEmailLists([]string{cfg.Email}, rep.CC) // Build error output and failing VM boot log can be way too long to inline. if len(rep.Error) > maxInlineError { rep.Error = rep.Error[len(rep.Error)-maxInlineError:] } else { rep.ErrorLink = "" } from, err := email.AddAddrContext(fromAddr(c), rep.ID) if err != nil { return err } creditEmail, err := email.AddAddrContext(ownEmail(c), rep.ID) if err != nil { return err } userspaceArch := "" if rep.Arch == "386" { userspaceArch = "i386" } link := fmt.Sprintf("%v/bug?extid=%v", appURL(c), rep.ID) // Data passed to the template. type BugReportData struct { First bool Link string CreditEmail string Moderation bool Maintainers []string CompilerID string KernelRepo string KernelCommit string KernelCommitTitle string KernelCommitDate string UserSpaceArch string CrashTitle string Report []byte Error []byte ErrorLink string LogLink string KernelConfigLink string ReproSyzLink string ReproCLink string NumCrashes int64 HappenedOn []string PatchLink string } data := &BugReportData{ First: rep.First, Link: link, CreditEmail: creditEmail, Moderation: cfg.Moderation, Maintainers: rep.Maintainers, CompilerID: rep.CompilerID, KernelRepo: rep.KernelRepoAlias, KernelCommit: rep.KernelCommit, KernelCommitTitle: rep.KernelCommitTitle, KernelCommitDate: formatKernelTime(rep.KernelCommitDate), UserSpaceArch: userspaceArch, CrashTitle: rep.CrashTitle, Report: rep.Report, Error: rep.Error, ErrorLink: rep.ErrorLink, LogLink: rep.LogLink, KernelConfigLink: rep.KernelConfigLink, ReproSyzLink: rep.ReproSyzLink, ReproCLink: rep.ReproCLink, NumCrashes: rep.NumCrashes, HappenedOn: rep.HappenedOn, PatchLink: rep.PatchLink, } if len(data.KernelCommit) > commitHashLen { data.KernelCommit = data.KernelCommit[:commitHashLen] } if len(data.KernelCommitTitle) > commitTitleLen { data.KernelCommitTitle = data.KernelCommitTitle[:commitTitleLen-2] + ".." } log.Infof(c, "sending email %q to %q", rep.Title, to) return sendMailTemplate(c, rep.Title, from, to, rep.ExtID, nil, templ, data) } // handleIncomingMail is the entry point for incoming emails. func handleIncomingMail(w http.ResponseWriter, r *http.Request) { c := appengine.NewContext(r) if err := incomingMail(c, r); err != nil { log.Errorf(c, "%v", err) } } func incomingMail(c context.Context, r *http.Request) error { msg, err := email.Parse(r.Body, ownEmails(c)) if err != nil { return err } log.Infof(c, "received email: subject %q, from %q, cc %q, msg %q, bug %q, cmd %q, link %q", msg.Subject, msg.From, msg.Cc, msg.MessageID, msg.BugID, msg.Command, msg.Link) if msg.Command == "fix:" && msg.CommandArgs == "exact-commit-title" { // Sometimes it happens that somebody sends us our own text back, ignore it. msg.Command, msg.CommandArgs = "", "" } bug, _, reporting := loadBugInfo(c, msg) if bug == nil { return nil // error was already logged } emailConfig := reporting.Config.(*EmailConfig) // A mailing list can send us a duplicate email, to not process/reply // to such duplicate emails, we ignore emails coming from our mailing lists. mailingList := email.CanonicalEmail(emailConfig.Email) fromMailingList := email.CanonicalEmail(msg.From) == mailingList mailingListInCC := checkMailingListInCC(c, msg, mailingList) log.Infof(c, "from/cc mailing list: %v/%v", fromMailingList, mailingListInCC) if msg.Command == "test:" { args := strings.Split(msg.CommandArgs, " ") if len(args) != 2 { return replyTo(c, msg, fmt.Sprintf("want 2 args (repo, branch), got %v", len(args)), nil) } reply := handleTestRequest(c, msg.BugID, email.CanonicalEmail(msg.From), msg.MessageID, msg.Link, msg.Patch, args[0], args[1], msg.Cc) if reply != "" { return replyTo(c, msg, reply, nil) } return nil } if fromMailingList && msg.Command != "" { log.Infof(c, "duplicate email from mailing list, ignoring") return nil } cmd := &dashapi.BugUpdate{ ID: msg.BugID, ExtID: msg.MessageID, Link: msg.Link, CC: msg.Cc, } switch msg.Command { case "": cmd.Status = dashapi.BugStatusUpdate case "upstream": cmd.Status = dashapi.BugStatusUpstream case "invalid": cmd.Status = dashapi.BugStatusInvalid case "undup": cmd.Status = dashapi.BugStatusOpen case "fix:": if msg.CommandArgs == "" { return replyTo(c, msg, fmt.Sprintf("no commit title"), nil) } cmd.Status = dashapi.BugStatusOpen cmd.FixCommits = []string{msg.CommandArgs} case "dup:": if msg.CommandArgs == "" { return replyTo(c, msg, fmt.Sprintf("no dup title"), nil) } cmd.Status = dashapi.BugStatusDup cmd.DupOf = msg.CommandArgs default: return replyTo(c, msg, fmt.Sprintf("unknown command %q", msg.Command), nil) } ok, reply, err := incomingCommand(c, cmd) if err != nil { return nil // the error was already logged } if !ok && reply != "" { return replyTo(c, msg, reply, nil) } if !mailingListInCC && msg.Command != "" { warnMailingListInCC(c, msg, mailingList) } return nil } func handleEmailBounce(w http.ResponseWriter, r *http.Request) { c := appengine.NewContext(r) body, err := ioutil.ReadAll(r.Body) if err != nil { log.Errorf(c, "email bounced: failed to read body: %v", err) return } if nonCriticalBounceRe.Match(body) { log.Infof(c, "email bounced: address not found") } else { log.Errorf(c, "email bounced") } log.Infof(c, "%s", body) } // These are just stale emails in MAINTAINERS. var nonCriticalBounceRe = regexp.MustCompile(`\*\* Address not found \*\*|550 #5\.1\.0 Address rejected`) func loadBugInfo(c context.Context, msg *email.Email) (bug *Bug, bugReporting *BugReporting, reporting *Reporting) { if msg.BugID == "" { if msg.Command == "" { // This happens when people CC syzbot on unrelated emails. log.Infof(c, "no bug ID (%q)", msg.Subject) } else { log.Errorf(c, "no bug ID (%q)", msg.Subject) if err := replyTo(c, msg, "Can't find the corresponding bug.", nil); err != nil { log.Errorf(c, "failed to send reply: %v", err) } } return nil, nil, nil } bug, _, err := findBugByReportingID(c, msg.BugID) if err != nil { log.Errorf(c, "can't find bug: %v", err) if err := replyTo(c, msg, "Can't find the corresponding bug.", nil); err != nil { log.Errorf(c, "failed to send reply: %v", err) } return nil, nil, nil } bugReporting, _ = bugReportingByID(bug, msg.BugID) if bugReporting == nil { log.Errorf(c, "can't find bug reporting: %v", err) if err := replyTo(c, msg, "Can't find the corresponding bug.", nil); err != nil { log.Errorf(c, "failed to send reply: %v", err) } return nil, nil, nil } reporting = config.Namespaces[bug.Namespace].ReportingByName(bugReporting.Name) if reporting == nil { log.Errorf(c, "can't find reporting for this bug: namespace=%q reporting=%q", bug.Namespace, bugReporting.Name) return nil, nil, nil } if reporting.Config.Type() != emailType { log.Errorf(c, "reporting is not email: namespace=%q reporting=%q config=%q", bug.Namespace, bugReporting.Name, reporting.Config.Type()) return nil, nil, nil } return bug, bugReporting, reporting } func checkMailingListInCC(c context.Context, msg *email.Email, mailingList string) bool { if email.CanonicalEmail(msg.From) == mailingList { return true } for _, cc := range msg.Cc { if email.CanonicalEmail(cc) == mailingList { return true } } msg.Cc = append(msg.Cc, mailingList) return false } func warnMailingListInCC(c context.Context, msg *email.Email, mailingList string) { reply := fmt.Sprintf("Your '%v' command is accepted, but please keep %v mailing list"+ " in CC next time. It serves as a history of what happened with each bug report."+ " Thank you.", msg.Command, mailingList) if err := replyTo(c, msg, reply, nil); err != nil { log.Errorf(c, "failed to send email reply: %v", err) } } func sendMailTemplate(c context.Context, subject, from string, to []string, replyTo string, attachments []aemail.Attachment, template string, data interface{}) error { body := new(bytes.Buffer) if err := mailTemplates.ExecuteTemplate(body, template, data); err != nil { return fmt.Errorf("failed to execute %v template: %v", template, err) } msg := &aemail.Message{ Sender: from, To: to, Subject: subject, Body: body.String(), Attachments: attachments, } if replyTo != "" { msg.Headers = mail.Header{"In-Reply-To": []string{replyTo}} msg.Subject = replySubjectPrefix + msg.Subject } return sendEmail(c, msg) } func replyTo(c context.Context, msg *email.Email, reply string, attachment *aemail.Attachment) error { var attachments []aemail.Attachment if attachment != nil { attachments = append(attachments, *attachment) } from, err := email.AddAddrContext(fromAddr(c), msg.BugID) if err != nil { return err } log.Infof(c, "sending reply: to=%q cc=%q subject=%q reply=%q", msg.From, msg.Cc, msg.Subject, reply) replyMsg := &aemail.Message{ Sender: from, To: []string{msg.From}, Cc: msg.Cc, Subject: replySubjectPrefix + msg.Subject, Body: email.FormReply(msg.Body, reply), Attachments: attachments, Headers: mail.Header{"In-Reply-To": []string{msg.MessageID}}, } return sendEmail(c, replyMsg) } // Sends email, can be stubbed for testing. var sendEmail = func(c context.Context, msg *aemail.Message) error { if err := aemail.Send(c, msg); err != nil { return fmt.Errorf("failed to send email: %v", err) } return nil } func ownEmail(c context.Context) string { return fmt.Sprintf("syzbot@%v.appspotmail.com", appengine.AppID(c)) } func fromAddr(c context.Context) string { return fmt.Sprintf("\"syzbot\" <%v>", ownEmail(c)) } func ownEmails(c context.Context) []string { // Now we use syzbot@ but we used to use bot@, so we add them both. return []string{ ownEmail(c), fmt.Sprintf("bot@%v.appspotmail.com", appengine.AppID(c)), } } func externalLink(c context.Context, tag string, id int64) string { if id == 0 { return "" } return fmt.Sprintf("%v/x/%v?x=%v", appURL(c), textFilename(tag), strconv.FormatUint(uint64(id), 16)) } func appURL(c context.Context) string { return fmt.Sprintf("https://%v.appspot.com", appengine.AppID(c)) } func formatKernelTime(t time.Time) string { if t.IsZero() { return "" } // This is how dates appear in git log. return t.Format("Mon Jan 2 15:04:05 2006 -0700") } func formatStringList(list []string) string { return strings.Join(list, ", ") } var ( mailTemplates = template.Must(template.New("").Funcs(mailFuncs).ParseGlob("mail_*.txt")) mailFuncs = template.FuncMap{ "formatTime": formatKernelTime, "formatList": formatStringList, } )