frugal-iot-server 0.3.8 → 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';
@@ -127,12 +134,36 @@ import sqlite3 from 'sqlite3'; // https://www.npmjs.com/package/sqlite3
127
134
  import crypto from 'crypto'; /* https://nodejs.org/api/crypto.html */
128
135
  // import cookieParser from 'cookie-parser'; // https://www.npmjs.com/package/cookie-parser (note comment on https://www.npmjs.com/package/express-session that not needed and conflicts with session)
129
136
  import {waterfall, each} from 'async';
137
+ import { mailInit, mailConfigured, sendMail } from './lib/mailer.js';
138
+ import { resetCodeMake, resetCodeCheck, resetRateOk } from './lib/resetcode.js';
130
139
  // import { openDB } from 'sqlite-express-package'; /* appContent, appSelect, validateId, validateAlias, tagCloud, atom, rss,*/
131
140
 
132
141
  export let config; // Live binding - lib/api-routes.js reads the current value at request time
133
142
  let mqttLogger = new MqttLogger();
134
143
  const loginUrl = '/dashboard/login.html';
135
144
 
145
+ // Back to the login page with something to say. A form POST can only be answered with a redirect
146
+ // and there is no session to hang a message on, so it all goes in the query string, where
147
+ // mqtt-login reads it as attributes. "mode" is which of the four forms to show;
148
+ // "messagetype" is error or info, which is the difference between a red box and a green one.
149
+ function loginRedirect(res, { mode = 'signin', message, messagetype, url }) {
150
+ const q = new URLSearchParams({ mode });
151
+ if (message) { q.set('message', message); }
152
+ if (messagetype) { q.set('messagetype', messagetype); }
153
+ if (url) { q.set('url', url); } // Encoded here: a return url usually has a query string of its own
154
+ res.redirect(`${loginUrl}?${q}`);
155
+ }
156
+ // A password reset link carries its token in the query string, and everything that logs a URL goes
157
+ // to the journal, which outlives the ten minutes the token is good for.
158
+ function redactUrl(url) {
159
+ return String(url).replace(/([?&]code=)[^&]*/, '$1REDACTED');
160
+ }
161
+ // Where this server can be reached from outside, for the link in a reset email. Behind a proxy that
162
+ // does not set X-Forwarded-Host there is nothing in the request to go on, hence the config override.
163
+ function publicBase(req) {
164
+ return (config.email && config.email.baseurl) || `${req.protocol}://${req.get('host')}`;
165
+ }
166
+
136
167
  const optionsHeaders = {
137
168
  'Access-Control-Allow-Origin': '*',
138
169
  // Probably Needs: GET, OPTIONS HEAD, do not believe can do POST, PUT, DELETE yet but could be wrong about that.
@@ -149,14 +180,19 @@ const responseHeaders = {
149
180
  };
150
181
  // ============ Helper functions ============
151
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.
152
188
  function findMostSpecificFile(topdir, org, project, node, attribs, cb) {
153
189
  let possfiles = [
154
- `${project}/${node}`, // Unlikely - if specify node, should be at the org level
155
- `+/${node}`,
156
- `${project}/${attribs}`,
157
- `+/${attribs}`
190
+ [project, node], // Unlikely - if specify node, should be at the org level
191
+ ['+', node],
192
+ [project, attribs],
193
+ ['+', attribs]
158
194
  //TODO-C14 might want to accept other variants on Arduino like sht30.ini.bin
159
- ].map(x => `${topdir}/${org}/${x}/firmware.bin`);
195
+ ].map(x => safeJoin(topdir, org, ...x, 'firmware.bin')).filter((x) => x);
160
196
  detectSeries(possfiles, (path, cb1) => {
161
197
  access(path, constants.R_OK, (err) => { cb1(null, !err); })},
162
198
  cb);
@@ -193,11 +229,20 @@ function startServer() {
193
229
  });
194
230
  }
195
231
  function isUnsafe(arr) {
196
- return arr.some( x => x && x.includes("/"))
232
+ return arr.some( x => x && (x.includes("/") || x === ".." ))
197
233
  }
198
- // Dont let client supplied filepath go up directorey tree
199
- function sanitize(filepath) {
200
- 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;
201
246
  }
202
247
 
203
248
  function adminUrl(req, message, lang) {
@@ -219,7 +264,7 @@ function clientErrorHandler(err, req, res, next) {
219
264
  }
220
265
  }
221
266
  const sqlPeoplePermList = `
222
- SELECT u.id, u.name, p.capability
267
+ SELECT u.id, u.name, p.capability, p.project
223
268
  FROM users u
224
269
  INNER JOIN permissions p ON u.id = p.id AND p.org = ?
225
270
  ;`;
@@ -228,7 +273,7 @@ const sqlPeopleList = `
228
273
  FROM users u
229
274
  ;`;
230
275
  const sqlAddPermission = `
231
- INSERT INTO permissions (id, capability, org) VALUES (?, ?, ?)
276
+ INSERT INTO permissions (id, capability, org, project) VALUES (?, ?, ?, ?)
232
277
  ;`;
233
278
  function get_people_list(org, cb) {
234
279
  db.all(sqlPeoplePermList, [org], (err, rows1) => {
@@ -244,6 +289,66 @@ function get_people_list(org, cb) {
244
289
  })
245
290
  }});
246
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
+
247
352
  function send_people_list(req, res) {
248
353
  // TODO-N89 list all People for an organization
249
354
  get_people_list(req.params.org, (err, people) => {
@@ -262,7 +367,10 @@ function notePermissionsChanged(id) {
262
367
  permissionsChangedAt.set(Number(id), Date.now());
263
368
  }
264
369
 
265
- 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 || '';
266
374
  if ((id === undefined)
267
375
  || (capability === undefined) || (capability.length < 2)
268
376
  || (org === undefined) || (org.length < 2)) {
@@ -271,22 +379,28 @@ function add_permission(id, capability, org, cb) {
271
379
  waterfall([
272
380
  (cb) => db.get('SELECT COUNT(id) FROM users WHERE id = ?', [id], cb),
273
381
  (n_users, cb) => { if (n_users["COUNT(id)"] != 1) { cb(new Error("User not found")); } else { cb(null); }},
274
- (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),
275
383
  (n_perms, cb) => { if (n_perms["COUNT(id)"] != 0) { cb(new Error("Duplicate permission")); } else { cb(null); }},
276
- (cb) => db.run(sqlAddPermission, [id, capability, org], cb),
384
+ (cb) => db.run(sqlAddPermission, [id, capability, org, project], cb),
277
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)); },
278
388
  ], cb);
279
389
  }
280
390
  }
281
- function permissions_delete(id, capability, org, cb) {
391
+ function permissions_delete(id, capability, org, project, cb) {
392
+ project = project || '';
282
393
  if ((id === undefined)
283
394
  || (capability === undefined) || (capability.length < 2)
284
395
  || (org === undefined) || (org.length < 2)) {
285
396
  cb(new Error("Invalid parameters"));
286
397
  } else {
287
398
  waterfall([
288
- (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),
289
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)); },
290
404
  ], cb);
291
405
  }
292
406
  }
@@ -459,10 +573,20 @@ function execSqlStart(cb) {
459
573
  });
460
574
  }
461
575
 
576
+ // Look somebody up by whatever they typed into the one "Username or email" box on the login page.
577
+ // Username first, so that a username with an "@" in it still works, then email - which is not
578
+ // declared UNIQUE in the schema, so take the first match rather than assuming there is only one.
579
+ function findUserByLogin(login, cb) {
580
+ db.get('SELECT * FROM users WHERE username = ?', [ login ], function(err, user) {
581
+ if (err || user || !String(login || '').includes('@')) { return cb(err, user); }
582
+ db.get('SELECT * FROM users WHERE email = ? COLLATE NOCASE', [ login ], cb);
583
+ });
584
+ }
585
+
462
586
  // This verifies the user, and if successful returns a data structure via cb
463
587
  // see passport.authenticate('local'... for where it gets used
464
588
  passport.use(new LocalStrategy(function verify(username, password, cb) {
465
- db.get('SELECT * FROM users WHERE username = ?', [ username ], function(err, user) {
589
+ findUserByLogin(username, function(err, user) {
466
590
  if (err) { return cb(err); }
467
591
  if (!user) { return cb(null, false, { message: 'Incorrect username or password+' }); }
468
592
  // TODO- - maybe just import pbkdf2 and timingSafeEqual ?
@@ -473,15 +597,24 @@ passport.use(new LocalStrategy(function verify(username, password, cb) {
473
597
  return cb(null, false, { message: 'Incorrect username or password*' });
474
598
  }
475
599
  db.all('SELECT * FROM permissions WHERE id = ? or id = 0', [ user.id ], function(err, permissions) {
476
- // permissions is [{ id, capability, org }]
477
- 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(","));
478
602
  if (err) {
479
603
  return cb(err);
480
604
  }
481
- return cb(null, {
482
- id: user.id, username: user.username, organization: user.organization,
483
- name: user.name, email: user.email, phone: user.phone, permissions
484
- }); // 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
+ });
485
618
  });
486
619
  });
487
620
  });
@@ -494,12 +627,13 @@ function loggedInOrRedirect(req, res, next) {
494
627
  next();
495
628
  } else {
496
629
  // If originalUrl is /private/index.html then req.url is just /index.html
497
- res.redirect(307, `${loginUrl}?register=false&message=Please%20login&url=` + req.originalUrl);
630
+ const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
631
+ res.redirect(307, `${loginUrl}?${q}`);
498
632
  }
499
633
  }
500
- function hasPermissions(user, org, permission) {
501
- return user.permissions.some(x => x.capability == permission && x.org == org);
502
- }
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 };
503
637
  // Like hasPermissions, but not org-scoped - true if the user has the capability on ANY org.
504
638
  function hasPermissionsAny(user, permission) {
505
639
  return user.permissions.some(x => x.capability == permission);
@@ -522,6 +656,20 @@ export function can_READ(req, res, next) {
522
656
  res.sendStatus(401);
523
657
  }
524
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
+ }
525
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)
526
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.
527
675
  export function can_ADMIN(req, res, next) {
@@ -576,8 +724,9 @@ function shouldIBeLoggedIn(req, res, next) {
576
724
  // intact), encoded so its own query string can't corrupt this redirect's query string. Carry "lang"
577
725
  // over as its own top-level param (from req.query, not duplicated into "url") so login.html itself
578
726
  // renders in the right language.
579
- const langParam = req.query.lang ? `&lang=${encodeURIComponent(req.query.lang)}` : '';
580
- res.redirect(307, `${loginUrl}?register=false&message=Please%20login${langParam}&url=${encodeURIComponent(req.originalUrl)}`);
727
+ const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
728
+ if (req.query.lang) { q.set('lang', req.query.lang); }
729
+ res.redirect(307, `${loginUrl}?${q}`);
581
730
  } else {
582
731
  next();
583
732
  }
@@ -606,25 +755,9 @@ function addLoggedNodesToConfig() {
606
755
  });
607
756
  });
608
757
  }
609
- // 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.
610
759
  function unsafeCopyConfigFor(user) {
611
- let oo = {
612
- organizations: {},
613
- user: user, // All data in user and permissions is visible to the user
614
- };
615
- Object.entries(config).forEach(([key, value]) => {
616
- if (key === 'organizations') {
617
- // noinspection JSCheckFunctionSignatures
618
- Object.entries(value).forEach(([orgid, org]) => {
619
- if (hasPermissions(user, orgid, 'READ')) {
620
- oo.organizations[orgid] = org;
621
- }
622
- });
623
- } else {
624
- oo[key] = value;
625
- }
626
- });
627
- return oo;
760
+ return buildConfigFor(config, user);
628
761
  }
629
762
  // ============ End Helper functions ============
630
763
 
@@ -656,13 +789,18 @@ app.options('/', (req, res) => {
656
789
 
657
790
  // Start the recognition of specific URL paths
658
791
 
659
- app.get('/echo', (req, res) => {
660
- res.status(200).json(req.headers);
661
- });
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
+ //});
662
800
  // This /debug can be freely rewritten to help debug stuff, nothing should rely on what it does remaining constant
663
- app.get('/debug', (req, res) => {
664
- res.status(200).json(mqttLogger.reportNodes());
665
- });
801
+ //app.get('/debug', (req, res) => {
802
+ // res.status(200).json(mqttLogger.reportNodes());
803
+ //});
666
804
  // Stick this as middleware to debug
667
805
  // noinspection JSUnusedLocalSymbols
668
806
  function debugRoutes(req, res, next) {
@@ -689,6 +827,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
689
827
  // Summarize rather than dumping the whole config: it is mostly the sensor schema, and it holds
690
828
  // each organization's mqtt_password, which should not be going to the console and the journal.
691
829
  console.log("Broker", config.mqtt.broker, "- organizations:", Object.keys(config.organizations).join(", ") || "(none)");
830
+ console.log(mailInit(config.email));
692
831
  // Could genericize config defaults
693
832
  // HTTP request logging. "morgan: false" in config.d/server.yaml turns it off completely, which
694
833
  // matters on a machine running from an SD card: every request logged is a line to the journal,
@@ -702,6 +841,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
702
841
  morganFormat = ':method :url :req[range] :status :res[content-length] :response-time ms :req[referer]'
703
842
  }
704
843
  // Seems to be writing to syslog which is being cycled.
844
+ morgan.token('url', (req) => redactUrl(req.originalUrl || req.url)); // see redactUrl
705
845
  app.use(morgan(morganFormat)); // see https://www.npmjs.com/package/morgan )
706
846
  }
707
847
 
@@ -721,10 +861,77 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
721
861
  // Just log the request for now
722
862
  // noinspection JSCheckFunctionSignatures
723
863
  app.use('/', (req, res, next) => {
724
- console.log(req.url);
864
+ console.log(redactUrl(req.url));
725
865
  next();
726
866
  })
727
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
+
728
935
  console.log("Doing OTA updates at /ota_update from", config.server.otadir);
729
936
  app.get('/ota_update/:org/:project/:node/:attribs', (req, res) => {
730
937
  //Intentionally no login
@@ -777,9 +984,34 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
777
984
  const routerNM = express.Router();
778
985
  app.use('/node_modules', routerNM);
779
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 };
780
1012
  routerNM.use(
781
- express.static(clientNodeModules, {immutable: true, maxAge: 1000 * 60 * 60 * 24}),
782
- express.static(config.server.nodemodulesdir, {immutable: true, maxAge: 1000 * 60 * 60 * 24})
1013
+ express.static(clientNodeModules, revalidate),
1014
+ express.static(config.server.nodemodulesdir, revalidate)
783
1015
  );
784
1016
 
785
1017
  openOrCreateDatabase((err, db) => {
@@ -796,8 +1028,36 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
796
1028
  app.set('trust proxy', 1); // trust first proxy - see note in https://www.npmjs.com/package/express-session
797
1029
  // TODO-N89 note need to setup session store, defaults to memory store which is not good for production
798
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
+ }
799
1059
  app.use(session({
800
- secret: 'keyboard cat', // TODO-N89 probably change, try changing this, hopefully should just require re-login
1060
+ secret: config.secrets.session_secret,
801
1061
  resave: false,
802
1062
  saveUninitialized: false,
803
1063
  cookie: { secure: 'auto' } // TODO-N89 cant be secure: true while testing on HTTP
@@ -815,6 +1075,12 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
815
1075
  email: user.email,
816
1076
  phone: user.phone,
817
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,
818
1084
  loginAt: Date.now(), // So a permission change can tell which sessions predate it
819
1085
  });
820
1086
  });
@@ -902,10 +1168,13 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
902
1168
  // successRedirect, passport calls next() on success and the request 404s as
903
1169
  // "Cannot POST /login" - a confusing way to report a missing form field.
904
1170
  const returnTo = req.body.url || '/dashboard';
1171
+ const failureQuery = new URLSearchParams({
1172
+ mode: 'signin', messagetype: 'error',
1173
+ message: 'Incorrect username or password', url: returnTo });
905
1174
  passport.authenticate('local', {
906
1175
  session: true,
907
1176
  //failWithError: true,
908
- failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${returnTo}`,
1177
+ failureRedirect: `${loginUrl}?${failureQuery}`,
909
1178
  successRedirect: returnTo,
910
1179
  // In failure case will also be messages in the session which need clearing out TODO-N89
911
1180
  //failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${req.body.url}`,
@@ -931,9 +1200,11 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
931
1200
  [username, hashedPassword, salt, organization, req.body.name, req.body.email, req.body.phone], (err) => {
932
1201
  if (err) {
933
1202
  console.log(err);
934
- res.redirect(`${loginUrl}?register=true&message=Registration%20failed&url=${req.body.url}`);
1203
+ loginRedirect(res, { mode: 'register', messagetype: 'error',
1204
+ message: 'Registration failed', url: req.body.url });
935
1205
  } else {
936
- res.redirect(`${loginUrl}?register=false&message=Registration%20successful%20-%20please%20login&url=${req.body.url}`);
1206
+ loginRedirect(res, { mode: 'signin', messagetype: 'info',
1207
+ message: 'Registration successful - please login', url: req.body.url });
937
1208
  }
938
1209
  });
939
1210
  }
@@ -942,6 +1213,93 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
942
1213
  });
943
1214
  });
944
1215
 
1216
+ // ---------- Forgotten password ----------
1217
+ // Nothing is stored: the code is an HMAC over the account and a five-minute slot, so it
1218
+ // expires by arithmetic and stops working the moment the password changes. See
1219
+ // lib/resetcode.js. The mail carries both a link (a long token) and six digits to type.
1220
+ app.post('/forgotpassword', (req, res) => {
1221
+ const login = String(req.body.username || '').trim();
1222
+ const url = req.body.url;
1223
+ // The same answer whether or not the account exists, so this form cannot be used to find
1224
+ // out who has an account here.
1225
+ const sameAnswer = { mode: 'reset', messagetype: 'info', url,
1226
+ message: 'If that account exists we have emailed a reset code' };
1227
+ if (!mailConfigured()) {
1228
+ return loginRedirect(res, { mode: 'signin', messagetype: 'error', url,
1229
+ message: 'Password reset is not available on this server' });
1230
+ }
1231
+ if (!resetRateOk('send', login.toLowerCase())) {
1232
+ return loginRedirect(res, { mode: 'forgot', messagetype: 'error', url,
1233
+ message: 'Too many attempts - please wait a few minutes' });
1234
+ }
1235
+ findUserByLogin(login, (err, user) => {
1236
+ if (err) { console.error("Looking up", login, "for a password reset:", err.message); }
1237
+ if (!user || !user.email) { return loginRedirect(res, sameAnswer); }
1238
+ const { code, token } = resetCodeMake(user);
1239
+ const q = new URLSearchParams({ mode: 'reset', username: user.username, code: token });
1240
+ if (url) { q.set('url', url); }
1241
+ const link = `${publicBase(req)}${loginUrl}?${q}`;
1242
+ const text = [
1243
+ `Somebody asked to reset the Frugal IoT password for ${user.username}.`,
1244
+ ``,
1245
+ `Open this link:`,
1246
+ link,
1247
+ ``,
1248
+ `or type this code into the reset form: ${code}`,
1249
+ ``,
1250
+ `Either works for the next ten minutes at most. If it was not you, ignore this mail -`,
1251
+ `nothing has changed and nothing will until the code is used.`,
1252
+ ].join('\n');
1253
+ sendMail({ to: user.email, subject: 'Frugal IoT password reset', text }, (err) => {
1254
+ // Still the same answer: telling them the mail failed would also tell an outsider
1255
+ // that the account exists. It is logged loudly instead, because a server whose SMTP
1256
+ // is broken looks from the outside exactly like one that is working.
1257
+ if (err) { console.error("Could not send reset mail for", user.username, "-", err.message); }
1258
+ loginRedirect(res, sameAnswer);
1259
+ });
1260
+ });
1261
+ });
1262
+ app.post('/resetpassword', (req, res) => {
1263
+ const login = String(req.body.username || '').trim();
1264
+ const url = req.body.url;
1265
+ const notValid = { mode: 'reset', messagetype: 'error', url,
1266
+ message: 'That code is not valid or has expired' };
1267
+ if (!resetRateOk('check', login.toLowerCase())) {
1268
+ return loginRedirect(res, { mode: 'reset', messagetype: 'error', url,
1269
+ message: 'Too many attempts - please wait a few minutes' });
1270
+ }
1271
+ if (!req.body.password) { return loginRedirect(res, notValid); }
1272
+ findUserByLogin(login, (err, user) => {
1273
+ if (err) { console.error("Looking up", login, "to reset a password:", err.message); }
1274
+ if (!user || !resetCodeCheck(user, String(req.body.code || '').trim())) {
1275
+ return loginRedirect(res, notValid);
1276
+ }
1277
+ crypto.randomBytes(16, (err, salt) => {
1278
+ if (err) { return res.status(500).send('Internal error 1101'); }
1279
+ crypto.pbkdf2(req.body.password, salt, 310000, 32, 'sha256', (err, hashedPassword) => {
1280
+ if (err) { return res.status(500).send('Internal error 1103'); }
1281
+ db.run('UPDATE users SET hashed_password = ?, salt = ? WHERE id = ?',
1282
+ [hashedPassword, salt, user.id], (err) => {
1283
+ if (err) {
1284
+ console.error("Could not reset the password for", user.username, "-", err.message);
1285
+ return loginRedirect(res, notValid);
1286
+ }
1287
+ console.log("Password reset for", user.username);
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
+ });
1297
+ });
1298
+ });
1299
+ });
1300
+ });
1301
+ });
1302
+
945
1303
  app.get('/config.json',
946
1304
  loggedInOrFail, // Config is only returned if user is logged in.
947
1305
  (req,res) => {
@@ -970,8 +1328,11 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
970
1328
  loggedInOrFail,
971
1329
  can_OTAUPDATE,
972
1330
  (req,res) => {
973
- let remainingpath = req.params.remainingpath.join('/');
974
- 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
+ }
975
1336
  console.log("Deleting OTA file", dirpath);
976
1337
  rm(dirpath, { recursive: true, force: true }, (err, unused) => {
977
1338
  if (err) {
@@ -996,8 +1357,11 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
996
1357
  loggedInOrFail,
997
1358
  can_OTAUPDATE,
998
1359
  (req,res) => {
999
- let remainingpath = req.params.remainingpath.join('/');
1000
- 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
+ }
1001
1365
  console.log("Sending OTA file", filepath);
1002
1366
  res.sendFile(filepath, {}, (err) => {
1003
1367
  if (err) {
@@ -1016,7 +1380,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1016
1380
  loggedInOrFail,
1017
1381
  can_ADMIN, // Gets org from URL
1018
1382
  (req,res, next) => {
1019
- 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) => {
1020
1384
  if (err) {
1021
1385
  res.status(400).send(err.message);
1022
1386
  } else {
@@ -1030,7 +1394,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1030
1394
  loggedInOrFail,
1031
1395
  can_ADMIN, // Gets org from URL
1032
1396
  (req,res, next) => {
1033
- 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) => {
1034
1398
  if (err) {
1035
1399
  res.status(400).send(err.message);
1036
1400
  } else {
@@ -1040,6 +1404,195 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1040
1404
  },
1041
1405
  send_people_list,
1042
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
+ );
1043
1596
  app.get('/projects_list/:org',
1044
1597
  loggedInOrFail,
1045
1598
  can_ADMIN, // Gets org from URL
@@ -1066,7 +1619,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1066
1619
  // *successful* login passport calls next(), nothing else handles POST /login, and express
1067
1620
  // answers "Cannot POST /login" - which looks like a broken login rather than a missing
1068
1621
  // parameter.
1069
- req.logout((err) => (err ? next(err) : res.redirect(`${loginUrl}?register=false&url=/dashboard`)));
1622
+ req.logout((err) => (err ? next(err) : loginRedirect(res, { url: '/dashboard' })));
1070
1623
  });
1071
1624
 
1072
1625
  // /dashboard is served statically, to logged in users //TODO-N89 restrict orgs to those have permissions for (maybe handled via /config.org )
@@ -1077,7 +1630,9 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1077
1630
  shouldIBeLoggedIn, // redirect to ./login.html if not logged in then back here
1078
1631
  //(req,res,next) => {console.log("XXX back to /dashboard handler for", req.url); next(); }, // Log attempt
1079
1632
  //(req,res,next) => {console.log("XXX", config.server.htmldir); next(); }, // Log attempt
1080
- 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
1081
1636
  );
1082
1637
 
1083
1638
  // Serve frugal-iot-logger data at /data but configure where to get them.
@@ -1092,8 +1647,23 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1092
1647
  // Important that these aren't cached, or the data will not be updated.
1093
1648
  routerData.use(
1094
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.
1095
1657
  (req,res,next) => {
1096
- 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];
1097
1667
  next(); }, //
1098
1668
  can_READ,
1099
1669
  // Nothing logged here on purpose - morgan already reports the URL and status of this same
@@ -1139,17 +1709,28 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1139
1709
  if (isUnsafe([req.body.organization, req.body.project, req.body.deviceid, req.body.otakey])) {
1140
1710
  return cb(new Error("Parameters may not contain '/'"));
1141
1711
  }
1712
+ // An upload that names no organization is a different fault from one the user is not
1713
+ // allowed to make, and without this they are indistinguishable: hasPermissions can
1714
+ // never match an undefined org, so a form that fails to send the field reports itself
1715
+ // as a permission problem and sends whoever is debugging it to the permissions table.
1716
+ // Note multer fills req.body only from the parts that arrive before the file, so a
1717
+ // field the form sends *after* the file is missing here too, even though it was sent.
1718
+ if (!req.body.organization) {
1719
+ return cb(new Error("No organization sent with the upload - the form must send an 'organization' field, ahead of the file"));
1720
+ }
1142
1721
  // Shouldnt actually happen, as dashboard should only show compliant orgs, so this would be a hack (or bad timing)
1143
1722
  if (!hasPermissions(req.user, req.body.organization, "OTAUPDATE")) {
1144
- return cb(new Error("Permission denied to OTAUPDATE"));
1723
+ return cb(new Error(`Permission denied to OTAUPDATE for organization '${req.body.organization}'`));
1145
1724
  }
1146
1725
  let dir;
1147
1726
  if (!req.body.otakey) {
1148
1727
  return cb(new Error("must specify either OTA key or Device ID"));
1149
- } else if (req.body.project) {
1150
- dir = `${config.server.otadir}/${req.body.organization}/${req.body.project}/${req.body.otakey}`;
1151
1728
  } else {
1152
- 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
+ }
1153
1734
  }
1154
1735
  mkdir(dir, {recursive: true}, (err, unusedpath) => {
1155
1736
  if (err) {
@@ -1205,18 +1786,49 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1205
1786
  });
1206
1787
 
1207
1788
  // Serve HTML files from a configurable location
1208
- // Use a 1-day cache to keep traffic down
1209
- // 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.
1210
1792
  // This has to come AFTER all the more specific paths like /data etc
1211
1793
  // Default catches rest (especially "/" so should be last)
1212
1794
  app.use(
1213
- express.static(config.server.publicdir, {immutable: true, maxAge: 1000 * 60 * 60 * 24})
1795
+ express.static(config.server.publicdir, revalidate)
1214
1796
  );
1215
1797
  app.use(clientErrorHandler);
1216
1798
  // Now start the server
1217
1799
  startServer();
1218
- // And logger
1219
- 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
+ });
1220
1832
  }
1221
1833
  });
1222
1834
  }