package sandbar import ( "bytes" "crypto/hmac" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "encoding/xml" "errors" "fmt" "log" "net/http" "net/mail" "net/url" "sort" "strings" "time" ) // Following the status page: email (confirmed by a link), webhook (confirmed by answering a test // request) and RSS. Every update to a public incident, and every maintenance window, goes to all // confirmed subscribers. // statusNote is one update, as sent to subscribers. type statusNote struct { Event string `json:"event"` // incident.created, incident.updated, incident.resolved, maintenance.scheduled, .rescheduled, .started, .completed, .canceled Title string `json:"title"` Status string `json:"status"` Message string `json:"message"` URL string `json:"url"` Time int64 `json:"time"` } func (n statusNote) subject(page string) string { label := map[string]string{ "incident.created": "New incident", "incident.updated": "Incident update", "incident.resolved": "Resolved", "maintenance.scheduled": "Scheduled maintenance", "maintenance.rescheduled": "Maintenance rescheduled", "maintenance.started": "Maintenance started", "maintenance.completed": "Maintenance complete", "maintenance.canceled": "Maintenance canceled", }[n.Event] return "[" + page + "] " + label + ": " + n.Title } type subscriber struct { ID string `json:"id"` Kind string `json:"kind"` // email or webhook Target string `json:"target"` Confirmed bool `json:"confirmed"` Created int64 `json:"created"` } func (s *Server) mailEnabled() bool { return s.opts.Mailer != nil || s.opts.SMTP.Host != "" } // ---------- links that act for a subscriber ---------- // statusSecret signs confirm and unsubscribe links, so none needs a stored token. func (s *Server) statusSecret() []byte { s.statusMu.Lock() defer s.statusMu.Unlock() if s.statusKey == nil { var v struct{ Value string } if s.st.get("secret", "status", &v) != nil || v.Value == "" { v.Value = newToken() s.st.put("secret", "status", v) } s.statusKey = []byte(v.Value) } return s.statusKey } func (s *Server) subToken(purpose, id string) string { m := hmac.New(sha256.New, s.statusSecret()) m.Write([]byte(purpose + "." + id)) return id + "." + hex.EncodeToString(m.Sum(nil))[:32] } func (s *Server) parseSubToken(purpose, tok string) (string, bool) { id, _, ok := strings.Cut(tok, ".") return id, ok && hmac.Equal([]byte(tok), []byte(s.subToken(purpose, id))) } // ---------- storage ---------- func subKey(kind, target string) string { return hashToken(kind + ":" + strings.ToLower(target)) } // addSubscriber stores a subscriber, or returns the one already stored for that address. func (s *Server) addSubscriber(kind, target string, confirmed bool) (sub subscriber, existed bool, err error) { var n int s.st.db.QueryRow(`SELECT COUNT(*) FROM subscribers`).Scan(&n) key := subKey(kind, target) if got, ok := s.subscriberBy(`key=?`, key); ok { return got, true, nil } if n >= 20000 { return sub, false, errors.New("this status page cannot take more subscribers") } sub = subscriber{ID: newID(), Kind: kind, Target: target, Confirmed: confirmed, Created: s.now().Unix()} _, err = s.st.db.Exec(`INSERT INTO subscribers(id,kind,target,key,confirmed,created) VALUES(?,?,?,?,?,?)`, sub.ID, kind, s.st.seal(target), key, boolInt(confirmed), sub.Created) return sub, false, err } func boolInt(b bool) int { if b { return 1 } return 0 } func (s *Server) subscriberBy(where string, args ...any) (subscriber, bool) { var sub subscriber var sealed string var conf int err := s.st.db.QueryRow(`SELECT id,kind,target,confirmed,created FROM subscribers WHERE `+where, args...).Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created) if errors.Is(err, sql.ErrNoRows) || err != nil { return sub, false } sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1 return sub, true } func (s *Server) confirmedSubscribers() []subscriber { rows, err := s.st.db.Query(`SELECT id,kind,target,created FROM subscribers WHERE confirmed=1`) if err != nil { return nil } defer rows.Close() var out []subscriber for rows.Next() { var sub subscriber var sealed string if rows.Scan(&sub.ID, &sub.Kind, &sealed, &sub.Created) == nil { sub.Target, sub.Confirmed = s.st.open(sealed), true out = append(out, sub) } } return out } // listSubscribers is for administrators. A webhook address often carries a secret, so only its host is shown. func (s *Server) listSubscribers(w http.ResponseWriter, r *http.Request) { out := []subscriber{} rows, err := s.st.db.Query(`SELECT id,kind,target,confirmed,created FROM subscribers ORDER BY created DESC LIMIT 1000`) if err != nil { fail(w, err) return } defer rows.Close() for rows.Next() { var sub subscriber var sealed string var conf int if rows.Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created) != nil { continue } sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1 if sub.Kind == "webhook" { if u, err := url.Parse(sub.Target); err == nil { sub.Target = u.Host } } out = append(out, sub) } writeJSON(w, 200, out) } // ---------- sending ---------- func (s *Server) notifySubscribers(n statusNote) { subs := s.confirmedSubscribers() if len(subs) == 0 { return } title := s.statusSettings().Title s.notifyWG.Add(1) go func() { defer s.notifyWG.Done() for _, sub := range subs { if err := s.deliver(sub, n, title); err != nil { log.Printf("status page subscriber %s: %v", sub.ID, err) } } }() } func (s *Server) deliver(sub subscriber, n statusNote, pageTitle string) error { unsub := s.absStatusURL(nil, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID))) switch sub.Kind { case "email": body := n.Message + "\n\n" + n.URL + "\n\nYou get this because you subscribed to updates from " + pageTitle + ". Unsubscribe: " + unsub return s.Mail(sub.Target, n.subject(pageTitle), body) case "webhook": return s.postWebhook(sub.Target, map[string]any{"page": pageTitle, "event": n.Event, "title": n.Title, "status": n.Status, "message": n.Message, "url": n.URL, "time": n.Time, "unsubscribe": unsub}) } return errors.New("unknown subscriber kind") } func (s *Server) postWebhook(target string, payload any) error { b, _ := json.Marshal(payload) req, err := http.NewRequest("POST", target, bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Sandbar status page") resp, err := s.httpClient(false).Do(req) // visitors choose these addresses, so private networks stay out of reach if err != nil { return err } resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("the webhook answered %s", resp.Status) } return nil } // ---------- public endpoints ---------- // subscribeAllowed lets each address make a handful of requests an hour. func (s *Server) subscribeAllowed(ip string) bool { s.subMu.Lock() defer s.subMu.Unlock() now := s.now() keep := s.subTry[ip][:0] for _, t := range s.subTry[ip] { if now.Sub(t) < time.Hour { keep = append(keep, t) } } if len(keep) >= 6 { s.subTry[ip] = keep return false } if len(s.subTry) > 5000 { // forget addresses that have gone quiet for k, v := range s.subTry { if len(v) == 0 || now.Sub(v[len(v)-1]) >= time.Hour { delete(s.subTry, k) } } } s.subTry[ip] = append(keep, now) return true } func (s *Server) followStatusHTTP(w http.ResponseWriter, r *http.Request) { var in struct { Email string `json:"email"` Webhook string `json:"webhook"` } if !decode(r, &in) { bad(w, "invalid request") return } if !s.subscribeAllowed(s.realIP(r)) { writeJSON(w, 429, map[string]string{"error": "Too many attempts. Try again in an hour."}) return } title := s.statusSettings().Title switch { case strings.TrimSpace(in.Email) != "": if !s.mailEnabled() { bad(w, "email updates are not set up on this page") return } addr := strings.TrimSpace(in.Email) if a, err := mail.ParseAddress(addr); err != nil || a.Address != addr || len(addr) > 254 || strings.ContainsAny(addr, "\r\n<>") { bad(w, "enter a valid email address") return } sub, existed, err := s.addSubscriber("email", addr, false) if err != nil { bad(w, err.Error()) return } // The answer is the same whether or not the address was known, so the form cannot be used to probe it. if !(existed && sub.Confirmed) { link := s.absStatusURL(r, "/confirm?token="+url.QueryEscape(s.subToken("confirm", sub.ID))) body := "Confirm that you want email updates from " + title + " by opening this link:\n\n" + link + "\n\nIf you did not ask for this, ignore this email and nothing will be sent." if err := s.Mail(addr, "["+title+"] Confirm your subscription", body); err != nil { log.Printf("status page confirmation to %s: %v", addr, err) writeJSON(w, 502, map[string]string{"error": "The confirmation email could not be sent. Try again later."}) return } } writeJSON(w, 200, map[string]string{"message": "Check your email and open the link to finish subscribing."}) case strings.TrimSpace(in.Webhook) != "": target := strings.TrimSpace(in.Webhook) if u, err := url.Parse(target); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || len(target) > 500 { bad(w, "the webhook must be an http:// or https:// address") return } sub, existed, err := s.addSubscriber("webhook", target, false) if err != nil { bad(w, err.Error()) return } if existed && sub.Confirmed { writeJSON(w, 200, map[string]string{"message": "That webhook is subscribed."}) return } // Answering a test request shows the address belongs to something that wants these updates. unsub := s.absStatusURL(r, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID))) if err := s.postWebhook(target, map[string]any{"page": title, "event": "subscription.created", "message": "This address is now subscribed to " + title + ".", "time": s.now().Unix(), "unsubscribe": unsub}); err != nil { s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, sub.ID) bad(w, "that address did not accept a test request, so it was not subscribed; check the URL and try again") return } s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, sub.ID) writeJSON(w, 200, map[string]string{"message": "Subscribed. A test request was sent to the webhook."}) default: bad(w, "enter an email address or a webhook address") } } func (s *Server) confirmSubscription(w http.ResponseWriter, r *http.Request) { notice := "invalid" if id, ok := s.parseSubToken("confirm", r.URL.Query().Get("token")); ok { if res, err := s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, id); err == nil { if n, _ := res.RowsAffected(); n > 0 { notice = "confirmed" } } } http.Redirect(w, r, pagePrefix(r)+"/?notice="+notice, http.StatusSeeOther) } // unsubscribeRedirect opens the page with a button instead of acting on a GET, since mail // scanners follow every link in a message. func (s *Server) unsubscribeRedirect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, pagePrefix(r)+"/?unsubscribe="+url.QueryEscape(r.URL.Query().Get("token")), http.StatusSeeOther) } func (s *Server) unsubscribeHTTP(w http.ResponseWriter, r *http.Request) { var in struct { Token string `json:"token"` } decode(r, &in) id, ok := s.parseSubToken("unsubscribe", in.Token) if !ok { bad(w, "that unsubscribe link is not valid") return } s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, id) writeJSON(w, 200, map[string]string{"message": "You are unsubscribed."}) } // ---------- RSS ---------- type rssItem struct { Title string `xml:"title"` Link string `xml:"link"` GUID string `xml:"guid"` PubDate string `xml:"pubDate"` Desc string `xml:"description"` t int64 } func (s *Server) statusFeed(w http.ResponseWriter, r *http.Request) { now := s.now() cfg := s.statusSettings() _, _, names := s.publicComponents() var items []rssItem add := func(id string, t int64, title, link, text string) { items = append(items, rssItem{Title: title, Link: link, GUID: id, PubDate: time.Unix(t, 0).UTC().Format(time.RFC1123Z), Desc: text, t: t}) } incs, _ := listOf[Incident](s.st, "incident") for _, in := range incs { if !in.Public { continue } pi := s.publicIncident(in, names) for _, u := range pi.Updates { add(fmt.Sprintf("%s-%d", in.ID, u.Time), u.Time, statusLabel(u.Status)+": "+in.Title, s.absStatusURL(r, "/incidents/"+in.ID), u.Text) } } for _, m := range s.loadMaintenance() { if m.Canceled { continue } add("maintenance-"+m.ID, m.Created, "Scheduled maintenance: "+m.Title, s.absStatusURL(r, ""), strings.TrimSpace(fmtWindow(m.Start, m.End)+". "+m.Message)) } sort.SliceStable(items, func(i, j int) bool { return items[i].t > items[j].t }) if len(items) > 50 { items = items[:50] } type channel struct { Title string `xml:"title"` Link string `xml:"link"` Description string `xml:"description"` LastBuildDate string `xml:"lastBuildDate"` Items []rssItem `xml:"item"` } feed := struct { XMLName xml.Name `xml:"rss"` Version string `xml:"version,attr"` Channel channel `xml:"channel"` }{Version: "2.0", Channel: channel{Title: cfg.Title, Link: s.absStatusURL(r, ""), Description: firstNonEmpty(cfg.Description, "Incidents and maintenance for "+cfg.Title), LastBuildDate: now.UTC().Format(time.RFC1123Z), Items: items}} w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") w.Header().Set("Cache-Control", "max-age=60") w.Write([]byte(xml.Header)) enc := xml.NewEncoder(w) enc.Indent("", " ") enc.Encode(feed) } func statusLabel(status string) string { if l := map[string]string{"investigating": "Investigating", "identified": "Identified", "monitoring": "Monitoring", "resolved": "Resolved"}[status]; l != "" { return l } return "Update" }