| // Copyright 2017 The Go Authors. All rights reserved. |
| // Use of this source code is governed by the Apache 2.0 |
| // license that can be found in the LICENSE file. |
| |
| // The pubsubhelper is an SMTP server for Gerrit updates and an HTTP |
| // server for Github webhook updates. It then lets other clients subscribe |
| // to those changes. |
| package main |
| |
| import ( |
| "bufio" |
| "bytes" |
| "context" |
| "crypto/tls" |
| "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| "io" |
| "log" |
| "net" |
| "net/http" |
| "net/textproto" |
| "os" |
| "os/signal" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| |
| "github.com/bradfitz/go-smtpd/smtpd" |
| "github.com/jellevandenhooff/dkim" |
| "go4.org/types" |
| "golang.org/x/build/cmd/pubsubhelper/pubsubtypes" |
| "golang.org/x/build/internal/secret" |
| "golang.org/x/crypto/acme/autocert" |
| ) |
| |
| var ( |
| botEmail = flag.String("rcpt", "\x67\x6f\x70\x68\x65\x72\x62\x6f\x74@pubsubhelper.golang.org", "email address of bot. incoming emails must be to this address.") |
| httpListen = flag.String("http", ":80", "HTTP listen address") |
| acmeDomain = flag.String("autocert", "pubsubhelper.golang.org", "If non-empty, listen on port 443 and serve HTTPS with a LetsEncrypt cert for this domain.") |
| smtpListen = flag.String("smtp", ":25", "SMTP listen address") |
| webhookSecret = flag.String("webhook-secret", "", "Development mode GitHub webhook secret. This flag should not be used in production.") |
| ) |
| |
| func main() { |
| flag.Parse() |
| |
| ctx := context.Background() |
| ch := make(chan os.Signal, 1) |
| signal.Notify(ch, os.Interrupt) |
| go func() { |
| sig := <-ch |
| log.Printf("Signal %v received; exiting with status 0.", sig) |
| os.Exit(0) |
| }() |
| |
| // webhooksecret should not be set in production |
| if *webhookSecret == "" { |
| sc := mustCreateSecretClient() |
| defer sc.Close() |
| |
| ctxSc, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| |
| var err error |
| *webhookSecret, err = sc.Retrieve(ctxSc, secret.NamePubSubHelperWebhook) |
| if err != nil { |
| log.Fatalf("unable to retrieve webhook secret %v", err) |
| } |
| } |
| |
| http.HandleFunc("/", handleRoot) |
| http.HandleFunc("/waitevent", handleWaitEvent) |
| http.HandleFunc("/recent", handleRecent) |
| http.HandleFunc("/github-webhook", handleGithubWebhook) |
| |
| errc := make(chan error) |
| go func() { |
| log.Printf("running pubsubhelper on %s", *smtpListen) |
| s := &smtpd.Server{ |
| Addr: *smtpListen, |
| OnNewMail: onNewMail, |
| OnNewConnection: onNewConnection, |
| ReadTimeout: time.Minute, |
| } |
| err := s.ListenAndServe() |
| errc <- fmt.Errorf("SMTP ListenAndServe: %v", err) |
| }() |
| m := &autocert.Manager{ |
| Prompt: autocert.AcceptTOS, |
| HostPolicy: autocert.HostWhitelist(*acmeDomain), |
| } |
| go func() { |
| if *acmeDomain == "" { |
| return |
| } |
| if _, err := os.Stat("/autocert-cache"); err == nil { |
| m.Cache = autocert.DirCache("/autocert-cache") |
| } else { |
| log.Printf("Warning: running acme/autocert without cache") |
| } |
| log.Printf("running pubsubhelper HTTPS on :443 for %s", *acmeDomain) |
| s := &http.Server{ |
| Addr: ":https", |
| TLSConfig: &tls.Config{GetCertificate: m.GetCertificate}, |
| ReadHeaderTimeout: 10 * time.Second, |
| WriteTimeout: 5 * time.Minute, |
| IdleTimeout: 5 * time.Minute, |
| } |
| err := s.ListenAndServeTLS("", "") |
| errc <- fmt.Errorf("HTTPS ListenAndServeTLS: %v", err) |
| }() |
| go func() { |
| log.Printf("running pubsubhelper HTTP on %s", *httpListen) |
| s := &http.Server{ |
| Addr: *httpListen, |
| ReadHeaderTimeout: 10 * time.Second, |
| WriteTimeout: 5 * time.Minute, |
| IdleTimeout: 5 * time.Minute, |
| Handler: m.HTTPHandler(http.DefaultServeMux), |
| } |
| err := s.ListenAndServe() |
| errc <- fmt.Errorf("HTTP ListenAndServe: %v", err) |
| }() |
| |
| log.Fatal(<-errc) |
| } |
| |
| func handleRoot(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/" { |
| http.NotFound(w, r) |
| return |
| } |
| io.WriteString(w, `<html> |
| <body> |
| This is <a href="https://godoc.org/golang.org/x/build/cmd/pubsubhelper">pubsubhelper</a>. |
| |
| <ul> |
| <li><b><a href="/waitevent">/waitevent</a></b>: long-poll wait 30s for next event (use ?after=[RFC3339Nano] to resume at point)</li> |
| <li><b><a href="/recent">/recent</a></b>: recent events, without long-polling.</li> |
| </ul> |
| |
| </body> |
| </html> |
| `) |
| } |
| |
| func handleWaitEvent(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "GET" { |
| http.Error(w, "requires GET", http.StatusBadRequest) |
| return |
| } |
| |
| ch := make(chan *eventAndJSON, 1) |
| |
| var after time.Time |
| if v := r.FormValue("after"); v != "" { |
| var err error |
| after, err = time.Parse(time.RFC3339Nano, v) |
| if err != nil { |
| http.Error(w, "'after' parameter is not in time.RFC3339Nano format", http.StatusBadRequest) |
| return |
| } |
| } else { |
| after = time.Now() |
| } |
| |
| register(ch, after) |
| defer unregister(ch) |
| ctx := r.Context() |
| |
| timer := time.NewTimer(30 * time.Second) |
| defer timer.Stop() |
| |
| var e *eventAndJSON |
| select { |
| case <-ctx.Done(): |
| return |
| case <-timer.C: |
| e = newEventAndJSON(&pubsubtypes.Event{ |
| LongPollTimeout: true, |
| }) |
| case e = <-ch: |
| } |
| |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| io.WriteString(w, e.json) |
| } |
| |
| func handleRecent(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| |
| var after time.Time |
| if v := r.FormValue("after"); v != "" { |
| var err error |
| after, err = time.Parse(time.RFC3339Nano, v) |
| if err != nil { |
| http.Error(w, "'after' parameter is not in time.RFC3339Nano format", http.StatusBadRequest) |
| return |
| } |
| } |
| |
| var buf bytes.Buffer |
| mu.Lock() |
| buf.WriteString("[\n") |
| n := 0 |
| for i := len(recent) - 1; i >= 0; i-- { |
| ev := recent[i] |
| if ev.Time.Time().Before(after) { |
| continue |
| } |
| if n > 0 { |
| buf.WriteString(",\n") |
| } |
| n++ |
| buf.WriteString(ev.json) |
| } |
| buf.WriteString("\n]\n") |
| mu.Unlock() |
| w.Write(buf.Bytes()) |
| } |
| |
| type env struct { |
| from smtpd.MailAddress |
| body bytes.Buffer |
| conn smtpd.Connection |
| tooBig bool |
| hasRcpt bool |
| } |
| |
| func (e *env) BeginData() error { |
| if !e.hasRcpt { |
| return smtpd.SMTPError("554 5.5.1 Error: no valid recipients") |
| } |
| return nil |
| } |
| |
| func (e *env) AddRecipient(rcpt smtpd.MailAddress) error { |
| if e.hasRcpt { |
| return smtpd.SMTPError("554 5.5.1 Error: dup recipients") |
| } |
| to := rcpt.Email() |
| if to != *botEmail { |
| return errors.New("bogus recipient") |
| } |
| e.hasRcpt = true |
| return nil |
| } |
| |
| func (e *env) Write(line []byte) error { |
| const maxSize = 5 << 20 |
| if e.body.Len() > maxSize { |
| e.tooBig = true |
| return nil |
| } |
| e.body.Write(line) |
| return nil |
| } |
| |
| var ( |
| headerSep = []byte("\r\n\r\n") |
| dkimSignatureHeader = []byte("\nDKIM-Signature:") |
| ) |
| |
| func (e *env) Close() error { |
| if e.tooBig { |
| log.Printf("Ignoring too-large email from %q", e.from) |
| return nil |
| } |
| from := e.from.Email() |
| bodyBytes := e.body.Bytes() |
| if !bytes.Contains(bodyBytes, dkimSignatureHeader) { |
| log.Printf("Ignoring unsigned (~spam) email from %q", from) |
| return nil |
| } |
| |
| headerBytes := bodyBytes |
| if i := bytes.Index(headerBytes, headerSep); i == -1 { |
| log.Printf("Ignoring email without header separator from %q", from) |
| return nil |
| } else { |
| headerBytes = headerBytes[:i+len(headerSep)] |
| } |
| |
| ve, err := dkim.ParseAndVerify(string(headerBytes), dkim.HeadersOnly, dnsClient{}) |
| if err != nil { |
| log.Printf("Email from %q didn't pass DKIM verifications: %v", from, err) |
| return nil |
| } |
| if !strings.HasSuffix(ve.Signature.Domain, "google.com") { |
| log.Printf("Ignoring DKIM-verified Gerrit email from non-Google domain %q", ve.Signature.Domain) |
| return nil |
| } |
| tp := textproto.NewReader(bufio.NewReader(bytes.NewReader(headerBytes))) |
| hdr, err := tp.ReadMIMEHeader() |
| if err != nil { |
| log.Printf("Ignoring ReadMIMEHeader error: %v from email:\n%s", err, headerBytes) |
| return nil |
| } |
| if e.from.Hostname() != "gerritcodereview.bounces.google.com" { |
| log.Printf("Ignoring signed, DKIM-verified, non-Gerrit email from %q:\n%s", from, bodyBytes) |
| return nil |
| } |
| changeNum, _ := strconv.Atoi(hdr.Get("X-Gerrit-Change-Number")) |
| |
| // Extract gerrit project "oauth2" from List-Id header like: |
| // List-Id: <gerrit-oauth2.go-review.googlesource.com> |
| project := strings.TrimPrefix(hdr.Get("List-Id"), "<gerrit-") |
| if i := strings.IndexByte(project, '.'); i == -1 { |
| project = "" |
| } else { |
| project = project[:i] |
| } |
| publish(&pubsubtypes.Event{ |
| Gerrit: &pubsubtypes.GerritEvent{ |
| URL: strings.Trim(hdr.Get("X-Gerrit-ChangeURL"), "<>"), |
| Project: project, |
| CommitHash: hdr.Get("X-Gerrit-Commit"), |
| ChangeNumber: changeNum, |
| }, |
| }) |
| return nil |
| } |
| |
| type eventAndJSON struct { |
| *pubsubtypes.Event |
| json string // JSON MarshalIndent of Event |
| } |
| |
| var ( |
| mu sync.Mutex // guards following |
| recent []*eventAndJSON // newest at end |
| waiting = map[chan *eventAndJSON]struct{}{} |
| ) |
| |
| const ( |
| keepMin = 50 |
| maxAge = 1 * time.Hour |
| ) |
| |
| func register(ch chan *eventAndJSON, after time.Time) { |
| mu.Lock() |
| defer mu.Unlock() |
| for _, e := range recent { |
| if e.Time.Time().After(after) { |
| ch <- e |
| return |
| } |
| } |
| waiting[ch] = struct{}{} |
| } |
| |
| func unregister(ch chan *eventAndJSON) { |
| mu.Lock() |
| defer mu.Unlock() |
| delete(waiting, ch) |
| } |
| |
| // numOldInRecentLocked returns how many leading items of recent are |
| // too old. |
| func numOldInRecentLocked() int { |
| if len(recent) <= keepMin { |
| return 0 |
| } |
| n := 0 |
| tooOld := time.Now().Add(-maxAge) |
| for _, e := range recent { |
| if e.Time.Time().After(tooOld) { |
| break |
| } |
| n++ |
| } |
| return n |
| } |
| |
| func newEventAndJSON(e *pubsubtypes.Event) *eventAndJSON { |
| e.Time = types.Time3339(time.Now()) |
| j, err := json.MarshalIndent(e, "", "\t") |
| if err != nil { |
| log.Printf("JSON error: %v", err) |
| } |
| return &eventAndJSON{ |
| Event: e, |
| json: string(j), |
| } |
| } |
| |
| func publish(e *pubsubtypes.Event) { |
| ej := newEventAndJSON(e) |
| log.Printf("Event: %s", ej.json) |
| |
| mu.Lock() |
| defer mu.Unlock() |
| |
| recent = append(recent, ej) |
| // Trim old ones off the front of recent |
| if n := numOldInRecentLocked(); n > 0 { |
| copy(recent, recent[n:]) |
| recent = recent[:len(recent)-n] |
| } |
| |
| for ch := range waiting { |
| ch <- ej |
| delete(waiting, ch) |
| } |
| } |
| |
| type dnsClient struct{} |
| |
| var resolver = &net.Resolver{PreferGo: true} |
| |
| func (dnsClient) LookupTxt(hostname string) ([]string, error) { |
| ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| defer cancel() |
| return resolver.LookupTXT(ctx, hostname) |
| } |
| |
| func onNewMail(c smtpd.Connection, from smtpd.MailAddress) (smtpd.Envelope, error) { |
| return &env{ |
| from: from, |
| conn: c, |
| }, nil |
| } |
| |
| func onNewConnection(c smtpd.Connection) error { |
| log.Printf("smtpd: new connection from %v", c.Addr()) |
| return nil |
| } |
| |
| func mustCreateSecretClient() *secret.Client { |
| client, err := secret.NewClient() |
| if err != nil { |
| log.Fatalf("unable to create secret client %v", err) |
| } |
| return client |
| } |