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.3 KB · 151 lines · Bash Raw History
  1#!/bin/sh
  2# Create, inspect and tear down the Cloudflare Tunnel that fronts this repo. Run
  3# these from the repo root, where the first three are make targets:
  4#
  5#   make tunnel-login           once per zone: browser auth, writes cert.pem
  6#   make tunnel                 create the tunnel, route DNS, write config.yml
  7#   make tunnel-status          what exists right now
  8#   sh edge/setup-tunnel.sh down    delete the tunnel and its volume
  9#
 10# `down` has no target on purpose, since deleting the tunnel is not something to
 11# have one keystroke away from `make doctor`.
 12#
 13# All cloudflared state lives in a named volume, never a bind mount. The Docker
 14# CLI here talks to Docker Desktop on the Windows host, whose daemon cannot see
 15# this filesystem, so a bind mount silently resolves to an empty directory.
 16#
 17# Every docker command goes through sudo, because the socket in the webdev
 18# container is root:root mode 660 and being in the docker group does not help.
 19# On a host where docker needs no sudo:  make tunnel SUDO=
 20set -e
 21
 22# Resolved before the cd below, since $0 is relative to the caller's
 23# directory and stops resolving the moment we leave it.
 24SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
 25
 26# Run from this script's own directory, whatever the caller's was, since every
 27# path below is relative to edge/.
 28cd "$(dirname "$0")"
 29
 30SUDO=${SUDO-sudo}
 31DOCKER_BIN=$(command -v docker) || { echo "no docker on PATH" >&2; exit 1; }
 32# An absolute path, because `command` is a shell builtin and sudo cannot exec
 33# one, and because sudo's secure_path is not this shell's PATH.
 34docker() { ${SUDO} "$DOCKER_BIN" "$@"; }
 35
 36VOLUME=orchard-cloudflared
 37TUNNEL=orchard
 38IMAGE=cloudflare/cloudflared:latest
 39# Every hostname the tunnel serves, matching the ingress rules in
 40# cloudflared/config.yml. These span two zones and cert.pem covers one at a time,
 41# so routing all of them means logging in once per zone and running `up` again.
 42# The per-host failures in between are expected.
 43HOSTNAMES="isaacbythewood.com www.isaacbythewood.com bythewood.me www.bythewood.me blog.bythewood.me analytics.bythewood.me auth.bythewood.me status.bythewood.me logging.bythewood.me ntfy.bythewood.me repos.bythewood.me dash.bythewood.me search.bythewood.me chat.bythewood.me llm.bythewood.me"
 44
 45# cloudflared's image is distroless with no shell, so anything that needs to
 46# poke at the volume borrows a plain alpine.
 47volume_sh() {
 48	docker run --rm -v "$VOLUME:/etc/cloudflared" alpine:3 sh -c "$1"
 49}
 50
 51# Every command except login has to be told where the origin cert is, or it
 52# looks in $HOME/.cloudflared, reports "cert.pem not found" and tells you to log
 53# in again on a machine that already has.
 54cfd() {
 55	docker run --rm -v "$VOLUME:/etc/cloudflared" "$IMAGE" \
 56		--origincert /etc/cloudflared/cert.pem "$@"
 57}
 58
 59ensure_volume() {
 60	docker volume inspect "$VOLUME" >/dev/null 2>&1 || docker volume create "$VOLUME" >/dev/null
 61	# The image runs as uid 65532 and a fresh volume is root-owned. Without this
 62	# `tunnel login` authenticates and then dies with EACCES writing cert.pem,
 63	# burning a one-time callback token.
 64	volume_sh "chown -R 65532:65532 /etc/cloudflared"
 65}
 66
 67case "${1:-}" in
 68login)
 69	ensure_volume
 70	# Mounted at the image's HOME rather than /etc/cloudflared, because login
 71	# ignores --origincert and writes to $HOME/.cloudflared unconditionally.
 72	# Mount it elsewhere and it reports success while writing the cert into the
 73	# container layer, taking the one-time callback token with it.
 74	#
 75	# No TTY either, since login only prints a URL and polls the callback.
 76	docker run --rm -v "$VOLUME:/home/nonroot/.cloudflared" "$IMAGE" tunnel login
 77	volume_sh "chown -R 65532:65532 /etc/cloudflared"
 78	;;
 79
 80up)
 81	ensure_volume
 82	cfd tunnel create "$TUNNEL" || true
 83
 84	# The credentials file is named after the tunnel id, so the id reads straight
 85	# off the volume instead of out of `tunnel list`.
 86	ID=$(volume_sh "ls /etc/cloudflared" | grep -E '^[0-9a-f-]{36}\.json$' | head -1 | sed 's/\.json$//')
 87	if [ -z "$ID" ]; then
 88		echo "could not determine tunnel id, run: make tunnel-status" >&2
 89		exit 1
 90	fi
 91	echo "tunnel id: $ID"
 92
 93	# ONE ZONE ONLY. cert.pem carries a single zoneID picked in the browser
 94	# during `login`. Handed a hostname from another zone this does not fail, it
 95	# treats the whole string as a subdomain and creates
 96	# "blog.bythewood.me.isaacbythewood.com". Routing a second zone means logging
 97	# in again to replace cert.pem, which is safe for a running tunnel since that
 98	# authenticates with the credentials JSON instead.
 99	#
100	# ONE LABEL ONLY. Cloudflare's free Universal SSL signs the apex and a single
101	# wildcard level, so a two-label host like next.blog.bythewood.me has no
102	# certificate and fails the TLS handshake. Use a hyphen instead.
103	for host in $HOSTNAMES; do
104		cfd tunnel route dns "$TUNNEL" "$host" || true
105	done
106
107	# Seeded over stdin rather than mounted, for the same reason as everything
108	# else here. The id appears twice in the template so the substitution is
109	# global, and it renders to a temp file first because a sed failure in the
110	# middle of a pipeline does not fail the pipeline.
111	rendered=$(mktemp)
112	trap 'rm -f "$rendered"' EXIT
113	sed "s/CHANGEME_TUNNEL_ID/$ID/g" cloudflared/config.yml > "$rendered"
114	grep -q CHANGEME_TUNNEL_ID "$rendered" && {
115		echo "tunnel id was not substituted into config.yml" >&2
116		exit 1
117	}
118	docker run --rm -i -v "$VOLUME:/etc/cloudflared" alpine:3 \
119		sh -c 'cat > /etc/cloudflared/config.yml && chown 65532:65532 /etc/cloudflared/config.yml' \
120		< "$rendered"
121
122	echo "config.yml written. now, from the repo root: make edge"
123	;;
124
125status)
126	echo "--- volume ---"
127	volume_sh "ls -la /etc/cloudflared" || echo "no volume"
128	echo "--- tunnels ---"
129	cfd tunnel list || true
130	echo "--- containers ---"
131	docker ps --filter name=orchard --format '{{.Names}}\t{{.Status}}'
132	;;
133
134down)
135	docker compose down --remove-orphans || true
136	cfd tunnel delete -f "$TUNNEL" || true
137	docker volume rm "$VOLUME" || true
138	# `tunnel route dns` creates records and cloudflared has no delete
139	# counterpart, so the CNAME outlives the tunnel and has to go from the
140	# dashboard. Until it does the hostname answers 530, error 1033.
141	echo
142	echo "NOTE: the CNAME for $HOSTNAMES still exists and must be deleted"
143	echo "in the Cloudflare dashboard. Until then it returns 530."
144	;;
145
146*)
147	awk 'NR>1 && /^#/ { sub(/^#[ ]?/, ""); print; next } NR>1 { exit }' "$SELF"
148	exit 1
149	;;
150esac