package main import ( "crypto/tls" "database/sql" "flag" "fmt" "html/template" "io" "log" "log/slog" "net" "net/http" "os" "path" "strconv" "strings" "time" "github.com/BurntSushi/toml" _ "github.com/jackc/pgx/v5/stdlib" "golang.org/x/term" ) var ( configPath = flag.String("config", "monfront.conf", "path to the config file") DB *sql.DB Tmpl *template.Template ) type ( Config struct { DB string `toml:"db"` Listen string `toml:"listen"` TemplatePath string `toml:"template_path"` SSL struct { Enable bool `toml:"enable"` Priv string `toml:"private_key"` Cert string `toml:"certificate"` } `toml:"ssl"` Authentication struct { Mode string `toml:"mode"` Token string `toml:"session_token"` AllowAnonymous bool `toml:"allow_anonymous"` Header string `toml:"header"` List [][]string `toml:"list"` ClientCA string `toml:"cert"` } `toml:"authentication"` Authorization struct { Mode string `toml:"mode"` List []string `toml:"list"` } Log struct { Format string `toml:"format"` Level string `toml:"level"` Output string `toml:"output"` } } MapEntry struct { Name string Title string Color string } ) func main() { flag.Parse() if len(flag.Args()) > 0 { switch flag.Arg(0) { case "pwgen": fmt.Printf("enter password: ") pw, err := term.ReadPassword(0) fmt.Println() if err != nil { log.Fatalf("could not read password: %s", err) } hash, err := newHash(string(pw)) if err != nil { log.Fatalf("could not generate password hash: %s", err) } fmt.Printf("generated password hash: %s\n", hash) os.Exit(0) default: log.Fatalf("unknown command '%s'", flag.Arg(0)) } } if info, err := os.Stat(*configPath); err != nil { log.Fatalf("could not find config '%s': %s", *configPath, err) } else if info.Mode() != 0600 && info.Mode() != 0400 { log.Fatalf("config '%s' is world readable!", *configPath) } raw, err := os.ReadFile(*configPath) if err != nil { log.Fatalf("could not read config: %s", err) } config := Config{ Listen: "127.0.0.1:8080", TemplatePath: "templates", } if err := toml.Unmarshal(raw, &config); err != nil { log.Fatalf("could not parse config: %s", err) } logger := parseLogger(config) db, err := sql.Open("pgx", config.DB) if err != nil { log.Fatalf("could not open database connection: %s", err) } DB = db authenticator := Authenticator{ db: db, Mode: config.Authentication.Mode, Token: []byte(config.Authentication.Token), AllowAnonymous: config.Authentication.AllowAnonymous, Header: config.Authentication.Header, List: config.Authentication.List, ClientCA: config.Authentication.ClientCA, } auth, err := authenticator.Handler() if err != nil { log.Fatalf("could not start authenticator") } authorizer := Authorizer{ db: db, Mode: config.Authorization.Mode, List: config.Authorization.List, } autho, err := authorizer.Handler() if err != nil { log.Fatalf("could not start authorizer") } tmpl := template.New("main") tmpl.Funcs(Funcs) files, err := os.ReadDir(config.TemplatePath) if err != nil { log.Fatalf("could not read directory '%s': %s", config.TemplatePath, err) } for _, file := range files { if !strings.HasSuffix(file.Name(), ".html") { continue } raw, err := os.ReadFile(path.Join(config.TemplatePath, file.Name())) if err != nil { log.Fatalf("could not read file '%s': %s", path.Join(config.TemplatePath, file.Name()), err) } template.Must(tmpl.New(strings.TrimSuffix(file.Name(), ".html")).Parse(string(raw))) } Tmpl = tmpl if config.Listen == "" { config.Listen = "127.0.0.1:8080" } l, err := net.Listen("tcp", config.Listen) if err != nil { log.Fatalf("could not create listener: %s", err) } if config.SSL.Enable { cert, err := tls.LoadX509KeyPair(config.SSL.Cert, config.SSL.Priv) if err != nil { log.Fatalf("could not load certificate: %s", err) } tlsConf := &tls.Config{ Certificates: []tls.Certificate{cert}, NextProtos: []string{"h2", "1.1"}, } l = tls.NewListener(l, tlsConf) } s := newServer(l, db, logger, tmpl, auth, autho) s.Handle("/", showChecks) s.Handle("/create", showCreate) s.Handle("/check", showCheck) s.Handle("/checks", showChecks) s.Handle("/groups", showGroups) s.Handle("/action", checkAction) s.HandleStatic("/static/", showStatic) log.Fatalf("http server stopped: %s", s.ListenAndServe()) } func parseLogger(config Config) *slog.Logger { var output io.Writer switch config.Log.Output { case "", "stderr": output = os.Stderr case "stdout": output = os.Stdout default: var err error output, err = os.OpenFile(config.Log.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0640) if err != nil { log.Fatalf("could not open log file handler: %s", err) } } var level slog.Level switch config.Log.Level { case "debug": level = slog.LevelDebug case "", "info": level = slog.LevelInfo case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: log.Fatalf("unknown log level '%s', only 'debug', 'info', 'warn' and 'error' are supported", config.Log.Level) } var handler slog.Handler switch config.Log.Format { case "", "text": handler = slog.NewTextHandler(output, &slog.HandlerOptions{Level: level}) case "json": handler = slog.NewJSONHandler(output, &slog.HandlerOptions{Level: level}) default: log.Fatalf("unknown log format '%s', only 'text' and 'json' are supported", config.Log.Format) } return slog.New(handler) } func checkAction(con *Context) { if con.r.Method != "POST" { con.w.WriteHeader(http.StatusMethodNotAllowed) con.w.Write([]byte("method is not supported")) return } if !con.CanEdit { con.w.WriteHeader(http.StatusForbidden) con.w.Write([]byte("no permission to change data")) return } if err := con.r.ParseForm(); err != nil { con.w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(con.w, "could not parse parameters: %s", err) return } ref, found := con.r.Header["Referer"] if found { con.w.Header()["Location"] = ref } else { con.w.Header()["Location"] = []string{"/"} } checks := con.r.PostForm["checks"] action := con.r.PostForm.Get("action") if action == "" || len(checks) == 0 { con.w.WriteHeader(http.StatusSeeOther) return } setTable := "checks" setClause := "" comment := con.r.PostForm.Get("comment") run_in := con.r.PostForm.Get("run_in") if action == "comment" && comment == "" && run_in != "" { action = "reschedule" } whereFields := []string{} whereVals := []any{} switch action { case "mute": setTable = "checks_notify" setClause = "enabled = false" case "unmute": setTable = "checks_notify" setClause = "enabled = true" case "enable": setClause = "enabled = true, updated = now()" case "disable": setClause = "enabled = false, updated = now()" case "delete_check": if _, err := DB.Exec(`delete from checks where id = any ($1::bigint[])`, checks); err != nil { con.log.Info("could not delete checks", "checks", checks, "error", err) con.Error = "could not delete checks" returnError(http.StatusInternalServerError, con, con.w) return } con.w.WriteHeader(http.StatusSeeOther) return case "create_check": case "reschedule": setClause = "next_time = now()" if run_in != "" { runNum, err := strconv.Atoi(run_in) if err != nil { con.Error = "run_in is not a valid number" returnError(http.StatusBadRequest, con, con.w) return } setClause = fmt.Sprintf("next_time = now() + '%dmin'::interval", runNum) } setTable = "active_checks" case "deack": setClause = "acknowledged = false" setTable = "active_checks" case "ack": setClause = "acknowledged = true" setTable = "active_checks" whereFields = append(whereFields, "states[0]") whereVals = append(whereVals, 0) hostname, err := os.Hostname() if err != nil { con.log.Info("could not resolve hostname", "error", err) con.Error = "could not resolve hostname" returnError(http.StatusInternalServerError, con, con.w) return } if _, err := DB.Exec(`insert into notifications(check_id, states, output, mapping_id, notifier_id, check_host) select ac.check_id, 0 || states[1:4], 'check acknowledged', ac.mapping_id, cn.notifier_id, $2 from checks_notify cn join active_checks ac on cn.check_id = ac.check_id where cn.check_id = any ($1::bigint[])`, &checks, &hostname); err != nil { con.log.Info("could not acknowledge check", "error", err) con.Error = "could not acknowledge check" returnError(http.StatusInternalServerError, con, con.w) return } case "comment": if comment == "" { con.w.WriteHeader(http.StatusSeeOther) return } _, err := DB.Exec( "update active_checks set notice = $2 where check_id = any ($1::bigint[]);", &checks, comment) if err != nil { con.w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(con.w, "could not store changes") con.log.Info("could not adjust checks", "error", err, "checks", checks) return } con.w.WriteHeader(http.StatusSeeOther) return case "uncomment": _, err := DB.Exec(`update active_checks set notice = null where check_id = any($1::bigint[]);`, &checks) if err != nil { con.Error = "could not uncomment checks" returnError(http.StatusInternalServerError, con, con.w) con.log.Info("could not uncomment checks", "error", err) return } con.w.WriteHeader(http.StatusSeeOther) return default: con.Error = fmt.Sprintf("requested action '%s' does not exist", action[0]) returnError(http.StatusNotFound, con, con.w) return } whereColumn := "id" if setTable == "active_checks" || setTable == "checks_notify" { whereColumn = "check_id" } sql := "update " + setTable + " set " + setClause + " where " + whereColumn + " = any($1::bigint[])" whereVals = append([]any{&checks}, whereVals...) if len(whereFields) > 0 { for i, column := range whereFields { sql = sql + " and " + column + fmt.Sprintf(" = $%d", i+1) } } _, err := DB.Exec(sql, whereVals...) if err != nil { con.w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(con.w, "could not store changes") con.log.Info("could not adjust checks", "checks", checks, "error", err) return } con.w.WriteHeader(http.StatusSeeOther) return } func returnError(status int, con *Context, w http.ResponseWriter) { w.Header()["Content-Type"] = []string{"text/html"} w.WriteHeader(status) if err := Tmpl.ExecuteTemplate(w, "error", con); err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("problem with a template")) con.log.Info("could not execute template", "error", err) } } func (c *Context) loadCommands() error { c.Commands = map[string]int{} rows, err := DB.Query(`select id, name from commands order by name`) if err != nil { return err } for rows.Next() { if rows.Err() != nil { return rows.Err() } var ( id int name string ) if err := rows.Scan(&id, &name); err != nil { return err } c.Commands[name] = id } return nil } func (c *Context) loadMappings() error { c.Mappings = map[int]map[int]MapEntry{} rows, err := DB.Query(SQLShowMappings) if err != nil { return err } for rows.Next() { if rows.Err() != nil { return rows.Err() } var ( mapId int name string target int title string color string ) if err := rows.Scan(&mapId, &name, &target, &title, &color); err != nil { return err } ma, found := c.Mappings[mapId] if !found { ma = map[int]MapEntry{} c.Mappings[mapId] = ma } ma[target] = MapEntry{Title: title, Color: color, Name: name} } return nil } func showStatic(w http.ResponseWriter, r *http.Request) { file := strings.TrimPrefix(r.URL.Path, "/static/") raw, found := Static[file] if !found { w.WriteHeader(http.StatusNotFound) w.Write([]byte("file does not exist")) return } w.Header()["Content-Type"] = []string{"image/svg+xml"} w.WriteHeader(http.StatusOK) w.Write([]byte(raw)) return } var ( SQLShowMappings = `select mapping_id, name, target, title, color from mappings m join mapping_level ml on m.id = ml.mapping_id` ) var ( Templates = map[string]string{} Static = map[string]string{ "icon-mute": `Check is muted`, "icon-notice": ``, "error": `{{ template "header" . }}{{ template "footer" . }}`, } TmplUnhandledGroups = `TODO` Funcs = template.FuncMap{ "int": func(in int64) int { return int(in) }, "sub": func(base, amount int) int { return base - amount }, "in": func(t time.Time) time.Duration { return t.Sub(time.Now()).Round(1 * time.Second) }, "since": func(t time.Time) time.Duration { return time.Now().Sub(t).Round(1 * time.Second) }, "now": func() time.Time { return time.Now() }, "join": func(args []string, c string) string { return strings.Join(args, c) }, "mapString": func(mapId, target int) string { return fmt.Sprintf("%d-%d", mapId, target) }, "itoa": func(i int) string { return strconv.Itoa(i) }, } )