frugal-iot-server 2.0.2 → 2.0.4
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 +37 -20
- package/config.d/schema/modules.yaml +15 -0
- package/frugal-iot-server.js +61 -1
- package/lib/dynsec-plan.js +51 -1
- package/lib/dynsec-server.js +41 -1
- package/lib/dynsec-sync.js +35 -4
- package/package.json +3 -3
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +38 -8
- package/scripts/rebuild-dynsec.js +41 -4
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 (
|
|
981
|
+
#### 11b. Authorizing this Pi (on the production server)
|
|
982
982
|
|
|
983
|
-
Once per Pi.
|
|
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
|
-
|
|
990
|
-
|
|
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
|
|
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
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1020
|
-
writes `/etc/mosquitto/conf.d/frugal-iot-bridge.conf
|
|
1021
|
-
|
|
1022
|
-
which they recover from on
|
|
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
|
package/frugal-iot-server.js
CHANGED
|
@@ -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
|
package/lib/dynsec-plan.js
CHANGED
|
@@ -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) {
|
package/lib/dynsec-server.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { dynsecConnect } from './dynsec.js';
|
|
12
|
-
import { readUserRows, applyUser, applyLogger, applyNode, removeUser
|
|
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) {
|
package/lib/dynsec-sync.js
CHANGED
|
@@ -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.
|
|
16
|
-
"frugal-iot-logger": "^2.0.
|
|
15
|
+
"frugal-iot-client": "^2.0.4",
|
|
16
|
+
"frugal-iot-logger": "^2.0.4",
|
|
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.
|
|
89
|
+
"version": "2.0.4"
|
|
90
90
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -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.
|
|
9
|
+
const CACHE_NAME = 'frugal-iot-cache-2.0.4';
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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,
|
|
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
|
|
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.');
|