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
1// Command auth is the front door for every bythewood.me site: one account, a
2// six digit code pushed over ntfy, and an opaque session the other sites check
3// against this process rather than verifying for themselves.
4package main
5
6import (
7 "context"
8 "database/sql"
9 "embed"
10 "flag"
11 "fmt"
12 "io/fs"
13 "log/slog"
14 "net/http"
15 "os"
16 "strings"
17 "time"
18
19 "auth.bythewood.me/web"
20)
21
22// Templates are source, so they ship in the binary unconditionally; the Vite
23// bundle is build output and only embeds in a release build.
24//
25//go:embed templates
26var templateFS embed.FS
27
28const listenAddr = ":8000"
29
30// allPages is shared with the tests, which render every one of them against a
31// real database: a template naming a field that does not exist fails at execute
32// time rather than at parse time, so nothing but rendering it catches that.
33var allPages = []string{
34 "home.html", "login.html", "code.html", "recovery.html",
35 "account.html", "sessions.html", "security.html", "codes.html",
36 "activity.html", "uninitialized.html", "error.html", "notfound.html",
37}
38
39func templateSub() (fs.FS, error) { return fs.Sub(templateFS, "templates") }
40
41func dir(env, fallback string) string {
42 if v := os.Getenv(env); v != "" {
43 return v
44 }
45 return fallback
46}
47
48// csp allows 'unsafe-inline' for the analytics collector snippet and for
49// Bootstrap's inline style attributes.
50func csp() string {
51 return strings.Join([]string{
52 "default-src 'self'",
53 "script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
54 "style-src 'self' 'unsafe-inline'",
55 "img-src 'self' data:",
56 "font-src 'self'",
57 "connect-src 'self' https://analytics.bythewood.me",
58 "base-uri 'self'",
59 "form-action 'self'",
60 "frame-ancestors 'none'",
61 }, "; ")
62}
63
64type site struct {
65 renderer *web.Renderer
66 db *sql.DB
67 dist fs.FS
68 assets *web.Assets
69 notifier *Notifier
70
71 baseScript string
72 baseStyles []string
73 pagesScript string
74 pagesStyles []string
75}
76
77func main() {
78 web.SetupLogging()
79
80 initialize := flag.Bool("init", false, "seed the account and print its recovery codes, then exit")
81 check := flag.Bool("check", false, "report whether the account exists and how many recovery codes are left, then exit")
82 recovery := flag.Bool("recovery", false, "replace the recovery codes and print them, then exit")
83 // The container HEALTHCHECK runs this: a FROM scratch image has no shell
84 // for a check to call, so the binary probes itself.
85 healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
86 flag.Parse()
87
88 if *healthcheck {
89 if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
90 slog.Info(fmt.Sprintf("healthcheck: %v", err))
91 os.Exit(1)
92 }
93 return
94 }
95
96 dataDir := dir("SITE_DATA", "data")
97 db, err := openDB(dataDir + "/db.sqlite3")
98 if err != nil {
99 slog.Error("startup failed", slog.Any("err", err))
100 os.Exit(1)
101 }
102 defer db.Close()
103
104 if *initialize {
105 if err := runInit(db); err != nil {
106 slog.Error(fmt.Sprintf("init: %v", err))
107 os.Exit(1)
108 }
109 return
110 }
111
112 if *recovery {
113 if err := runRecovery(db); err != nil {
114 slog.Error(fmt.Sprintf("recovery: %v", err))
115 os.Exit(1)
116 }
117 return
118 }
119
120 if *check {
121 if err := runCheck(db); err != nil {
122 slog.Error(fmt.Sprintf("check: %v", err))
123 os.Exit(1)
124 }
125 return
126 }
127
128 dist := distFS()
129
130 assets, err := web.LoadAssets(dist)
131 if err != nil {
132 slog.Error("startup failed", slog.Any("err", err))
133 os.Exit(1)
134 }
135
136 templates, err := templateSub()
137 if err != nil {
138 slog.Error("startup failed", slog.Any("err", err))
139 os.Exit(1)
140 }
141
142 renderer, err := web.NewRenderer(templates, templateFuncs,
143 []string{"base.html", "partials.html"}, allPages)
144 if err != nil {
145 slog.Error("startup failed", slog.Any("err", err))
146 os.Exit(1)
147 }
148
149 s := &site{
150 renderer: renderer,
151 db: db,
152 dist: dist,
153 assets: assets,
154 notifier: NewNotifier(),
155 baseScript: assets.Script("static_src/base/index.js"),
156 baseStyles: assets.Styles("static_src/base/index.js"),
157 pagesScript: assets.Script("static_src/pages/index.js"),
158 pagesStyles: assets.Styles("static_src/pages/index.js"),
159 }
160
161 shipper := web.ShipLogs(selfSource, web.HTTPSink())
162 defer shipper.Close()
163
164 sweepCtx, stopSweeping := context.WithCancel(context.Background())
165 defer stopSweeping()
166 go runSweeper(sweepCtx, db)
167
168 handler := s.handler()
169
170 slog.Info(fmt.Sprintf("auth serving %s (staging=%t, cookie domain=%q)",
171 baseURL, Staging, cookieDomain()))
172 if err := web.Serve(listenAddr, handler); err != nil {
173 slog.Error("startup failed", slog.Any("err", err))
174 os.Exit(1)
175 }
176}
177
178// noStore is applied to every authenticated page. These carry addresses, user
179// agents and session times, and a zone-wide Cloudflare cache rule added later
180// would otherwise make them eligible.
181func noStore(next http.HandlerFunc) http.HandlerFunc {
182 return func(w http.ResponseWriter, r *http.Request) {
183 w.Header().Set("Cache-Control", "no-store, private")
184 next(w, r)
185 }
186}
187
188func methodNotAllowed(allow string) http.HandlerFunc {
189 return func(w http.ResponseWriter, r *http.Request) {
190 w.Header().Set("Allow", allow)
191 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
192 }
193}
194
195// runSweeper keeps the four tables that only grow from doing so.
196func runSweeper(ctx context.Context, db *sql.DB) {
197 sweep := func() {
198 for _, f := range []func(*sql.DB) error{sweepSessions, sweepPending, sweepEvents} {
199 if err := f(db); err != nil {
200 slog.Error("sweep failed", slog.String("component", "auth"), slog.Any("err", err))
201 }
202 }
203 }
204 sweep()
205
206 t := time.NewTicker(time.Hour)
207 defer t.Stop()
208 for {
209 select {
210 case <-ctx.Done():
211 return
212 case <-t.C:
213 sweep()
214 }
215 }
216}
217
218// healthz stays shallow: a corrupt database is not fixed by the restart a
219// failing check would trigger.
220func healthz(w http.ResponseWriter, r *http.Request) {
221 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
222 // EdgeCache is not in this site's chain, but saying nothing here would
223 // still let a Cloudflare cache rule answer a liveness check long after this
224 // process stopped serving.
225 w.Header().Set("Cache-Control", "no-store")
226 _, _ = w.Write([]byte("ok\n"))
227}
228
229// handler wires every route. It is a method so the tests drive the real mux
230// rather than a second copy of it that can drift.
231func (s *site) handler() http.Handler {
232 mux := http.NewServeMux()
233
234 mux.HandleFunc("GET /{$}", s.landing)
235
236 mux.HandleFunc("GET /login", s.loginForm)
237 mux.HandleFunc("POST /login", s.loginSubmit)
238 mux.HandleFunc("GET /code", s.codeForm)
239 mux.HandleFunc("POST /code", s.codeSubmit)
240 mux.HandleFunc("GET /recovery", s.recoveryForm)
241 mux.HandleFunc("POST /recovery", s.recoverySubmit)
242 mux.HandleFunc("POST /logout", s.logout)
243
244 mux.HandleFunc("GET /account", noStore(s.requireAuth(s.account)))
245 mux.HandleFunc("POST /account/username", s.requireSudo(s.changeUsername))
246 mux.HandleFunc("GET /sessions", noStore(s.requireAuth(s.sessions)))
247 mux.HandleFunc("POST /sessions/revoke", s.requireAuth(s.revoke))
248 mux.HandleFunc("POST /sessions/revoke-others", s.requireAuth(s.revokeOthers))
249 mux.HandleFunc("GET /security", noStore(s.requireAuth(s.security)))
250 mux.HandleFunc("POST /security/recovery", s.requireSudo(s.rotateRecovery))
251 mux.HandleFunc("GET /activity", noStore(s.requireAuth(s.activity)))
252
253 // Unauthenticated by design and reachable only over the bridge: Caddy
254 // refuses /verify on the public hostname. The cookie in the request is the
255 // credential, and this only says whether it is live.
256 mux.HandleFunc("GET /verify", s.verify)
257
258 // The mux answers 405 itself only when nothing else matches, and the
259 // "GET /" catch-all below matches every GET path there is.
260 for path, allow := range map[string]string{
261 "/logout": "POST",
262 "/account/username": "POST",
263 "/sessions/revoke": "POST",
264 "/sessions/revoke-others": "POST",
265 "/security/recovery": "POST",
266 } {
267 mux.HandleFunc("GET "+path, methodNotAllowed(allow))
268 }
269
270 mux.HandleFunc("GET /favicon.ico", favicon)
271 mux.HandleFunc("GET /robots.txt", robots)
272
273 mux.Handle("GET /static/", web.Static(s.dist, s.assets))
274
275 mux.HandleFunc("GET /healthz", healthz)
276
277 mux.HandleFunc("GET /", s.notFound)
278
279 return web.Chain(mux,
280 web.Recovered,
281 web.Logged,
282 web.SecurityHeaders(csp()),
283 )
284}