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 +149 -128
- package/README.md +3 -0
- package/frugal-iot-createdb.sql +34 -1
- package/frugal-iot-server.js +56 -4
- package/lib/dynsec-plan.js +24 -0
- package/lib/enrol.js +132 -29
- package/package.json +7 -4
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +48 -19
- package/scripts/addorganization.zsh +25 -5
- package/scripts/diagnostic.zsh +149 -27
- package/scripts/init.zsh +106 -6
- package/scripts/install-pi.sh +269 -44
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`.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
255
|
-
|
|
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
|
|
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
|
|
334
|
-
|
|
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
|
-
|
|
667
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
|
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.
|
|
733
|
+
frugal_iot.configure_mqtt_enrolled(SYSTEM_MQTT_HOST, SYSTEM_MQTT_ENROL_SECRET);
|
|
677
734
|
```
|
|
678
735
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
the
|
|
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
|
-
|
|
685
|
-
|
|
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
|
|
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>`.
|
|
702
|
-
|
|
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
|
|
823
|
-
|
|
824
|
-
* **
|
|
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
|
|
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
|
|
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/`,
|
|
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
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
|
package/frugal-iot-createdb.sql
CHANGED
|
@@ -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,
|
|
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
|
+
);
|
package/frugal-iot-server.js
CHANGED
|
@@ -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
|
-
*
|
|
297
|
-
* that
|
|
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.
|
|
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
|
-
|
|
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
|
*
|
package/lib/dynsec-plan.js
CHANGED
|
@@ -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] = [];
|