frugal-iot-server 2.0.3 → 2.0.5

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
@@ -217,6 +217,15 @@ organization's web login - twice each, not echoed, and pressing Enter has one ge
217
217
  and `--yes` asks nothing at all, which is what to use over ssh with no terminal. A password you
218
218
  chose is not written to the log; a generated one has to be, or you would have no way of knowing it.
219
219
 
220
+ It also asks for the password of **the login you are using on the Pi** (`pi`, normally), and gives
221
+ that same password to the setup network the Pi raises if it ever loses its WiFi - see
222
+ [Moving the Pi to another WiFi network](#moving-the-pi-to-another-wifi-network). Then whoever moves
223
+ the Pi needs to know only one password. Linux keeps only a scrambled (hashed) form of a login
224
+ password, so the installer cannot read it; it checks that what you type matches, so a typing slip
225
+ does not give the network a password nobody knows. Press Enter instead to give the network a password
226
+ of its own, or pass `--ap-password`. WiFi needs 8 to 63 characters, so a shorter login password
227
+ cannot be reused. `--no-setup-ap` leaves the setup network out.
228
+
220
229
  The organization's *broker* credential is no longer among them: it is generated and never shown,
221
230
  because nothing on the Pi needs you to know it. Nodes are issued their own credential when they
222
231
  enrol, and browsers derive their own when they log in. `--broker-password` still sets it, for an
@@ -462,6 +471,14 @@ sudo cp extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf
462
471
  (Everything in `/etc/mosquitto/conf.d/` is read in addition to the packaged
463
472
  `/etc/mosquitto/mosquitto.conf`, which keeps its own settings for logging and persistence.)
464
473
 
474
+ It also says how long the broker holds messages for a node that is asleep. Nodes keep their
475
+ session with the broker across a sleep, so a setting changed on the dashboard while a node sleeps
476
+ is held and handed over when it wakes - up to `max_queued_messages` (1000) per node, for up to
477
+ `persistent_client_expiration` (14 days) of not reconnecting, after which the node simply
478
+ subscribes afresh. Holding them across a broker restart relies on `persistence true` in the
479
+ packaged `mosquitto.conf`, which Raspberry Pi OS and Debian both ship; check with
480
+ `grep persistence /etc/mosquitto/mosquitto.conf`.
481
+
465
482
  That file also turns off the broker's per-connection logging, because a node on a weak signal
466
483
  reconnects constantly and each of those lines is a write to the SD card. Nothing is lost — the
467
484
  readings themselves are recorded by the server, and everything else the broker says, including why
@@ -701,6 +718,50 @@ journalctl -u frugaliot -f
701
718
  Reboot the Pi (`sudo reboot`), wait a couple of minutes, and check `http://frugaliot.local:8080`
702
719
  still answers. Your server is now installed.
703
720
 
721
+ ### 8a. A setup network, for when the Pi loses its WiFi
722
+
723
+ A Pi on WiFi that is moved to another site, or whose router is replaced, cannot be reached, and
724
+ changing its WiFi needs a login. [comitup](https://davesteele.github.io/comitup/) fixes that: when
725
+ no saved network is in range, the Pi raises a network of its own, `frugaliot-bridge-setup`. Its page
726
+ lists the WiFi networks the Pi can see and takes the password for one.
727
+ [Moving the Pi to another WiFi network](#moving-the-pi-to-another-wifi-network) is how it is used.
728
+
729
+ **Write comitup's configuration before installing it.** Installing the package starts it at once,
730
+ and without this file it uses its defaults: a network called `comitup-<nnn>` with **no password**,
731
+ raised straight away if the Pi has no WiFi connection at that moment.
732
+
733
+ ```
734
+ sudo install -m 600 /dev/null /etc/comitup.conf
735
+ sudo nano /etc/comitup.conf
736
+ ```
737
+
738
+ Put these two lines in it. The password is normally the one for the `pi` login, so that whoever moves
739
+ the Pi needs only one. WiFi needs 8 to 63 characters.
740
+
741
+ ```
742
+ ap_name: frugaliot-bridge-setup
743
+ ap_password: <the pi login password>
744
+ ```
745
+
746
+ Do not call it `frugaliot`. comitup announces its name on the network too, and announcing the Pi's own
747
+ name a second time stops `frugaliot.local` resolving for up to 15 minutes after each reconnect.
748
+
749
+ Then install comitup, and the watchdog that covers two ways comitup alone can strand a Pi (the
750
+ comments in `extras/frugaliot-ap-watchdog` give both):
751
+
752
+ ```
753
+ sudo apt-get install -y -o Dpkg::Options::=--force-confold comitup
754
+ sudo install -m 755 node_modules/frugal-iot-server/extras/frugaliot-ap-watchdog /usr/local/sbin/
755
+ sudo install -m 644 node_modules/frugal-iot-server/extras/frugaliot-ap-watchdog.service \
756
+ node_modules/frugal-iot-server/extras/frugaliot-ap-watchdog.timer /etc/systemd/system/
757
+ sudo systemctl daemon-reload
758
+ sudo systemctl enable --now frugaliot-ap-watchdog.timer
759
+ ```
760
+
761
+ `--force-confold` keeps the `/etc/comitup.conf` you just wrote, instead of stopping to ask about the
762
+ one the package ships. `systemctl is-active comitup` should say `active`. The Pi stays on its WiFi;
763
+ nothing changes until that WiFi goes away.
764
+
704
765
  ### 9. Point your sensor nodes at the Pi
705
766
 
706
767
  A node is flashed with an **enrolment secret**,
@@ -1138,6 +1199,89 @@ and read the **Wear on the SD card** section, which reports how much has been wr
1138
1199
  whether the machine is swapping, and whether each of the settings above is in force. Run it twice a
1139
1200
  few days apart — the interesting number is the rate, not the total.
1140
1201
 
1202
+ ## Moving the Pi to another WiFi network
1203
+
1204
+ This is how to give the Pi a new WiFi network when you cannot log in to it, because it no longer
1205
+ finds the old one: it has moved to another site, or the router has been replaced. It needs the setup
1206
+ network from [step 8a](#8a-a-setup-network-for-when-the-pi-loses-its-wifi), which the installer sets
1207
+ up.
1208
+
1209
+ **You need:**
1210
+
1211
+ * a phone or laptop;
1212
+ * the setup network's password - the `pi` login's password, unless the installer said otherwise;
1213
+ * the name and password of the new WiFi network.
1214
+
1215
+ **Then:**
1216
+
1217
+ 1. Power the Pi up where it is going, and give it about three minutes. It tries every network it
1218
+ has saved first, and each one that is not there costs it about half a minute.
1219
+ 2. On the phone or laptop, join the network **`frugaliot-bridge-setup`** with that password. A
1220
+ "sign in to network" page should open by itself. If it does not, browse to `http://10.41.0.1`.
1221
+ 3. Choose the new network from the list, and type its password. The setup network disappears as the
1222
+ Pi leaves it to join the new one.
1223
+ 4. Join the phone or laptop to the new network too, and check the dashboard at
1224
+ `http://frugaliot.local:8080`.
1225
+ 5. **Tell the phone or laptop to forget `frugaliot-bridge-setup`.** Devices rejoin a network they know
1226
+ whenever it appears. While anything is connected to the setup network, the Pi stops looking for
1227
+ its real WiFi. It gives up waiting after 15 minutes, so nothing is stranded for good, but a phone
1228
+ left in the house can delay the Pi's recovery after every power cut.
1229
+
1230
+ **If the setup network comes back a minute or so later**, the Pi could not join the network you
1231
+ chose, usually because the password was mistyped. Join the setup network again and choose it again.
1232
+
1233
+ **If `frugaliot.local` does not answer on the new network**, find the Pi's address in the router's
1234
+ list of connected devices and use that instead, e.g. `http://192.168.1.107:8080`. Some range
1235
+ extenders, and possibly phone hotspots, pass ordinary traffic but not the lookups that make `.local`
1236
+ names work.
1237
+
1238
+ **If the page will not do it**, log in while you are joined to the setup network, and set the WiFi
1239
+ by hand as in [step 2](#2-log-in-over-the-network):
1240
+
1241
+ ```
1242
+ ssh pi@10.41.0.1
1243
+ sudo nmtui
1244
+ ```
1245
+
1246
+ **A phone hotspot as the new network** works, with two things to know on an iPhone: turn on
1247
+ *Maximise Compatibility*, and keep the *Personal Hotspot* screen open while the Pi joins. Without
1248
+ that, the iPhone often ignores a new device.
1249
+
1250
+ ### What it does by itself
1251
+
1252
+ Nobody needs to be there for any of this:
1253
+
1254
+ * **After a power cut**, the Pi usually boots faster than the router. It raises the setup network,
1255
+ tries its saved networks again every three to six minutes, and rejoins the router once it is up.
1256
+ * **If a device sits on the setup network**, the Pi stops retrying while it is there - so that you
1257
+ are not cut off while you are using the page. After 15 minutes it retries regardless.
1258
+ * **A network that connects but hands out no address** counts as a failure, and the Pi moves on.
1259
+ Without that, a range extender whose router is switched off holds the Pi for ever, because the WiFi
1260
+ connection itself succeeds.
1261
+
1262
+ ### Deleting networks the Pi no longer needs
1263
+
1264
+ Every network it has saved costs the Pi about half a minute each time it searches, so remove the
1265
+ ones it will not see again. This needs a login - the setup page cannot do it:
1266
+
1267
+ ```
1268
+ nmcli connection show
1269
+ sudo nmcli connection delete "<name>"
1270
+ ```
1271
+
1272
+ or `sudo nmtui`, then *Edit a connection*. Leave `frugaliot-bridge-setup-0000`, which is the setup
1273
+ network itself.
1274
+
1275
+ ### Changing the setup network's password
1276
+
1277
+ Changing the `pi` login's password later does **not** change the setup network's. To change that,
1278
+ edit `ap_password` in `/etc/comitup.conf` (`sudo nano /etc/comitup.conf`), then
1279
+ `sudo systemctl restart comitup`. The password matters because anyone who has it can point the Pi
1280
+ at a network of their choosing, and the Pi stops talking to your sensors until that is undone.
1281
+
1282
+ What it has been doing is in `sudo tail -50 /var/log/comitup.log`, and the watchdog's part in
1283
+ `journalctl -u frugaliot-ap-watchdog`.
1284
+
1141
1285
  ## When something does not work
1142
1286
 
1143
1287
  From your install directory:
@@ -1258,6 +1402,10 @@ install these three, because none of them can simply be copied into place:
1258
1402
  | `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. |
1259
1403
  | `extras/frugaliot.service` | written for user `pi` in `/home/pi/frugal-iot`; the installer rewrites `User`, `WorkingDirectory` and `ExecStart` for wherever this actually went. |
1260
1404
 
1405
+ The setup network's watchdog, `extras/frugaliot-ap-watchdog` and its `.service` and `.timer`, *can* be
1406
+ copied as they are; step 8a has the commands. On a Pi installed before the setup network existed,
1407
+ re-running the installer adds it, and asks once for the `pi` login's password to give it.
1408
+
1261
1409
  The installer will not overwrite an ACL file that has per-bridge rules in it. If this release adds a
1262
1410
  rule to a broker that has them, it says which lines are missing and leaves the file alone for you to
1263
1411
  add them by hand, then `sudo systemctl reload mosquitto`.
@@ -136,6 +136,109 @@ carousel:
136
136
  name: Display carousel
137
137
  topics:
138
138
  - leaf: select
139
+ # The lcd_ht example's control - formats another node's temperature and humidity for an LCD.
140
+ # Its inputs are wired to the remote node, its output to this node's lcd/message.
141
+ control_lcd_ht:
142
+ summary: false # its readings are another node's, shown on that node's card
143
+ name: LCD SHT
144
+ topics:
145
+ - leaf: temperature
146
+ leaf_from: controlfloat
147
+ name: Temperature
148
+ min: -40
149
+ max: 85
150
+ color: "#ff0000"
151
+ - leaf: humidity
152
+ leaf_from: controlfloat
153
+ name: Humidity
154
+ color: "#0000ff"
155
+ - leaf: message
156
+ leaf_from: controltext
157
+ name: Message
158
+ rw: r
159
+ # The OLED display controls. Each input is wired to whatever reading it should show, often another
160
+ # node's, so like the LCD they stay off the summary. Ranges and colours match the firmware.
161
+ # control_oled_gps is in the gps example.
162
+ control_oled_gps:
163
+ summary: false # a display, not a reading
164
+ name: OLED GPS
165
+ topics:
166
+ - leaf: latitude
167
+ leaf_from: controlfloat
168
+ name: Latitude
169
+ min: -90
170
+ max: 90
171
+ width: 10
172
+ color: "#0000ff"
173
+ - leaf: longitude
174
+ leaf_from: controlfloat
175
+ name: Longitude
176
+ min: -180
177
+ max: 180
178
+ width: 11
179
+ color: "#0000ff"
180
+ - leaf: altitude
181
+ leaf_from: controlfloat
182
+ name: Altitude
183
+ min: -500
184
+ max: 9000
185
+ width: 6
186
+ color: "#008000"
187
+ - leaf: speed
188
+ leaf_from: controlfloat
189
+ name: Speed
190
+ max: 999
191
+ color: "#ffa500"
192
+ - leaf: course
193
+ leaf_from: controlfloat
194
+ name: Course
195
+ max: 360
196
+ - leaf: satellites
197
+ leaf_from: controlfloat
198
+ name: Satellites
199
+ max: 32
200
+ - leaf: hdop
201
+ leaf_from: controlfloat
202
+ name: HDOP
203
+ max: 50
204
+ # In the sht, loramesher and remotedisplay examples, and in frugal-iot-demo
205
+ control_oled_ht:
206
+ summary: false # a display, not a reading
207
+ name: OLED HT
208
+ topics:
209
+ - leaf: temperature
210
+ leaf_from: controlfloat
211
+ name: Temperature
212
+ max: 50
213
+ color: "#ff0000"
214
+ - leaf: humidity
215
+ leaf_from: controlfloat
216
+ name: Humidity
217
+ color: "#0000ff"
218
+ - leaf: battery
219
+ leaf_from: controlfloat
220
+ name: Battery
221
+ max: 5000
222
+ color: "#00ff00"
223
+ # Library control - src/control/oled_loramesher.cpp. The mesh status it shows is read directly, not wired.
224
+ control_oled_loramesher:
225
+ summary: false # a display, not a reading
226
+ name: OLED LoRaMesher
227
+ topics:
228
+ - leaf: battery
229
+ leaf_from: controlfloat
230
+ name: Battery
231
+ max: 5000
232
+ color: "#00ff00"
233
+ # Library control - src/control/oled_single.cpp. One reading, unit and label set in the sketch.
234
+ control_oled_single:
235
+ summary: false # a display, not a reading
236
+ name: OLED
237
+ topics:
238
+ - leaf: value
239
+ leaf_from: controlfloat
240
+ name: Value
241
+ max: 10000
139
242
  controlblinken:
140
243
  name: "Control Blinken"
141
244
  topics:
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/zsh
2
+ # Two guards for comitup, which raises the frugaliot-bridge-setup access point when no known WiFi
3
+ # is in range. Installed to /usr/local/sbin by install-pi.sh, and run every minute by
4
+ # frugaliot-ap-watchdog.timer. Argument: how many seconds the access point may stay up.
5
+ #
6
+ # 1. A WiFi network that gives no IPv4 address must count as FAILED. NetworkManager's default,
7
+ # ipv4.may-fail yes, together with the link-local IPv6 comitup sets, makes any network that
8
+ # completes the WiFi handshake "connected" - so a range extender that outlives its router, with
9
+ # no DHCP behind it, holds the Pi for ever and the access point never appears. comitup creates a
10
+ # new profile for each network chosen on its page, so this is applied every minute, not once.
11
+ #
12
+ # 2. Restart comitup when it has held the access point for too long. comitup only retries the saved
13
+ # networks while NOBODY is connected to its access point, and a phone or laptop that joined it
14
+ # once rejoins by itself whenever it appears - so after a power cut it could hold the Pi in AP
15
+ # mode for ever, with the real WiFi back and the bridge down. On a restart comitup tries every
16
+ # saved network before raising the access point again.
17
+
18
+ LIMIT=${1:-900}
19
+ MARK=/run/frugaliot-ap-since # /run is emptied at boot, which is what we want
20
+
21
+ for UUID in ${(f)"$(nmcli -t -f UUID,TYPE connection show | grep ':802-11-wireless$' | cut -d: -f1)"}; do
22
+ SETTINGS=("${(@f)$(nmcli -g 802-11-wireless.mode,ipv4.may-fail,connection.id connection show $UUID)}")
23
+ if [[ $SETTINGS[1] != ap && $SETTINGS[2] == yes ]]; then
24
+ echo "Requiring IPv4 on WiFi profile '$SETTINGS[3]'"
25
+ nmcli connection modify $UUID ipv4.may-fail no
26
+ fi
27
+ done
28
+
29
+ WIFI=$(nmcli -t -f DEVICE,TYPE device | awk -F: '$2 == "wifi" { print $1; exit }')
30
+ if [[ -z $WIFI || "$(iw dev $WIFI info 2>/dev/null)" != *"type AP"* ]]; then
31
+ rm -f $MARK
32
+ exit 0
33
+ fi
34
+
35
+ NOW=$(date +%s)
36
+ if [[ ! -f $MARK ]]; then
37
+ echo $NOW > $MARK
38
+ exit 0
39
+ fi
40
+
41
+ SINCE=$(<$MARK)
42
+ if (( NOW - SINCE >= LIMIT )); then
43
+ echo "In AP mode for $(( NOW - SINCE ))s (limit ${LIMIT}s) - restarting comitup to retry saved networks"
44
+ rm -f $MARK
45
+ systemctl restart comitup
46
+ fi
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=Frugal IoT: stop the fallback access point holding the Pi for too long
3
+ After=comitup.service
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ # Seconds the frugaliot-bridge-setup access point may stay up before comitup is made to retry
8
+ ExecStart=/usr/local/sbin/frugaliot-ap-watchdog 900
@@ -0,0 +1,9 @@
1
+ [Unit]
2
+ Description=Frugal IoT: check every minute whether the fallback access point has been up too long
3
+
4
+ [Timer]
5
+ OnBootSec=2min
6
+ OnUnitActiveSec=1min
7
+
8
+ [Install]
9
+ WantedBy=timers.target
@@ -38,6 +38,25 @@ acl_file /etc/mosquitto/aclfile
38
38
  plugin PLUGIN_PATH_SET_BY_INSTALLER
39
39
  plugin_opt_config_file /var/lib/mosquitto/dynamic-security.json
40
40
 
41
+ # Messages held for nodes that are asleep. A node keeps a persistent session across a sleep, and the
42
+ # broker holds every QoS 1 message for its subscriptions until it reconnects - which is how a setting
43
+ # changed on the dashboard, or sent by another node, reaches a node that was asleep when it was sent.
44
+ # (The dashboard and the nodes send "set/" commands at QoS 1 for exactly this reason.)
45
+ #
46
+ # Persistence itself - which keeps those sessions, and the retained values, across a broker restart -
47
+ # is NOT set here: the packaged /etc/mosquitto/mosquitto.conf already has "persistence true", and
48
+ # repeating its settings in this file can stop the broker starting.
49
+ #
50
+ # At most this many held messages per node, beyond those in flight; once full, newer ones are dropped.
51
+ # Plenty for a node that sleeps for minutes. A node that sleeps for a day and has wired inputs from
52
+ # busy nodes can exceed it, but it resubscribes on waking, which brings each topic's latest retained
53
+ # value anyway. This is the broker's own default, set here so the limit is visible.
54
+ max_queued_messages 1000
55
+ # A session nobody has reconnected to in this long is discarded, with whatever it holds - otherwise a
56
+ # node taken out of service keeps up to max_queued_messages on the broker forever. A node that comes
57
+ # back after longer simply finds no session, and subscribes afresh.
58
+ persistent_client_expiration 14d
59
+
41
60
  # A node on a weak WiFi signal reconnects often, and each connection and disconnection would be a
42
61
  # line in /var/log/mosquitto/mosquitto.log. On an SD card those writes wear the card out, and
43
62
  # nothing is lost by dropping them, since the readings themselves are recorded by the server.
@@ -80,6 +80,11 @@ CREATE TABLE IF NOT EXISTS `bridges` (
80
80
  `token` TEXT NOT NULL,
81
81
  `created_at` INTEGER NOT NULL,
82
82
  `last_pull` INTEGER,
83
+ -- Where the bridge's broker password lives, and so whether frugal-iot-rebuild-dynsec may set it:
84
+ -- 'derived' (issued by the dashboard), 'password_file' (frugal-iot-addbridge-prod), or NULL for a
85
+ -- row from before this was recorded. See bridgesToSync in lib/dynsec-plan.js. Added after the
86
+ -- table first shipped, so lib/schema-columns.js adds it to an existing database.
87
+ `credential` TEXT,
83
88
  UNIQUE(`org`, `site`)
84
89
  );
85
90
 
@@ -114,6 +114,7 @@ import { enrol, ENROL, enrolmentSecretsFor, makeRateLimiter, forgetNode,
114
114
  makeAttemptLog, setGrant, clearGrant, readGrants } from './lib/enrol.js';
115
115
  import { deleteRetained } from './lib/retained.js';
116
116
  import { replicaFor, bridgeForToken, noteBridgePull, startReplica } from './lib/replica.js';
117
+ import { addMissingColumns } from './lib/schema-columns.js';
117
118
  import { createLoggerClient } from './lib/logger-client.js';
118
119
  import { createPushManager } from './lib/farm-platform-push.js';
119
120
  import { APIError } from './lib/api-errors.js';
@@ -578,7 +579,8 @@ function execSqlStart(cb) {
578
579
  cb(err);
579
580
  } else {
580
581
  console.log("Exec-ed starting SQL");
581
- cb(null, db);
582
+ // A column added to an existing table, which CREATE TABLE IF NOT EXISTS cannot do
583
+ addMissingColumns(db, (err2) => (err2 ? cb(err2) : cb(null, db)));
582
584
  }
583
585
  });
584
586
  }
@@ -1649,9 +1651,16 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1649
1651
  }
1650
1652
  const now = Date.now();
1651
1653
  const token = crypto.randomBytes(32).toString('base64url');
1652
- db.run(`INSERT INTO bridges (org, site, token, created_at) VALUES (?, ?, ?, ?)
1654
+ // Whether this re-issues a bridge that exists, because then the Pi already has a bridge
1655
+ // configuration, and the command it is given has to say --replace or it refuses
1656
+ db.get('SELECT 1 AS found FROM bridges WHERE org = ? AND site = ?', [org, site], (gerr, existing) => {
1657
+ if (gerr) { return res.status(500).json({ error: gerr.message }); }
1658
+ // credential 'derived' is what lets frugal-iot-rebuild-dynsec recreate this account,
1659
+ // and tells it apart from one authorized with addbridge-prod - see bridgesToSync
1660
+ db.run(`INSERT INTO bridges (org, site, token, created_at, credential) VALUES (?, ?, ?, ?, 'derived')
1653
1661
  ON CONFLICT(org, site) DO UPDATE SET token = excluded.token,
1654
- created_at = excluded.created_at`,
1662
+ created_at = excluded.created_at,
1663
+ credential = excluded.credential`,
1655
1664
  [org, site, token, now], (err) => {
1656
1665
  if (err) { return res.status(500).json({ error: err.message }); }
1657
1666
  syncBridge(config, { org, site, createdAt: now }, (serr, result) => {
@@ -1669,11 +1678,15 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1669
1678
  replica_token: token,
1670
1679
  // The command to run on the Pi. Neither secret is in it on purpose: that
1671
1680
  // script prompts for both precisely so they stay out of the Pi's shell history,
1672
- // and putting them here would undo that.
1673
- command: `npx --no frugal-iot-addbridge-pi ${org} <this-server-fqdn> ${result.username}`,
1681
+ // and putting them here would undo that. Re-issuing needs --replace, which npm
1682
+ // would swallow without the "--" ahead of it - hence the flag form there.
1683
+ command: existing
1684
+ ? `npx --no frugal-iot-addbridge-pi -- --org ${org} --host <this-server-fqdn> --account ${result.username} --replace`
1685
+ : `npx --no frugal-iot-addbridge-pi ${org} <this-server-fqdn> ${result.username}`,
1674
1686
  });
1675
1687
  });
1676
1688
  });
1689
+ });
1677
1690
  },
1678
1691
  );
1679
1692
  app.post('/node_reset/:org',
@@ -298,6 +298,36 @@ export function deriveBridgePassword(userSecret, site, createdAt) {
298
298
  .digest('base64url').slice(0, 22);
299
299
  }
300
300
 
301
+ /*
302
+ * Which bridges a rebuild may create or re-password broker accounts for. A bridge's password comes
303
+ * from one of two places, recorded in bridges.credential, and only one of them is ours to set:
304
+ *
305
+ * 'derived' issued by the dashboard (POST /bridge_add) from user_secret and created_at, so
306
+ * it can be recreated exactly - always applied.
307
+ * 'password_file' authorized with frugal-iot-addbridge-prod: a random password in mosquitto's
308
+ * password file, which only the Pi holds. Creating a plugin client of that name
309
+ * REPLACES it, because the plugin's answer for a client it knows is final - which
310
+ * is how a rebuild during the 2.0.3 release cut a working bridge off.
311
+ * null a row from before that was recorded. Applied only if the plugin already has
312
+ * the client, because then its password is the derived one already; otherwise
313
+ * there is no telling, and replacing a working credential is the worse mistake.
314
+ *
315
+ * Pure, so it is tested without a broker. clients is the plugin's list of client names.
316
+ */
317
+ export function bridgesToSync(bridges, clients) {
318
+ const apply = [];
319
+ const skipped = [];
320
+ for (const b of bridges) {
321
+ const account = names.bridgeClient(b.site);
322
+ if (b.credential === 'derived' || (b.credential == null && clients.includes(account))) {
323
+ apply.push(b);
324
+ } else {
325
+ skipped.push({ ...b, account, reason: b.credential === 'password_file' ? 'password_file' : 'unknown' });
326
+ }
327
+ }
328
+ return { apply, skipped };
329
+ }
330
+
301
331
  // The server's administrative account for one organization. Derived, like the logger's, with its
302
332
  // own label so the two can never collide.
303
333
  export function deriveOrgAdminPassword(userSecret, org) {
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Columns added to a table after it first shipped.
3
+ *
4
+ * frugal-iot-createdb.sql is all CREATE TABLE IF NOT EXISTS, which creates a new table whole but
5
+ * cannot add a column to one that exists already - so a database from an earlier release would
6
+ * otherwise never get it, and the first INSERT naming it would fail. Each is added here, guarded on
7
+ * its absence, every time the server starts. ADD COLUMN only: anything that changes a constraint
8
+ * needs the table rebuilt, which is what scripts/init.zsh step 3a does for permissions.project.
9
+ */
10
+ const ADDED = [
11
+ { table: 'bridges', column: 'credential', type: 'TEXT' },
12
+ ];
13
+
14
+ export function addMissingColumns(db, cb) {
15
+ let i = 0;
16
+ const next = (err) => {
17
+ if (err) return cb(err);
18
+ if (i >= ADDED.length) return cb(null);
19
+ const { table, column, type } = ADDED[i++];
20
+ db.all(`PRAGMA table_info(${table})`, [], (e, cols) => {
21
+ if (e) return cb(e);
22
+ // No such table means CREATE TABLE has not run yet, or it is a table this database never had
23
+ if (!cols.length || cols.some((c) => c.name === column)) return next();
24
+ db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`, [], (e2) => {
25
+ if (!e2) console.log(`Database: added ${table}.${column}`);
26
+ next(e2);
27
+ });
28
+ });
29
+ };
30
+ next();
31
+ }
package/package.json CHANGED
@@ -12,8 +12,8 @@
12
12
  "cookie-parser": "^1.4.7",
13
13
  "express": "^5.2.1",
14
14
  "express-session": "^1.19.0",
15
- "frugal-iot-client": "^2.0.3",
16
- "frugal-iot-logger": "^2.0.3",
15
+ "frugal-iot-client": "^2.0.5",
16
+ "frugal-iot-logger": "^2.0.5",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -56,6 +56,9 @@
56
56
  "extras/aclfile",
57
57
  "extras/mosquitto-bridge.conf.example",
58
58
  "extras/frugaliot.service",
59
+ "extras/frugaliot-ap-watchdog",
60
+ "extras/frugaliot-ap-watchdog.service",
61
+ "extras/frugaliot-ap-watchdog.timer",
59
62
  "API.md",
60
63
  "INSTALLATION.md"
61
64
  ],
@@ -86,5 +89,5 @@
86
89
  "check-files": "node scripts/check-published-files.js"
87
90
  },
88
91
  "type": "module",
89
- "version": "2.0.3"
92
+ "version": "2.0.5"
90
93
  }
@@ -6,7 +6,7 @@
6
6
  // it, an installed browser keeps serving the files it already cached - and since the list now covers
7
7
  // the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
8
8
  // needs this bumped, or it does not reach anyone who already has the old one.
9
- const CACHE_NAME = 'frugal-iot-cache-2.0.3';
9
+ const CACHE_NAME = 'frugal-iot-cache-2.0.5';
10
10
  /*
11
11
  * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
12
  *
@@ -72,6 +72,29 @@ if ! command -v mosquitto_passwd >/dev/null; then
72
72
  exit 1
73
73
  fi
74
74
 
75
+ # A bridge issued from the dashboard has its account in the dynamic security plugin, and the
76
+ # plugin's answer for a client it knows is final - so a password-file entry made here for the same
77
+ # name would simply be ignored, and the Pi given it refused. The same trap the other way round cut a
78
+ # working bridge off in 2.0.3 (see bridgesToSync in lib/dynsec-plan.js). Node, not sqlite3, for the
79
+ # reason given at the replica token below.
80
+ BRIDGE_CREDENTIAL=$(node -e '
81
+ const sqlite3 = require("sqlite3");
82
+ const [org, site] = process.argv.slice(1);
83
+ const db = new sqlite3.Database("./frugal-iot.db", sqlite3.OPEN_READONLY, (err) => {
84
+ if (err) process.exit(0);
85
+ db.get("SELECT * FROM bridges WHERE org = ? AND site = ?", [org, site], (e, row) => {
86
+ process.stdout.write((row && row.credential) || "");
87
+ db.close();
88
+ });
89
+ });
90
+ ' "$ORG_ID" "$SITE" 2>/dev/null) || true
91
+ if [[ "$BRIDGE_CREDENTIAL" == derived ]]; then
92
+ echo "Error: bridge '${SITE}' was issued from the dashboard, so its account is in the broker's" >&2
93
+ echo "dynamic security plugin, which would ignore a password-file entry made here." >&2
94
+ echo "To give its Pi new credentials, re-issue it on the dashboard's Bridges card instead." >&2
95
+ exit 1
96
+ fi
97
+
75
98
  # Which files the running broker actually uses, rather than where they usually are. Only ".conf"
76
99
  # files in conf.d are read, and the last setting wins.
77
100
  MOSQ_CONFS=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
@@ -263,15 +286,24 @@ const token = randomBytes(32).toString("base64url");
263
286
  const db = new sqlite3.Database("./frugal-iot.db", (err) => {
264
287
  if (err) { console.error(err.message); process.exit(1); }
265
288
  db.run(`CREATE TABLE IF NOT EXISTS bridges (org TEXT NOT NULL, site TEXT NOT NULL,
266
- token TEXT NOT NULL, created_at INTEGER NOT NULL, last_pull INTEGER,
289
+ token TEXT NOT NULL, created_at INTEGER NOT NULL, last_pull INTEGER, credential TEXT,
267
290
  UNIQUE(org, site))`, () => {
268
- db.run(`INSERT INTO bridges (org, site, token, created_at) VALUES (?, ?, ?, ?)
269
- ON CONFLICT(org, site) DO UPDATE SET token = excluded.token, created_at = excluded.created_at`,
270
- [org, site, token, Date.now()], (e) => {
271
- if (e) { console.error(e.message); process.exit(1); }
272
- process.stdout.write(token);
273
- db.close();
291
+ // An existing table may predate the credential column, if the server has not been restarted
292
+ // on this release yet - it adds it at startup, so do the same here
293
+ db.all("PRAGMA table_info(bridges)", [], (pe, cols) => {
294
+ const add = (pe || cols.some((c) => c.name === "credential")) ? "SELECT 1" : "ALTER TABLE bridges ADD COLUMN credential TEXT";
295
+ db.run(add, [], () => {
296
+ // "password_file" tells frugal-iot-rebuild-dynsec this account is not its to set
297
+ db.run(`INSERT INTO bridges (org, site, token, created_at, credential) VALUES (?, ?, ?, ?, 'password_file')
298
+ ON CONFLICT(org, site) DO UPDATE SET token = excluded.token, created_at = excluded.created_at,
299
+ credential = excluded.credential`,
300
+ [org, site, token, Date.now()], (e) => {
301
+ if (e) { console.error(e.message); process.exit(1); }
302
+ process.stdout.write(token);
303
+ db.close();
304
+ });
274
305
  });
306
+ });
275
307
  });
276
308
  });
277
309
  ' "$ORG_ID" "$SITE") || true
@@ -317,6 +349,11 @@ echo "Then, on the Pi at site '${SITE}':"
317
349
  echo ""
318
350
  echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <fqdn-from-1-above> ${ACCOUNT}"
319
351
  echo ""
352
+ echo "If that Pi already has a bridge configured, to anywhere, it needs --replace, which"
353
+ echo "npm swallows unless a \"--\" comes first:"
354
+ echo ""
355
+ echo " npx --no frugal-iot-addbridge-pi -- --org ${ORG_ID} --host <fqdn-from-1-above> --account ${ACCOUNT} --replace"
356
+ echo ""
320
357
  if [[ -n "$REPLICA_TOKEN" ]]; then
321
358
  echo "It asks for the password from 2, then for the replica token from 3. Neither is given on the"
322
359
  echo "command line on purpose, so that neither ends up in that Pi's shell history."
@@ -496,15 +496,31 @@ section "Bridge to a production server (optional)"
496
496
  # extras/mosquitto-bridge.conf.example. It is configured by a "connection" line in one of
497
497
  # mosquitto's own config files, so most installations have none, which is not a problem.
498
498
  BRIDGECONFS=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
499
- BRIDGENAMES=""
500
- (( ${#BRIDGECONFS} )) && BRIDGENAMES=$(grep -hE '^[[:space:]]*connection[[:space:]]' $BRIDGECONFS 2>/dev/null | awk '{print $2}')
501
- if [[ -z "$BRIDGENAMES" ]]; then
499
+ # The bridge's own file is 600 root, because it holds the bridge's password - so read as an ordinary
500
+ # user it yields nothing, which looked exactly like "no bridge configured" on a Pi whose bridge was
501
+ # configured and failing. Read each file with sudo when it cannot be read plainly, and say so when
502
+ # even that fails rather than report an answer that was never looked for.
503
+ BRIDGETEXT=""; BRIDGEUNREAD=()
504
+ for f in $BRIDGECONFS; do
505
+ if [[ -r $f ]]; then
506
+ BRIDGETEXT+="$(<$f)"$'\n'
507
+ elif have sudo && t=$(sudo -n cat $f 2>/dev/null); then
508
+ BRIDGETEXT+="$t"$'\n'
509
+ else
510
+ BRIDGEUNREAD+=($f)
511
+ fi
512
+ done
513
+ BRIDGENAMES=$(print -r -- "$BRIDGETEXT" | grep -E '^[[:space:]]*connection[[:space:]]' | awk '{print $2}')
514
+ if [[ -z "$BRIDGENAMES" ]] && (( ${#BRIDGEUNREAD} )); then
515
+ item "cannot tell whether a bridge is configured: not readable as $(id -un) - ${(j:, :)BRIDGEUNREAD}"
516
+ item " sudo npx --no frugal-iot-diagnostic reads them"
517
+ elif [[ -z "$BRIDGENAMES" ]]; then
502
518
  item "no bridge configured - readings stay on this Pi"
503
519
  else
504
520
  for b in ${(f)BRIDGENAMES}; do item "configured bridge: $b"; done
505
521
  # What it is set to relay and where. remote_password is deliberately not among these.
506
522
  item "its settings:"
507
- grep -hE '^[[:space:]]*(address|remote_username|remote_clientid|topic|cleansession|restart_timeout|notifications)[[:space:]]' $BRIDGECONFS 2>/dev/null | sed 's/^/ /'
523
+ print -r -- "$BRIDGETEXT" | grep -E '^[[:space:]]*(address|remote_username|remote_clientid|topic|cleansession|restart_timeout|notifications)[[:space:]]' | sed 's/^/ /'
508
524
  # Whether it is actually up, rather than what the configuration hoped for. With "notifications"
509
525
  # left on, the bridge publishes a retained 1 or 0 here each time it connects or drops.
510
526
  if have mosquitto_sub; then
package/scripts/init.zsh CHANGED
@@ -255,6 +255,11 @@ check_installed() {
255
255
  check_installed "${PKG}/extras/mosquitto.conf" /etc/mosquitto/conf.d/frugal-iot.conf plugin
256
256
  check_installed "${PKG}/extras/aclfile" /etc/mosquitto/aclfile subset
257
257
  check_installed "${PKG}/extras/frugaliot.service" /etc/systemd/system/frugaliot.service service
258
+ # Installed as they are, so compared exactly. Absent on a machine with no WiFi, which install-pi.sh
259
+ # leaves without a setup network - reported as "nothing to compare", which is what it is.
260
+ check_installed "${PKG}/extras/frugaliot-ap-watchdog" /usr/local/sbin/frugaliot-ap-watchdog exact
261
+ check_installed "${PKG}/extras/frugaliot-ap-watchdog.service" /etc/systemd/system/frugaliot-ap-watchdog.service exact
262
+ check_installed "${PKG}/extras/frugaliot-ap-watchdog.timer" /etc/systemd/system/frugaliot-ap-watchdog.timer exact
258
263
 
259
264
  if (( ${#DIFFER_TO} )); then
260
265
  echo
@@ -25,13 +25,18 @@
25
25
  # workstation, not here. It prints the settings to give them.
26
26
  # * HTTPS, which over-the-air firmware updates to ESP32 need - INSTALLATION.md step 10
27
27
  # * reboot. A reboot is worth doing afterwards if the upgrade brought a new kernel.
28
+ #
29
+ # It does set up what happens when the Pi later LOSES its WiFi - moved to another site, or the router
30
+ # replaced: a network of its own, frugaliot-bridge-setup, whose page sets a new one. Otherwise a Pi
31
+ # on WiFi is unreachable at exactly the moment it needs its WiFi changed. INSTALLATION.md, "Moving the
32
+ # Pi to another WiFi network", is the operator's side of it.
28
33
 
29
34
  set -Eeuo pipefail
30
35
 
31
36
  # ---------------------------------------------------------------- settings and arguments
32
37
 
33
38
  ORG_ID=""; ORG_NAME=""; ORG_EMAIL=""; ORG_PHONE=""
34
- BROKER_PW=""; SUPER_PW=""; LOGIN_PW=""; BROKER_HOST=""
39
+ BROKER_PW=""; SUPER_PW=""; LOGIN_PW=""; BROKER_HOST=""; AP_PW=""; SETUP_AP=1
35
40
  INSTALL_DIR=""; RUN_USER=""; ASSUME_YES=0; DRY_RUN=0; RANDOM_PASSWORDS=0
36
41
 
37
42
  usage() {
@@ -52,6 +57,12 @@ Usage: bash install-pi.sh [options]
52
57
  thing from the broker credential above and should not be the same string.
53
58
  Either of the two logins, if not given, is asked for - and generated if you
54
59
  just press Enter, or if there is no terminal to ask at.
60
+ --ap-password password of the frugaliot-bridge-setup network this Pi raises when it cannot
61
+ find its WiFi, 8 to 63 characters. If not given you are asked for the
62
+ password of this Pi's login, and that is used - so whoever moves the Pi
63
+ needs to know only one. Generated if there is no terminal to ask at.
64
+ --no-setup-ap do not set that network up. Leaves a Pi that loses its WiFi reachable only
65
+ by plugging in a screen and keyboard, or an Ethernet cable.
55
66
  --broker-host <host> what the browser and the nodes should call this Pi.
56
67
  Default: this Pi's hostname with ".local". Use an IP address if you will
57
68
  view the dashboard on Android, which cannot resolve ".local" names.
@@ -76,6 +87,8 @@ while (( $# )); do
76
87
  --superuser-password) SUPER_PW="${2:-}"; shift 2 ;;
77
88
  --login-password) LOGIN_PW="${2:-}"; shift 2 ;;
78
89
  --broker-host) BROKER_HOST="${2:-}"; shift 2 ;;
90
+ --ap-password) AP_PW="${2:-}"; shift 2 ;;
91
+ --no-setup-ap) SETUP_AP=0; shift ;;
79
92
  --dir) INSTALL_DIR="${2:-}"; shift 2 ;;
80
93
  --user) RUN_USER="${2:-}"; shift 2 ;;
81
94
  --yes|-y) ASSUME_YES=1; shift ;;
@@ -265,6 +278,91 @@ ask() { # ask <variable name> <prompt> [generated]
265
278
  [[ -n "${!var}" ]] || { echo "That cannot be empty" >&2; return 1; }
266
279
  }
267
280
 
281
+ # ---------------------------------------------------------------- the setup network
282
+ # What this Pi raises, through comitup, when no saved WiFi network is in range. Not "frugaliot":
283
+ # comitup also announces its name over mDNS, and announcing the Pi's own hostname a second time made
284
+ # avahi refuse the real one - frugaliot.local stopped resolving for up to 15 minutes after every
285
+ # reconnect.
286
+ AP_NAME="frugaliot-bridge-setup"
287
+ COMITUP_CONF=/etc/comitup.conf
288
+ AP_SOURCE="" # login, own, given or kept - which password the network ended up with, for the summary
289
+
290
+ # Why there can be no setup network on this machine, or nothing if there can be
291
+ ap_unavailable() {
292
+ command -v nmcli >/dev/null 2>&1 || { echo "NetworkManager is not installed"; return 0; }
293
+ systemctl is-active --quiet NetworkManager 2>/dev/null || { echo "NetworkManager is not running"; return 0; }
294
+ nmcli -t -f TYPE device 2>/dev/null | grep -qx wifi || echo "this machine has no WiFi"
295
+ }
296
+
297
+ # Is "$1" the password of RUN_USER's login? 0 yes, 1 no, 2 cannot tell.
298
+ # Linux keeps only a one-way hash of a password, in /etc/shadow, so there is nothing to read back -
299
+ # instead what was typed is hashed the same way, with the same salt, and the two compared. Without
300
+ # that a typing slip would give the network a password nobody knows. libcrypt through ctypes because
301
+ # Python 3.13 removed its crypt module; both values go to python on stdin, since "ps" shows arguments.
302
+ login_password_matches() {
303
+ local stored rc=0
304
+ stored="$(sudo_ getent shadow "$RUN_USER" 2>/dev/null | cut -d: -f2 || true)"
305
+ [[ "$stored" == \$* ]] || return 2 # locked ("!"), no password ("*"), or unreadable
306
+ printf '%s\n%s' "$stored" "$1" | python3 -c '
307
+ import ctypes, sys
308
+ stored, typed = sys.stdin.read().split("\n", 1)
309
+ try:
310
+ lib = ctypes.CDLL("libcrypt.so.1")
311
+ except OSError:
312
+ sys.exit(2)
313
+ lib.crypt.restype = ctypes.c_char_p
314
+ lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
315
+ result = lib.crypt(typed.encode(), stored.encode())
316
+ sys.exit(0 if result and result.decode() == stored else 1)
317
+ ' || rc=$?
318
+ (( rc <= 1 )) || rc=2 # 127 is no python3 at all, which is also "cannot tell"
319
+ return $rc
320
+ }
321
+
322
+ wifi_length_ok() { (( ${#1} >= 8 && ${#1} <= 63 )); } # what WPA accepts
323
+
324
+ ask_ap_password() {
325
+ if [[ -n "$AP_PW" ]]; then
326
+ AP_SOURCE=given
327
+ return 0
328
+ fi
329
+ if (( RANDOM_PASSWORDS )) || (( ASSUME_YES )) || [[ ! -t 0 ]]; then
330
+ AP_PW="$(randpw)"; GENERATED+=(AP_PW); AP_SOURCE=own
331
+ info "generated a password for the ${AP_NAME} network$( [[ -t 0 ]] || echo ' (no terminal to ask at)' )"
332
+ return 0
333
+ fi
334
+ local typed rc tries=0
335
+ printf ' When this Pi cannot find its WiFi it raises a network called %s,\n' "$AP_NAME" > /dev/tty
336
+ printf ' whose password is that of the %s login - so whoever moves it needs to know only one.\n' "$RUN_USER" > /dev/tty
337
+ while (( tries < 3 )); do
338
+ printf ' Password for the %s login\n (or press Enter to give that network a password of its own): ' "$RUN_USER" > /dev/tty
339
+ read -rs typed < /dev/tty; printf '\n' > /dev/tty
340
+ [[ -n "$typed" ]] || break
341
+ rc=0; login_password_matches "$typed" || rc=$?
342
+ case $rc in
343
+ 0)
344
+ if wifi_length_ok "$typed"; then
345
+ AP_PW="$typed"; AP_SOURCE=login
346
+ info "the ${AP_NAME} network will have the password of the ${RUN_USER} login"
347
+ return 0
348
+ fi
349
+ printf ' That is right, but a WiFi password must be 8 to 63 characters, so this\n network needs one of its own.\n' > /dev/tty
350
+ break ;;
351
+ 1) printf ' That is not the password for %s - try again.\n' "$RUN_USER" > /dev/tty ;;
352
+ *) printf ' That cannot be checked against the %s login here, so this network gets\n a password of its own.\n' "$RUN_USER" > /dev/tty
353
+ break ;;
354
+ esac
355
+ tries=$((tries+1))
356
+ done
357
+ AP_SOURCE=own
358
+ while true; do
359
+ ask_password AP_PW "the ${AP_NAME} network (8 to 63 characters)"
360
+ wifi_length_ok "$AP_PW" && return 0
361
+ printf ' A WiFi password must be 8 to 63 characters - try again.\n' > /dev/tty
362
+ AP_PW=""
363
+ done
364
+ }
365
+
268
366
  echo "Frugal IoT server installation"
269
367
  (( DRY_RUN )) && echo "Dry run - nothing will be changed" || echo "Log: ${LOG}"
270
368
 
@@ -375,6 +473,21 @@ if [[ -z "$BROKER_PW" ]]; then
375
473
  fi
376
474
  [[ -n "$BROKER_HOST" ]] || BROKER_HOST="$(hostname).local"
377
475
 
476
+ # The setup network: whether it can be had here, and whether a previous run already gave it a
477
+ # password - re-running this should not ask again. Read without a pipe into "grep -q", which under
478
+ # pipefail can report a match as a failure when grep closes the pipe early.
479
+ if (( SETUP_AP )); then AP_SKIP="$(ap_unavailable)"; else AP_SKIP="--no-setup-ap was given"; fi
480
+ if [[ -n "$AP_PW" ]] && ! wifi_length_ok "$AP_PW"; then
481
+ echo "--ap-password must be 8 to 63 characters, which is what WiFi accepts" >&2; exit 1
482
+ fi
483
+ AP_KEEP=0
484
+ if [[ -z "$AP_SKIP" && -z "$AP_PW" ]]; then
485
+ COMITUP_NOW="$(read_maybe_root "$COMITUP_CONF")"
486
+ grep -q '^ap_password:' <<< "$COMITUP_NOW" && { AP_KEEP=1; AP_SOURCE=kept; }
487
+ fi
488
+ # Not in a dry run: it would mean typing the login password for a run that then does nothing with it
489
+ if [[ -z "$AP_SKIP" ]] && (( ! AP_KEEP )) && (( ! DRY_RUN )); then ask_ap_password; fi
490
+
378
491
  info "install into: ${INSTALL_DIR} (owned by ${RUN_USER})"
379
492
  info "organization: ${ORG_ID} (${ORG_NAME})"
380
493
  info "broker URL: ws://${BROKER_HOST}:9012"
@@ -391,6 +504,13 @@ $( (( NEEDS_SWAP )) && echo " * add a 2G swap file, because this board has unde
391
504
  * install and configure mosquitto, with listeners on 1883 and 9012
392
505
  * create the superuser login and the ${ORG_ID} organization, and test the broker
393
506
  * install and start the frugaliot service, running as ${RUN_USER}
507
+ $( if [[ -n "$AP_SKIP" ]]; then
508
+ echo " * no setup network: ${AP_SKIP}"
509
+ else
510
+ echo " * install comitup and the watchdog that keeps it honest, so that with no saved WiFi in"
511
+ echo " range this Pi raises a network called ${AP_NAME} whose page sets a new one."
512
+ echo " Its password: $( (( AP_KEEP )) && echo "kept, from ${COMITUP_CONF}" || { [[ -n "$AP_PW" ]] && echo "the one given" || echo "that of the ${RUN_USER} login, asked for"; } )"
513
+ fi )
394
514
 
395
515
  Passwords it would use. A generated one is different on every run, so pass it or type it if you
396
516
  want this exact set. All of them are shown here because a dry run writes no log; a real run prints
@@ -413,7 +533,9 @@ UPGRADE_PLAN="$(sudo_ apt-get -s full-upgrade 2>/dev/null | awk '/^Inst /{print
413
533
  BOOT_BEFORE="$(boot_fingerprint)"
414
534
  # No reboot here even if this brings a new kernel, so that the run can continue unattended. Whether
415
535
  # one is needed is worked out below and said plainly in the summary.
416
- sudo_ env DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y -qq
536
+ # --force-confold: a package whose shipped configuration changed keeps the one on this machine
537
+ # rather than stopping to ask. /etc/comitup.conf, written below, is one such.
538
+ sudo_ env DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y -qq -o Dpkg::Options::=--force-confold
417
539
  if [[ -n "$UPGRADE_PLAN" ]]; then
418
540
  info "upgraded: $(echo "$UPGRADE_PLAN" | wc -l | tr -d ' ') package(s)"
419
541
  else
@@ -861,6 +983,66 @@ else
861
983
  fi
862
984
  systemctl is-enabled --quiet frugaliot && ok "will start again at boot"
863
985
 
986
+ step "A setup network, for when this Pi cannot find its WiFi"
987
+ # Moved to another site, or the router replaced, a Pi on WiFi is unreachable - and changing its WiFi
988
+ # needs a login. comitup raises a network of its own whenever no saved one is in range; its page
989
+ # lists the networks it can see and takes the password for one. The watchdog covers two ways comitup
990
+ # alone was seen to strand a Pi - see the comments in extras/frugaliot-ap-watchdog.
991
+ if [[ -n "$AP_SKIP" ]]; then
992
+ info "not set up: ${AP_SKIP}"
993
+ else
994
+ # The configuration goes in BEFORE the package, because installing it starts comitup at once - and
995
+ # without this file it uses its defaults, a network called comitup-<nnn> with NO password, raised
996
+ # straight away on a Pi that has no WiFi connection at that moment. Root 600: it holds a password,
997
+ # by default the login's, and comitup runs as root.
998
+ if (( AP_KEEP )); then
999
+ skip "${COMITUP_CONF} already has a password"
1000
+ else
1001
+ sudo_ install -o root -g root -m 600 /dev/null "$COMITUP_CONF"
1002
+ printf '# Written by install-pi.sh: the network this Pi raises when no saved WiFi is in range.\n# After changing either line: sudo systemctl restart comitup\nap_name: %s\nap_password: %s\n' \
1003
+ "$AP_NAME" "$AP_PW" | sudo_ tee "$COMITUP_CONF" >/dev/null
1004
+ ok "${COMITUP_CONF} written"
1005
+ fi
1006
+ if dpkg -s comitup >/dev/null 2>&1; then
1007
+ skip "comitup already installed"
1008
+ # A restart while connected keeps the connection; it only matters for the next time it is lost
1009
+ (( AP_KEEP )) || { sudo_ systemctl restart comitup; ok "comitup restarted to read the new password"; }
1010
+ else
1011
+ # --force-confold: the package ships its own /etc/comitup.conf, and would otherwise stop to ask
1012
+ # which to keep
1013
+ sudo_ env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq -o Dpkg::Options::=--force-confold comitup
1014
+ ok "comitup installed"
1015
+ fi
1016
+
1017
+ # From the installed package, not ${INSTALL_DIR}/extras - frugal-iot-init never overwrites its
1018
+ # copy, so a release that changed one of these would otherwise never reach the system
1019
+ install_extra() { # install_extra <file in extras/> <destination> <mode>; fails if already current
1020
+ local src="${PKG_DIR}/extras/$1"
1021
+ [[ -f "$src" ]] || src="${INSTALL_DIR}/extras/$1"
1022
+ [[ -f "$src" ]] || { echo "Expected extras/$1 in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1; }
1023
+ cmp -s "$src" "$2" && return 1
1024
+ sudo_ install -o root -g root -m "$3" "$src" "$2"
1025
+ }
1026
+ WD_CHANGED=0
1027
+ install_extra frugaliot-ap-watchdog /usr/local/sbin/frugaliot-ap-watchdog 755 && WD_CHANGED=1 || true
1028
+ install_extra frugaliot-ap-watchdog.service /etc/systemd/system/frugaliot-ap-watchdog.service 644 && WD_CHANGED=1 || true
1029
+ install_extra frugaliot-ap-watchdog.timer /etc/systemd/system/frugaliot-ap-watchdog.timer 644 && WD_CHANGED=1 || true
1030
+ if (( WD_CHANGED )); then
1031
+ sudo_ systemctl daemon-reload
1032
+ ok "watchdog installed"
1033
+ else
1034
+ skip "watchdog already current"
1035
+ fi
1036
+ sudo_ systemctl enable -q --now frugaliot-ap-watchdog.timer
1037
+
1038
+ if systemctl is-active --quiet comitup; then
1039
+ ok "comitup running: with no saved WiFi in range, this Pi raises ${AP_NAME}"
1040
+ else
1041
+ warn "comitup is not running, so this Pi will NOT raise ${AP_NAME} if it loses its WiFi."
1042
+ warn " Why: sudo tail -20 /var/log/comitup.log"
1043
+ fi
1044
+ fi
1045
+
864
1046
  # ---------------------------------------------------------------- what is left to do
865
1047
 
866
1048
  IP_ADDR="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
@@ -883,6 +1065,22 @@ else
883
1065
  installed here is already running."
884
1066
  fi
885
1067
 
1068
+ if [[ -n "$AP_SKIP" ]]; then
1069
+ AP_TEXT=" If this Pi loses its WiFi there is no setup network to fix it with (${AP_SKIP}).
1070
+ It can then be reached only with a screen and keyboard, or an Ethernet cable."
1071
+ else
1072
+ case "$AP_SOURCE" in
1073
+ login) AP_PW_TEXT="the password of the ${RUN_USER} login" ;;
1074
+ kept) AP_PW_TEXT="unchanged - see ${COMITUP_CONF}" ;;
1075
+ given) AP_PW_TEXT="the one you gave" ;;
1076
+ *) AP_PW_TEXT="$(shown AP_PW)" ;;
1077
+ esac
1078
+ AP_TEXT=" If this Pi cannot find its WiFi - moved, or a new router - it raises a network
1079
+ called ${AP_NAME}. Join it from a phone or laptop; the password is
1080
+ ${AP_PW_TEXT}. A page opens to choose the new WiFi.
1081
+ INSTALLATION.md, \"Moving the Pi to another WiFi network\", has the rest."
1082
+ fi
1083
+
886
1084
  cat <<SUMMARY
887
1085
 
888
1086
  =======================================================================
@@ -939,6 +1137,8 @@ ${REBOOT_TEXT}
939
1137
  network, which is all an offline installation needs. What you do not get is over-the-air
940
1138
  firmware updates, since an ESP32 requires HTTPS to fetch them.
941
1139
 
1140
+ ${AP_TEXT}
1141
+
942
1142
  If anything looks wrong:
943
1143
  cd ${INSTALL_DIR} && npx --no frugal-iot-diagnostic
944
1144
 
@@ -24,7 +24,7 @@ import {
24
24
  applyRolesAndGroups, applyUser, applyNode, applyLogger, applyOrgAdmin, applyBridge,
25
25
  checkRolesAndGroups,
26
26
  } from '../lib/dynsec-sync.js';
27
- import { names } from '../lib/dynsec-plan.js';
27
+ import { names, bridgesToSync } from '../lib/dynsec-plan.js';
28
28
 
29
29
  const userClientName = names.userClient;
30
30
 
@@ -90,10 +90,14 @@ new MqttLogger().readYamlConfig('.', (err, config) => {
90
90
  const want = names.loggerClient(org);
91
91
  if (!clients.includes(want)) differences.push(`client missing: ${want}`);
92
92
  }
93
- for (const b of bridges) {
93
+ // Only a bridge whose password this server derives can be missing - see
94
+ // bridgesToSync for why the others are left alone rather than reported
95
+ const { apply, skipped } = bridgesToSync(bridges, clients);
96
+ for (const b of apply) {
94
97
  const want = names.bridgeClient(b.site);
95
98
  if (!clients.includes(want)) differences.push(`client missing: ${want}`);
96
99
  }
100
+ reportSkippedBridges(skipped);
97
101
  report(differences);
98
102
  dynsec.end(() => process.exit(differences.length ? 3 : 0));
99
103
  });
@@ -118,9 +122,10 @@ new MqttLogger().readYamlConfig('.', (err, config) => {
118
122
  syncOrgAdmins(dynsec, orgs, userSecret, (e7, j) => {
119
123
  if (e7) return fail(`Syncing admin accounts: ${e7.message}`);
120
124
  console.log(`${j} server admin account(s)`);
121
- syncBridges(dynsec, bridges, userSecret, (e8, b) => {
125
+ syncBridges(dynsec, bridges, userSecret, (e8, b, skipped) => {
122
126
  if (e8) return fail(`Syncing bridge accounts: ${e8.message}`);
123
127
  console.log(`${b} bridge account(s)`);
128
+ reportSkippedBridges(skipped);
124
129
  dynsec.end(() => process.exit(0));
125
130
  });
126
131
  });
@@ -146,19 +151,41 @@ function readBridges(db, cb) {
146
151
  db.all("SELECT name FROM sqlite_master WHERE type='table' AND name='bridges'", [], (err, t) => {
147
152
  if (err) return cb(err);
148
153
  if (!t.length) return cb(null, []);
149
- db.all('SELECT org, site, created_at FROM bridges', [], (e, rows) => cb(e, rows || []));
154
+ // "*" and not a column list: credential is absent from a database the server has not yet
155
+ // started on since it was added, and a missing column must read as null, not fail the query
156
+ db.all('SELECT * FROM bridges', [], (e, rows) => cb(e, rows || []));
150
157
  });
151
158
  }
152
159
 
160
+ // Only the bridges whose password this server derives - see bridgesToSync in lib/dynsec-plan.js.
161
+ // A bridge authorized with frugal-iot-addbridge-prod has its own password in mosquitto's password
162
+ // file, and creating a plugin client of the same name would silently replace it.
153
163
  function syncBridges(dynsec, bridges, userSecret, cb) {
154
- let i = 0;
155
- const next = () => {
156
- if (i >= bridges.length) return cb(null, bridges.length);
157
- const b = bridges[i++];
158
- applyBridge(dynsec, { org: b.org, site: b.site, createdAt: b.created_at, userSecret },
159
- (e) => (e ? cb(e) : next()));
160
- };
161
- next();
164
+ dynsec.listClients((err, clients) => {
165
+ if (err) return cb(err);
166
+ const { apply, skipped } = bridgesToSync(bridges, clients);
167
+ let i = 0;
168
+ const next = () => {
169
+ if (i >= apply.length) return cb(null, apply.length, skipped);
170
+ const b = apply[i++];
171
+ applyBridge(dynsec, { org: b.org, site: b.site, createdAt: b.created_at, userSecret },
172
+ (e) => (e ? cb(e) : next()));
173
+ };
174
+ next();
175
+ });
176
+ }
177
+
178
+ function reportSkippedBridges(skipped) {
179
+ for (const b of skipped) {
180
+ if (b.reason === 'password_file') {
181
+ console.log(` ${b.account}: left alone - authorized with frugal-iot-addbridge-prod, so its password`);
182
+ console.log(' is in mosquitto\'s password file. Re-issue it on the dashboard\'s Bridges card to move it here.');
183
+ } else {
184
+ console.log(` ${b.account}: left alone - no account for it here, and nothing records whether its password`);
185
+ console.log(' is in mosquitto\'s password file. If its Pi is connected, it is, and nothing is wrong;');
186
+ console.log(' if not, re-issue it on the dashboard\'s Bridges card.');
187
+ }
188
+ }
162
189
  }
163
190
 
164
191
  function report(differences) {