Initial commit.

master
J. David Lee 2019-06-10 19:09:10 +02:00
parent 0515fc10c2
commit 082cfca0c3
23 changed files with 2650 additions and 0 deletions

View File

@ -1,2 +1,5 @@
# am
* X Go client
* X Command line client: `am https://_:<api_key>@addr <action> <log or alert>`
* alert timeout not updated

44
alert.go Normal file
View File

@ -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)
}

57
amclient/client.go Normal file
View File

@ -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},
})
}

59
cmd/am/am.go Normal file
View File

@ -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 <URL> <api_key> <action> [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)
}
}

7
cmd/amserver/main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "git.crumpington.com/public/am"
func main() {
am.Main()
}

28
cmd/generate/templates.go Normal file
View File

@ -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)
}
}

30
crypto.go Normal file
View File

@ -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)
}

373
db.go Normal file
View File

@ -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<?"
qArgs = append(qArgs, args.BeforeID)
}
if args.Alert != nil {
query += " AND l.Alert=?"
qArgs = append(qArgs, *args.Alert)
}
if args.SourceID != "" {
query += " AND l.SourceID=?"
qArgs = append(qArgs, args.SourceID)
}
query += ` ORDER BY TS DESC LIMIT ` + strconv.FormatInt(args.Limit, 10)
rows, err := db.Query(query, qArgs...)
if err != nil {
db.logf("Failed to list log: %v", err)
return nil, err
}
for rows.Next() {
e := EntryListRow{}
err := rows.Scan(&e.LogID, &e.SourceName, &e.TS, &e.Alert, &e.Text)
if err != nil {
db.logf("Failed to scan entry list row: %v", err)
return nil, err
}
l = append(l, e)
}
return l, nil
}
func (db *dbal) LogDeleteBefore(ts time.Time) error {
_, err := db.Exec("DELETE FROM log WHERE TS < ?", ts)
if err != nil {
db.logf("Failed to delete log entries: %v", err)
}
return err
}

140
db_test.go Normal file
View File

@ -0,0 +1,140 @@
package am
import "testing"
func NewDBForTesting() *dbal {
db, err := newDB(":memory:")
if err != nil {
panic(err)
}
return db
}
func TestDBUserInsert(t *testing.T) {
db := NewDBForTesting()
err := db.UserInsert(User{Username: `test`, Admin: false}, `123`)
if err != nil {
t.Fatal(err)
}
err = db.UserInsert(User{Username: `test`, Admin: false}, `123`)
if err == nil {
t.Fatal(err)
}
err = db.UserInsert(User{Username: `root`, Admin: false}, `root`)
if err == nil {
t.Fatal(err)
}
}
func TestDBUserGet(t *testing.T) {
db := NewDBForTesting()
err := db.UserInsert(User{Username: `test`, Admin: false}, `123`)
if err != nil {
t.Fatal(err)
}
u, err := db.UserGet(`test`)
if err != nil {
t.Fatal(err)
}
if u.Username != `test` || u.Admin {
t.Fatal(u)
}
_, err = db.UserGet(`test2`)
if err == nil {
t.Fatal(err)
}
}
func TestDBUserGetWithPwd(t *testing.T) {
db := NewDBForTesting()
u, err := db.UserGetWithPwd("root", "root")
if err != nil {
t.Fatal(err)
}
if u.Username != "root" || !u.Admin {
t.Fatal(u)
}
if _, err = db.UserGetWithPwd("root", "root2"); err == nil {
t.Fatal(err)
}
}
func TestDBUserList(t *testing.T) {
db := NewDBForTesting()
err := db.UserInsert(User{Username: `test`, Admin: false}, `123`)
if err != nil {
t.Fatal(err)
}
l, err := db.UserList()
if err != nil {
t.Fatal(err)
}
if len(l) != 2 {
t.Fatal(l)
}
if l[0].Username != "root" || l[1].Username != "test" {
t.Fatal(l)
}
}
func TestDBUserUpdatePwd(t *testing.T) {
db := NewDBForTesting()
if err := db.UserUpdatePwd("root", "xyz"); err != nil {
t.Fatal(err)
}
if _, err := db.UserGetWithPwd("root", "xyz"); err != nil {
t.Fatal(err)
}
if _, err := db.UserGetWithPwd("root", "root"); err == nil {
t.Fatal(err)
}
}
func TestDBUserUpdateAdmin(t *testing.T) {
db := NewDBForTesting()
err := db.UserInsert(User{Username: `test`, Admin: false}, `123`)
if err != nil {
t.Fatal(err)
}
if err := db.UserUpdateAdmin("test", true); err != nil {
t.Fatal(err)
}
u, err := db.UserGet("test")
if err != nil {
t.Fatal(err)
}
if !u.Admin {
t.Fatal(u)
}
}
func TestDBUserDelete(t *testing.T) {
db := NewDBForTesting()
if err := db.UserDelete("root"); err == nil {
t.Fatal(err)
}
err := db.UserInsert(User{Username: `test`, Admin: false}, `123`)
if err != nil {
t.Fatal(err)
}
if err := db.UserDelete("test"); err != nil {
t.Fatal(err)
}
if _, err := db.UserGet("test"); err == nil {
t.Fatal(err)
}
}

73
main.go Normal file
View File

@ -0,0 +1,73 @@
package am
import (
"flag"
"html/template"
"net/http"
"os"
"strings"
"golang.org/x/crypto/acme/autocert"
)
//go:generate go run cmd/generate/templates.go
/***********
* Globals *
***********/
var db *dbal
var tmpl *template.Template
func Main() {
var listenAddr string
flag.StringVar(&listenAddr, "listen", "localhost:8888",
"Listen address, <host>:<port>")
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)
}
}

33
migration.go Normal file
View File

@ -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);
`

55
source-server.go Normal file
View File

@ -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)
}

20
templates/base.html Normal file
View File

@ -0,0 +1,20 @@
{{define "PageStart" -}}
<!doctype html>
<html lang="en">
<head>
<title>am</title>
<style>{{template "CSS"}}</style>
</head>
<body>
<ul class="top-menu">
<li><a href="/log/list">Log</a></li>
<li><a href="/source/list">Sources</a></li>
<li><a href="/user/list">Users</a></li>
</ul>
{{end}}
{{define "PageEnd" -}}
</body>
</html>
{{- end}}

12
templates/error.html Normal file
View File

@ -0,0 +1,12 @@
{{define "Error" -}}
{{template "PageStart"}}
<h1>Error</h1>
<section>
<p>{{.}}</p>
</section>
{{template "PageEnd"}}
{{- end}}

58
templates/log.html Normal file
View File

@ -0,0 +1,58 @@
-------------------------------------------------------------------------------
-- List
-------------------------------------------------------------------------------
{{define "LogList" -}}
{{template "PageStart"}}
<h1>Log</h1>
<section>
<form>
<select Name="Type">
<option value="all" {{if (eq .Args.Type "all")}}selected{{end}}>Type: All</option>
<option value="alert" {{if (eq .Args.Type "alert")}}selected{{end}}>Type: Alerts</option>
<option value="log" {{if (eq .Args.Type "log")}}selected{{end}}>Type: Non-alerts</option>
</select>
<select Name="SourceID">
<option value="">Source: All</option>
{{range .Sources}}
<option value="{{.SourceID}}" {{if (eq .SourceID $.Args.SourceID)}}selected{{end}}>
Source: {{.Name}}
</option>
{{end}}
</select>
<input type="submit" value="Submit">
</form>
{{if .Args.BeforeID}}
<p><a href="javascript:history.back()">Back</a></p>
{{end}}
<table>
<tr>
<th>Time</th>
<th> </th>
<th>Source</th>
<th>Text</th>
</tr>
{{range .Entries}}
<tr>
<td>{{.TS.Format "2006-01-02 15:04"}}
</td>
<td>{{if .Alert}}<b>!</b>{{end}}</td>
<td>{{.SourceName}}</td>
<td style="width:100%;">{{.Text}}</td>
</tr>
{{end}}
</table>
{{if .NextURL}}
<p><a href="{{.NextURL}}">More</a></p>{{end}}
</section>
{{template "PageEnd"}}
{{- end}}

204
templates/sources.html Normal file
View File

@ -0,0 +1,204 @@
-------------------------------------------------------------------------------
-- Insert
-------------------------------------------------------------------------------
{{define "SourceInsert" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> / Insert
</h1>
<section>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<label for="Name">Name:</label>
<input type="text" id="Name" name="Name" required>
</li>
<li>
<label for="Description">Description</label>
<textarea id="Description" name="Description" rows=8></textarea>
</li>
<li>
<label for="AlertTimeout">Alert Timeout:</label>
<input type="number" id="AlertTimeout" name="AlertTimeout">
</li>
<li>
<label for="LogAction">Log Action:</label>
<input type="text" id="LogAction" name="LogAction">
</li>
<li>
<label for="AlertAction">Alert Action:</label>
<input type="text" id="AlertAction" name="AlertAction">
</li>
<li>
<input type="submit" value="Insert">
</li>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- List
-------------------------------------------------------------------------------
{{define "SourceList" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">Sources</h1>
<ul class="section-menu">
<li><a href="/source/insert">Insert</a></li>
</ul>
<section>
<ul class="user-list">
{{range . -}}
<li>
<a href="/source/view/{{.SourceID}}">
{{.Name}}
{{if .TimedOut}}&#x2716;{{end}}
{{if .Ignore}}&#x2225;{{end}}
</a>
</li>
{{- end}}
</ul>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- View
-------------------------------------------------------------------------------
{{define "SourceView" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> / {{.Name}}
</h1>
<ul class="section-menu">
<li><a href="/source/update/{{.SourceID}}">Update</a></li>
<li><a href="/source/delete/{{.SourceID}}">Delete</a></li>
</ul>
<section>
<dl>
<dt>API Key</dt>
<dd>{{.APIKey}}</dd>
<dt>Description</dt>
<dd class="multiline">{{.Description}}</dd>
<dt>Last Seen</dt>
<dd>{{.LastSeenAt.Format "2006-01-02 15:04"}}</dd>
<dt>Alert Timeout (sec)</dt>
<dd>{{.AlertTimeout}}</dd>
<dt>Alerted At</dt>
<dd>{{.AlertedAt.Format "2006-01-02 15:04"}}</dd>
<dt>Ignore</dt>
<dd>{{if .Ignore}}True{{else}}False{{end}}</dd>
<dt>Log Action</dt>
<dd>{{.LogAction}}</dd>
<dt>Alert Action</dt>
<dd>{{.AlertAction}}</dd>
</dl>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Update
-------------------------------------------------------------------------------
{{define "SourceUpdate" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> /
<a href="/source/view/{{.Source.SourceID}}">{{.Source.Name}}</a> /
Update
</h1>
<section>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<input type="hidden" name="SourceID" value="{{.Source.SourceID}}">
<ul class="form-list">
<li>
<input type="checkbox" name="Ignore" id="Ignore" {{if .Source.Ignore}}Checked{{end}}>
<label for="Ignore">Ignore</label>
</li>
<li>
<label for="APIKey">API Key:</label>
<input type="text" id="APIKey" name="APIKey" value="{{.Source.APIKey}}">
</li>
<li>
<label for="Description">Description</label>
<textarea id="Description" name="Description" rows=8>
{{- .Source.Description -}}
</textarea>
</li>
<li>
<label for="AlertTimeout">Alert Timeout:</label>
<input type="number" id="AlertTimeout" name="AlertTimeout" value="{{.Source.AlertTimeout}}">
</li>
<li>
<label for="LogAction">Log Action:</label>
<input type="text" id="LogAction" name="LogAction" value="{{.Source.LogAction}}">
</li>
<li>
<label for="AlertAction">Alert Action:</label>
<input type="text" id="AlertAction" name="AlertAction" value="{{.Source.AlertAction}}">
</li>
<li>
<input type="submit" value="Update">
</li>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Delete
-------------------------------------------------------------------------------
{{define "SourceDelete" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> /
<a href="/source/view/{{.Source.SourceID}}">{{.Source.Name}}</a> /
Delete
</h1>
<section>
<p>Really delete source {{.Source.Name}}?</p>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<input type="submit" value="Delete">
</li>
</ul>
</form>
</section>
{{template "PageEnd"}}
{{- end}}

172
templates/style.css Normal file
View File

@ -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}}

156
templates/users.html Normal file
View File

@ -0,0 +1,156 @@
-------------------------------------------------------------------------------
-- Insert
-------------------------------------------------------------------------------
{{define "UserInsert" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> / Insert
</h1>
<section>
<form method="POST" autocomplete="off">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<label for="Username">Username:</label>
<input type="text" name="Username" autocomplete="off" required>
</li>
<li>
<label for="Password">Password:</label>
<input type="password" name="Password" autocomplete="off" required>
</li>
<li>
<input type="checkbox" name="Admin" id="Admin">
<label for="Admin">Admin</label>
</li>
<li>
<input type="submit" value="Insert">
</li>
</ul>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- List
-------------------------------------------------------------------------------
{{define "UserList" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">Users</h1>
<ul class="section-menu">
<li><a href="/user/insert">Insert</a></li>
</ul>
<section>
<ul class="user-list">
{{range . -}}
<li>
<a href="/user/view/{{.Username}}">
{{.Username}}{{if .Admin}} &#x272a;{{end}}
</a>
</li>
{{- end}}
</ul>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- View
-------------------------------------------------------------------------------
{{define "UserView" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> / {{.Username}}
</h1>
<ul class="section-menu">
<li><a href="/user/update/{{.Username}}">Update</a></li>
<li><a href="/user/delete/{{.Username}}">Delete</a></li>
</ul>
<section>
<dl>
<dt>Admin</dt>
<dd>{{if .Admin}}True{{else}}False{{end}}</dd>
</dl>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Update
-------------------------------------------------------------------------------
{{define "UserUpdate" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> /
<a href="/user/view/{{.User.Username}}">{{.User.Username}}</a> /
Update
</h1>
<section>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<label for="NewPassword">Password:</label>
<input type="password" name="NewPassword" autocomplete="off">
</li>
<li>
<input type="checkbox" name="Admin" id="Admin" {{if .User.Admin}}Checked{{end}}>
<label for="Admin">Admin</label>
</li>
<li>
<input type="submit" value="Update">
</li>
</ul>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Delete
-------------------------------------------------------------------------------
{{define "UserDelete" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> /
<a href="/user/view/{{.User.Username}}">{{.User.Username}}</a> /
Delete
</h1>
<section>
<p>Really delete user {{.User.Username}}?</p>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<input type="submit" value="Delete">
</li>
</ul>
</form>
</section>
{{template "PageEnd"}}
{{- end}}

34
timeout.go Normal file
View File

@ -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)
}
}

632
tmpl_gen.go Normal file
View File

@ -0,0 +1,632 @@
package am
var tmpls = `
{{define "PageStart" -}}
<!doctype html>
<html lang="en">
<head>
<title>am</title>
<style>{{template "CSS"}}</style>
</head>
<body>
<ul class="top-menu">
<li><a href="/log/list">Log</a></li>
<li><a href="/source/list">Sources</a></li>
<li><a href="/user/list">Users</a></li>
</ul>
{{end}}
{{define "PageEnd" -}}
</body>
</html>
{{- end}}
{{define "Error" -}}
{{template "PageStart"}}
<h1>Error</h1>
<section>
<p>{{.}}</p>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- List
-------------------------------------------------------------------------------
{{define "LogList" -}}
{{template "PageStart"}}
<h1>Log</h1>
<section>
<form>
<select Name="Type">
<option value="all" {{if (eq .Args.Type "all")}}selected{{end}}>Type: All</option>
<option value="alert" {{if (eq .Args.Type "alert")}}selected{{end}}>Type: Alerts</option>
<option value="log" {{if (eq .Args.Type "log")}}selected{{end}}>Type: Non-alerts</option>
</select>
<select Name="SourceID">
<option value="">Source: All</option>
{{range .Sources}}
<option value="{{.SourceID}}" {{if (eq .SourceID $.Args.SourceID)}}selected{{end}}>
Source: {{.Name}}
</option>
{{end}}
</select>
<input type="submit" value="Submit">
</form>
{{if .Args.BeforeID}}
<p><a href="javascript:history.back()">Back</a></p>
{{end}}
<table>
<tr>
<th>Time</th>
<th> </th>
<th>Source</th>
<th>Text</th>
</tr>
{{range .Entries}}
<tr>
<td>{{.TS.Format "2006-01-02 15:04"}}
</td>
<td>{{if .Alert}}<b>!</b>{{end}}</td>
<td>{{.SourceName}}</td>
<td style="width:100%;">{{.Text}}</td>
</tr>
{{end}}
</table>
{{if .NextURL}}
<p><a href="{{.NextURL}}">More</a></p>{{end}}
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Insert
-------------------------------------------------------------------------------
{{define "SourceInsert" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> / Insert
</h1>
<section>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<label for="Name">Name:</label>
<input type="text" id="Name" name="Name" required>
</li>
<li>
<label for="Description">Description</label>
<textarea id="Description" name="Description" rows=8></textarea>
</li>
<li>
<label for="AlertTimeout">Alert Timeout:</label>
<input type="number" id="AlertTimeout" name="AlertTimeout">
</li>
<li>
<label for="LogAction">Log Action:</label>
<input type="text" id="LogAction" name="LogAction">
</li>
<li>
<label for="AlertAction">Alert Action:</label>
<input type="text" id="AlertAction" name="AlertAction">
</li>
<li>
<input type="submit" value="Insert">
</li>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- List
-------------------------------------------------------------------------------
{{define "SourceList" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">Sources</h1>
<ul class="section-menu">
<li><a href="/source/insert">Insert</a></li>
</ul>
<section>
<ul class="user-list">
{{range . -}}
<li>
<a href="/source/view/{{.SourceID}}">
{{.Name}}
{{if .TimedOut}}&#x2716;{{end}}
{{if .Ignore}}&#x2225;{{end}}
</a>
</li>
{{- end}}
</ul>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- View
-------------------------------------------------------------------------------
{{define "SourceView" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> / {{.Name}}
</h1>
<ul class="section-menu">
<li><a href="/source/update/{{.SourceID}}">Update</a></li>
<li><a href="/source/delete/{{.SourceID}}">Delete</a></li>
</ul>
<section>
<dl>
<dt>API Key</dt>
<dd>{{.APIKey}}</dd>
<dt>Description</dt>
<dd class="multiline">{{.Description}}</dd>
<dt>Last Seen</dt>
<dd>{{.LastSeenAt.Format "2006-01-02 15:04"}}</dd>
<dt>Alert Timeout (sec)</dt>
<dd>{{.AlertTimeout}}</dd>
<dt>Alerted At</dt>
<dd>{{.AlertedAt.Format "2006-01-02 15:04"}}</dd>
<dt>Ignore</dt>
<dd>{{if .Ignore}}True{{else}}False{{end}}</dd>
<dt>Log Action</dt>
<dd>{{.LogAction}}</dd>
<dt>Alert Action</dt>
<dd>{{.AlertAction}}</dd>
</dl>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Update
-------------------------------------------------------------------------------
{{define "SourceUpdate" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> /
<a href="/source/view/{{.Source.SourceID}}">{{.Source.Name}}</a> /
Update
</h1>
<section>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<input type="hidden" name="SourceID" value="{{.Source.SourceID}}">
<ul class="form-list">
<li>
<input type="checkbox" name="Ignore" id="Ignore" {{if .Source.Ignore}}Checked{{end}}>
<label for="Ignore">Ignore</label>
</li>
<li>
<label for="APIKey">API Key:</label>
<input type="text" id="APIKey" name="APIKey" value="{{.Source.APIKey}}">
</li>
<li>
<label for="Description">Description</label>
<textarea id="Description" name="Description" rows=8>
{{- .Source.Description -}}
</textarea>
</li>
<li>
<label for="AlertTimeout">Alert Timeout:</label>
<input type="number" id="AlertTimeout" name="AlertTimeout" value="{{.Source.AlertTimeout}}">
</li>
<li>
<label for="LogAction">Log Action:</label>
<input type="text" id="LogAction" name="LogAction" value="{{.Source.LogAction}}">
</li>
<li>
<label for="AlertAction">Alert Action:</label>
<input type="text" id="AlertAction" name="AlertAction" value="{{.Source.AlertAction}}">
</li>
<li>
<input type="submit" value="Update">
</li>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Delete
-------------------------------------------------------------------------------
{{define "SourceDelete" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/source/list">Sources</a> /
<a href="/source/view/{{.Source.SourceID}}">{{.Source.Name}}</a> /
Delete
</h1>
<section>
<p>Really delete source {{.Source.Name}}?</p>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<input type="submit" value="Delete">
</li>
</ul>
</form>
</section>
{{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"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> / Insert
</h1>
<section>
<form method="POST" autocomplete="off">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<label for="Username">Username:</label>
<input type="text" name="Username" autocomplete="off" required>
</li>
<li>
<label for="Password">Password:</label>
<input type="password" name="Password" autocomplete="off" required>
</li>
<li>
<input type="checkbox" name="Admin" id="Admin">
<label for="Admin">Admin</label>
</li>
<li>
<input type="submit" value="Insert">
</li>
</ul>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- List
-------------------------------------------------------------------------------
{{define "UserList" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">Users</h1>
<ul class="section-menu">
<li><a href="/user/insert">Insert</a></li>
</ul>
<section>
<ul class="user-list">
{{range . -}}
<li>
<a href="/user/view/{{.Username}}">
{{.Username}}{{if .Admin}} &#x272a;{{end}}
</a>
</li>
{{- end}}
</ul>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- View
-------------------------------------------------------------------------------
{{define "UserView" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> / {{.Username}}
</h1>
<ul class="section-menu">
<li><a href="/user/update/{{.Username}}">Update</a></li>
<li><a href="/user/delete/{{.Username}}">Delete</a></li>
</ul>
<section>
<dl>
<dt>Admin</dt>
<dd>{{if .Admin}}True{{else}}False{{end}}</dd>
</dl>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Update
-------------------------------------------------------------------------------
{{define "UserUpdate" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> /
<a href="/user/view/{{.User.Username}}">{{.User.Username}}</a> /
Update
</h1>
<section>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<label for="NewPassword">Password:</label>
<input type="password" name="NewPassword" autocomplete="off">
</li>
<li>
<input type="checkbox" name="Admin" id="Admin" {{if .User.Admin}}Checked{{end}}>
<label for="Admin">Admin</label>
</li>
<li>
<input type="submit" value="Update">
</li>
</ul>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
-------------------------------------------------------------------------------
-- Delete
-------------------------------------------------------------------------------
{{define "UserDelete" -}}
{{template "PageStart"}}
<h1 class="breadcrumbs">
<a href="/user/list">Users</a> /
<a href="/user/view/{{.User.Username}}">{{.User.Username}}</a> /
Delete
</h1>
<section>
<p>Really delete user {{.User.Username}}?</p>
<form method="POST">
<input type="hidden" name="CSRF" value="{{.CSRF}}">
<ul class="form-list">
<li>
<input type="submit" value="Delete">
</li>
</ul>
</form>
</section>
{{template "PageEnd"}}
{{- end}}
`

45
types.go Normal file
View File

@ -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
}

384
user-server.go Normal file
View File

@ -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})
}

31
validation.go Normal file
View File

@ -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
}