frugal-iot-server 0.3.7 → 0.3.9
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 +398 -0
- package/README.md +94 -19
- package/config.d/email.yaml +23 -0
- package/config.d/schema/devices.yaml +61 -0
- package/config.d/schema/modules.yaml +337 -39
- package/config.d/schema/topics.yaml +156 -52
- package/extras/mosquitto-bridge.conf.example +130 -0
- package/frugal-iot-server.js +190 -10
- package/lib/housekeeping.js +10 -4
- package/lib/mailer.js +43 -0
- package/lib/resetcode.js +103 -0
- package/package.json +13 -5
- package/public/index.html +16 -0
- package/public/service-worker.js +29 -29
- package/scripts/addbridge-pi.zsh +194 -0
- package/scripts/addbridge-prod.zsh +221 -0
- package/scripts/check-published-files.js +0 -0
- package/scripts/check-schema.js +126 -15
- package/scripts/clearretained.js +185 -0
- package/scripts/copy-schema-to-examples.zsh +9 -5
- package/scripts/diagnostic.zsh +47 -0
- package/scripts/init.zsh +1 -1
- package/scripts/install-pi.sh +646 -0
- package/scripts/not-published.txt +27 -0
- package/scripts/prerelease.zsh +91 -17
package/frugal-iot-server.js
CHANGED
|
@@ -127,12 +127,36 @@ import sqlite3 from 'sqlite3'; // https://www.npmjs.com/package/sqlite3
|
|
|
127
127
|
import crypto from 'crypto'; /* https://nodejs.org/api/crypto.html */
|
|
128
128
|
// 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
129
|
import {waterfall, each} from 'async';
|
|
130
|
+
import { mailInit, mailConfigured, sendMail } from './lib/mailer.js';
|
|
131
|
+
import { resetCodeMake, resetCodeCheck, resetRateOk } from './lib/resetcode.js';
|
|
130
132
|
// import { openDB } from 'sqlite-express-package'; /* appContent, appSelect, validateId, validateAlias, tagCloud, atom, rss,*/
|
|
131
133
|
|
|
132
134
|
export let config; // Live binding - lib/api-routes.js reads the current value at request time
|
|
133
135
|
let mqttLogger = new MqttLogger();
|
|
134
136
|
const loginUrl = '/dashboard/login.html';
|
|
135
137
|
|
|
138
|
+
// Back to the login page with something to say. A form POST can only be answered with a redirect
|
|
139
|
+
// and there is no session to hang a message on, so it all goes in the query string, where
|
|
140
|
+
// mqtt-login reads it as attributes. "mode" is which of the four forms to show;
|
|
141
|
+
// "messagetype" is error or info, which is the difference between a red box and a green one.
|
|
142
|
+
function loginRedirect(res, { mode = 'signin', message, messagetype, url }) {
|
|
143
|
+
const q = new URLSearchParams({ mode });
|
|
144
|
+
if (message) { q.set('message', message); }
|
|
145
|
+
if (messagetype) { q.set('messagetype', messagetype); }
|
|
146
|
+
if (url) { q.set('url', url); } // Encoded here: a return url usually has a query string of its own
|
|
147
|
+
res.redirect(`${loginUrl}?${q}`);
|
|
148
|
+
}
|
|
149
|
+
// A password reset link carries its token in the query string, and everything that logs a URL goes
|
|
150
|
+
// to the journal, which outlives the ten minutes the token is good for.
|
|
151
|
+
function redactUrl(url) {
|
|
152
|
+
return String(url).replace(/([?&]code=)[^&]*/, '$1REDACTED');
|
|
153
|
+
}
|
|
154
|
+
// Where this server can be reached from outside, for the link in a reset email. Behind a proxy that
|
|
155
|
+
// does not set X-Forwarded-Host there is nothing in the request to go on, hence the config override.
|
|
156
|
+
function publicBase(req) {
|
|
157
|
+
return (config.email && config.email.baseurl) || `${req.protocol}://${req.get('host')}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
const optionsHeaders = {
|
|
137
161
|
'Access-Control-Allow-Origin': '*',
|
|
138
162
|
// Probably Needs: GET, OPTIONS HEAD, do not believe can do POST, PUT, DELETE yet but could be wrong about that.
|
|
@@ -255,6 +279,13 @@ function send_people_list(req, res) {
|
|
|
255
279
|
});
|
|
256
280
|
}
|
|
257
281
|
|
|
282
|
+
// Sessions older than this hold that user's permissions from before the change - see the middleware
|
|
283
|
+
// after passport.authenticate('session')
|
|
284
|
+
const permissionsChangedAt = new Map(); // user id -> when it last changed
|
|
285
|
+
function notePermissionsChanged(id) {
|
|
286
|
+
permissionsChangedAt.set(Number(id), Date.now());
|
|
287
|
+
}
|
|
288
|
+
|
|
258
289
|
function add_permission(id, capability, org, cb) {
|
|
259
290
|
if ((id === undefined)
|
|
260
291
|
|| (capability === undefined) || (capability.length < 2)
|
|
@@ -267,6 +298,7 @@ function add_permission(id, capability, org, cb) {
|
|
|
267
298
|
(cb) => db.get('SELECT COUNT(id) FROM permissions WHERE id = ? AND capability = ? AND org = ?', [id, capability, org], cb),
|
|
268
299
|
(n_perms, cb) => { if (n_perms["COUNT(id)"] != 0) { cb(new Error("Duplicate permission")); } else { cb(null); }},
|
|
269
300
|
(cb) => db.run(sqlAddPermission, [id, capability, org], cb),
|
|
301
|
+
(cb) => { notePermissionsChanged(id); cb(null); },
|
|
270
302
|
], cb);
|
|
271
303
|
}
|
|
272
304
|
}
|
|
@@ -278,6 +310,7 @@ function permissions_delete(id, capability, org, cb) {
|
|
|
278
310
|
} else {
|
|
279
311
|
waterfall([
|
|
280
312
|
(cb) => db.get('DELETE FROM permissions WHERE id = ? AND capability = ? AND org = ?', [id,capability,org], cb),
|
|
313
|
+
(cb) => { notePermissionsChanged(id); cb(null); },
|
|
281
314
|
], cb);
|
|
282
315
|
}
|
|
283
316
|
}
|
|
@@ -450,10 +483,20 @@ function execSqlStart(cb) {
|
|
|
450
483
|
});
|
|
451
484
|
}
|
|
452
485
|
|
|
486
|
+
// Look somebody up by whatever they typed into the one "Username or email" box on the login page.
|
|
487
|
+
// Username first, so that a username with an "@" in it still works, then email - which is not
|
|
488
|
+
// declared UNIQUE in the schema, so take the first match rather than assuming there is only one.
|
|
489
|
+
function findUserByLogin(login, cb) {
|
|
490
|
+
db.get('SELECT * FROM users WHERE username = ?', [ login ], function(err, user) {
|
|
491
|
+
if (err || user || !String(login || '').includes('@')) { return cb(err, user); }
|
|
492
|
+
db.get('SELECT * FROM users WHERE email = ? COLLATE NOCASE', [ login ], cb);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
453
496
|
// This verifies the user, and if successful returns a data structure via cb
|
|
454
497
|
// see passport.authenticate('local'... for where it gets used
|
|
455
498
|
passport.use(new LocalStrategy(function verify(username, password, cb) {
|
|
456
|
-
|
|
499
|
+
findUserByLogin(username, function(err, user) {
|
|
457
500
|
if (err) { return cb(err); }
|
|
458
501
|
if (!user) { return cb(null, false, { message: 'Incorrect username or password+' }); }
|
|
459
502
|
// TODO- - maybe just import pbkdf2 and timingSafeEqual ?
|
|
@@ -485,7 +528,8 @@ function loggedInOrRedirect(req, res, next) {
|
|
|
485
528
|
next();
|
|
486
529
|
} else {
|
|
487
530
|
// If originalUrl is /private/index.html then req.url is just /index.html
|
|
488
|
-
|
|
531
|
+
const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
|
|
532
|
+
res.redirect(307, `${loginUrl}?${q}`);
|
|
489
533
|
}
|
|
490
534
|
}
|
|
491
535
|
function hasPermissions(user, org, permission) {
|
|
@@ -567,8 +611,9 @@ function shouldIBeLoggedIn(req, res, next) {
|
|
|
567
611
|
// intact), encoded so its own query string can't corrupt this redirect's query string. Carry "lang"
|
|
568
612
|
// over as its own top-level param (from req.query, not duplicated into "url") so login.html itself
|
|
569
613
|
// renders in the right language.
|
|
570
|
-
const
|
|
571
|
-
|
|
614
|
+
const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
|
|
615
|
+
if (req.query.lang) { q.set('lang', req.query.lang); }
|
|
616
|
+
res.redirect(307, `${loginUrl}?${q}`);
|
|
572
617
|
} else {
|
|
573
618
|
next();
|
|
574
619
|
}
|
|
@@ -680,6 +725,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
680
725
|
// Summarize rather than dumping the whole config: it is mostly the sensor schema, and it holds
|
|
681
726
|
// each organization's mqtt_password, which should not be going to the console and the journal.
|
|
682
727
|
console.log("Broker", config.mqtt.broker, "- organizations:", Object.keys(config.organizations).join(", ") || "(none)");
|
|
728
|
+
console.log(mailInit(config.email));
|
|
683
729
|
// Could genericize config defaults
|
|
684
730
|
// HTTP request logging. "morgan: false" in config.d/server.yaml turns it off completely, which
|
|
685
731
|
// matters on a machine running from an SD card: every request logged is a line to the journal,
|
|
@@ -693,6 +739,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
693
739
|
morganFormat = ':method :url :req[range] :status :res[content-length] :response-time ms :req[referer]'
|
|
694
740
|
}
|
|
695
741
|
// Seems to be writing to syslog which is being cycled.
|
|
742
|
+
morgan.token('url', (req) => redactUrl(req.originalUrl || req.url)); // see redactUrl
|
|
696
743
|
app.use(morgan(morganFormat)); // see https://www.npmjs.com/package/morgan )
|
|
697
744
|
}
|
|
698
745
|
|
|
@@ -712,7 +759,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
712
759
|
// Just log the request for now
|
|
713
760
|
// noinspection JSCheckFunctionSignatures
|
|
714
761
|
app.use('/', (req, res, next) => {
|
|
715
|
-
console.log(req.url);
|
|
762
|
+
console.log(redactUrl(req.url));
|
|
716
763
|
next();
|
|
717
764
|
})
|
|
718
765
|
|
|
@@ -806,6 +853,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
806
853
|
email: user.email,
|
|
807
854
|
phone: user.phone,
|
|
808
855
|
permissions: user.permissions,
|
|
856
|
+
loginAt: Date.now(), // So a permission change can tell which sessions predate it
|
|
809
857
|
});
|
|
810
858
|
});
|
|
811
859
|
});
|
|
@@ -822,6 +870,30 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
822
870
|
// Check if have a session, and if so store in req.user, uses function defined in deserializeUser above
|
|
823
871
|
app.use(passport.authenticate('session')); // Add user to req.user
|
|
824
872
|
|
|
873
|
+
// End any session that predates a change to that user's permissions.
|
|
874
|
+
//
|
|
875
|
+
// The session carries the permissions read at login, and can_READ, can_ADMIN and the rest
|
|
876
|
+
// read them from there - so without this a change takes effect only when the user next logs
|
|
877
|
+
// in. Granting is merely confusing; revoking is worse, because someone whose ADMIN has been
|
|
878
|
+
// taken away keeps it until their session ends.
|
|
879
|
+
//
|
|
880
|
+
// Rather than re-reading the table on every request to catch something this rare, note when
|
|
881
|
+
// a user's permissions changed and end the sessions older than that. They log in again,
|
|
882
|
+
// which is a small price for a change that happens a handful of times.
|
|
883
|
+
//
|
|
884
|
+
// In memory on purpose: express-session's default store keeps the sessions in this process
|
|
885
|
+
// too, so the two are lost together on a restart and neither can outlive the other.
|
|
886
|
+
app.use((req, res, next) => {
|
|
887
|
+
const changedAt = req.user && permissionsChangedAt.get(Number(req.user.id));
|
|
888
|
+
if (changedAt && (!req.user.loginAt || (changedAt > req.user.loginAt))) {
|
|
889
|
+
console.log("Permissions changed for", req.user.id, "- ending the session made before it");
|
|
890
|
+
// Carry on unauthenticated: a page redirects to login, /config.json answers 401 and the
|
|
891
|
+
// client redirects itself
|
|
892
|
+
return req.logout((err) => (err ? next(err) : next()));
|
|
893
|
+
}
|
|
894
|
+
next();
|
|
895
|
+
});
|
|
896
|
+
|
|
825
897
|
// ===== API Integration: Farm IoT Interoperability Standard =====
|
|
826
898
|
// Its tables are in frugal-iot-createdb.sql, already run by execSqlStart above, so there is
|
|
827
899
|
// nothing to initialize here (lib/database.js still exports initializeSchema for the tests).
|
|
@@ -864,11 +936,18 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
864
936
|
(req,res,next) => {
|
|
865
937
|
console.log("Trying to login with redirect to",req.body.url);
|
|
866
938
|
// This is ugly, but I cannot see how to pass the URL to passport.authenticate options
|
|
939
|
+
// Default the destination rather than trusting every caller to supply one: with no
|
|
940
|
+
// successRedirect, passport calls next() on success and the request 404s as
|
|
941
|
+
// "Cannot POST /login" - a confusing way to report a missing form field.
|
|
942
|
+
const returnTo = req.body.url || '/dashboard';
|
|
943
|
+
const failureQuery = new URLSearchParams({
|
|
944
|
+
mode: 'signin', messagetype: 'error',
|
|
945
|
+
message: 'Incorrect username or password', url: returnTo });
|
|
867
946
|
passport.authenticate('local', {
|
|
868
947
|
session: true,
|
|
869
948
|
//failWithError: true,
|
|
870
|
-
failureRedirect: `${loginUrl}
|
|
871
|
-
successRedirect:
|
|
949
|
+
failureRedirect: `${loginUrl}?${failureQuery}`,
|
|
950
|
+
successRedirect: returnTo,
|
|
872
951
|
// In failure case will also be messages in the session which need clearing out TODO-N89
|
|
873
952
|
//failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${req.body.url}`,
|
|
874
953
|
})(req, res, next);
|
|
@@ -893,9 +972,11 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
893
972
|
[username, hashedPassword, salt, organization, req.body.name, req.body.email, req.body.phone], (err) => {
|
|
894
973
|
if (err) {
|
|
895
974
|
console.log(err);
|
|
896
|
-
res
|
|
975
|
+
loginRedirect(res, { mode: 'register', messagetype: 'error',
|
|
976
|
+
message: 'Registration failed', url: req.body.url });
|
|
897
977
|
} else {
|
|
898
|
-
res
|
|
978
|
+
loginRedirect(res, { mode: 'signin', messagetype: 'info',
|
|
979
|
+
message: 'Registration successful - please login', url: req.body.url });
|
|
899
980
|
}
|
|
900
981
|
});
|
|
901
982
|
}
|
|
@@ -904,6 +985,86 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
904
985
|
});
|
|
905
986
|
});
|
|
906
987
|
|
|
988
|
+
// ---------- Forgotten password ----------
|
|
989
|
+
// Nothing is stored: the code is an HMAC over the account and a five-minute slot, so it
|
|
990
|
+
// expires by arithmetic and stops working the moment the password changes. See
|
|
991
|
+
// lib/resetcode.js. The mail carries both a link (a long token) and six digits to type.
|
|
992
|
+
app.post('/forgotpassword', (req, res) => {
|
|
993
|
+
const login = String(req.body.username || '').trim();
|
|
994
|
+
const url = req.body.url;
|
|
995
|
+
// The same answer whether or not the account exists, so this form cannot be used to find
|
|
996
|
+
// out who has an account here.
|
|
997
|
+
const sameAnswer = { mode: 'reset', messagetype: 'info', url,
|
|
998
|
+
message: 'If that account exists we have emailed a reset code' };
|
|
999
|
+
if (!mailConfigured()) {
|
|
1000
|
+
return loginRedirect(res, { mode: 'signin', messagetype: 'error', url,
|
|
1001
|
+
message: 'Password reset is not available on this server' });
|
|
1002
|
+
}
|
|
1003
|
+
if (!resetRateOk('send', login.toLowerCase())) {
|
|
1004
|
+
return loginRedirect(res, { mode: 'forgot', messagetype: 'error', url,
|
|
1005
|
+
message: 'Too many attempts - please wait a few minutes' });
|
|
1006
|
+
}
|
|
1007
|
+
findUserByLogin(login, (err, user) => {
|
|
1008
|
+
if (err) { console.error("Looking up", login, "for a password reset:", err.message); }
|
|
1009
|
+
if (!user || !user.email) { return loginRedirect(res, sameAnswer); }
|
|
1010
|
+
const { code, token } = resetCodeMake(user);
|
|
1011
|
+
const q = new URLSearchParams({ mode: 'reset', username: user.username, code: token });
|
|
1012
|
+
if (url) { q.set('url', url); }
|
|
1013
|
+
const link = `${publicBase(req)}${loginUrl}?${q}`;
|
|
1014
|
+
const text = [
|
|
1015
|
+
`Somebody asked to reset the Frugal IoT password for ${user.username}.`,
|
|
1016
|
+
``,
|
|
1017
|
+
`Open this link:`,
|
|
1018
|
+
link,
|
|
1019
|
+
``,
|
|
1020
|
+
`or type this code into the reset form: ${code}`,
|
|
1021
|
+
``,
|
|
1022
|
+
`Either works for the next ten minutes at most. If it was not you, ignore this mail -`,
|
|
1023
|
+
`nothing has changed and nothing will until the code is used.`,
|
|
1024
|
+
].join('\n');
|
|
1025
|
+
sendMail({ to: user.email, subject: 'Frugal IoT password reset', text }, (err) => {
|
|
1026
|
+
// Still the same answer: telling them the mail failed would also tell an outsider
|
|
1027
|
+
// that the account exists. It is logged loudly instead, because a server whose SMTP
|
|
1028
|
+
// is broken looks from the outside exactly like one that is working.
|
|
1029
|
+
if (err) { console.error("Could not send reset mail for", user.username, "-", err.message); }
|
|
1030
|
+
loginRedirect(res, sameAnswer);
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
app.post('/resetpassword', (req, res) => {
|
|
1035
|
+
const login = String(req.body.username || '').trim();
|
|
1036
|
+
const url = req.body.url;
|
|
1037
|
+
const notValid = { mode: 'reset', messagetype: 'error', url,
|
|
1038
|
+
message: 'That code is not valid or has expired' };
|
|
1039
|
+
if (!resetRateOk('check', login.toLowerCase())) {
|
|
1040
|
+
return loginRedirect(res, { mode: 'reset', messagetype: 'error', url,
|
|
1041
|
+
message: 'Too many attempts - please wait a few minutes' });
|
|
1042
|
+
}
|
|
1043
|
+
if (!req.body.password) { return loginRedirect(res, notValid); }
|
|
1044
|
+
findUserByLogin(login, (err, user) => {
|
|
1045
|
+
if (err) { console.error("Looking up", login, "to reset a password:", err.message); }
|
|
1046
|
+
if (!user || !resetCodeCheck(user, String(req.body.code || '').trim())) {
|
|
1047
|
+
return loginRedirect(res, notValid);
|
|
1048
|
+
}
|
|
1049
|
+
crypto.randomBytes(16, (err, salt) => {
|
|
1050
|
+
if (err) { return res.status(500).send('Internal error 1101'); }
|
|
1051
|
+
crypto.pbkdf2(req.body.password, salt, 310000, 32, 'sha256', (err, hashedPassword) => {
|
|
1052
|
+
if (err) { return res.status(500).send('Internal error 1103'); }
|
|
1053
|
+
db.run('UPDATE users SET hashed_password = ?, salt = ? WHERE id = ?',
|
|
1054
|
+
[hashedPassword, salt, user.id], (err) => {
|
|
1055
|
+
if (err) {
|
|
1056
|
+
console.error("Could not reset the password for", user.username, "-", err.message);
|
|
1057
|
+
return loginRedirect(res, notValid);
|
|
1058
|
+
}
|
|
1059
|
+
console.log("Password reset for", user.username);
|
|
1060
|
+
loginRedirect(res, { mode: 'signin', messagetype: 'info', url,
|
|
1061
|
+
message: 'Password reset - please sign in' });
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
|
|
907
1068
|
app.get('/config.json',
|
|
908
1069
|
loggedInOrFail, // Config is only returned if user is logged in.
|
|
909
1070
|
(req,res) => {
|
|
@@ -1021,6 +1182,16 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1021
1182
|
},
|
|
1022
1183
|
send_projects_list,
|
|
1023
1184
|
);
|
|
1185
|
+
// Log out and go back to the login page. There was no way to do this at all before, which
|
|
1186
|
+
// matters more now that a permissions change ends the session and people log in again.
|
|
1187
|
+
app.get('/logout', (req, res, next) => {
|
|
1188
|
+
// With a url to come back to: without one, POST /login has no successRedirect, so on a
|
|
1189
|
+
// *successful* login passport calls next(), nothing else handles POST /login, and express
|
|
1190
|
+
// answers "Cannot POST /login" - which looks like a broken login rather than a missing
|
|
1191
|
+
// parameter.
|
|
1192
|
+
req.logout((err) => (err ? next(err) : loginRedirect(res, { url: '/dashboard' })));
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1024
1195
|
// /dashboard is served statically, to logged in users //TODO-N89 restrict orgs to those have permissions for (maybe handled via /config.org )
|
|
1025
1196
|
const routerDashboard = express.Router();
|
|
1026
1197
|
app.use('/dashboard', routerDashboard);
|
|
@@ -1091,9 +1262,18 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1091
1262
|
if (isUnsafe([req.body.organization, req.body.project, req.body.deviceid, req.body.otakey])) {
|
|
1092
1263
|
return cb(new Error("Parameters may not contain '/'"));
|
|
1093
1264
|
}
|
|
1265
|
+
// An upload that names no organization is a different fault from one the user is not
|
|
1266
|
+
// allowed to make, and without this they are indistinguishable: hasPermissions can
|
|
1267
|
+
// never match an undefined org, so a form that fails to send the field reports itself
|
|
1268
|
+
// as a permission problem and sends whoever is debugging it to the permissions table.
|
|
1269
|
+
// Note multer fills req.body only from the parts that arrive before the file, so a
|
|
1270
|
+
// field the form sends *after* the file is missing here too, even though it was sent.
|
|
1271
|
+
if (!req.body.organization) {
|
|
1272
|
+
return cb(new Error("No organization sent with the upload - the form must send an 'organization' field, ahead of the file"));
|
|
1273
|
+
}
|
|
1094
1274
|
// Shouldnt actually happen, as dashboard should only show compliant orgs, so this would be a hack (or bad timing)
|
|
1095
1275
|
if (!hasPermissions(req.user, req.body.organization, "OTAUPDATE")) {
|
|
1096
|
-
return cb(new Error(
|
|
1276
|
+
return cb(new Error(`Permission denied to OTAUPDATE for organization '${req.body.organization}'`));
|
|
1097
1277
|
}
|
|
1098
1278
|
let dir;
|
|
1099
1279
|
if (!req.body.otakey) {
|
package/lib/housekeeping.js
CHANGED
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
* appending to them.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { createReadStream, createWriteStream, readdir, rename, stat,
|
|
22
|
+
import { createReadStream, createWriteStream, readdir, rename, stat, unlink, readFile, writeFile } from 'fs';
|
|
23
|
+
// statfs arrived in node 18.15, and this package says it needs node 18. Taken off the namespace
|
|
24
|
+
// rather than imported by name, because a named import of something a builtin does not have fails
|
|
25
|
+
// when the module loads - which would stop the server outright on an 18.x older than that, instead
|
|
26
|
+
// of just doing without the one check that needs it (see freePercent below).
|
|
27
|
+
import fs from 'fs';
|
|
23
28
|
import { createGzip, gunzip, gzip } from 'zlib';
|
|
24
29
|
import path from 'path';
|
|
25
30
|
import { each, eachSeries, waterfall } from 'async';
|
|
@@ -147,8 +152,8 @@ function deleteFiles(files, callback) {
|
|
|
147
152
|
* How much of the filesystem holding "dir" is free, as a percentage, or null if it cannot be told.
|
|
148
153
|
*/
|
|
149
154
|
function freePercent(dir, callback) {
|
|
150
|
-
if (typeof statfs !== 'function') { callback(null, null); return; } //
|
|
151
|
-
statfs(dir, (err, st) => {
|
|
155
|
+
if (typeof fs.statfs !== 'function') { callback(null, null); return; } // node older than 18.15
|
|
156
|
+
fs.statfs(dir, (err, st) => {
|
|
152
157
|
if (err || !st || !st.blocks) { callback(null, null); return; }
|
|
153
158
|
callback(null, (st.bavail / st.blocks) * 100);
|
|
154
159
|
});
|
|
@@ -189,7 +194,8 @@ export function housekeep(datadir, config, callback) {
|
|
|
189
194
|
// Delete oldest days until there is enough room. The shortfall is worked out in bytes so
|
|
190
195
|
// that the number of days removed matches what is actually needed.
|
|
191
196
|
console.log(`Housekeeping: ${free.toFixed(1)}% of the disk free, below the ${freeWanted}% wanted - removing the oldest readings`);
|
|
192
|
-
|
|
197
|
+
// Reached only when freePercent returned a number, so statfs exists on this node
|
|
198
|
+
fs.statfs(datadir, (err3, st) => {
|
|
193
199
|
let shortfall = (err3 || !st) ? 0 : ((freeWanted / 100) * st.blocks - st.bavail) * st.bsize;
|
|
194
200
|
let toRemove = [];
|
|
195
201
|
let planned = 0;
|
package/lib/mailer.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sending mail, configured in config.d/email.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Absent or incomplete configuration means this server does not send mail. That is a supported
|
|
5
|
+
* state - most installations are a Pi in a shed - so mailConfigured() lets a caller say so plainly
|
|
6
|
+
* instead of accepting a request and dropping it.
|
|
7
|
+
*/
|
|
8
|
+
import nodemailer from 'nodemailer';
|
|
9
|
+
|
|
10
|
+
let transport = null;
|
|
11
|
+
let mailFrom = null;
|
|
12
|
+
|
|
13
|
+
// emailConfig is config.email, i.e. config.d/email.yaml. Returns a line for the startup log.
|
|
14
|
+
function mailInit(emailConfig) {
|
|
15
|
+
transport = null;
|
|
16
|
+
mailFrom = null;
|
|
17
|
+
if (!emailConfig || !emailConfig.host || !emailConfig.from) {
|
|
18
|
+
return "Not sending mail (no host/from in config.d/email.yaml) - password reset is unavailable";
|
|
19
|
+
}
|
|
20
|
+
const port = emailConfig.port || 587;
|
|
21
|
+
transport = nodemailer.createTransport({
|
|
22
|
+
host: emailConfig.host,
|
|
23
|
+
port,
|
|
24
|
+
// 465 is TLS from the first byte; 587 and 25 start plain and STARTTLS. Wrong either way is a
|
|
25
|
+
// connection that hangs rather than an error that says what is wrong, so derive it.
|
|
26
|
+
secure: (emailConfig.secure !== undefined) ? emailConfig.secure : (port === 465),
|
|
27
|
+
auth: emailConfig.user ? { user: emailConfig.user, pass: emailConfig.pass } : undefined,
|
|
28
|
+
});
|
|
29
|
+
mailFrom = emailConfig.from;
|
|
30
|
+
return `Sending mail via ${emailConfig.host}:${port} as ${mailFrom}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mailConfigured() { return !!transport; }
|
|
34
|
+
|
|
35
|
+
// cb(err) - nodemailer is promise-based, wrapped here because the rest of this codebase is callbacks
|
|
36
|
+
function sendMail({ to, subject, text, html }, cb) {
|
|
37
|
+
if (!transport) { return cb(new Error("Mail is not configured on this server")); }
|
|
38
|
+
transport.sendMail({ from: mailFrom, to, subject, text, html })
|
|
39
|
+
.then(() => cb(null))
|
|
40
|
+
.catch((err) => cb(err));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { mailInit, mailConfigured, sendMail };
|
package/lib/resetcode.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Password reset codes, held nowhere.
|
|
3
|
+
*
|
|
4
|
+
* A code is an HMAC over (user id, email, current password hash, five-minute slot), keyed by a
|
|
5
|
+
* secret this process made at startup. Nothing is written to the database and nothing expires by
|
|
6
|
+
* being deleted - a code stops verifying because the slot has moved on, the password has changed,
|
|
7
|
+
* or the server has restarted. Two slots are accepted, so a code lasts between 5 and 10 minutes.
|
|
8
|
+
*
|
|
9
|
+
* Including the current hashed_password is what makes a code single-use: resetting the password
|
|
10
|
+
* changes the hash, and every code derived from the old one stops matching immediately.
|
|
11
|
+
*
|
|
12
|
+
* Two forms come out of the same digest:
|
|
13
|
+
* code - six digits, short enough to read off a phone and type in
|
|
14
|
+
* token - 32 hex characters, put in the emailed link, where length costs nothing
|
|
15
|
+
* Six digits is only a million guesses, so the six-digit form is worth having only alongside the
|
|
16
|
+
* rate limiting below; the link form does not depend on it.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'crypto';
|
|
19
|
+
|
|
20
|
+
const SLOT_MS = 5 * 60 * 1000; // a code is valid for its own slot and the one before it
|
|
21
|
+
const SLOTS_ACCEPTED = 2;
|
|
22
|
+
// Regenerated every start: a restart invalidates outstanding codes, which is the right way round.
|
|
23
|
+
const secret = crypto.randomBytes(32);
|
|
24
|
+
|
|
25
|
+
// Rate limits, per identifier, in memory. Lost on restart, which also invalidates every code, so
|
|
26
|
+
// there is nothing to carry over. Not a defence against a distributed attacker - it is here so that
|
|
27
|
+
// a million guesses at a six-digit code cannot be made in the ten minutes one is alive.
|
|
28
|
+
const SEND_LIMIT = 3; // reset emails per identifier
|
|
29
|
+
const CHECK_LIMIT = 10; // verification attempts per identifier
|
|
30
|
+
const LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
31
|
+
const buckets = { send: new Map(), check: new Map() };
|
|
32
|
+
|
|
33
|
+
function slotNow() { return Math.floor(Date.now() / SLOT_MS); }
|
|
34
|
+
|
|
35
|
+
function digestFor(user, slot) {
|
|
36
|
+
// The password hash is a BLOB from sqlite; a user with no password yet has none.
|
|
37
|
+
const hashed = user.hashed_password ? Buffer.from(user.hashed_password).toString('hex') : '';
|
|
38
|
+
return crypto.createHmac('sha256', secret)
|
|
39
|
+
.update([user.id, user.email || '', hashed, slot].join('\n'))
|
|
40
|
+
.digest();
|
|
41
|
+
}
|
|
42
|
+
function formsFor(user, slot) {
|
|
43
|
+
const d = digestFor(user, slot);
|
|
44
|
+
return {
|
|
45
|
+
code: String(d.readUInt32BE(0) % 1000000).padStart(6, '0'),
|
|
46
|
+
token: d.toString('hex').slice(0, 32),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Constant-time, and safe on differing lengths - timingSafeEqual throws on those rather than
|
|
50
|
+
// returning false.
|
|
51
|
+
function sameSecret(a, b) {
|
|
52
|
+
const ba = Buffer.from(String(a));
|
|
53
|
+
const bb = Buffer.from(String(b));
|
|
54
|
+
if (ba.length !== bb.length) { return false; }
|
|
55
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// { code, token } for the current slot - what goes in the email
|
|
59
|
+
function resetCodeMake(user) {
|
|
60
|
+
return formsFor(user, slotNow());
|
|
61
|
+
}
|
|
62
|
+
// True if "supplied" is either form, for this slot or the one before
|
|
63
|
+
function resetCodeCheck(user, supplied) {
|
|
64
|
+
if (!supplied) { return false; }
|
|
65
|
+
const now = slotNow();
|
|
66
|
+
let ok = false;
|
|
67
|
+
for (let i = 0; i < SLOTS_ACCEPTED; i++) {
|
|
68
|
+
const { code, token } = formsFor(user, now - i);
|
|
69
|
+
// No early return: keep the work the same whichever slot matches
|
|
70
|
+
ok = sameSecret(supplied, code) || sameSecret(supplied, token) || ok;
|
|
71
|
+
}
|
|
72
|
+
return ok;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// True if this identifier may make another attempt of this kind, and counts it if so.
|
|
76
|
+
// bucket is 'send' (asking for an email) or 'check' (trying a code).
|
|
77
|
+
function resetRateOk(bucket, key) {
|
|
78
|
+
const limit = (bucket === 'send') ? SEND_LIMIT : CHECK_LIMIT;
|
|
79
|
+
const map = buckets[bucket];
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const hits = (map.get(key) || []).filter((t) => (now - t) < LIMIT_WINDOW_MS);
|
|
82
|
+
if (hits.length >= limit) {
|
|
83
|
+
map.set(key, hits);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
hits.push(now);
|
|
87
|
+
map.set(key, hits);
|
|
88
|
+
// Nothing else prunes this, and an attacker can name any identifier they like, so drop entries
|
|
89
|
+
// whose window has passed whenever the map gets big rather than growing without bound.
|
|
90
|
+
if (map.size > 1000) {
|
|
91
|
+
for (const [k, v] of map) {
|
|
92
|
+
if (!v.some((t) => (now - t) < LIMIT_WINDOW_MS)) { map.delete(k); }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// So a test can start from a clean slate without waiting out the window
|
|
98
|
+
function resetRateClear() {
|
|
99
|
+
buckets.send.clear();
|
|
100
|
+
buckets.check.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { resetCodeMake, resetCodeCheck, resetRateOk, resetRateClear, SLOT_MS, SLOTS_ACCEPTED };
|
package/package.json
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
"cookie-parser": "^1.4.7",
|
|
13
13
|
"express": "^5.2.1",
|
|
14
14
|
"express-session": "^1.19.0",
|
|
15
|
-
"frugal-iot-client": "^1.3.
|
|
16
|
-
"frugal-iot-logger": "^1.1.
|
|
15
|
+
"frugal-iot-client": "^1.3.15",
|
|
16
|
+
"frugal-iot-logger": "^1.1.21",
|
|
17
17
|
"hash-wasm": "^4.12.0",
|
|
18
18
|
"js-yaml": "^4.2.0",
|
|
19
19
|
"morgan": "^1.11.0",
|
|
20
|
+
"mqtt": "^5.15.2",
|
|
20
21
|
"multer": "^2.1.1",
|
|
22
|
+
"nodemailer": "^9.0.6",
|
|
21
23
|
"passport": "^0.7.0",
|
|
22
24
|
"passport-local": "^1.0.0",
|
|
23
25
|
"sqlite3": "^5.1.7"
|
|
@@ -27,7 +29,10 @@
|
|
|
27
29
|
"frugal-iot-init": "scripts/init.zsh",
|
|
28
30
|
"frugal-iot-addorganization": "scripts/addorganization.zsh",
|
|
29
31
|
"frugal-iot-setpassword": "scripts/setpassword.zsh",
|
|
30
|
-
"frugal-iot-diagnostic": "scripts/diagnostic.zsh"
|
|
32
|
+
"frugal-iot-diagnostic": "scripts/diagnostic.zsh",
|
|
33
|
+
"frugal-iot-clearretained": "scripts/clearretained.js",
|
|
34
|
+
"frugal-iot-addbridge-prod": "scripts/addbridge-prod.zsh",
|
|
35
|
+
"frugal-iot-addbridge-pi": "scripts/addbridge-pi.zsh"
|
|
31
36
|
},
|
|
32
37
|
"description": "Frugal IoT client and server",
|
|
33
38
|
"//files": "An allowlist, not an ignore list - anything not named here is never published, so a stray file (a database backup, a credential) cannot leak by default. Check with: npm pack --dry-run",
|
|
@@ -35,6 +40,7 @@
|
|
|
35
40
|
"frugal-iot-server.js",
|
|
36
41
|
"frugal-iot-createdb.sql",
|
|
37
42
|
"config.yaml",
|
|
43
|
+
"config.d/email.yaml",
|
|
38
44
|
"config.d/logger.yaml",
|
|
39
45
|
"config.d/mqtt.yaml",
|
|
40
46
|
"config.d/server.yaml",
|
|
@@ -44,6 +50,7 @@
|
|
|
44
50
|
"private/",
|
|
45
51
|
"scripts/",
|
|
46
52
|
"extras/mosquitto.conf",
|
|
53
|
+
"extras/mosquitto-bridge.conf.example",
|
|
47
54
|
"extras/frugaliot.service",
|
|
48
55
|
"API.md",
|
|
49
56
|
"INSTALLATION.md"
|
|
@@ -68,8 +75,9 @@
|
|
|
68
75
|
"test:watch": "vitest",
|
|
69
76
|
"test:coverage": "vitest run --coverage",
|
|
70
77
|
"check-schema": "node scripts/check-schema.js",
|
|
71
|
-
"prerelease": "zsh scripts/prerelease.zsh"
|
|
78
|
+
"prerelease": "zsh scripts/prerelease.zsh",
|
|
79
|
+
"check-files": "node scripts/check-published-files.js"
|
|
72
80
|
},
|
|
73
81
|
"type": "module",
|
|
74
|
-
"version": "0.3.
|
|
82
|
+
"version": "0.3.9"
|
|
75
83
|
}
|
package/public/index.html
CHANGED
|
@@ -167,6 +167,14 @@
|
|
|
167
167
|
<div class="nav-section">
|
|
168
168
|
<h2>Other examples</h2>
|
|
169
169
|
<div class="box-grid">
|
|
170
|
+
<!-- The display the Dashboard used before the cards. Kept reachable while people get used
|
|
171
|
+
to the new one; it will go once nothing needs it. -->
|
|
172
|
+
<div class="nav-box">
|
|
173
|
+
<div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
|
|
174
|
+
<div class="nav-box-content">
|
|
175
|
+
<a href="/dashboard/index-old.html">Previous dashboard</a>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
170
178
|
<div class="nav-box">
|
|
171
179
|
<div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
|
|
172
180
|
<div class="nav-box-content">
|
|
@@ -189,6 +197,14 @@
|
|
|
189
197
|
</div>
|
|
190
198
|
<script>
|
|
191
199
|
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
|
200
|
+
// The worker calls skipWaiting(), so a new one can take over a page whose scripts came
|
|
201
|
+
// from the old one. Reload once to get a consistent set - but not on a first install,
|
|
202
|
+
// where there was no previous controller and nothing is stale.
|
|
203
|
+
const hadController = !!navigator.serviceWorker.controller;
|
|
204
|
+
let reloading = false;
|
|
205
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
206
|
+
if (hadController && !reloading) { reloading = true; location.reload(); }
|
|
207
|
+
});
|
|
192
208
|
navigator.serviceWorker.register('/service-worker.js')
|
|
193
209
|
.then(reg => console.log('Service Worker registered'))
|
|
194
210
|
.catch(err => console.log('Service Worker registration failed', err));
|