frugal-iot-server 0.3.9 → 2.0.0

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.
@@ -21,8 +21,8 @@
21
21
  O /config.json Return configuration info - depends on user's org
22
22
  O /data Back files from logger for graphing
23
23
  A /dashboard serves dashboard via frugal-iot-client
24
- * /debug repurposed for development
25
- * /echo Send back headers etc
24
+ X /debug repurposed for development - commented out, see the note by the route
25
+ X /echo Send back headers etc - commented out, see the note by the route
26
26
  * /login (get) served under default handler - which might go away TODO-N89 make sure not hidden under dashboards Authentication
27
27
  * /login (post) login a user, & redirect (to dashboard typically)
28
28
  * /node_modules Javascript libraries (from frugal-iot-client)
@@ -107,6 +107,13 @@ import { MqttLogger } from "frugal-iot-logger"; // https://github.com/mitra42/f
107
107
 
108
108
  // API Integration - Farm IoT Interoperability Standard
109
109
  import { createAPIRouter, createAPIErrorHandler } from './lib/api-routes.js';
110
+ import { buildConfigFor, hasPermissions } from './lib/config-for-user.js';
111
+ import { ensureSecrets, addEnrolmentSecret, removeEnrolmentSecret } from './lib/secrets.js';
112
+ import { syncUser, syncUserById, syncLoggers, syncNode, dropNode } from './lib/dynsec-server.js';
113
+ import { enrol, ENROL, enrolmentSecretsFor, makeRateLimiter, forgetNode,
114
+ makeAttemptLog, setGrant, clearGrant, readGrants } from './lib/enrol.js';
115
+ import { deleteRetained } from './lib/retained.js';
116
+ import { replicaFor, bridgeForToken, noteBridgePull, startReplica } from './lib/replica.js';
110
117
  import { createLoggerClient } from './lib/logger-client.js';
111
118
  import { createPushManager } from './lib/farm-platform-push.js';
112
119
  import { APIError } from './lib/api-errors.js';
@@ -173,14 +180,19 @@ const responseHeaders = {
173
180
  };
174
181
  // ============ Helper functions ============
175
182
  // Note attribs is the otakey e.g. sht30_d1_mini
183
+ // The four parameters come from the URL of a route with no login on it (devices call it), so they
184
+ // are as untrusted as anything gets here. safeJoin drops any candidate that would leave otadir -
185
+ // before this, `..` in a parameter could read a file called firmware.bin from anywhere the process
186
+ // could reach. A dropped candidate is simply not looked for, which reads the same to the caller as
187
+ // a file that is not there.
176
188
  function findMostSpecificFile(topdir, org, project, node, attribs, cb) {
177
189
  let possfiles = [
178
- `${project}/${node}`, // Unlikely - if specify node, should be at the org level
179
- `+/${node}`,
180
- `${project}/${attribs}`,
181
- `+/${attribs}`
190
+ [project, node], // Unlikely - if specify node, should be at the org level
191
+ ['+', node],
192
+ [project, attribs],
193
+ ['+', attribs]
182
194
  //TODO-C14 might want to accept other variants on Arduino like sht30.ini.bin
183
- ].map(x => `${topdir}/${org}/${x}/firmware.bin`);
195
+ ].map(x => safeJoin(topdir, org, ...x, 'firmware.bin')).filter((x) => x);
184
196
  detectSeries(possfiles, (path, cb1) => {
185
197
  access(path, constants.R_OK, (err) => { cb1(null, !err); })},
186
198
  cb);
@@ -217,11 +229,20 @@ function startServer() {
217
229
  });
218
230
  }
219
231
  function isUnsafe(arr) {
220
- return arr.some( x => x && x.includes("/"))
232
+ return arr.some( x => x && (x.includes("/") || x === ".." ))
221
233
  }
222
- // Dont let client supplied filepath go up directorey tree
223
- function sanitize(filepath) {
224
- return filepath.replace(/[.][.]\//g, '/');
234
+ // Build a path under topdir out of untrusted components, or return null if the result would not be
235
+ // inside topdir. Resolve first and then ask where the answer landed: the reverse - looking for ".."
236
+ // in the components and removing it - is what the two functions this replaced did, and both were
237
+ // defeatable. A single-pass replace of "../" turns "....//" back into "../" rather than removing it,
238
+ // and Express has already decoded "%2e%2e" and "..%2f" by the time a route sees them, so there is
239
+ // nothing distinctive left to search for. Where the resolved path is cannot be argued with.
240
+ // Callers must treat null as "refuse the request" - it is not a path.
241
+ function safeJoin(topdir, ...parts) {
242
+ if (parts.some((x) => (x === undefined) || (x === null) || (x === ''))) return null;
243
+ const root = path.resolve(topdir);
244
+ const wanted = path.resolve(root, path.join(...parts.map(String)));
245
+ return ((wanted === root) || wanted.startsWith(root + path.sep)) ? wanted : null;
225
246
  }
226
247
 
227
248
  function adminUrl(req, message, lang) {
@@ -243,7 +264,7 @@ function clientErrorHandler(err, req, res, next) {
243
264
  }
244
265
  }
245
266
  const sqlPeoplePermList = `
246
- SELECT u.id, u.name, p.capability
267
+ SELECT u.id, u.name, p.capability, p.project
247
268
  FROM users u
248
269
  INNER JOIN permissions p ON u.id = p.id AND p.org = ?
249
270
  ;`;
@@ -252,7 +273,7 @@ const sqlPeopleList = `
252
273
  FROM users u
253
274
  ;`;
254
275
  const sqlAddPermission = `
255
- INSERT INTO permissions (id, capability, org) VALUES (?, ?, ?)
276
+ INSERT INTO permissions (id, capability, org, project) VALUES (?, ?, ?, ?)
256
277
  ;`;
257
278
  function get_people_list(org, cb) {
258
279
  db.all(sqlPeoplePermList, [org], (err, rows1) => {
@@ -268,6 +289,66 @@ function get_people_list(org, cb) {
268
289
  })
269
290
  }});
270
291
  }
292
+ /*
293
+ * Nodes that asked to enrol and were refused. Module level because two things need it: POST /enrol
294
+ * writes to it and GET /nodes_list reads it, and they are in different scopes.
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.
298
+ */
299
+ const enrolAttempts = makeAttemptLog();
300
+
301
+ // Which nodes have enrolled in an organization. No password, obviously - the point of the list is
302
+ // to know what exists and to be able to forget one.
303
+ const sqlNodesList = `
304
+ SELECT project, nodeid, lora, enrolled_at FROM nodes WHERE org = ? ORDER BY project, nodeid
305
+ ;`;
306
+ /*
307
+ * Every node the organization knows anything about, with one state each (SECURITY.md S12):
308
+ *
309
+ * enrolled it has a credential
310
+ * approved an admin has said its next request may be accepted
311
+ * denied an admin has stopped it
312
+ * failed it asked and was refused - which is how an admin learns it exists at all
313
+ *
314
+ * Three sources, because they answer different questions: the nodes table is what exists, node_grants
315
+ * is what an admin decided, and the in-memory attempt log is who is asking now. A node that has never
316
+ * connected appears only in the third, which is exactly the node needing attention.
317
+ */
318
+ function send_nodes_list(req, res) {
319
+ const org = req.params.org;
320
+ db.all(sqlNodesList, [org], (err, enrolled) => {
321
+ if (err) { return res.status(500).send(err.message); }
322
+ readGrants(db, org, (gerr, grants) => {
323
+ if (gerr) { return res.status(500).send(gerr.message); }
324
+ const byId = new Map();
325
+ for (const n of enrolled) {
326
+ byId.set(n.nodeid, { ...n, state: 'enrolled' });
327
+ }
328
+ for (const g of grants) {
329
+ const row = byId.get(g.nodeid) || { project: g.project, nodeid: g.nodeid, lora: 0, enrolled_at: null };
330
+ // A decision outranks "enrolled": a denied node still has a nodes row until it is forgotten
331
+ byId.set(g.nodeid, { ...row, state: g.state, decided_by: g.created_by, decided_at: g.created_at });
332
+ }
333
+ for (const a of enrolAttempts.forOrg(org)) {
334
+ if (byId.has(a.nodeid)) {
335
+ // Still asking despite being enrolled or decided - worth showing, not worth reclassifying
336
+ Object.assign(byId.get(a.nodeid),
337
+ { asked_at: a.at, asked_from: a.from, asked_reason: a.reason, asked_count: a.count });
338
+ continue;
339
+ }
340
+ byId.set(a.nodeid, {
341
+ project: a.project, nodeid: a.nodeid, lora: 0, enrolled_at: null, state: 'failed',
342
+ asked_at: a.at, asked_from: a.from, asked_reason: a.reason, asked_count: a.count,
343
+ });
344
+ }
345
+ const rows = [...byId.values()].sort((x, y) =>
346
+ (x.project || '').localeCompare(y.project || '') || x.nodeid.localeCompare(y.nodeid));
347
+ res.status(200).json(rows);
348
+ });
349
+ });
350
+ }
351
+
271
352
  function send_people_list(req, res) {
272
353
  // TODO-N89 list all People for an organization
273
354
  get_people_list(req.params.org, (err, people) => {
@@ -286,7 +367,10 @@ function notePermissionsChanged(id) {
286
367
  permissionsChangedAt.set(Number(id), Date.now());
287
368
  }
288
369
 
289
- function add_permission(id, capability, org, cb) {
370
+ // project is optional: '' means the whole organization, which is what a caller that knows nothing
371
+ // about projects sends - so the admin UI keeps working unchanged until it grows a project field.
372
+ function add_permission(id, capability, org, project, cb) {
373
+ project = project || '';
290
374
  if ((id === undefined)
291
375
  || (capability === undefined) || (capability.length < 2)
292
376
  || (org === undefined) || (org.length < 2)) {
@@ -295,22 +379,28 @@ function add_permission(id, capability, org, cb) {
295
379
  waterfall([
296
380
  (cb) => db.get('SELECT COUNT(id) FROM users WHERE id = ?', [id], cb),
297
381
  (n_users, cb) => { if (n_users["COUNT(id)"] != 1) { cb(new Error("User not found")); } else { cb(null); }},
298
- (cb) => db.get('SELECT COUNT(id) FROM permissions WHERE id = ? AND capability = ? AND org = ?', [id, capability, org], cb),
382
+ (cb) => db.get('SELECT COUNT(id) FROM permissions WHERE id = ? AND capability = ? AND org = ? AND project = ?', [id, capability, org, project], cb),
299
383
  (n_perms, cb) => { if (n_perms["COUNT(id)"] != 0) { cb(new Error("Duplicate permission")); } else { cb(null); }},
300
- (cb) => db.run(sqlAddPermission, [id, capability, org], cb),
384
+ (cb) => db.run(sqlAddPermission, [id, capability, org, project], cb),
301
385
  (cb) => { notePermissionsChanged(id); cb(null); },
386
+ // The database has changed, so the broker's idea of this user is now stale. Best effort.
387
+ (cb) => { syncUserById(db, config, id, () => cb(null)); },
302
388
  ], cb);
303
389
  }
304
390
  }
305
- function permissions_delete(id, capability, org, cb) {
391
+ function permissions_delete(id, capability, org, project, cb) {
392
+ project = project || '';
306
393
  if ((id === undefined)
307
394
  || (capability === undefined) || (capability.length < 2)
308
395
  || (org === undefined) || (org.length < 2)) {
309
396
  cb(new Error("Invalid parameters"));
310
397
  } else {
311
398
  waterfall([
312
- (cb) => db.get('DELETE FROM permissions WHERE id = ? AND capability = ? AND org = ?', [id,capability,org], cb),
399
+ (cb) => db.get('DELETE FROM permissions WHERE id = ? AND capability = ? AND org = ? AND project = ?', [id,capability,org,project], cb),
313
400
  (cb) => { notePermissionsChanged(id); cb(null); },
401
+ // Revoking has to reach the broker, or the credential already in a browser keeps working -
402
+ // which is the whole of SEC-1's second requirement.
403
+ (cb) => { syncUserById(db, config, id, () => cb(null)); },
314
404
  ], cb);
315
405
  }
316
406
  }
@@ -507,15 +597,24 @@ passport.use(new LocalStrategy(function verify(username, password, cb) {
507
597
  return cb(null, false, { message: 'Incorrect username or password*' });
508
598
  }
509
599
  db.all('SELECT * FROM permissions WHERE id = ? or id = 0', [ user.id ], function(err, permissions) {
510
- // permissions is [{ id, capability, org }]
511
- console.log("User",user.id, "with permissions", permissions.map(x => x.capability + " " +x.org).join(","));
600
+ // permissions is [{ id, capability, org, project }]
601
+ console.log("User",user.id, "with permissions", permissions.map(x => x.capability + " " +x.org + (x.project ? "/" + x.project : "")).join(","));
512
602
  if (err) {
513
603
  return cb(err);
514
604
  }
515
- return cb(null, {
516
- id: user.id, username: user.username, organization: user.organization,
517
- name: user.name, email: user.email, phone: user.phone, permissions
518
- }); // TO-ADD-REGISTRATION-FIELD
605
+ // Bring this user's broker account into line with what the database says, and get the
606
+ // credential their browser will use. Deliberately not fatal: if the broker cannot be
607
+ // reached the login still succeeds and the dashboard loses live data, which is far better
608
+ // than not being able to log in at all. The credential is derived, so it is returned either
609
+ // way and a later frugal-iot-rebuild-dynsec makes the broker agree.
610
+ syncUser(db, config, user, (serr, cred) => {
611
+ if (serr && !cred) console.log("No broker credential for", user.username, "-", serr.message);
612
+ return cb(null, {
613
+ id: user.id, username: user.username, organization: user.organization,
614
+ name: user.name, email: user.email, phone: user.phone, permissions,
615
+ mqtt_username: cred && cred.username, mqtt_password: cred && cred.password,
616
+ }); // TO-ADD-REGISTRATION-FIELD
617
+ });
519
618
  });
520
619
  });
521
620
  });
@@ -532,9 +631,9 @@ function loggedInOrRedirect(req, res, next) {
532
631
  res.redirect(307, `${loginUrl}?${q}`);
533
632
  }
534
633
  }
535
- function hasPermissions(user, org, permission) {
536
- return user.permissions.some(x => x.capability == permission && x.org == org);
537
- }
634
+ // hasPermissions now lives in lib/config-for-user.js and is re-exported here, because the config
635
+ // filtering needs it and that had to be testable without booting a server.
636
+ export { hasPermissions };
538
637
  // Like hasPermissions, but not org-scoped - true if the user has the capability on ANY org.
539
638
  function hasPermissionsAny(user, permission) {
540
639
  return user.permissions.some(x => x.capability == permission);
@@ -557,6 +656,20 @@ export function can_READ(req, res, next) {
557
656
  res.sendStatus(401);
558
657
  }
559
658
  }
659
+ // Exported for reuse by lib/api-routes.js - reads org from req.params.org or res.locals.org, so callers must set one of those first.
660
+ // Note WRITE is not implied by READ, or by ADMIN: an administrator who should also be able to change
661
+ // a device needs a WRITE row of their own. Anything that changes a device must use this and not
662
+ // can_READ - see the API's /devices/action, where using can_READ meant any reader could command any
663
+ // device.
664
+ export function can_WRITE(req, res, next) {
665
+ const org = req.params.org || res.locals.org;
666
+ if (req.isAuthenticated() && hasPermissions(req.user, org, "WRITE")) {
667
+ next();
668
+ } else {
669
+ console.log("Failing permission to Write", req.user, org);
670
+ res.sendStatus(401);
671
+ }
672
+ }
560
673
  // Not used as check direct in Multer storage, (since Multer fills the body) but use as template for other permissions (and then delete this comment)
561
674
  // Exported for reuse by lib/api-routes.js - reads org from req.params.org, so callers without an :org URL segment must set it first.
562
675
  export function can_ADMIN(req, res, next) {
@@ -642,25 +755,9 @@ function addLoggedNodesToConfig() {
642
755
  });
643
756
  });
644
757
  }
645
- // Produce an "unsafe" copy of config, i.e. it is a subset of config but points to objects rather than copying. Don't change the result!
758
+ // See lib/config-for-user.js - kept there so it can be tested without starting a server.
646
759
  function unsafeCopyConfigFor(user) {
647
- let oo = {
648
- organizations: {},
649
- user: user, // All data in user and permissions is visible to the user
650
- };
651
- Object.entries(config).forEach(([key, value]) => {
652
- if (key === 'organizations') {
653
- // noinspection JSCheckFunctionSignatures
654
- Object.entries(value).forEach(([orgid, org]) => {
655
- if (hasPermissions(user, orgid, 'READ')) {
656
- oo.organizations[orgid] = org;
657
- }
658
- });
659
- } else {
660
- oo[key] = value;
661
- }
662
- });
663
- return oo;
760
+ return buildConfigFor(config, user);
664
761
  }
665
762
  // ============ End Helper functions ============
666
763
 
@@ -692,13 +789,18 @@ app.options('/', (req, res) => {
692
789
 
693
790
  // Start the recognition of specific URL paths
694
791
 
695
- app.get('/echo', (req, res) => {
696
- res.status(200).json(req.headers);
697
- });
792
+ // Both of these are commented out rather than deleted: each was added to debug one specific thing
793
+ // and neither is needed in normal running. They sit here, above the session middleware, so nothing
794
+ // authenticates them - /debug in particular listed every node, project and topic the logger knows
795
+ // about to anyone who asked. Uncomment whichever is wanted while debugging, and comment it out
796
+ // again afterwards.
797
+ //app.get('/echo', (req, res) => {
798
+ // res.status(200).json(req.headers);
799
+ //});
698
800
  // This /debug can be freely rewritten to help debug stuff, nothing should rely on what it does remaining constant
699
- app.get('/debug', (req, res) => {
700
- res.status(200).json(mqttLogger.reportNodes());
701
- });
801
+ //app.get('/debug', (req, res) => {
802
+ // res.status(200).json(mqttLogger.reportNodes());
803
+ //});
702
804
  // Stick this as middleware to debug
703
805
  // noinspection JSUnusedLocalSymbols
704
806
  function debugRoutes(req, res, next) {
@@ -763,6 +865,73 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
763
865
  next();
764
866
  })
765
867
 
868
+ // Nodes enrol here to get their own broker credential. Deliberately not behind a session: a node
869
+ // has no session, and this is what replaces the shared password compiled into its firmware.
870
+ // Authenticated by the organization's enrolment secret, which grants only enrolment.
871
+ //
872
+ // Body is JSON, so express.json() has to have run - it is added below for /api, and this route
873
+ // brings its own parser rather than depending on the order of the two.
874
+ const enrolLimiter = makeRateLimiter();
875
+ app.post('/enrol', express.json({ limit: '4kb' }), (req, res) => {
876
+ enrol({ db, config, limiter: enrolLimiter,
877
+ syncNode: (node, cb) => syncNode(config, node, cb),
878
+ // So a refusal reaches the dashboard's Nodes card, which is the only way an admin
879
+ // learns that a node exists and is asking (SECURITY.md S12).
880
+ attempts: enrolAttempts, from: req.ip },
881
+ req.body, (err, cred) => {
882
+ if (!err) {
883
+ console.log("Enrolled", cred.username, req.body.lora ? "(LoRa-capable)" : "");
884
+ return res.status(200).json(cred);
885
+ }
886
+ const status = {
887
+ [ENROL.BAD_REQUEST]: 400,
888
+ [ENROL.REFUSED]: 403,
889
+ [ENROL.RATE_LIMITED]: 429,
890
+ [ENROL.NEEDS_RESET]: 409,
891
+ [ENROL.NO_PROJECT]: 404,
892
+ [ENROL.DENIED]: 403,
893
+ [ENROL.BROKER]: 503,
894
+ }[err.code] || 500;
895
+ // Every attempt is logged, successful or not: anyone holding an enrolment secret can add a
896
+ // fictitious node, which is a far smaller privilege than the password it replaces but
897
+ // should not be invisible.
898
+ console.log(`Enrolment refused (${err.code || 'error'}) for`,
899
+ `${req.body && req.body.org}/${req.body && req.body.project}/${req.body && req.body.nodeid}:`,
900
+ err.message);
901
+ res.status(status).json({ error: err.code || 'error', message: err.message });
902
+ });
903
+ });
904
+
905
+ /*
906
+ * What a bridged Pi may know about this server's people (SECURITY.md S11).
907
+ *
908
+ * Authenticated by a bearer token, one per Pi per organization, issued by
909
+ * frugal-iot-addbridge-prod - not by a session, because there is no person here: the Pi asks
910
+ * for itself, on a timer.
911
+ *
912
+ * Read-only, and scoped to the organization the token was issued for. What it returns is
913
+ * logins, the stored password hashes and the permission rows - nothing derived from a password,
914
+ * and no secret of this server's: the Pi authenticates logins itself and derives its own broker
915
+ * credentials from its own user_secret.
916
+ */
917
+ app.get('/replica/:org', (req, res) => {
918
+ const auth = req.get('Authorization') || '';
919
+ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
920
+ bridgeForToken(db, token, (err, bridge) => {
921
+ if (err) { return res.status(500).json({ error: err.message }); }
922
+ // Same answer for an unknown token and for a token belonging to another organization: a
923
+ // bridge should not be able to discover which organizations exist here.
924
+ if (!bridge || bridge.org !== req.params.org) {
925
+ return res.status(403).json({ error: 'Not a bridge for this organization' });
926
+ }
927
+ replicaFor(db, bridge.org, (rerr, payload) => {
928
+ if (rerr) { return res.status(500).json({ error: rerr.message }); }
929
+ noteBridgePull(db, bridge.org, bridge.site);
930
+ res.status(200).json(payload);
931
+ });
932
+ });
933
+ });
934
+
766
935
  console.log("Doing OTA updates at /ota_update from", config.server.otadir);
767
936
  app.get('/ota_update/:org/:project/:node/:attribs', (req, res) => {
768
937
  //Intentionally no login
@@ -815,9 +984,34 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
815
984
  const routerNM = express.Router();
816
985
  app.use('/node_modules', routerNM);
817
986
  //routerData.use('/', (req, res, next) => { console.log("NM:", req.url); next(); });
987
+ /*
988
+ * Revalidated, not cached for a day.
989
+ *
990
+ * These URLs are not versioned - "/node_modules/mqtt/dist/mqtt.esm.js" serves whatever this
991
+ * release installed - so "immutable" was a promise the server could not keep. It means "never
992
+ * even ask again", so a browser went on running the previous release's code for up to 24 hours
993
+ * after an upgrade, with no way to know it was doing so. That is also why deploying to the test
994
+ * Pi appeared to do nothing.
995
+ *
996
+ * Still a day - these users are often on an expensive link with poor reception, so a request
997
+ * saved is worth more than a minute's freshness. What changes is only `immutable`, and the
998
+ * difference between the two is exactly the one that matters here: a RELOAD revalidates a
999
+ * max-age resource and does not revalidate an immutable one. So ordinary navigation still costs
1000
+ * no requests, while a refresh picks up a new release instead of being told not to ask for a
1001
+ * day. ETag and Last-Modified, which express.static already sends, make that a 304.
1002
+ *
1003
+ * Freshness for an installed PWA is the service worker's job, not this header's: it is
1004
+ * cache-first, its CACHE_NAME follows the release, and it fetches with cache 'reload' on
1005
+ * install, which bypasses this cache entirely.
1006
+ *
1007
+ * The complete answer is a version in the URL, which would allow a year here AND appear the
1008
+ * instant a release changed it. That needs the client's module imports to carry the version, so
1009
+ * it is not a one-line change - noted, not done.
1010
+ */
1011
+ const revalidate = { maxAge: 1000 * 60 * 60 * 24 };
818
1012
  routerNM.use(
819
- express.static(clientNodeModules, {immutable: true, maxAge: 1000 * 60 * 60 * 24}),
820
- express.static(config.server.nodemodulesdir, {immutable: true, maxAge: 1000 * 60 * 60 * 24})
1013
+ express.static(clientNodeModules, revalidate),
1014
+ express.static(config.server.nodemodulesdir, revalidate)
821
1015
  );
822
1016
 
823
1017
  openOrCreateDatabase((err, db) => {
@@ -834,8 +1028,36 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
834
1028
  app.set('trust proxy', 1); // trust first proxy - see note in https://www.npmjs.com/package/express-session
835
1029
  // TODO-N89 note need to setup session store, defaults to memory store which is not good for production
836
1030
  // TODO-N89 think about cookie timeout and add "keep me logged in on this device" option that controls it
1031
+ // The session secret signs the cookie, so a known one would let anyone mint a validly-signed
1032
+ // one. That alone is not a way in - the cookie carries only a session id and the session
1033
+ // lives server-side, so a forged one names no session - but it was the express-session
1034
+ // README's own 'keyboard cat', which is not a state to leave a server in.
1035
+ //
1036
+ // frugal-iot-init writes these, but a server upgraded from a release before that has none,
1037
+ // so anything missing is generated AND written to config.d/secrets.yaml here. Generating
1038
+ // without saving would end every session on every restart, and the symptom does not point
1039
+ // at the cause, so it would be lived with rather than fixed.
1040
+ // One enrolment secret per organization, as a list so that rotating one does not strand
1041
+ // nodes already flashed with the old value. Generated here if absent, so adding an
1042
+ // organization needs no extra step and an existing installation fixes itself.
1043
+ const enrolNames = Object.keys(config.organizations || {})
1044
+ .filter((org) => !enrolmentSecretsFor(config, org).length)
1045
+ .map((org) => `enrolment_${org}`);
1046
+ const secretsResult = ensureSecrets(config.secrets, './config.d',
1047
+ ['session_secret', 'user_secret', ...enrolNames]);
1048
+ config.secrets = secretsResult.secrets; // so the rest of this run sees them
1049
+ if (secretsResult.generated.length) {
1050
+ if (secretsResult.written) {
1051
+ console.log("Generated", secretsResult.generated.join(", "),
1052
+ "and saved them to config.d/secrets.yaml");
1053
+ } else {
1054
+ console.error("Could not write config.d/secrets.yaml:", secretsResult.error);
1055
+ console.error(" Generated", secretsResult.generated.join(", "), "for this run only, so",
1056
+ "every restart will log everyone out until that file is writable.");
1057
+ }
1058
+ }
837
1059
  app.use(session({
838
- secret: 'keyboard cat', // TODO-N89 probably change, try changing this, hopefully should just require re-login
1060
+ secret: config.secrets.session_secret,
839
1061
  resave: false,
840
1062
  saveUninitialized: false,
841
1063
  cookie: { secure: 'auto' } // TODO-N89 cant be secure: true while testing on HTTP
@@ -853,6 +1075,12 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
853
1075
  email: user.email,
854
1076
  phone: user.phone,
855
1077
  permissions: user.permissions,
1078
+ // The broker credential this user's browser will use, derived at login by
1079
+ // lib/dynsec-server.js. Kept in the session because that is where the rest of the
1080
+ // login's results live, and sessions are in memory - so it dies with the process and
1081
+ // is derived again at the next login. /config.json serves it from here.
1082
+ mqtt_username: user.mqtt_username,
1083
+ mqtt_password: user.mqtt_password,
856
1084
  loginAt: Date.now(), // So a permission change can tell which sessions predate it
857
1085
  });
858
1086
  });
@@ -1057,8 +1285,15 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1057
1285
  return loginRedirect(res, notValid);
1058
1286
  }
1059
1287
  console.log("Password reset for", user.username);
1060
- loginRedirect(res, { mode: 'signin', messagetype: 'info', url,
1061
- message: 'Password reset - please sign in' });
1288
+ // The broker credential is derived from the stored hash, so changing the
1289
+ // password changes it - which is a useful property (the old one dies by
1290
+ // itself) but only if the broker is told. They have to sign in again anyway,
1291
+ // and that would sync it too; doing it here means the old credential stops
1292
+ // working now rather than whenever they next appear.
1293
+ syncUserById(db, config, user.id, () => {
1294
+ loginRedirect(res, { mode: 'signin', messagetype: 'info', url,
1295
+ message: 'Password reset - please sign in' });
1296
+ });
1062
1297
  });
1063
1298
  });
1064
1299
  });
@@ -1093,8 +1328,11 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1093
1328
  loggedInOrFail,
1094
1329
  can_OTAUPDATE,
1095
1330
  (req,res) => {
1096
- let remainingpath = req.params.remainingpath.join('/');
1097
- let dirpath = `${config.server.otadir}/${req.params.org}/${sanitize(remainingpath)}`;
1331
+ let dirpath = safeJoin(config.server.otadir, req.params.org, ...[].concat(req.params.remainingpath));
1332
+ if (!dirpath) {
1333
+ res.status(400).send("Bad path");
1334
+ return;
1335
+ }
1098
1336
  console.log("Deleting OTA file", dirpath);
1099
1337
  rm(dirpath, { recursive: true, force: true }, (err, unused) => {
1100
1338
  if (err) {
@@ -1119,8 +1357,11 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1119
1357
  loggedInOrFail,
1120
1358
  can_OTAUPDATE,
1121
1359
  (req,res) => {
1122
- let remainingpath = req.params.remainingpath.join('/');
1123
- let filepath = `${config.server.otadir}/${req.params.org}/${sanitize(remainingpath)}/firmware.bin`;
1360
+ let filepath = safeJoin(config.server.otadir, req.params.org, ...[].concat(req.params.remainingpath), 'firmware.bin');
1361
+ if (!filepath) {
1362
+ res.status(400).send("Bad path");
1363
+ return;
1364
+ }
1124
1365
  console.log("Sending OTA file", filepath);
1125
1366
  res.sendFile(filepath, {}, (err) => {
1126
1367
  if (err) {
@@ -1139,7 +1380,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1139
1380
  loggedInOrFail,
1140
1381
  can_ADMIN, // Gets org from URL
1141
1382
  (req,res, next) => {
1142
- add_permission(req.query.id, req.query.capability,req.params.org, (err) => {
1383
+ add_permission(req.query.id, req.query.capability, req.params.org, req.query.project, (err) => {
1143
1384
  if (err) {
1144
1385
  res.status(400).send(err.message);
1145
1386
  } else {
@@ -1153,7 +1394,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1153
1394
  loggedInOrFail,
1154
1395
  can_ADMIN, // Gets org from URL
1155
1396
  (req,res, next) => {
1156
- permissions_delete(req.query.id, req.query.capability,req.params.org, (err) => {
1397
+ permissions_delete(req.query.id, req.query.capability, req.params.org, req.query.project, (err) => {
1157
1398
  if (err) {
1158
1399
  res.status(400).send(err.message);
1159
1400
  } else {
@@ -1163,6 +1404,195 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1163
1404
  },
1164
1405
  send_people_list,
1165
1406
  );
1407
+ app.get('/nodes_list/:org',
1408
+ loggedInOrFail,
1409
+ can_ADMIN, // Gets org from URL
1410
+ send_nodes_list,
1411
+ );
1412
+ /*
1413
+ * Forget a node, so it can enrol again and be issued a new credential.
1414
+ *
1415
+ * The same job as frugal-iot-resetnode, for somebody who administers an organization but has
1416
+ * no shell on the server - which is most people who will need it. A node whose filesystem
1417
+ * has been erased cannot prove it is itself, and this is what says "yes, it really is".
1418
+ *
1419
+ * POST, not GET, unlike the older admin routes beside it (/add_permission,
1420
+ * /permissions_delete). Those are GETs that change things, which means a link or an image
1421
+ * tag can trigger them against anybody with a live admin session - see SEC-17. Rather than
1422
+ * add another, this one is a POST; the older ones want the same treatment together.
1423
+ */
1424
+ /*
1425
+ * Delete retained messages, on behalf of an organization's admin.
1426
+ *
1427
+ * The browser used to publish the empty payloads itself. It cannot any more - since S4 its
1428
+ * broker credential may publish to "set/" topics only - and it failed silently, because a
1429
+ * QoS 1 publish is acknowledged whether or not the broker will act on it. See
1430
+ * lib/retained.js for why widening the browser's rights is the wrong repair.
1431
+ */
1432
+ /*
1433
+ * The organization's enrolment secrets (SECURITY.md S10).
1434
+ *
1435
+ * An admin has to compile one into firmware, and until this existed only shell access could
1436
+ * read it. Its own route, never /config.json: that is served to every logged-in user, and
1437
+ * lib/config-for-user.js withholds the whole secrets section for exactly this reason.
1438
+ *
1439
+ * A list, not one value. Rotating means adding a new secret while the old is still
1440
+ * accepted, or every node already flashed with the old value and not yet enrolled is
1441
+ * stranded. So: add, reflash at leisure, then delete.
1442
+ */
1443
+ app.get('/enrolment_secret/:org',
1444
+ loggedInOrFail,
1445
+ can_ADMIN, // Gets org from URL - an admin sees only their own organization's
1446
+ (req, res) => {
1447
+ const org = req.params.org;
1448
+ // Worth a line: this is the one route that hands a secret to a browser.
1449
+ console.log("Enrolment secrets read by", req.user.username, "for", org);
1450
+ res.status(200).json({
1451
+ org,
1452
+ secrets: enrolmentSecretsFor(config, org),
1453
+ enrol_url: `${req.protocol}://${req.get('host')}/enrol`,
1454
+ });
1455
+ },
1456
+ );
1457
+ app.post('/enrolment_secret/:org',
1458
+ loggedInOrFail,
1459
+ can_ADMIN,
1460
+ (req, res) => {
1461
+ const org = req.params.org;
1462
+ addEnrolmentSecret('./config.d', org, enrolmentSecretsFor(config, org), (err, result) => {
1463
+ // The in-memory copy either way, so POST /enrol accepts the new secret at once; a
1464
+ // failed write means it works until the next restart, which is worth saying.
1465
+ config.secrets[`enrolment_${org}`] = result.list;
1466
+ console.log("Enrolment secret added by", req.user.username, "for", org,
1467
+ err ? `(NOT saved: ${err.message})` : '');
1468
+ res.status(200).json({
1469
+ org, secrets: result.list, saved: !err,
1470
+ message: err
1471
+ ? `Added, but config.d/secrets.yaml could not be written (${err.message}) - it will be gone after a restart.`
1472
+ : 'Added. Existing secrets still work, so nodes already flashed are unaffected.',
1473
+ });
1474
+ });
1475
+ },
1476
+ );
1477
+ app.delete('/enrolment_secret/:org',
1478
+ loggedInOrFail,
1479
+ can_ADMIN,
1480
+ express.json({ limit: '4kb' }),
1481
+ (req, res) => {
1482
+ const org = req.params.org;
1483
+ const secret = (req.body && req.body.secret) || '';
1484
+ if (!secret) { return res.status(400).json({ error: 'secret is required' }); }
1485
+ removeEnrolmentSecret('./config.d', org, secret, enrolmentSecretsFor(config, org),
1486
+ (err, result) => {
1487
+ if (!result.removed) {
1488
+ return res.status(404).json({ error: 'That is not one of this organization\'s secrets' });
1489
+ }
1490
+ config.secrets[`enrolment_${org}`] = result.list;
1491
+ console.log("Enrolment secret withdrawn by", req.user.username, "for", org,
1492
+ err ? `(NOT saved: ${err.message})` : '');
1493
+ res.status(200).json({
1494
+ org, secrets: result.list, saved: !err,
1495
+ message: result.list.length
1496
+ ? 'Withdrawn. A node flashed with it can no longer enrol.'
1497
+ : 'Withdrawn. No secrets left, so no new node can enrol until one is added or the server restarts.',
1498
+ });
1499
+ });
1500
+ },
1501
+ );
1502
+ app.post('/retained_delete/:org',
1503
+ loggedInOrFail,
1504
+ can_ADMIN, // Gets org from URL - and deleteRetained checks every topic is inside it
1505
+ express.json({ limit: '512kb' }), // a few thousand topic names
1506
+ (req, res) => {
1507
+ const topics = (req.body && req.body.topics) || [];
1508
+ deleteRetained(config, req.params.org, topics, (err, result) => {
1509
+ if (err) { return res.status(err.status || 500).json({ error: err.message }); }
1510
+ console.log("Retained messages deleted by", req.user.username, "-",
1511
+ req.params.org, result.deleted, "topic(s)");
1512
+ res.status(200).json(result);
1513
+ });
1514
+ },
1515
+ );
1516
+ /*
1517
+ * An admin's decision about one node (SECURITY.md S12): approved, denied, or cleared.
1518
+ *
1519
+ * `denied` is the kill switch the system otherwise lacks - for a node publishing bad
1520
+ * readings. It works by taking the credential away at the BROKER, not by anything on the
1521
+ * node: the node still holds its copy, is refused, discards it after five refusals, asks to
1522
+ * enrol, and is refused again by the stored decision. Its own nodes row is forgotten too,
1523
+ * so that clearing the denial later lets it enrol normally instead of being asked to prove
1524
+ * a password it no longer has.
1525
+ */
1526
+ app.post('/node_state/:org',
1527
+ loggedInOrFail,
1528
+ can_ADMIN, // Gets org from URL
1529
+ express.json({ limit: '4kb' }),
1530
+ (req, res) => {
1531
+ const org = req.params.org;
1532
+ const { project, nodeid, state } = req.body || {};
1533
+ if (!nodeid || !state) {
1534
+ return res.status(400).json({ error: 'nodeid and state are required' });
1535
+ }
1536
+ if (!['approved', 'denied', 'cleared'].includes(state)) {
1537
+ return res.status(400).json({ error: 'state must be approved, denied or cleared' });
1538
+ }
1539
+ const finish = (message) => {
1540
+ console.log("Node", state, "by", req.user.username, "-", org, project || '?', nodeid);
1541
+ res.status(200).json({ org, project, nodeid, state, message });
1542
+ };
1543
+ if (state === 'cleared') {
1544
+ return clearGrant(db, org, nodeid, (err) => {
1545
+ if (err) { return res.status(500).json({ error: err.message }); }
1546
+ finish('Cleared. It may enrol again with a valid enrolment secret.');
1547
+ });
1548
+ }
1549
+ setGrant(db, { org, project, nodeid, state, by: req.user.username }, (err) => {
1550
+ if (err) { return res.status(500).json({ error: err.message }); }
1551
+ if (state === 'approved') {
1552
+ return finish('Approved. Its next request is accepted, whatever secret it presents. ' +
1553
+ 'It may take a few minutes to ask again.');
1554
+ }
1555
+ // Denied: take the credential away, and forget the node so that clearing this later
1556
+ // does not leave it unable to prove a password it has already discarded.
1557
+ forgetNode(db, org, project || '', nodeid, () => {
1558
+ dropNode(config, org, project || '', nodeid, (derr) => {
1559
+ finish(derr
1560
+ ? `Denied. The broker account could not be removed (${derr.message}) - run frugal-iot-rebuild-dynsec.`
1561
+ : 'Denied. Its broker account is gone and it cannot enrol again until this is cleared.');
1562
+ });
1563
+ });
1564
+ });
1565
+ },
1566
+ );
1567
+ app.post('/node_reset/:org',
1568
+ loggedInOrFail,
1569
+ can_ADMIN, // Gets org from URL - an organization admin may reset only their own nodes
1570
+ express.json({ limit: '4kb' }),
1571
+ (req, res) => {
1572
+ const { project, nodeid } = req.body || {};
1573
+ if (!project || !nodeid) {
1574
+ return res.status(400).json({ error: 'project and nodeid are required' });
1575
+ }
1576
+ forgetNode(db, req.params.org, project, nodeid, (err, changes) => {
1577
+ if (err) { return res.status(500).json({ error: err.message }); }
1578
+ if (!changes) {
1579
+ return res.status(404).json({ error: `No enrolled node ${project}/${nodeid}` });
1580
+ }
1581
+ console.log("Node reset by", req.user.username, "-", req.params.org, project, nodeid);
1582
+ // Remove the broker account too. Best effort: enrolling recreates it, and
1583
+ // frugal-iot-diagnostic reports anything left behind as drift.
1584
+ dropNode(config, req.params.org, project, nodeid, (derr) => {
1585
+ res.status(200).json({
1586
+ reset: `${req.params.org}/${project}/${nodeid}`,
1587
+ brokerAccountRemoved: !derr,
1588
+ message: derr
1589
+ ? 'Forgotten. The broker account could not be removed, which is harmless - enrolling replaces it.'
1590
+ : 'Forgotten. The node will enrol again and be issued a new credential.',
1591
+ });
1592
+ });
1593
+ });
1594
+ },
1595
+ );
1166
1596
  app.get('/projects_list/:org',
1167
1597
  loggedInOrFail,
1168
1598
  can_ADMIN, // Gets org from URL
@@ -1200,7 +1630,9 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1200
1630
  shouldIBeLoggedIn, // redirect to ./login.html if not logged in then back here
1201
1631
  //(req,res,next) => {console.log("XXX back to /dashboard handler for", req.url); next(); }, // Log attempt
1202
1632
  //(req,res,next) => {console.log("XXX", config.server.htmldir); next(); }, // Log attempt
1203
- express.static(config.server.htmldir, {immutable: true, maxAge: 1000 * 60 * 60 * 24}) // Serve static
1633
+ // Cached for a day but not immutable - see the note on routerNM above. This is the mount
1634
+ // that serves the dashboard's own code, which changes every release at the same URL.
1635
+ express.static(config.server.htmldir, revalidate) // Serve static
1204
1636
  );
1205
1637
 
1206
1638
  // Serve frugal-iot-logger data at /data but configure where to get them.
@@ -1215,8 +1647,23 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1215
1647
  // Important that these aren't cached, or the data will not be updated.
1216
1648
  routerData.use(
1217
1649
  loggedInOrRedirect,
1650
+ // The organization for the permission check comes out of the path, and express.static
1651
+ // resolves "." and ".." afterwards - so before this, a request naming one organization
1652
+ // and reading another's file passed the check: /data/dev/../varta/x.csv was authorised as
1653
+ // "dev" and served varta's file. Percent-encoding (%2e%2e, ..%2f) did the same, and a
1654
+ // browser hides it by normalising the path itself - but curl --path-as-is, or any script
1655
+ // writing its own request, does not. Decoding and normalising here means the organization
1656
+ // checked is the one whose file express.static will actually reach.
1218
1657
  (req,res,next) => {
1219
- res.locals.org = req.url.split("/")[1];
1658
+ let decoded;
1659
+ try {
1660
+ decoded = decodeURIComponent(req.path);
1661
+ } catch (e) { // A malformed escape; nothing legitimate sends one
1662
+ res.sendStatus(400);
1663
+ return;
1664
+ }
1665
+ // path.posix, not path: a URL separator is "/" whoever is running the server.
1666
+ res.locals.org = path.posix.normalize(decoded).split("/")[1];
1220
1667
  next(); }, //
1221
1668
  can_READ,
1222
1669
  // Nothing logged here on purpose - morgan already reports the URL and status of this same
@@ -1278,10 +1725,12 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1278
1725
  let dir;
1279
1726
  if (!req.body.otakey) {
1280
1727
  return cb(new Error("must specify either OTA key or Device ID"));
1281
- } else if (req.body.project) {
1282
- dir = `${config.server.otadir}/${req.body.organization}/${req.body.project}/${req.body.otakey}`;
1283
1728
  } else {
1284
- dir = `${config.server.otadir}/${req.body.organization}/+/${req.body.otakey}`;
1729
+ dir = safeJoin(config.server.otadir, req.body.organization,
1730
+ req.body.project || '+', req.body.otakey);
1731
+ if (!dir) {
1732
+ return cb(new Error("Parameters may not name a directory outside the OTA directory"));
1733
+ }
1285
1734
  }
1286
1735
  mkdir(dir, {recursive: true}, (err, unusedpath) => {
1287
1736
  if (err) {
@@ -1337,18 +1786,49 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1337
1786
  });
1338
1787
 
1339
1788
  // Serve HTML files from a configurable location
1340
- // Use a 1-day cache to keep traffic down
1341
- // Its important that frugaliot.css is cached, or the UX will flash while checking it hasn't changed.
1789
+ // A day, but not immutable, so a reload can pick up a new release - see the note on
1790
+ // routerNM above. Keeping it cached is what stops frugaliot.css being rechecked on every
1791
+ // component render, which would flash unstyled on a slow link.
1342
1792
  // This has to come AFTER all the more specific paths like /data etc
1343
1793
  // Default catches rest (especially "/" so should be last)
1344
1794
  app.use(
1345
- express.static(config.server.publicdir, {immutable: true, maxAge: 1000 * 60 * 60 * 24})
1795
+ express.static(config.server.publicdir, revalidate)
1346
1796
  );
1347
1797
  app.use(clientErrorHandler);
1348
1798
  // Now start the server
1349
1799
  startServer();
1350
- // And logger
1351
- mqttLogger.start();
1800
+
1801
+ // And the logger - but give it its OWN broker account first, and create that account before
1802
+ // it tries to use it.
1803
+ //
1804
+ // It used to connect as the organization itself, with readwrite over the whole topic tree,
1805
+ // which meant a confused or compromised logger could invent sensor readings. Its own account
1806
+ // is in <org>-read and <org>-write, and <org>-write is set/-only - so it can still publish
1807
+ // the platform API's device commands (which is why it needs write at all) and can no longer
1808
+ // forge a reading.
1809
+ //
1810
+ // Not fatal if the broker cannot be reached: syncLoggers reports it and hands back the
1811
+ // derived credentials anyway, and the logger falls back to the organization's shared
1812
+ // password, which works until S8 retires it.
1813
+ syncLoggers(config, Object.keys(config.organizations || {}), (err, creds) => {
1814
+ Object.entries(creds || {}).forEach(([org, cred]) => {
1815
+ const o = config.organizations[org];
1816
+ if (!o) return;
1817
+ // Separate field names rather than overwriting mqtt_password, so that
1818
+ // config.organizations[x].mqtt_password means the same thing in memory as it does in
1819
+ // the file it came from. Withheld from browsers - see lib/config-for-user.js.
1820
+ o.logger_userid = cred.username;
1821
+ o.logger_password = cred.password;
1822
+ });
1823
+ mqttLogger.start();
1824
+ });
1825
+
1826
+ // On a bridged Pi, keep production's logins and permissions in step (SECURITY.md S11).
1827
+ // Does nothing unless config.d/replica.yaml says where to pull from, so a production
1828
+ // server and a standalone Pi are unaffected.
1829
+ startReplica(config, db, {
1830
+ onUser: (id) => syncUserById(db, config, id),
1831
+ });
1352
1832
  }
1353
1833
  });
1354
1834
  }