blob: 799820fb466ca077ad16e15224a9019c8d7cadc9 [file] [log] [blame]
// 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,
}
)