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.
- package/INSTALLATION.md +219 -61
- package/config.d/email.yaml +23 -0
- package/config.d/schema/devices.yaml +0 -2
- package/config.d/schema/modules.yaml +344 -2
- package/config.d/schema/topics.yaml +140 -46
- package/extras/aclfile +38 -0
- package/extras/mosquitto.conf +29 -0
- package/frugal-iot-createdb.sql +82 -1
- package/frugal-iot-server.js +692 -80
- 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/mailer.js +43 -0
- package/lib/replica.js +246 -0
- package/lib/resetcode.js +103 -0
- package/lib/retained.js +110 -0
- package/lib/secrets.js +148 -0
- package/package.json +12 -5
- package/public/index.html +8 -8
- package/public/service-worker.js +76 -27
- package/scripts/addbridge-pi.zsh +91 -7
- package/scripts/addbridge-prod.zsh +57 -3
- package/scripts/addorganization.zsh +5 -0
- package/scripts/clearretained.js +185 -0
- package/scripts/diagnostic.zsh +121 -2
- package/scripts/dynsec-init.js +142 -0
- package/scripts/init.zsh +64 -1
- 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/scripts/clearretained.zsh +0 -153
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';
|
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 =>
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
630
|
+
const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
|
|
631
|
+
res.redirect(307, `${loginUrl}?${q}`);
|
|
498
632
|
}
|
|
499
633
|
}
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
580
|
-
|
|
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
|
-
//
|
|
758
|
+
// See lib/config-for-user.js - kept there so it can be tested without starting a server.
|
|
610
759
|
function unsafeCopyConfigFor(user) {
|
|
611
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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,
|
|
782
|
-
express.static(config.server.nodemodulesdir,
|
|
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:
|
|
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}
|
|
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
|
|
1203
|
+
loginRedirect(res, { mode: 'register', messagetype: 'error',
|
|
1204
|
+
message: 'Registration failed', url: req.body.url });
|
|
935
1205
|
} else {
|
|
936
|
-
res
|
|
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
|
|
974
|
-
|
|
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
|
|
1000
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
//
|
|
1209
|
-
//
|
|
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,
|
|
1795
|
+
express.static(config.server.publicdir, revalidate)
|
|
1214
1796
|
);
|
|
1215
1797
|
app.use(clientErrorHandler);
|
|
1216
1798
|
// Now start the server
|
|
1217
1799
|
startServer();
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
}
|