repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

2.9 KB · 108 lines · Go Raw History
  1package main
  2
  3import (
  4	"database/sql"
  5	"log/slog"
  6	"net/http"
  7	"time"
  8)
  9
 10// The event vocabulary, fixed here rather than spelled at each call site. A
 11// query written against these outlives the code that emits them, so adding a
 12// kind is cheap and renaming one is not.
 13const (
 14	evCodeRequested   = "code_requested"
 15	evCodeSent        = "code_sent"
 16	evCodeFailed      = "code_failed"
 17	evCodeExpired     = "code_expired"
 18	evLogin           = "login"
 19	evLogout          = "logout"
 20	evSessionRevoked  = "session_revoked"
 21	evRecoveryUsed    = "recovery_used"
 22	evRecoveryFailed  = "recovery_failed"
 23	evRecoveryRotated = "recovery_rotated"
 24	evRateLimited     = "rate_limited"
 25	evCeilingHit      = "ceiling_hit"
 26	evUsernameChanged = "username_changed"
 27)
 28
 29// Event is one row of the activity page.
 30type Event struct {
 31	TS      time.Time
 32	Kind    string
 33	IP      string
 34	Country string
 35	City    string
 36	UA      string
 37	Detail  string
 38}
 39
 40// audit writes the local copy and ships the same fact to
 41// logging.bythewood.me through the slog tee.
 42//
 43// No code ever reaches here, expired or otherwise. Retention outlives incident
 44// response, and a login code in a log store with a year on it is a login code
 45// somebody can read next spring.
 46func audit(db *sql.DB, r *http.Request, kind, detail string) {
 47	c := requestContext(r)
 48
 49	// Best effort. A login must not fail because the audit write did.
 50	_, err := db.Exec(`
 51        INSERT INTO events (ts, kind, ip, country, city, ua, cf_ray, detail)
 52        VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
 53		time.Now().Unix(), kind, c.IP, c.Country, c.City, c.UA, c.Ray, detail)
 54	if err != nil {
 55		slog.Error("audit write failed", slog.String("component", "auth"), slog.Any("err", err))
 56	}
 57
 58	attrs := []any{
 59		slog.String("component", "auth"),
 60		slog.String("event", kind),
 61		slog.String("ip", c.IP),
 62		slog.String("country", c.Country),
 63		slog.String("city", c.City),
 64	}
 65	if c.Ray != "" {
 66		attrs = append(attrs, slog.String("cf_ray", c.Ray))
 67	}
 68	if detail != "" {
 69		attrs = append(attrs, slog.String("detail", detail))
 70	}
 71	slog.Info("auth "+kind, attrs...)
 72}
 73
 74func recentEvents(db *sql.DB, limit int) ([]Event, error) {
 75	rows, err := db.Query(`
 76        SELECT ts, kind, ip, country, city, ua, detail FROM events
 77        ORDER BY id DESC LIMIT ?`, limit)
 78	if err != nil {
 79		return nil, err
 80	}
 81	defer rows.Close()
 82
 83	var out []Event
 84	for rows.Next() {
 85		var (
 86			e  Event
 87			ts int64
 88		)
 89		if err := rows.Scan(&ts, &e.Kind, &e.IP, &e.Country, &e.City, &e.UA, &e.Detail); err != nil {
 90			return nil, err
 91		}
 92		e.TS = time.Unix(ts, 0).UTC()
 93		out = append(out, e)
 94	}
 95	return out, rows.Err()
 96}
 97
 98// sweepEvents keeps the local copy bounded. logging.bythewood.me holds the long
 99// history, this table is only what the activity page reads when logging is the
100// thing that is down.
101func sweepEvents(db *sql.DB) error {
102	_, err := db.Exec(`
103        DELETE FROM events WHERE id NOT IN (
104            SELECT id FROM events ORDER BY id DESC LIMIT 2000
105        )`)
106	return err
107}