frugal-iot-server 2.0.1 → 2.0.2

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.
package/INSTALLATION.md CHANGED
@@ -25,6 +25,12 @@ and go to [Upgrading](#upgrading).
25
25
  `npx --no frugal-iot-diagnostic` from your install directory — it inspects the whole installation and
26
26
  reports what looks wrong. See [When something does not work](#when-something-does-not-work).
27
27
 
28
+ Adding `quiet` makes it say nothing at all unless something is wrong, and exit non-zero when it
29
+ does, so it can be run periodically rather than only when you already have a problem:
30
+ `npx --no frugal-iot-diagnostic quiet`. The word has no dash on purpose — npm parses the command
31
+ line itself and never passes a flag through to the script, and `-q` is worse still, because it is
32
+ npm's own abbreviation for `--quiet`.
33
+
28
34
  ---
29
35
 
30
36
  ## Which Raspberry Pi
@@ -189,17 +195,32 @@ bash install-pi.sh --org myfarm --name "My Farm" --email you@example.com --phone
189
195
  ```
190
196
 
191
197
  It asks for anything it needs that you did not pass, says what it is doing as it goes, and stops at
192
- the first thing that fails - leaving a log, and telling you to run `frugal-iot-diagnostic`. Running
193
- it again after a failure carries on rather than starting over, which matters on a Pi Zero W where one
194
- step takes about 40 minutes. `--dry-run` checks the arguments and shows the plan without touching the
195
- machine.
196
-
197
- **Passwords** are asked for, not invented, unless you say otherwise: it prompts for each (twice, not
198
- echoed) and generates one if you just press Enter. Pass them as `--superuser-password`,
199
- `--login-password` and `--broker-password` to skip the prompts, `--random-passwords` to have all
200
- three generated, or `--yes` to ask nothing at all — which is what to use over ssh with no terminal.
201
- A password you chose is not written to the log; a generated one has to be, since otherwise you would
202
- have no way of knowing it.
198
+ the first thing that fails - leaving a log, and telling you to run `frugal-iot-diagnostic`.
199
+ `--dry-run` checks the arguments and shows the plan without touching the machine.
200
+
201
+ **It is safe to run again, as many times as you like.** Every step that changes something checks
202
+ first and says `already done:` instead, so a run that stopped half way carries on from where it got
203
+ to rather than starting over. That matters most on a Pi Zero W, where compiling `sqlite3` takes
204
+ about 40 minutes and is not repeated once it has succeeded. It is also how to pick up a fix: re-run
205
+ it after upgrading the package and it will put right anything the newer version installs differently.
206
+
207
+ Two things it will tell you it cannot mend by itself, rather than looping:
208
+
209
+ * a `sqlite3` that still will not load after being rebuilt;
210
+ * a `/var/lib/mosquitto/dynamic-security.json` that exists while `config.d/secrets.yaml` has no
211
+ `dynsec_admin_password` - a previous run stopped between creating the one and recording the other,
212
+ and the password it chose was never written down. It tells you to delete that file and run again.
213
+
214
+ **Passwords.** You are asked for the two you will actually type - the `superuser` login and the
215
+ organization's web login - twice each, not echoed, and pressing Enter has one generated instead.
216
+ `--superuser-password` and `--login-password` skip the prompts, `--random-passwords` generates both,
217
+ and `--yes` asks nothing at all, which is what to use over ssh with no terminal. A password you
218
+ chose is not written to the log; a generated one has to be, or you would have no way of knowing it.
219
+
220
+ The organization's *broker* credential is no longer among them: it is generated and never shown,
221
+ because nothing on the Pi needs you to know it. Nodes are issued their own credential when they
222
+ enrol, and browsers derive their own when they log in. `--broker-password` still sets it, for an
223
+ organization whose nodes were flashed before enrolment existed.
203
224
 
204
225
  **It tells you whether a reboot is needed**, rather than leaving you to wonder — it notices a kernel
205
226
  or boot-firmware package in the upgrade, the images in `/boot` changing underneath it, and
@@ -209,8 +230,10 @@ Fetch it with `curl -O` and then run it, rather than piping curl into bash - pip
209
230
  terminal to ask questions at.
210
231
 
211
232
  It finishes with a live server and a tested broker. It cannot do step 9, pointing your nodes at it,
212
- because a node learns its broker by being flashed with it - so it prints the settings to give them.
213
- Nor step 10, HTTPS.
233
+ because a node learns its broker by being flashed with it - so it prints what to build into them:
234
+ the broker host, the organization, and the organization's **enrolment secret**. A node presents that
235
+ secret once, is issued a broker credential of its own, and stores it; the secret grants nothing else,
236
+ no read and no write. Nor can it do step 10, HTTPS.
214
237
 
215
238
  The steps below are what it does, in the same order, if you would rather do it by hand or need to
216
239
  understand what went wrong, otherwise skip to Step 9.
@@ -242,23 +265,38 @@ which you may not have the password for.
242
265
  address does not change. Sensor nodes and phones then have something stable to talk to even where
243
266
  `.local` names do not work.
244
267
 
245
- Now the packages the server needs.
268
+ Now the packages the server needs. Which set depends on whether this is a **32-bit or a 64-bit
269
+ installation** — and that is not the same question as which board it is, so check rather than assume:
246
270
 
247
- **On a Pi 4** (or any 64-bit board):
271
+ ```
272
+ dpkg --print-architecture
273
+ ```
274
+
275
+ `arm64` is a 64-bit installation; `armhf` is a 32-bit one. **Do not use `uname -m` for this.** It
276
+ reports the *kernel*, and Raspberry Pi OS 32-bit boots the 64-bit kernel by default on any board
277
+ that can run it — so a Pi 4 running the 32-bit image answers `armv8l` there while every binary on
278
+ it is 32-bit.
279
+
280
+ **If it said `arm64`:**
248
281
 
249
282
  ```
250
283
  sudo apt install -y nodejs npm sqlite3 zsh
251
284
  node -v
252
285
  ```
253
286
 
254
- **On a Pi Zero W**, three more packages, because a 32-bit machine has to compile part of the server
255
- in step 4 and a Lite image has no compiler:
287
+ **If it said `armhf`** (any 32-bit installation, on a Pi Zero W or a Pi 4 alike) — three more
288
+ packages, because no ready-made binary is published for 32-bit ARM, so part of the server is
289
+ compiled in step 4 and a Lite image has no compiler:
256
290
 
257
291
  ```
258
292
  sudo apt install -y nodejs npm sqlite3 zsh build-essential python3-dev python3-setuptools
259
293
  node -v
260
294
  ```
261
295
 
296
+ > Skipping those three on a 32-bit installation does not fail until step 4, and then it fails deep
297
+ > inside pages of `gyp` output, ending in `ModuleNotFoundError: No module named 'distutils'`. If you
298
+ > are reading this after seeing that, install them and run step 4 again — nothing is lost.
299
+
262
300
  What each is for:
263
301
 
264
302
  * `nodejs` — the server needs **Node 18 or later**. Current Raspberry Pi OS (Debian 13, trixie)
@@ -321,8 +359,8 @@ The server is an npm package. Make a directory for this server to live in and in
321
359
  that directory will hold your configuration, your data, and your database, while npm looks after
322
360
  the software itself underneath it in `node_modules`.
323
361
 
324
- **On a Pi 4** (or any 64-bit board) — a few minutes, everything arrives as ready-made binaries and
325
- nothing is compiled:
362
+ **On a 64-bit installation** (`dpkg --print-architecture` said `arm64`) — a few minutes, everything
363
+ arrives as ready-made binaries and nothing is compiled:
326
364
 
327
365
  ```
328
366
  mkdir ~/frugal-iot
@@ -330,8 +368,10 @@ cd ~/frugal-iot
330
368
  npm install frugal-iot-server
331
369
  ```
332
370
 
333
- **On a Pi Zero W** — the extra flags make npm do one thing at a time, which lowers the peak memory
334
- as well as being kinder to the SD card. This is the slow part; leave it running:
371
+ **On a 32-bit installation** (`armhf`) `sqlite3` is compiled here — a few minutes on a Pi 4, about
372
+ 40 on a Pi Zero W. On a board with under about a gigabyte of memory add `--maxsockets 1 --no-audit
373
+ --no-fund`, which makes npm do one thing at a time: that lowers its peak memory as well as being
374
+ kinder to the SD card. This is the slow part; leave it running:
335
375
 
336
376
  ```
337
377
  mkdir ~/frugal-iot
@@ -663,30 +703,50 @@ still answers. Your server is now installed.
663
703
 
664
704
  ### 9. Point your sensor nodes at the Pi
665
705
 
666
- Your ESP8266/ESP32 nodes are told which broker to use in their sketch — `main.cpp`, or the `.ino`
667
- file if you build in the Arduino IDE. Look for a line like:
706
+ A node is flashed with an **enrolment secret**,
707
+ presents that once, and is issued a broker credential of its own which it stores. The secret grants only one thing — "create a node in this organization" — so it is not the thing to guard, and a node that is stolen cannot be used to read anyone else's data.
668
708
 
669
- ```cpp
670
- frugal_iot.configure_mqtt("frugaliot.naturalinnovation.org", "dev", "public");
709
+ **First, get the enrolment secret.** On the dashboard on the Pi: **gear icon → Nodes → Enrolment secret**. It prints the line ready to paste.
710
+ (It is also in `config.d/secrets.yaml` as `enrolment_myfarm`.)
711
+
712
+ **Second, check the project exists.** A node states which project it is in, and enrolling into one the server does not know is refused. The organization you made in step 6 starts with none, so create one on the dashboard before flashing.
713
+
714
+ Then, on your workstation, in the PlatformIO project's `platformio-secrets-local.ini` for example ...:
715
+
716
+ ```ini
717
+ [common]
718
+
719
+ build_flags_local =
720
+ '-D SYSTEM_FRUGAL_ORG="myfarm"'
721
+ '-D SYSTEM_FRUGAL_PROJECT="lotus"'
722
+ '-D SYSTEM_MQTT_HOST="frugaliot.local"'
723
+ '-D SYSTEM_MQTT_ENROL_URL="http://frugaliot.local:8080/enrol"'
724
+ '-D SYSTEM_MQTT_ENROL_SECRET="<the secret from the dashboard>"'
671
725
  ```
672
726
 
673
- and point it at your Pi instead:
727
+ Note `http`, not `https`, in the enrol URL: a Pi on your own network has no certificate, and step 10 is where that may change in the future. The defaults in `platformio.ini` point at the production server, so all five
728
+ lines are needed to aim a build at your Pi.
729
+
730
+ The sketch itself calls:
674
731
 
675
732
  ```cpp
676
- frugal_iot.configure_mqtt("frugaliot.local", "myfarm", "<broker-password>");
733
+ frugal_iot.configure_mqtt_enrolled(SYSTEM_MQTT_HOST, SYSTEM_MQTT_ENROL_SECRET);
677
734
  ```
678
735
 
679
- The three arguments are the broker's host, the organization, and that organization's broker
680
- password. The organization must be the one you created in step 6, because it is the first part of
681
- every topic the node publishes to, and the password is the *broker* password from that step — not
682
- the login password. Then rebuild and flash the node as usual.
736
+ which is already what the examples do. Then rebuild and flash as usual. The node enrols on first boot and appears on the dashboard by itself.
737
+
738
+ > If the node does not connect, try the Pi's IP address in place of `frugaliot.local`, in both the
739
+ > host and the enrol URL. Resolving `.local` names needs mDNS support in the firmware, which is not
740
+ > something this guide has confirmed; an IP address avoids the question entirely, which is why step
741
+ > 3 suggests reserving one for the Pi in your router.
742
+
743
+ > A node whose enrolment is refused — wrong secret, unknown project, or awaiting approval — retries
744
+ > every half hour rather than giving up, because nobody can reach a node in a field to reboot it.
683
745
 
684
- > If the node does not connect, try the Pi's IP address in place of `frugaliot.local`. Resolving
685
- > `.local` names needs mDNS support in the firmware, which is not something this guide has
686
- > confirmed; an IP address avoids the question entirely, which is why step 3 suggests reserving one
687
- > for the Pi in your router.
746
+ **To rotate the secret**, add a new line above the old one in `config.d/secrets.yaml` and leave the
747
+ old one there until every node flashed with it has enrolled. Deleting it first strands them.
688
748
 
689
- You can confirm nodes are reporting without involving the UI, using the subscriber from step 6:
749
+ You can watch nodes arriving without involving the UI, using the subscriber from step 6:
690
750
 
691
751
  ```
692
752
  mosquitto_sub -h localhost -u myfarm -P '<broker-password>' -t '#' -v
@@ -698,8 +758,10 @@ port 1883 is reachable from off the Pi, which is what the nodes need.
698
758
  ### 9a. Optional: outgoing mail, so people can reset a forgotten password
699
759
 
700
760
  Without this, a forgotten password can only be fixed by you, on the Pi, with
701
- `npx --no frugal-iot-setpassword <username> <new-password>`. The login page's "Forgot password?"
702
- link says "Password reset is not available on this server" rather than pretending to send anything.
761
+ `npx --no frugal-iot-setpassword <username> <new-password>`.
762
+
763
+ Until you set this up, the login page's "Forgot password?"
764
+ link says "Password reset is not available on this server".
703
765
 
704
766
  An offline Pi cannot send mail at all, so skip this unless it has internet access.
705
767
 
@@ -819,10 +881,9 @@ surrounding work.
819
881
  It comes in three parts, and only the first is a one-off:
820
882
 
821
883
  * **11a — preparing a production server to accept bridges.** A TLS listener, a certificate, and an
822
- access-control file. Done once for a given production server, and not again until there is
823
- another one.
824
- * **11b — authorizing this Pi on that server.** One command, run there, once per Pi.
825
- * **11c — pointing this Pi at it.** One command, run here, once per Pi.
884
+ access-control file. Done once for a given production server.
885
+ * **11b — authorizing this Pi on the production server.** One command, run on that server, once per Pi.
886
+ * **11c — pointing this Pi at it.** One command, run on the Pi, once per Pi.
826
887
 
827
888
  **What you get, and what you do not.** While the link is up, readings appear on production within
828
889
  a second or so. While it is down, the Pi records everything as usual and production simply has a
@@ -839,7 +900,7 @@ asks for it; with it, this Pi pulls that organization's logins and permissions f
839
900
  fifteen minutes, so the same people can use this Pi's dashboard — including while production is
840
901
  unreachable, which is the point of the Pi. It is optional: leave the token blank and the bridge
841
902
  relays readings as before. Nothing derived from a password travels either way, and each server
842
- issues its own broker credentials, so the same person has different ones here and there. Revoking a
903
+ issues its own broker credentials, so the same person has different ones on the Pi and on production. Revoking a
843
904
  permission on production removes it here on the next pull. See SECURITY.md.
844
905
 
845
906
  #### 11a. Preparing a production server to accept bridges
@@ -917,67 +978,6 @@ installed by hand, the written procedure for doing that.
917
978
  > Note the asymmetry with the Pi end below: a *certificate* change needs only `reload`, but adding
918
979
  > or changing a *bridge* needs a full `restart`, because Mosquitto does not reload bridges.
919
980
 
920
- **Turn on access control** — if this broker predates the release that ships it. Since
921
- `extras/aclfile` became part of the base install, a Pi set up with `install-pi.sh` already has this
922
- and there is nothing to do here; the steps below are for a broker built before that, or one whose
923
- configuration was written by hand.
924
-
925
- Without it, any account that can log in to the broker can publish and subscribe anywhere on it, so
926
- a per-Pi account would be no more confined than the organization's own. This is the part to plan
927
- carefully, because Mosquitto's ACL file is **deny-by-default**: the moment the broker names an
928
- `acl_file`, every existing account with no entry in it stops working. So the file that restricts new
929
- bridge accounts has to grant the existing organizations what they already have, in the same change.
930
-
931
- ```
932
- sudo tee /etc/mosquitto/aclfile >/dev/null <<'EOF'
933
- # Deny-by-default: an account with no rule here can connect but reach no topic at all.
934
-
935
- # Each organization reaches only its own topic tree. "%u" is the connecting account name, and the
936
- # first element of every Frugal IoT topic is the organization id - so this is what the nodes,
937
- # dashboards and the server's own logger already do. It just stops being optional.
938
- pattern readwrite %u/#
939
-
940
- # Whether a bridge is up, for frugal-iot-diagnostic. "pattern readwrite %u/#" does not match $SYS,
941
- # so without this the diagnostic reports every bridge as never having connected. Mosquitto logs a
942
- # warning that this pattern contains no %u - harmless, and a "topic" line would apply to anonymous
943
- # clients only, which is not what is wanted.
944
- pattern read $SYS/broker/connection/+/state
945
- EOF
946
- sudo chown mosquitto:mosquitto /etc/mosquitto/aclfile
947
- sudo chmod 600 /etc/mosquitto/aclfile
948
- ```
949
-
950
- The ownership matters: like the password file and the TLS key, the broker reads this *after*
951
- dropping to the `mosquitto` user, so a root-only file stops it starting. Mode 600 owned by
952
- `mosquitto` rather than 644 owned by root — 2.0.21 accepts the latter but warns on every start that
953
- a file it does not own, or one that is world readable, "will be refused by a future version". Then name it in the configuration
954
- and restart:
955
-
956
- ```
957
- echo 'acl_file /etc/mosquitto/aclfile' | sudo tee /etc/mosquitto/conf.d/zy-frugal-iot-acl.conf
958
- sudo systemctl restart mosquitto
959
- ```
960
-
961
- Check every existing organization can still log in **before** connecting any Pi — a wrong ACL file
962
- locks out every node and dashboard at once, and the reason appears only in Mosquitto's own log. On a
963
- Frugal IoT server the quickest check is its journal, which logs one line per organization:
964
-
965
- ```
966
- sudo journalctl -u frugaliot -n 40 --no-pager | grep -iE "mqtt |not authoris"
967
- ```
968
-
969
- Every organization should show `connect` and none should show `not authorized`. To back the change
970
- out, delete `/etc/mosquitto/conf.d/zy-frugal-iot-acl.conf` and restart.
971
-
972
- Nothing needs adding here per Pi: `frugal-iot-addbridge-prod` in 11b appends each bridge's own rule.
973
-
974
- > `pattern readwrite %u/#` on its own denies `$SYS`, which would stop `frugal-iot-diagnostic`
975
- > reporting whether a bridge is connected — it reads `$SYS/broker/connection/+/state` as the
976
- > organization. The second rule above restores exactly that one topic and nothing else. Reading all
977
- > of `$SYS` would also work and would hand every account the broker's client counts, subscription
978
- > counts and traffic totals, which on a multi-organization broker tells each organization about the
979
- > others.
980
-
981
981
  #### 11b. Authorizing this Pi (run on the production server)
982
982
 
983
983
  Once per Pi. On the production server, from its own directory:
@@ -1016,19 +1016,13 @@ cd ~/frugal-iot
1016
1016
  npx --no frugal-iot-addbridge-pi <org-id> <prod-host> bridge-<site-name>
1017
1017
  ```
1018
1018
 
1019
- It asks for the password rather than taking it on the command line, so it stays out of your shell
1020
- history. Then it checks the far end is reachable and that its certificate is valid for that name,
1019
+ It asks for the password, then it checks the far end is reachable and that its certificate is valid for that name,
1021
1020
  writes `/etc/mosquitto/conf.d/frugal-iot-bridge.conf`, restarts the broker, and reports whether the
1022
- bridge actually connected.
1023
-
1024
- The restart is unavoidable: Mosquitto does not pick up bridges on a reload signal, so `reload`
1025
- appears to succeed and changes nothing. It briefly disconnects every node, which they recover from
1026
- on their own.
1021
+ bridge actually connected. It briefly disconnects every node,
1022
+ which they recover from on their own.
1027
1023
 
1028
1024
  > The configuration goes into `/etc/mosquitto/conf.d/` and not into this installation's
1029
- > `config.d/`, because it holds a password for the production server. Everything under `config.d/`
1030
- > is served to any logged-in browser by `/config.json`, so a credential put there would not stay
1031
- > private. The file is written mode 600 for the same reason.
1025
+ > `config.d/`, to keep the password out of `/config.json`. The file is written mode 600 for the same reason.
1032
1026
 
1033
1027
  To undo it: `sudo rm /etc/mosquitto/conf.d/frugal-iot-bridge.conf && sudo systemctl restart
1034
1028
  mosquitto`. The Pi goes back to being self-contained and keeps everything it has recorded.
@@ -1222,24 +1216,51 @@ journalctl -u frugaliot -n 30
1222
1216
  npm ls frugal-iot-server
1223
1217
  ```
1224
1218
 
1225
- `frugal-iot-init` leaves your files alone, so watch its output for a section headed **"These files
1226
- were left as you have them, but this release ships a different version"**. It gives you the `diff`
1227
- command for each. That matters most for the files you copy somewhere else — a changed
1228
- `extras/mosquitto.conf` or `extras/frugaliot.service` does nothing until you install it again:
1219
+ `frugal-iot-init` leaves your own files alone. Two sections of its output are worth reading.
1220
+
1221
+ **"These files were left as you have them, but this release ships a different version"** covers the
1222
+ sensor schema under `config.d/schema/`, and gives you a `diff` command for each. If you added a
1223
+ sensor type of your own, that is exactly what it should say; if you did not, this release changed
1224
+ the schema and the difference is worth reading.
1225
+
1226
+ **"Installed elsewhere from extras/, and older than this release"** covers the files that live
1227
+ somewhere else — the broker configuration, its ACL, the systemd unit. It compares what is actually
1228
+ in `/etc`, not the copy in `extras/`, because that copy is never overwritten and so differs for ever
1229
+ once a release changes it. To bring anything it names up to date, re-run the installer:
1229
1230
 
1230
1231
  ```
1231
- sudo cp node_modules/frugal-iot-server/extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf
1232
- sudo cp node_modules/frugal-iot-server/extras/aclfile /etc/mosquitto/aclfile
1233
- sudo chown mosquitto:mosquitto /etc/mosquitto/aclfile && sudo chmod 600 /etc/mosquitto/aclfile
1234
- sudo systemctl restart mosquitto
1235
- sudo cp node_modules/frugal-iot-server/extras/frugaliot.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl restart frugaliot
1232
+ bash node_modules/frugal-iot-server/scripts/install-pi.sh
1236
1233
  ```
1237
1234
 
1238
- Note those read from `node_modules/frugal-iot-server/extras/`, **not** from `extras/` in this
1239
- directory. `frugal-iot-init` keeps your local copy and only tells you it differs, so the local one is
1240
- the *old* version — copying it would reinstall what you already have. Re-running the install script
1241
- (`bash node_modules/frugal-iot-server/scripts/install-pi.sh`) does the same job and reads from the
1242
- same place.
1235
+ It is safe to re-run: it changes only what is out of date, and it is the *only* correct way to
1236
+ install these three, because none of them can simply be copied into place:
1237
+
1238
+ | file | why a plain `cp` is wrong |
1239
+ |------|---------------------------|
1240
+ | `extras/mosquitto.conf` | carries `plugin PLUGIN_PATH_SET_BY_INSTALLER`. The path differs by distribution, so the installer substitutes it. Copied as-is, **the broker will not start.** |
1241
+ | `extras/aclfile` | `frugal-iot-addbridge-prod` appends a rule per Pi. Copying over it deletes them, and a mosquitto `acl_file` is deny-by-default — so every bridge goes on logging in and reaches nothing at all. |
1242
+ | `extras/frugaliot.service` | written for user `pi` in `/home/pi/frugal-iot`; the installer rewrites `User`, `WorkingDirectory` and `ExecStart` for wherever this actually went. |
1243
+
1244
+ The installer will not overwrite an ACL file that has per-bridge rules in it. If this release adds a
1245
+ rule to a broker that has them, it says which lines are missing and leaves the file alone for you to
1246
+ add them by hand, then `sudo systemctl reload mosquitto`.
1247
+
1248
+ > **If your broker's configuration was assembled by hand** — several small files in
1249
+ > `/etc/mosquitto/conf.d/` rather than the single `frugal-iot.conf` the installer writes — then
1250
+ > `frugal-iot-init` reports those files under "Nothing at the usual place, so these were not
1251
+ > compared", and re-running the installer is *not* what you want: it would add `frugal-iot.conf`
1252
+ > alongside what you already have, declaring the same listeners and the same plugin twice. Compare
1253
+ > by hand instead, and add only what is missing:
1254
+ >
1255
+ > ```
1256
+ > sudo grep -hvE '^\s*(#|$)' /etc/mosquitto/conf.d/*.conf | sort -u > /tmp/running.txt
1257
+ > grep -hvE '^\s*(#|$)' node_modules/frugal-iot-server/extras/mosquitto.conf | sort -u > /tmp/shipped.txt
1258
+ > diff /tmp/running.txt /tmp/shipped.txt
1259
+ > ```
1260
+ >
1261
+ > Lines only in the running config are usually this machine's own — TLS `certfile`/`keyfile`, a
1262
+ > bridge `listener`, a `password_file` in a different place. Lines only in the shipped one are what
1263
+ > this release added.
1243
1264
 
1244
1265
  > It does not compare `config.yaml`, `config.d/mqtt.yaml`, `config.d/logger.yaml` or
1245
1266
  > `config.d/server.yaml`, because those hold your own settings and would differ every time. If a
package/README.md CHANGED
@@ -100,6 +100,9 @@ broker logins, what has been logged - and changes nothing:
100
100
  ```
101
101
  npx --no frugal-iot-diagnostic
102
102
  ```
103
+ Add `quiet` and it says nothing unless something is wrong, exiting non-zero when it does, which is
104
+ what makes it usable as a periodic check: `npx --no frugal-iot-diagnostic quiet`. No dash, because
105
+ npm consumes flags before they reach the script - and `-q` is npm's own `--quiet`.
103
106
 
104
107
  To upgrade later: `npm update frugal-iot-server` then `npx --no frugal-iot-init`.
105
108
 
@@ -96,7 +96,8 @@ CREATE TABLE IF NOT EXISTS `bridges` (
96
96
  -- Keyed by (org, nodeid), NOT by project: a node states its own project, so keying on it would let
97
97
  -- a denied node return by claiming a different one.
98
98
  --
99
- -- Persisted, unlike the record of failed attempts, because a restart must not un-deny a node.
99
+ -- Persisted, because a restart must not un-deny a node. So is node_attempts below, for a
100
+ -- different reason - see the note there.
100
101
  CREATE TABLE IF NOT EXISTS `node_grants` (
101
102
  `org` TEXT NOT NULL,
102
103
  `project` TEXT NOT NULL DEFAULT '',
@@ -193,3 +194,35 @@ CREATE TABLE IF NOT EXISTS notification_queue (
193
194
  -- silently becoming the superuser.
194
195
  INSERT OR IGNORE INTO `users` (`id`, `username`, `name`, `organization`) VALUES (0, 'everyone', 'Everyone', '');
195
196
  INSERT OR IGNORE INTO `users` (`id`, `username`, `name`, `organization`) VALUES (1, 'superuser', 'Superuser', '');
197
+
198
+ -- Every node that asked to enrol and was refused, for the Nodes card on the dashboard.
199
+ --
200
+ -- This is the ONLY way an administrator learns that a node exists: a node id is not discoverable
201
+ -- from the server, so a node nobody can reach physically can only announce itself by asking and
202
+ -- being refused. The rows are therefore UNTRUSTED - anyone can make an id appear by attempting
203
+ -- enrolment - so each keeps when it asked, from where, and why it was refused, for an administrator
204
+ -- to recognise rather than to trust.
205
+ --
206
+ -- Persisted, although it was in memory first and the reasoning for that was not wrong: the nodes
207
+ -- table has no last_seen column because that would be a database write per reading. But an
208
+ -- enrolment attempt is not a reading. It is capped by the rate limiter at 20 per organization per
209
+ -- hour, repeats from one node are throttled to one write a minute, and a rate-limited request is
210
+ -- not written at all - so the write rate here is a few rows an hour, not one per reading.
211
+ --
212
+ -- What losing them on a restart cost was the thing this table exists to give: a node retries only
213
+ -- every half hour, so a server restart made a node that was asking invisible for up to thirty
214
+ -- minutes, which is indistinguishable from a node that is not asking at all. That is precisely the
215
+ -- case an administrator is trying to diagnose.
216
+ --
217
+ -- Keyed by (org, nodeid) like node_grants, and for the same reason: a node states its own project,
218
+ -- so keying on it would let one node occupy several rows by varying what it claims.
219
+ CREATE TABLE IF NOT EXISTS `node_attempts` (
220
+ `org` TEXT NOT NULL,
221
+ `project` TEXT NOT NULL DEFAULT '',
222
+ `nodeid` TEXT NOT NULL,
223
+ `reason` TEXT NOT NULL DEFAULT '',
224
+ `from_addr` TEXT NOT NULL DEFAULT '',
225
+ `at` INTEGER NOT NULL,
226
+ `count` INTEGER NOT NULL DEFAULT 1,
227
+ UNIQUE(`org`, `nodeid`)
228
+ );
@@ -293,13 +293,25 @@ function get_people_list(org, cb) {
293
293
  * Nodes that asked to enrol and were refused. Module level because two things need it: POST /enrol
294
294
  * writes to it and GET /nodes_list reads it, and they are in different scopes.
295
295
  *
296
- * In memory on purpose - see makeAttemptLog. Losing it on restart costs nothing, because a node
297
- * that is still retrying reappears within minutes.
296
+ * Kept in memory and written through to the node_attempts table - see makeAttemptLog. It used to be
297
+ * memory alone, which meant a restart hid a node that was asking for up to the half hour until its
298
+ * next attempt.
298
299
  */
299
300
  const enrolAttempts = makeAttemptLog();
300
301
 
301
302
  // Which nodes have enrolled in an organization. No password, obviously - the point of the list is
302
303
  // to know what exists and to be able to forget one.
304
+ /*
305
+ * The Pis bridging to this server for one organization.
306
+ *
307
+ * The token is deliberately not selected: it is the credential a Pi presents to pull replicated
308
+ * logins, and this list is rendered in a browser. "site" is also the bridge's broker account name
309
+ * ("bridge-<site>"), which is what ties a row here to the live state the broker publishes on
310
+ * $SYS/broker/connection/<clientid>/state.
311
+ */
312
+ const sqlBridgesList = `
313
+ SELECT site, created_at, last_pull FROM bridges WHERE org = ? ORDER BY site
314
+ ;`;
303
315
  const sqlNodesList = `
304
316
  SELECT project, nodeid, lora, enrolled_at FROM nodes WHERE org = ? ORDER BY project, nodeid
305
317
  ;`;
@@ -628,6 +640,11 @@ function loggedInOrRedirect(req, res, next) {
628
640
  } else {
629
641
  // If originalUrl is /private/index.html then req.url is just /index.html
630
642
  const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
643
+ // Never store this one. The paths behind it are fetched by script as well as navigated to - the
644
+ // dashboard asks for /data/.../<date>.csv - so a redirect kept in a cache would go on sending a
645
+ // browser that has since logged in to the login page, and that arrives as a 200 full of HTML
646
+ // where the caller expected CSV.
647
+ res.set('Cache-Control', 'no-store');
631
648
  res.redirect(307, `${loginUrl}?${q}`);
632
649
  }
633
650
  }
@@ -715,10 +732,13 @@ export function loggedInOrFail(req, res, next) {
715
732
  }
716
733
  // While serving from frugal-iot-client it is only the dashboard we want to protect
717
734
  // as need user to be logged in to access config etc
718
- // Note if originalUrl is /dashboard/index.html then req.url is just /index.html
735
+ // Note if originalUrl is /dashboard/index.html then req.path is just /index.html
719
736
  function shouldIBeLoggedIn(req, res, next) {
720
737
  //console.log("XXX shouldIBeLoggedIn", req.user, req.params.org);
721
- if ((['/','/index.html'].includes(req.url)) && !req.isAuthenticated()) {
738
+ // req.path, not req.url: the latter carries the query string, and the page is reached with one in
739
+ // ordinary use - login.html passes "lang" back - so /dashboard/?lang=fr would match neither name
740
+ // here and be served to whoever asked.
741
+ if ((['/','/index.html'].includes(req.path)) && !req.isAuthenticated()) {
722
742
  console.log(`Not authenticated redirecting ${req.url} for login`);
723
743
  // Capture the full original URL as-is in "url" (so e.g. /data?... comes back with all its params
724
744
  // intact), encoded so its own query string can't corrupt this redirect's query string. Carry "lang"
@@ -726,6 +746,9 @@ function shouldIBeLoggedIn(req, res, next) {
726
746
  // renders in the right language.
727
747
  const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
728
748
  if (req.query.lang) { q.set('lang', req.query.lang); }
749
+ // Whether a browser is logged in has to be asked again every time, so say so rather than
750
+ // relying on a 307 being uncacheable by default - as loggedInOrRedirect does for the same reason.
751
+ res.set('Cache-Control', 'no-store');
729
752
  res.redirect(307, `${loginUrl}?${q}`);
730
753
  } else {
731
754
  next();
@@ -1023,6 +1046,15 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1023
1046
  console.error("Error loading projects into config", err);
1024
1047
  }
1025
1048
  });
1049
+ // Reload the nodes that were asking to enrol when this server last stopped, so a restart
1050
+ // does not hide them until each retries - which for a node is half an hour away.
1051
+ enrolAttempts.useDatabase(db, (err, loaded) => {
1052
+ if (err) {
1053
+ console.error("Could not read past enrolment attempts", err.message);
1054
+ } else if (loaded) {
1055
+ console.log("Enrolment attempts pending:", loaded);
1056
+ }
1057
+ });
1026
1058
  // app.use(express.json()); // Not needed
1027
1059
  app.use(express.urlencoded({ extended: true })); // Passport will not function without this
1028
1060
  app.set('trust proxy', 1); // trust first proxy - see note in https://www.npmjs.com/package/express-session
@@ -1409,6 +1441,26 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1409
1441
  can_ADMIN, // Gets org from URL
1410
1442
  send_nodes_list,
1411
1443
  );
1444
+ /*
1445
+ * Which Pis bridge to this server, and when each last checked in.
1446
+ *
1447
+ * last_pull is stamped by lib/replica.js every time a Pi collects this organization's
1448
+ * logins, so it answers "is that Pi alive" without needing the broker at all. Whether the
1449
+ * BRIDGE itself is up is a different question, answered by the retained message the broker
1450
+ * publishes on $SYS/broker/connection/<account>/state - which the dashboard subscribes to
1451
+ * directly, since it already holds an MQTT connection. The two differ in a way worth
1452
+ * seeing: a Pi that is up with a broken bridge still pulls logins.
1453
+ */
1454
+ app.get('/bridges_list/:org',
1455
+ loggedInOrFail,
1456
+ can_ADMIN, // Gets org from URL
1457
+ (req, res) => {
1458
+ db.all(sqlBridgesList, [req.params.org], (err, rows) => {
1459
+ if (err) { return res.status(500).send(err.message); }
1460
+ res.json(rows.map((r) => ({ ...r, account: `bridge-${r.site}` })));
1461
+ });
1462
+ },
1463
+ );
1412
1464
  /*
1413
1465
  * Forget a node, so it can enrol again and be issued a new credential.
1414
1466
  *
@@ -28,6 +28,15 @@ export const names = {
28
28
  gatewayRole: (org) => `${org}-gateway`,
29
29
  discoverRole:(org) => `${org}-discover`,
30
30
  ownSubtreeRole: 'own-subtree', // one for the whole broker: publishClientSend %u/#
31
+ /*
32
+ * Reading whether a bridge is connected, for the dashboard's Bridges card.
33
+ *
34
+ * A GROUP and not just a role, although only one role goes in it, because applyUser removes a
35
+ * client from groups it no longer qualifies for but never removes a role - so losing ADMIN would
36
+ * leave the rights behind if this were granted as a role.
37
+ */
38
+ bridgeStateRole: 'bridge-state',
39
+ bridgeStateGroup: 'bridge-state',
31
40
  publicRole: 'public-read', // one for the whole broker: the id-0 "everyone" rows
32
41
  publicGroup: 'public', // what setAnonymousGroup points at
33
42
  nodeClient: (org, project, nodeid) => `${org}/${project}/${nodeid}`,
@@ -111,6 +120,12 @@ export function groupsForUser(rows) {
111
120
  const project = r.project || ORGWIDE;
112
121
  if (r.capability === 'READ') groups.add(names.readGroup(r.org, project));
113
122
  if (r.capability === 'WRITE') groups.add(names.writeGroup(r.org, project));
123
+ // An organization's admin may see whether its Pis' bridges are up. The topic is not
124
+ // org-scoped - a clientid is "bridge-<site>" with no organization in it - so this does show an
125
+ // admin of one organization that another's bridges exist, by name. Site names are not secret
126
+ // and the alternative is naming bridge accounts per organization, which would break every
127
+ // bridge already configured.
128
+ if (r.capability === 'ADMIN') groups.add(names.bridgeStateGroup);
114
129
  }
115
130
  // Every user also gets whatever is public, as one role rather than per-organization membership.
116
131
  roles.add(names.publicRole);
@@ -133,6 +148,15 @@ export function desiredRolesAndGroups(scopes, publics, projects) {
133
148
  { acltype: 'publishClientSend', topic: topics.ownSubtree, allow: true },
134
149
  ];
135
150
 
151
+ // Whether each bridge is connected: mosquitto publishes a retained 1/0 per bridge account, and
152
+ // both ACLs are needed - subscribePattern to be allowed to ask, publishClientReceive to actually
153
+ // be sent it.
154
+ roles[names.bridgeStateRole] = [
155
+ { acltype: 'subscribePattern', topic: topics.bridgeState, allow: true },
156
+ { acltype: 'publishClientReceive', topic: topics.bridgeState, allow: true },
157
+ ];
158
+ groups[names.bridgeStateGroup] = { roles: [names.bridgeStateRole] };
159
+
136
160
  // Public read, as ACL pairs on a single role - so withdrawing it is one removeRoleACL, for
137
161
  // everybody, rather than removing every user from a group.
138
162
  roles[names.publicRole] = [];