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.
- package/INSTALLATION.md +77 -18
- package/config.d/schema/modules.yaml +15 -0
- package/config.d/schema/topics.yaml +23 -0
- package/extras/aclfile +38 -0
- package/extras/mosquitto.conf +29 -0
- package/frugal-iot-createdb.sql +82 -1
- package/frugal-iot-server.js +552 -72
- package/lib/api-routes.js +7 -7
- package/lib/config-for-user.js +115 -0
- package/lib/dynsec-plan.js +246 -0
- package/lib/dynsec-server.js +204 -0
- package/lib/dynsec-sync.js +209 -0
- package/lib/dynsec.js +166 -0
- package/lib/enrol.js +288 -0
- package/lib/replica.js +246 -0
- package/lib/retained.js +110 -0
- package/lib/secrets.js +148 -0
- package/package.json +8 -4
- package/public/index.html +0 -8
- package/public/service-worker.js +51 -1
- package/scripts/addbridge-pi.zsh +56 -2
- package/scripts/addbridge-prod.zsh +48 -0
- package/scripts/addorganization.zsh +5 -0
- package/scripts/diagnostic.zsh +121 -2
- package/scripts/dynsec-init.js +142 -0
- package/scripts/init.zsh +63 -0
- package/scripts/install-pi.sh +83 -4
- package/scripts/migrate-permissions-project.sql +21 -0
- package/scripts/not-published.txt +5 -0
- package/scripts/rebuild-dynsec.js +190 -0
- package/scripts/resetnode.js +69 -0
package/frugal-iot-server.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 =>
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
//
|
|
758
|
+
// See lib/config-for-user.js - kept there so it can be tested without starting a server.
|
|
646
759
|
function unsafeCopyConfigFor(user) {
|
|
647
|
-
|
|
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
|
-
|
|
696
|
-
|
|
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,
|
|
820
|
-
express.static(config.server.nodemodulesdir,
|
|
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:
|
|
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
|
-
|
|
1061
|
-
|
|
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
|
|
1097
|
-
|
|
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
|
|
1123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1341
|
-
//
|
|
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,
|
|
1795
|
+
express.static(config.server.publicdir, revalidate)
|
|
1346
1796
|
);
|
|
1347
1797
|
app.use(clientErrorHandler);
|
|
1348
1798
|
// Now start the server
|
|
1349
1799
|
startServer();
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
}
|