frugal-iot-server 2.0.2 → 2.0.3

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
@@ -978,48 +978,65 @@ installed by hand, the written procedure for doing that.
978
978
  > Note the asymmetry with the Pi end below: a *certificate* change needs only `reload`, but adding
979
979
  > or changing a *bridge* needs a full `restart`, because Mosquitto does not reload bridges.
980
980
 
981
- #### 11b. Authorizing this Pi (run on the production server)
981
+ #### 11b. Authorizing this Pi (on the production server)
982
982
 
983
- Once per Pi. On the production server, from its own directory:
983
+ Once per Pi, and there are two ways. **Use the dashboard** unless that server's broker has no
984
+ dynamic security plugin.
985
+
986
+ **From the dashboard** — no shell account on the production server needed, which is the point:
987
+
988
+ 1. Log in to production as someone with ADMIN on the organization.
989
+ 2. Open the organization, gear icon → **Bridges**.
990
+ 3. Under "Add a bridge", give the Pi a short name — `northfield`, `shed`, `village2`. It only
991
+ distinguishes one Pi from another within an organization, and becomes part of the broker account
992
+ name, so it cannot be changed afterwards without reconfiguring that Pi.
993
+ 4. Copy the **broker password** and the **replica token** it shows. Both are shown once.
994
+
995
+ **From a shell on the production server**, which is what to use if its broker predates the plugin:
984
996
 
985
997
  ```
986
998
  npx --no frugal-iot-addbridge-prod <org-id> <site-name>
987
999
  ```
988
1000
 
989
- The site name only distinguishes one Pi from another within an organization — `northfield`,
990
- `shed`, `village2` — and becomes part of the account name. The script creates the broker account,
991
- adds its access-control rule, reloads the broker, and prints both the password and the exact
992
- command to run in 11c.
1001
+ Either way you end up with a broker account `bridge-<site-name>`, its access-control rules, a
1002
+ replica token, and the command to run in 11c.
993
1003
 
994
- Three things it checks, because each of them is a way this quietly fails:
1004
+ Three things worth knowing, because each is a way this quietly fails:
995
1005
 
996
1006
  * **The organization must already exist on the production server**, or the readings will arrive at
997
1007
  its broker and nothing will record them — that server's logger subscribes per organization,
998
1008
  driven by the files in its `config.d/organizations/`. If it is missing, add it there first with
999
1009
  `frugal-iot-addorganization`, using the *same* organization id as this Pi.
1000
- * **Each Pi gets its own account**, rather than sharing the organization's. The organization's
1001
- broker password is handed to every browser that logs in, so it is not a secret; a per-Pi account
1002
- can be confined to one site and revoked on its own.
1003
- * **An existing account is left alone.** Re-running it for a site that already has one is refused
1004
- rather than quietly issuing a new password, which would stop that Pi relaying without anyone
1005
- touching it.
1010
+ * **Each Pi gets its own account**, rather than sharing the organization's, so it can be confined to
1011
+ one site and revoked on its own.
1012
+ * **Doing it again for a site that already exists re-issues both credentials**, and the ones that Pi
1013
+ is using stop working — from the dashboard it asks first. That is how to recover from having lost
1014
+ them, and how to revoke a Pi you no longer trust; it is not a way to look them up again.
1006
1015
 
1007
- The password is shown once and is not stored anywhere you can read back — copy it before you lose
1008
- the output.
1016
+ Neither credential is stored anywhere you can read back, so copy both before you lose the output.
1009
1017
 
1010
1018
  #### 11c. Bridging this Pi (run on the Pi)
1011
1019
 
1012
- Once per Pi. Run the command 11b printed, from this installation's directory:
1020
+ Once per Pi. Have both credentials from 11b to hand first — it asks for each in turn:
1021
+
1022
+ * the **broker password** for `bridge-<site-name>`, which the MQTT bridge authenticates with;
1023
+ * the **replica token**, which lets this Pi pull production's logins so the same people can log in
1024
+ here. Press Enter to skip it: a bridge relays readings perfectly well without one, and you can
1025
+ re-run this later to add it.
1026
+
1027
+ They are two different credentials for two different connections — MQTT on port 8883 for the first,
1028
+ HTTPS for the second — so neither substitutes for the other.
1013
1029
 
1014
1030
  ```
1015
1031
  cd ~/frugal-iot
1016
1032
  npx --no frugal-iot-addbridge-pi <org-id> <prod-host> bridge-<site-name>
1017
1033
  ```
1018
1034
 
1019
- It asks for the password, then it checks the far end is reachable and that its certificate is valid for that name,
1020
- writes `/etc/mosquitto/conf.d/frugal-iot-bridge.conf`, restarts the broker, and reports whether the
1021
- bridge actually connected. It briefly disconnects every node,
1022
- which they recover from on their own.
1035
+ Having asked for those, it checks the far end is reachable and that its certificate is valid for
1036
+ that name, writes `/etc/mosquitto/conf.d/frugal-iot-bridge.conf` and — if you gave a token —
1037
+ `config.d/replica.yaml` with the token in `config.d/secrets.yaml`, restarts the broker, and reports
1038
+ whether the bridge actually connected. It briefly disconnects every node, which they recover from on
1039
+ their own.
1023
1040
 
1024
1041
  > The configuration goes into `/etc/mosquitto/conf.d/` and not into this installation's
1025
1042
  > `config.d/`, to keep the password out of `/config.json`. The file is written mode 600 for the same reason.
@@ -249,6 +249,21 @@ gps:
249
249
  - leaf: hdop
250
250
  - leaf: position
251
251
  - leaf: utc_time
252
+ # HC-SR04 ultrasonic module, timed trigger/echo - Sensor_HCSR04. The distance is the same shape
253
+ # as the Modbus `ultrasonic` module's, but the module is only rated to 4m. The temperature is a
254
+ # compensation input for the speed of sound, not a reading - so controlfloat, as for ens160.
255
+ hcsr04:
256
+ name: "HC-SR04 Distance"
257
+ topics:
258
+ - leaf: hcsr04
259
+ leaf_from: ultrasonic
260
+ max: 4000
261
+ - leaf: temperature
262
+ leaf_from: controlfloat
263
+ name: Air Temperature
264
+ min: -20
265
+ max: 50
266
+ color: "#ff0000"
252
267
  health:
253
268
  name: System
254
269
  insidefrugaliot: true
@@ -109,7 +109,7 @@ import { MqttLogger } from "frugal-iot-logger"; // https://github.com/mitra42/f
109
109
  import { createAPIRouter, createAPIErrorHandler } from './lib/api-routes.js';
110
110
  import { buildConfigFor, hasPermissions } from './lib/config-for-user.js';
111
111
  import { ensureSecrets, addEnrolmentSecret, removeEnrolmentSecret } from './lib/secrets.js';
112
- import { syncUser, syncUserById, syncLoggers, syncNode, dropNode } from './lib/dynsec-server.js';
112
+ import { syncUser, syncUserById, syncLoggers, syncNode, dropNode, syncBridge } from './lib/dynsec-server.js';
113
113
  import { enrol, ENROL, enrolmentSecretsFor, makeRateLimiter, forgetNode,
114
114
  makeAttemptLog, setGrant, clearGrant, readGrants } from './lib/enrol.js';
115
115
  import { deleteRetained } from './lib/retained.js';
@@ -1616,6 +1616,66 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1616
1616
  });
1617
1617
  },
1618
1618
  );
1619
+ /*
1620
+ * Add a Pi bridge, or re-issue an existing one's credentials.
1621
+ *
1622
+ * The dashboard equivalent of frugal-iot-addbridge-prod, and it exists because that script
1623
+ * needs root: it writes the password file and edits /etc/mosquitto/aclfile. Here the
1624
+ * account and its rules go into the dynamic-security plugin, which the server can drive
1625
+ * with the credential it already holds - so an organization's admin can add a bridge
1626
+ * without anybody having a shell on this machine.
1627
+ *
1628
+ * The password is DERIVED from user_secret and the row's created_at, so nothing new is
1629
+ * stored and frugal-iot-rebuild-dynsec can recreate the account exactly. Re-running this
1630
+ * for a site that already exists moves created_at, which changes the derived password and
1631
+ * mints a new replica token - deliberately, since that is how a Pi's credentials are
1632
+ * rotated, and it revokes the old ones.
1633
+ *
1634
+ * POST because it changes things: a GET here could be triggered against a live admin
1635
+ * session by any page that can make this browser fetch a URL (SEC-17).
1636
+ */
1637
+ app.post('/bridge_add/:org',
1638
+ loggedInOrFail,
1639
+ can_ADMIN, // Gets org from URL - an admin may add a bridge only to their own organization
1640
+ express.json({ limit: '4kb' }),
1641
+ (req, res) => {
1642
+ const org = req.params.org;
1643
+ const site = String((req.body && req.body.site) || '').trim();
1644
+ // The site becomes a broker account name and an MQTT topic level, so keep it to what
1645
+ // is safe in both, and to what the Pi's own config can carry unquoted.
1646
+ if (!/^[a-z0-9][a-z0-9-]{0,19}$/.test(site)) {
1647
+ return res.status(400).json({ error:
1648
+ 'Site must be 1-20 characters, lower-case letters, digits and hyphens, starting with a letter or digit' });
1649
+ }
1650
+ const now = Date.now();
1651
+ const token = crypto.randomBytes(32).toString('base64url');
1652
+ db.run(`INSERT INTO bridges (org, site, token, created_at) VALUES (?, ?, ?, ?)
1653
+ ON CONFLICT(org, site) DO UPDATE SET token = excluded.token,
1654
+ created_at = excluded.created_at`,
1655
+ [org, site, token, now], (err) => {
1656
+ if (err) { return res.status(500).json({ error: err.message }); }
1657
+ syncBridge(config, { org, site, createdAt: now }, (serr, result) => {
1658
+ if (serr) {
1659
+ // The row is written but the broker has no account, so the Pi could not connect.
1660
+ // Say so plainly rather than handing over a credential that does not work.
1661
+ return res.status(500).json({ error:
1662
+ `The bridge was recorded but its broker account could not be created: ${serr.message}` });
1663
+ }
1664
+ console.log("Bridge added by", req.user.username, "-", org, site);
1665
+ res.status(200).json({
1666
+ site,
1667
+ account: result.username,
1668
+ password: result.password,
1669
+ replica_token: token,
1670
+ // The command to run on the Pi. Neither secret is in it on purpose: that
1671
+ // 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}`,
1674
+ });
1675
+ });
1676
+ });
1677
+ },
1678
+ );
1619
1679
  app.post('/node_reset/:org',
1620
1680
  loggedInOrFail,
1621
1681
  can_ADMIN, // Gets org from URL - an organization admin may reset only their own nodes
@@ -75,6 +75,18 @@ export const names = {
75
75
  */
76
76
  orgAdminClient: (org) => `${org}-admin`,
77
77
  clearRole: (org) => `${org}-clear`,
78
+
79
+ /*
80
+ * A Pi bridge. The account name is fixed by history: addbridge-prod.zsh has always called it
81
+ * "bridge-<site>", the Pi's mosquitto config carries that as remote_clientid, and the state topic
82
+ * the dashboard reads is named after it - so this cannot be renamed without reconfiguring every
83
+ * bridge that exists.
84
+ *
85
+ * Its role is per-bridge, not per-organization, because one of its rules names the account:
86
+ * a bridge may publish its OWN connection state and no one else's.
87
+ */
88
+ bridgeClient: (site) => `bridge-${site}`,
89
+ bridgeRole: (site) => `bridge-${site}`,
78
90
  };
79
91
 
80
92
  // Topic filters, in one place for the same reason.
@@ -92,6 +104,8 @@ const topics = {
92
104
  // dashboard showing no devices while the readings themselves went through perfectly.
93
105
  discovery: (org, project) => `${org}/${project}`,
94
106
  bridgeState: '$SYS/broker/connection/+/state',
107
+ // One bridge's own state, which only that bridge may publish
108
+ bridgeOwnState: (account) => `$SYS/broker/connection/${account}/state`,
95
109
  };
96
110
 
97
111
  /*
@@ -139,7 +153,7 @@ export function groupsForUser(rows) {
139
153
  * scopes [{org, project}] - '' project meaning organization-wide
140
154
  * publics [{org, project}] - the id-0 READ rows, as ACLs on the one shared public role
141
155
  */
142
- export function desiredRolesAndGroups(scopes, publics, projects) {
156
+ export function desiredRolesAndGroups(scopes, publics, projects, bridges) {
143
157
  const roles = {};
144
158
  const groups = {};
145
159
 
@@ -223,6 +237,25 @@ export function desiredRolesAndGroups(scopes, publics, projects) {
223
237
  roles: [names.readRole(org, ORGWIDE), names.ownSubtreeRole, names.discoverRole(org)] };
224
238
  groups[names.gatewayGroup(org)] = { roles: [names.gatewayRole(org)] };
225
239
  }
240
+
241
+ /*
242
+ * One role per bridge: the whole of its organization, plus its own connection state.
243
+ *
244
+ * The state rule is what the dashboard's Bridges card depends on. A bridge's LOCAL side is exempt
245
+ * from access control, so a Pi always has its own state; the REMOTE side is an ordinary client,
246
+ * and without this it is refused - silently, which is how a bridge that was up and relaying
247
+ * perfectly still showed as "Unknown".
248
+ */
249
+ for (const { org, site } of bridges || []) {
250
+ if (!org || !site) continue;
251
+ const account = names.bridgeClient(site);
252
+ roles[names.bridgeRole(site)] = [
253
+ { acltype: 'subscribePattern', topic: topics.orgTree(org, ORGWIDE), allow: true },
254
+ { acltype: 'publishClientSend', topic: topics.orgTree(org, ORGWIDE), allow: true },
255
+ { acltype: 'publishClientReceive', topic: topics.orgTree(org, ORGWIDE), allow: true },
256
+ { acltype: 'publishClientSend', topic: topics.bridgeOwnState(account), allow: true },
257
+ ];
258
+ }
226
259
  return { roles, groups };
227
260
  }
228
261
 
@@ -248,6 +281,23 @@ export function deriveLoggerPassword(userSecret, org) {
248
281
  * then a confused or compromised logger could invent sensor data - and the readings are the whole
249
282
  * point of the system. The <org>-write role is set/-only, which is exactly that.
250
283
  */
284
+ /*
285
+ * A bridge's password. Derived, like the logger's and the org admin's, so nothing has to be stored
286
+ * and "the broker can be rebuilt from the database" stays true - a bridge account recreated by
287
+ * frugal-iot-rebuild-dynsec gets exactly the password the Pi is already configured with.
288
+ *
289
+ * created_at is in the input so that a bridge CAN be given a new password: updating that column
290
+ * changes the derivation, which is what re-running the add makes happen, and the old credential
291
+ * stops working. Without it a bridge password could never be rotated short of changing user_secret,
292
+ * which would reset every account on the broker.
293
+ */
294
+ export function deriveBridgePassword(userSecret, site, createdAt) {
295
+ if (!userSecret) throw new Error('No user_secret - run frugal-iot-init');
296
+ return createHmac('sha256', userSecret)
297
+ .update('bridge:').update(String(site)).update(':').update(String(createdAt))
298
+ .digest('base64url').slice(0, 22);
299
+ }
300
+
251
301
  // The server's administrative account for one organization. Derived, like the logger's, with its
252
302
  // own label so the two can never collide.
253
303
  export function deriveOrgAdminPassword(userSecret, org) {
@@ -9,7 +9,8 @@
9
9
  */
10
10
 
11
11
  import { dynsecConnect } from './dynsec.js';
12
- import { readUserRows, applyUser, applyLogger, applyNode, removeUser } from './dynsec-sync.js';
12
+ import { readUserRows, applyUser, applyLogger, applyNode, removeUser,
13
+ applyRolesAndGroups, applyBridge, removeBridge } from './dynsec-sync.js';
13
14
  import { derivePassword, deriveLoggerPassword, names } from './dynsec-plan.js';
14
15
 
15
16
  let unavailableReported = false;
@@ -226,6 +227,45 @@ export function dropNode(config, org, project, nodeid, cb) {
226
227
  });
227
228
  }
228
229
 
230
+ /*
231
+ * Create or re-issue one Pi bridge's broker account, and its role.
232
+ *
233
+ * Both the role and the client, because a bridge's role names the bridge - unlike a user, whose
234
+ * groups exist already. applyRolesAndGroups is given just this one bridge: it is idempotent and
235
+ * only ever adds, so the roles and groups of everything else are left as they are.
236
+ *
237
+ * Unlike addbridge-prod.zsh this needs no root and no shell: the account goes into the plugin
238
+ * rather than the password file, and the ACL into a dynsec role rather than /etc/mosquitto/aclfile.
239
+ * That is what lets an organization's admin add a bridge from the dashboard.
240
+ */
241
+ export function syncBridge(config, { org, site, createdAt }, cb) {
242
+ const userSecret = config.secrets && config.secrets.user_secret;
243
+ if (!userSecret) return cb(new Error('No user_secret in config.d/secrets.yaml'));
244
+ if (!dynsecConfigured(config)) {
245
+ return cb(new Error('This server has no dynamic security credential, so it cannot create '
246
+ + 'broker accounts. Use frugal-iot-addbridge-prod from a shell on the server instead.'));
247
+ }
248
+ withDynsec(config, (cerr, dynsec, done) => {
249
+ if (cerr) { reportUnavailable(`bridge ${site}`, cerr); return cb(cerr); }
250
+ applyRolesAndGroups(dynsec, [], [], [], [{ org, site }], (rerr) => {
251
+ if (rerr) { done(); return cb(rerr); }
252
+ applyBridge(dynsec, { org, site, createdAt, userSecret }, (err, res) => { done(); cb(err, res); });
253
+ });
254
+ });
255
+ }
256
+
257
+ // Remove a bridge's account and its role, for when a Pi is retired.
258
+ export function dropBridge(config, site, cb) {
259
+ cb = cb || (() => {});
260
+ withDynsec(config, (cerr, dynsec, done) => {
261
+ if (cerr) { reportUnavailable(`removing bridge ${site}`, cerr); return cb(cerr); }
262
+ removeBridge(dynsec, site, (err) => {
263
+ done();
264
+ cb(err && !/not found/i.test(err.message) ? err : null);
265
+ });
266
+ });
267
+ }
268
+
229
269
  // When an account goes away. Not wired up yet - nothing deletes users - but the rebuild tool cannot
230
270
  // notice a user that is no longer in the database, so this is what the deletion path will need.
231
271
  export function dropUser(config, username, cb) {
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { desiredRolesAndGroups, groupsForUser, groupsForNode, groupsForLogger,
10
- derivePassword, deriveLoggerPassword, deriveOrgAdminPassword, names } from './dynsec-plan.js';
10
+ derivePassword, deriveLoggerPassword, deriveOrgAdminPassword, deriveBridgePassword, names } from './dynsec-plan.js';
11
11
 
12
12
  // ---- reading the database -------------------------------------------------------------------
13
13
  // Every (org, project) any permission row mentions, plus every organization in the config, so an
@@ -51,8 +51,8 @@ export function readUserRows(db, id, cb) {
51
51
  * Create every role and group the plan calls for, and set their ACLs. Idempotent: re-running is how
52
52
  * the rebuild tool works, and how a release that adds a rule delivers it.
53
53
  */
54
- export function applyRolesAndGroups(dynsec, scopes, publics, projects, cb) {
55
- const { roles, groups } = desiredRolesAndGroups(scopes, publics, projects);
54
+ export function applyRolesAndGroups(dynsec, scopes, publics, projects, bridges, cb) {
55
+ const { roles, groups } = desiredRolesAndGroups(scopes, publics, projects, bridges);
56
56
  const create = [];
57
57
  const acls = [];
58
58
  for (const [rolename, list] of Object.entries(roles)) {
@@ -171,6 +171,37 @@ export function applyOrgAdmin(dynsec, { org, userSecret }, cb) {
171
171
  });
172
172
  }
173
173
 
174
+ /*
175
+ * One Pi bridge's broker account.
176
+ *
177
+ * Its role is attached DIRECTLY rather than through a group, like the org admin's, because the role
178
+ * names this one account in its state rule - a group would invite a second bridge being put in it,
179
+ * which would let each publish the other's connection state.
180
+ *
181
+ * Nothing here needs root, which is the point: addbridge-prod.zsh had to run mosquitto_passwd and
182
+ * edit /etc/mosquitto/aclfile, so only someone with a shell on the server could add a bridge.
183
+ */
184
+ export function applyBridge(dynsec, { org, site, createdAt, userSecret }, cb) {
185
+ const username = names.bridgeClient(site);
186
+ const password = deriveBridgePassword(userSecret, site, createdAt);
187
+ const role = names.bridgeRole(site);
188
+ dynsec.idempotent([{ command: 'createClient', username, password }], (err) => {
189
+ if (err) return cb(err);
190
+ dynsec.getClient(username, (err2, client) => {
191
+ if (err2) return cb(err2);
192
+ const haveRoles = ((client && client.roles) || []).map((r) => r.rolename || r);
193
+ const cmds = [{ command: 'setClientPassword', username, password }];
194
+ if (!haveRoles.includes(role)) cmds.push({ command: 'addClientRole', username, rolename: role });
195
+ dynsec.idempotent(cmds, (err3) => cb(err3, { username, password, org }));
196
+ });
197
+ });
198
+ }
199
+
200
+ export function removeBridge(dynsec, site, cb) {
201
+ dynsec.raw([{ command: 'deleteClient', username: names.bridgeClient(site) },
202
+ { command: 'deleteRole', rolename: names.bridgeRole(site) }], (err) => cb(err));
203
+ }
204
+
174
205
  export function removeUser(dynsec, username, cb) {
175
206
  dynsec.raw([{ command: 'deleteClient', username: names.userClient(username) }], (err) => {
176
207
  if (err && /not found/i.test(err.message)) return cb(null);
@@ -184,7 +215,7 @@ export function removeUser(dynsec, username, cb) {
184
215
  * frugal-iot-rebuild-dynsec --check reports and what scripts/diagnostic.zsh calls: reporting drift
185
216
  * is useful more often than repairing it.
186
217
  */
187
- export function checkRolesAndGroups(dynsec, scopes, publics, projects, cb) {
218
+ export function checkRolesAndGroups(dynsec, scopes, publics, projects, bridges, cb) {
188
219
  const { roles, groups } = desiredRolesAndGroups(scopes, publics, projects);
189
220
  const differences = [];
190
221
  dynsec.listRoles((err, haveRoles) => {
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.2",
16
- "frugal-iot-logger": "^2.0.2",
15
+ "frugal-iot-client": "^2.0.3",
16
+ "frugal-iot-logger": "^2.0.3",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -86,5 +86,5 @@
86
86
  "check-files": "node scripts/check-published-files.js"
87
87
  },
88
88
  "type": "module",
89
- "version": "2.0.2"
89
+ "version": "2.0.3"
90
90
  }
@@ -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.2';
9
+ const CACHE_NAME = 'frugal-iot-cache-2.0.3';
10
10
  /*
11
11
  * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
12
  *
@@ -190,15 +190,42 @@ if [[ -n "$ACLFILE" ]]; then
190
190
  echo "The account has been created but cannot reach anything until that is sorted out." >&2
191
191
  exit 1
192
192
  fi
193
- if sudo grep -qE "^[[:space:]]*user[[:space:]]+${ACCOUNT}[[:space:]]*$" "$ACLFILE"; then
194
- echo "ACL rule for ${ACCOUNT} is already in ${ACLFILE} - leaving it alone."
193
+ #
194
+ # Two grants, and the second is easy to leave out.
195
+ #
196
+ # A bridge with "notifications true" publishes a retained 1/0 to
197
+ # $SYS/broker/connection/<clientid>/state on the local AND the remote broker, and that is what the
198
+ # dashboard's Bridges card reads. The bridge's LOCAL side is exempt from the ACL, so the Pi always
199
+ # has it; the remote side is not, and on a deny-by-default broker the write is refused silently -
200
+ # so the card said "Unknown" for a bridge that was up and relaying perfectly. Measured on
201
+ # mosquitto 2.0.20: the write is allowed once the ACL says so, so this is an ACL matter and not
202
+ # one of mosquitto refusing $SYS writes outright.
203
+ ACL_ORG_RULE=0; ACL_SYS_RULE=0
204
+ sudo grep -qE "^[[:space:]]*user[[:space:]]+${ACCOUNT}[[:space:]]*$" "$ACLFILE" && ACL_ORG_RULE=1
205
+ sudo grep -qF "topic write \$SYS/broker/connection/${ACCOUNT}/state" "$ACLFILE" && ACL_SYS_RULE=1
206
+
207
+ if (( ACL_ORG_RULE && ACL_SYS_RULE )); then
208
+ echo "ACL rules for ${ACCOUNT} are already in ${ACLFILE} - leaving them alone."
209
+ elif (( ACL_ORG_RULE )); then
210
+ # An existing bridge from before the state grant existed. A second "user" stanza for the same
211
+ # account is valid and additive, which is simpler and safer than editing the first one in place.
212
+ echo "Adding the connection-state ACL rule for ${ACCOUNT} to ${ACLFILE} ..."
213
+ sudo tee -a "$ACLFILE" >/dev/null <<EOF
214
+
215
+ # Lets the bridge report whether it is connected, for the dashboard's Bridges card
216
+ user ${ACCOUNT}
217
+ topic write \$SYS/broker/connection/${ACCOUNT}/state
218
+ EOF
219
+ echo " Restart the Pi's broker to have it republish that state: it is sent on connect only."
195
220
  else
196
- echo "Adding ACL rule to ${ACLFILE} ..."
221
+ echo "Adding ACL rules to ${ACLFILE} ..."
197
222
  sudo tee -a "$ACLFILE" >/dev/null <<EOF
198
223
 
199
224
  # Pi bridge for site "${SITE}", confined to its organization's topics
200
225
  user ${ACCOUNT}
201
226
  topic readwrite ${ORG_ID}/#
227
+ # ... and its own connection state, which the dashboard's Bridges card reads
228
+ topic write \$SYS/broker/connection/${ACCOUNT}/state
202
229
  EOF
203
230
  fi
204
231
  else
@@ -288,11 +315,14 @@ if [[ -n "$REPLICA_TOKEN" ]]; then
288
315
  fi
289
316
  echo "Then, on the Pi at site '${SITE}':"
290
317
  echo ""
318
+ echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <fqdn-from-1-above> ${ACCOUNT}"
319
+ echo ""
291
320
  if [[ -n "$REPLICA_TOKEN" ]]; then
292
- echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <fqdn-from-1-above> ${ACCOUNT} -- --replica-token <token-from-3>"
321
+ echo "It asks for the password from 2, then for the replica token from 3. Neither is given on the"
322
+ echo "command line on purpose, so that neither ends up in that Pi's shell history."
293
323
  else
294
- echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <fqdn-from-1-above> ${ACCOUNT}"
324
+ echo "It asks for the password from 2. Neither secret is given on the command line on purpose, so"
325
+ echo "that neither ends up in that Pi's shell history."
295
326
  fi
296
- echo ""
297
- echo "The first three are positional on purpose: npm swallows any --flag it does not recognise and"
298
- echo "passes only the value on, so the --org form would arrive at that script as a bare word."
327
+ echo "The arguments are positional because npm swallows any --flag it does not recognise and passes"
328
+ echo "only the value on, so the --org form would arrive at that script as a bare word."
@@ -21,7 +21,8 @@ import { MqttLogger } from 'frugal-iot-logger';
21
21
  import { dynsecConnect } from '../lib/dynsec.js';
22
22
  import {
23
23
  readScopes, readPublics, readProjects, readUserRows,
24
- applyRolesAndGroups, applyUser, applyNode, applyLogger, applyOrgAdmin, checkRolesAndGroups,
24
+ applyRolesAndGroups, applyUser, applyNode, applyLogger, applyOrgAdmin, applyBridge,
25
+ checkRolesAndGroups,
25
26
  } from '../lib/dynsec-sync.js';
26
27
  import { names } from '../lib/dynsec-plan.js';
27
28
 
@@ -71,9 +72,11 @@ new MqttLogger().readYamlConfig('.', (err, config) => {
71
72
  if (e2) return fail(e2.message);
72
73
  readProjects(db, (e2b, projects) => {
73
74
  if (e2b) return fail(e2b.message);
75
+ readBridges(db, (e2c, bridges) => {
76
+ if (e2c) return fail(e2c.message);
74
77
 
75
78
  if (CHECK) {
76
- checkRolesAndGroups(dynsec, scopes, publics, projects, (e3, differences) => {
79
+ checkRolesAndGroups(dynsec, scopes, publics, projects, bridges, (e3, differences) => {
77
80
  if (e3) return fail(e3.message);
78
81
  db.all('SELECT id, username FROM users WHERE id > 0 AND username IS NOT NULL', [], (e4, users) => {
79
82
  if (e4) return fail(e4.message);
@@ -87,6 +90,10 @@ new MqttLogger().readYamlConfig('.', (err, config) => {
87
90
  const want = names.loggerClient(org);
88
91
  if (!clients.includes(want)) differences.push(`client missing: ${want}`);
89
92
  }
93
+ for (const b of bridges) {
94
+ const want = names.bridgeClient(b.site);
95
+ if (!clients.includes(want)) differences.push(`client missing: ${want}`);
96
+ }
90
97
  report(differences);
91
98
  dynsec.end(() => process.exit(differences.length ? 3 : 0));
92
99
  });
@@ -95,7 +102,7 @@ new MqttLogger().readYamlConfig('.', (err, config) => {
95
102
  return;
96
103
  }
97
104
 
98
- applyRolesAndGroups(dynsec, scopes, publics, projects, (e3) => {
105
+ applyRolesAndGroups(dynsec, scopes, publics, projects, bridges, (e3) => {
99
106
  if (e3) return fail(`Setting up roles and groups: ${e3.message}`);
100
107
  console.log(`Roles and groups for ${scopes.length} organization/project scope(s), ` +
101
108
  `${projects.length} project(s), ${publics.length} publicly readable`);
@@ -111,12 +118,17 @@ new MqttLogger().readYamlConfig('.', (err, config) => {
111
118
  syncOrgAdmins(dynsec, orgs, userSecret, (e7, j) => {
112
119
  if (e7) return fail(`Syncing admin accounts: ${e7.message}`);
113
120
  console.log(`${j} server admin account(s)`);
114
- dynsec.end(() => process.exit(0));
121
+ syncBridges(dynsec, bridges, userSecret, (e8, b) => {
122
+ if (e8) return fail(`Syncing bridge accounts: ${e8.message}`);
123
+ console.log(`${b} bridge account(s)`);
124
+ dynsec.end(() => process.exit(0));
125
+ });
115
126
  });
116
127
  });
117
128
  });
118
129
  });
119
130
  });
131
+ });
120
132
  });
121
133
  });
122
134
  });
@@ -124,6 +136,31 @@ new MqttLogger().readYamlConfig('.', (err, config) => {
124
136
  });
125
137
  });
126
138
 
139
+ /*
140
+ * The Pi bridges, which are accounts like any other and so belong in a rebuild.
141
+ *
142
+ * The table arrives with the first bridge ever added, so its absence is normal rather than an
143
+ * error - the same treatment the nodes table gets below.
144
+ */
145
+ function readBridges(db, cb) {
146
+ db.all("SELECT name FROM sqlite_master WHERE type='table' AND name='bridges'", [], (err, t) => {
147
+ if (err) return cb(err);
148
+ if (!t.length) return cb(null, []);
149
+ db.all('SELECT org, site, created_at FROM bridges', [], (e, rows) => cb(e, rows || []));
150
+ });
151
+ }
152
+
153
+ 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();
162
+ }
163
+
127
164
  function report(differences) {
128
165
  if (!differences.length) {
129
166
  console.log('The broker matches the database.');