Initial commit: notifications, monitoring, on-call and incidents

One binary and one SQLite file. Topics with ntfy-style publish and
subscribe, HTTP/TCP/DNS/TLS/heartbeat monitors with a public status page,
schedules and escalation policies, and incidents with a timeline and
post-mortem. The UI follows the Lightwell design language.

Builds against an unreleased Reef (secretbox) through a local replace line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 23:21:56 -06:00
commit 0c5a57556a
22 files changed
+4953

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
package sandbar
import (
"context"
"database/sql"
"errors"
"net"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"` // admin or member
Phone string `json:"phone"`
Created int64 `json:"created"`
Topic string `json:"topic"` // the personal topic their phone subscribes to
hash string
}
const (
sessionCookie = "sandbar_session"
sessionTTL = 14 * 24 * time.Hour
)
type ctxKey struct{}
func (s *Server) countUsers() int {
var n int
s.st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}
func scanUser(r scanner) (*User, error) {
var u User
err := r.Scan(&u.ID, &u.Name, &u.Email, &u.hash, &u.Role, &u.Phone, &u.Created)
if errors.Is(err, sql.ErrNoRows) {
return nil, errNotFound
}
u.Topic = userTopic(u.ID)
return &u, err
}
const userCols = `id,name,email,hash,role,phone,created`
func (s *Server) userByID(id string) (*User, error) {
return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
}
func (s *Server) userByEmail(email string) (*User, error) {
return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email=?`, strings.TrimSpace(email)))
}
func (s *Server) listUsers() []User {
out := []User{}
rows, err := s.st.db.Query(`SELECT ` + userCols + ` FROM users ORDER BY name`)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
if u, err := scanUser(rows); err == nil {
out = append(out, *u)
}
}
return out
}
func validPassword(p string) error {
if len(p) < 10 {
return errors.New("use a password of at least 10 characters")
}
if len(p) > 72 {
return errors.New("passwords are limited to 72 characters")
}
return nil
}
func (s *Server) createUser(name, email, password, role, phone string) (*User, error) {
name, email = strings.TrimSpace(name), strings.TrimSpace(email)
if name == "" || !strings.Contains(email, "@") || strings.ContainsAny(email, " \r\n<>") {
return nil, errors.New("a user needs a name and an email address")
}
if role != "admin" && role != "member" {
return nil, errors.New("role must be admin or member")
}
if err := validPassword(password); err != nil {
return nil, err
}
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
u := &User{ID: newID(), Name: name, Email: email, Role: role, Phone: strings.TrimSpace(phone), Created: s.now().Unix()}
u.Topic = userTopic(u.ID)
_, err = s.st.db.Exec(`INSERT INTO users(`+userCols+`) VALUES(?,?,?,?,?,?,?)`, u.ID, u.Name, u.Email, string(h), u.Role, u.Phone, u.Created)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, errors.New("that email address is already registered")
}
return nil, err
}
return u, nil
}
func (s *Server) setPassword(id, password string) error {
if err := validPassword(password); err != nil {
return err
}
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.st.db.Exec(`UPDATE users SET hash=? WHERE id=?`, string(h), id)
if err == nil {
s.st.db.Exec(`DELETE FROM sessions WHERE user_id=?`, id) // a password change signs everyone out
}
return err
}
// throttled limits password guessing per client address: 10 tries per 10 minutes.
func (s *Server) throttled(ip string) bool {
s.loginMu.Lock()
defer s.loginMu.Unlock()
cut := s.now().Add(-10 * time.Minute)
var keep []time.Time
for _, t := range s.loginTry[ip] {
if t.After(cut) {
keep = append(keep, t)
}
}
s.loginTry[ip] = keep
return len(keep) >= 10
}
func (s *Server) noteFailure(ip string) {
s.loginMu.Lock()
s.loginTry[ip] = append(s.loginTry[ip], s.now())
s.loginMu.Unlock()
}
func clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
var errThrottled = errors.New("too many attempts, try again in a few minutes")
// login checks credentials and starts a session.
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
ip := clientIP(r)
if s.throttled(ip) {
return nil, errThrottled
}
u, err := s.userByEmail(email)
hash := "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5BWYKTaYBwxwEGLMzRVr4qhCXWM9S" // so a wrong email costs the same time as a wrong password
if err == nil {
hash = u.hash
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil || err != nil {
s.noteFailure(ip)
return nil, errors.New("wrong email or password")
}
tok := newToken()
exp := s.now().Add(sessionTTL)
if _, err := s.st.db.Exec(`INSERT INTO sessions(hash,user_id,expires) VALUES(?,?,?)`, hashToken(tok), u.ID, exp.Unix()); err != nil {
return nil, err
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", Expires: exp, HttpOnly: true,
SameSite: http.SameSiteStrictMode, Secure: strings.HasPrefix(s.opts.URL, "https://")})
return u, nil
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
s.st.db.Exec(`DELETE FROM sessions WHERE hash=?`, hashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
}
// authenticate finds who is calling: a session cookie, or an API key given as a
// bearer token or as the password of HTTP basic auth (ntfy clients do the latter).
func (s *Server) authenticate(r *http.Request) *User {
if c, err := r.Cookie(sessionCookie); err == nil {
var uid string
var exp int64
if s.st.db.QueryRow(`SELECT user_id,expires FROM sessions WHERE hash=?`, hashToken(c.Value)).Scan(&uid, &exp) == nil && exp > s.now().Unix() {
if u, err := s.userByID(uid); err == nil {
return u
}
}
}
key := ""
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
key = strings.TrimSpace(h[7:])
} else if _, pw, ok := r.BasicAuth(); ok {
key = pw
}
if key == "" {
return nil
}
var uid string
if s.st.db.QueryRow(`SELECT user_id FROM apikeys WHERE hash=?`, hashToken(key)).Scan(&uid) == nil {
if u, err := s.userByID(uid); err == nil {
return u
}
}
return nil
}
func userFrom(ctx context.Context) *User {
u, _ := ctx.Value(ctxKey{}).(*User)
return u
}
func (s *Server) createAPIKey(userID, name string) (id, token string, err error) {
token = "sb_" + newToken()
id = newID()
_, err = s.st.db.Exec(`INSERT INTO apikeys(id,name,hash,user_id,created) VALUES(?,?,?,?,?)`, id, strings.TrimSpace(name), hashToken(token), userID, s.now().Unix())
return
}