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