aboutsummaryrefslogtreecommitdiff
path: root/cmd/monfront
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/monfront')
-rw-r--r--cmd/monfront/README.md72
-rw-r--r--cmd/monfront/authenticater.go183
-rw-r--r--cmd/monfront/authorizer.go35
-rw-r--r--cmd/monfront/checks.go226
-rw-r--r--cmd/monfront/filter.go96
-rw-r--r--cmd/monfront/groups.go85
-rw-r--r--cmd/monfront/main.go426
-rw-r--r--cmd/monfront/pw.go99
-rw-r--r--cmd/monfront/server.go153
-rw-r--r--cmd/monfront/templates/check.html51
-rw-r--r--cmd/monfront/templates/checkfilter.html49
-rw-r--r--cmd/monfront/templates/checkformfooter.html1
-rw-r--r--cmd/monfront/templates/checkformheader.html43
-rw-r--r--cmd/monfront/templates/checklist.html29
-rw-r--r--cmd/monfront/templates/error.html1
-rw-r--r--cmd/monfront/templates/footer.html76
-rw-r--r--cmd/monfront/templates/grouplist.html21
-rw-r--r--cmd/monfront/templates/header.html101
18 files changed, 1747 insertions, 0 deletions
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": `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35.3 35.3" version="1.1"><title>Check is muted</title><style>.s0{fill:#191919;}</style><g transform="translate(0,-261.72223)"><path d="m17.6 261.7v35.3L5.3 284.7H0v-10.6l5.3 0zM30.2 273.1l-3.7 3.7-3.7-3.7-2.5 2.5 3.7 3.7-3.7 3.7 2.5 2.5 3.7-3.7 3.7 3.7 2.5-2.5-3.7-3.7 3.7-3.7z" fill="#191919"/></g></svg>`,
+ "icon-notice": `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="36" height="36"><path d="M2.572.19h30.857c1.319 0 2.38 1.356 2.38 3.041v19.98c0 1.685-1.061 3.04-2.38 3.04H15.941L4 35.81v-9.56H2.572C1.252 26.252.19 24.897.19 23.212V3.232C.19 1.545 1.252.19 2.57.19z" stroke="#000" stroke-width=".38" stroke-linejoin="round"/></svg>`,
+ "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" . }}
+ <section id="content">
+ {{ template "checkformheader" . }}
+ {{ $mapping := .Mappings }}
+ {{ with .CheckDetails }}
+ <input type="hidden" name="checks" value="{{ .Id }}" />
+ <article class="detail">
+ <h1>check for service {{ .Name }}</h1>
+ <div><span class="label">current state</span><span class="value state-{{ index .States 0 }}"></span></div>
+ <div><span class="label">current notice</span><span class="value">{{ if .Notice }}{{ .Notice.String }}{{ end }}</span></div>
+ <div><span class="label">Message</span><span class="value">{{ .Message }}</span></div>
+ <div><span class="label">enabled</span><span class="value">{{ .Enabled }}</span></div>
+ <div><span class="label">updated</span><span class="value">{{ .Updated.Format "2006.01.02 15:04:05" }}</span></div>
+ <div><span class="label">next check</span><span class="value">{{ .NextTime.Format "2006.01.02 15:04:05" }}</span></div>
+ <div><span class="label">last refresh</span><span class="value">{{ .LastRefresh.Format "2006.01.02 15:04:05" }}</span></div>
+ <div><span class="label">mapping</span><span class="value">{{ .MappingId }}</span></div>
+ </article>
+ <article class="detail">
+ <h1>node <a href="/checks?node_id={{ .NodeId }}">{{ .NodeName }}</a></h1>
+ <div><span class="label">Message</span><span class="value">{{ .NodeMessage }}</span></div>
+ </article>
+ <article class="detail">
+ <h1>command {{ .CommandName }}</h1>
+ <div><span class="label">Message</span><span class="value">{{ .CommandMessage }}</span></div>
+ <div><span class="label">command line</span><span class="value"><code>{{ join .CommandLine " " }}</code></span></div>
+ </article>
+ <article class="detail">
+ <h1>checker {{ .CheckerName }}</h1>
+ <div><span class="label">Description</span><span class="value">{{ .CheckerMsg }}</span></div>
+ </article>
+ <article>
+ <h1>notifications</h1>
+ <table>
+ <thead><tr><th>notifier</th><th>state</th><th>created</th><th>sent</th><th>output</th></thead>
+ <tbody>
+ {{ range .Notifications -}}
+ <tr>
+ <td>{{ .NotifierName }}</td>
+ <td class="state-{{ .MappingId }}-{{ .State }}">{{ (index $mapping .MappingId .State).Title }}</td>
+ <td>{{ .Inserted.Format "2006.01.02 15:04:05" }}</td>
+ <td>{{ if .Sent.Valid }}{{ .Sent.Time.Format "2006.01.02 15:04:05" }}{{ end }}</td>
+ <td>{{ .Output }}</td>
+ </tr>
+ {{ end -}}
+ </tbody>
+ </table>
+ </article>
+ {{ end }}
+ {{ template "checkformfooter" . }}
+ </section>
+ {{ 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 @@
+ <form action="/checks" method="get">
+ <aside id="edit">
+ <div class="option">
+ <select id="filter-state" name="filter-state" class="states">
+ <option value="">filter state</option>
+ {{ $FilterValsState := "" }}
+ {{ if .Filter.Vals.state }}{{$FilterValsState = .Filter.Vals.state }}{{ end }}
+ <option value="0" {{ if eq (itoa 0) $FilterValsState }}selected{{ end }}>&gt;= OK</option>
+ <option value="1" {{ if eq (itoa 1) $FilterValsState }}selected{{ end }}>&gt;= Warning</option>
+ <option value="2" {{ if eq (itoa 2) $FilterValsState }}selected{{ end }}>&gt;= Error</option>
+ <option value="3" {{ if eq (itoa 3) $FilterValsState }}selected{{ end }}>&gt;= Unknown</option>
+ </select>
+ </div>
+ <div class="option">
+ <select id="filter-ack" name="filter-ack">
+ <option value="">filter acknowledged</option>
+ <option value="false" {{ if eq "false" (index .Filter.Vals "ack") }}selected{{ end }}>unacknowledged</option>
+ <option value="true" {{ if eq "true" (index .Filter.Vals "ack") }}selected{{ end }}>acknowledged</option>
+ </select>
+ </div>
+ <div class="option">
+ <select id="filter-mapping" name="filter-mapping">
+ <option value="">filter mapping</option>
+ {{ $FilterValsMapping := "" }}
+ {{ if .Filter.Vals.mapping }}{{ $FilterValsMapping = .Filter.Vals.mapping }}{{ end }}
+ {{ range $mapId, $mapping := .Mappings -}}
+ <option value="{{ $mapId }}" {{ if eq (itoa $mapId) $FilterValsMapping }}selected{{ end }}>{{ (index $mapping 0).Name }}</option>
+ {{ end }}
+ </select>
+ </div>
+ <div class="option">
+ <select id="filter-command" name="filter-command">
+ <option value="">filter command</option>
+ {{ $FilterValsCommands := "" }}
+ {{ if .Filter.Vals.command }}{{ $FilterValsCommands = .Filter.Vals.command }}{{ end }}
+ {{ range $command, $comId := .Commands -}}
+ <option value="{{ $comId }}" {{ if eq (itoa $comId) $FilterValsCommands }}selected{{ end }}>{{ $command }}</option>
+ {{ end }}
+ </select>
+ </div>
+ <div class="option">
+ <input name="filter-search" placeholder="hostname" value="{{ .Filter.Vals.search }}" />
+ </div>
+ <div class="option">
+ <button name="filter" value="1">filter</button>
+ <button name="reset" value="1">reset</button>
+ </div>
+ </aside>
+ </form>
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 @@
+</form>
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 }}
+ <form action="/action" method="post">
+ <aside id="edit">
+ <button class="default_button" name="action" value="comment"></button>
+ <div class="option input">
+ <input type="number" name="run_in" placeholder="run in" title="define the number of minutes after which the check should be run again" />
+ <button name="action" value="reschedule">run now</button>
+ </div>
+ <div class="option">
+ <button name="action" value="deack">deack</button>
+ <button name="action" value="ack">ack</button>
+ </div>
+ <div class="option">
+ <button name="action" value="enable">enable</button>
+ <button name="action" value="disable">disable</button>
+ </div>
+ <div class="option">
+ <button name="action" value="mute">mute</button>
+ <button name="action" value="unmute">unmute</button>
+ </div>
+ <div class="option input">
+ <input size="" name="comment" placeholder="enter comment ..." />
+ </div>
+ <div class="option">
+ <button name="action" value="comment">comment</button>
+ <button name="action" value="uncomment">uncomment</button>
+ </div>
+ <div class="option">
+ <button type="button" name="create_check">create</button>
+ <button name="action" value="delete_check">delete</button>
+ </div>
+ </aside>
+ <aside id="create_check" class="hidden">
+ <div class="option">
+ <label for="host">host</label>
+ <input name="host" placeholder="hostname" />
+ </div>
+ <div class="option">
+ <label for="command">command</label>
+ <input name="command" placeholder="command" />
+ </div>
+ </aside>
+ {{ 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" . }}
+ <section id="content">
+ {{ template "checkfilter" . }}
+ {{ template "checkformheader" . }}
+ <table>
+ <thead><tr><th><input type="checkbox" title="select all" /></th><th>host</th><th>service</th><th>status</th><th title="shows how long the check is already in that state">for</th><th>next check in</th><th>message</th></tr></thead>
+ <tbody>
+ {{ $current := "" }}
+ {{ $mapping := .Mappings }}
+ {{ range .Checks }}
+ <tr>
+ <td><input type="checkbox" name="checks" value="{{ .CheckID }}" /></td>
+ <td>{{ if ne $current .NodeName }}{{ $current = .NodeName }}<a href="/checks?node_id={{ .NodeId }}">{{ .NodeName }}</a>{{ end }}</td>
+ <td>{{ .CheckName }}</td>
+ <td class="state-{{ .State }}">
+ {{- if ne .Notify true }}<span class="icon mute"></span>{{ end -}}
+ {{- if .Notice.Valid }}<span class="icon notice" title="{{ .Notice.String }}"></span>{{ end -}}
+ <a href="/check?check_id={{ .CheckID }}">{{ .CommandName }}</a>
+ </td>
+ <td>{{ since .StateSince }}</td>
+ <td>{{ in .NextTime }}</td>
+ <td><code>{{ .Msg }}</code></td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+ {{ template "checkformfooter" . }}
+ </section>
+ {{ 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 @@
+ <script type="text/javascript">
+ function row_head_click_event(event) {
+ check = false;
+ current = event.target;
+ while (current != null) {
+ if (current.nodeName == 'TABLE') {
+ break;
+ }
+ if (current.nodeName == 'TR') {
+ check = !current.children[0].children[0].checked;
+ current.children[0].children[0].checked = check;
+ }
+ current = current.parentNode;
+ }
+ lines = current.children[1].children
+ for (i = 0; i < lines.length; i++) {
+ select_row(event, lines[i], lines[i].children[0].children[0], check);
+ }
+ }
+ function row_click_event(event) {
+ if (event.target.nodeName == 'INPUT') {
+ return;
+ }
+ current = event.target;
+ while (current = current.parentNode) {
+ if (current.nodeName == 'BODY') {
+ break;
+ }
+ if (current.nodeName != 'TR') {
+ continue;
+ }
+ e = current.children[0].children[0];
+ check = !e.checked;
+ select_row(event, current, e, check);
+ break;
+ }
+ }
+ function select_row(event, row, input, check) {
+ if (input != event.target) {
+ input.checked = check;
+ }
+ if (input.checked) {
+ row.classList.add("selected");
+ } else {
+ row.classList.remove("selected");
+ }
+ input.focus();
+ }
+
+ for (selector of ['thead > tr', 'thead input']) {
+ els = document.querySelectorAll(selector);
+ for (i = 0; i < els.length; i++) {
+ els[i].addEventListener('click', {handleEvent: row_head_click_event});
+ }
+ }
+ for (selector of ['tbody > tr', 'tbody input']) {
+ els = document.querySelectorAll(selector);
+ for (i = 0; i < els.length; i++) {
+ els[i].addEventListener('click', {handleEvent: row_click_event});
+ }
+ }
+ butt = document.querySelectorAll('button[type=button][name=create_check]');
+ for (i = 0; i < butt.length; i++) {
+ butt[i].addEventListener('click', {handleEvent: function(event){
+ cur = document.querySelector('#create_check').style.display;
+ console.log("meh: " + cur);
+ if (cur == 'block') {
+ document.querySelector('#create_check').style.display = 'none';
+ } else {
+ document.querySelector('#create_check').style.display = 'block';
+ }
+ }});
+ }
+ </script>
+ </body>
+</html>
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" . }}
+ <content>
+ <table>
+ <thead><tr><th></th><th>group</th><th>host</th><th>worst state</th></tr></thead>
+ <tbody>
+ {{ $current := "" }}
+ {{ $mapping := .Mappings }}
+ {{ range .Groups }}
+ <tr>
+ <td><input type="checkbox" name="nodes" value="{{ .NodeId }}" /></td>
+ <td>{{ if ne $current .Name }}{{ $current = .Name }}<a href="{{ if $.Unhandled }}/unhandled{{ end }}/checks?group_id={{ .GroupId }}">{{ .Name }}</a>{{ end }}</td>
+ <td><a href="/checks?node_id={{ .NodeId }}">{{ .NodeName }}</a></td>
+ <td class="state-{{ .MappingId }}-{{ .State }}">{{ (index $mapping .MappingId .State).Title }}</td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+ </content>
+ {{ 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 @@
+<!doctype html>
+<html>
+ <head>
+ <title>{{ .Title }}</title>
+ <meta name="referrer" content="same-origin">
+ <link rel="shortcut icon" href="/static/favicon" />
+ <style type="text/css">
+ :root {
+ --main-bg-color: #3a4149;
+ --dark-bg-color: #2f353a;
+ --light-bg-color: #626971;
+ --main-fg-color: #eeeeee;
+
+ --bg-okay: hsla(125, 50%, 40%, 1);
+ --bg-warn: hsla(40, 100%, 50%, 1);
+ --bg-crit: hsla(0, 75%, 50%, 1);
+ --bg-unkn: gray;
+ }
+ * { font-size: 100%; }
+ body { background: var(--dark-bg-color); padding: 0; margin: 0; color: var(--main-fg-color); }
+ section#content { padding: 1em; background: var(--main-bg-color); border: 1px solid black; margin: 0.5em; }
+ #mainmenu, aside { background: var(--main-bg-color); border-bottom: 1px solid black; }
+ #mainmenu ul, aside {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+ align-content: center; }
+ aside { padding: 0.5em; border: 1px solid black; border-bottom: none; }
+ #mainmenu li { list-style-type: none; }
+ #mainmenu a, #mainmenu a:visited, #mainmenu a:active, #mainmenu a:hover, #mainmenu span {
+ text-decoration: none;
+ color: #e4e7ea;
+ padding: 0.5em 0.75em;
+ display: block; }
+ aside .option { display: grid; grid-template-columns: auto auto; margin: 0em 0.25em; }
+ input[type="number"] { width: 4em; }
+ button, select { background: var(--dark-bg-color); color: var(--main-fg-color); border: 1px solid black; padding: 0.25em 0.5em; }
+ a { color: var(--main-fg-color); }
+ form article { border: 1px solid black; border-bottom: none; padding: 0.5em; }
+ table { border-collapse: collapse; border-spacing: 0; width: 100%; }
+ table tr:nth-child(odd) { background: rgba(0, 0, 0, 0.15); }
+ table tr:nth-child(even) { background: var(--main-bg-color); }
+ table tr.selected:nth-child(odd) { background: var(--light-bg-color); }
+ table tr.selected:nth-child(even) { background: rgba(255, 255, 255, 0.45); }
+ table tr:hover, table tr:hover a { background: #dfdfdf; color: black; }
+ table th { background: var(--main-bg-color); color: var(--main-fg-color); font-weigth: 700; }
+ table td, table th { text-align: center; border: 1px solid black; padding: 0.35em 0.15em; }
+ table code { font-size: 75%; }
+ table td.disabled { text-decoration: line-through; }
+ .icon {
+ display: inline-block;
+ height: 1em;
+ margin: 0;
+ width: 1em;
+ vertical-align: bottom;
+ margin-right: 0.5em;
+ background-size: contain;
+ }
+ .hidden { display: none; }
+ .default_button { margin: 0; padding: 0; border: 0; height: 0; width: 0; }
+ .mute { background-image: url(/static/icon-mute); }
+ .notice { background-image: url(/static/icon-notice); }
+ .detail > div { display: grid; grid-template-columns: 25% auto; }
+ .detail > div:hover { background: #dfdfdf; color: black; }
+ .error { padding: 0.5em; background: #ffc6c6; border: 1px solid red; }
+ select.states option[value="0"], .state-0 { background-color: var(--bg-okay); }
+ select.states option[value="1"], .state-1 { background-color: var(--bg-warn); }
+ select.states option[value="2"], .state-2 { background-color: var(--bg-crit); }
+ select.states option[value="3"], .state-3 { background-color: var(--bg-unkn); }
+ .state-0:after { content: 'okay' }
+ .state-1:after { content: 'warning' }
+ .state-2:after { content: 'critical' }
+ .state-3:after { content: 'unknown' }
+ /* state background colors */
+ {{ range $mapId, $mapping := .Mappings -}}
+ {{ range $target, $val := $mapping -}}
+ .state-{{ $mapId }}-{{ $target }} { background: {{ $val.Color }}; color: black; }
+ {{ end -}}
+ {{ end -}}
+ </style>
+ <script>
+ setTimeout(function() { if (document.activeElement.tagName == "BODY") { location.reload(true) } }, 30000)
+ </script>
+ </head>
+ <body>
+ <nav id="mainmenu">
+ <ul>
+ <li><span>{{ now.Format "2006.01.02" }}</span></li>
+ <li><span>{{ now.Format "15:04:05" }}</span></li>
+ <li><a href="/">home</a></li>
+ <li><a href="/checks?filter-state=1&filter-ack=false">checks</a></li>
+ <li><a href="/groups">groups</a></li>
+ <li class="submenu">
+ <form action="/checks" method="get">
+ </form>
+ </li>
+ </ul>
+ </nav>
+ {{ if .Error }}<div class="error">{{ .Error }}</div>{{ end }}