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

8.1 KB · 285 lines · Go Raw History
  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}