frugal-iot-server 0.3.9 → 2.0.0

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
@@ -503,9 +503,9 @@ npx --no frugal-iot-addorganization myfarm "My Farm" you@example.com +6112345678
503
503
 
504
504
  The arguments are: organization id, display name, your email, your phone (`+` and digits only),
505
505
  and a password. That one command writes `config.d/organizations/myfarm.yaml`, creates a login account
506
- named after the organization (`myfarm`), grants it admin rights, creates its OTA directory, **and adds
507
- the organization's account to the broker's password file** — which is why the broker had to be
508
- installed first.
506
+ named after the organization (`myfarm`), grants it admin, read and write rights, creates its OTA
507
+ directory, **and adds the organization's account to the broker's password file** — which is why the
508
+ broker had to be installed first.
509
509
 
510
510
  * **Organization id `myfarm`** — must be 1–10 lower-case letters or digits. It becomes the first part
511
511
  of every MQTT topic, so it must match what your sensor nodes are configured to publish to.
@@ -833,6 +833,15 @@ in the instant the link returned. A gap is honest; that would not be. Controls a
833
833
  other way round, and *are* queued, so turning something on from production while the Pi is offline
834
834
  takes effect when it reconnects.
835
835
 
836
+ **People, as well as readings.** A bridge relays topics, not accounts, so on its own it does not let
837
+ someone registered on production log in here. Step 11b also prints a *replica token*, and step 11c
838
+ asks for it; with it, this Pi pulls that organization's logins and permissions from production every
839
+ fifteen minutes, so the same people can use this Pi's dashboard — including while production is
840
+ unreachable, which is the point of the Pi. It is optional: leave the token blank and the bridge
841
+ 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
843
+ permission on production removes it here on the next pull. See SECURITY.md.
844
+
836
845
  #### 11a. Preparing a production server to accept bridges
837
846
 
838
847
  Once per production server, not per Pi — and if bridging to `frugaliot.naturalinnovation.org`
@@ -908,12 +917,16 @@ installed by hand, the written procedure for doing that.
908
917
  > Note the asymmetry with the Pi end below: a *certificate* change needs only `reload`, but adding
909
918
  > or changing a *bridge* needs a full `restart`, because Mosquitto does not reload bridges.
910
919
 
911
- **Turn on access control.** Without it, any account that can log in to the broker can publish and
912
- subscribe anywhere on it, so a per-Pi account would be no more confined than the organization's
913
- own. This is the part to plan carefully, because Mosquitto's ACL file is **deny-by-default**: the
914
- moment the broker names an `acl_file`, every existing account with no entry in it stops working.
915
- So the file that restricts new bridge accounts has to grant the existing organizations what they
916
- already have, in the same change.
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.
917
930
 
918
931
  ```
919
932
  sudo tee /etc/mosquitto/aclfile >/dev/null <<'EOF'
@@ -923,12 +936,21 @@ sudo tee /etc/mosquitto/aclfile >/dev/null <<'EOF'
923
936
  # first element of every Frugal IoT topic is the organization id - so this is what the nodes,
924
937
  # dashboards and the server's own logger already do. It just stops being optional.
925
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
926
945
  EOF
927
- sudo chmod 644 /etc/mosquitto/aclfile
946
+ sudo chown mosquitto:mosquitto /etc/mosquitto/aclfile
947
+ sudo chmod 600 /etc/mosquitto/aclfile
928
948
  ```
929
949
 
930
- `chmod 644` matters: like the password file and the TLS key, the broker reads this *after* dropping
931
- to the `mosquitto` user, so a root-only file stops it starting. Then name it in the configuration
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
932
954
  and restart:
933
955
 
934
956
  ```
@@ -949,10 +971,12 @@ out, delete `/etc/mosquitto/conf.d/zy-frugal-iot-acl.conf` and restart.
949
971
 
950
972
  Nothing needs adding here per Pi: `frugal-iot-addbridge-prod` in 11b appends each bridge's own rule.
951
973
 
952
- > One thing this costs: `pattern readwrite %u/#` also denies `$SYS`, so the production broker can no
953
- > longer tell anyone which bridges are currently connected. The Pi still reports its own bridge
954
- > state to `frugal-iot-diagnostic`, which is where you would look anyway, so this is a fair trade —
955
- > but it is why production's dashboard cannot show a site as up or down.
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.
956
980
 
957
981
  #### 11b. Authorizing this Pi (run on the production server)
958
982
 
@@ -1204,10 +1228,19 @@ command for each. That matters most for the files you copy somewhere else — a
1204
1228
  `extras/mosquitto.conf` or `extras/frugaliot.service` does nothing until you install it again:
1205
1229
 
1206
1230
  ```
1207
- sudo cp extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf && sudo systemctl restart mosquitto
1208
- sudo cp extras/frugaliot.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl restart frugaliot
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
1209
1236
  ```
1210
1237
 
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.
1243
+
1211
1244
  > It does not compare `config.yaml`, `config.d/mqtt.yaml`, `config.d/logger.yaml` or
1212
1245
  > `config.d/server.yaml`, because those hold your own settings and would differ every time. If a
1213
1246
  > release note mentions a new setting in one of them, compare it yourself:
@@ -1223,6 +1256,32 @@ do. On a Pi, compare the two files above and copy across the settings you want.
1223
1256
  Your organizations, accounts, database and logged data are untouched by an upgrade — they live in
1224
1257
  this directory, not in `node_modules`.
1225
1258
 
1259
+ ### One thing to do by hand after upgrading: grant WRITE
1260
+
1261
+ Commanding a device through the API (`/devices/action`, `/devices/property`) now requires a **WRITE**
1262
+ permission, which is separate from READ and is *not* implied by ADMIN. New organizations get it
1263
+ automatically, but an upgrade does not touch your database, so on an existing server nobody has it
1264
+ yet and switching a relay on answers 401.
1265
+
1266
+ Grant it from the dashboard — **Admin → People**, pick the person, choose `WRITE` — or in one
1267
+ statement, to everyone who already administers an organization:
1268
+
1269
+ ```
1270
+ sqlite3 frugal-iot.db "INSERT OR IGNORE INTO permissions (id, capability, org)
1271
+ SELECT id, 'WRITE', org FROM permissions WHERE capability = 'ADMIN';"
1272
+ sudo systemctl restart frugaliot
1273
+ ```
1274
+
1275
+ Check who has it:
1276
+
1277
+ ```
1278
+ sqlite3 frugal-iot.db "SELECT id, org FROM permissions WHERE capability = 'WRITE';"
1279
+ ```
1280
+
1281
+ Do **not** grant WRITE to id `0`. That row means "every logged-in account", including anyone who has
1282
+ just registered themselves, and handing them write access is exactly what the WRITE check exists to
1283
+ prevent. (Read is a different matter — see the `(0, 'READ', ...)` rows, which are there on purpose.)
1284
+
1226
1285
  ---
1227
1286
 
1228
1287
  ## Open questions
@@ -523,11 +523,16 @@ soc:
523
523
  name: Battery
524
524
  min: 0
525
525
  max: 16000
526
+ # controlfloat is "log: false" because most control inputs are wired from a topic that is
527
+ # already logged in its own right. Battery and panel are not - the node measures and
528
+ # publishes them here and nowhere else, so without this they were never archived.
529
+ log: true
526
530
  - leaf: panel
527
531
  leaf_from: controlfloat
528
532
  name: Panel
529
533
  min: 0
530
534
  max: 30000
535
+ log: true
531
536
  - leaf: profile
532
537
  leaf_from: controlint
533
538
  name: Battery type
@@ -554,6 +559,16 @@ soilmodbus:
554
559
  - leaf: temperature
555
560
  name: "Soil Temperature"
556
561
  color: brown
562
+ # The node's clock - what time it thinks it is and how far from UTC it displays. Infrastructure
563
+ # rather than a sensor, so it belongs in the Frugal_IoT drop-down alongside ota, and neither leaf is
564
+ # logged. Without this entry both leaves resolved to no schema at all, so every message from them
565
+ # reached valueFromText with no type and was reported as "Unrecognized message type: undefined".
566
+ time:
567
+ name: Time
568
+ insidefrugaliot: true
569
+ topics:
570
+ - leaf: epoch
571
+ - leaf: offset
557
572
  ultrasonic:
558
573
  name: "Ultrasonic Distance"
559
574
  topics:
@@ -273,6 +273,18 @@ eco2:
273
273
  significantvalue: 5
274
274
  rw: r
275
275
  graphable: true
276
+ # Unix time in seconds, as the node's clock currently reads it - published by the "time" module so
277
+ # the client can see how far a node has drifted. A clock reading rather than a measurement: logging
278
+ # it would fill the CSVs with a number that only ever counts up.
279
+ epoch:
280
+ leaf: epoch
281
+ name: Epoch
282
+ type: int
283
+ log: false
284
+ display: text
285
+ color: "#000000"
286
+ rw: r
287
+ units: s
276
288
  gas:
277
289
  leaf: gas
278
290
  name: Gas Resistance
@@ -481,6 +493,17 @@ name:
481
493
  display: text
482
494
  rw: w
483
495
  retain: true
496
+ # How far the node is displaying from UTC, so it can show local time. Like epoch, a setting the node
497
+ # reports back rather than anything measured, so not logged.
498
+ offset:
499
+ leaf: offset
500
+ name: UTC Offset
501
+ type: int
502
+ log: false
503
+ display: text
504
+ color: "#000000"
505
+ rw: r
506
+ units: min
484
507
  on:
485
508
  leaf: on
486
509
  name: On
package/extras/aclfile ADDED
@@ -0,0 +1,38 @@
1
+ # Frugal IoT access control. Named by acl_file in extras/mosquitto.conf.
2
+ #
3
+ # DENY BY DEFAULT: the moment the broker names an acl_file, an account with no rule here can still
4
+ # log in but can reach no topic at all. So adding an account means adding its rule in the same
5
+ # change - see scripts/addbridge-prod.zsh, which appends one per Pi.
6
+ #
7
+ # This file is read AFTER mosquitto drops privileges, so it must be readable by the mosquitto user.
8
+ # Install it as mosquitto:mosquitto mode 600 - NOT root-owned 644. Mosquitto 2.0.21 warns
9
+ # "owner is not mosquitto ... Future versions will refuse to load this file" and separately
10
+ # "has world readable permissions", so 644 root works today and is on notice to stop working. The
11
+ # rule is the same one the password file follows: owned by whoever opens it.
12
+
13
+ # Each organization reaches only its own topic tree. "%u" is the connecting account name, and the
14
+ # first element of every Frugal IoT topic is the organization id - so this is what the nodes,
15
+ # dashboards and the server's own logger already do. It just stops being optional.
16
+ pattern readwrite %u/#
17
+
18
+ # Whether a bridge is up. With "notifications" left on (the default) a bridge publishes a retained
19
+ # 1 or 0 to $SYS/broker/connection/<remote_clientid>/state, and that is what frugal-iot-diagnostic
20
+ # reads to say whether a site is connected.
21
+ #
22
+ # Needed because "pattern readwrite %u/#" does not match $SYS, so without this line the diagnostic
23
+ # reports every bridge as never having connected - measured, not guessed. This is the narrowest rule
24
+ # that fixes it: it exposes which bridges exist and whether each is up, and nothing else. Reading
25
+ # all of $SYS ("pattern read $SYS/#") would also work and would hand every account the broker's
26
+ # client counts, subscription counts and traffic totals, which on a multi-organization broker tells
27
+ # each organization about the others.
28
+ #
29
+ # Mosquitto logs "Warning: ACL pattern ... does not contain '%c' or '%u'" for the line below, on
30
+ # every start and reload. It is harmless and expected: a "pattern" line is the only way to say
31
+ # "every authenticated account", because a bare "topic" line applies to anonymous clients only.
32
+ # Do not "fix" it by changing it to a topic line - that would silently grant nothing.
33
+ pattern read $SYS/broker/connection/+/state
34
+
35
+ # Bridge connections are exempt from this file on their local broker - a bridge with no
36
+ # local_username still carries traffic in both directions with the rules above in force (measured on
37
+ # mosquitto 2.0.21). So a Pi's own bridge needs nothing here; it is the far end, on production, that
38
+ # needs a rule, and addbridge-prod.zsh writes it.
@@ -9,6 +9,35 @@ protocol websockets
9
9
  # directory has to be writable by that same user. See INSTALLATION.md step A5.
10
10
  password_file /var/lib/mosquitto/passwords
11
11
 
12
+ # Which topics each account may reach. Without this, every account that can log in can publish and
13
+ # subscribe anywhere on the broker, across all organizations - so an organization's own password,
14
+ # which is handed to every node and every logged-in browser, would reach every other organization's
15
+ # data too.
16
+ #
17
+ # DENY BY DEFAULT: naming this file stops any account that has no rule in it. The shipped file
18
+ # grants each organization its own topic tree, which is all the nodes, dashboards and logger use, so
19
+ # nothing should notice. But an account added by hand needs a rule adding by hand.
20
+ #
21
+ # Installed from extras/aclfile - which must be in place BEFORE this line takes effect, or the
22
+ # broker will not start.
23
+ acl_file /etc/mosquitto/aclfile
24
+
25
+ # Accounts and permissions the server can create and remove while the broker is running, over
26
+ # $CONTROL/dynamic-security/v1. This is what lets each user and each node have its own credential
27
+ # rather than sharing the organization's - see SECURITY-REVIEW.md.
28
+ #
29
+ # The plugin's path is set by the installer, because it varies by distribution: Debian and
30
+ # Raspberry Pi OS put it in /usr/lib/<architecture>/, others in a mosquitto/ subdirectory.
31
+ #
32
+ # Both files must exist before this takes effect or the broker will not start. The state file is
33
+ # created by "mosquitto_ctrl dynsec init", which install-pi.sh does.
34
+ #
35
+ # Plugin checks run BEFORE the built-in password_file and acl_file checks, and an account dynsec
36
+ # does not know is passed through to them - measured on 2.0.21, and what makes it possible to move
37
+ # accounts across one at a time rather than all at once.
38
+ plugin PLUGIN_PATH_SET_BY_INSTALLER
39
+ plugin_opt_config_file /var/lib/mosquitto/dynamic-security.json
40
+
12
41
  # A node on a weak WiFi signal reconnects often, and each connection and disconnection would be a
13
42
  # line in /var/log/mosquitto/mosquitto.log. On an SD card those writes wear the card out, and
14
43
  # nothing is lost by dropping them, since the readings themselves are recorded by the server.
@@ -20,12 +20,93 @@ CREATE TABLE IF NOT EXISTS `users` (
20
20
  `email` TEXT,
21
21
  `phone` TEXT
22
22
  );
23
+ -- `project` scopes a permission to one project of an organization. Empty string means the whole
24
+ -- organization, which is how every row created before this column existed behaves.
25
+ --
26
+ -- NOT NULL DEFAULT '' rather than nullable, and that matters: SQLite treats NULLs as DISTINCT in a
27
+ -- UNIQUE constraint, so with a nullable column `(2,'READ','dev',NULL)` could be inserted twice and
28
+ -- the constraint that used to prevent duplicate organization-wide rows would silently stop working.
29
+ -- An empty string compares equal to itself, so uniqueness holds. It also keeps the queries simple:
30
+ -- `project = ?` everywhere, never `IS NULL`.
23
31
  CREATE TABLE IF NOT EXISTS `permissions` (
24
32
  `id` INTEGER NOT NULL,
25
33
  `capability` TEXT NOT NULL,
26
34
  `org` TEXT NOT NULL,
27
- UNIQUE(`id`, `capability`, `org`)
35
+ `project` TEXT NOT NULL DEFAULT '',
36
+ UNIQUE(`id`, `capability`, `org`, `project`)
37
+ );
38
+ -- One row per node that has enrolled, holding the broker credential it was issued.
39
+ --
40
+ -- The password is stored, not derived: a node keeps its copy in LittleFS and cannot recompute
41
+ -- anything, so deriving it from a secret would mean that losing the secret strands the whole fleet.
42
+ -- Storing it here is also what makes "the database is the source of truth, and dynsec can be
43
+ -- rebuilt from it" true for nodes as well as users.
44
+ --
45
+ -- No last_seen column on purpose: the logger already tracks that in memory and serves it through
46
+ -- reportNodes(), and writing it here would be a database write per reading - the SD-card wear the
47
+ -- logger's own buffering exists to avoid.
48
+ --
49
+ -- `lora` records whether the firmware was built with LoRaMesher, declared at enrolment. It decides
50
+ -- whether the node joins <org>-gateways, which grants the broader publish a gateway cannot avoid
51
+ -- needing (it republishes other nodes' readings under its own account). Scoped by what the build
52
+ -- can do rather than by what it is doing at the time, because any node that sees WiFi can promote
53
+ -- itself to gateway at runtime.
54
+ CREATE TABLE IF NOT EXISTS `nodes` (
55
+ `org` TEXT NOT NULL,
56
+ `project` TEXT NOT NULL,
57
+ `nodeid` TEXT NOT NULL,
58
+ `password` TEXT NOT NULL,
59
+ `lora` INTEGER NOT NULL DEFAULT 0,
60
+ `enrolled_at` INTEGER NOT NULL,
61
+ UNIQUE(`org`, `project`, `nodeid`)
28
62
  );
63
+
64
+ -- A Pi authorised to bridge into this server, and the token it pulls users with (SECURITY.md S11).
65
+ --
66
+ -- A bridge relays topics, not accounts, so a person who logs into this server has no account on the
67
+ -- Pi's broker. GET /replica/:org lets the Pi fetch the logins and permissions for the organizations
68
+ -- it hosts, so the same people can log in there - including when this server is unreachable, which
69
+ -- is the reason the Pi exists.
70
+ --
71
+ -- Nothing derived from a password travels: the Pi authenticates the login itself against the
72
+ -- replicated hash and derives its own broker credential from its own user_secret, so the two
73
+ -- brokers issue different passwords for the same person and neither secret is shared.
74
+ --
75
+ -- One token per Pi per organization, so one site can be revoked without disturbing another. The
76
+ -- token is a bearer credential for reading that organization's logins - not for changing anything.
77
+ CREATE TABLE IF NOT EXISTS `bridges` (
78
+ `org` TEXT NOT NULL,
79
+ `site` TEXT NOT NULL,
80
+ `token` TEXT NOT NULL,
81
+ `created_at` INTEGER NOT NULL,
82
+ `last_pull` INTEGER,
83
+ UNIQUE(`org`, `site`)
84
+ );
85
+
86
+ -- An admin's decision about one node, for the Enrolled column on the dashboard's Nodes card
87
+ -- (SECURITY.md S12). Two states are worth storing:
88
+ --
89
+ -- approved the next enrolment request from this node id is accepted whatever secret it presents,
90
+ -- and without having to prove it holds a credential - for a node whose secret was
91
+ -- withdrawn, or that was flashed with none, and which cannot be reached physically.
92
+ -- Consumed on use, so an approval admits one node once.
93
+ -- denied its broker account is deleted and enrolment is refused - the kill switch for a node
94
+ -- publishing bad readings.
95
+ --
96
+ -- Keyed by (org, nodeid), NOT by project: a node states its own project, so keying on it would let
97
+ -- a denied node return by claiming a different one.
98
+ --
99
+ -- Persisted, unlike the record of failed attempts, because a restart must not un-deny a node.
100
+ CREATE TABLE IF NOT EXISTS `node_grants` (
101
+ `org` TEXT NOT NULL,
102
+ `project` TEXT NOT NULL DEFAULT '',
103
+ `nodeid` TEXT NOT NULL,
104
+ `state` TEXT NOT NULL,
105
+ `created_by` TEXT NOT NULL DEFAULT '',
106
+ `created_at` INTEGER NOT NULL,
107
+ UNIQUE(`org`, `nodeid`)
108
+ );
109
+
29
110
  CREATE TABLE IF NOT EXISTS `projects` (
30
111
  `org` TEXT NOT NULL,
31
112
  `id` TEXT NOT NULL,