diff --git a/README.md b/README.md index 10c0ab6..a3b5750 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # am +* X Go client +* X Command line client: `am https://_:@addr ` +* alert timeout not updated \ No newline at end of file diff --git a/alert.go b/alert.go new file mode 100644 index 0000000..f5bd9d1 --- /dev/null +++ b/alert.go @@ -0,0 +1,44 @@ +package am + +import ( + "log" + "os" + "os/exec" + "path/filepath" +) + +func run(cmd, name, text string) { + c := exec.Command(cmd, name, text) + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + log.Printf("Failed to run command in shell `%s`: %v", cmd, err) + } +} + +func runLogAction(action, sourceName, text string) { + log.Printf("[%s] %s", sourceName, text) + + if action == "" { + dir, err := os.Getwd() + if err != nil { + log.Printf("Failed to get working directory: %v", err) + return + } + action = filepath.Join(dir, "log-action") + } + + run(action, sourceName, text) +} + +func runAlertAction(action, sourceName, text string) { + log.Printf("[%s] ALERT %s", sourceName, text) + if action == "" { + dir, err := os.Getwd() + if err != nil { + log.Printf("Failed to get working directory: %v", err) + return + } + action = filepath.Join(dir, "alert-action") + } + run(action, sourceName, text) +} diff --git a/amclient/client.go b/amclient/client.go new file mode 100644 index 0000000..e8a265e --- /dev/null +++ b/amclient/client.go @@ -0,0 +1,57 @@ +package amclient + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +type Client struct { + addr string + apiKey string +} + +// addr is the full address, for example: https://x.io/report. +func New(addr, apiKey string) Client { + return Client{addr, apiKey} +} + +func (cl Client) post(values url.Values) error { + resp, err := http.PostForm(cl.addr, values) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("POST failed: [%d] %s", resp.StatusCode, body) + } + return nil +} + +func (cl Client) Ping() error { + return cl.post(url.Values{ + "key": []string{cl.apiKey}, + "action": []string{"ping"}, + }) +} + +func (cl Client) Log(text string) error { + return cl.post(url.Values{ + "key": []string{cl.apiKey}, + "action": []string{"log"}, + "text": []string{text}, + }) +} + +func (cl Client) Alert(text string) error { + return cl.post(url.Values{ + "key": []string{cl.apiKey}, + "action": []string{"alert"}, + "text": []string{text}, + }) +} diff --git a/cmd/am/am.go b/cmd/am/am.go new file mode 100644 index 0000000..6787d94 --- /dev/null +++ b/cmd/am/am.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "git.crumpington.com/public/am/amclient" +) + +func main() { + usage := func() { + fmt.Fprintf( + os.Stderr, + "\n\n%s [text]\n\n"+ + "Action is one of `ping`, `log`, or `alert`.\n"+ + "The `log` and `alert` actions require `text`.\n\n", + os.Args[0]) + os.Exit(1) + } + + if len(os.Args) < 4 { + usage() + } + + url := os.Args[1] + apiKey := os.Args[2] + + cl := amclient.New(url, apiKey) + + action := os.Args[3] + + var err error + + switch action { + case "ping": + err = cl.Ping() + + case "log": + if len(os.Args) < 5 { + usage() + } + err = cl.Log(strings.Join(os.Args[4:], " ")) + + case "alert": + if len(os.Args) < 5 { + usage() + } + err = cl.Alert(strings.Join(os.Args[4:], " ")) + + default: + usage() + } + + if err != nil { + log.Fatalf("Error: %v", err) + } +} diff --git a/cmd/amserver/main.go b/cmd/amserver/main.go new file mode 100644 index 0000000..1e616a9 --- /dev/null +++ b/cmd/amserver/main.go @@ -0,0 +1,7 @@ +package main + +import "git.crumpington.com/public/am" + +func main() { + am.Main() +} diff --git a/cmd/generate/templates.go b/cmd/generate/templates.go new file mode 100644 index 0000000..63e1058 --- /dev/null +++ b/cmd/generate/templates.go @@ -0,0 +1,28 @@ +package main + +import ( + "io/ioutil" + "path/filepath" + "strings" +) + +func main() { + paths, err := filepath.Glob("templates/*") + if err != nil { + panic(err) + } + + contents := []string{} + for _, path := range paths { + data, err := ioutil.ReadFile(path) + if err != nil { + panic(err) + } + contents = append(contents, string(data)) + } + + s := "package am\n\nvar tmpls = `\n" + strings.Join(contents, "\n") + "\n`\n" + if err := ioutil.WriteFile("tmpl_gen.go", []byte(s), 0644); err != nil { + panic(err) + } +} diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..3dab19b --- /dev/null +++ b/crypto.go @@ -0,0 +1,30 @@ +package am + +import ( + "crypto/rand" + "encoding/hex" +) + +func newUUID() string { + buf := make([]byte, 16) + _, err := rand.Read(buf) + if err != nil { + panic(err) + } + + s := hex.EncodeToString(buf) + return s[:8] + "-" + + s[8:12] + "-" + + s[12:16] + "-" + + s[16:20] + "-" + + s[20:32] +} + +func newCSRF() string { + buf := make([]byte, 16) + _, err := rand.Read(buf) + if err != nil { + panic(err) + } + return hex.EncodeToString(buf) +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..4110b17 --- /dev/null +++ b/db.go @@ -0,0 +1,373 @@ +package am + +import ( + "database/sql" + "errors" + "log" + "strconv" + "time" + + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" +) + +type dbal struct { + sql.DB +} + +func (*dbal) logf(s string, args ...interface{}) { + log.Printf("db: "+s, args...) +} + +func newDB(dbPath string) (*dbal, error) { + _db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Printf("Failed to open database %s: %v", dbPath, err) + return nil, err + } + + db := &dbal{*_db} + + if _, err := db.Exec(migration); err != nil { + log.Printf("Failed to apply database migration: %v", err) + return nil, err + } + + _ = db.UserInsert(User{ + Username: `root`, + Admin: true, + }, `root`) + + return db, nil +} + +/********* + * Users * + *********/ + +func (db *dbal) UserInsert(u User, pwd string) error { + if err := validateName(u.Username); err != nil { + return err + } + if err := validatePwd(pwd); err != nil { + return err + } + + bHash, err := bcrypt.GenerateFromPassword( + []byte(pwd), + bcrypt.DefaultCost) + if err != nil { + db.logf("Failed to hash password: %v", err) + return err + } + _, err = db.Exec( + `INSERT INTO users(Username,Password,Admin)VALUES(?,?,?)`, + u.Username, string(bHash), u.Admin) + if err != nil { + db.logf("Failed to insert user %s: %v", u.Username, err) + } + return err +} + +func (db *dbal) UserGet(username string) (u User, err error) { + u.Username = username + err = db.QueryRow( + `SELECT Admin FROM users WHERE Username=?`, + username).Scan(&u.Admin) + if err != nil { + db.logf("Failed to get user %s: %v", username, err) + } + return +} + +func (db *dbal) UserGetWithPwd(username, pwd string) (u User, err error) { + u.Username = username + pwdHash := "" + err = db.QueryRow( + `SELECT Password,Admin FROM users WHERE Username=?`, + username).Scan(&pwdHash, &u.Admin) + if err != nil { + db.logf("Failed to get user %s: %v", username, err) + return u, err + } + err = bcrypt.CompareHashAndPassword([]byte(pwdHash), []byte(pwd)) + if err != nil { + db.logf("Password comparison failed for user %s: %v", username, err) + } + return u, err +} + +func (db *dbal) UserList() (l []User, err error) { + rows, err := db.Query(`SELECT Username, Admin FROM users ORDER BY Username`) + if err != nil { + db.logf("Failed to list users: %v", err) + return nil, err + } + + for rows.Next() { + u := User{} + if err := rows.Scan(&u.Username, &u.Admin); err != nil { + db.logf("Failed to scan user: %v", err) + return nil, err + } + l = append(l, u) + } + + return l, nil +} + +func (db *dbal) UserUpdatePwd(username, pwd string) error { + if err := validatePwd(pwd); err != nil { + return err + } + + bHash, err := bcrypt.GenerateFromPassword( + []byte(pwd), + bcrypt.DefaultCost) + if err != nil { + db.logf("Failed to hash password: %v", err) + return err + } + _, err = db.Exec( + `UPDATE users SET Password=? WHERE Username=?`, + string(bHash), username) + if err != nil { + db.logf("Failed to update password for user %s: %v", username, err) + } + return err +} + +func (db *dbal) UserUpdateAdmin(username string, b bool) error { + if username == `root` { + return errors.New("Root user is always an admin.") + } + _, err := db.Exec( + `UPDATE users SET Admin=? WHERE Username=?`, + b, username) + if err != nil { + db.logf("Failed to update admin for user %s: %v", username, err) + } + return err +} + +func (db *dbal) UserDelete(username string) error { + if username == `root` { + return errors.New("Root user cannot be deleted.") + } + _, err := db.Exec(`DELETE FROM users WHERE username=?`, username) + if err != nil { + db.logf("Failed to delete user %s: %v", username, err) + } + return err +} + +/*********** + * Sources * + ***********/ + +func (db *dbal) SourceInsert(s *Source) error { + if err := validateName(s.Name); err != nil { + return err + } + + s.SourceID = newUUID() + s.APIKey = newUUID() + s.LastSeenAt = time.Now().UTC() + s.AlertedAt = s.LastSeenAt + + _, err := db.Exec(`INSERT INTO sources(`+ + ` SourceID,Name,APIKey,Description,`+ + ` LastSeenAt,AlertTimeout,AlertedAt,`+ + ` Ignore,LogAction,AlertAction`+ + `)VALUES(?,?,?,?,?,?,?,?,?,?)`, + s.SourceID, s.Name, s.APIKey, s.Description, + s.LastSeenAt, s.AlertTimeout, s.AlertedAt, + s.Ignore, s.LogAction, s.AlertAction) + if err != nil { + db.logf("Failed to insert source: %v", err) + } + return err +} + +const sourceCols = `SourceID,Name,APIKey,Description,` + + `LastSeenAt,AlertTimeout,AlertedAt,` + + `Ignore,LogAction,AlertAction` + +func (db *dbal) scanSource( + row interface{ Scan(...interface{}) error }, +) (s Source, err error) { + err = row.Scan( + &s.SourceID, &s.Name, &s.APIKey, &s.Description, + &s.LastSeenAt, &s.AlertTimeout, &s.AlertedAt, + &s.Ignore, &s.LogAction, &s.AlertAction) + if err != nil { + db.logf("Failed to scan source: %v", err) + } + return +} + +func (db *dbal) SourceGet(id string) (s Source, err error) { + return db.scanSource( + db.QueryRow( + `SELECT `+sourceCols+` FROM sources WHERE SourceID=?`, id)) +} + +func (db *dbal) SourceGetByKey(key string) (s Source, err error) { + return db.scanSource( + db.QueryRow( + `SELECT `+sourceCols+` FROM sources WHERE APIKey=?`, key)) +} + +func (db *dbal) SourceList() (l []Source, err error) { + rows, err := db.Query(`SELECT ` + sourceCols + ` FROM sources ORDER BY Name`) + if err != nil { + db.logf("Failed to list sources: %v", err) + return nil, err + } + for rows.Next() { + u, err := db.scanSource(rows) + if err != nil { + return nil, err + } + l = append(l, u) + } + return l, nil +} + +// Updates APIKey, Description, LogAction, AlertAction. +func (db *dbal) SourceUpdate(s Source) error { + if err := validateAPIKey(s.APIKey); err != nil { + return err + } + + _, err := db.Exec(`UPDATE sources `+ + `SET APIKey=?,Description=?,AlertTimeout=?,LogAction=?,AlertAction=? `+ + `WHERE SourceID=?`, + s.APIKey, s.Description, s.AlertTimeout, + s.LogAction, s.AlertAction, s.SourceID) + if err != nil { + db.logf("Failed to update source %s: %v", s.Name, err) + } + return err +} + +func (db *dbal) SourceUpdateIgnore(id string, b bool) error { + _, err := db.Exec(`UPDATE sources SET Ignore=? WHERE SourceID=?`, b, id) + if err != nil { + db.logf("Failed to update source ignore %s: %v", id, err) + } + return err +} + +func (db *dbal) SourceUpdateLastSeenAt(id string) error { + t := time.Now() + _, err := db.Exec(`UPDATE sources SET LastSeenAt=? WHERE SourceID=?`, t, id) + if err != nil { + db.logf("Failed to update source last seen at %s: %v", id, err) + } + return err +} + +func (db *dbal) SourceUpdateAlertedAt(id string) error { + t := time.Now() + _, err := db.Exec(`UPDATE sources SET AlertedAt=? WHERE SourceID=?`, t, id) + if err != nil { + db.logf("Failed to update source alerted at %s: %v", id, err) + } + return err +} + +func (db *dbal) SourceDelete(id string) error { + _, err := db.Exec(`DELETE FROM sources WHERE SourceID=?`, id) + if err != nil { + db.logf("Failed to delete source %s: %v", id, err) + } + return err +} + +/******* + * Log * + *******/ + +func (db *dbal) LogInsert(e Entry) error { + e.TS = time.Now() + _, err := db.Exec(`INSERT INTO log`+ + `(SourceID,TS,Alert,Text)VALUES(?,?,?,?)`, + e.SourceID, e.TS, e.Alert, e.Text) + if err != nil { + db.logf("Failed to insert log entry: %v", err) + } + return err +} + +const logCols = `LogID,SourceID,TS,Alert,Text` + +func (db *dbal) scanLog( + row interface{ Scan(...interface{}) error }, +) (e Entry, err error) { + err = row.Scan(&e.LogID, &e.SourceID, &e.TS, &e.Alert, &e.Text) + if err != nil { + db.logf("Failed to scan log entry: %v", err) + } + return +} + +type LogListArgs struct { + BeforeID int64 + Alert *bool + SourceID string + Limit int64 +} + +func (db *dbal) LogList(args LogListArgs) (l []EntryListRow, err error) { + if args.Limit <= 0 { + args.Limit = 100 + } + + qArgs := []interface{}{} + query := `SELECT ` + + `l.LogID,s.Name,l.TS,l.Alert,l.Text ` + + `FROM log l JOIN sources s ON l.SourceID=s.SourceID WHERE 1` + if args.BeforeID != 0 { + query += " AND LogID:") + flag.Parse() + + l := strings.Split(listenAddr, ":") + if len(l) != 2 { + flag.Usage() + os.Exit(1) + } + host := l[0] + port := l[1] + + var err error + + // Initialize globals. + tmpl = template.Must(template.New("").Parse(tmpls)) + if db, err = newDB("database.sqlite3"); err != nil { + os.Exit(1) + } + + go processTimeouts() + go purgeOldEntries() + + // Root. + http.HandleFunc("/", handleRoot) + + // API requests. + http.HandleFunc("/report", handleReport) + + // User routes. + routeAdmin("/user/insert", handleUserInsert) + routeUser("/user/list", handleUserList) + routeUser("/user/view/", handleUserView) + routeAdmin("/user/update/", handleUserUpdate) + routeAdmin("/user/delete/", handleUserDelete) + + routeAdmin("/source/insert", handleSourceInsert) + routeUser("/source/list", handleSourceList) + routeUser("/source/view/", handleSourceView) + routeAdmin("/source/update/", handleSourceUpdate) + routeAdmin("/source/delete/", handleSourceDelete) + + routeUser("/log/list", handleLogList) + + if port == "443" { + http.Serve(autocert.NewListener(host), http.DefaultServeMux) + } else { + http.ListenAndServe(listenAddr, nil) + } +} diff --git a/migration.go b/migration.go new file mode 100644 index 0000000..997b996 --- /dev/null +++ b/migration.go @@ -0,0 +1,33 @@ +package am + +var migration = ` +CREATE TABLE IF NOT EXISTS users( + Username TEXT NOT NULL PRIMARY KEY, + Password TEXT NOT NULL, + Admin BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS sources( + SourceID TEXT NOT NULL PRIMARY KEY, + Name TEXT NOT NULL UNIQUE, + APIKey TEXT NOT NULL UNIQUE, + Description TEXT NOT NULL, + LastSeenAt TIMESTAMP NOT NULL, + AlertTimeout BIGINT NOT NULL, + AlertedAt TIMESTAMP NOT NULL, + Ignore BOOLEAN NOT NULL, + LogAction TEXT NOT NULL, + AlertAction TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS log( + LogID INTEGER PRIMARY KEY AUTOINCREMENT, + SourceID TEXT NOT NULL REFERENCES sources(SourceID) ON DELETE CASCADE, + TS TIMESTAMP NOT NULL, + Alert BOOLEAN NOT NULL, + Text TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS log_ts_idx ON log(TS); +CREATE INDEX IF NOT EXISTS log_source_ts_idx ON log(SourceID, TS); +` diff --git a/source-server.go b/source-server.go new file mode 100644 index 0000000..bab077b --- /dev/null +++ b/source-server.go @@ -0,0 +1,55 @@ +package am + +import ( + "net/http" +) + +func respondReportError(w http.ResponseWriter, msg string) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(msg)) +} + +func handleReport(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + apiKey := r.Form.Get("key") + + src, err := db.SourceGetByKey(apiKey) + if err != nil { + respondNotAuthorized(w) + return + } + + // Always update last seen timestamp. + _ = db.SourceUpdateLastSeenAt(src.SourceID) + + action := r.Form.Get("action") + var actionFunc func(string, string, string) + + switch action { + case "ping": + w.WriteHeader(http.StatusOK) + return + + case "log": + actionFunc = runLogAction + + case "alert": + actionFunc = runAlertAction + + default: + respondReportError(w, "Unknown action: "+action) + return + } + + e := Entry{ + SourceID: src.SourceID, + Text: r.Form.Get("text"), + Alert: action == "alert", + } + + _ = db.LogInsert(e) + actionFunc(src.LogAction, src.Name, e.Text) + w.WriteHeader(http.StatusOK) + +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..dbc1117 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,20 @@ +{{define "PageStart" -}} + + + + am + + + + + +{{end}} + +{{define "PageEnd" -}} + + +{{- end}} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..c7cf256 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,12 @@ + +{{define "Error" -}} +{{template "PageStart"}} + +

Error

+ +
+

{{.}}

+
+ +{{template "PageEnd"}} +{{- end}} diff --git a/templates/log.html b/templates/log.html new file mode 100644 index 0000000..51dc965 --- /dev/null +++ b/templates/log.html @@ -0,0 +1,58 @@ + +------------------------------------------------------------------------------- +-- List +------------------------------------------------------------------------------- + +{{define "LogList" -}} +{{template "PageStart"}} + +

Log

+ +
+
+ + + + +
+ + {{if .Args.BeforeID}} +

Back

+ {{end}} + + + + + + + + + + {{range .Entries}} + + + + + + + {{end}} +
Time SourceText
{{.TS.Format "2006-01-02 15:04"}} + {{if .Alert}}!{{end}}{{.SourceName}}{{.Text}}
+ + {{if .NextURL}} +

More

{{end}} +
+ +{{template "PageEnd"}} +{{- end}} diff --git a/templates/sources.html b/templates/sources.html new file mode 100644 index 0000000..feb7431 --- /dev/null +++ b/templates/sources.html @@ -0,0 +1,204 @@ + +------------------------------------------------------------------------------- +-- Insert +------------------------------------------------------------------------------- + +{{define "SourceInsert" -}} +{{template "PageStart"}} + +

+ Sources / Insert +

+ +
+
+ + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + +
  • + +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- List +------------------------------------------------------------------------------- + +{{define "SourceList" -}} +{{template "PageStart"}} + +

Sources

+ + + +
+ +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- View +------------------------------------------------------------------------------- + +{{define "SourceView" -}} +{{template "PageStart"}} + +

+ Sources / {{.Name}} +

+ + + +
+
+
API Key
+
{{.APIKey}}
+ +
Description
+
{{.Description}}
+ +
Last Seen
+
{{.LastSeenAt.Format "2006-01-02 15:04"}}
+ +
Alert Timeout (sec)
+
{{.AlertTimeout}}
+ +
Alerted At
+
{{.AlertedAt.Format "2006-01-02 15:04"}}
+ +
Ignore
+
{{if .Ignore}}True{{else}}False{{end}}
+ +
Log Action
+
{{.LogAction}}
+ +
Alert Action
+
{{.AlertAction}}
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Update +------------------------------------------------------------------------------- + +{{define "SourceUpdate" -}} +{{template "PageStart"}} + +

+ Sources / + {{.Source.Name}} / + Update +

+ +
+
+ + + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + +
  • + +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Delete +------------------------------------------------------------------------------- + +{{define "SourceDelete" -}} +{{template "PageStart"}} + +

+ Sources / + {{.Source.Name}} / + Delete +

+ +
+

Really delete source {{.Source.Name}}?

+
+ +
    +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} diff --git a/templates/style.css b/templates/style.css new file mode 100644 index 0000000..f6ec710 --- /dev/null +++ b/templates/style.css @@ -0,0 +1,172 @@ +{{define "CSS"}} + +/********* + * Links * + *********/ + +a { + text-decoration: none; + color: #444; +} + +/************ + * Top Menu * + ************/ + +.top-menu { + list-style-type: none; + margin: 0; + padding: 0; + overflow: hidden; + border-bottom: 1px solid #999999; +} + +.top-menu li { + float: left; +} + +.top-menu li a { + display: block; + text-align: center; + padding: 14px 16px; +} + +/**************** + * Section Menu * + ****************/ + +.section-menu { + list-style-type: none; + padding: 0; + overflow: hidden; +} + +.section-menu li { + float: left; + border: 1px solid #999999; + margin-right: 8px; +} + +.section-menu li a { + display: block; + text-align: center; + padding: 8px 12px; +} + +/*********** + * Section * + ***********/ + +section { + padding-left: 40px; +} + +/******************** + * Definition Lists * + ********************/ + +dt { + font-weight: bold; + margin: 8px 0; + max-width: 400px; +} + +dd { + margin: 0 0 0 32px; + padding: 8px 0 8px 16px; + min-height: 20px; + max-width: 480px; + border-left: 1px solid #999999; +} + +.multiline { + white-space: pre-wrap; +} + +/********* + * Table * + *********/ + +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + padding: 8px; + text-align: left; + white-space: nowrap; +} + +tr:nth-child(even){ + background-color: #f8f8f8; +} + +/********* + * Forms * + *********/ + +.form-list { + max-width: 400px; + padding: 0; +} + +.form-list li { + display: block; + list-style: none; + margin: 10px 0 0 0; +} + +.form-list label{ + margin:0 0 4px 0; + padding:0px; + display:block; +} + +.form-list input,.form-list textarea,.form-liat select{ + padding: 8px; + margin: 0px; + width: 100%; + box-sizing: border-box; +} + +.form-list input[type=checkbox] { + padding: 8px; + margin: 0px; + width: auto; +} + +.form-list input + label { + display: inline; +} + +.form-list input[type=submit] { + padding: 8px; + margin: 0px; + width: auto; + background: none; + border: 1px solid #999999; + color: #444; + padding: 8px 12px; +} + + + +/************* + * User List * + *************/ + +.user-list { + list-style: none; + padding: 0; +} + +.user-list li { +} + +.user-list li a { + margin: 6px 0; + display: block; +} + +{{end}} diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..c39e082 --- /dev/null +++ b/templates/users.html @@ -0,0 +1,156 @@ + +------------------------------------------------------------------------------- +-- Insert +------------------------------------------------------------------------------- + +{{define "UserInsert" -}} +{{template "PageStart"}} + +

+ Users / Insert +

+ +
+
+ + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- List +------------------------------------------------------------------------------- + +{{define "UserList" -}} +{{template "PageStart"}} + +

Users

+ + + +
+ +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- View +------------------------------------------------------------------------------- + +{{define "UserView" -}} +{{template "PageStart"}} + +

+ Users / {{.Username}} +

+ + + + +
+
+
Admin
+
{{if .Admin}}True{{else}}False{{end}}
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Update +------------------------------------------------------------------------------- + +{{define "UserUpdate" -}} +{{template "PageStart"}} + +

+ Users / + {{.User.Username}} / + Update +

+ +
+
+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Delete +------------------------------------------------------------------------------- + +{{define "UserDelete" -}} +{{template "PageStart"}} + +

+ Users / + {{.User.Username}} / + Delete +

+ +
+

Really delete user {{.User.Username}}?

+
+ +
    +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} diff --git a/timeout.go b/timeout.go new file mode 100644 index 0000000..738e4e2 --- /dev/null +++ b/timeout.go @@ -0,0 +1,34 @@ +package am + +import "time" + +func processTimeouts() { + for { + time.Sleep(10 * time.Second) + + l, _ := db.SourceList() + + for _, src := range l { + if !src.RequiresAlertForTimeout() { + continue + } + + e := Entry{ + SourceID: src.SourceID, + Alert: true, + Text: "Source hasn't reported in.", + } + + _ = db.LogInsert(e) + runAlertAction(src.AlertAction, src.Name, e.Text) + _ = db.SourceUpdateAlertedAt(src.SourceID) + } + } +} + +func purgeOldEntries() { + for { + _ = db.LogDeleteBefore(time.Now().Add(-90 * 24 * time.Hour)) + time.Sleep(10 * time.Minute) + } +} diff --git a/tmpl_gen.go b/tmpl_gen.go new file mode 100644 index 0000000..71a3128 --- /dev/null +++ b/tmpl_gen.go @@ -0,0 +1,632 @@ +package am + +var tmpls = ` +{{define "PageStart" -}} + + + + am + + + + + +{{end}} + +{{define "PageEnd" -}} + + +{{- end}} + + +{{define "Error" -}} +{{template "PageStart"}} + +

Error

+ +
+

{{.}}

+
+ +{{template "PageEnd"}} +{{- end}} + + +------------------------------------------------------------------------------- +-- List +------------------------------------------------------------------------------- + +{{define "LogList" -}} +{{template "PageStart"}} + +

Log

+ +
+
+ + + + +
+ + {{if .Args.BeforeID}} +

Back

+ {{end}} + + + + + + + + + + {{range .Entries}} + + + + + + + {{end}} +
Time SourceText
{{.TS.Format "2006-01-02 15:04"}} + {{if .Alert}}!{{end}}{{.SourceName}}{{.Text}}
+ + {{if .NextURL}} +

More

{{end}} +
+ +{{template "PageEnd"}} +{{- end}} + + +------------------------------------------------------------------------------- +-- Insert +------------------------------------------------------------------------------- + +{{define "SourceInsert" -}} +{{template "PageStart"}} + +

+ Sources / Insert +

+ +
+
+ + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + +
  • + +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- List +------------------------------------------------------------------------------- + +{{define "SourceList" -}} +{{template "PageStart"}} + +

Sources

+ + + +
+ +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- View +------------------------------------------------------------------------------- + +{{define "SourceView" -}} +{{template "PageStart"}} + +

+ Sources / {{.Name}} +

+ + + +
+
+
API Key
+
{{.APIKey}}
+ +
Description
+
{{.Description}}
+ +
Last Seen
+
{{.LastSeenAt.Format "2006-01-02 15:04"}}
+ +
Alert Timeout (sec)
+
{{.AlertTimeout}}
+ +
Alerted At
+
{{.AlertedAt.Format "2006-01-02 15:04"}}
+ +
Ignore
+
{{if .Ignore}}True{{else}}False{{end}}
+ +
Log Action
+
{{.LogAction}}
+ +
Alert Action
+
{{.AlertAction}}
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Update +------------------------------------------------------------------------------- + +{{define "SourceUpdate" -}} +{{template "PageStart"}} + +

+ Sources / + {{.Source.Name}} / + Update +

+ +
+
+ + + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + +
  • + +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Delete +------------------------------------------------------------------------------- + +{{define "SourceDelete" -}} +{{template "PageStart"}} + +

+ Sources / + {{.Source.Name}} / + Delete +

+ +
+

Really delete source {{.Source.Name}}?

+
+ +
    +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} + +{{define "CSS"}} + +/********* + * Links * + *********/ + +a { + text-decoration: none; + color: #444; +} + +/************ + * Top Menu * + ************/ + +.top-menu { + list-style-type: none; + margin: 0; + padding: 0; + overflow: hidden; + border-bottom: 1px solid #999999; +} + +.top-menu li { + float: left; +} + +.top-menu li a { + display: block; + text-align: center; + padding: 14px 16px; +} + +/**************** + * Section Menu * + ****************/ + +.section-menu { + list-style-type: none; + padding: 0; + overflow: hidden; +} + +.section-menu li { + float: left; + border: 1px solid #999999; + margin-right: 8px; +} + +.section-menu li a { + display: block; + text-align: center; + padding: 8px 12px; +} + +/*********** + * Section * + ***********/ + +section { + padding-left: 40px; +} + +/******************** + * Definition Lists * + ********************/ + +dt { + font-weight: bold; + margin: 8px 0; + max-width: 400px; +} + +dd { + margin: 0 0 0 32px; + padding: 8px 0 8px 16px; + min-height: 20px; + max-width: 480px; + border-left: 1px solid #999999; +} + +.multiline { + white-space: pre-wrap; +} + +/********* + * Table * + *********/ + +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + padding: 8px; + text-align: left; + white-space: nowrap; +} + +tr:nth-child(even){ + background-color: #f8f8f8; +} + +/********* + * Forms * + *********/ + +.form-list { + max-width: 400px; + padding: 0; +} + +.form-list li { + display: block; + list-style: none; + margin: 10px 0 0 0; +} + +.form-list label{ + margin:0 0 4px 0; + padding:0px; + display:block; +} + +.form-list input,.form-list textarea,.form-liat select{ + padding: 8px; + margin: 0px; + width: 100%; + box-sizing: border-box; +} + +.form-list input[type=checkbox] { + padding: 8px; + margin: 0px; + width: auto; +} + +.form-list input + label { + display: inline; +} + +.form-list input[type=submit] { + padding: 8px; + margin: 0px; + width: auto; + background: none; + border: 1px solid #999999; + color: #444; + padding: 8px 12px; +} + + + +/************* + * User List * + *************/ + +.user-list { + list-style: none; + padding: 0; +} + +.user-list li { +} + +.user-list li a { + margin: 6px 0; + display: block; +} + +{{end}} + + +------------------------------------------------------------------------------- +-- Insert +------------------------------------------------------------------------------- + +{{define "UserInsert" -}} +{{template "PageStart"}} + +

+ Users / Insert +

+ +
+
+ + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- List +------------------------------------------------------------------------------- + +{{define "UserList" -}} +{{template "PageStart"}} + +

Users

+ + + +
+ +
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- View +------------------------------------------------------------------------------- + +{{define "UserView" -}} +{{template "PageStart"}} + +

+ Users / {{.Username}} +

+ + + + +
+
+
Admin
+
{{if .Admin}}True{{else}}False{{end}}
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Update +------------------------------------------------------------------------------- + +{{define "UserUpdate" -}} +{{template "PageStart"}} + +

+ Users / + {{.User.Username}} / + Update +

+ +
+
+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} + +------------------------------------------------------------------------------- +-- Delete +------------------------------------------------------------------------------- + +{{define "UserDelete" -}} +{{template "PageStart"}} + +

+ Users / + {{.User.Username}} / + Delete +

+ +
+

Really delete user {{.User.Username}}?

+
+ +
    +
  • + +
  • +
+
+
+ +{{template "PageEnd"}} +{{- end}} + +` diff --git a/types.go b/types.go new file mode 100644 index 0000000..9675107 --- /dev/null +++ b/types.go @@ -0,0 +1,45 @@ +package am + +import "time" + +type User struct { + Username string + Admin bool +} + +type Source struct { + SourceID string + Name string + APIKey string + Description string + LastSeenAt time.Time + AlertTimeout int64 // In seconds. + AlertedAt time.Time // Timeout alert time. + Ignore bool // Don't trigger alerts. + LogAction string // Override log action. + AlertAction string // Override alert action. +} + +func (s Source) TimedOut() bool { + return time.Since(s.LastSeenAt) > time.Duration(s.AlertTimeout)*time.Second +} + +func (s Source) RequiresAlertForTimeout() bool { + return s.TimedOut() && s.AlertedAt.Before(s.LastSeenAt) +} + +type Entry struct { + LogID int64 + SourceID string + TS time.Time + Alert bool + Text string +} + +type EntryListRow struct { + LogID int64 + SourceName string + TS time.Time + Alert bool + Text string +} diff --git a/user-server.go b/user-server.go new file mode 100644 index 0000000..77cc1cf --- /dev/null +++ b/user-server.go @@ -0,0 +1,384 @@ +package am + +import ( + "fmt" + "log" + "net/http" + "path/filepath" + "strconv" +) + +func formGetInt(r *http.Request, name string) int64 { + s := r.Form.Get(name) + i, _ := strconv.ParseInt(s, 10, 64) + return i +} + +func execTmpl(w http.ResponseWriter, name string, data interface{}) { + if err := tmpl.ExecuteTemplate(w, name, data); err != nil { + log.Printf("Failed to execute template %s: %v", name, err) + } +} + +func respondNotAuthorized(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="am"`) + w.WriteHeader(401) + w.Write([]byte("Unauthorised.\n")) +} + +func respondInvalidCSRF(w http.ResponseWriter) { + w.WriteHeader(403) + w.Write([]byte("Forbidden.\n")) +} + +func respondRedirect(w http.ResponseWriter, r *http.Request, url string, args ...interface{}) { + http.Redirect(w, r, fmt.Sprintf(url, args...), http.StatusSeeOther) +} + +func getReqUser(w http.ResponseWriter, r *http.Request) (User, bool) { + username, pass, ok := r.BasicAuth() + if !ok { + return User{}, false + } + + user, err := db.UserGetWithPwd(username, pass) + if err != nil { + return user, false + } + + return user, true +} + +func getCSRF(w http.ResponseWriter, r *http.Request) string { + cookie, err := r.Cookie("am_csrf") + if err != nil || len(cookie.Value) != 32 { + token := newCSRF() + http.SetCookie(w, &http.Cookie{ + Name: "am_csrf", + Value: token, + Path: "/", + }) + return token + } + return cookie.Value +} + +func checkCSRF(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodPost { + return true + } + + cookieVal := getCSRF(w, r) + + r.ParseForm() + formVal := r.Form.Get("CSRF") + if formVal == "" { + return false + } + + return formVal == cookieVal +} + +// TODO: Rename function. +func routeUser(path string, h func(w http.ResponseWriter, r *http.Request)) { + http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if _, ok := getReqUser(w, r); !ok { + respondNotAuthorized(w) + return + } + if !checkCSRF(w, r) { + respondInvalidCSRF(w) + return + } + h(w, r) + }) +} + +// TODO: Rename function. +func routeAdmin(path string, h func(w http.ResponseWriter, r *http.Request)) { + http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if u, ok := getReqUser(w, r); !ok || !u.Admin { + respondNotAuthorized(w) + return + } + if !checkCSRF(w, r) { + respondInvalidCSRF(w) + return + } + h(w, r) + }) +} + +// ---------------------------------------------------------------------------- + +func handleRoot(w http.ResponseWriter, r *http.Request) { + respondRedirect(w, r, "/user/list") +} + +// ---------------------------------------------------------------------------- + +func handleUserInsert(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + execTmpl(w, "UserInsert", struct{ CSRF string }{getCSRF(w, r)}) + return + } + + user := User{ + Username: r.Form.Get("Username"), + Admin: r.Form.Get("Admin") != "", + } + + if err := db.UserInsert(user, r.Form.Get("Password")); err != nil { + execTmpl(w, "Error", err) + return + } + respondRedirect(w, r, "/user/view/%s", user.Username) +} + +func handleUserList(w http.ResponseWriter, r *http.Request) { + l, err := db.UserList() + if err != nil { + execTmpl(w, "Error", err) + return + } + execTmpl(w, "UserList", l) +} + +func handleUserView(w http.ResponseWriter, r *http.Request) { + name := filepath.Base(r.URL.Path) + u, err := db.UserGet(name) + if err != nil { + execTmpl(w, "Error", err) + return + } + execTmpl(w, "UserView", u) +} + +func handleUserUpdate(w http.ResponseWriter, r *http.Request) { + name := filepath.Base(r.URL.Path) + u, err := db.UserGet(name) + if err != nil { + execTmpl(w, "Error", err) + return + } + + if r.Method == http.MethodGet { + execTmpl(w, "UserUpdate", struct { + User User + CSRF string + }{u, getCSRF(w, r)}) + return + } + + admin := r.Form.Get("Admin") != "" + if u.Admin != admin { + if err := db.UserUpdateAdmin(u.Username, admin); err != nil { + execTmpl(w, "Error", err) + return + } + } + + pwd := r.Form.Get("NewPassword") + if pwd != "" { + if err := db.UserUpdatePwd(u.Username, pwd); err != nil { + execTmpl(w, "Error", err) + return + } + } + + respondRedirect(w, r, "/user/view/%s", u.Username) +} + +func handleUserDelete(w http.ResponseWriter, r *http.Request) { + name := filepath.Base(r.URL.Path) + u, err := db.UserGet(name) + if err != nil { + execTmpl(w, "Error", err) + return + } + + if r.Method == http.MethodGet { + execTmpl(w, "UserDelete", struct { + User User + CSRF string + }{u, getCSRF(w, r)}) + return + } + + if err := db.UserDelete(name); err != nil { + execTmpl(w, "Error", err) + return + } + + respondRedirect(w, r, "/user/list") +} + +// ---------------------------------------------------------------------------- + +func handleSourceInsert(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + execTmpl(w, "SourceInsert", struct{ CSRF string }{getCSRF(w, r)}) + return + } + + s := Source{ + Name: r.Form.Get("Name"), + Description: r.Form.Get("Description"), + AlertTimeout: formGetInt(r, "AlertTimeout"), + LogAction: r.Form.Get("LogAction"), + AlertAction: r.Form.Get("AlertAction"), + } + + if err := db.SourceInsert(&s); err != nil { + execTmpl(w, "Error", err) + return + } + + respondRedirect(w, r, "/source/view/%s", s.SourceID) +} + +func handleSourceList(w http.ResponseWriter, r *http.Request) { + l, err := db.SourceList() + if err != nil { + execTmpl(w, "Error", err) + return + } + execTmpl(w, "SourceList", l) +} + +func handleSourceView(w http.ResponseWriter, r *http.Request) { + id := filepath.Base(r.URL.Path) + s, err := db.SourceGet(id) + if err != nil { + execTmpl(w, "Error", err) + return + } + execTmpl(w, "SourceView", s) +} + +func handleSourceUpdate(w http.ResponseWriter, r *http.Request) { + id := filepath.Base(r.URL.Path) + s, err := db.SourceGet(id) + if err != nil { + execTmpl(w, "Error", err) + return + } + if r.Method == http.MethodGet { + execTmpl(w, "SourceUpdate", struct { + Source Source + CSRF string + }{s, getCSRF(w, r)}) + return + } + + s.APIKey = r.Form.Get("APIKey") + s.Description = r.Form.Get("Description") + s.AlertTimeout = formGetInt(r, "AlertTimeout") + s.LogAction = r.Form.Get("LogAction") + s.AlertAction = r.Form.Get("AlertAction") + + if err := db.SourceUpdate(s); err != nil { + execTmpl(w, "Error", err) + return + } + + ignore := r.Form.Get("Ignore") != "" + if ignore != s.Ignore { + if err := db.SourceUpdateIgnore(s.SourceID, ignore); err != nil { + execTmpl(w, "Error", err) + return + } + } + + respondRedirect(w, r, "/source/view/%s", s.SourceID) +} + +func handleSourceDelete(w http.ResponseWriter, r *http.Request) { + id := filepath.Base(r.URL.Path) + u, err := db.SourceGet(id) + if err != nil { + execTmpl(w, "Error", err) + return + } + + if r.Method == http.MethodGet { + execTmpl(w, "SourceDelete", struct { + Source Source + CSRF string + }{u, getCSRF(w, r)}) + return + } + + if err := db.SourceDelete(id); err != nil { + execTmpl(w, "Error", err) + return + } + + respondRedirect(w, r, "/source/list") +} + +// ---------------------------------------------------------------------------- + +type logListArgs struct { + BeforeID int64 + SourceID string + Type string // One of "all", "alert", "log". + Limit int64 +} + +func handleLogList(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + args := logListArgs{ + BeforeID: formGetInt(r, "BeforeID"), + Limit: formGetInt(r, "Limit"), + SourceID: r.Form.Get("SourceID"), + Type: r.Form.Get("Type"), + } + if args.Limit < 1 { + args.Limit = 100 + } + + listArgs := LogListArgs{ + BeforeID: args.BeforeID, + Limit: args.Limit + 1, + SourceID: args.SourceID, + } + + switch args.Type { + case "alert": + b := true + listArgs.Alert = &b + + case "log": + b := false + listArgs.Alert = &b + } + + l, err := db.LogList(listArgs) + if err != nil { + execTmpl(w, "Error", err) + return + } + + nextURL := "" + + if len(l) > int(args.Limit) { + l = l[:len(l)-1] + nextURL = fmt.Sprintf("?Limit=%d&BeforeID=%d&SourceID=%s&Type=%s", + args.Limit, l[len(l)-1].LogID, args.SourceID, args.Type) + } + + sources, err := db.SourceList() + if err != nil { + execTmpl(w, "Error", err) + return + } + + execTmpl(w, "LogList", struct { + Entries []EntryListRow + Sources []Source + Args logListArgs + NextURL string + }{l, sources, args, nextURL}) +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..937c009 --- /dev/null +++ b/validation.go @@ -0,0 +1,31 @@ +package am + +import ( + "errors" + "fmt" + "regexp" +) + +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`) + +func validateName(s string) error { + if len(s) < 2 || !namePattern.MatchString(s) { + return fmt.Errorf("Invalid name: %s", s) + } + return nil +} + +func validatePwd(pwd string) error { + if len(pwd) < 8 { + return errors.New("Password must be at least 8 characters.") + } + return nil + +} + +func validateAPIKey(key string) error { + if len(key) < 16 { + return errors.New("API key must be at least 16 characters.") + } + return nil +}