frugal-iot-server 2.0.8 → 2.0.10

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,709 @@
1
+ #!/bin/sh
2
+ #
3
+ # Install a complete Frugal IoT server - broker, server, service - on an OpenWrt router.
4
+ #
5
+ # UNTESTED ON REAL HARDWARE. It is install-pi.sh's job done with opkg, procd and BusyBox, written
6
+ # from knowledge of OpenWrt rather than from a router. Run scripts/openwrt-preflight.sh first; if that
7
+ # reports FAIL, this will too. docs/OPENWRT.md says what to send back when something goes wrong.
8
+ #
9
+ # wget -O install-openwrt.sh https://raw.githubusercontent.com/mitra42/frugal-iot-server/main/scripts/install-openwrt.sh
10
+ # sh install-openwrt.sh --org myfarm --name "My Farm" --email you@example.com --phone +61123456789 \
11
+ # --dir /mnt/sda1/frugal-iot
12
+ #
13
+ # Plain /bin/sh on purpose - a fresh OpenWrt has neither bash nor zsh. (zsh IS installed here, because
14
+ # the server's own setup commands - npx frugal-iot-addorganization and friends - are zsh scripts.)
15
+ #
16
+ # Passwords not given are generated and printed at the end, and kept in a log readable only by root.
17
+ # Safe to run again: each step checks whether it is already done.
18
+ #
19
+ # What it does NOT do: set up HTTPS (over-the-air updates need it), open anything in the firewall, or
20
+ # flash your sensor nodes. It prints the settings to give them.
21
+
22
+ # ---------------------------------------------------------------- log everything, keep the exit code
23
+ # Re-run ourselves with output going to the terminal and a log. A pipe to tee loses the exit status
24
+ # in plain sh, so it is passed out through a file.
25
+ case " $* " in
26
+ *" --dry-run "*|*" --help "*|*" -h "*) ;;
27
+ *)
28
+ if [ -z "${FRUGAL_INSTALL_LOGGING:-}" ] && [ -f "$0" ]; then
29
+ FRUGAL_INSTALL_LOG="/root/frugal-iot-install-$(date +%Y%m%d-%H%M%S).log"
30
+ ( umask 077; : >> "$FRUGAL_INSTALL_LOG" )
31
+ RC_FILE=$(mktemp)
32
+ export FRUGAL_INSTALL_LOGGING=1 FRUGAL_INSTALL_LOG
33
+ ( sh "$0" "$@" && echo 0 > "$RC_FILE" || echo $? > "$RC_FILE" ) 2>&1 | tee -a "$FRUGAL_INSTALL_LOG"
34
+ RC=$(cat "$RC_FILE"); rm -f "$RC_FILE"
35
+ exit "${RC:-1}"
36
+ fi ;;
37
+ esac
38
+
39
+ set -eu
40
+
41
+ # ---------------------------------------------------------------- settings and arguments
42
+
43
+ ORG_ID=""; ORG_NAME=""; ORG_EMAIL=""; ORG_PHONE=""
44
+ BROKER_PW=""; SUPER_PW=""; LOGIN_PW=""; BROKER_HOST=""
45
+ INSTALL_DIR="/opt/frugal-iot"; RUN_USER="root"
46
+ ASSUME_YES=0; DRY_RUN=0; FORCE=0
47
+ LOG="${FRUGAL_INSTALL_LOG:-none}"
48
+ MQ_STATE="/etc/mosquitto/state"
49
+
50
+ usage() {
51
+ cat <<USAGE
52
+ Usage: sh install-openwrt.sh [options]
53
+
54
+ --org <id> organization id, 1-10 lower-case letters or digits. Becomes the first part of
55
+ every MQTT topic, so it must match what your nodes publish to.
56
+ --name "<name>" organization display name
57
+ --email <address> contact address for the organization
58
+ --phone <number> contact phone, "+" and digits only
59
+ --superuser-password password for this server's administrator login (generated if not given)
60
+ --login-password web login password for the organization's own account (generated if not given)
61
+ --broker-password the organization's shared broker credential. Generated, and not needed: nodes
62
+ are issued their own when they enrol. Set it only to match nodes flashed
63
+ before enrolment existed.
64
+ --broker-host <host> what browsers and nodes should call this router. Default: its LAN address.
65
+ --dir <path> where to install. Default: ${INSTALL_DIR}. Needs ~400 MB free on ext4/f2fs/btrfs,
66
+ so on most routers this is a USB or SSD drive: --dir /mnt/sda1/frugal-iot
67
+ --user <name> account to run the server as. Default: root, which is what most OpenWrt
68
+ services do. A named account is created if it does not exist.
69
+ --yes never ask anything
70
+ --force carry on past checks that would otherwise stop the install
71
+ --dry-run check the arguments and the router, say what would be done, change nothing
72
+ --help
73
+ USAGE
74
+ exit "${1:-0}"
75
+ }
76
+
77
+ need_arg() { [ $# -ge 2 ] || { echo "$1 needs a value" >&2; usage 1; }; }
78
+
79
+ while [ $# -gt 0 ]; do
80
+ case "$1" in
81
+ --org) need_arg "$@"; ORG_ID=$2; shift 2 ;;
82
+ --name) need_arg "$@"; ORG_NAME=$2; shift 2 ;;
83
+ --email) need_arg "$@"; ORG_EMAIL=$2; shift 2 ;;
84
+ --phone) need_arg "$@"; ORG_PHONE=$2; shift 2 ;;
85
+ --superuser-password) need_arg "$@"; SUPER_PW=$2; shift 2 ;;
86
+ --login-password) need_arg "$@"; LOGIN_PW=$2; shift 2 ;;
87
+ --broker-password) need_arg "$@"; BROKER_PW=$2; shift 2 ;;
88
+ --broker-host) need_arg "$@"; BROKER_HOST=$2; shift 2 ;;
89
+ --dir) need_arg "$@"; INSTALL_DIR=$2; shift 2 ;;
90
+ --user) need_arg "$@"; RUN_USER=$2; shift 2 ;;
91
+ --yes|-y) ASSUME_YES=1; shift ;;
92
+ --force) FORCE=1; shift ;;
93
+ --dry-run) DRY_RUN=1; shift ;;
94
+ --help|-h) usage 0 ;;
95
+ *) echo "Unknown option: $1" >&2; usage 1 ;;
96
+ esac
97
+ done
98
+
99
+ STEP_N=0
100
+ step() { STEP_N=$((STEP_N+1)); echo; echo "=== ${STEP_N}. $* ==="; }
101
+ info() { echo " $*"; }
102
+ ok() { echo " ok: $*"; }
103
+ skip() { echo " already done: $*"; }
104
+ warn() { echo " WARNING: $*"; }
105
+ die() { echo; echo "STOPPED: $*" >&2; exit 1; }
106
+
107
+ on_exit() {
108
+ rc=$?
109
+ if [ "$rc" -ne 0 ] && [ "$DRY_RUN" = 0 ]; then
110
+ echo
111
+ echo "=== Stopped (exit ${rc}) ==="
112
+ echo "Nothing after the last 'ok' above has been done. Run it again once the cause is dealt"
113
+ echo "with - the steps that succeeded are not repeated."
114
+ [ "$LOG" != none ] && echo "The log of this run, to send back if you need help: ${LOG}"
115
+ fi
116
+ }
117
+ trap on_exit EXIT
118
+
119
+ randpw() { LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 || true; }
120
+
121
+ ask() { # ask <variable> <prompt>
122
+ eval "cur=\${$1:-}"
123
+ [ -n "$cur" ] && return 0
124
+ if [ "$ASSUME_YES" = 1 ] || [ ! -t 0 ]; then
125
+ die "nothing given for '$2', and there is no terminal to ask at. Pass it on the command line - see --help."
126
+ fi
127
+ printf ' %s: ' "$2"; read -r reply
128
+ [ -n "$reply" ] || die "that cannot be empty"
129
+ eval "$1=\$reply"
130
+ }
131
+
132
+ # Passwords go inside single quotes on a shell command line, so refuse one that contains a quote
133
+ no_quote() { case "$2" in *\'*) die "$1 must not contain a single quote" ;; esac; }
134
+
135
+ # ---------------------------------------------------------------- package manager
136
+ if command -v opkg >/dev/null 2>&1; then PM=opkg
137
+ elif command -v apk >/dev/null 2>&1; then PM=apk
138
+ else PM=""; fi
139
+
140
+ pkg_update() { case "$PM" in opkg) opkg update ;; apk) apk update ;; esac; }
141
+ pkg_installed() {
142
+ case "$PM" in
143
+ opkg) opkg list-installed 2>/dev/null | grep -q "^$1 " ;;
144
+ apk) apk info -e "$1" >/dev/null 2>&1 ;;
145
+ *) return 1 ;;
146
+ esac
147
+ }
148
+ pkg_available() {
149
+ case "$PM" in
150
+ opkg) opkg list 2>/dev/null | grep -q "^$1 - " ;;
151
+ apk) apk search -e "$1" 2>/dev/null | grep -q . ;;
152
+ *) return 1 ;;
153
+ esac
154
+ }
155
+ pkg_add() { # install packages, stopping on the first that cannot be had, and saying which
156
+ for p in "$@"; do
157
+ pkg_installed "$p" && continue
158
+ case "$PM" in
159
+ opkg) opkg install "$p" ;;
160
+ apk) apk add "$p" ;;
161
+ esac || die "could not install package '${p}'"
162
+ info "installed ${p}"
163
+ done
164
+ }
165
+
166
+ node_version_ok() { # "v20.17.0" -> succeeds if at least 20.17
167
+ echo "$1" | sed 's/^v//' | awk -F'[.-]' '{ exit !($1 > 20 || ($1 == 20 && $2 >= 17)) }'
168
+ }
169
+
170
+ mount_info() { # "mountpoint fstype" of whatever holds a path (or its nearest existing parent)
171
+ p=$1
172
+ while [ ! -e "$p" ] && [ "$p" != / ]; do p=$(dirname "$p"); done
173
+ p=$(cd "$p" 2>/dev/null && pwd -P || echo "$p")
174
+ awk -v p="$p" '
175
+ { m = $2; pre = (m == "/") ? "/" : (m "/")
176
+ if ((p == m || index(p, pre) == 1) && length(m) >= length(best)) { best = m; fs = $3 } }
177
+ END { print best, fs }' /proc/mounts
178
+ }
179
+ free_mb() {
180
+ p=$1; while [ ! -e "$p" ] && [ "$p" != / ]; do p=$(dirname "$p"); done
181
+ df -Pk "$p" 2>/dev/null | awk 'NR==2 {print int($4/1024)}'
182
+ }
183
+
184
+ # Run a command as the account that owns the install, from the install directory. npm's cache goes
185
+ # under the install directory too: the router's own flash is small, and the default is ~/.npm.
186
+ in_dir() {
187
+ cmd="export HOME='${INSTALL_DIR}' npm_config_cache='${INSTALL_DIR}/.npm-cache' NPM_CONFIG_UPDATE_NOTIFIER=false; cd '${INSTALL_DIR}' && $1"
188
+ if [ "$RUN_USER" = root ]; then sh -c "$cmd"; else su -s /bin/sh "$RUN_USER" -c "$cmd"; fi
189
+ }
190
+
191
+ # OpenWrt's BusyBox has no install(1) - it is in the separate coreutils-install package, which
192
+ # stopped the first real run at the first file it tried to place. Same effect with cp, chown, chmod
193
+ # and mv; the mv makes it atomic, so the broker never sees a half-written file.
194
+ put() { # put <mode> <owner> <group> <source> <destination>
195
+ put_tmp="$5.new.$$"
196
+ cp "$4" "$put_tmp" && chown "$2:$3" "$put_tmp" && chmod "$1" "$put_tmp" && mv -f "$put_tmp" "$5"
197
+ }
198
+
199
+ # ---------------------------------------------------------------- is this a machine we can do this on
200
+
201
+ echo "Frugal IoT server installation - OpenWrt"
202
+ [ "$DRY_RUN" = 1 ] && echo "Dry run - nothing will be changed" || echo "Log: ${LOG}"
203
+
204
+ step "Checking the router"
205
+ [ "$(uname -s)" = Linux ] || die "this is $(uname -s), not Linux"
206
+ [ "$(id -u)" = 0 ] || die "run this as root - it installs packages and a system service"
207
+ if [ -r /etc/openwrt_release ]; then
208
+ . /etc/openwrt_release
209
+ info "${DISTRIB_DESCRIPTION:-OpenWrt} (${DISTRIB_TARGET:-?}, ${DISTRIB_ARCH:-?})"
210
+ elif [ "$FORCE" = 1 ]; then
211
+ warn "not OpenWrt - carrying on because of --force"
212
+ DISTRIB_ARCH=$(uname -m)
213
+ else
214
+ die "this does not look like OpenWrt (no /etc/openwrt_release). For Raspberry Pi OS or Debian use install-pi.sh. --force overrides."
215
+ fi
216
+ [ -n "$PM" ] || die "found neither opkg nor apk"
217
+ info "package manager: ${PM}"
218
+ [ -r /tmp/sysinfo/model ] && info "board: $(cat /tmp/sysinfo/model)"
219
+
220
+ case "${DISTRIB_ARCH:-}" in
221
+ x86_64*|aarch64*) ;;
222
+ *) warn "CPU architecture ${DISTRIB_ARCH:-unknown}: node and the sqlite3 module may not exist for it. openwrt-preflight.sh says whether they do." ;;
223
+ esac
224
+
225
+ MEM_KB=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
226
+ info "memory: $((MEM_KB / 1024)) MB"
227
+ if [ "$MEM_KB" -lt 240000 ] && [ "$FORCE" != 1 ]; then die "under 256 MB of memory - the server alone uses about 250 MB. --force overrides."; fi
228
+ NPM_FLAGS="--no-audit --no-fund"
229
+ [ "$MEM_KB" -lt 1200000 ] && NPM_FLAGS="--maxsockets 1 --no-audit --no-fund"
230
+
231
+ set -- $(mount_info "$INSTALL_DIR"); DMNT=${1:-/}; DFS=${2:-?}
232
+ DFREE=$(free_mb "$INSTALL_DIR"); DFREE=${DFREE:-0}
233
+ info "install into ${INSTALL_DIR}: on ${DMNT} (${DFS}), ${DFREE} MB free"
234
+ case "$DFS" in
235
+ vfat|exfat|msdos|ntfs|fuseblk)
236
+ die "${INSTALL_DIR} is on ${DFS}, which has no Unix permissions or symlinks. Use an ext4 drive: --dir /mnt/<drive>/frugal-iot" ;;
237
+ esac
238
+ if [ "$DFREE" -lt 400 ] && [ "$FORCE" != 1 ]; then
239
+ die "only ${DFREE} MB free under ${INSTALL_DIR}; about 400 MB is wanted. Attach a drive and use --dir, or --force."
240
+ fi
241
+
242
+ ask ORG_ID "Organization id (1-10 lower-case letters or digits, e.g. myfarm)"
243
+ echo "$ORG_ID" | grep -qE '^[a-z0-9]{1,10}$' || die "organization id must be 1-10 lower-case letters or digits: '${ORG_ID}'"
244
+ ask ORG_NAME "Organization display name (e.g. My Farm)"
245
+ ask ORG_EMAIL "Contact email"
246
+ ask ORG_PHONE "Contact phone ('+' and digits only)"
247
+ echo "$ORG_PHONE" | grep -qE '^[+]?[0-9]+$' || die "phone must be '+' and digits only: '${ORG_PHONE}'"
248
+ for v in "ORG_NAME:$ORG_NAME" "ORG_EMAIL:$ORG_EMAIL" "SUPER_PW:$SUPER_PW" "LOGIN_PW:$LOGIN_PW" "BROKER_PW:$BROKER_PW"; do
249
+ no_quote "${v%%:*}" "${v#*:}"
250
+ done
251
+
252
+ GENERATED=""
253
+ [ -n "$SUPER_PW" ] || { SUPER_PW=$(randpw); GENERATED="$GENERATED SUPER_PW"; }
254
+ [ -n "$LOGIN_PW" ] || { LOGIN_PW=$(randpw); GENERATED="$GENERATED LOGIN_PW"; }
255
+ [ -n "$BROKER_PW" ] || BROKER_PW=$(randpw)
256
+
257
+ # A router has a fixed LAN address, which beats a ".local" name: that needs umdns installed, and
258
+ # Android cannot resolve it at all.
259
+ if [ -z "$BROKER_HOST" ]; then
260
+ BROKER_HOST=$(uci -q get network.lan.ipaddr 2>/dev/null | sed 's|/.*||' || true)
261
+ [ -n "$BROKER_HOST" ] || BROKER_HOST=$(ip -4 -o addr show br-lan 2>/dev/null | awk '{print $4}' | sed 's|/.*||' | head -1 || true)
262
+ [ -n "$BROKER_HOST" ] || die "could not work out this router's LAN address - say it with --broker-host"
263
+ fi
264
+ info "organization: ${ORG_ID} (${ORG_NAME})"
265
+ info "broker URL: ws://${BROKER_HOST}:9012"
266
+ info "run as: ${RUN_USER}"
267
+
268
+ # Node, from wherever it came. OpenWrt 25.12 has no node package for aarch64 at all, so a router
269
+ # like that has it unpacked by hand from a musl build (docs/OPENWRT.md says how). Insisting on the
270
+ # package then fails with "no such package" on a router that has a perfectly good node.
271
+ NODE_PKGS="node node-npm"
272
+ if command -v node >/dev/null 2>&1; then
273
+ node -v >/dev/null 2>&1 || die "node is on this router but will not run. A musl build of node usually needs libstdcpp6 and libatomic1 ($( [ "$PM" = apk ] && echo 'apk add' || echo 'opkg install' ) libstdcpp6 libatomic1)."
274
+ command -v npm >/dev/null 2>&1 || die "node $(node -v) is here but npm is not. Put npm on the PATH, or install node-npm."
275
+ NODE_PKGS=""
276
+ info "node: $(node -v), already on this router, so no node package is installed"
277
+ fi
278
+
279
+ if [ "$DRY_RUN" = 1 ]; then
280
+ cat <<PLAN
281
+
282
+ Nothing has been changed. With the same arguments and without --dry-run, this would:
283
+ * install packages: ${NODE_PKGS:+${NODE_PKGS} }zsh sqlite3-cli curl ca-bundle, and mosquitto with its clients
284
+ * npm install frugal-iot-server into ${INSTALL_DIR}
285
+ * point the broker at ws://${BROKER_HOST}:9012, keeping its state under ${MQ_STATE} (not /var,
286
+ which OpenWrt keeps in RAM and empties at every reboot)
287
+ * configure mosquitto, with listeners on 1883 and 9012, then create the superuser and the ${ORG_ID} organization
288
+ * install /etc/init.d/frugaliot (procd) and start it
289
+ PLAN
290
+ exit 0
291
+ fi
292
+
293
+ # ---------------------------------------------------------------- packages
294
+
295
+ step "Installing packages"
296
+ info "refreshing the package list"
297
+ pkg_update || die "could not refresh the package list - does the router have internet?"
298
+
299
+ if pkg_installed mosquitto-ssl || pkg_installed mosquitto-nossl; then MQ_PKG=installed
300
+ elif pkg_available mosquitto-ssl; then MQ_PKG=mosquitto-ssl
301
+ elif pkg_available mosquitto-nossl; then MQ_PKG=mosquitto-nossl
302
+ else die "this package feed has neither mosquitto-ssl nor mosquitto-nossl"; fi
303
+ case "$MQ_PKG" in
304
+ mosquitto-nossl) MQ_CLIENT=mosquitto-client-nossl ;;
305
+ *) MQ_CLIENT=mosquitto-client-ssl ;;
306
+ esac
307
+ [ "$MQ_PKG" = installed ] && MQ_PKG=""; pkg_installed mosquitto-nossl && MQ_CLIENT=mosquitto-client-nossl
308
+
309
+ info "free space on the router's own flash before: $(free_mb /) MB"
310
+ if [ -n "$NODE_PKGS" ] && ! pkg_available node; then
311
+ die "this package feed has no node (OpenWrt 25.12 has none for aarch64). Install Node.js 20.17 or later yourself - docs/OPENWRT.md, 'If the feed has no node' - then run this again."
312
+ fi
313
+ # shellcheck disable=SC2086
314
+ pkg_add ca-bundle ${NODE_PKGS} zsh sqlite3-cli curl ${MQ_PKG} ${MQ_CLIENT}
315
+ info "free space on the router's own flash after: $(free_mb /) MB"
316
+ # Written to the log on purpose: which mosquitto parts this OpenWrt ships is the main unknown
317
+ case "$PM" in
318
+ opkg) info "mosquitto packages in this feed: $(opkg list 2>/dev/null | awk '/^(lib)?mosquitto/ {printf "%s ", $1}')" ;;
319
+ apk) info "mosquitto packages in this feed: $(apk search mosquitto 2>/dev/null | tr '\n' ' ')" ;;
320
+ esac
321
+
322
+ NODE_VERSION=$(node -v)
323
+ info "node ${NODE_VERSION}, npm $(npm -v)"
324
+ node_version_ok "$NODE_VERSION" || die "node ${NODE_VERSION} is older than the 20.17 the server needs"
325
+ command -v mosquitto_passwd >/dev/null 2>&1 || die "no mosquitto_passwd command after installing mosquitto - the organization's broker account cannot be created"
326
+ command -v mosquitto_pub >/dev/null 2>&1 || die "no mosquitto_pub - the broker test cannot run"
327
+ ok "packages installed"
328
+
329
+ # ---------------------------------------------------------------- the account that runs it
330
+
331
+ step "The account the server runs as"
332
+ if [ "$RUN_USER" = root ]; then
333
+ info "root - as most OpenWrt services run. Use --user <name> to run as a separate account."
334
+ else
335
+ if id "$RUN_USER" >/dev/null 2>&1; then
336
+ skip "user ${RUN_USER} exists"
337
+ else
338
+ # BusyBox adduser: -D no password, -H no home directory. The shell is /bin/false, so everything
339
+ # run as this account goes through "su -s /bin/sh".
340
+ if command -v adduser >/dev/null 2>&1 && adduser -D -H -s /bin/false -h "$INSTALL_DIR" "$RUN_USER" 2>/dev/null; then :
341
+ elif command -v useradd >/dev/null 2>&1 && useradd -M -s /bin/false -d "$INSTALL_DIR" "$RUN_USER"; then :
342
+ else die "could not create user ${RUN_USER}. Install the 'shadow-useradd' package, or leave --user out to run as root."; fi
343
+ ok "created user ${RUN_USER}"
344
+ fi
345
+ fi
346
+ mkdir -p "$INSTALL_DIR"
347
+ [ "$RUN_USER" = root ] || chown "$RUN_USER" "$INSTALL_DIR"
348
+
349
+ # ---------------------------------------------------------------- the server
350
+
351
+ step "Installing the Frugal IoT server"
352
+ PKG_DIR="${INSTALL_DIR}/node_modules/frugal-iot-server"
353
+ if [ -d "$PKG_DIR" ]; then
354
+ skip "already installed in ${INSTALL_DIR}"
355
+ else
356
+ # shellcheck disable=SC2086
357
+ in_dir "npm install ${NPM_FLAGS} frugal-iot-server" || die "npm install failed. The reason is above."
358
+ ok "installed"
359
+ fi
360
+ info "server $(in_dir "node -e \"console.log(require('${PKG_DIR}/package.json').version)\"")"
361
+
362
+ step "Checking the compiled part of sqlite3 actually loads"
363
+ if in_dir "node -e \"require('sqlite3')\"" >/dev/null 2>&1; then
364
+ ok "sqlite3 loads"
365
+ else
366
+ in_dir "node -e \"require('sqlite3')\"" 2>&1 | tail -5 | sed 's/^/ /' || true
367
+ die "sqlite3 will not load on this router. It is a compiled module, and the ready-made binary the server relies on is probably not published for this CPU and C library (OpenWrt uses musl). The messages above are what to send back."
368
+ fi
369
+
370
+ step "Setting up the server's directory"
371
+ in_dir "npx --no frugal-iot-init" || die "frugal-iot-init failed"
372
+ ok "configuration, directories and database in place"
373
+
374
+ step "Pointing the server at this router's own broker"
375
+ MQTT_YAML="${INSTALL_DIR}/config.d/mqtt.yaml"
376
+ CURRENT_BROKER=$(sed -n 's/^broker:[[:space:]]*//p' "$MQTT_YAML" 2>/dev/null | head -1 || true)
377
+ WANTED_BROKER="ws://${BROKER_HOST}:9012"
378
+ if [ "$CURRENT_BROKER" = "$WANTED_BROKER" ]; then
379
+ skip "broker already ${WANTED_BROKER}"
380
+ else
381
+ in_dir "printf '# This router is its own broker - see docs/OPENWRT.md\nbroker: %s\n' '${WANTED_BROKER}' > '${MQTT_YAML}'"
382
+ ok "broker set to ${WANTED_BROKER} (was ${CURRENT_BROKER:-unset})"
383
+ fi
384
+
385
+ # ---------------------------------------------------------------- the broker
386
+
387
+ step "Configuring the broker"
388
+ # Who mosquitto drops to after reading its configuration. Everything it opens afterwards - the
389
+ # password file, the ACL, the plugin's state - has to belong to that account.
390
+ MQ_CONF=/etc/mosquitto/mosquitto.conf
391
+ MQ_USER=$(awk '/^[[:space:]]*user[[:space:]]/ {print $2}' "$MQ_CONF" 2>/dev/null | tail -1 || true)
392
+ [ -n "$MQ_USER" ] || MQ_USER=mosquitto
393
+ id "$MQ_USER" >/dev/null 2>&1 || { warn "no '${MQ_USER}' account on this router - the broker will use 'nobody'"; MQ_USER=nobody; }
394
+ MQ_GROUP=$(id -gn "$MQ_USER")
395
+ info "the broker runs as ${MQ_USER}:${MQ_GROUP}"
396
+
397
+ mkdir -p /etc/mosquitto/conf.d "$MQ_STATE"
398
+ chown "$MQ_USER:$MQ_GROUP" "$MQ_STATE"; chmod 700 "$MQ_STATE"
399
+
400
+ # OpenWrt's /var is /tmp, i.e. RAM. The shipped configuration keeps the password file and the dynamic
401
+ # security state in /var/lib/mosquitto, which here would be gone at every reboot - taking every
402
+ # account with it - so both are redirected to ${MQ_STATE}.
403
+ SRC_CONF="${PKG_DIR}/extras/mosquitto.conf"; [ -f "$SRC_CONF" ] || die "no extras/mosquitto.conf in ${PKG_DIR}"
404
+ SRC_ACL="${PKG_DIR}/extras/aclfile"; [ -f "$SRC_ACL" ] || die "no extras/aclfile in ${PKG_DIR}"
405
+
406
+ # The main configuration must read conf.d, and must keep its persistence somewhere that survives a
407
+ # reboot: retained values are how the dashboard shows something the moment it opens.
408
+ if [ ! -f "$MQ_CONF" ]; then
409
+ printf 'user %s\npersistence true\npersistence_location %s/\ninclude_dir /etc/mosquitto/conf.d\n' "$MQ_USER" "$MQ_STATE" > "$MQ_CONF"
410
+ ok "wrote ${MQ_CONF}"
411
+ else
412
+ [ -f "${MQ_CONF}.pre-frugal" ] || cp -p "$MQ_CONF" "${MQ_CONF}.pre-frugal"
413
+ grep -qE '^[[:space:]]*include_dir' "$MQ_CONF" || { printf '\ninclude_dir /etc/mosquitto/conf.d\n' >> "$MQ_CONF"; ok "added include_dir to ${MQ_CONF}"; }
414
+ if grep -qE '^[[:space:]]*persistence[[:space:]]' "$MQ_CONF"; then
415
+ sed -i 's/^[[:space:]]*persistence[[:space:]].*/persistence true/' "$MQ_CONF"
416
+ else
417
+ printf 'persistence true\n' >> "$MQ_CONF"
418
+ fi
419
+ PLOC=$(awk '/^[[:space:]]*persistence_location[[:space:]]/ {print $2}' "$MQ_CONF" | tail -1)
420
+ case "$PLOC" in
421
+ ""|/tmp*|/var/*|/run/*)
422
+ if [ -n "$PLOC" ]; then sed -i "s|^[[:space:]]*persistence_location[[:space:]].*|persistence_location ${MQ_STATE}/|" "$MQ_CONF"
423
+ else printf 'persistence_location %s/\n' "$MQ_STATE" >> "$MQ_CONF"; fi
424
+ ok "persistence moved from '${PLOC:-unset}' to ${MQ_STATE}/ so it survives a reboot (original saved as ${MQ_CONF}.pre-frugal)" ;;
425
+ *) skip "persistence already at ${PLOC}" ;;
426
+ esac
427
+ fi
428
+
429
+ # The ACL goes in first: the configuration names it, and the broker will not start without it
430
+ if [ ! -e /etc/mosquitto/aclfile ]; then
431
+ put 600 "$MQ_USER" "$MQ_GROUP" "$SRC_ACL" /etc/mosquitto/aclfile
432
+ ok "access control list installed"
433
+ else
434
+ T1=$(mktemp); T2=$(mktemp)
435
+ grep -vE '^[[:space:]]*(#|$)' "$SRC_ACL" > "$T1" || true
436
+ grep -vE '^[[:space:]]*(#|$)' /etc/mosquitto/aclfile > "$T2" || true
437
+ ACL_MISSING=$(grep -vxFf "$T2" "$T1" || true)
438
+ ACL_ADDED=$(grep -cE '^[[:space:]]*user[[:space:]]' /etc/mosquitto/aclfile || true)
439
+ rm -f "$T1" "$T2"
440
+ if [ -z "$ACL_MISSING" ]; then skip "/etc/mosquitto/aclfile already has every rule this release ships"
441
+ elif [ "${ACL_ADDED:-0}" = 0 ]; then
442
+ put 600 "$MQ_USER" "$MQ_GROUP" "$SRC_ACL" /etc/mosquitto/aclfile
443
+ ok "access control list brought up to date"
444
+ else
445
+ warn "/etc/mosquitto/aclfile is missing rules this release ships and holds ${ACL_ADDED} added by hand, which replacing it would delete. Add these yourself:"
446
+ echo "$ACL_MISSING" | sed 's/^/ /'
447
+ fi
448
+ fi
449
+
450
+ # The dynamic security plugin, if this mosquitto has one
451
+ DYNSEC_SO=""
452
+ for cand in /usr/lib/mosquitto/mosquitto_dynamic_security.so /usr/lib/mosquitto_dynamic_security.so \
453
+ /usr/lib/*/mosquitto_dynamic_security.so /usr/lib/*/mosquitto/mosquitto_dynamic_security.so; do
454
+ [ -f "$cand" ] && { DYNSEC_SO=$cand; break; }
455
+ done
456
+ DYNSEC_JSON="${MQ_STATE}/dynamic-security.json"
457
+ SECRETS="${INSTALL_DIR}/config.d/secrets.yaml"
458
+ if [ -z "$DYNSEC_SO" ]; then
459
+ warn "no mosquitto dynamic security plugin on this router. The broker will run on the organization's"
460
+ warn " shared password instead: every node and browser uses the same credential. It works, but it"
461
+ warn " is the older and weaker scheme. Tell whoever sent you this - it matters."
462
+ elif ! command -v mosquitto_ctrl >/dev/null 2>&1; then
463
+ warn "the plugin is there (${DYNSEC_SO}) but there is no mosquitto_ctrl to initialise it - carrying on without it"
464
+ DYNSEC_SO=""
465
+ elif [ -f "$DYNSEC_JSON" ]; then
466
+ if grep -q '^dynsec_admin_password:' "$SECRETS" 2>/dev/null; then skip "dynamic security already initialised"
467
+ else
468
+ warn "${DYNSEC_JSON} exists but ${SECRETS} has no dynsec_admin_password - a previous run stopped between the two."
469
+ warn " Remove ${DYNSEC_JSON} and run this again."
470
+ DYNSEC_SO=""
471
+ fi
472
+ else
473
+ DYNSEC_PW=$(randpw)
474
+ if printf '%s\n%s\n' "$DYNSEC_PW" "$DYNSEC_PW" | mosquitto_ctrl dynsec init "$DYNSEC_JSON" frugal-admin >/dev/null 2>&1 && [ -s "$DYNSEC_JSON" ]; then
475
+ chown "$MQ_USER:$MQ_GROUP" "$DYNSEC_JSON"; chmod 600 "$DYNSEC_JSON"
476
+ {
477
+ echo ""
478
+ echo "# The broker account the server uses to create and remove other accounts, over"
479
+ echo "# \$CONTROL/dynamic-security/v1. Created by install-openwrt.sh."
480
+ echo "dynsec_admin_user: \"frugal-admin\""
481
+ echo "dynsec_admin_password: \"${DYNSEC_PW}\""
482
+ } >> "$SECRETS"
483
+ chmod 600 "$SECRETS"; [ "$RUN_USER" = root ] || chown "$RUN_USER" "$SECRETS"
484
+ ok "dynamic security initialised; admin credential saved to config.d/secrets.yaml"
485
+ else
486
+ warn "mosquitto_ctrl dynsec init failed - continuing without it"
487
+ rm -f "$DYNSEC_JSON"; DYNSEC_SO=""
488
+ fi
489
+ fi
490
+
491
+ # The shipped configuration, with the plugin path and the state directory filled in
492
+ TMP_CONF=$(mktemp)
493
+ if [ -n "$DYNSEC_SO" ]; then
494
+ sed -e "s|^plugin PLUGIN_PATH_SET_BY_INSTALLER|plugin ${DYNSEC_SO}|" \
495
+ -e "s|/var/lib/mosquitto/|${MQ_STATE}/|g" "$SRC_CONF" > "$TMP_CONF"
496
+ else
497
+ sed -e "s|^plugin PLUGIN_PATH_SET_BY_INSTALLER|#plugin (not installed)|" \
498
+ -e "s|^plugin_opt_config_file|#plugin_opt_config_file|" \
499
+ -e "s|/var/lib/mosquitto/|${MQ_STATE}/|g" "$SRC_CONF" > "$TMP_CONF"
500
+ fi
501
+ if cmp -s "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
502
+ skip "/etc/mosquitto/conf.d/frugal-iot.conf already current"
503
+ else
504
+ put 644 root root "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf
505
+ ok "configuration installed at /etc/mosquitto/conf.d/frugal-iot.conf"
506
+ fi
507
+ rm -f "$TMP_CONF"
508
+
509
+ PWFILE=$(grep -hE '^[[:space:]]*password_file[[:space:]]' /etc/mosquitto/conf.d/frugal-iot.conf | tail -1 | awk '{print $2}' || true)
510
+ [ -n "$PWFILE" ] || die "no password_file line in /etc/mosquitto/conf.d/frugal-iot.conf"
511
+ if [ -f "$PWFILE" ]; then skip "password file ${PWFILE} exists"
512
+ else put 600 "$MQ_USER" "$MQ_GROUP" /dev/null "$PWFILE"; ok "empty password file created at ${PWFILE}"; fi
513
+
514
+ # Everything the broker opens after dropping privileges must be its own. mosquitto_passwd run as
515
+ # root rewrites the password file as root, and the broker then cannot read it - so this is repeated
516
+ # after each step below that touches it.
517
+ fix_broker_files() {
518
+ [ -e "$PWFILE" ] && { chown "$MQ_USER:$MQ_GROUP" "$PWFILE"; chmod 600 "$PWFILE"; }
519
+ [ -e "$DYNSEC_JSON" ] && { chown "$MQ_USER:$MQ_GROUP" "$DYNSEC_JSON"; chmod 600 "$DYNSEC_JSON"; }
520
+ [ -e /etc/mosquitto/aclfile ] && { chown "$MQ_USER:$MQ_GROUP" /etc/mosquitto/aclfile; chmod 600 /etc/mosquitto/aclfile; }
521
+ return 0
522
+ }
523
+ broker_listening() { netstat -ln 2>/dev/null | grep -q "[:.]$1 "; }
524
+ restart_broker() {
525
+ fix_broker_files
526
+ /etc/init.d/mosquitto enable
527
+ /etc/init.d/mosquitto restart
528
+ sleep 3
529
+ if ! pidof mosquitto >/dev/null 2>&1; then
530
+ echo "Mosquitto did not start. What it logged:" >&2
531
+ logread -e mosquitto 2>/dev/null | tail -15 >&2 || true
532
+ die "the broker is not running (see above)"
533
+ fi
534
+ broker_listening 1883 || die "the broker is running but not listening on 1883"
535
+ broker_listening 9012 || die "the broker is running but not listening on 9012 - its websockets listener. This mosquitto build probably has no websockets support, and the browser dashboard cannot work without it."
536
+ }
537
+ restart_broker
538
+ ok "broker running, listening on 1883 and 9012"
539
+
540
+ # ---------------------------------------------------------------- accounts
541
+
542
+ step "Creating the administrator login"
543
+ if in_dir "sqlite3 frugal-iot.db \"SELECT 1 FROM users WHERE username='superuser' AND hashed_password IS NOT NULL;\"" | grep -q 1; then
544
+ skip "superuser already has a password"
545
+ SUPER_PW="(unchanged - set by an earlier run)" # not the one generated above, which was never used
546
+ else
547
+ in_dir "npx --no frugal-iot-setpassword superuser '${SUPER_PW}'" >/dev/null
548
+ ok "superuser password set"
549
+ fi
550
+
551
+ step "Creating the organization"
552
+ if [ -f "${INSTALL_DIR}/config.d/organizations/${ORG_ID}.yaml" ]; then
553
+ skip "organization ${ORG_ID} already exists"
554
+ LOGIN_PW="(unchanged - set by an earlier run)"
555
+ BROKER_PW=$(sed -n 's/^mqtt_password:[[:space:]]*//p' "${INSTALL_DIR}/config.d/organizations/${ORG_ID}.yaml" | head -1 | tr -d "\"'" || true)
556
+ info "using the broker password already in its configuration"
557
+ else
558
+ in_dir "npx --no frugal-iot-addorganization '${ORG_ID}' '${ORG_NAME}' '${ORG_EMAIL}' '${ORG_PHONE}' '${BROKER_PW}'"
559
+ fix_broker_files
560
+ ok "organization ${ORG_ID} created"
561
+ # addorganization set the web login password to the broker password; they are for different things
562
+ in_dir "npx --no frugal-iot-setpassword '${ORG_ID}' '${LOGIN_PW}'" >/dev/null
563
+ ok "web login password for ${ORG_ID} set, different from the broker password"
564
+ fi
565
+ /etc/init.d/mosquitto restart # so it re-reads the password file
566
+ sleep 3
567
+
568
+ step "Creating the broker's roles and groups"
569
+ if [ -n "$DYNSEC_SO" ]; then
570
+ in_dir "npx --no frugal-iot-rebuild-dynsec"
571
+ ok "roles and groups built from the database"
572
+ else
573
+ skip "no dynamic security plugin, so there are no groups to build"
574
+ fi
575
+
576
+ step "Testing the broker end to end"
577
+ TEST_TOPIC="${ORG_ID}/installtest/hello"
578
+ SUB_OUT=$(mktemp)
579
+ mosquitto_sub -h localhost -u "$ORG_ID" -P "$BROKER_PW" -t "$TEST_TOPIC" -C 1 -W 10 > "$SUB_OUT" 2>&1 &
580
+ SUB_PID=$!
581
+ sleep 2
582
+ mosquitto_pub -h localhost -u "$ORG_ID" -P "$BROKER_PW" -t "$TEST_TOPIC" -m '42' || true
583
+ wait "$SUB_PID" 2>/dev/null || true
584
+ if grep -qx '42' "$SUB_OUT"; then
585
+ ok "published and received on ${TEST_TOPIC}"
586
+ else
587
+ echo "The broker did not deliver a test message. It said:" >&2
588
+ cat "$SUB_OUT" >&2
589
+ rm -f "$SUB_OUT"
590
+ die "broker test failed"
591
+ fi
592
+ rm -f "$SUB_OUT"
593
+
594
+ # ---------------------------------------------------------------- the service
595
+
596
+ step "Running the server as a service"
597
+ # procd, not systemd. It has no "working directory" setting, so the command changes directory itself,
598
+ # and "respawn 3600 10 0" restarts the server for ever, ten seconds after each exit. Output goes to
599
+ # the system log: read it with "logread -e frugaliot".
600
+ USER_LINE=""
601
+ [ "$RUN_USER" = root ] || USER_LINE=" procd_set_param user ${RUN_USER}"
602
+ TMP_INIT=$(mktemp)
603
+ cat > "$TMP_INIT" <<INIT
604
+ #!/bin/sh /etc/rc.common
605
+ # Written by install-openwrt.sh. The Frugal IoT server.
606
+ START=95
607
+ STOP=10
608
+ USE_PROCD=1
609
+
610
+ start_service() {
611
+ procd_open_instance
612
+ procd_set_param command /bin/sh -c "cd '${INSTALL_DIR}' && exec '${INSTALL_DIR}/node_modules/.bin/frugal-iot-server'"
613
+ ${USER_LINE}
614
+ procd_set_param env HOME='${INSTALL_DIR}'
615
+ procd_set_param respawn 3600 10 0
616
+ procd_set_param stdout 1
617
+ procd_set_param stderr 1
618
+ procd_close_instance
619
+ }
620
+ INIT
621
+ if cmp -s "$TMP_INIT" /etc/init.d/frugaliot 2>/dev/null; then
622
+ skip "/etc/init.d/frugaliot already current"
623
+ else
624
+ put 755 root root "$TMP_INIT" /etc/init.d/frugaliot
625
+ ok "/etc/init.d/frugaliot installed, running as ${RUN_USER} from ${INSTALL_DIR}"
626
+ fi
627
+ rm -f "$TMP_INIT"
628
+ /etc/init.d/frugaliot enable
629
+ /etc/init.d/frugaliot restart
630
+
631
+ step "Checking the server answers"
632
+ PORT=$(sed -n 's/^port:[[:space:]]*//p' "${INSTALL_DIR}/config.d/server.yaml" 2>/dev/null | head -1 || true)
633
+ PORT=${PORT:-8080}
634
+ CODE=""; i=0
635
+ while [ "$i" -lt 20 ]; do
636
+ CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://localhost:${PORT}/" || true)
637
+ [ "$CODE" = 200 ] && break
638
+ sleep 2; i=$((i+1))
639
+ done
640
+ if [ "$CODE" = 200 ]; then
641
+ ok "http://localhost:${PORT}/ answered 200"
642
+ else
643
+ echo "The server is not answering on port ${PORT} (last response: ${CODE:-none}). What it logged:" >&2
644
+ logread -e frugaliot 2>/dev/null | tail -30 >&2 || true
645
+ if [ "$CODE" = 404 ]; then
646
+ echo "A 404 means the server is up but cannot find its web pages. Look at publicdir and htmldir in" >&2
647
+ echo "${INSTALL_DIR}/config.d/server.yaml: after a normal install they point into ./node_modules." >&2
648
+ echo "If publicdir is still './public', an earlier frugal-iot-init stopped part-way and its re-run" >&2
649
+ echo "left server.yaml alone. Set publicdir, privatedir, htmldir as in the packaged server.yaml." >&2
650
+ fi
651
+ die "server not answering"
652
+ fi
653
+ ls /etc/rc.d/ 2>/dev/null | grep -q 'frugaliot$' && ok "will start again at boot"
654
+
655
+ # ---------------------------------------------------------------- what is left to do
656
+
657
+ ENROL_SECRET=$(grep -A1 "^enrolment_${ORG_ID}:" "$SECRETS" 2>/dev/null | tail -1 | sed -e 's/^[[:space:]]*-[[:space:]]*//' -e 's/^"//' -e 's/"$//' || true)
658
+
659
+ shown() {
660
+ eval "shown_v=\${$1}"
661
+ case "$shown_v" in "("*) echo "$shown_v"; return ;; esac # not set by this run
662
+ case " $GENERATED " in *" $1 "*) echo "$shown_v" ;; *) echo "(the one you gave)" ;; esac
663
+ }
664
+
665
+ cat <<SUMMARY
666
+
667
+ =======================================================================
668
+ Frugal IoT is installed and running.
669
+ =======================================================================
670
+
671
+ Dashboard http://${BROKER_HOST}:${PORT}/
672
+
673
+ Logins superuser / $(shown SUPER_PW)
674
+ ${ORG_ID} / $(shown LOGIN_PW)
675
+ (Change either with: cd ${INSTALL_DIR} && npx --no frugal-iot-setpassword <login> "<password>")
676
+
677
+ Broker ws://${BROKER_HOST}:9012 for browsers
678
+ ${BROKER_HOST}:1883 for sensor nodes
679
+
680
+ Installed in ${INSTALL_DIR}, running as ${RUN_USER}
681
+ Log of this run ${LOG} (readable only by root; any generated password above is in it)
682
+
683
+ Still to do:
684
+
685
+ 1. A project. Create one on the dashboard before flashing any node: a node says which project
686
+ it is in, and enrolling into one this server does not know is refused.
687
+
688
+ 2. Your sensor nodes. In the PlatformIO project's platformio-secrets-local.ini:
689
+
690
+ build_flags_local =
691
+ '-D SYSTEM_FRUGAL_ORG="${ORG_ID}"'
692
+ '-D SYSTEM_FRUGAL_PROJECT="<the project from 1>"'
693
+ '-D SYSTEM_MQTT_HOST="${BROKER_HOST}"'
694
+ '-D SYSTEM_MQTT_ENROL_URL="http://${BROKER_HOST}:${PORT}/enrol"'
695
+ '-D SYSTEM_MQTT_ENROL_SECRET="${ENROL_SECRET:-see the dashboard, gear icon -> Nodes}"'
696
+
697
+ http and not https, because this router has no certificate yet, so no over-the-air updates.
698
+
699
+ 3. The firewall. By default OpenWrt accepts connections from the LAN and refuses them from the
700
+ WAN, which is what you want: do NOT forward or open ports 1883, 9012 or ${PORT} to the internet.
701
+
702
+ Useful:
703
+ logread -e frugaliot the server's log
704
+ logread -e mosquitto the broker's log
705
+ service frugaliot restart
706
+ cd ${INSTALL_DIR} && npx --no frugal-iot-diagnostic (written for systemd; parts of it will
707
+ report things that do not apply here)
708
+
709
+ SUMMARY