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