orchard
mirrorEvery 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
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}