From fa05045d31c05c8928020f05f1d281901d983b2b Mon Sep 17 00:00:00 2001 From: Gibheer Date: Thu, 2 Dec 2021 17:54:14 +0100 Subject: cmd/monfront - import monfront from separate repository This is the import from the separate monfront repository. The history could not be imported, but this should suffice. --- cmd/monfront/README.md | 72 +++++ cmd/monfront/authenticater.go | 183 ++++++++++++ cmd/monfront/authorizer.go | 35 +++ cmd/monfront/checks.go | 226 +++++++++++++++ cmd/monfront/filter.go | 96 +++++++ cmd/monfront/groups.go | 85 ++++++ cmd/monfront/main.go | 426 ++++++++++++++++++++++++++++ cmd/monfront/pw.go | 99 +++++++ cmd/monfront/server.go | 153 ++++++++++ cmd/monfront/templates/check.html | 51 ++++ cmd/monfront/templates/checkfilter.html | 49 ++++ cmd/monfront/templates/checkformfooter.html | 1 + cmd/monfront/templates/checkformheader.html | 43 +++ cmd/monfront/templates/checklist.html | 29 ++ cmd/monfront/templates/error.html | 1 + cmd/monfront/templates/footer.html | 76 +++++ cmd/monfront/templates/grouplist.html | 21 ++ cmd/monfront/templates/header.html | 101 +++++++ 18 files changed, 1747 insertions(+) create mode 100644 cmd/monfront/README.md create mode 100644 cmd/monfront/authenticater.go create mode 100644 cmd/monfront/authorizer.go create mode 100644 cmd/monfront/checks.go create mode 100644 cmd/monfront/filter.go create mode 100644 cmd/monfront/groups.go create mode 100644 cmd/monfront/main.go create mode 100644 cmd/monfront/pw.go create mode 100644 cmd/monfront/server.go create mode 100644 cmd/monfront/templates/check.html create mode 100644 cmd/monfront/templates/checkfilter.html create mode 100644 cmd/monfront/templates/checkformfooter.html create mode 100644 cmd/monfront/templates/checkformheader.html create mode 100644 cmd/monfront/templates/checklist.html create mode 100644 cmd/monfront/templates/error.html create mode 100644 cmd/monfront/templates/footer.html create mode 100644 cmd/monfront/templates/grouplist.html create mode 100644 cmd/monfront/templates/header.html (limited to 'cmd/monfront') diff --git a/cmd/monfront/README.md b/cmd/monfront/README.md new file mode 100644 index 0000000..ad64845 --- /dev/null +++ b/cmd/monfront/README.md @@ -0,0 +1,72 @@ +monfront +======== + +Monfront is the frontend to manage monzero. Monzero consists of the other two +components moncheck and monwork too. + +requirements +------------ + +runtime requirements: +* PostgreSQL >= 10.0 + +build requirements: +* Go >= 1.11 + +components +---------- + +The following components exist: + +### monfront + +Monfront is a webfrontend to view the current state of all checks, configure +hosts, groups, checks and view current notifications. +It is possible to run multiple instances. + +configuration +------------- + +To get the system working, first install the database. After that, create an +alarm mapping: + +``` +insert into mappings(name, description) values ('default', 'The default mapping'); +insert into mapping_level values (1, 0, 0, 'okay', 'green'); +insert into mapping_level values (1, 1, 1, 'okay', 'orange'); +insert into mapping_level values (1, 2, 2, 'okay', 'red'); +insert into mapping_level values (1, 3, 3, 'okay', 'gray'); +``` + +Next is to create a notifier. This feature doesn't work 100% yet and needs some +work and may look different later: + +``` +insert into notifier(name) values ('default'); +``` + +After that create a check command: + +``` +insert into commands(name, command, message) values ('ping', 'ping -n -c 1 {{ .ip }}', 'Ping a target'); +``` + +This command can contain variables that are set in the check. It will be executed by moncheck and the result stored. + +After that, create a node which will get the checks attached: + +``` +insert into nodes(name, message) values ('localhost', 'My localhost is my castle'); +``` + +With that prepared, create the first check: + +``` +insert into checks(node_id, command_id, notifier_id, message, options) +values (1, 1, 1, 'This is my localhost ping check!', '{"ip": "127.0.0.1"}'); +``` + +Now start the daemons moncheck, monfront and monwork. + +monwork will transform the configured check into an active check, while moncheck +will run the actual checks. Through monfront one can view the current status. diff --git a/cmd/monfront/authenticater.go b/cmd/monfront/authenticater.go new file mode 100644 index 0000000..8453264 --- /dev/null +++ b/cmd/monfront/authenticater.go @@ -0,0 +1,183 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "fmt" + "log" + "net/http" + "strings" + "time" +) + +const ( + BasicAuthPrompt = `Basic realm="auth for monfront"` + SessionCookie = `session` + UserAnonymous = `anonymous` +) + +type ( + // Authenticator is a middleware taking a context and authenticating + // the user. + Authenticator struct { + db *sql.DB + Mode string + Token []byte + AllowAnonymous bool + Header string + List [][]string + ClientCA string + + sessions map[string]*session // maps a session key to a user + } + + session struct { + user string + t time.Time + } +) + +// Handler returns the handler for the authentication configuration. +func (a *Authenticator) Handler() (func(*Context) error, error) { + switch a.Mode { + case "none": + return func(_ *Context) error { return nil }, nil + case "header": + if a.Header == "" { + return nil, fmt.Errorf("authentication mode is 'header' but no header was provided") + } + return func(c *Context) error { + if user := c.r.Header.Get(a.Header); user == "" { + if a.AllowAnonymous { + c.User = UserAnonymous + return nil + } + return a.Unauthorized(c) + } else { + c.User = user + } + return nil + }, nil + case "list": + return func(c *Context) error { + user, pass, ok := c.r.BasicAuth() + if !ok || user == "" || pass == "" { + if a.AllowAnonymous { + c.User = UserAnonymous + return nil + } + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } + var found string + for _, entry := range a.List { + if entry[0] == user { + found = entry[1] + } + } + if found == "" { + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } + p := pwHash{} + if err := p.Parse(found); err != nil { + log.Printf("could not parse hash for user '%s': %s", user, err) + return a.Unauthorized(c) + } + if ok, err := p.compare(pass); err != nil { + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } else if !ok { + c.w.Header().Set("WWW-Authenticate", BasicAuthPrompt) + return a.Unauthorized(c) + } + c.User = user + return nil + }, nil + case "db": + return func(c *Context) error { + sessCookie := c.GetCookieVal(SessionCookie) + if sessCookie != "" { + ses := a.getSession(sessCookie) + if ses != "" { + // TODO fix time limit to make it variable + c.SetCookie(SessionCookie, sessCookie, time.Now().Add(2*time.Hour)) + c.User = ses + return nil + } + } + return fmt.Errorf("NOT YET IMPLEMENTED") + }, fmt.Errorf("NOT YET IMPLEMENTED") + case "cert": + return func(c *Context) error { + return fmt.Errorf("NOT YET IMPLEMENTED") + }, fmt.Errorf("NOT YET IMPLEMENTED") + default: + return nil, fmt.Errorf("unknown mode '%s' for authentication", a.Mode) + } + return nil, fmt.Errorf("could not create authenticator") +} + +func (a *Authenticator) Unauthorized(c *Context) error { + c.w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(c.w, "unauthorized\n") + return fmt.Errorf("no authentication") +} + +// creates a session for a user +func (a *Authenticator) createSession(user string) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("could not generate new session key") + } + res := a.mac(raw) + ses := fmt.Sprintf( + "%s-%s", + base64.StdEncoding.EncodeToString(raw), + base64.StdEncoding.EncodeToString(res), + ) + a.sessions[ses] = &session{user: user, t: time.Now()} + return ses, nil +} + +func (a *Authenticator) mac(input []byte) []byte { + mac := hmac.New(sha256.New, a.Token) + mac.Write(input) + return mac.Sum(nil) +} + +// getSession returns the username of the current session. +func (a *Authenticator) getSession(session string) string { + if session == "" { + return "" + } + parts := strings.Split(session, "-") + if len(parts) != 2 { + return "" + } + msg, err := base64.StdEncoding.DecodeString(parts[0]) + if err != nil { + return "" + } + mac, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + verify := a.mac(msg) + if !hmac.Equal(mac, verify) { + return "" + } + if ses, found := a.sessions[session]; found { + // TODO make timeout a config option + if time.Now().Sub(ses.t) < 8*time.Hour { + delete(a.sessions, session) + return "" + } + ses.t = time.Now() + return ses.user + } + return "" +} diff --git a/cmd/monfront/authorizer.go b/cmd/monfront/authorizer.go new file mode 100644 index 0000000..7880cd0 --- /dev/null +++ b/cmd/monfront/authorizer.go @@ -0,0 +1,35 @@ +package main + +import ( + "database/sql" + "fmt" +) + +type ( + Authorizer struct { + db *sql.DB + Mode string + List []string + } +) + +func (a *Authorizer) Handler() (func(c *Context) error, error) { + switch a.Mode { + case "none": + return func(_ *Context) error { return nil }, nil + case "list": + return func(c *Context) error { + for _, user := range a.List { + if user == c.User { + c.CanEdit = true + return nil + } + } + return nil + }, nil + case "all": + return func(c *Context) error { c.CanEdit = true; return nil }, nil + default: + return func(_ *Context) error { return nil }, fmt.Errorf("authorization mode '%s' is unsupported", a.Mode) + } +} diff --git a/cmd/monfront/checks.go b/cmd/monfront/checks.go new file mode 100644 index 0000000..d61d337 --- /dev/null +++ b/cmd/monfront/checks.go @@ -0,0 +1,226 @@ +package main + +import ( + "database/sql" + "log" + "net/http" + "time" + + "github.com/lib/pq" +) + +type ( + check struct { + NodeId int + NodeName string + CommandName string + CheckID int64 + CheckName string + MappingId int + State int + Enabled bool + Notify bool + Notice sql.NullString + NextTime time.Time + Msg string + StateSince time.Time + } + + checkDetails struct { + Id int64 + Name string + Message string + Enabled bool + Updated time.Time + LastRefresh time.Time + NextTime time.Time + MappingId int + MappingName string + NodeId int + NodeName string + NodeMessage string + CommandId int + CommandName string + CommandLine []string + CommandMessage string + States []int64 + Notice sql.NullString + Notifiers []notifier + Notifications []notification + CheckerID int + CheckerName string + CheckerMsg string + } + + notifier struct { + Id int + Name string + Enabled bool + } + + notification struct { + Id int64 + State int + Output string + Inserted time.Time + Sent pq.NullTime + NotifierName string + MappingId int + } +) + +// showCheck loads shows the notifications for a specific check. +func showCheck(con *Context) { + cd := checkDetails{} + con.CheckDetails = &cd + id, found := con.r.URL.Query()["check_id"] + if !found { + con.Error = "no check given to view" + returnError(http.StatusNotFound, con, con.w) + return + } + query := `select c.id, c.name, c.message, c.enabled, c.updated, c.last_refresh, + m.id, m.name, n.id, n.name, n.message, co.id, co.Name, co.message, + ac.cmdline, ac.states, ac.msg, ac.next_time, ch.id, ch.name, ch.description + from checks c + join active_checks ac on c.id = ac.check_id + join nodes n on c.node_id = n.id + join commands co on c.command_id = co.id + join mappings m on ac.mapping_id = m.id + join checkers ch on c.checker_id = ch.id + where c.id = $1::bigint` + err := DB.QueryRow(query, id[0]).Scan(&cd.Id, &cd.Name, &cd.Message, &cd.Enabled, + &cd.Updated, &cd.LastRefresh, &cd.MappingId, &cd.MappingName, &cd.NodeId, + &cd.NodeName, &cd.NodeMessage, &cd.CommandId, &cd.CommandName, &cd.CommandMessage, + pq.Array(&cd.CommandLine), pq.Array(&cd.States), &cd.Notice, &cd.NextTime, + &cd.CheckerID, &cd.CheckerName, &cd.CheckerMsg) + if err != nil && err == sql.ErrNoRows { + con.w.Header()["Location"] = []string{"/"} + con.w.WriteHeader(http.StatusSeeOther) + return + } else if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problems with the database")) + log.Printf("could not get check details for check id %s: %s", id[0], err) + return + } + + query = `select n.id, states[1], output, inserted, sent, no.name, n.mapping_id + from notifications n + join notifier no on n.notifier_id = no.id + where check_id = $1::bigint + order by inserted desc + limit 500` + rows, err := DB.Query(query, cd.Id) + defer rows.Close() + if err != nil { + log.Printf("could not load notifications: %s", err) + con.Error = "could not load notification information" + returnError(http.StatusInternalServerError, con, con.w) + return + } + cd.Notifications = []notification{} + for rows.Next() { + if err := rows.Err(); err != nil { + log.Printf("could not load notifications: %s", err) + con.Error = "could not load notification information" + returnError(http.StatusInternalServerError, con, con.w) + return + } + no := notification{} + if err := rows.Scan(&no.Id, &no.State, &no.Output, &no.Inserted, + &no.Sent, &no.NotifierName, &no.MappingId); err != nil { + log.Printf("could not scan notifications: %s", err) + con.Error = "could not load notification information" + returnError(http.StatusInternalServerError, con, con.w) + return + } + cd.Notifications = append(cd.Notifications, no) + } + + if err := con.loadMappings(); err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problem with the mappings")) + log.Printf("could not load mappings: %s", err) + return + } + + con.w.Header()["Content-Type"] = []string{"text/html"} + con.Render("check") +} + +func showChecks(con *Context) { + query := `select c.id, c.name, n.id, n.name, co.name, ac.mapping_id, ac.states[1] as state, + ac.enabled, ac.notice, ac.next_time, ac.msg, + case when cn.check_id is null then false else true end as notify_enabled, + state_since + from active_checks ac + join checks c on ac.check_id = c.id + join nodes n on c.node_id = n.id + join commands co on c.command_id = co.id + left join ( select distinct check_id from checks_notify where enabled = true) cn on c.id = cn.check_id` + filter := newFilter() + con.Filter = filter + if id, found := con.r.URL.Query()["group_id"]; found { + query += ` join nodes_groups ng on n.id = ng.node_id` + filter.Add("ng.group_id", "=", id[0], "int") + } + filter.filterChecks(con) + if search, found := con.r.URL.Query()["search"]; found { + filter.AddSpecial( + `to_tsvector('english', regexp_replace(n.name, '[.-/]', ' ', 'g'))`, + `@@`, + `to_tsquery('english', regexp_replace($%d, '[.-/]', ' & ', 'g') || ':*')`, + search[0]) + } + if id, found := con.r.URL.Query()["node_id"]; found { + filter.Add("n.id", "=", id[0], "int") + } + if id, found := con.r.URL.Query()["check_id"]; found { + filter.Add("c.id", "=", id[0], "int") + } + where, params := filter.Join() + if len(where) > 0 { + query += " where " + where + } + query += ` order by n.name, c.name, co.name` + rows, err := DB.Query(query, params...) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problems with the database")) + log.Printf("could not get check list: %s", err) + return + } + defer rows.Close() + + checks := []check{} + for rows.Next() { + c := check{} + err := rows.Scan(&c.CheckID, &c.CheckName, &c.NodeId, &c.NodeName, &c.CommandName, &c.MappingId, + &c.State, &c.Enabled, &c.Notice, &c.NextTime, &c.Msg, &c.Notify, &c.StateSince) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + returnError(http.StatusInternalServerError, con, con.w) + log.Printf("could not get check list: %s", err) + return + } + checks = append(checks, c) + } + con.Checks = checks + if err := con.loadCommands(); err != nil { + con.Error = "could not load commands" + returnError(http.StatusInternalServerError, con, con.w) + log.Printf("could not get commands: %s", err) + return + } + if err := con.loadMappings(); err != nil { + con.Error = "could not load mapping data" + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problem with the mappings")) + log.Printf("could not load mappings: %s", err) + return + } + con.w.Header()["Content-Type"] = []string{"text/html"} + con.Render("checklist") + return +} diff --git a/cmd/monfront/filter.go b/cmd/monfront/filter.go new file mode 100644 index 0000000..bd82c91 --- /dev/null +++ b/cmd/monfront/filter.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "strings" +) + +type ( + filter struct { + idx int + where []string + params []interface{} + Vals map[string]string + } +) + +func newFilter() *filter { + return &filter{ + idx: 0, + where: []string{}, + params: []interface{}{}, + Vals: map[string]string{}, + } +} + +func (f *filter) filterChecks(c *Context) { + args := c.r.URL.Query() + for name, val := range args { + if !strings.HasPrefix(name, "filter-") { + continue + } + arg := strings.TrimPrefix(name, "filter-") + switch arg { + case "command": + if val[0] == "" { + continue + } + f.Add("co.id", "=", val[0], "int") + f.Vals[arg] = val[0] + case "search": + if val[0] == "" { + continue + } + f.Add(`n.name`, `like`, strings.ReplaceAll(val[0], "*", "%"), "text") + f.Vals[arg] = val[0] + case "state": + if val[0] == "" { + continue + } + f.Add("states[1]", ">=", val[0], "int") + f.Vals[arg] = val[0] + case "ack": + if val[0] == "" { + continue + } + if val[0] != "true" && val[0] != "false" { + continue + } + f.Add("acknowledged", "=", val[0], "boolean") + f.Vals[arg] = val[0] + case "mapping": + if val[0] == "" { + continue + } + f.Add("ac.mapping_id", "=", val[0], "int") + f.Vals[arg] = val[0] + } + } +} + +// Add a new where clause element which will be joined at the end. +func (f *filter) Add(field, op string, arg interface{}, castTo string) { + f.idx += 1 + f.where = append(f.where, fmt.Sprintf("%s %s $%d::%s", field, op, f.idx, castTo)) + f.params = append(f.params, arg) +} + +// AddSpecial lets you add a special where clause comparison where you can +// wrap the argument in whatevery you like. +// +// Your string has to contain %d. This will place the index of the variable +// in the query string. +// +// Example: +// AddSpecial("foo", "=", "to_tsvector('english', $%d), search) +func (f *filter) AddSpecial(field, op, special string, arg interface{}) { + f.idx += 1 + f.where = append(f.where, fmt.Sprintf("%s %s "+special, field, op, f.idx)) + f.params = append(f.params, arg) +} + +// Join takes all where clauses and joins them together with the AND operator. +// The result and all collected parameters are then returned. +func (f *filter) Join() (string, []interface{}) { + return strings.Join(f.where, " and "), f.params +} diff --git a/cmd/monfront/groups.go b/cmd/monfront/groups.go new file mode 100644 index 0000000..0dfa40c --- /dev/null +++ b/cmd/monfront/groups.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "strings" +) + +type ( + group struct { + GroupId int + Name string + NodeId int + NodeName string + State int + MappingId int + } +) + +func showGroups(con *Context) { + query := `select + group_id, + group_name, + node_id, + node_name, + mapping_id, + state +from ( + select + g.id group_id, + g.name group_name, + n.id node_id, + n.name node_name, + ac.states[1] state, + ac.mapping_id, + ac.acknowledged, + row_number() over (partition by c.node_id order by ac.states[1] desc) maxstate + from groups g + join nodes_groups ng on g.id = ng.group_id + join nodes n on ng.node_id = n.id + join checks c on n.id = c.node_id + join active_checks ac on c.id = ac.check_id + %s + order by g.name, n.name +) groups +where maxstate = 1` + if strings.HasPrefix(con.r.URL.Path, "/unhandled") { + query = fmt.Sprintf(query, `where ac.states[1] != 0 and acknowledged = false`) + con.Unhandled = true + } else { + query = fmt.Sprintf(query, "") + } + + rows, err := DB.Query(query) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problems with the database")) + log.Printf("could not get check list: %s", err) + return + } + + groups := []group{} + for rows.Next() { + g := group{} + err := rows.Scan(&g.GroupId, &g.Name, &g.NodeId, &g.NodeName, &g.MappingId, &g.State) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problems with the database")) + log.Printf("could not get check list: %s", err) + return + } + groups = append(groups, g) + } + con.Groups = groups + if err := con.loadMappings(); err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + con.w.Write([]byte("problem with the mappings")) + log.Printf("could not load mappings: %s", err) + return + } + con.w.Header()["Content-Type"] = []string{"text/html"} + con.Render("grouplist") + return +} diff --git a/cmd/monfront/main.go b/cmd/monfront/main.go new file mode 100644 index 0000000..6275b34 --- /dev/null +++ b/cmd/monfront/main.go @@ -0,0 +1,426 @@ +package main + +import ( + "crypto/tls" + "database/sql" + "flag" + "fmt" + "html/template" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/BurntSushi/toml" + "github.com/lib/pq" + "golang.org/x/crypto/ssh/terminal" +) + +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"` + } + } + + 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 := terminal.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 := ioutil.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) + } + + db, err := sql.Open("postgres", 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 := ioutil.ReadDir(config.TemplatePath) + if err != nil { + log.Fatalf("could not read directory '%s': %s", config.TemplatePath, err) + } + for _, file := range files { + if !file.Mode().IsRegular() { + continue + } + if !strings.HasSuffix(file.Name(), ".html") { + continue + } + raw, err := ioutil.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, tmpl, auth, autho) + s.Handle("/", showChecks) + 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 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" + } + + 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[])`, pq.Array(checks)); err != nil { + log.Printf("could not delete checks '%s': %s", checks, 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" + + hostname, err := os.Hostname() + if err != nil { + log.Printf("could not resolve hostname: %s", 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[])`, pq.Array(&checks), &hostname); err != nil { + log.Printf("could not acknowledge check: %s", 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[]);", + pq.Array(&checks), + comment) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(con.w, "could not store changes") + log.Printf("could not adjust checks %#v: %s", checks, err) + return + } + con.w.WriteHeader(http.StatusSeeOther) + return + case "uncomment": + _, err := DB.Exec(`update active_checks set notice = null where check_id = any($1::bigint[]);`, + pq.Array(&checks)) + if err != nil { + con.Error = "could not uncomment checks" + returnError(http.StatusInternalServerError, con, con.w) + log.Printf("could not uncomment checks: %s", 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" + } + + _, err := DB.Exec("update "+setTable+" set "+setClause+" where "+whereColumn+" = any ($1::bigint[]);", pq.Array(&checks)) + if err != nil { + con.w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(con.w, "could not store changes") + log.Printf("could not adjust checks %#v: %s", checks, err) + return + } + con.w.WriteHeader(http.StatusSeeOther) + return +} + +func returnError(status int, con interface{}, 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")) + log.Printf("could not execute template: %s", 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) }, + } +) diff --git a/cmd/monfront/pw.go b/cmd/monfront/pw.go new file mode 100644 index 0000000..d06e9f2 --- /dev/null +++ b/cmd/monfront/pw.go @@ -0,0 +1,99 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/scrypt" +) + +type ( + pwHash struct { + salt []byte + hash []byte + } +) + +// Create a new password hash. +func newHash(pw string) (*pwHash, error) { + hash := pwHash{} + if err := hash.genSalt(); err != nil { + return nil, err + } + h, err := hash.Hash(pw) + if err != nil { + return nil, err + } + hash.hash = h + return &hash, nil +} + +// generate a hash for the given salt and password +func (p *pwHash) Hash(pw string) ([]byte, error) { + if len(p.salt) == 0 { + return []byte{}, fmt.Errorf("salt not initialized") + } + // constants taken from https://godoc.org/golang.org/x/crypto/scrypt + hash, err := scrypt.Key([]byte(pw), p.salt, 32768, 8, 1, 32) + if err != nil { + return []byte{}, fmt.Errorf("could not compute hash: %s", err) + } + return hash, nil +} + +// genSalt generates 8 bytes of salt. +func (p *pwHash) genSalt() error { + salt := make([]byte, 8) + _, err := rand.Read(salt) + p.salt = salt + return err +} + +// compare a hash to a password and return true, when it matches. +func (p *pwHash) compare(pw string) (bool, error) { + hash, err := p.Hash(pw) + if err != nil { + return false, fmt.Errorf("could not check password") + } + if bytes.Compare(p.hash, hash) == 0 { + return true, nil + } + return false, nil +} + +// Encode a hash and salt to a string. +func (p *pwHash) String() string { + return fmt.Sprintf( + "1$%s$%s", + base64.StdEncoding.EncodeToString(p.salt), + base64.StdEncoding.EncodeToString(p.hash), + ) +} + +// Parse a hash from a file or anywhere. +func (p *pwHash) Parse(raw string) error { + if len(raw) == 0 { + return fmt.Errorf("no hash found") + } + parts := strings.Split(raw, "$") + if len(parts) != 3 { + return fmt.Errorf("format error") + } + if parts[0] != "1" { + return fmt.Errorf("unknown hash version") + } + salt, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("could not parse salt: %s", err) + } + hash, err := base64.StdEncoding.DecodeString(parts[2]) + if err != nil { + return fmt.Errorf("could not parse salt: %s", err) + } + p.salt = salt + p.hash = hash + return nil +} diff --git a/cmd/monfront/server.go b/cmd/monfront/server.go new file mode 100644 index 0000000..2ada1d0 --- /dev/null +++ b/cmd/monfront/server.go @@ -0,0 +1,153 @@ +package main + +import ( + "compress/gzip" + "database/sql" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "strings" + "time" +) + +type ( + server struct { + listen net.Listener + db *sql.DB + h *http.ServeMux + tmpl *template.Template + auth func(c *Context) error // authentication + autho func(c *Context) error // authorization + } + + handleFunc func(c *Context) + + Context struct { + // internal maintenance stuff + w http.ResponseWriter + r *http.Request + tmpl *template.Template + db *sql.DB + + User string `json:"-"` + Filter *filter `json:"-"` + CanEdit bool `json:"-"` // has user permission to edit stuff? + + Title string `json:"title,omitempty"` + CurrentPath string `json:"-"` + Error string `json:"error,omitempty"` + Mappings map[int]map[int]MapEntry `json:"mappings,omitempty"` + Commands map[string]int `json:"commands,omitempty"` + Checks []check `json:"checks,omitempty"` + CheckDetails *checkDetails `json:"check_details,omitempty"` + Groups []group `json:"groups,omitempty"` + Unhandled bool `json:"-"` // set this flag when unhandled was called + } +) + +func newServer(l net.Listener, db *sql.DB, tmpl *template.Template, auth func(c *Context) error, autho func(c *Context) error) *server { + s := &server{ + listen: l, + db: db, + tmpl: tmpl, + h: http.NewServeMux(), + auth: auth, + autho: autho, + } + return s +} + +func (s *server) ListenAndServe() error { + server := http.Server{Handler: s.h} + return server.Serve(s.listen) +} + +func (s *server) Handle(path string, fun handleFunc) { + s.h.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + c := &Context{ + w: w, + r: r, + tmpl: s.tmpl, + db: s.db, + } + if err := s.auth(c); err != nil { + return + } + if err := s.autho(c); err != nil { + return + } + fun(c) + return + }) +} + +func (s *server) HandleStatic(path string, h func(w http.ResponseWriter, r *http.Request)) { + s.h.HandleFunc(path, h) +} + +// Render calls the template with the given name to +// render the appropiate content. +// In case of an error, a error message is automatically pushed +// to the client. +func (c *Context) Render(t string) error { + var w io.Writer = c.w + if strings.Contains(c.r.Header.Get("Accept-Encoding"), "gzip") { + gz, err := gzip.NewWriterLevel(w, 5) + if err != nil { + log.Printf("could not create gzip writer: %s", err) + return fmt.Errorf("could not create gzip writer: %s", err) + } + defer gz.Close() + w = gz + c.w.Header().Set("Content-Encoding", "gzip") + } + + if c.r.Header.Get("Accept") == "application/json" { + c.w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", "") // disable indentation to save traffic + if err := enc.Encode(c); err != nil { + c.w.WriteHeader(http.StatusInternalServerError) + c.w.Write([]byte("could not write json output")) + log.Printf("could not write json output: %s", err) + return err + } + return nil + } + + if err := c.tmpl.ExecuteTemplate(w, t, c); err != nil { + c.w.WriteHeader(http.StatusInternalServerError) + c.w.Write([]byte("problem with a template")) + log.Printf("could not execute template: %s", err) + return err + } + return nil +} + +// Get a cookie value. +func (c *Context) GetCookieVal(name string) string { + cook, err := c.r.Cookie(name) + if err == http.ErrNoCookie { + return "" + } + return cook.Value +} + +// Set a new key value cookie with a deadline. +func (c *Context) SetCookie(name, val string, expire time.Time) { + cook := http.Cookie{ + Name: name, + Value: val, + Expires: expire, + Secure: true, + SameSite: http.SameSiteStrictMode, + HttpOnly: true, + Path: "/", + } + http.SetCookie(c.w, &cook) + return +} diff --git a/cmd/monfront/templates/check.html b/cmd/monfront/templates/check.html new file mode 100644 index 0000000..e30f62a --- /dev/null +++ b/cmd/monfront/templates/check.html @@ -0,0 +1,51 @@ + {{ template "header" . }} +
+ {{ template "checkformheader" . }} + {{ $mapping := .Mappings }} + {{ with .CheckDetails }} + +
+

check for service {{ .Name }}

+
current state
+
current notice{{ if .Notice }}{{ .Notice.String }}{{ end }}
+
Message{{ .Message }}
+
enabled{{ .Enabled }}
+
updated{{ .Updated.Format "2006.01.02 15:04:05" }}
+
next check{{ .NextTime.Format "2006.01.02 15:04:05" }}
+
last refresh{{ .LastRefresh.Format "2006.01.02 15:04:05" }}
+
mapping{{ .MappingId }}
+
+ +
+

command {{ .CommandName }}

+
Message{{ .CommandMessage }}
+
command line{{ join .CommandLine " " }}
+
+
+

checker {{ .CheckerName }}

+
Description{{ .CheckerMsg }}
+
+
+

notifications

+ + + + {{ range .Notifications -}} + + + + + + + + {{ end -}} + +
notifierstatecreatedsentoutput
{{ .NotifierName }}{{ (index $mapping .MappingId .State).Title }}{{ .Inserted.Format "2006.01.02 15:04:05" }}{{ if .Sent.Valid }}{{ .Sent.Time.Format "2006.01.02 15:04:05" }}{{ end }}{{ .Output }}
+
+ {{ end }} + {{ template "checkformfooter" . }} +
+ {{ template "footer" . }} diff --git a/cmd/monfront/templates/checkfilter.html b/cmd/monfront/templates/checkfilter.html new file mode 100644 index 0000000..f86ce3c --- /dev/null +++ b/cmd/monfront/templates/checkfilter.html @@ -0,0 +1,49 @@ +
+ +
diff --git a/cmd/monfront/templates/checkformfooter.html b/cmd/monfront/templates/checkformfooter.html new file mode 100644 index 0000000..5582354 --- /dev/null +++ b/cmd/monfront/templates/checkformfooter.html @@ -0,0 +1 @@ + diff --git a/cmd/monfront/templates/checkformheader.html b/cmd/monfront/templates/checkformheader.html new file mode 100644 index 0000000..7662670 --- /dev/null +++ b/cmd/monfront/templates/checkformheader.html @@ -0,0 +1,43 @@ + {{ if .CanEdit }} +
+ + + {{ end }} diff --git a/cmd/monfront/templates/checklist.html b/cmd/monfront/templates/checklist.html new file mode 100644 index 0000000..17830ef --- /dev/null +++ b/cmd/monfront/templates/checklist.html @@ -0,0 +1,29 @@ + {{ template "header" . }} +
+ {{ template "checkfilter" . }} + {{ template "checkformheader" . }} + + + + {{ $current := "" }} + {{ $mapping := .Mappings }} + {{ range .Checks }} + + + + + + + + + + {{ end }} + +
hostservicestatusfornext check inmessage
{{ if ne $current .NodeName }}{{ $current = .NodeName }}{{ .NodeName }}{{ end }}{{ .CheckName }} + {{- if ne .Notify true }}{{ end -}} + {{- if .Notice.Valid }}{{ end -}} + {{ .CommandName }} + {{ since .StateSince }}{{ in .NextTime }}{{ .Msg }}
+ {{ template "checkformfooter" . }} +
+ {{ template "footer" . }} diff --git a/cmd/monfront/templates/error.html b/cmd/monfront/templates/error.html new file mode 100644 index 0000000..efffc4c --- /dev/null +++ b/cmd/monfront/templates/error.html @@ -0,0 +1 @@ +{{ .Error }} diff --git a/cmd/monfront/templates/footer.html b/cmd/monfront/templates/footer.html new file mode 100644 index 0000000..124b585 --- /dev/null +++ b/cmd/monfront/templates/footer.html @@ -0,0 +1,76 @@ + + + diff --git a/cmd/monfront/templates/grouplist.html b/cmd/monfront/templates/grouplist.html new file mode 100644 index 0000000..fa0554c --- /dev/null +++ b/cmd/monfront/templates/grouplist.html @@ -0,0 +1,21 @@ + {{ template "header" . }} + {{ template "checkformheader" . }} + + + + + {{ $current := "" }} + {{ $mapping := .Mappings }} + {{ range .Groups }} + + + + + + + {{ end }} + +
grouphostworst state
{{ if ne $current .Name }}{{ $current = .Name }}{{ .Name }}{{ end }}{{ .NodeName }}{{ (index $mapping .MappingId .State).Title }}
+
+ {{ template "checkformfooter" . }} + {{ template "footer" . }} diff --git a/cmd/monfront/templates/header.html b/cmd/monfront/templates/header.html new file mode 100644 index 0000000..1f9040d --- /dev/null +++ b/cmd/monfront/templates/header.html @@ -0,0 +1,101 @@ + + + + {{ .Title }} + + + + + + +
+ {{ if .Error }}
{{ .Error }}
{{ end }} -- cgit v1.2.3-70-g09d2