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# No TLS and no ACME. Cloudflare terminates HTTPS and cloudflared hands plain
2# HTTP to the Docker bridge, so there is nothing here to encrypt.
3#
4# Caddy sits between cloudflared and the apps so headers, compression and
5# per-host routing live in one place. Adding a site is a block here plus an
6# ingress rule.
7{
8 auto_https off
9 admin off
10 servers {
11 # Or client_ip resolves to cloudflared's bridge address.
12 trusted_proxies static private_ranges
13
14 # Only Cloudflare can set this one. Without it client_ip comes out of
15 # X-Forwarded-For, which the client sends, so a scanner claiming to be
16 # 127.0.0.1 is logged as 127.0.0.1 and hides from the direct-hits panel
17 # on logging that exists to catch requests which skipped Cloudflare.
18 client_ip_headers Cf-Connecting-Ip
19 }
20}
21
22# Split out from (site) so repos can import these without the blanket `encode`
23# that its git wire must not have.
24(headers) {
25 header {
26 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
27 X-Frame-Options SAMEORIGIN
28 X-Content-Type-Options nosniff
29 Referrer-Policy strict-origin-when-cross-origin
30 -Server
31 # reverse_proxy adds "Via: 1.1 Caddy", which undoes the line above.
32 -Via
33 }
34
35 # Off by default in Caddy 2. Two loggers carry the same access events:
36 # console is what `docker logs orchard-caddy` shows and stays the source
37 # of truth, and ship is a copy for logging.bythewood.me.
38 log console {
39 output stderr
40 format console
41 }
42
43 # Caddy cannot carry a Go handler, so it ships over its own net writer
44 # rather than web/shipper.go. Only the access logs go, never Caddy's own
45 # runtime log: the runtime log is where a failing net writer reports
46 # itself, and shipping that over the connection that is failing is a
47 # feedback loop. The access log is driven by requests instead.
48 log ship {
49 # soft_start, or Caddy refuses to boot whenever the logging site is
50 # down. `make up` guarantees that case, since the edge starts first.
51 output net tcp/orchard-logging:9001 {
52 soft_start
53 }
54
55 # Headers are most of the bytes on the wire and the record they land
56 # in caps attrs at 8KB anyway. cf_ray is the one worth keeping, and
57 # log_append below puts it back as a field of its own.
58 format filter {
59 wrap json
60 fields {
61 request>headers delete
62 request>tls delete
63 resp_headers delete
64 }
65 }
66 }
67
68 # Read before the filter above drops the headers, so a row here can be
69 # matched to the site's own row for the same request.
70 log_append cf_ray {http.request.header.Cf-Ray}
71
72 # Every app names the kind of route it served, and an edge row is its own
73 # kind, so the log store can group by this without a hole where Caddy is.
74 log_append component edge
75}
76
77# Exploit scanners probing software nothing here runs, about a third of all
78# requests. Blocked at the edge because it is a property of the edge, not of any
79# app, and because a third of every log record being hostile noise is what stops
80# a real new 404 after a deploy from being visible at all.
81#
82# `respond 404` rather than `abort`: Cloudflare reads a closed origin connection
83# as an origin error and counts it against origin health. A 404 is what these
84# paths already returned.
85#
86# The same list also exists at Cloudflare, which catches these first and more
87# cheaply, since a blocked request never crosses the tunnel. Keeping a copy here
88# is the point, because a dashboard rule is not in version control and does not
89# come back on a rebuild.
90#
91# Two custom rules rather than one, of the five the free plan allows. Together
92# they come to more than the 4,096 character cap on a single expression, and
93# splitting is free. Both are action Block, pasted identically into the
94# bythewood.me and isaacbythewood.com zones.
95#
96# Rule 1, "Exploit scanner paths":
97#
98# (starts_with(http.request.uri.path, "/."))
99# or (
100# not (
101# http.host eq "repos.bythewood.me"
102# and (
103# lower(http.request.uri.path) contains "/blob/"
104# or lower(http.request.uri.path) contains "/raw/"
105# or lower(http.request.uri.path) contains "/tree/"
106# or lower(http.request.uri.path) contains "/commit/"
107# or lower(http.request.uri.path) contains "/log/"
108# or lower(http.request.uri.path) contains "/archive/"
109# or lower(http.request.uri.path) contains "/branches"
110# or lower(http.request.uri.path) contains "/tags"
111# )
112# )
113# and (
114# lower(http.request.uri.path) in {"/env" "/_environment"}
115# or lower(http.request.uri.path) contains "wp-"
116# or lower(http.request.uri.path) contains "wordpress"
117# or lower(http.request.uri.path) contains "/wp/"
118# or lower(http.request.uri.path) contains "xmlrpc"
119# or lower(http.request.uri.path) contains "phpmyadmin"
120# or lower(http.request.uri.path) contains "autodiscover"
121# or lower(http.request.uri.path) contains "/cgi-bin/"
122# or lower(http.request.uri.path) contains "/vendor/"
123# or lower(http.request.uri.path) contains ".env"
124# or lower(http.request.uri.path) contains "/.git"
125# or lower(http.request.uri.path) contains "/.aws"
126# or lower(http.request.uri.path) contains "/.ssh"
127# or lower(http.request.uri.path) contains "/.vscode"
128# or lower(http.request.uri.path) contains ".php"
129# or lower(http.request.uri.path) contains ".asp"
130# or lower(http.request.uri.path) contains ".jsp"
131# or lower(http.request.uri.path) contains ".cgi"
132# or lower(http.request.uri.path) contains ".axd"
133# or lower(http.request.uri.path) contains ".sql"
134# or lower(http.request.uri.path) contains ".bak"
135# or lower(http.request.uri.path) contains "phpinfo"
136# or lower(http.request.uri.path) contains "/actuator/"
137# or lower(http.request.uri.path) contains "/_profiler/"
138# or lower(http.request.uri.path) contains "/solr/"
139# or lower(http.request.uri.path) contains "/livewire/"
140# or lower(http.request.uri.path) contains "/sitecore/"
141# or lower(http.request.uri.path) contains "telerik"
142# or lower(http.request.uri.path) contains "id_rsa"
143# or lower(http.request.uri.path) contains "id_ed25519"
144# or lower(http.request.uri.path) contains "credentials.json"
145# or lower(http.request.uri.path) contains "service-account"
146# or lower(http.request.uri.path) contains "serviceaccount"
147# or lower(http.request.uri.path) contains "keyfile.json"
148# or lower(http.request.uri.path) contains "adminsdk"
149# or lower(http.request.uri.path) contains "-key.json"
150# or lower(http.request.uri.path) contains "-sa.json"
151# or lower(http.request.uri.path) contains "/sa.json"
152# or lower(http.request.uri.path) contains "/key.json"
153# or lower(http.request.uri.path) contains "/_ignition/"
154# or lower(http.request.uri.path) contains "/storage/logs/"
155# or lower(http.request.uri.path) contains "/webinterface/"
156# or lower(http.request.uri.path) contains "/zabbix/"
157# or lower(http.request.uri.path) contains "/owa/"
158# or lower(http.request.uri.path) contains "/boaform/"
159# or lower(http.request.uri.path) contains "/jenkins/"
160# or lower(http.request.uri.path) contains "/hudson/"
161# or lower(http.request.uri.path) contains "sugar_version.json"
162# or lower(http.request.uri.path) contains "com_pagebuilderck"
163# or lower(http.request.uri.path) contains "/login.do"
164# or lower(http.request.uri.path) contains "/showlogin.cc"
165# )
166# )
167#
168# Rule 2, "Exploit scanner paths 2":
169#
170# http.host ne "repos.bythewood.me"
171# and (
172# lower(http.request.uri.path) contains "/remote/fgt_lang"
173# or lower(http.request.uri.path) contains "/remote/login"
174# or lower(http.request.uri.path) contains "/global-protect/"
175# or lower(http.request.uri.path) contains "/ssl-vpn/"
176# or lower(http.request.uri.path) contains "/app/rest/"
177# or lower(http.request.uri.path) contains "/blade-auth/"
178# or lower(http.request.uri.path) contains "/blade-system/"
179# or lower(http.request.uri.path) contains "/pimcore-studio/"
180# or lower(http.request.uri.path) contains "/php-cgi/"
181# or lower(http.request.uri.path) contains "/administrator/"
182# or lower(http.request.uri.path) contains "/language/en-gb/"
183# or lower(http.request.uri.path) contains "/media/system/"
184# or lower(http.request.uri.path) contains "/api/v1/totp/"
185# or lower(http.request.uri.path) contains "joomla"
186# or lower(http.request.uri.path) contains "keys-status"
187# or lower(http.request.uri.path) contains ".esp"
188# or lower(http.request.uri.path) contains ".action"
189# or lower(http.request.uri.path) contains "/_cat/"
190# or lower(http.request.uri.path) contains "/_cluster/"
191# or lower(http.request.uri.path) contains "/_watcher/"
192# or lower(http.request.uri.path) contains "/_snapshot/"
193# or lower(http.request.uri.path) contains "/_nodes"
194# or lower(http.request.uri.path) contains "/api/datasources"
195# or lower(http.request.uri.path) contains "/api/alert-notifications"
196# or lower(http.request.uri.path) contains "/api/v1/provisioning/"
197# or lower(http.request.uri.path) contains "/api/org"
198# or lower(http.request.uri.path) contains "/api/dashboards"
199# or lower(http.request.uri.path) contains "/api/2.0/mlflow/"
200# or lower(http.request.uri.path) contains "/containers/json"
201# or lower(http.request.uri.path) contains "/v1.24/"
202# or lower(http.request.uri.path) contains "/images/json"
203# or lower(http.request.uri.path) contains "/v2/keys/"
204# or lower(http.request.uri.path) contains "/api/contents"
205# or lower(http.request.uri.path) contains "/auth/v1/"
206# or lower(http.request.uri.path) contains "/rest/v1/"
207# or lower(http.request.uri.path) contains "/api/v1/validate/code"
208# or lower(http.request.uri.path) contains "/api/v1/auto_login"
209# or lower(http.request.uri.path) contains "/druid/"
210# or lower(http.request.uri.path) contains "/hazelcast/"
211# or lower(http.request.uri.path) contains "/geoserver/"
212# or lower(http.request.uri.path) contains "/ws/v1/cluster"
213# or lower(http.request.uri.path) contains "editor/filemanager/"
214# or lower(http.request.uri.path) contains "/plugins/editors/"
215# or lower(http.request.uri.path) contains "/media/jui/"
216# or ends_with(lower(http.request.uri.path), ".zip")
217# or ends_with(lower(http.request.uri.path), ".rar")
218# or ends_with(lower(http.request.uri.path), ".7z")
219# or ends_with(lower(http.request.uri.path), ".tgz")
220# or ends_with(lower(http.request.uri.path), ".tar.gz")
221# )
222#
223# Rule 1's first line sits outside the guard so it covers repos as well. Nothing
224# here serves a path starting with a dot and no site registers a .well-known
225# route, so it cannot reach repository content.
226#
227# Rule 1 guards on the repository content shape and rule 2 on the host, because
228# the families in rule 2 never collide with a file in a repository and Caddy
229# covers repos exactly anyway. The free plan has no regex operator, so the shape
230# test there is `contains "/blob/"` where the matcher below anchors the verb to
231# the second segment. Cloudflare is the loose layer and Caddy the exact one, and
232# that asymmetry is the two layers working rather than a hole.
233#
234# "/.git" keeps its leading slash, and so do "/.aws", "/.ssh" and "/.vscode". A
235# bare ".git" matches /orchard.git/info/refs and takes clone and push over
236# HTTPS down with it.
237#
238# ".env" is bare on purpose, which is what catches /app.env and /docker.env, and
239# it is only safe because repos is excluded above.
240#
241# "/media/system/" has to carry the system segment. blog serves /media/images/
242# and /media/avatar_images/ for real, so a bare "/media/" takes them down.
243#
244# The archive extensions are end-anchored and repos is excluded from them, since
245# repos serves /{name}/archive/{rev}.tar.gz for real and that is a genuine
246# download. Caddy exempts it by the repository content shape and the Cloudflare
247# rule by host, which is the same asymmetry as everything else in rule 2.
248#
249# Everything generic stays out. /info, /console, /new/, /old/ and /backup/ are
250# all plausible routes here one day and each is worth a cheap 404, and /code is
251# already a real route on isaacbythewood.com.
252#
253# Do not turn on Bot Fight Mode to solve this. status probes its own public
254# hostnames with a plain Go client, which cannot solve a JS challenge, so every
255# site here would report a permanent false outage.
256
257# repos.bythewood.me serves arbitrary paths out of arbitrary repositories, so
258# matching on path content collides with real files: it took
259# /orchard/blob/main/sites/analytics.bythewood.me/.env.example off the file
260# browser, and every site here has to commit a .env.example.
261#
262# Depth is the wrong separator for that. Anchoring at the root gave up
263# everything below the first slash, and scanners walk /var/www/.git/config and
264# /api/.env/.git/config all day. Repository content always carries a verb as its
265# second segment, so exempting that shape and applying the full list everywhere
266# else keeps the browser working and still catches the nested probes.
267#
268# /orchard.git/info/refs is not exempt and must not match, which is why every
269# dotfile clause keeps its leading slash. Clone and push over HTTPS depend on it.
270(scanners_repos) {
271 @hostile_repos {
272 not path_regexp repo_content ^/[^/]+/(?:blob|raw|tree|commit|log|archive|branches|tags)(?:/|$)
273 path_regexp hostile_repos (?i)(?:^|/)(?:wp-|wordpress|wp/|xmlrpc|phpmyadmin|autodiscover|cgi-bin/|vendor/|actuator/|_profiler/|solr/|livewire/|sitecore/|_ignition/|storage/logs/|webinterface/|zabbix/|owa/|boaform/|jenkins/|hudson/|sa\.json|key\.json|\.git|\.aws|\.ssh|\.vscode|remote/fgt_lang|remote/login|global-protect/|ssl-vpn/|app/rest/|blade-auth/|blade-system/|pimcore-studio/|php-cgi/|administrator/|language/en-gb/|media/system/|api/v1/totp/|_cat/|_cluster/|_watcher/|_snapshot/|_nodes|api/datasources|api/alert-notifications|api/v1/provisioning/|api/org|api/dashboards|api/2\.0/mlflow/|containers/json|v1\.24/|images/json|v2/keys/|api/contents|auth/v1/|rest/v1/|api/v1/validate/code|api/v1/auto_login|druid/|hazelcast/|geoserver/|ws/v1/cluster|editor/filemanager/|plugins/editors/|media/jui/)|telerik|phpinfo|id_rsa|id_ed25519|credentials\.json|keyfile\.json|adminsdk|-key\.json|-sa\.json|sugar_version\.json|com_pagebuilderck|login\.do|showlogin\.cc|service-?account|\.env|joomla|keys-status|\.(?:php|asp|jsp|cgi|axd|sql|bak|esp|action)|\.(?:zip|rar|7z|tgz|tar\.gz)$|^/(?:env|_environment)$
274 }
275
276 log_skip @hostile_repos
277
278 handle @hostile_repos {
279 # A header-less response lets Cloudflare stamp its own TTL and hold
280 # the 404 at the edge.
281 header Cache-Control "no-store"
282 respond 404
283 }
284}
285
286(scanners) {
287 # Matched anywhere in the path rather than at the root, since most of the
288 # volume arrives nested under a guessed prefix (/blog/wp-json/, /app/.env).
289 # Ordinary 404s like /login or /apple-touch-icon.png are left alone.
290 @hostile path_regexp hostile (?i)(?:^|/)(?:wp-|wordpress|wp/|xmlrpc|phpmyadmin|autodiscover|cgi-bin/|vendor/|actuator/|_profiler/|solr/|livewire/|sitecore/|_ignition/|storage/logs/|webinterface/|zabbix/|owa/|boaform/|jenkins/|hudson/|sa\.json|key\.json|\.git|\.aws|\.ssh|\.vscode|remote/fgt_lang|remote/login|global-protect/|ssl-vpn/|app/rest/|blade-auth/|blade-system/|pimcore-studio/|php-cgi/|administrator/|language/en-gb/|media/system/|api/v1/totp/|_cat/|_cluster/|_watcher/|_snapshot/|_nodes|api/datasources|api/alert-notifications|api/v1/provisioning/|api/org|api/dashboards|api/2\.0/mlflow/|containers/json|v1\.24/|images/json|v2/keys/|api/contents|auth/v1/|rest/v1/|api/v1/validate/code|api/v1/auto_login|druid/|hazelcast/|geoserver/|ws/v1/cluster|editor/filemanager/|plugins/editors/|media/jui/)|telerik|phpinfo|id_rsa|id_ed25519|credentials\.json|keyfile\.json|adminsdk|-key\.json|-sa\.json|sugar_version\.json|com_pagebuilderck|login\.do|showlogin\.cc|service-?account|\.env|joomla|keys-status|\.(?:php|asp|jsp|cgi|axd|sql|bak|esp|action)|\.(?:zip|rar|7z|tgz|tar\.gz)$|^/(?:env|_environment)$
291
292 # Not logged. Moving the noise to another log is not the point.
293 log_skip @hostile
294
295 handle @hostile {
296 header Cache-Control "no-store"
297 respond 404
298 }
299}
300
301(site) {
302 import scanners
303 import headers
304
305 encode zstd gzip
306}
307
308# One block per hostname, each proxying to a container by name.
309http://isaacbythewood.com {
310 import site
311 reverse_proxy orchard-isaacbythewood:8000
312}
313
314http://blog.bythewood.me {
315 import site
316 reverse_proxy orchard-blog:8000
317}
318
319# The collector is the one route here an anonymous stranger can POST to. The app
320# caps the body at 16KB itself, and this is the second fence.
321http://analytics.bythewood.me {
322 import site
323
324 request_body {
325 max_size 16KB
326 }
327
328 reverse_proxy orchard-analytics:8000
329}
330
331# /verify is answered here rather than proxied, so reaching it really does mean
332# being inside the network. The other sites call orchard-auth:8000/verify by
333# container name and never come through Caddy at all.
334#
335# The login form is the one page here a stranger can reach. What bounds the abuse
336# is in the app: one outstanding code at a time and a per account ceiling that
337# ignores the source address, since a per-IP limit is bypassed by sending one
338# request from each of a thousand proxies.
339http://auth.bythewood.me {
340 import site
341
342 # The wildcard catches /verify/ too.
343 handle /verify* {
344 respond "not here" 404
345 }
346
347 handle {
348 reverse_proxy orchard-auth:8000
349 }
350}
351
352http://status.bythewood.me {
353 import site
354 reverse_proxy orchard-status:8000
355}
356
357# /ingest and /aggregate are answered here rather than proxied, so reaching
358# either really does mean being inside the network. Every site posts to
359# orchard-logging:8000 by container name and never comes through Caddy at all,
360# and dash reads /aggregate the same way.
361http://logging.bythewood.me {
362 import site
363
364 # Two handle blocks so the order is stated rather than inherited from
365 # Caddy's directive ordering. The wildcards catch /ingest/ too.
366 handle /ingest* {
367 respond "not here" 404
368 }
369
370 handle /aggregate* {
371 respond "not here" 404
372 }
373
374 handle {
375 reverse_proxy orchard-logging:8000
376 }
377}
378
379# The live panels arrive over server-sent events, which must not be buffered or
380# compressed on the way out, so this imports the two halves of (site) directly
381# and gives encode a matcher instead of taking the blanket one.
382http://dash.bythewood.me {
383 import scanners
384 import headers
385
386 @compressible not path /events
387
388 encode @compressible zstd gzip
389
390 reverse_proxy orchard-dash:8000
391}
392
393# The answer arrives over server-sent events, which must not be buffered or
394# compressed, so this block imports scanners and headers directly rather than
395# taking (site) with its blanket encode. Same reason dash does.
396# Streams its answers and runs a local model, so the same shape as search: no
397# blanket encode over the event stream and timeouts that outlast a slow turn.
398http://chat.bythewood.me {
399 import scanners
400 import headers
401
402 @compressible not path /api/send
403
404 encode @compressible zstd gzip
405
406 # A turn can call tools for several rounds before it answers, and the whole
407 # of it is one held open response.
408 reverse_proxy orchard-chat:8000 {
409 flush_interval -1
410 transport http {
411 response_header_timeout 15m
412 read_timeout 16m
413 }
414 }
415}
416
417# The model gateway. Every other site reaches it by container name on the
418# bridge, so this hostname exists for the machines that are not on it.
419http://llm.bythewood.me {
420 import scanners
421 import headers
422
423 @compressible not path /v1/chat/completions
424
425 encode @compressible zstd gzip
426
427 reverse_proxy orchard-llm:8000 {
428 flush_interval -1
429 transport http {
430 response_header_timeout 15m
431 read_timeout 16m
432 }
433 }
434}
435
436http://search.bythewood.me {
437 import scanners
438 import headers
439
440 @compressible not path /stream
441
442 encode @compressible zstd gzip
443
444 # A question runs a local model and can take a minute, well past Caddy's
445 # default. The stream is held open for the whole of it.
446 reverse_proxy orchard-search:8000 {
447 flush_interval -1
448 transport http {
449 response_header_timeout 5m
450 read_timeout 6m
451 }
452 }
453}
454
455# ntfy, the alert path. An allowlist of three routes, for two reasons.
456#
457# ntfy publishes over GET as well as POST, so a denylist leaks: /<topic>/publish,
458# /<topic>/send and /<topic>/trigger all publish with no body, and a POST to /
459# publishes with the topic in a JSON body. All four were confirmed against the
460# running container.
461#
462# And everything else ntfy serves is surface nothing here needs. The three below
463# are the complete set the Android client uses, read off this proxy's own access
464# log. The web app is separately off in server.yml.
465#
466# ntfy's own deny-all auth is the fence that matters. This is the second one, so
467# even a stolen write token cannot publish from out here.
468http://ntfy.bythewood.me {
469 import site
470
471 # Commas allowed, since the client subscribes to "status,logging" as one
472 # path segment rather than opening two connections.
473 @client {
474 method GET
475 path_regexp ^/[A-Za-z0-9_,-]+/(ws|json|sse|raw|auth)$
476 }
477
478 handle @client {
479 reverse_proxy orchard-ntfy:8000
480 }
481
482 handle {
483 respond "not here" 404
484 }
485}
486
487# repos, the git remote. 100MB is Cloudflare's own ceiling on a proxied request
488# body for Free and Pro, which nothing here can raise, because a tunnel hostname
489# must stay proxied. Stating it means an oversized push is refused here with a
490# 413 that names the limit rather than by Cloudflare with one that does not.
491http://repos.bythewood.me {
492 # Not `import site`, because packfiles are already compressed and gzipping
493 # one costs CPU on both ends to make it slightly larger. `encode` goes on
494 # the browse handle instead.
495 import scanners_repos
496 import headers
497
498 @git path_regexp ^/[^/]+\.git(/.*)?$
499
500 handle @git {
501 request_body {
502 max_size 100MB
503 }
504 reverse_proxy orchard-repos:8000
505 }
506
507 # The browse UI is ordinary HTML and does want compression.
508 handle {
509 encode zstd gzip
510 reverse_proxy orchard-repos:8000
511 }
512}
513
514# bythewood.me has never served a site. It is the zone apex carrying the MX
515# records, so all that is decided here is where HTTP goes.
516http://www.isaacbythewood.com {
517 import site
518
519 # Wrapped in a handle so it is ordered against the scanner block rather
520 # than ahead of it. Caddy sorts redir before handle, so a bare redir here
521 # answers a scanner with a 301 and the block above never runs.
522 handle {
523 redir https://isaacbythewood.com{uri} permanent
524 }
525}
526
527http://bythewood.me, http://www.bythewood.me {
528 import site
529
530 # See www.isaacbythewood.com above.
531 handle {
532 redir https://isaacbythewood.com{uri} permanent
533 }
534}
535
536# No matching Host means it did not come through an ingress rule. Say so rather
537# than serving a site by accident.
538#
539# Only one logger here, and it has to be the console one. A block with no host
540# matcher becomes the server's default_logger_name, which is a single string
541# rather than a list, so naming two loggers silently keeps one and drops the
542# other. Everything reaching a real hostname is logged both ways above, and
543# these 404s stay where they already were.
544:80 {
545 log console {
546 output stderr
547 format console
548 }
549 header -Server
550 respond "orchard edge: no site block for this host" 404
551}