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#!/bin/sh
2# Create and inspect the two ntfy accounts the alert path runs on. Run these
3# from the repo root, where each one is a make target:
4#
5# make ntfy create both accounts and their topic access
6# make ntfy-token mint the publishers' token, into their .env files
7# make ntfy-status what exists right now
8# make ntfy-passwd change the reading account's password
9#
10# ntfy is deny-all and there are three accounts: isaac reads every topic from
11# the phone, orchard writes the alert topics from status and logging, and
12# orchard-auth writes the auth topic and nothing else. That third one is
13# separate because the token in status' and logging' .env files can publish to
14# their topics, and sharing it would let a copy of either mint its own login
15# codes. ntfy cannot restrict an account by source address, so Caddy also
16# refuses the publish routes on the public hostname.
17#
18# All ntfy state lives in the orchard-ntfy-data volume, never a bind mount. The
19# Docker CLI here talks to Docker Desktop on the Windows host, whose daemon
20# cannot see this filesystem, so a bind mount silently resolves to an empty
21# directory.
22#
23# Every docker command goes through sudo, because the socket in the webdev
24# container is root:root mode 660 and being in the docker group does not help.
25# On a host where docker needs no sudo: make ntfy SUDO=
26set -e
27
28# Resolved before the cd below, since $0 is relative to the caller's
29# directory and stops resolving the moment we leave it.
30SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
31
32# Run from this script's own directory, whatever the caller's was, since the
33# token step writes into ../sites/*/.env.
34cd "$(dirname "$0")"
35
36SUDO=${SUDO-sudo}
37DOCKER_BIN=$(command -v docker) || { echo "no docker on PATH" >&2; exit 1; }
38# An absolute path, because `command` is a shell builtin and sudo cannot exec
39# one, and because sudo's secure_path is not this shell's PATH.
40docker() { ${SUDO} "$DOCKER_BIN" "$@"; }
41
42CONTAINER=orchard-ntfy
43READER=isaac
44WRITER=orchard
45AUTH_WRITER=orchard-auth
46ALERT_TOPICS="status logging"
47AUTH_TOPIC="auth"
48TOPICS="$ALERT_TOPICS $AUTH_TOPIC"
49
50# ntfy reads auth-file out of the config baked into the image, so every command
51# here runs inside the container rather than against the volume directly.
52running() {
53 docker ps --filter "name=^${CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q .
54}
55
56require_running() {
57 running || {
58 echo "$CONTAINER is not running. from the repo root:" >&2
59 echo "" >&2
60 echo " make up" >&2
61 exit 1
62 }
63}
64
65ntfy() { docker exec -i "$CONTAINER" ntfy "$@"; }
66
67user_exists() {
68 ntfy user list 2>/dev/null | grep -q "^user $1 "
69}
70
71# 32 characters of /dev/urandom in groups of eight. Long enough that nobody is
72# going to type it by hand, grouped so it can be read back off a screen.
73gen_password() {
74 LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom \
75 | head -c 32 \
76 | sed 's/.\{8\}/&-/g; s/-$//'
77 echo
78}
79
80case "${1:-}" in
81up)
82 require_running
83
84 # These passwords exist nowhere else, so they are generated and printed
85 # rather than asked for. An account that already exists keeps the one it
86 # has, because ntfy will not hand a password back and printing a new one
87 # that was never applied is worse than printing nothing.
88 made=""
89 for user in "$READER" "$WRITER" "$AUTH_WRITER"; do
90 if user_exists "$user"; then
91 echo "$user exists already, and its password is untouched"
92 continue
93 fi
94 pw=$(gen_password)
95 # Over stdin, not NTFY_PASSWORD. sudo runs with env_reset and strips
96 # the variable, and forwarding it through sudo would put the password
97 # in the command line for anyone running ps.
98 printf '%s\n%s\n' "$pw" "$pw" | ntfy user add "$user" >/dev/null
99 made="$made$user $pw
100"
101 done
102
103 # isaac reads everything from the phone. The two writers are scoped to
104 # their own topics and cannot read any of them back.
105 for topic in $TOPICS; do
106 ntfy access "$READER" "$topic" read-only
107 done
108 for topic in $ALERT_TOPICS; do
109 ntfy access "$WRITER" "$topic" write-only
110 done
111 ntfy access "$AUTH_WRITER" "$AUTH_TOPIC" write-only
112
113 if [ -n "$made" ]; then
114 echo
115 echo "created, and these are the only copies:"
116 echo
117 printf '%s' "$made" | while read -r u p; do
118 printf ' %-10s %s\n' "$u" "$p"
119 done
120 echo
121 echo "$READER is the account the phone logs in with. put both in 1Password."
122 fi
123
124 echo
125 echo "now mint the publishers' token:"
126 echo
127 echo " make ntfy-token"
128 ;;
129
130token)
131 require_running
132
133 # Written straight into the .env files rather than printed, so a token
134 # never gets pasted between terminals and into a shell history.
135 mint() {
136 ntfy token add -l "$2" "$1" | grep -o 'tk_[A-Za-z0-9]*' | head -1
137 }
138
139 # Two tokens, because the auth topic has its own writer. Sharing the
140 # publishers' token would mean a copy of status' or logging' .env could
141 # publish a login code.
142 token=$(mint "$WRITER" "orchard site publishers")
143 auth_token=$(mint "$AUTH_WRITER" "orchard auth publisher")
144 if [ -z "$token" ] || [ -z "$auth_token" ]; then
145 echo "no token came back; is $CONTAINER healthy?" >&2
146 exit 1
147 fi
148
149 write_var() {
150 site=$1
151 var=$2
152 value=$3
153 if [ ! -f "$site/.env" ]; then
154 echo "no $site/.env yet; create it from .env.example first" >&2
155 exit 1
156 fi
157 # Passed to awk as a variable, so the token never appears on a
158 # command line where `ps` could read it.
159 tmp="$site/.env.tmp"
160 awk -v name="$var" -v tok="$value" \
161 '$0 ~ "^" name "=" { print name "=" tok; found=1; next } { print }
162 END { if (!found) print name "=" tok }' \
163 "$site/.env" > "$tmp"
164 chmod 600 "$tmp"
165 mv "$tmp" "$site/.env"
166 echo "wrote $var into $(basename "$site")/.env"
167 }
168
169 write_var ../sites/status.bythewood.me NTFY_TOKEN "$token"
170 write_var ../sites/logging.bythewood.me NTFY_TOKEN "$token"
171 write_var ../sites/auth.bythewood.me AUTH_NTFY_TOKEN "$auth_token"
172
173 echo
174 echo "now, from the repo root, to hand it to the running sites:"
175 echo
176 echo " make up"
177 ;;
178
179status)
180 require_running
181 echo "--- access ---"
182 ntfy access
183 echo "--- tokens ---"
184 ntfy token list
185 ;;
186
187passwd)
188 require_running
189 ntfy user change-pass "$READER"
190 echo "changed. update the password in the ntfy app on the phone."
191 ;;
192
193*)
194 awk 'NR>1 && /^#/ { sub(/^#[ ]?/, ""); print; next } NR>1 { exit }' "$SELF"
195 exit 1
196 ;;
197esac