frugal-iot-server 2.0.1 → 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 +180 -142
- package/README.md +3 -0
- package/config.d/schema/modules.yaml +15 -0
- package/frugal-iot-createdb.sql +34 -1
- package/frugal-iot-server.js +117 -5
- package/lib/dynsec-plan.js +75 -1
- package/lib/dynsec-server.js +41 -1
- package/lib/dynsec-sync.js +35 -4
- package/lib/enrol.js +132 -29
- package/package.json +7 -4
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +81 -22
- package/scripts/addorganization.zsh +25 -5
- package/scripts/diagnostic.zsh +149 -27
- package/scripts/init.zsh +106 -6
- package/scripts/install-pi.sh +269 -44
- package/scripts/rebuild-dynsec.js +41 -4
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';
|
|
@@ -293,13 +293,25 @@ function get_people_list(org, cb) {
|
|
|
293
293
|
* Nodes that asked to enrol and were refused. Module level because two things need it: POST /enrol
|
|
294
294
|
* writes to it and GET /nodes_list reads it, and they are in different scopes.
|
|
295
295
|
*
|
|
296
|
-
*
|
|
297
|
-
* that
|
|
296
|
+
* Kept in memory and written through to the node_attempts table - see makeAttemptLog. It used to be
|
|
297
|
+
* memory alone, which meant a restart hid a node that was asking for up to the half hour until its
|
|
298
|
+
* next attempt.
|
|
298
299
|
*/
|
|
299
300
|
const enrolAttempts = makeAttemptLog();
|
|
300
301
|
|
|
301
302
|
// Which nodes have enrolled in an organization. No password, obviously - the point of the list is
|
|
302
303
|
// to know what exists and to be able to forget one.
|
|
304
|
+
/*
|
|
305
|
+
* The Pis bridging to this server for one organization.
|
|
306
|
+
*
|
|
307
|
+
* The token is deliberately not selected: it is the credential a Pi presents to pull replicated
|
|
308
|
+
* logins, and this list is rendered in a browser. "site" is also the bridge's broker account name
|
|
309
|
+
* ("bridge-<site>"), which is what ties a row here to the live state the broker publishes on
|
|
310
|
+
* $SYS/broker/connection/<clientid>/state.
|
|
311
|
+
*/
|
|
312
|
+
const sqlBridgesList = `
|
|
313
|
+
SELECT site, created_at, last_pull FROM bridges WHERE org = ? ORDER BY site
|
|
314
|
+
;`;
|
|
303
315
|
const sqlNodesList = `
|
|
304
316
|
SELECT project, nodeid, lora, enrolled_at FROM nodes WHERE org = ? ORDER BY project, nodeid
|
|
305
317
|
;`;
|
|
@@ -628,6 +640,11 @@ function loggedInOrRedirect(req, res, next) {
|
|
|
628
640
|
} else {
|
|
629
641
|
// If originalUrl is /private/index.html then req.url is just /index.html
|
|
630
642
|
const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
|
|
643
|
+
// Never store this one. The paths behind it are fetched by script as well as navigated to - the
|
|
644
|
+
// dashboard asks for /data/.../<date>.csv - so a redirect kept in a cache would go on sending a
|
|
645
|
+
// browser that has since logged in to the login page, and that arrives as a 200 full of HTML
|
|
646
|
+
// where the caller expected CSV.
|
|
647
|
+
res.set('Cache-Control', 'no-store');
|
|
631
648
|
res.redirect(307, `${loginUrl}?${q}`);
|
|
632
649
|
}
|
|
633
650
|
}
|
|
@@ -715,10 +732,13 @@ export function loggedInOrFail(req, res, next) {
|
|
|
715
732
|
}
|
|
716
733
|
// While serving from frugal-iot-client it is only the dashboard we want to protect
|
|
717
734
|
// as need user to be logged in to access config etc
|
|
718
|
-
// Note if originalUrl is /dashboard/index.html then req.
|
|
735
|
+
// Note if originalUrl is /dashboard/index.html then req.path is just /index.html
|
|
719
736
|
function shouldIBeLoggedIn(req, res, next) {
|
|
720
737
|
//console.log("XXX shouldIBeLoggedIn", req.user, req.params.org);
|
|
721
|
-
|
|
738
|
+
// req.path, not req.url: the latter carries the query string, and the page is reached with one in
|
|
739
|
+
// ordinary use - login.html passes "lang" back - so /dashboard/?lang=fr would match neither name
|
|
740
|
+
// here and be served to whoever asked.
|
|
741
|
+
if ((['/','/index.html'].includes(req.path)) && !req.isAuthenticated()) {
|
|
722
742
|
console.log(`Not authenticated redirecting ${req.url} for login`);
|
|
723
743
|
// Capture the full original URL as-is in "url" (so e.g. /data?... comes back with all its params
|
|
724
744
|
// intact), encoded so its own query string can't corrupt this redirect's query string. Carry "lang"
|
|
@@ -726,6 +746,9 @@ function shouldIBeLoggedIn(req, res, next) {
|
|
|
726
746
|
// renders in the right language.
|
|
727
747
|
const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
|
|
728
748
|
if (req.query.lang) { q.set('lang', req.query.lang); }
|
|
749
|
+
// Whether a browser is logged in has to be asked again every time, so say so rather than
|
|
750
|
+
// relying on a 307 being uncacheable by default - as loggedInOrRedirect does for the same reason.
|
|
751
|
+
res.set('Cache-Control', 'no-store');
|
|
729
752
|
res.redirect(307, `${loginUrl}?${q}`);
|
|
730
753
|
} else {
|
|
731
754
|
next();
|
|
@@ -1023,6 +1046,15 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1023
1046
|
console.error("Error loading projects into config", err);
|
|
1024
1047
|
}
|
|
1025
1048
|
});
|
|
1049
|
+
// Reload the nodes that were asking to enrol when this server last stopped, so a restart
|
|
1050
|
+
// does not hide them until each retries - which for a node is half an hour away.
|
|
1051
|
+
enrolAttempts.useDatabase(db, (err, loaded) => {
|
|
1052
|
+
if (err) {
|
|
1053
|
+
console.error("Could not read past enrolment attempts", err.message);
|
|
1054
|
+
} else if (loaded) {
|
|
1055
|
+
console.log("Enrolment attempts pending:", loaded);
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1026
1058
|
// app.use(express.json()); // Not needed
|
|
1027
1059
|
app.use(express.urlencoded({ extended: true })); // Passport will not function without this
|
|
1028
1060
|
app.set('trust proxy', 1); // trust first proxy - see note in https://www.npmjs.com/package/express-session
|
|
@@ -1409,6 +1441,26 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1409
1441
|
can_ADMIN, // Gets org from URL
|
|
1410
1442
|
send_nodes_list,
|
|
1411
1443
|
);
|
|
1444
|
+
/*
|
|
1445
|
+
* Which Pis bridge to this server, and when each last checked in.
|
|
1446
|
+
*
|
|
1447
|
+
* last_pull is stamped by lib/replica.js every time a Pi collects this organization's
|
|
1448
|
+
* logins, so it answers "is that Pi alive" without needing the broker at all. Whether the
|
|
1449
|
+
* BRIDGE itself is up is a different question, answered by the retained message the broker
|
|
1450
|
+
* publishes on $SYS/broker/connection/<account>/state - which the dashboard subscribes to
|
|
1451
|
+
* directly, since it already holds an MQTT connection. The two differ in a way worth
|
|
1452
|
+
* seeing: a Pi that is up with a broken bridge still pulls logins.
|
|
1453
|
+
*/
|
|
1454
|
+
app.get('/bridges_list/:org',
|
|
1455
|
+
loggedInOrFail,
|
|
1456
|
+
can_ADMIN, // Gets org from URL
|
|
1457
|
+
(req, res) => {
|
|
1458
|
+
db.all(sqlBridgesList, [req.params.org], (err, rows) => {
|
|
1459
|
+
if (err) { return res.status(500).send(err.message); }
|
|
1460
|
+
res.json(rows.map((r) => ({ ...r, account: `bridge-${r.site}` })));
|
|
1461
|
+
});
|
|
1462
|
+
},
|
|
1463
|
+
);
|
|
1412
1464
|
/*
|
|
1413
1465
|
* Forget a node, so it can enrol again and be issued a new credential.
|
|
1414
1466
|
*
|
|
@@ -1564,6 +1616,66 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1564
1616
|
});
|
|
1565
1617
|
},
|
|
1566
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
|
+
);
|
|
1567
1679
|
app.post('/node_reset/:org',
|
|
1568
1680
|
loggedInOrFail,
|
|
1569
1681
|
can_ADMIN, // Gets org from URL - an organization admin may reset only their own nodes
|
package/lib/dynsec-plan.js
CHANGED
|
@@ -28,6 +28,15 @@ export const names = {
|
|
|
28
28
|
gatewayRole: (org) => `${org}-gateway`,
|
|
29
29
|
discoverRole:(org) => `${org}-discover`,
|
|
30
30
|
ownSubtreeRole: 'own-subtree', // one for the whole broker: publishClientSend %u/#
|
|
31
|
+
/*
|
|
32
|
+
* Reading whether a bridge is connected, for the dashboard's Bridges card.
|
|
33
|
+
*
|
|
34
|
+
* A GROUP and not just a role, although only one role goes in it, because applyUser removes a
|
|
35
|
+
* client from groups it no longer qualifies for but never removes a role - so losing ADMIN would
|
|
36
|
+
* leave the rights behind if this were granted as a role.
|
|
37
|
+
*/
|
|
38
|
+
bridgeStateRole: 'bridge-state',
|
|
39
|
+
bridgeStateGroup: 'bridge-state',
|
|
31
40
|
publicRole: 'public-read', // one for the whole broker: the id-0 "everyone" rows
|
|
32
41
|
publicGroup: 'public', // what setAnonymousGroup points at
|
|
33
42
|
nodeClient: (org, project, nodeid) => `${org}/${project}/${nodeid}`,
|
|
@@ -66,6 +75,18 @@ export const names = {
|
|
|
66
75
|
*/
|
|
67
76
|
orgAdminClient: (org) => `${org}-admin`,
|
|
68
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}`,
|
|
69
90
|
};
|
|
70
91
|
|
|
71
92
|
// Topic filters, in one place for the same reason.
|
|
@@ -83,6 +104,8 @@ const topics = {
|
|
|
83
104
|
// dashboard showing no devices while the readings themselves went through perfectly.
|
|
84
105
|
discovery: (org, project) => `${org}/${project}`,
|
|
85
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`,
|
|
86
109
|
};
|
|
87
110
|
|
|
88
111
|
/*
|
|
@@ -111,6 +134,12 @@ export function groupsForUser(rows) {
|
|
|
111
134
|
const project = r.project || ORGWIDE;
|
|
112
135
|
if (r.capability === 'READ') groups.add(names.readGroup(r.org, project));
|
|
113
136
|
if (r.capability === 'WRITE') groups.add(names.writeGroup(r.org, project));
|
|
137
|
+
// An organization's admin may see whether its Pis' bridges are up. The topic is not
|
|
138
|
+
// org-scoped - a clientid is "bridge-<site>" with no organization in it - so this does show an
|
|
139
|
+
// admin of one organization that another's bridges exist, by name. Site names are not secret
|
|
140
|
+
// and the alternative is naming bridge accounts per organization, which would break every
|
|
141
|
+
// bridge already configured.
|
|
142
|
+
if (r.capability === 'ADMIN') groups.add(names.bridgeStateGroup);
|
|
114
143
|
}
|
|
115
144
|
// Every user also gets whatever is public, as one role rather than per-organization membership.
|
|
116
145
|
roles.add(names.publicRole);
|
|
@@ -124,7 +153,7 @@ export function groupsForUser(rows) {
|
|
|
124
153
|
* scopes [{org, project}] - '' project meaning organization-wide
|
|
125
154
|
* publics [{org, project}] - the id-0 READ rows, as ACLs on the one shared public role
|
|
126
155
|
*/
|
|
127
|
-
export function desiredRolesAndGroups(scopes, publics, projects) {
|
|
156
|
+
export function desiredRolesAndGroups(scopes, publics, projects, bridges) {
|
|
128
157
|
const roles = {};
|
|
129
158
|
const groups = {};
|
|
130
159
|
|
|
@@ -133,6 +162,15 @@ export function desiredRolesAndGroups(scopes, publics, projects) {
|
|
|
133
162
|
{ acltype: 'publishClientSend', topic: topics.ownSubtree, allow: true },
|
|
134
163
|
];
|
|
135
164
|
|
|
165
|
+
// Whether each bridge is connected: mosquitto publishes a retained 1/0 per bridge account, and
|
|
166
|
+
// both ACLs are needed - subscribePattern to be allowed to ask, publishClientReceive to actually
|
|
167
|
+
// be sent it.
|
|
168
|
+
roles[names.bridgeStateRole] = [
|
|
169
|
+
{ acltype: 'subscribePattern', topic: topics.bridgeState, allow: true },
|
|
170
|
+
{ acltype: 'publishClientReceive', topic: topics.bridgeState, allow: true },
|
|
171
|
+
];
|
|
172
|
+
groups[names.bridgeStateGroup] = { roles: [names.bridgeStateRole] };
|
|
173
|
+
|
|
136
174
|
// Public read, as ACL pairs on a single role - so withdrawing it is one removeRoleACL, for
|
|
137
175
|
// everybody, rather than removing every user from a group.
|
|
138
176
|
roles[names.publicRole] = [];
|
|
@@ -199,6 +237,25 @@ export function desiredRolesAndGroups(scopes, publics, projects) {
|
|
|
199
237
|
roles: [names.readRole(org, ORGWIDE), names.ownSubtreeRole, names.discoverRole(org)] };
|
|
200
238
|
groups[names.gatewayGroup(org)] = { roles: [names.gatewayRole(org)] };
|
|
201
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
|
+
}
|
|
202
259
|
return { roles, groups };
|
|
203
260
|
}
|
|
204
261
|
|
|
@@ -224,6 +281,23 @@ export function deriveLoggerPassword(userSecret, org) {
|
|
|
224
281
|
* then a confused or compromised logger could invent sensor data - and the readings are the whole
|
|
225
282
|
* point of the system. The <org>-write role is set/-only, which is exactly that.
|
|
226
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
|
+
|
|
227
301
|
// The server's administrative account for one organization. Derived, like the logger's, with its
|
|
228
302
|
// own label so the two can never collide.
|
|
229
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/lib/enrol.js
CHANGED
|
@@ -72,14 +72,40 @@ function sameSecret(a, b) {
|
|
|
72
72
|
* leaked secret filling the database with invented nodes, and per node id stops repeated attempts
|
|
73
73
|
* against one real node. In memory is enough - it survives as long as the process, a restart is a
|
|
74
74
|
* fair reset, and persisting it would mean a disk write per attempt.
|
|
75
|
+
*
|
|
76
|
+
* The numbers were set for a node that retries every half hour - twice an hour, well under a limit
|
|
77
|
+
* of five. THAT IS NOT WHAT A DEEP-SLEEPING NODE DOES. nextEnrolTime lives in RAM, which deep sleep
|
|
78
|
+
* discards, so such a node asks once per WAKE: at the default ten-minute cycle that is six an hour,
|
|
79
|
+
* over the old per-node limit, and three such nodes exhausted the whole organization's twenty. A
|
|
80
|
+
* fleet flashed together and waiting to be approved is the ordinary case, not an attack, and the
|
|
81
|
+
* limit turned it into `rate_limited` rows that hid the real reason each node was being refused.
|
|
82
|
+
*
|
|
83
|
+
* So: twelve per node (a five-minute cycle) and a hundred and twenty per organization (twenty such
|
|
84
|
+
* nodes). What this costs is small, because the limiter is no longer the only thing bounding the
|
|
85
|
+
* damage - makeAttemptLog evicts to perOrg rows, throttles repeats to one write a minute, and
|
|
86
|
+
* writes nothing at all for a rate-limited request. And the per-node number never protected much:
|
|
87
|
+
* the only thing it guards by counting is the `current_password` proof, which is 24 random bytes
|
|
88
|
+
* and is not reachable by guessing at five an hour or at fifty.
|
|
75
89
|
*/
|
|
76
|
-
export function makeRateLimiter({ perOrg =
|
|
90
|
+
export function makeRateLimiter({ perOrg = 120, perNode = 12, windowMs = 3600000, now = Date.now,
|
|
91
|
+
sweepAbove = 1000 } = {}) {
|
|
77
92
|
const hits = new Map(); // key -> [timestamps]
|
|
78
93
|
const record = (key, limit) => {
|
|
79
94
|
const t = now();
|
|
80
95
|
const kept = (hits.get(key) || []).filter((x) => x > t - windowMs);
|
|
81
96
|
kept.push(t);
|
|
82
97
|
hits.set(key, kept);
|
|
98
|
+
/*
|
|
99
|
+
* Sweep expired keys, rather than only expired timestamps within a key.
|
|
100
|
+
*
|
|
101
|
+
* Nothing removed a key once made, which was a slow leak even when every key was a valid node
|
|
102
|
+
* id, and is a faster one now that the limiter runs BEFORE the shape check and so sees whatever
|
|
103
|
+
* an unauthenticated caller sent. Only above a threshold, so the ordinary case - a handful of
|
|
104
|
+
* nodes in a handful of organizations - never walks the map at all.
|
|
105
|
+
*/
|
|
106
|
+
if (hits.size > sweepAbove) {
|
|
107
|
+
for (const [k, v] of hits) if (!v.length || v[v.length - 1] <= t - windowMs) hits.delete(k);
|
|
108
|
+
}
|
|
83
109
|
return kept.length <= limit;
|
|
84
110
|
};
|
|
85
111
|
return {
|
|
@@ -105,28 +131,87 @@ const NAME = /^[a-z0-9]{1,32}$/;
|
|
|
105
131
|
* are therefore UNTRUSTED - anyone can make an id appear by attempting enrolment - so each keeps
|
|
106
132
|
* when it asked and from where, for the admin to recognise rather than to trust.
|
|
107
133
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
134
|
+
* Bounded, and PERSISTED once useDatabase() is called - see the node_attempts table for why that
|
|
135
|
+
* changed. Briefly: "losing the list on a restart costs nothing, a node still retrying reappears
|
|
136
|
+
* within minutes" was wrong on the interval. A node retries every HALF HOUR, so a restart made a
|
|
137
|
+
* node that was asking indistinguishable from a node that was not asking at all, for up to thirty
|
|
138
|
+
* minutes - and that is exactly the question an administrator restarts a server to investigate.
|
|
112
139
|
*/
|
|
113
|
-
export function makeAttemptLog({ perOrg = 50, now = Date.now } = {}) {
|
|
114
|
-
const rows = new Map(); // "org/nodeid" -> {org, project, nodeid, at, from, reason, count}
|
|
140
|
+
export function makeAttemptLog({ perOrg = 50, now = Date.now, minWriteMs = 60000 } = {}) {
|
|
141
|
+
const rows = new Map(); // "org/nodeid" -> {org, project, nodeid, at, from, reason, count, written}
|
|
142
|
+
let db = null; // set by useDatabase(), because the database is opened after this is made
|
|
143
|
+
const keyOf = (org, nodeid) => `${org}/${nodeid}`;
|
|
144
|
+
|
|
145
|
+
const write = (r) => {
|
|
146
|
+
if (!db) return;
|
|
147
|
+
r.written = r.at;
|
|
148
|
+
db.run(`INSERT INTO node_attempts (org, project, nodeid, reason, from_addr, at, count)
|
|
149
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
150
|
+
ON CONFLICT(org, nodeid)
|
|
151
|
+
DO UPDATE SET project = excluded.project, reason = excluded.reason,
|
|
152
|
+
from_addr = excluded.from_addr, at = excluded.at, count = excluded.count`,
|
|
153
|
+
[r.org, r.project || '', r.nodeid, r.reason || '', r.from || '', r.at, r.count],
|
|
154
|
+
// Best effort: this is a diagnostic aid, and failing to record one must not turn an
|
|
155
|
+
// enrolment refusal into a 500.
|
|
156
|
+
(err) => { if (err) console.error("Could not record enrolment attempt:", err.message); });
|
|
157
|
+
};
|
|
158
|
+
const drop = (org, nodeid) => {
|
|
159
|
+
rows.delete(keyOf(org, nodeid));
|
|
160
|
+
if (db) db.run('DELETE FROM node_attempts WHERE org = ? AND nodeid = ?', [org, nodeid], () => {});
|
|
161
|
+
};
|
|
162
|
+
|
|
115
163
|
return {
|
|
116
|
-
|
|
117
|
-
|
|
164
|
+
/*
|
|
165
|
+
* Adopt a database: load what is already there, and write through from then on.
|
|
166
|
+
*
|
|
167
|
+
* Separate from the constructor only because of when each happens - the log is made at module
|
|
168
|
+
* level so that POST /enrol and GET /nodes_list share one, and the database is opened later.
|
|
169
|
+
* Until this is called the log behaves exactly as it used to, in memory alone, so a caller
|
|
170
|
+
* that never calls it (a test) needs no database.
|
|
171
|
+
*/
|
|
172
|
+
useDatabase(database, cb) {
|
|
173
|
+
db = database;
|
|
174
|
+
db.all('SELECT org, project, nodeid, reason, from_addr, at, count FROM node_attempts', [],
|
|
175
|
+
(err, saved) => {
|
|
176
|
+
if (err) return cb(err);
|
|
177
|
+
for (const r of saved || []) {
|
|
178
|
+
rows.set(keyOf(r.org, r.nodeid), {
|
|
179
|
+
org: r.org, project: r.project, nodeid: r.nodeid, from: r.from_addr,
|
|
180
|
+
reason: r.reason, at: r.at, count: r.count, written: r.at,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
cb(null, (saved || []).length);
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
/*
|
|
187
|
+
* durable: false records in memory but does not write. Used for a rate-limited request, which
|
|
188
|
+
* is the one kind that arrives faster than the limiter caps - the limiter refuses it but this
|
|
189
|
+
* is still called, so without that flag an unauthenticated flood would be a database write per
|
|
190
|
+
* request. A node that is genuinely retrying has already been written by its first few
|
|
191
|
+
* attempts, so nothing an administrator needs is lost.
|
|
192
|
+
*/
|
|
193
|
+
record({ org, project, nodeid, from, reason, durable = true }) {
|
|
194
|
+
const key = keyOf(org, nodeid);
|
|
118
195
|
const existing = rows.get(key);
|
|
119
|
-
|
|
196
|
+
const row = {
|
|
120
197
|
org, project, nodeid, from, reason,
|
|
121
198
|
at: now(),
|
|
122
199
|
count: existing ? existing.count + 1 : 1,
|
|
123
|
-
|
|
200
|
+
written: existing ? (existing.written || 0) : 0,
|
|
201
|
+
};
|
|
202
|
+
rows.set(key, row);
|
|
203
|
+
// A new node, or one whose refusal reason has changed, is written at once - those are what an
|
|
204
|
+
// administrator is waiting to see. A node repeating the same refusal is throttled, because
|
|
205
|
+
// the only thing changing is a timestamp and a count.
|
|
206
|
+
if (durable && (!existing || existing.reason !== reason || row.at - row.written >= minWriteMs)) {
|
|
207
|
+
write(row);
|
|
208
|
+
}
|
|
124
209
|
// Oldest out first, per organization, so one organization cannot crowd out another
|
|
125
210
|
const mine = [...rows.values()].filter((r) => r.org === org).sort((a, b) => a.at - b.at);
|
|
126
|
-
for (const r of mine.slice(0, Math.max(0, mine.length - perOrg)))
|
|
211
|
+
for (const r of mine.slice(0, Math.max(0, mine.length - perOrg))) drop(r.org, r.nodeid);
|
|
127
212
|
},
|
|
128
213
|
forOrg(org) { return [...rows.values()].filter((r) => r.org === org); },
|
|
129
|
-
forget(org, nodeid) {
|
|
214
|
+
forget(org, nodeid) { drop(org, nodeid); },
|
|
130
215
|
};
|
|
131
216
|
}
|
|
132
217
|
|
|
@@ -164,30 +249,48 @@ export function readGrants(db, org, cb) {
|
|
|
164
249
|
*/
|
|
165
250
|
export function enrol(deps, body, cb) {
|
|
166
251
|
const { db, config, limiter, syncNode, attempts, from } = deps;
|
|
167
|
-
// Every refusal past the shape check is worth showing an admin: it is the only way they learn
|
|
168
|
-
// that a node exists and is asking. The shape check is not - a request that is not even
|
|
169
|
-
// org/project/nodeid tells them nothing they could act on.
|
|
170
|
-
const refuse = (code, message) => {
|
|
171
|
-
if (attempts) {
|
|
172
|
-
attempts.record({
|
|
173
|
-
org: String((body && body.org) || ''), project: String((body && body.project) || ''),
|
|
174
|
-
nodeid: String((body && body.nodeid) || ''), from, reason: code,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
return cb(new EnrolError(code, message));
|
|
178
|
-
};
|
|
179
252
|
const org = String((body && body.org) || '');
|
|
180
253
|
const project = String((body && body.project) || '');
|
|
181
254
|
const nodeid = String((body && body.nodeid) || '');
|
|
182
255
|
const secret = String((body && body.enrolment_secret) || '');
|
|
183
256
|
const lora = !!(body && body.lora);
|
|
184
257
|
|
|
185
|
-
|
|
186
|
-
|
|
258
|
+
/*
|
|
259
|
+
* EVERY refusal is worth showing an admin, including a malformed one. It is the only way they
|
|
260
|
+
* learn that a node exists and is asking at all.
|
|
261
|
+
*
|
|
262
|
+
* The shape check used to be excluded, on the grounds that a request which is not even
|
|
263
|
+
* org/project/nodeid tells an admin nothing they could act on. That was wrong twice over: a node
|
|
264
|
+
* whose project is mistyped (a capital letter, a hyphen) fails the shape check, and then the ONE
|
|
265
|
+
* symptom of the typo - "nothing appears anywhere" - is identical to the node being dead, off the
|
|
266
|
+
* network, or pointed at a different server. Seeing the row is what distinguishes them, even
|
|
267
|
+
* though approving it would not help. What an admin can act on is the FIRMWARE.
|
|
268
|
+
*
|
|
269
|
+
* Two conditions on recording, both because the strings below the shape check are arbitrary input
|
|
270
|
+
* from an unauthenticated request:
|
|
271
|
+
* - the organization must be a nameable one, or there is no admin who could ever see the row
|
|
272
|
+
* and nothing bounds how many organizations an attacker can invent;
|
|
273
|
+
* - project and nodeid are truncated, since they are shown in a table.
|
|
274
|
+
*/
|
|
275
|
+
const refuse = (code, message, durable = true) => {
|
|
276
|
+
if (attempts && NAME.test(org)) {
|
|
277
|
+
attempts.record({
|
|
278
|
+
org, project: project.slice(0, 64), nodeid: nodeid.slice(0, 64), from, reason: code, durable,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return cb(new EnrolError(code, message));
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Before the shape check, not after it as it used to be. Now that a malformed request is recorded,
|
|
285
|
+
// it has to be capped the same way a well-formed one is, or a flood of invented node ids would
|
|
286
|
+
// reach the log unlimited.
|
|
287
|
+
if (limiter && !limiter.allow(org, nodeid.slice(0, 64))) {
|
|
288
|
+
// durable: false - the one refusal that by definition arrives faster than the limit allows
|
|
289
|
+
return refuse(ENROL.RATE_LIMITED, 'Too many enrolment attempts - try later', false);
|
|
187
290
|
}
|
|
188
291
|
|
|
189
|
-
if (
|
|
190
|
-
return refuse(ENROL.
|
|
292
|
+
if (!NAME.test(org) || !NAME.test(project) || !NODEID.test(nodeid)) {
|
|
293
|
+
return refuse(ENROL.BAD_REQUEST, 'org, project and nodeid are required and must be plain names');
|
|
191
294
|
}
|
|
192
295
|
|
|
193
296
|
/*
|