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.
@@ -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
- * In memory on purpose - see makeAttemptLog. Losing it on restart costs nothing, because a node
297
- * that is still retrying reappears within minutes.
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.url is just /index.html
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
- if ((['/','/index.html'].includes(req.url)) && !req.isAuthenticated()) {
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
@@ -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) {
@@ -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/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 = 20, perNode = 5, windowMs = 3600000, now = Date.now } = {}) {
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
- * In memory, and bounded. The nodes table deliberately has no last_seen column because that would
109
- * be a database write per reading on an SD card; a write per enrolment attempt is the same mistake
110
- * on a smaller scale. Losing the list on restart costs nothing: a node that is still retrying
111
- * reappears within minutes.
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
- record({ org, project, nodeid, from, reason }) {
117
- const key = `${org}/${nodeid}`;
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
- rows.set(key, {
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))) rows.delete(`${r.org}/${r.nodeid}`);
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) { rows.delete(`${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
- if (!NAME.test(org) || !NAME.test(project) || !NODEID.test(nodeid)) {
186
- return cb(new EnrolError(ENROL.BAD_REQUEST, 'org, project and nodeid are required and must be plain names'));
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 (limiter && !limiter.allow(org, nodeid)) {
190
- return refuse(ENROL.RATE_LIMITED, 'Too many enrolment attempts - try later');
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
  /*