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:
commit
0c5a57556a
22 files changed
+4953
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user