frugal-iot-server 0.3.7 → 0.3.8

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,646 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Install a complete Frugal IoT server - broker, server, service - on a freshly flashed Raspberry Pi.
4
+ #
5
+ # This does automatically what INSTALLATION.md walks through by hand, steps 3 to 8. Read that if you
6
+ # want to know why any of it is being done; this only says what it is doing as it goes.
7
+ #
8
+ # curl -fsSLO https://raw.githubusercontent.com/mitra42/frugal-iot-server/main/scripts/install-pi.sh
9
+ # bash install-pi.sh --org myfarm --name "My Farm" --email you@example.com --phone +61123456789
10
+ #
11
+ # Written in bash on purpose: a fresh Raspberry Pi OS Lite image has no zsh, which the server's own
12
+ # setup commands need, so this cannot be a zsh script - it is what installs zsh.
13
+ #
14
+ # Anything not given on the command line is asked for, or made up where that is reasonable
15
+ # (passwords). With --yes it never asks and invents whatever is missing, for an unattended run.
16
+ #
17
+ # Safe to run again. Every step checks whether it has already been done, so a run that stopped
18
+ # half way - or was interrupted by the board running out of memory, which a Pi Zero W will do -
19
+ # carries on from where it got to rather than starting over. That matters most for the one slow
20
+ # step: compiling sqlite3 on a Zero W takes about 40 minutes and is not repeated once it is done.
21
+ #
22
+ # What it does NOT do:
23
+ # * flash the SD card, or set up wifi - INSTALLATION.md steps 1 and 2, before you can ssh in
24
+ # * point your sensor nodes at this server - that means flashing the nodes, which happens on your
25
+ # workstation, not here. It prints the settings to give them.
26
+ # * HTTPS, which over-the-air firmware updates to ESP32 need - INSTALLATION.md step 10
27
+ # * reboot. A reboot is worth doing afterwards if the upgrade brought a new kernel.
28
+
29
+ set -Eeuo pipefail
30
+
31
+ # ---------------------------------------------------------------- settings and arguments
32
+
33
+ ORG_ID=""; ORG_NAME=""; ORG_EMAIL=""; ORG_PHONE=""
34
+ BROKER_PW=""; SUPER_PW=""; LOGIN_PW=""; BROKER_HOST=""
35
+ INSTALL_DIR=""; RUN_USER=""; ASSUME_YES=0; DRY_RUN=0; RANDOM_PASSWORDS=0
36
+
37
+ usage() {
38
+ cat <<USAGE
39
+ Usage: bash install-pi.sh [options]
40
+
41
+ --org <id> organization id, 1-10 lower-case letters or digits. Becomes the first part
42
+ of every MQTT topic, so it must match what your nodes publish to.
43
+ --name "<name>" organization display name
44
+ --email <address> contact address for the organization
45
+ --phone <number> contact phone, "+" and digits only
46
+ --broker-password the machine credential shared by the server, nodes and dashboards.
47
+ --superuser-password password for this server's administrator login.
48
+ --login-password web login password for the organization's own account, which is a different
49
+ thing from the broker credential above and should not be the same string.
50
+ Any of these three not given is asked for, and generated if you just press
51
+ Enter, or if there is no terminal to ask at.
52
+ --broker-host <host> what the browser and the nodes should call this Pi.
53
+ Default: this Pi's hostname with ".local". Use an IP address if you will
54
+ view the dashboard on Android, which cannot resolve ".local" names.
55
+ --dir <path> where to install. Default: ~/frugal-iot of the user running this.
56
+ --user <name> account to own and run the server. Default: whoever invoked this.
57
+ --yes never ask anything; invent whatever was not given.
58
+ --random-passwords generate the passwords instead of asking, even at a terminal.
59
+ --dry-run work out what would be done, check the arguments, and stop before
60
+ changing anything. Also the way to see the generated passwords first.
61
+ --help
62
+ USAGE
63
+ exit "${1:-0}"
64
+ }
65
+
66
+ while (( $# )); do
67
+ case "$1" in
68
+ --org) ORG_ID="${2:-}"; shift 2 ;;
69
+ --name) ORG_NAME="${2:-}"; shift 2 ;;
70
+ --email) ORG_EMAIL="${2:-}"; shift 2 ;;
71
+ --phone) ORG_PHONE="${2:-}"; shift 2 ;;
72
+ --broker-password) BROKER_PW="${2:-}"; shift 2 ;;
73
+ --superuser-password) SUPER_PW="${2:-}"; shift 2 ;;
74
+ --login-password) LOGIN_PW="${2:-}"; shift 2 ;;
75
+ --broker-host) BROKER_HOST="${2:-}"; shift 2 ;;
76
+ --dir) INSTALL_DIR="${2:-}"; shift 2 ;;
77
+ --user) RUN_USER="${2:-}"; shift 2 ;;
78
+ --yes|-y) ASSUME_YES=1; shift ;;
79
+ --dry-run) DRY_RUN=1; shift ;;
80
+ --random-passwords) RANDOM_PASSWORDS=1; shift ;;
81
+ --help|-h) usage 0 ;;
82
+ *) echo "Unknown option: $1" >&2; usage 1 ;;
83
+ esac
84
+ done
85
+
86
+ # ---------------------------------------------------------------- who this runs as
87
+ # npm must not run as root: it would leave root-owned files in the install directory, and the
88
+ # service runs as an ordinary user, so the server would then be unable to write its own database.
89
+ # Being invoked with sudo is fine and expected - the user-level parts are handed back to the
90
+ # account that called it.
91
+
92
+ if [[ -z "$RUN_USER" ]]; then
93
+ if [[ ${EUID} -eq 0 ]]; then
94
+ RUN_USER="${SUDO_USER:-}"
95
+ if [[ -z "$RUN_USER" ]]; then
96
+ echo "Running as root with no SUDO_USER to hand back to." >&2
97
+ echo "Either run this as your ordinary account (it will use sudo where it needs to)," >&2
98
+ echo "or say which account should own the server: --user pi" >&2
99
+ exit 1
100
+ fi
101
+ else
102
+ RUN_USER="$(id -un)"
103
+ fi
104
+ fi
105
+ id "$RUN_USER" >/dev/null 2>&1 || { echo "No such user: $RUN_USER" >&2; exit 1; }
106
+ if command -v getent >/dev/null 2>&1; then
107
+ RUN_HOME="$(getent passwd "$RUN_USER" | cut -d: -f6)"
108
+ else
109
+ RUN_HOME="$(eval echo "~${RUN_USER}")" # no getent outside linux; only reached on the wrong OS
110
+ fi
111
+ [[ -n "$RUN_HOME" ]] || { echo "Could not find the home directory of ${RUN_USER}" >&2; exit 1; }
112
+ [[ -n "$INSTALL_DIR" ]] || INSTALL_DIR="${RUN_HOME}/frugal-iot"
113
+
114
+ # Run a command as the owning account, or straight through if that is already us
115
+ as_user() {
116
+ if [[ "$(id -un)" == "$RUN_USER" ]]; then
117
+ "$@"
118
+ else
119
+ sudo -u "$RUN_USER" -H "$@"
120
+ fi
121
+ }
122
+ # Same, but for something that has to be a shell line (cd, pipes, redirection)
123
+ as_user_sh() {
124
+ if [[ "$(id -un)" == "$RUN_USER" ]]; then
125
+ bash -c "$1"
126
+ else
127
+ sudo -u "$RUN_USER" -H bash -c "$1"
128
+ fi
129
+ }
130
+ # sudo, or nothing if we are already root
131
+ sudo_() { if [[ ${EUID} -eq 0 ]]; then "$@"; else sudo "$@"; fi; }
132
+
133
+ # ---------------------------------------------------------------- logging
134
+
135
+ if (( DRY_RUN )); then
136
+ LOG=/dev/null # --dry-run changes nothing, and that includes not leaving a log behind
137
+ else
138
+ LOG="${RUN_HOME}/frugal-iot-install-$(date +%Y%m%d-%H%M%S).log"
139
+ touch "$LOG"; chmod 600 "$LOG"; chown "$RUN_USER" "$LOG" 2>/dev/null || true
140
+ # Everything from here on goes to the terminal and to the log. The log holds the passwords, which
141
+ # is why it is readable only by its owner.
142
+ exec > >(tee -a "$LOG") 2>&1
143
+ fi
144
+
145
+ STEP_N=0
146
+ step() { STEP_N=$((STEP_N+1)); echo; echo "=== ${STEP_N}. $* ==="; }
147
+ info() { echo " $*"; }
148
+ ok() { echo " ok: $*"; }
149
+ skip() { echo " already done: $*"; }
150
+ warn() { echo " WARNING: $*"; }
151
+
152
+ on_error() {
153
+ local line=$1
154
+ echo
155
+ echo "=== Stopped at line ${line} ==="
156
+ echo "Nothing after this point has been done."
157
+ if [[ "$LOG" != /dev/null ]]; then
158
+ echo "The log of this run is:"
159
+ echo " ${LOG}"
160
+ fi
161
+ echo
162
+ echo "Run it again once the cause is dealt with - the steps that succeeded are not repeated."
163
+ if [[ -x "${INSTALL_DIR}/node_modules/.bin/frugal-iot-diagnostic" ]]; then
164
+ echo "For what state the installation is in now:"
165
+ echo " cd ${INSTALL_DIR} && npx --no frugal-iot-diagnostic"
166
+ fi
167
+ exit 1
168
+ }
169
+ trap 'on_error $LINENO' ERR
170
+
171
+ randpw() { ( set +o pipefail; LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 ); }
172
+
173
+ REBOOT_REASONS=()
174
+
175
+ # Size and modification time of every kernel and initrd image, so that the upgrade replacing one can
176
+ # be noticed. /boot/firmware is where Raspberry Pi OS keeps them from Debian 12 on, /boot before that.
177
+ boot_fingerprint() {
178
+ local f
179
+ for f in /boot/*.img /boot/vmlinuz* /boot/initrd* /boot/firmware/*.img /boot/firmware/vmlinuz* /boot/firmware/initrd*; do
180
+ [[ -e "$f" ]] || continue
181
+ printf '%s %s\n' "$f" "$(stat -c '%Y:%s' "$f" 2>/dev/null || echo '?')"
182
+ done | sort
183
+ }
184
+
185
+ # Which passwords were made up rather than given, so the summary can print those and leave the ones
186
+ # you already know out of the log
187
+ GENERATED=()
188
+
189
+ ask_password() { # ask_password <variable name> <what it is for>
190
+ local var=$1 what=$2
191
+ if [[ -n "${!var}" ]]; then
192
+ return 0 # given on the command line
193
+ fi
194
+ if (( RANDOM_PASSWORDS )) || (( ASSUME_YES )) || [[ ! -t 0 ]]; then
195
+ printf -v "$var" '%s' "$(randpw)"
196
+ GENERATED+=("$var")
197
+ info "generated a password for ${what}$( [[ -t 0 ]] || echo ' (no terminal to ask at)' )"
198
+ return 0
199
+ fi
200
+ # Prompt and reply both go straight to the terminal rather than through the log: nothing typed
201
+ # here is echoed, and nothing typed here is written to the log either. Going via stdout would also
202
+ # mean the prompt queued behind tee. Empty means "make one up", which is the quickest answer for
203
+ # the broker credential - a machine password nobody wants to type twice.
204
+ local first second
205
+ while true; do
206
+ printf ' Password for %s\n (or press Enter to have one generated): ' "$what" > /dev/tty
207
+ read -rs first < /dev/tty; printf '\n' > /dev/tty
208
+ if [[ -z "$first" ]]; then
209
+ printf -v "$var" '%s' "$(randpw)"
210
+ GENERATED+=("$var")
211
+ info "generated a password for ${what}"
212
+ return 0
213
+ fi
214
+ printf ' Again, to be sure: ' > /dev/tty
215
+ read -rs second < /dev/tty; printf '\n' > /dev/tty
216
+ if [[ "$first" == "$second" ]]; then
217
+ printf -v "$var" '%s' "$first"
218
+ info "took the password you typed for ${what}"
219
+ return 0
220
+ fi
221
+ printf ' Those did not match - try again.\n' > /dev/tty
222
+ done
223
+ }
224
+
225
+ ask() { # ask <variable name> <prompt> [generated]
226
+ local var=$1 prompt=$2 generate=${3:-}
227
+ local current="${!var}"
228
+ [[ -n "$current" ]] && return 0
229
+ if [[ -n "$generate" ]]; then
230
+ printf -v "$var" '%s' "$(randpw)"
231
+ return 0
232
+ fi
233
+ if (( ASSUME_YES )) || [[ ! -t 0 ]]; then
234
+ echo "Nothing given for '${prompt}', and there is no terminal to ask at (or --yes was given)." >&2
235
+ echo "Pass it on the command line - see --help." >&2
236
+ return 1
237
+ fi
238
+ local reply
239
+ read -r -p " ${prompt}: " reply
240
+ printf -v "$var" '%s' "$reply"
241
+ [[ -n "${!var}" ]] || { echo "That cannot be empty" >&2; return 1; }
242
+ }
243
+
244
+ echo "Frugal IoT server installation"
245
+ (( DRY_RUN )) && echo "Dry run - nothing will be changed" || echo "Log: ${LOG}"
246
+
247
+ # Everything below assumes a Debian with systemd - Raspberry Pi OS, or Debian/Ubuntu on anything
248
+ # else. Say so now rather than failing obscurely four steps in.
249
+ MISSING_PLATFORM=()
250
+ [[ "$(uname -s)" == "Linux" ]] || MISSING_PLATFORM+=("this is $(uname -s), not Linux")
251
+ command -v apt-get >/dev/null 2>&1 || MISSING_PLATFORM+=("no apt-get")
252
+ command -v systemctl >/dev/null 2>&1 || MISSING_PLATFORM+=("no systemctl")
253
+ if (( ${#MISSING_PLATFORM[@]} )); then
254
+ if (( DRY_RUN )); then
255
+ warn "not a Debian with systemd (${MISSING_PLATFORM[*]}) - checking the arguments anyway"
256
+ else
257
+ echo "This installs onto a Debian system with systemd - Raspberry Pi OS, or Debian or Ubuntu." >&2
258
+ printf ' %s\n' "${MISSING_PLATFORM[@]}" >&2
259
+ exit 1
260
+ fi
261
+ fi
262
+
263
+ # ---------------------------------------------------------------- what we are installing
264
+
265
+ step "Working out what to install"
266
+
267
+ if [[ -r /etc/os-release ]]; then
268
+ info "os: $(sed -n 's/^PRETTY_NAME="\(.*\)"$/\1/p' /etc/os-release)"
269
+ fi
270
+ [[ -r /proc/device-tree/model ]] && info "board: $(tr -d '\0' < /proc/device-tree/model)"
271
+ ARCH="$(uname -m)"
272
+ MEM_KB="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)"
273
+ info "arch: ${ARCH}, memory: $((MEM_KB / 1024)) MB"
274
+
275
+ # A 32-bit ARMv6 board (Pi Zero W, Pi 1) has no ready-made sqlite3 binary, so it has to compile it,
276
+ # which needs a compiler and more memory than the board has.
277
+ NEEDS_BUILD_TOOLS=0
278
+ NPM_SLOW_FLAGS=()
279
+ case "$ARCH" in
280
+ armv6l|armv7l) NEEDS_BUILD_TOOLS=1; NPM_SLOW_FLAGS=(--maxsockets 1 --no-audit --no-fund) ;;
281
+ esac
282
+ if (( MEM_KB > 0 && MEM_KB < 700000 )); then NEEDS_SWAP=1; else NEEDS_SWAP=0; fi
283
+ (( NEEDS_BUILD_TOOLS )) && info "32-bit ARM: sqlite3 will be compiled, which is slow (allow 40 minutes on a Zero W)"
284
+ (( NEEDS_SWAP )) && info "under 700 MB of memory: a swap file will be added so the install can finish"
285
+
286
+ ask ORG_ID "Organization id (1-10 lower-case letters or digits, e.g. myfarm)" || exit 1
287
+ [[ "$ORG_ID" =~ ^[a-z0-9]{1,10}$ ]] || { echo "Organization id must be 1-10 lower-case letters or digits: '${ORG_ID}'" >&2; exit 1; }
288
+ ask ORG_NAME "Organization display name (e.g. My Farm)" || exit 1
289
+ ask ORG_EMAIL "Contact email" || exit 1
290
+ ask ORG_PHONE "Contact phone ('+' and digits only)" || exit 1
291
+ [[ "$ORG_PHONE" =~ ^[+]?[0-9]+$ ]] || { echo "Phone must be '+' and digits only: '${ORG_PHONE}'" >&2; exit 1; }
292
+ # Asked for in the order they matter. Each may be typed, passed as an option, or generated.
293
+ ask_password SUPER_PW "the superuser login (this server's administrator)"
294
+ ask_password LOGIN_PW "the ${ORG_ID} web login"
295
+ ask_password BROKER_PW "the ${ORG_ID} broker credential, shared by the nodes and dashboards"
296
+ [[ -n "$BROKER_HOST" ]] || BROKER_HOST="$(hostname).local"
297
+
298
+ info "install into: ${INSTALL_DIR} (owned by ${RUN_USER})"
299
+ info "organization: ${ORG_ID} (${ORG_NAME})"
300
+ info "broker URL: ws://${BROKER_HOST}:9012"
301
+
302
+ if (( DRY_RUN )); then
303
+ cat <<PLAN
304
+
305
+ Nothing has been changed. With the same arguments and without --dry-run, this would:
306
+ * apt update, full-upgrade, and install: nodejs npm sqlite3 zsh curl$( (( NEEDS_BUILD_TOOLS )) && echo " build-essential python3-dev python3-setuptools" )
307
+ * cap the systemd journal at 16M and set vm.swappiness=1
308
+ $( (( NEEDS_SWAP )) && echo " * add a 2G swap file, because this board has under 700 MB of memory" )
309
+ * npm install frugal-iot-server into ${INSTALL_DIR} as ${RUN_USER}$( (( NEEDS_BUILD_TOOLS )) && echo ", compiling sqlite3 (slow)" )
310
+ * set the broker to ws://${BROKER_HOST}:9012
311
+ * install and configure mosquitto, with listeners on 1883 and 9012
312
+ * create the superuser login and the ${ORG_ID} organization, and test the broker
313
+ * install and start the frugaliot service, running as ${RUN_USER}
314
+
315
+ Passwords it would use. A generated one is different on every run, so pass it or type it if you
316
+ want this exact set. All of them are shown here because a dry run writes no log; a real run prints
317
+ only the generated ones, so the ones you chose stay out of the log:
318
+ $(printf ' %-22s %s\n' "superuser login" "${SUPER_PW}" "${ORG_ID} web login" "${LOGIN_PW}" "${ORG_ID} broker" "${BROKER_PW}")
319
+ PLAN
320
+ exit 0
321
+ fi
322
+
323
+ # ---------------------------------------------------------------- the system
324
+
325
+ step "Updating the operating system"
326
+ sudo_ apt-get update -qq
327
+ # What is about to be upgraded, asked for before doing it: "-s" simulates, and each package it would
328
+ # install shows up as an "Inst" line. Doing it this way rather than reading the real output means the
329
+ # upgrade itself can stay quiet.
330
+ UPGRADE_PLAN="$(sudo_ apt-get -s full-upgrade 2>/dev/null | awk '/^Inst /{print $2}' | sort -u || true)"
331
+ BOOT_BEFORE="$(boot_fingerprint)"
332
+ # No reboot here even if this brings a new kernel, so that the run can continue unattended. Whether
333
+ # one is needed is worked out below and said plainly in the summary.
334
+ sudo_ env DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y -qq
335
+ if [[ -n "$UPGRADE_PLAN" ]]; then
336
+ info "upgraded: $(echo "$UPGRADE_PLAN" | wc -l | tr -d ' ') package(s)"
337
+ else
338
+ info "nothing needed upgrading"
339
+ fi
340
+ ok "system up to date"
341
+
342
+ # The running kernel is whatever was loaded at boot; replacing the file on disk does not change it.
343
+ # Three ways to notice, because no single one is reliable on every image:
344
+ # * a kernel or firmware package in the upgrade list
345
+ # * the boot images on disk having changed underneath us
346
+ # * /var/run/reboot-required, which only appears if update-notifier-common is installed - it is
347
+ # not on a Lite image, which is why the other two matter
348
+ if [[ -n "$UPGRADE_PLAN" ]]; then
349
+ KERNEL_PKGS="$(echo "$UPGRADE_PLAN" | grep -E '^(linux-image|linux-headers|linux-firmware|raspberrypi-kernel|raspberrypi-bootloader|raspi-firmware)' || true)"
350
+ [[ -n "$KERNEL_PKGS" ]] && REBOOT_REASONS+=("a new kernel or boot firmware was installed: $(echo "$KERNEL_PKGS" | tr '\n' ' ')")
351
+ CORE_PKGS="$(echo "$UPGRADE_PLAN" | grep -E '^(libc6|libssl[0-9]|systemd)$' || true)"
352
+ [[ -n "$CORE_PKGS" ]] && REBOOT_REASONS+=("core libraries were replaced, and running programs still have the old ones in memory: $(echo "$CORE_PKGS" | tr '\n' ' ')")
353
+ fi
354
+ if [[ "$(boot_fingerprint)" != "$BOOT_BEFORE" ]]; then
355
+ REBOOT_REASONS+=("the kernel images in /boot changed, so the kernel running now is not the one on disk")
356
+ fi
357
+ [[ -f /var/run/reboot-required ]] && REBOOT_REASONS+=("the system itself asked for one (/var/run/reboot-required)")
358
+ if (( ${#REBOOT_REASONS[@]} )); then
359
+ warn "a reboot will be needed when this finishes - the summary says so again"
360
+ else
361
+ ok "no reboot needed: the kernel running now is the one on disk"
362
+ fi
363
+
364
+ step "Installing the packages the server needs"
365
+ PACKAGES=(nodejs npm sqlite3 zsh curl)
366
+ (( NEEDS_BUILD_TOOLS )) && PACKAGES+=(build-essential python3-dev python3-setuptools)
367
+ sudo_ env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${PACKAGES[@]}"
368
+ NODE_VERSION="$(node -v)"
369
+ info "node ${NODE_VERSION}, npm $(npm -v), sqlite3 $(sqlite3 --version | awk '{print $1}')"
370
+ # The server needs node 18 or later
371
+ NODE_MAJOR="$(echo "${NODE_VERSION#v}" | cut -d. -f1)"
372
+ if (( NODE_MAJOR < 18 )); then
373
+ echo "node ${NODE_VERSION} is too old - the server needs 18 or later." >&2
374
+ echo "Install a current version from https://github.com/nodesource/distributions and run this again." >&2
375
+ exit 1
376
+ fi
377
+ ok "packages installed"
378
+
379
+ step "Settings that make the SD card last longer"
380
+ # The journal would otherwise be allowed to grow to a tenth of the card, and every line in it is a
381
+ # write; swapping to an SD card wears it out fastest of all.
382
+ if [[ -f /etc/systemd/journald.conf.d/frugal-iot.conf ]]; then
383
+ skip "journal size already capped"
384
+ else
385
+ sudo_ mkdir -p /etc/systemd/journald.conf.d
386
+ printf '[Journal]\nSystemMaxUse=16M\nSystemMaxFileSize=4M\n' | sudo_ tee /etc/systemd/journald.conf.d/frugal-iot.conf >/dev/null
387
+ sudo_ systemctl restart systemd-journald
388
+ ok "journal capped at 16M"
389
+ fi
390
+ if [[ -f /etc/sysctl.d/99-frugal-iot-swappiness.conf ]]; then
391
+ skip "swappiness already set"
392
+ else
393
+ echo 'vm.swappiness=1' | sudo_ tee /etc/sysctl.d/99-frugal-iot-swappiness.conf >/dev/null
394
+ sudo_ sysctl -q -w vm.swappiness=1
395
+ ok "swappiness set to 1 (swap only as a last resort)"
396
+ fi
397
+
398
+ if (( NEEDS_SWAP )); then
399
+ step "Adding swap, so the install can finish on a small board"
400
+ if [[ -f /swapfile ]]; then
401
+ skip "/swapfile exists"
402
+ swapon --show=NAME --noheadings 2>/dev/null | grep -qx /swapfile || sudo_ swapon /swapfile
403
+ else
404
+ # zram alone is not enough here: compressed RAM does not help when the working set is genuinely
405
+ # large, and without this the board stops answering ssh and has to have its power pulled.
406
+ sudo_ fallocate -l 2G /swapfile
407
+ sudo_ chmod 600 /swapfile
408
+ sudo_ mkswap -q /swapfile
409
+ sudo_ swapon /swapfile
410
+ grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo_ tee -a /etc/fstab >/dev/null
411
+ ok "2G swap file added and enabled"
412
+ fi
413
+ info "swap now: $(free -h | awk '/^Swap:/ {print $3 " used of " $2}')"
414
+ fi
415
+
416
+ # ---------------------------------------------------------------- the server
417
+
418
+ step "Installing the Frugal IoT server"
419
+ as_user mkdir -p "$INSTALL_DIR"
420
+ if [[ -d "${INSTALL_DIR}/node_modules/frugal-iot-server" ]]; then
421
+ skip "already installed in ${INSTALL_DIR}"
422
+ else
423
+ if (( NEEDS_BUILD_TOOLS )); then
424
+ info "this is the slow part - sqlite3 is compiled here. Leave it running."
425
+ fi
426
+ as_user_sh "cd '${INSTALL_DIR}' && npm install ${NPM_SLOW_FLAGS[*]:-} frugal-iot-server"
427
+ ok "installed"
428
+ fi
429
+ info "server $(as_user_sh "node -e \"console.log(require('${INSTALL_DIR}/node_modules/frugal-iot-server/package.json').version)\"")"
430
+
431
+ step "Checking the compiled part of sqlite3 actually built"
432
+ # npm can report success while leaving this out, and nothing else looks wrong when it does - the
433
+ # server simply refuses to start later.
434
+ if as_user_sh "cd '${INSTALL_DIR}' && node -e \"require('sqlite3')\"" >/dev/null 2>&1; then
435
+ ok "sqlite3 loads"
436
+ else
437
+ warn "sqlite3 did not build on the first attempt - rebuilding it"
438
+ (( NEEDS_BUILD_TOOLS )) && info "expect around 40 minutes on a Pi Zero W; it is working as long as output keeps appearing"
439
+ as_user_sh "cd '${INSTALL_DIR}' && npm rebuild sqlite3 --foreground-scripts"
440
+ as_user_sh "cd '${INSTALL_DIR}' && node -e \"require('sqlite3')\"" >/dev/null 2>&1 \
441
+ || { echo "sqlite3 still will not load - the server cannot run without it." >&2; exit 1; }
442
+ ok "sqlite3 loads after rebuilding"
443
+ fi
444
+
445
+ step "Setting up the server's directory"
446
+ as_user_sh "cd '${INSTALL_DIR}' && npx --no frugal-iot-init"
447
+ ok "configuration, directories and database in place"
448
+
449
+ step "Pointing the server at this Pi's own broker"
450
+ # One URL, used by the server's own logger and by the browser, so it has to be an address the phone
451
+ # or laptop showing the dashboard can reach - not localhost.
452
+ MQTT_YAML="${INSTALL_DIR}/config.d/mqtt.yaml"
453
+ CURRENT_BROKER="$(sed -n 's/^broker:[[:space:]]*//p' "$MQTT_YAML" 2>/dev/null | head -1 || true)"
454
+ WANTED_BROKER="ws://${BROKER_HOST}:9012"
455
+ if [[ "$CURRENT_BROKER" == "$WANTED_BROKER" ]]; then
456
+ skip "broker already ${WANTED_BROKER}"
457
+ else
458
+ as_user_sh "printf '# This Pi is its own broker - see INSTALLATION.md step 4\nbroker: %s\n' '${WANTED_BROKER}' > '${MQTT_YAML}'"
459
+ ok "broker set to ${WANTED_BROKER} (was ${CURRENT_BROKER:-unset})"
460
+ fi
461
+
462
+ # ---------------------------------------------------------------- the broker
463
+
464
+ step "Installing the MQTT broker"
465
+ sudo_ env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq mosquitto mosquitto-clients
466
+ ok "mosquitto $(mosquitto -h 2>&1 | head -1 | awk '{print $3}' || true)"
467
+
468
+ step "Configuring the broker"
469
+ # Two listeners: 1883 for the sensor nodes, 9012 websockets for the browser. Shipped with the
470
+ # server, so it is copied rather than written here.
471
+ SRC_CONF="${INSTALL_DIR}/extras/mosquitto.conf"
472
+ [[ -f "$SRC_CONF" ]] || { echo "Expected ${SRC_CONF} to exist after frugal-iot-init" >&2; exit 1; }
473
+ if cmp -s "$SRC_CONF" /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
474
+ skip "/etc/mosquitto/conf.d/frugal-iot.conf already current"
475
+ else
476
+ sudo_ cp "$SRC_CONF" /etc/mosquitto/conf.d/frugal-iot.conf
477
+ ok "configuration copied to /etc/mosquitto/conf.d/frugal-iot.conf"
478
+ fi
479
+ # The broker refuses to start if the password file it is told about does not exist. It has to belong
480
+ # to the mosquitto user, because both the broker and mosquitto_passwd warn unless the file belongs to
481
+ # whoever opened it - and mosquitto_passwd writes a temporary file beside it, so the directory has to
482
+ # be writable by that user too.
483
+ PWFILE="$(grep -hE '^[[:space:]]*password_file[[:space:]]' /etc/mosquitto/conf.d/frugal-iot.conf | tail -1 | awk '{print $2}' || true)"
484
+ [[ -n "$PWFILE" ]] || { echo "No password_file line in /etc/mosquitto/conf.d/frugal-iot.conf" >&2; exit 1; }
485
+ if [[ -f "$PWFILE" ]]; then
486
+ skip "password file ${PWFILE} exists"
487
+ else
488
+ sudo_ install -o mosquitto -g mosquitto -m 600 /dev/null "$PWFILE"
489
+ ok "empty password file created at ${PWFILE}"
490
+ fi
491
+ sudo_ systemctl restart mosquitto
492
+ sleep 2
493
+ systemctl is-active --quiet mosquitto \
494
+ || { echo "Mosquitto did not start. Its own log says why - the journal usually does not:" >&2
495
+ sudo_ tail -20 /var/log/mosquitto/mosquitto.log >&2 || true
496
+ exit 1; }
497
+ ok "broker running"
498
+
499
+ # ---------------------------------------------------------------- accounts
500
+
501
+ step "Creating the administrator login"
502
+ if as_user_sh "cd '${INSTALL_DIR}' && sqlite3 frugal-iot.db \"SELECT 1 FROM users WHERE username='superuser' AND hashed_password IS NOT NULL;\"" | grep -q 1; then
503
+ skip "superuser already has a password"
504
+ else
505
+ as_user_sh "cd '${INSTALL_DIR}' && npx --no frugal-iot-setpassword superuser '${SUPER_PW}'" >/dev/null
506
+ ok "superuser password set"
507
+ fi
508
+
509
+ step "Creating the organization"
510
+ if [[ -f "${INSTALL_DIR}/config.d/organizations/${ORG_ID}.yaml" ]]; then
511
+ skip "organization ${ORG_ID} already exists"
512
+ BROKER_PW="$(sed -n 's/^mqtt_password:[[:space:]]*//p' "${INSTALL_DIR}/config.d/organizations/${ORG_ID}.yaml" | head -1 | tr -d "\"'" || true)"
513
+ info "using the broker password already in its configuration"
514
+ else
515
+ # Writes the organization's yaml, creates a login account of the same name, grants it admin, makes
516
+ # its OTA directory, and adds it to the broker's password file
517
+ as_user_sh "cd '${INSTALL_DIR}' && npx --no frugal-iot-addorganization '${ORG_ID}' '${ORG_NAME}' '${ORG_EMAIL}' '${ORG_PHONE}' '${BROKER_PW}'"
518
+ ok "organization ${ORG_ID} created"
519
+ # addorganization set the web login password to the broker password as a side effect. They are for
520
+ # entirely different things, so give the login its own.
521
+ as_user_sh "cd '${INSTALL_DIR}' && npx --no frugal-iot-setpassword '${ORG_ID}' '${LOGIN_PW}'" >/dev/null
522
+ ok "web login password for ${ORG_ID} set, different from the broker password"
523
+ fi
524
+ sudo_ systemctl restart mosquitto # so it re-reads the password file
525
+ sleep 2
526
+
527
+ step "Testing the broker end to end"
528
+ # Proves the account, the password file and the port 1883 listener the nodes use all work together.
529
+ TEST_TOPIC="${ORG_ID}/installtest/hello"
530
+ SUB_OUT="$(mktemp)"
531
+ mosquitto_sub -h localhost -u "$ORG_ID" -P "$BROKER_PW" -t "$TEST_TOPIC" -C 1 -W 10 > "$SUB_OUT" 2>&1 &
532
+ SUB_PID=$!
533
+ sleep 2
534
+ mosquitto_pub -h localhost -u "$ORG_ID" -P "$BROKER_PW" -t "$TEST_TOPIC" -m '42'
535
+ wait "$SUB_PID" 2>/dev/null || true
536
+ if grep -qx '42' "$SUB_OUT"; then
537
+ ok "published and received on ${TEST_TOPIC}"
538
+ else
539
+ echo "The broker did not deliver a test message. It said:" >&2
540
+ cat "$SUB_OUT" >&2
541
+ rm -f "$SUB_OUT"
542
+ exit 1
543
+ fi
544
+ rm -f "$SUB_OUT"
545
+
546
+ # ---------------------------------------------------------------- the service
547
+
548
+ step "Running the server as a service"
549
+ # The shipped unit is written for user "pi" installing into /home/pi/frugal-iot, so the three lines
550
+ # that depend on that are rewritten for wherever this actually went.
551
+ SRC_SERVICE="${INSTALL_DIR}/extras/frugaliot.service"
552
+ [[ -f "$SRC_SERVICE" ]] || { echo "Expected ${SRC_SERVICE} to exist after frugal-iot-init" >&2; exit 1; }
553
+ TMP_SERVICE="$(mktemp)"
554
+ sed -e "s|^User=.*|User=${RUN_USER}|" \
555
+ -e "s|^WorkingDirectory=.*|WorkingDirectory=${INSTALL_DIR}|" \
556
+ -e "s|^ExecStart=/home/pi/frugal-iot/node_modules/.bin/frugal-iot-server|ExecStart=${INSTALL_DIR}/node_modules/.bin/frugal-iot-server|" \
557
+ "$SRC_SERVICE" > "$TMP_SERVICE"
558
+ if cmp -s "$TMP_SERVICE" /etc/systemd/system/frugaliot.service 2>/dev/null; then
559
+ skip "service file already current"
560
+ else
561
+ sudo_ cp "$TMP_SERVICE" /etc/systemd/system/frugaliot.service
562
+ ok "service installed, running as ${RUN_USER} from ${INSTALL_DIR}"
563
+ fi
564
+ rm -f "$TMP_SERVICE"
565
+ sudo_ systemctl daemon-reload
566
+ sudo_ systemctl enable -q frugaliot
567
+ sudo_ systemctl restart frugaliot
568
+
569
+ step "Checking the server answers"
570
+ PORT="$(sed -n 's/^port:[[:space:]]*//p' "${INSTALL_DIR}/config.d/server.yaml" | head -1 || true)"
571
+ PORT="${PORT:-8080}"
572
+ for attempt in $(seq 1 20); do
573
+ CODE="$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://localhost:${PORT}/" || true)"
574
+ [[ "$CODE" == "200" ]] && break
575
+ sleep 2
576
+ done
577
+ if [[ "${CODE:-}" == "200" ]]; then
578
+ ok "http://localhost:${PORT}/ answered 200"
579
+ else
580
+ echo "The server is not answering on port ${PORT} (last response: ${CODE:-none})." >&2
581
+ echo "What it logged:" >&2
582
+ sudo_ journalctl -u frugaliot -n 30 --no-pager >&2 || true
583
+ exit 1
584
+ fi
585
+ systemctl is-enabled --quiet frugaliot && ok "will start again at boot"
586
+
587
+ # ---------------------------------------------------------------- what is left to do
588
+
589
+ IP_ADDR="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
590
+
591
+ # Only the generated ones are worth printing - and printing a password you typed would put it in the
592
+ # log for no reason
593
+ was_generated() { local v; for v in ${GENERATED[@]+"${GENERATED[@]}"}; do [[ "$v" == "$1" ]] && return 0; done; return 1; }
594
+ shown() { if was_generated "$1"; then echo "${!1}"; else echo "(the one you gave)"; fi; }
595
+
596
+ if (( ${#REBOOT_REASONS[@]} )); then
597
+ REBOOT_TEXT=" 2. REBOOT NEEDED - $(printf '%s' "${REBOOT_REASONS[0]}")."
598
+ for r in "${REBOOT_REASONS[@]:1}"; do REBOOT_TEXT="${REBOOT_TEXT}"$'\n'" Also: ${r}."; done
599
+ REBOOT_TEXT="${REBOOT_TEXT}"$'\n'" sudo reboot"$'\n'" The server and broker both come back by themselves afterwards."
600
+ else
601
+ REBOOT_TEXT=" 2. No reboot needed. The kernel running now is the one on disk, and everything
602
+ installed here is already running."
603
+ fi
604
+
605
+ cat <<SUMMARY
606
+
607
+ =======================================================================
608
+ Frugal IoT is installed and running.
609
+ =======================================================================
610
+
611
+ Dashboard http://${BROKER_HOST}:${PORT}/
612
+ http://${IP_ADDR:-this-pi}:${PORT}/ (if .local does not resolve for you)
613
+
614
+ Logins superuser / $(shown SUPER_PW)
615
+ ${ORG_ID} / $(shown LOGIN_PW)
616
+
617
+ Broker ws://${BROKER_HOST}:9012 for browsers
618
+ ${BROKER_HOST}:1883 for sensor nodes
619
+ user ${ORG_ID}, password $(shown BROKER_PW)
620
+
621
+ Installed in ${INSTALL_DIR}, running as ${RUN_USER}
622
+ Log of this run ${LOG} (readable only by you; any generated password above is in it)
623
+
624
+ Still to do:
625
+
626
+ 1. Your sensor nodes. This cannot be done from here - a node learns the broker by being
627
+ flashed with it, which happens on your workstation. Build each node's firmware with:
628
+ broker ${BROKER_HOST}
629
+ org ${ORG_ID}
630
+ password $(shown BROKER_PW)
631
+ They appear on the dashboard by themselves once they connect.
632
+
633
+ ${REBOOT_TEXT}
634
+
635
+ 3. HTTPS, if you want over-the-air firmware updates - ESP32 requires it for those.
636
+ See INSTALLATION.md step 10.
637
+
638
+ If anything looks wrong:
639
+ cd ${INSTALL_DIR} && npx --no frugal-iot-diagnostic
640
+
641
+ Note the server is deliberately quiet - it does not log every message or web request,
642
+ because writes wear out SD cards. To watch messages while setting nodes up, set
643
+ "verbose: true" in ${INSTALL_DIR}/config.d/logger.yaml and restart with
644
+ "sudo service frugaliot restart".
645
+
646
+ SUMMARY
@@ -0,0 +1,27 @@
1
+ # Files in this repository that are deliberately left out of the npm package.
2
+ #
3
+ # "files" in package.json is an allow-list, so anything not named there is left out whether or not
4
+ # that was intended. scripts/check-published-files.js compares the two and reports anything left
5
+ # out that is not listed here - so this file is what lets that report stay short enough to read.
6
+ #
7
+ # When it names something new, either add it to "files" in package.json (if installers need it) or
8
+ # add it here with a line saying why. Blank lines and #comments are ignored; "*" matches within a
9
+ # path segment and "**" across segments.
10
+
11
+ # --- Never publish: this installation's own secrets ---
12
+ # Organization configs hold mqtt_password, and the broker password file holds its hashes. Both are
13
+ # in git, which is its own problem, but they must certainly not go to npm as well.
14
+ config.d/organizations/*.yaml
15
+ extras/mosquitto_passwords
16
+
17
+ # --- Development-only: no use to somebody installing the server ---
18
+ .gitignore
19
+ claude.md
20
+ create-favicon.js
21
+ jest.config.js
22
+ vitest.config.js
23
+ test/**
24
+
25
+ # --- Placeholders ---
26
+ # Keeps the ota directory present in a clone; a real installation gets one from frugal-iot-init
27
+ ota/README.md