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

6.4 KB · 210 lines · Go Raw History
  1package web
  2
  3import (
  4	"log/slog"
  5	"net"
  6	"net/http"
  7	"strings"
  8	"time"
  9)
 10
 11// ClientIP resolves the real client address. CF-Connecting-IP wins over
 12// X-Forwarded-For, which is not the usual ordering: behind the tunnel the last
 13// XFF entry is always cloudflared's own bridge address.
 14func ClientIP(r *http.Request) string {
 15	if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
 16		return ip
 17	}
 18	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
 19		parts := strings.Split(xff, ",")
 20		return strings.TrimSpace(parts[len(parts)-1])
 21	}
 22	host, _, err := net.SplitHostPort(r.RemoteAddr)
 23	if err != nil {
 24		return r.RemoteAddr
 25	}
 26	return host
 27}
 28
 29type recorder struct {
 30	http.ResponseWriter
 31	status int
 32	bytes  int
 33}
 34
 35func (w *recorder) WriteHeader(code int) {
 36	if w.status == 0 {
 37		w.status = code
 38	}
 39	w.ResponseWriter.WriteHeader(code)
 40}
 41
 42func (w *recorder) Write(b []byte) (int, error) {
 43	if w.status == 0 {
 44		w.status = http.StatusOK
 45	}
 46	n, err := w.ResponseWriter.Write(b)
 47	w.bytes += n
 48	return n, err
 49}
 50
 51// Unwrap is what http.ResponseController follows to reach the real writer.
 52// Without it a wrapped handler cannot flush, so a server-sent events endpoint
 53// behind this middleware buffers until the connection closes.
 54func (w *recorder) Unwrap() http.ResponseWriter { return w.ResponseWriter }
 55
 56// Logged writes one structured record per request. Duration is a float in
 57// milliseconds rather than a formatted Duration, because "1.042ms" cannot be
 58// sorted or compared by a log query and 1.042 can.
 59func Logged(next http.Handler) http.Handler {
 60	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 61		start := time.Now()
 62		rec := &recorder{ResponseWriter: w}
 63
 64		next.ServeHTTP(rec, r)
 65
 66		if rec.status == 0 {
 67			rec.status = http.StatusOK
 68		}
 69		attrs := []any{
 70			slog.Int("status", rec.status),
 71			slog.String("method", r.Method),
 72			slog.String("path", r.URL.Path),
 73			slog.String("host", r.Host),
 74			slog.String("ip", ClientIP(r)),
 75			slog.Int("bytes", rec.bytes),
 76			slog.Float64("ms", float64(time.Since(start).Microseconds())/1000),
 77			slog.String("component", routeClass(rec, r.URL.Path)),
 78		}
 79		// Absent means the request never crossed the tunnel.
 80		if ray := r.Header.Get("CF-Ray"); ray != "" {
 81			attrs = append(attrs, slog.String("cf_ray", ray))
 82		}
 83		slog.Info("request", attrs...)
 84	})
 85}
 86
 87// routeClass buckets a request into something the log store can group by. It
 88// has to stay small: this is a rollup dimension there, and one that grew with
 89// the URL space would make that table grow like the raw one it exists to avoid.
 90//
 91// A stream is named because its elapsed time measures the visit rather than any
 92// work done, so nothing downstream should average it in with real requests.
 93func routeClass(w *recorder, path string) string {
 94	if strings.HasPrefix(w.Header().Get("Content-Type"), "text/event-stream") {
 95		return "stream"
 96	}
 97	switch path {
 98	case "/healthz":
 99		return "healthz"
100	case "/favicon.ico", "/favicon.svg", "/robots.txt", "/sitemap.xml",
101		"/manifest.json", "/latest.json", "/rss.xml", "/feed":
102		return "asset"
103	}
104	for _, prefix := range []string{"/static/", "/static_maps/", "/content/", "/og/", "/media/", "/_next/"} {
105		if strings.HasPrefix(path, prefix) {
106			return "static"
107		}
108	}
109	return "page"
110}
111
112// Recovered turns a panic in a handler into a 500 rather than killing the
113// process and every other in-flight request with it.
114func Recovered(next http.Handler) http.Handler {
115	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116		defer func() {
117			if err := recover(); err != nil {
118				slog.Error("panic serving request",
119					slog.String("method", r.Method),
120					slog.String("path", r.URL.Path),
121					slog.Any("panic", err),
122				)
123				http.Error(w, "internal server error", http.StatusInternalServerError)
124			}
125		}()
126		next.ServeHTTP(w, r)
127	})
128}
129
130// SecurityHeaders applies the headers that belong to the app rather than the
131// edge. No HSTS: Caddy sets it, and this process only ever speaks plaintext on
132// a Docker bridge.
133func SecurityHeaders(csp string) func(http.Handler) http.Handler {
134	return func(next http.Handler) http.Handler {
135		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136			h := w.Header()
137			h.Set("X-Content-Type-Options", "nosniff")
138			h.Set("X-Frame-Options", "SAMEORIGIN")
139			h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
140			h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
141			if csp != "" {
142				h.Set("Content-Security-Policy", csp)
143			}
144			next.ServeHTTP(w, r)
145		})
146	}
147}
148
149// Chain applies middleware so the first argument is the outermost layer.
150func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
151	for i := len(mw) - 1; i >= 0; i-- {
152		h = mw[i](h)
153	}
154	return h
155}
156
157// EdgeCache sets a shared cache policy on 200 GETs that have not already chosen
158// one; 400 and above get no-store, and a handler's own Cache-Control is left
159// alone.
160//
161// Never use s-maxage here. It carries proxy-revalidate semantics, which makes
162// Cloudflare disable stale-while-revalidate and stale-if-error, and
163// stale-if-error is what keeps the last good copy served when the tunnel drops.
164// Cloudflare also ignores it with Always Online on, so that has to stay off.
165func EdgeCache(policy string) func(http.Handler) http.Handler {
166	return func(next http.Handler) http.Handler {
167		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
168			if r.Method != http.MethodGet && r.Method != http.MethodHead {
169				next.ServeHTTP(w, r)
170				return
171			}
172			next.ServeHTTP(&edgeCacheWriter{ResponseWriter: w, policy: policy}, r)
173		})
174	}
175}
176
177// edgeCacheWriter defers the decision to WriteHeader, the first point at which
178// both the status code and the handler's own choice are known.
179type edgeCacheWriter struct {
180	http.ResponseWriter
181	policy string
182	done   bool
183}
184
185func (w *edgeCacheWriter) WriteHeader(code int) {
186	if !w.done {
187		w.done = true
188		if w.Header().Get("Cache-Control") == "" {
189			switch {
190			case code == http.StatusOK:
191				w.Header().Set("Cache-Control", w.policy)
192			case code >= 400:
193				// Cloudflare stamps its own TTL on a header-less
194				// response and will hold a 404 at the edge.
195				w.Header().Set("Cache-Control", "no-store")
196			}
197		}
198	}
199	w.ResponseWriter.WriteHeader(code)
200}
201
202func (w *edgeCacheWriter) Write(b []byte) (int, error) {
203	if !w.done {
204		w.WriteHeader(http.StatusOK)
205	}
206	return w.ResponseWriter.Write(b)
207}
208
209func (w *edgeCacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }