frugal-iot-server 2.0.11 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env zsh
2
+ #
3
+ # Put back a snapshot taken by frugal-iot-backup. See BACKUP_PLAN.md.
4
+ #
5
+ # Usage, from the server's own directory, on a machine where the package is already installed:
6
+ # npx --no frugal-iot-restore <snapshot|latest> # say what it would do; changes nothing
7
+ # npx --no frugal-iot-restore <snapshot|latest> state # onto a machine laid out differently
8
+ # npx --no frugal-iot-restore <snapshot|latest> all # onto the layout it was taken from
9
+ # Add "force" as the last word to replace an installation that already has logins of its own.
10
+ #
11
+ # "latest" is the newest snapshot in /var/backups/frugal-iot, from whichever machine it was taken on.
12
+ #
13
+ # "state" is for a machine set up by its own installer - a rebuilt Pi, a new server, the restore
14
+ # drill. It brings back what this installation knows: the database, config.d (secrets included),
15
+ # the broker's password file, ACL file and dynamic-security state - each put wherever THIS broker's
16
+ # configuration says it keeps them. It leaves alone what describes the machine: the broker's own
17
+ # configuration, its TLS certificate, the systemd unit, and config.d/mqtt.yaml and server.yaml.
18
+ #
19
+ # "all" is for the same machine, or a copy of it: everything goes back exactly where it came from,
20
+ # and broker configuration files the snapshot does not have are moved aside, because mosquitto
21
+ # would read them too and a listener declared twice stops it starting.
22
+ #
23
+ # Either way, it first takes a snapshot of what is here now into /var/backups/frugal-iot/pre-restore,
24
+ # stops the server and the broker (every node disconnects for a few seconds), puts the files back,
25
+ # runs frugal-iot-init (so an older database gets this release's migrations) and
26
+ # frugal-iot-rebuild-dynsec, starts both again and runs the diagnostic.
27
+ #
28
+ # data/ is not in a snapshot. Copy it back from wherever it is mirrored.
29
+
30
+ set -euo pipefail
31
+ autoload -Uz is-at-least
32
+
33
+ SCRIPT="${0:A}"
34
+ # Where "latest" looks, and where the snapshot of what was here before goes (in pre-restore/)
35
+ BACKUP_DIR=${FRUGAL_IOT_BACKUPS:-/var/backups/frugal-iot}
36
+
37
+ usage() {
38
+ sed -n '5,10p' "$SCRIPT" | sed 's/^# //' >&2
39
+ exit 2
40
+ }
41
+
42
+ (( $# >= 1 )) || usage
43
+ SNAPARG=$1
44
+ MODE=check
45
+ FORCE=0
46
+ for a in "${@[2,-1]}"; do
47
+ case $a in
48
+ check|state|all) MODE=$a ;;
49
+ force) FORCE=1 ;;
50
+ *) echo "Unexpected argument: $a" >&2; usage ;;
51
+ esac
52
+ done
53
+
54
+ have() { command -v "$1" >/dev/null 2>&1; }
55
+ die() { echo "Error: $*" >&2; exit 1; }
56
+ warn() { echo "Warning: $*" >&2; }
57
+ ownmode() { stat -c '%U:%G %a' "$1" 2>/dev/null || stat -f '%Su:%Sg %Lp' "$1" 2>/dev/null || echo "?:? ?"; }
58
+ as_root() {
59
+ have sudo || die "this needs root, and there is no sudo here - run it as root"
60
+ echo "Re-running as root..." >&2
61
+ exec sudo -- zsh "$SCRIPT" "$@"
62
+ }
63
+
64
+ # ---- Which snapshot ----
65
+ if [[ $SNAPARG == latest ]]; then
66
+ # Newest by the time in its name, not by mtime - a snapshot copied here from another machine has
67
+ # the mtime of the copy. The directory is 700, so this may need root just to look.
68
+ if [[ ! -r "$BACKUP_DIR" ]] && (( EUID != 0 )); then as_root "$@"; fi
69
+ SNAPSHOT=""; NEWEST=""
70
+ for f in "$BACKUP_DIR"/frugal-iot-state-*-<->-<->.tar.gz(N); do
71
+ stamp=${${f:t}%.tar.gz}; stamp=${stamp[-15,-1]}
72
+ [[ "$stamp" > "$NEWEST" ]] && { NEWEST=$stamp; SNAPSHOT=$f; }
73
+ done
74
+ [[ -n "$SNAPSHOT" ]] || die "no snapshots in $BACKUP_DIR"
75
+ else
76
+ SNAPSHOT=${SNAPARG:A}
77
+ [[ -e "$SNAPSHOT" ]] || die "$SNAPARG does not exist"
78
+ fi
79
+
80
+ # ---- Which installation ----
81
+ # As frugal-iot-backup: the current directory if it holds a database, else the frugaliot service's.
82
+ # The package has to be installed already - restoring is not installing.
83
+ if [[ -f ./frugal-iot.db ]]; then
84
+ INSTALL_DIR=$PWD
85
+ else
86
+ INSTALL_DIR=""
87
+ have systemctl && { INSTALL_DIR=$(systemctl show -p WorkingDirectory --value frugaliot 2>/dev/null) || true; }
88
+ [[ -n "$INSTALL_DIR" && -f "$INSTALL_DIR/frugal-iot.db" ]] ||
89
+ die "no frugal-iot.db here - install the package and run 'npx --no frugal-iot-init' first, then run this from that directory"
90
+ fi
91
+ cd "$INSTALL_DIR"
92
+ have sqlite3 || die "sqlite3 command not found - install it with: sudo apt install sqlite3"
93
+
94
+ # ---- Root, before anything secret is unpacked ----
95
+ # Re-running under sudo replaces this process, and an EXIT trap does not fire on exec - so deciding
96
+ # after unpacking would leave a copy of every credential behind in /tmp.
97
+ if (( EUID != 0 )); then
98
+ if [[ $MODE != check ]] && have systemctl; then as_root "$@"; fi
99
+ [[ -r "$SNAPSHOT" ]] || as_root "$@"
100
+ fi
101
+
102
+ # Quoted: unquoted, the substitution is split into words and the %% applies to each
103
+ SERVICE_USER=${"$(ownmode frugal-iot.db)"%%:*}
104
+ SERVICE_GROUP=${${"$(ownmode frugal-iot.db)"#*:}%% *}
105
+
106
+ # ---- Unpack and check it ----
107
+ umask 077
108
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/frugal-iot-restore.XXXXXX")
109
+ trap 'rm -rf "$WORK"' EXIT
110
+ tar -xpzf "$SNAPSHOT" -C "$WORK" 2>/dev/null || die "$SNAPSHOT cannot be read as a snapshot"
111
+ MANIFEST="$WORK/MANIFEST"
112
+ [[ -f "$MANIFEST" ]] || die "$SNAPSHOT has no MANIFEST - it was not made by frugal-iot-backup"
113
+ mf() { awk -F'\t' -v k="$1" '$1 == k {print $2; exit}' "$MANIFEST"; }
114
+ [[ "$(mf format)" == 2 ]] ||
115
+ die "$SNAPSHOT was taken by a test version of frugal-iot-backup that did not record enough to restore it - take a new one"
116
+
117
+ SNAP_HOSTNAME=$(mf host)
118
+ SNAP_INSTALL=$(mf install_dir)
119
+ SNAP_CREATED=$(mf created)
120
+ SNAP_VERSION=$(mf frugal-iot-server)
121
+ HERE_VERSION=$(node -e "console.log(require('${SCRIPT:h:h}/package.json').version)" 2>/dev/null) || true
122
+
123
+ INTEGRITY=$(sqlite3 "$WORK/install/frugal-iot.db" 'PRAGMA integrity_check;' 2>/dev/null) || true
124
+ [[ "$INTEGRITY" == ok ]] || die "the database in the snapshot fails its integrity check: ${INTEGRITY:-unreadable}"
125
+ [[ -f "$WORK/install/config.d/secrets.yaml" ]] ||
126
+ die "the snapshot has no config.d/secrets.yaml - without it no broker password can be derived"
127
+
128
+ # Every file the MANIFEST lists, as parallel arrays
129
+ M_ROLE=(); M_OWNMODE=(); M_FROM=(); M_IN=()
130
+ while IFS=$'\t' read -r kind role om from in; do
131
+ [[ $kind == file ]] || continue
132
+ [[ -e "$WORK/$in" ]] || die "the MANIFEST lists $in but it is not in the snapshot"
133
+ M_ROLE+=("$role"); M_OWNMODE+=("$om"); M_FROM+=("$from"); M_IN+=("$in")
134
+ done < "$MANIFEST"
135
+
136
+ # ---- This machine's broker ----
137
+ source "${SCRIPT:h}/broker-config.zsh"
138
+ broker_conf_read
139
+ (( ${#BROKER_CONF_UNREADABLE} == 0 )) ||
140
+ die "cannot read ${(j:, :)BROKER_CONF_UNREADABLE}, so cannot tell where this broker keeps its files - run with sudo"
141
+ here_setting() { broker_setting "$1"; print -r -- "$BROKER_SETTING"; }
142
+
143
+ # ---- The plan ----
144
+ # P_SRC (in the unpacked snapshot) -> P_DST, owned and moded as P_OWNMODE. SKIPS says why not.
145
+ P_SRC=(); P_DST=(); P_OWNMODE=(); SKIPS=(); ASIDE=()
146
+ plan() { P_SRC+=("$1"); P_DST+=("$2"); P_OWNMODE+=("$3"); }
147
+ # Where a broker file goes keeps the owner and mode the file there has now - this machine's broker
148
+ # already reads it - and only takes the snapshot's when there is nothing there yet.
149
+ broker_ownmode() { if [[ -e "$1" ]]; then ownmode "$1"; else print -r -- "$2"; fi; }
150
+
151
+ for i in {1..${#M_ROLE}}; do
152
+ role=$M_ROLE[$i]; from=$M_FROM[$i]; src="$WORK/$M_IN[$i]"
153
+ case $role in
154
+ install)
155
+ rel=${M_IN[$i]#install/}
156
+ if [[ -d "$src" ]]; then
157
+ for f in "$src"/**/*(.N); do
158
+ frel="$rel/${f#$src/}"
159
+ if [[ $MODE != all && ( $frel == config.d/mqtt.yaml || $frel == config.d/server.yaml ) && -e "$INSTALL_DIR/$frel" ]]; then
160
+ SKIPS+=("$INSTALL_DIR/$frel - describes this machine (its broker address, port, paths); this one's is kept")
161
+ continue
162
+ fi
163
+ plan "$f" "$INSTALL_DIR/$frel" "$SERVICE_USER:$SERVICE_GROUP ${${"$(ownmode "$f")"}##* }"
164
+ done
165
+ else
166
+ plan "$src" "$INSTALL_DIR/$rel" "$SERVICE_USER:$SERVICE_GROUP ${M_OWNMODE[$i]##* }"
167
+ fi
168
+ ;;
169
+ service|config|certfile|keyfile|cafile|capath|dhparamfile|crlfile)
170
+ if [[ $MODE == all ]]; then
171
+ plan "$src" "$from" "$M_OWNMODE[$i]"
172
+ else
173
+ case $role in
174
+ service) SKIPS+=("$from - this machine's own systemd unit is kept: it names this machine's paths and account") ;;
175
+ config) SKIPS+=("$from - this machine's own broker configuration is kept") ;;
176
+ *) SKIPS+=("$from - a TLS file for ${SNAP_HOSTNAME}'s name; this machine keeps its own") ;;
177
+ esac
178
+ fi
179
+ ;;
180
+ password_file|acl_file|plugin_opt_config_file)
181
+ if [[ $MODE == all ]]; then
182
+ dst=$from
183
+ else
184
+ dst=$(here_setting "$role")
185
+ fi
186
+ if [[ -z "$dst" ]]; then
187
+ SKIPS+=("$from - this machine's broker has no $role setting, so nothing would read it")
188
+ else
189
+ plan "$src" "$dst" "$(broker_ownmode "$dst" "$M_OWNMODE[$i]")"
190
+ fi
191
+ ;;
192
+ *)
193
+ SKIPS+=("$from - role '$role' is not one this version knows how to restore")
194
+ ;;
195
+ esac
196
+ done
197
+
198
+ # "all": a broker configuration file here that the snapshot does not have would still be read by
199
+ # mosquitto alongside the restored ones - so move it aside, out of the *.conf pattern it reads
200
+ if [[ $MODE == all ]]; then
201
+ for f in ${BROKER_CONF_FILES[2,-1]}; do
202
+ (( ${M_FROM[(Ie)$f]} )) || ASIDE+=("$f")
203
+ done
204
+ fi
205
+
206
+ # An organization configured here that the snapshot does not have is left, and said so: its logger
207
+ # would keep starting, for an organization the restored database knows nothing about
208
+ EXTRA_ORGS=()
209
+ for f in "$INSTALL_DIR"/config.d/organizations/*.yaml(N); do
210
+ [[ -e "$WORK/install/config.d/organizations/${f:t}" ]] || EXTRA_ORGS+=("${f:t:r}")
211
+ done
212
+
213
+ # ---- Refusals ----
214
+ BLOCKERS=()
215
+ LOGINS_HERE=$(sqlite3 frugal-iot.db "SELECT count(*) FROM users WHERE id > 1;" 2>/dev/null) || LOGINS_HERE=0
216
+ if (( LOGINS_HERE > 0 && ! FORCE )); then
217
+ BLOCKERS+=("this installation already has ${LOGINS_HERE} login(s) of its own, which the restore would replace - add \"force\" if that is what you mean (a snapshot of them is taken first)")
218
+ fi
219
+ if [[ $MODE == all && "$SNAP_INSTALL" != "$INSTALL_DIR" ]]; then
220
+ BLOCKERS+=("the snapshot was installed at $SNAP_INSTALL and this one is at $INSTALL_DIR - \"all\" puts everything back exactly where it was, so use \"state\" here")
221
+ fi
222
+ if [[ -n "$HERE_VERSION" && -n "$SNAP_VERSION" && "$SNAP_VERSION" != "not installed here" ]] && ! is-at-least "$SNAP_VERSION" "$HERE_VERSION"; then
223
+ BLOCKERS+=("the snapshot is from frugal-iot-server $SNAP_VERSION and this is $HERE_VERSION - its database may have changes this version does not know. Upgrade here first")
224
+ fi
225
+
226
+ # ---- Say what it would do ----
227
+ echo "Snapshot: $SNAPSHOT"
228
+ echo " taken: $SNAP_CREATED on $SNAP_HOSTNAME, frugal-iot-server $SNAP_VERSION, from $SNAP_INSTALL"
229
+ echo "Restoring to: $INSTALL_DIR on $(hostname 2>/dev/null) (runs as $SERVICE_USER), frugal-iot-server ${HERE_VERSION:-unknown}"
230
+ echo "Mode: $MODE"
231
+ echo
232
+ echo "Would put back:"
233
+ for i in {1..${#P_DST}}; do
234
+ if [[ ! -e "$P_DST[$i]" ]]; then st="new"
235
+ elif cmp -s "$P_SRC[$i]" "$P_DST[$i]" 2>/dev/null; then st="unchanged"
236
+ elif [[ -r "$P_DST[$i]" ]]; then st="replaces what is there"
237
+ # check runs as the service user, which cannot read the broker's files - ask sudo before
238
+ # reporting an identical file as one that will be replaced
239
+ elif (( EUID != 0 )) && have sudo && sudo -n cmp -s "$P_SRC[$i]" "$P_DST[$i]" 2>/dev/null; then st="unchanged"
240
+ elif (( EUID != 0 )) && have sudo && sudo -n test -r "$P_DST[$i]" 2>/dev/null; then st="replaces what is there"
241
+ else st="replaces (needs root to compare)"
242
+ fi
243
+ printf ' %-60s %-32s %s\n' "$P_DST[$i]" "$st" "$P_OWNMODE[$i]"
244
+ done
245
+ if (( ${#ASIDE} )); then
246
+ echo
247
+ echo "Would move aside, as <name>.pre-restore, because the snapshot's broker does not have them:"
248
+ for f in $ASIDE; do echo " $f"; done
249
+ fi
250
+ if (( ${#SKIPS} )); then
251
+ echo
252
+ echo "Would leave alone:"
253
+ for s in $SKIPS; do echo " $s"; done
254
+ fi
255
+ (( ${#EXTRA_ORGS} )) && { echo; echo "Organizations configured here and not in the snapshot, left as they are: ${EXTRA_ORGS}"; }
256
+ echo
257
+ echo "Not in a snapshot: data/ - copy it back from its mirror, e.g. rsync -a <mirror>/data/ ${INSTALL_DIR}/data/"
258
+
259
+ if (( ${#BLOCKERS} )); then
260
+ echo
261
+ for b in $BLOCKERS; do echo "CANNOT: $b"; done
262
+ [[ $MODE == check ]] && exit 1
263
+ exit 1
264
+ fi
265
+ if [[ $MODE == check ]]; then
266
+ echo
267
+ echo "Nothing changed. To do it: npx --no frugal-iot-restore $SNAPARG state (or all - see the top of this script)"
268
+ exit 0
269
+ fi
270
+
271
+ # ---- Do it ----
272
+ echo
273
+ # Run something as the account the server runs as, in its directory - init and rebuild-dynsec write
274
+ # files there, which must not end up owned by root
275
+ run_as() {
276
+ if [[ "$(id -un)" == "$SERVICE_USER" ]]; then
277
+ ( cd "$INSTALL_DIR" && "$@" )
278
+ else
279
+ sudo -u "$SERVICE_USER" -H -- zsh -c 'cd "$1" && shift && "$@"' _ "$INSTALL_DIR" "$@"
280
+ fi
281
+ }
282
+ unit_exists() { have systemctl && systemctl cat "$1" >/dev/null 2>&1; }
283
+
284
+ echo "1. A snapshot of what is here now, in case this is the wrong thing to have done"
285
+ PRE_RESTORE="$BACKUP_DIR/pre-restore"
286
+ if zsh "${SCRIPT:h}/backup.zsh" "$PRE_RESTORE"; then
287
+ # The one just written, so the way back can be named exactly rather than as "the newest"
288
+ PRE_RESTORE=( $PRE_RESTORE/frugal-iot-state-*.tar.gz(Nom[1]) )
289
+ else
290
+ (( FORCE )) || die "could not take it - add \"force\" to restore without a copy of what is here"
291
+ warn "carrying on without it, because of \"force\""
292
+ PRE_RESTORE=""
293
+ fi
294
+
295
+ echo "2. Stopping the server and the broker"
296
+ unit_exists frugaliot && systemctl stop frugaliot
297
+ # The plugin keeps its state in memory and rewrites its file on every change, so the broker must be
298
+ # down while that file is replaced - and it only reads it at startup anyway
299
+ unit_exists mosquitto && systemctl stop mosquitto
300
+
301
+ echo "3. Putting the files back"
302
+ for f in $ASIDE; do
303
+ mv -- "$f" "$f.pre-restore"
304
+ echo " moved aside $f"
305
+ done
306
+ for i in {1..${#P_DST}}; do
307
+ dst=$P_DST[$i]; own=${P_OWNMODE[$i]% *}; mode=${P_OWNMODE[$i]##* }
308
+ mkdir -p "${dst:h}"
309
+ if [[ -d "$P_SRC[$i]" ]]; then # capath
310
+ rm -rf -- "$dst"; cp -Rp -- "$P_SRC[$i]" "$dst"
311
+ else
312
+ cp -p -- "$P_SRC[$i]" "$dst.restoring"
313
+ mv -f -- "$dst.restoring" "$dst"
314
+ fi
315
+ chmod "$mode" "$dst" 2>/dev/null || warn "could not set mode $mode on $dst"
316
+ # The snapshot's owner may not exist here (production's "mitra" on a Pi) - then whoever owns the
317
+ # installation, which is the account that has to read it
318
+ if [[ "${"$(ownmode "$dst")"% *}" != "$own" ]]; then
319
+ chown "$own" "$dst" 2>/dev/null || chown "$SERVICE_USER:$SERVICE_GROUP" "$dst" 2>/dev/null ||
320
+ warn "could not set the owner of $dst to $own"
321
+ fi
322
+ echo " $dst"
323
+ done
324
+ [[ $MODE == all ]] && have systemctl && systemctl daemon-reload
325
+
326
+ echo "4. Starting the broker"
327
+ if unit_exists mosquitto; then
328
+ systemctl start mosquitto || true
329
+ for n in {1..10}; do
330
+ [[ "$(systemctl is-active mosquitto 2>/dev/null)" == active ]] && break
331
+ sleep 1
332
+ done
333
+ if [[ "$(systemctl is-active mosquitto 2>/dev/null)" != active ]]; then
334
+ echo "The broker did not start. The server has been left stopped." >&2
335
+ echo "See why: journalctl -u mosquitto -n 30 --no-pager (and its own log, named in its configuration)" >&2
336
+ [[ -n "$PRE_RESTORE" ]] && echo "Put it back: npx --no frugal-iot-restore $PRE_RESTORE all force" >&2
337
+ exit 1
338
+ fi
339
+ fi
340
+
341
+ echo "5. Bringing the database up to this release (frugal-iot-init)"
342
+ run_as zsh "${SCRIPT:h}/init.zsh" >/dev/null || warn "frugal-iot-init reported a problem - run it by hand to see it"
343
+
344
+ broker_conf_read
345
+ broker_setting plugin
346
+ if [[ -n "$BROKER_SETTING" ]]; then
347
+ echo "6. Making the broker's accounts match the database (frugal-iot-rebuild-dynsec)"
348
+ run_as node "${SCRIPT:h}/rebuild-dynsec.js" || warn "frugal-iot-rebuild-dynsec failed - run it by hand to see why"
349
+ else
350
+ echo "6. (no dynamic-security plugin on this broker - accounts come from its password file)"
351
+ fi
352
+
353
+ echo "7. Starting the server"
354
+ unit_exists frugaliot && systemctl start frugaliot
355
+
356
+ echo "8. Checking"
357
+ if [[ -n "$BROKER_SETTING" ]]; then
358
+ run_as node "${SCRIPT:h}/rebuild-dynsec.js" check || warn "the broker's accounts do not match the database - see above"
359
+ fi
360
+ if run_as zsh "${SCRIPT:h}/diagnostic.zsh" quiet; then
361
+ echo " the diagnostic finds nothing wrong"
362
+ else
363
+ warn "the diagnostic found something - run 'npx --no frugal-iot-diagnostic' for the whole report"
364
+ fi
365
+
366
+ echo
367
+ echo "Restored $SNAPSHOT."
368
+ if [[ -n "$PRE_RESTORE" ]]; then
369
+ echo "What was here before is in $PRE_RESTORE - to go back: npx --no frugal-iot-restore $PRE_RESTORE all force"
370
+ else
371
+ echo "Nothing was kept of what was here before."
372
+ fi
373
+ echo "data/ is not in a snapshot - copy it back from its mirror if this machine lost it."
374
+ (( ${#EXTRA_ORGS} )) && echo "Still configured here and not in the snapshot: ${EXTRA_ORGS} - remove config.d/organizations/<org>.yaml if it should not be."
375
+ exit 0