diff --git a/Makefile b/Makefile
index 5d712f2..3f43128 100644
--- a/Makefile
+++ b/Makefile
@@ -9,6 +9,8 @@ prefix ?= /usr/local
exec_prefix ?= ${prefix}
bindir ?= ${exec_prefix}/bin
sysconfdir ?= ${prefix}/etc/${NAME}
+datarootdir ?= ${prefix}/share
+datadir ?= ${datarootdir}/${NAME}
WRKDIR ?= build
GOBIN ?= go
@@ -19,23 +21,51 @@ LDFLAGS += -B ${BUILDID}
BUILD_DATE ?= `date +%FT%T%z`
LDFLAGS += -X main.BUILD_DATE=${BUILD_DATE}
-build: clean
+MONFRONT_FILES = $(wildcard cmd/monfront/*.go) $(wildcard *.go)
+
+all: build
+
+build: env/${WRKDIR} moncheck monwork monfront
+
+env/${WRKDIR}:
mkdir -p ${WRKDIR}
+
+moncheck:
GOOS=${GOOS} CGO_ENABLED=false go build -ldflags="${LDFLAGS}" -o ${WRKDIR}/moncheck ${PKGNAME}/cmd/moncheck
+
+monwork:
GOOS=${GOOS} CGO_ENABLED=false go build -ldflags="${LDFLAGS}" -o ${WRKDIR}/monwork ${PKGNAME}/cmd/monwork
+monfront:
+ GOOS=${GOOS} CGO_ENABLED=false go build -ldflags="${LDFLAGS}" -o ${WRKDIR}/monfront ${PKGNAME}/cmd/monfront
+
clean:
-rm -r ${WRKDIR}
-install: build
+install: build preinstall install-monwork install-moncheck install-monfront
+
+preinstall:
install -d -m 0755 ${DESTDIR}${bindir}
install -d -m 0755 ${DESTDIR}${sysconfdir}
+
+install-moncheck: preinstall
install -m 0755 ${WRKDIR}/moncheck ${DESTDIR}${bindir}
- install -m 0755 ${WRKDIR}/monwork ${DESTDIR}${bindir}
install -m 0644 moncheck.conf.example ${DESTDIR}${sysconfdir}
+
+install-monwork: preinstall
+ install -m 0755 ${WRKDIR}/monwork ${DESTDIR}${bindir}
install -m 0644 monwork.conf.example ${DESTDIR}${sysconfdir}
+install-monfront: preinstall
+ install -m 0755 ${WRKDIR}/monfront ${DESTDIR}${bindir}
+ install -m 0644 monfront.conf.example ${DESTDIR}${sysconfdir}
+ install -d -m 0755 ${DESTDIR}${datadir}/templates
+ sed -i'' "s-\#template_path.*-template_path = \"${datadir}/templates\"-g" ${DESTDIR}${sysconfdir}/monfront.conf.example
+ find cmd/monfront/templates -type f -exec install -m 0644 "{}" ${DESTDIR}${datadir}/templates \;
+
package: DESTDIR = ${NAME}-${VERSION}
package: install
tar -czf ${NAME}-${VERSION}.tar.gz ${DESTDIR}
rm -R ${DESTDIR}
+
+.PHONY: clean build moncheck monwork 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": ``,
+ "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 }}
+
+
+
+ Message{{ .NodeMessage }}
+
+
+ command {{ .CommandName }}
+ Message{{ .CommandMessage }}
+ command line{{ join .CommandLine " " }}
+
+
+ checker {{ .CheckerName }}
+ Description{{ .CheckerMsg }}
+
+
+ notifications
+
+ notifier | state | created | sent | output |
+
+ {{ range .Notifications -}}
+
+ {{ .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 -}}
+
+
+
+ {{ 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 }}
+