Golang程序  |  512行  |  15.15 KB

// 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,
	}
)