package main import ( "cmp" "database/sql" _ "embed" "errors" "fmt" "log/slog" "net/http" "os" "os/exec" "slices" "strconv" "strings" "sync" "time" _ "modernc.org/sqlite" "gopkg.awl.red/cicada/config" "gopkg.awl.red/cicada/h" ) //go:embed favicon.ico var favicon []byte func wBdTp(w http.ResponseWriter, r *http.Request, err error) { if err == nil { return } slog.Error("could not write route", "route", r.URL.Path, "err", err) w.WriteHeader(http.StatusInternalServerError) } func die(err error, str string) { if err == nil { return } log.Error(str, "err", err) os.Exit(1) } var log = slog.New(slog.NewJSONHandler(os.Stdout, nil)) const ( migrationText = ` CREATE TABLE alert( id INTEGER PRIMARY KEY, created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, service TEXT NOT NULL, component TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', message TEXT NOT NULL, alerter TEXT NOT NULL, updater TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'TRIGGERED', severity TEXT NOT NULL DEFAULT 'IMMEDIATE', status_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_voiced TIMESTAMP, metadata JSONB ); ` devSeedAlert = `INSERT INTO alert (service, message, alerter) VALUES ('my_web_service', 'can not get index page', 'service_checker'); ` ) const ( severityImmediate = "IMMEDIATE" severityInvestigate = "INVESTIGATE" ) const ( statusTriggered = "TRIGGERED" statusAcknowledged = "ACKNOWLEDGED" statusResolved = "RESOLVED" ) // key is service, component // do grouping in time? type Payload struct { Service string `json:"service"` Component string `json:"component"` Error string `json:"error"` Message string `json:"message"` Status string `json:"status"` Severity string `json:"severity"` Metadata map[string]any `json:"metadata"` } var ( ErrNilPayloadCreate = errors.New("nil payload create") ) func (p *Payload) ValidateCreate() error { errs := []error{} if p == nil { errs = append(errs, ErrNilPayloadCreate) return errors.Join(errs...) } return errors.Join(errs...) } type SpeakAlert struct { Id int Service string Component string Error string Message string Severity string LastVoiced time.Time StatusTime time.Time } func timeAsSpeak(t time.Time) string { return t.Format("January _2 2006 3:04PM MST") } func timeAsTerse(t time.Time) string { return t.Format("Jan _2 06 15:04") } func (sa *SpeakAlert) Speak(cfg config.Config) error { var msg strings.Builder if sa.Service != "" { msg.WriteString(" service: ") msg.WriteString(sa.Service) } if sa.Component != "" { msg.WriteString(" component: ") msg.WriteString(sa.Component) } if sa.Error != "" { msg.WriteString(" error: ") msg.WriteString(sa.Error) } if sa.Message != "" { msg.WriteString(" message: ") msg.WriteString(sa.Message) } if !sa.StatusTime.IsZero() { msg.WriteString(" since: ") msg.WriteString(timeAsSpeak(sa.StatusTime)) } err := speak(msg.String(), cfg) return err } func (sa *SpeakAlert) FetchId(db *sql.DB, id int, cfg config.Config) error { getVoiceableSql := `SELECT id, service, component, error, message, severity, last_voiced, status_time FROM alert WHERE id = $1;` row := db.QueryRow(getVoiceableSql, id) err := row.Scan(&sa.Id, &sa.Service, &sa.Component, &sa.Error, &sa.Message, &sa.Severity, &sa.LastVoiced, &sa.StatusTime, ) if err != nil { return err } sa.LastVoiced = sa.LastVoiced.UTC() sa.StatusTime = sa.StatusTime.UTC() return nil } type updateVoicing struct { id int tme time.Time } func voiceAlerts(db *sql.DB, cfg config.Config) error { getVoiceableSql := `SELECT id, service, component, error, message, severity, last_voiced, status_time FROM alert WHERE status = 'TRIGGERED';` rows, err := db.Query(getVoiceableSql) if err != nil { return err } now := time.Now().UTC() updateSqls := []updateVoicing{} for rows.Next() { var sa SpeakAlert rows.Scan(&sa.Id, &sa.Service, &sa.Component, &sa.Error, &sa.Message, &sa.Severity, &sa.LastVoiced, &sa.StatusTime, ) sa.LastVoiced = sa.LastVoiced.UTC() sa.StatusTime = sa.StatusTime.UTC() if sa.Severity == severityImmediate && now.Sub(sa.LastVoiced) <= time.Second*time.Duration(cfg.IntervalImmediate) { continue } if sa.Severity == severityInvestigate && now.Sub(sa.LastVoiced) <= time.Second*time.Duration(cfg.IntervalInvestigate) { continue } go sa.Speak(cfg) updateSqls = append(updateSqls, updateVoicing{ id: sa.Id, tme: time.Now().UTC(), }) } if err := rows.Err(); err != nil { return err } for _, updateSqlArgs := range updateSqls { updateSql := `UPDATE alert SET last_voiced = $1 WHERE id = $2;` _, err = db.Exec(updateSql, updateSqlArgs.tme, updateSqlArgs.id) if err != nil { log.Error("could not update last voiced", "id", updateSqlArgs.id) } } return nil } var muSpeak sync.Mutex func speak(str string, cfg config.Config) error { if cfg.SkipVoicing { return nil } cmd := exec.Command(cfg.Speaker, str) muSpeak.Lock() err := cmd.Run() muSpeak.Unlock() if err != nil { log.Error("unable to speak", "err", err) return err } return nil } func voiceWorker(db *sql.DB, cfg config.Config) { if cfg.SkipVoicing { return } for { err := voiceAlerts(db, cfg) if err != nil { log.Error("error voicing alerts", "err", err) } time.Sleep(time.Minute) } } var okBytes = []byte("ok") func migrate(db *sql.DB, cfg config.Config) error { rows, err := db.Query(`SELECT service, message FROM alert LIMIT 1;`) if err != nil { _, err := db.Exec(migrationText) if err != nil { return err } } else { defer rows.Close() } if !cfg.SeedAlert { return nil } if rows == nil { rows, err = db.Query(`SELECT service, message FROM alert LIMIT 1;`) if err != nil { return err } defer rows.Close() } var service, message string for rows.Next() { err = rows.Scan(&service, &message) if err != nil { return err } } if service == "my_web_service" && message == "can not get index page" { return nil } _, err = db.Exec(devSeedAlert) if err != nil { return err } return nil } func isAdmin(r *http.Request, cfg config.Config) bool { cookie, err := r.Cookie("Session") if err != nil { return false } username, ok := cfg.Keys[cookie.Value] if !ok || username != "admin" { return false } return true } func getUser(r *http.Request, cfg config.Config) string { var key string cookie, err := r.Cookie("Session") if err != nil { authHeader := r.Header.Get("Authorization") _, token, _ := strings.Cut(authHeader, "Bearer ") key = token return "" } else { key = cookie.Value } username, ok := cfg.Keys[key] if !ok || username != "admin" { return "" } return username } func main() { cfg := config.Must() db, err := sql.Open("sqlite", cfg.Conn) die(err, "could not open db") defer db.Close() die(migrate(db, cfg), "could not run migrations") http.Handle("GET /healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(okBytes) })) http.Handle("GET /favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(favicon) })) http.Handle("GET /login", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if is := isAdmin(r, cfg); is { http.Redirect(w, r, "/alerts", http.StatusSeeOther) return } wBdTp(w, r, h.TLogin(w, r.URL.Query(), &cfg, nil)) })) http.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if is := isAdmin(r, cfg); is { http.Redirect(w, r, "/alerts", http.StatusSeeOther) return } wBdTp(w, r, h.TLogin(w, r.URL.Query(), &cfg, nil)) })) http.Handle("GET /logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Set-Cookie", DelSessionCookie) http.Redirect(w, r, "/login", http.StatusSeeOther) })) http.Handle("POST /login", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if is := isAdmin(r, cfg); is { http.Redirect(w, r, "/alerts", http.StatusSeeOther) } err := r.ParseForm() if err != nil { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) } key := r.Form.Get("cicada_key") username, ok := cfg.Keys[key] if !ok || username != "admin" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) return } SetSession(w.Header(), key, &cfg) http.Redirect(w, r, "/alerts", http.StatusSeeOther) })) http.Handle("GET /alerts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !isAdmin(r, cfg) { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) return } rows, err := db.Query(`SELECT id, service, component, severity, status, created, status_time, error, message, updater, alerter FROM alert;`) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("invalid status")) } alerts := []map[string]any{} resolved := []map[string]any{} for rows.Next() { msa := map[string]any{} var id int var service, component, severity, status, error, message, updater, alerter string var createdTime, statusTime time.Time rows.Scan(&id, &service, &component, &severity, &status, &createdTime, &statusTime, &error, &message, &updater, &alerter) msa["Id"] = id msa["Service"] = service msa["Component"] = component msa["Severity"] = severity msa["Status"] = status msa["CreatedTime"] = timeAsTerse(createdTime) msa["StatusTime"] = timeAsTerse(statusTime) msa["_StatusTime"] = statusTime msa["Error"] = error msa["Message"] = message msa["Updater"] = updater msa["Alerter"] = alerter if status == statusResolved { resolved = append(resolved, msa) } else { alerts = append(alerts, msa) } } slices.SortFunc(resolved, func(x map[string]any, y map[string]any) int { xTime, ok := x["_StatusTime"].(time.Time) if !ok { return 0 } yTime, ok := y["_StatusTime"].(time.Time) if !ok { return 0 } return cmp.Compare(xTime.Unix(), yTime.Unix()) * -1 }) slices.SortFunc(alerts, func(x map[string]any, y map[string]any) int { xComponent, ok := x["Component"].(string) if !ok { return 0 } yComponent, ok := y["Component"].(string) if !ok { return 0 } return cmp.Compare(xComponent, yComponent) }) if err := rows.Err(); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return } data := map[string]any{ "Alerts": alerts, "Resolved": resolved, } wBdTp(w, r, h.TAlerts(w, r.URL.Query(), &cfg, data)) })) http.Handle("GET /alerts/{id}/newstatus/{status}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username := getUser(r, cfg) if username == "" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) } id := r.PathValue("id") newStatus := r.PathValue("status") switch newStatus { case statusTriggered, statusAcknowledged, statusResolved: default: { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("invalid status")) return } } _, err := db.Exec(`UPDATE alert SET status=$1, status_time = $2, updater = $3 WHERE id = $4`, newStatus, time.Now().UTC(), username, id) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("could not update new status: " + err.Error())) return } if newStatus == statusTriggered { idInt, err := strconv.Atoi(id) if err != nil { log.Warn("id not a number", "err", err, "id", id) } else { if !cfg.SkipVoicing { sa := SpeakAlert{} err = sa.FetchId(db, idInt, cfg) go sa.Speak(cfg) } } } http.Redirect(w, r, "/alerts", http.StatusSeeOther) })) http.Handle("GET /alerts/{id}/delete", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !isAdmin(r, cfg) { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) } id := r.PathValue("id") _, err := db.Exec(`DELETE FROM alert WHERE id = $1`, id) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("could not delete alert: " + err.Error())) return } http.Redirect(w, r, "/alerts", http.StatusSeeOther) })) http.Handle("POST /alerts", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username := getUser(r, cfg) if username == "" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) return } err := r.ParseForm() if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("could not parse form: " + err.Error())) return } service := r.Form.Get("service") if service == "" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("must pass service")) return } component := r.Form.Get("component") error := r.Form.Get("error") message := r.Form.Get("message") if message == "" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("must pass message")) return } status := r.Form.Get("status") if status != statusTriggered && status != statusAcknowledged && status != statusResolved { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("could not parse form: bad status: " + status)) return } severity := r.Form.Get("severity") if severity != severityImmediate && severity != severityInvestigate { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("could not parse form: bad severity: " + severity)) return } statusTimeStr := r.Form.Get("status_time") var statusTime time.Time if statusTimeStr != "" { statusTimeParsed, err := time.Parse(time.RFC3339, statusTimeStr) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("could not parse form: " + err.Error())) return } statusTime = statusTimeParsed.UTC() } else { statusTime = time.Now().UTC() } insertSql := `INSERT INTO alert (service, component, error, message, status, severity, status_time, alerter) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` _, err = db.Exec(insertSql, service, component, error, message, status, severity, statusTime, username) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("could not insert alert: " + err.Error())) return } http.Redirect(w, r, "/alerts", http.StatusSeeOther) })) log.Info("listening", "addr_port", cfg.AddrPort) if !cfg.SkipVoicing { go func() { speak("starting cicada", cfg) voiceWorker(db, cfg) }() } err = http.ListenAndServe(cfg.AddrPort, nil) if !cfg.SkipVoicing { speak("stopping cicada", cfg) } die(err, "end serve") } const DelSessionCookie = "Session=; Path=/; Max-Age=0; HttpOnly; Secure; Partitioned; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT" func SetSession(h http.Header, token string, cfg *config.Config) { cookieVal := fmt.Sprintf( "Session=%s; Path=/; Max-Age=%d; HttpOnly; Secure; Partitioned; SameSite=Lax", token, cfg.SessionLengthMax, ) h.Set("Set-Cookie", cookieVal) }