frugal-iot-server 0.3.4 → 0.3.6
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 +380 -123
- package/README.md +6 -6
- package/config.d/logger.yaml +16 -0
- package/config.d/schema/modules.yaml +66 -1
- package/config.d/schema/topics.yaml +184 -2
- package/config.d/server.yaml +28 -4
- package/extras/mosquitto.conf +13 -1
- package/frugal-iot-server.js +71 -5
- package/lib/api-routes.js +3 -0
- package/lib/data-loader.js +11 -4
- package/lib/housekeeping.js +251 -0
- package/lib/logger-client.js +16 -0
- package/package.json +10 -5
- package/public/service-worker.js +1 -1
- package/scripts/addorganization.zsh +59 -15
- package/scripts/check-schema.js +127 -0
- package/scripts/copy-schema-to-examples.zsh +55 -0
- package/scripts/diagnostic.zsh +382 -0
- package/scripts/init.zsh +37 -10
- package/scripts/prerelease.zsh +125 -0
- package/scripts/setpassword.zsh +3 -3
package/frugal-iot-server.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*Δ /admin (get) dashboard for administrators - includes OTA and will include permission management
|
|
31
31
|
P /ota_update (post) protected place to upload new binaries (OTAUPDATE)
|
|
32
32
|
XP /ota_list/:org list all ota files for an organization
|
|
33
|
+
XP /ota_get/:org/*remainingpath download a binary so the client can flash it over USB
|
|
33
34
|
O /private Serve up private files under authentication - currently unused
|
|
34
35
|
* /register (post) register a new user
|
|
35
36
|
*/
|
|
@@ -111,6 +112,8 @@ import { createPushManager } from './lib/farm-platform-push.js';
|
|
|
111
112
|
import { APIError } from './lib/api-errors.js';
|
|
112
113
|
|
|
113
114
|
import { access, constants, createReadStream, mkdir, readdir, readFile, rm } from 'fs'; // https://nodejs.org/api/fs.html
|
|
115
|
+
import { createGunzip } from 'zlib'; // https://nodejs.org/api/zlib.html - for serving compressed readings
|
|
116
|
+
import { startHousekeeping } from './lib/housekeeping.js';
|
|
114
117
|
import { detectSeries } from 'async'; // https://caolan.github.io/async/v3/docs.html
|
|
115
118
|
import { createMD5 } from 'hash-wasm';
|
|
116
119
|
import multer from 'multer'; // https://www.npmjs.com/package/multer
|
|
@@ -678,11 +681,20 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
678
681
|
// each organization's mqtt_password, which should not be going to the console and the journal.
|
|
679
682
|
console.log("Broker", config.mqtt.broker, "- organizations:", Object.keys(config.organizations).join(", ") || "(none)");
|
|
680
683
|
// Could genericize config defaults
|
|
681
|
-
|
|
682
|
-
|
|
684
|
+
// HTTP request logging. "morgan: false" in config.d/server.yaml turns it off completely, which
|
|
685
|
+
// matters on a machine running from an SD card: every request logged is a line to the journal,
|
|
686
|
+
// and so a write that wears the card. Absent means log, so an existing server is unaffected by
|
|
687
|
+
// this setting arriving. The older top-level config.yaml setting still works if anyone set it.
|
|
688
|
+
let morganFormat = (config.server.morgan !== undefined) ? config.server.morgan : config.morgan;
|
|
689
|
+
if (morganFormat === false) {
|
|
690
|
+
console.log("Not logging HTTP requests (morgan: false in config.d/server.yaml)");
|
|
691
|
+
} else {
|
|
692
|
+
if (!morganFormat || (morganFormat === true)) { // Unset, or "morgan: true" meaning just turn it on
|
|
693
|
+
morganFormat = ':method :url :req[range] :status :res[content-length] :response-time ms :req[referer]'
|
|
694
|
+
}
|
|
695
|
+
// Seems to be writing to syslog which is being cycled.
|
|
696
|
+
app.use(morgan(morganFormat)); // see https://www.npmjs.com/package/morgan )
|
|
683
697
|
}
|
|
684
|
-
// Seems to be writing to syslog which is being cycled.
|
|
685
|
-
app.use(morgan(config.morgan)); // see https://www.npmjs.com/package/morgan )
|
|
686
698
|
|
|
687
699
|
if (config.server.otadir.startsWith("./")) {
|
|
688
700
|
config.server.otadir = process.cwd() + config.server.otadir.substring(1);
|
|
@@ -920,6 +932,24 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
920
932
|
});
|
|
921
933
|
}
|
|
922
934
|
);
|
|
935
|
+
// Download an OTA binary so the client can flash it over USB (see FLASH_PLAN.md in
|
|
936
|
+
// frugal-iot-client). Deliberately not /ota_update, which is unauthenticated for devices
|
|
937
|
+
// and answers 304 rather than sending bytes.
|
|
938
|
+
app.get('/ota_get/:org/*remainingpath',
|
|
939
|
+
loggedInOrFail,
|
|
940
|
+
can_OTAUPDATE,
|
|
941
|
+
(req,res) => {
|
|
942
|
+
let remainingpath = req.params.remainingpath.join('/');
|
|
943
|
+
let filepath = `${config.server.otadir}/${req.params.org}/${sanitize(remainingpath)}/firmware.bin`;
|
|
944
|
+
console.log("Sending OTA file", filepath);
|
|
945
|
+
res.sendFile(filepath, {}, (err) => {
|
|
946
|
+
if (err) {
|
|
947
|
+
console.error("Error sending ota file:", filepath, err);
|
|
948
|
+
if (!res.headersSent) res.status(404).send(err.message);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
);
|
|
923
953
|
app.get('/people_list/:org',
|
|
924
954
|
loggedInOrFail,
|
|
925
955
|
can_ADMIN, // Gets org from URL
|
|
@@ -985,6 +1015,10 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
985
1015
|
|
|
986
1016
|
// Serve frugal-iot-logger data at /data but configure where to get them.
|
|
987
1017
|
console.log("Serving /data from", config.server.datadir);
|
|
1018
|
+
// Nothing ever removed old readings, so a server left running filled its disk - which on a
|
|
1019
|
+
// field node takes the whole machine down. This compresses days gone by and, if asked to,
|
|
1020
|
+
// removes the oldest ones. See lib/housekeeping.js for what the settings mean.
|
|
1021
|
+
startHousekeeping(config.server.datadir, config.server.housekeeping);
|
|
988
1022
|
//TODO-N89 should be authenticated to correct org
|
|
989
1023
|
const routerData = express.Router();
|
|
990
1024
|
app.use('/data', routerData);
|
|
@@ -995,7 +1029,39 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
995
1029
|
res.locals.org = req.url.split("/")[1];
|
|
996
1030
|
next(); }, //
|
|
997
1031
|
can_READ,
|
|
998
|
-
|
|
1032
|
+
// Nothing logged here on purpose - morgan already reports the URL and status of this same
|
|
1033
|
+
// request, and on an SD card each duplicated line is another write.
|
|
1034
|
+
// The logger holds recent readings in memory rather than writing each one to the card as
|
|
1035
|
+
// it arrives, so write them out before serving the files, or today's would be short of
|
|
1036
|
+
// the last few minutes. Reading data is rare compared with recording it, and this costs
|
|
1037
|
+
// nothing when there is nothing waiting.
|
|
1038
|
+
// The guard is for an installation whose logger predates flush() - it would rather serve
|
|
1039
|
+
// data a few minutes out of date than fail the request
|
|
1040
|
+
(req, res, next) => { if (mqttLogger.flush) { mqttLogger.flush(() => next()); } else { next(); } },
|
|
1041
|
+
// Readings from days gone by get compressed (see lib/housekeeping.js) but are still
|
|
1042
|
+
// asked for by their ".csv" name, so answer one of those with the ".csv.gz" if that is
|
|
1043
|
+
// what is on disk. A browser decompresses a gzip Content-Encoding itself, so the client
|
|
1044
|
+
// needed no change; anything that says it does not want gzip - curl, by default - gets
|
|
1045
|
+
// it decompressed here instead.
|
|
1046
|
+
(req, res, next) => {
|
|
1047
|
+
if (!req.path.endsWith('.csv')) { next(); return; }
|
|
1048
|
+
// req.path comes from the URL, so it has to be checked before being used as a path
|
|
1049
|
+
const wanted = path.resolve(config.server.datadir, '.' + path.normalize(req.path));
|
|
1050
|
+
if (!wanted.startsWith(path.resolve(config.server.datadir) + path.sep)) { next(); return; }
|
|
1051
|
+
access(wanted, constants.R_OK, (err) => {
|
|
1052
|
+
if (!err) { next(); return; } // Not compressed - serve it in the ordinary way
|
|
1053
|
+
access(wanted + '.gz', constants.R_OK, (err1) => {
|
|
1054
|
+
if (err1) { next(); return; } // Neither exists - let express.static give the 404
|
|
1055
|
+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
1056
|
+
if (req.acceptsEncodings('gzip')) {
|
|
1057
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
1058
|
+
createReadStream(wanted + '.gz').pipe(res);
|
|
1059
|
+
} else {
|
|
1060
|
+
createReadStream(wanted + '.gz').pipe(createGunzip()).pipe(res);
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
},
|
|
999
1065
|
express.static(config.server.datadir, {immutable: false})
|
|
1000
1066
|
);
|
|
1001
1067
|
|
package/lib/api-routes.js
CHANGED
|
@@ -76,6 +76,9 @@ export function createAPIRouter(db, dataDir, loggerClient, pushManager = null) {
|
|
|
76
76
|
validateTimeRange(fromTime, toTime);
|
|
77
77
|
|
|
78
78
|
console.log("API fetching data from", device, fromTime*1000, toTime*1000);
|
|
79
|
+
// Recent readings are held in memory rather than written to disk as they arrive, so write
|
|
80
|
+
// them out first - otherwise a request covering "up to now" would stop a few minutes short
|
|
81
|
+
await loggerClient.flush();
|
|
79
82
|
// Load data from disk
|
|
80
83
|
const readings = await loadDeviceData(device, dataDir, fromTime, toTime);
|
|
81
84
|
|
package/lib/data-loader.js
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readdir, readFile, stat } from 'fs/promises';
|
|
7
|
+
import { gunzip as gunzipCallback } from 'zlib';
|
|
8
|
+
import { promisify } from 'util';
|
|
7
9
|
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
const gunzip = promisify(gunzipCallback);
|
|
8
12
|
import { parseDeviceId, toSenML, filterSenMLByTime } from './senml-utils.js';
|
|
9
13
|
import { APIError } from './api-errors.js';
|
|
10
14
|
|
|
@@ -29,7 +33,8 @@ function unixToDateString(unixTime) {
|
|
|
29
33
|
* @returns {number|null} Unix timestamp at start of day (UTC), or null if invalid
|
|
30
34
|
*/
|
|
31
35
|
function dateFilenameToUnix(filename) {
|
|
32
|
-
|
|
36
|
+
// ".csv.gz" as well as ".csv" - days gone by are compressed by lib/housekeeping.js
|
|
37
|
+
const match = filename.match(/^(\d{4})-(\d{2})-(\d{2})\.csv(\.gz)?$/);
|
|
33
38
|
if (!match) return null;
|
|
34
39
|
|
|
35
40
|
const year = parseInt(match[1]);
|
|
@@ -121,8 +126,8 @@ export async function loadDeviceData(deviceId, dataDir, fromTime, toTime) {
|
|
|
121
126
|
// console.log(`[loadDeviceData] Field ${module}/${field} has files: ${files.join(', ')}`);
|
|
122
127
|
|
|
123
128
|
for (const file of files) {
|
|
124
|
-
// Only process .csv
|
|
125
|
-
if (!file.endsWith('.csv')) continue;
|
|
129
|
+
// Only process reading files - ".csv", or ".csv.gz" once housekeeping has been over them
|
|
130
|
+
if (!file.endsWith('.csv') && !file.endsWith('.csv.gz')) continue;
|
|
126
131
|
|
|
127
132
|
// Parse date from filename and check if it's in range
|
|
128
133
|
const fileUnixTime = dateFilenameToUnix(file);
|
|
@@ -150,7 +155,9 @@ export async function loadDeviceData(deviceId, dataDir, fromTime, toTime) {
|
|
|
150
155
|
const filePath = join(fieldPath, file);
|
|
151
156
|
|
|
152
157
|
try {
|
|
153
|
-
const content =
|
|
158
|
+
const content = file.endsWith('.gz')
|
|
159
|
+
? (await gunzip(await readFile(filePath))).toString('utf-8')
|
|
160
|
+
: await readFile(filePath, 'utf-8');
|
|
154
161
|
const lines = content.trim().split('\n');
|
|
155
162
|
// console.log(`[loadDeviceData] File ${file} has ${lines.length} lines`);
|
|
156
163
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Housekeeping for the data directory.
|
|
3
|
+
*
|
|
4
|
+
* Readings accumulate one file per topic per day and nothing ever removed them, so a server left
|
|
5
|
+
* running eventually fills its disk - which on a field node running from an SD card takes the whole
|
|
6
|
+
* machine down, and is a good deal more likely than the card wearing out.
|
|
7
|
+
*
|
|
8
|
+
* Two things happen here, once a day:
|
|
9
|
+
*
|
|
10
|
+
* - Days older than "compressafterdays" are gzipped. A day's file never changes once the day is
|
|
11
|
+
* over, and they compress to about a third. Nothing needs to know: the server serves a
|
|
12
|
+
* ".csv.gz" in answer to a request for the ".csv", and lib/data-loader.js reads either.
|
|
13
|
+
*
|
|
14
|
+
* - Days older than "deleteafterdays" are deleted, and separately, if the disk has less than
|
|
15
|
+
* "freespacepercent" free, the oldest days are deleted until it has. Deleting is off unless
|
|
16
|
+
* asked for, except the free space rule, which is there to keep the machine alive.
|
|
17
|
+
*
|
|
18
|
+
* Today's file and yesterday's are never touched, whatever the settings say - the logger is still
|
|
19
|
+
* appending to them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createReadStream, createWriteStream, readdir, rename, stat, statfs, unlink, readFile, writeFile } from 'fs';
|
|
23
|
+
import { createGzip, gunzip, gzip } from 'zlib';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import { each, eachSeries, waterfall } from 'async';
|
|
26
|
+
|
|
27
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
// A file for a day still being written to must never be compressed or deleted. Two days of margin
|
|
29
|
+
// covers the server and the reader disagreeing about the timezone, and a node with a slow clock.
|
|
30
|
+
const MINIMUM_AGE_DAYS = 2;
|
|
31
|
+
|
|
32
|
+
// Day-stamped reading files, e.g. "2026-08-19.csv" or "2026-08-19.csv.gz"
|
|
33
|
+
const DATAFILE = /^(\d{4}-\d{2}-\d{2})\.csv(\.gz)?$/;
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Every reading file under a directory, with the day it holds and whether it is compressed.
|
|
37
|
+
* callback(err, [ {filepath, day, ageDays, compressed, size} ])
|
|
38
|
+
*/
|
|
39
|
+
function findDataFiles(dir, callback) {
|
|
40
|
+
let found = [];
|
|
41
|
+
let today = Date.now();
|
|
42
|
+
function walk(d, cb) {
|
|
43
|
+
readdir(d, {withFileTypes: true}, (err, entries) => {
|
|
44
|
+
if (err) {
|
|
45
|
+
// A directory disappearing under us is not worth stopping for
|
|
46
|
+
if (err.code === 'ENOENT') { cb(null); } else { cb(err); }
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
each(entries, (entry, cb1) => {
|
|
50
|
+
let full = path.join(d, entry.name);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
walk(full, cb1);
|
|
53
|
+
} else {
|
|
54
|
+
let m = entry.name.match(DATAFILE);
|
|
55
|
+
if (!m) { cb1(null); return; }
|
|
56
|
+
stat(full, (err1, st) => {
|
|
57
|
+
if (err1) { cb1(null); return; } // Gone, or unreadable - leave it alone
|
|
58
|
+
found.push({
|
|
59
|
+
filepath: full,
|
|
60
|
+
day: m[1],
|
|
61
|
+
ageDays: (today - Date.parse(m[1] + 'T00:00:00Z')) / DAY_MS,
|
|
62
|
+
compressed: !!m[2],
|
|
63
|
+
size: st.size,
|
|
64
|
+
});
|
|
65
|
+
cb1(null);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}, cb);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
walk(dir, (err) => callback(err, found));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* Compress one day's file. If a compressed file for that day already exists - which happens when a
|
|
76
|
+
* node with a wrong clock reports a reading for a day already dealt with - the two are joined into
|
|
77
|
+
* one, because a request for that day is answered with a single file.
|
|
78
|
+
*/
|
|
79
|
+
function compressFile(file, callback) {
|
|
80
|
+
let gzpath = file.filepath + '.gz';
|
|
81
|
+
let tmppath = gzpath + '.tmp';
|
|
82
|
+
stat(gzpath, (err) => {
|
|
83
|
+
if (err) {
|
|
84
|
+
// The ordinary case: nothing compressed for this day yet, so stream it out. Streaming rather
|
|
85
|
+
// than reading it all in keeps this affordable on a machine with 512 MB.
|
|
86
|
+
let source = createReadStream(file.filepath);
|
|
87
|
+
let destination = createWriteStream(tmppath);
|
|
88
|
+
source.on('error', callback);
|
|
89
|
+
destination.on('error', callback);
|
|
90
|
+
destination.on('finish', () => {
|
|
91
|
+
// Only once the compressed copy is complete on disk is the original removed, so an
|
|
92
|
+
// interruption anywhere in here leaves the readings intact in one file or the other
|
|
93
|
+
rename(tmppath, gzpath, (err1) => {
|
|
94
|
+
if (err1) { callback(err1); return; }
|
|
95
|
+
unlink(file.filepath, callback);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
source.pipe(createGzip()).pipe(destination);
|
|
99
|
+
} else {
|
|
100
|
+
// Rare: join the existing compressed day and the new rows into a single compressed file
|
|
101
|
+
waterfall([
|
|
102
|
+
(cb) => readFile(gzpath, cb),
|
|
103
|
+
(gzdata, cb) => gunzip(gzdata, cb),
|
|
104
|
+
(existing, cb) => readFile(file.filepath, (err1, added) => cb(err1, existing, added)),
|
|
105
|
+
(existing, added, cb) => gzip(Buffer.concat([existing, added]), cb),
|
|
106
|
+
(combined, cb) => writeFile(tmppath, combined, cb),
|
|
107
|
+
(cb) => rename(tmppath, gzpath, cb),
|
|
108
|
+
(cb) => unlink(file.filepath, cb),
|
|
109
|
+
], callback);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* Compress every day old enough to be finished with. One file at a time: this runs on a machine
|
|
116
|
+
* with one core, and there is nothing waiting on it.
|
|
117
|
+
*/
|
|
118
|
+
function compressOldFiles(files, afterDays, callback) {
|
|
119
|
+
let due = files.filter((f) => !f.compressed && (f.ageDays >= Math.max(afterDays, MINIMUM_AGE_DAYS)));
|
|
120
|
+
if (!due.length) { callback(null, 0); return; }
|
|
121
|
+
let done = 0;
|
|
122
|
+
eachSeries(due, (f, cb) => {
|
|
123
|
+
compressFile(f, (err) => {
|
|
124
|
+
if (err) { console.error("Housekeeping could not compress", f.filepath, "-", err.message); } else { done++; }
|
|
125
|
+
cb(null); // One failure should not stop the others
|
|
126
|
+
});
|
|
127
|
+
}, (err) => callback(err, done));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/*
|
|
131
|
+
* Delete whole days, oldest first, either because they are older than asked for or because the disk
|
|
132
|
+
* is filling up. Never touches a day recent enough that the logger might still be writing to it.
|
|
133
|
+
*/
|
|
134
|
+
function deleteFiles(files, callback) {
|
|
135
|
+
let removed = 0;
|
|
136
|
+
let freed = 0;
|
|
137
|
+
eachSeries(files, (f, cb) => {
|
|
138
|
+
unlink(f.filepath, (err) => {
|
|
139
|
+
if (err) { console.error("Housekeeping could not delete", f.filepath, "-", err.message); }
|
|
140
|
+
else { removed++; freed += f.size; }
|
|
141
|
+
cb(null);
|
|
142
|
+
});
|
|
143
|
+
}, (err) => callback(err, removed, freed));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
* How much of the filesystem holding "dir" is free, as a percentage, or null if it cannot be told.
|
|
148
|
+
*/
|
|
149
|
+
function freePercent(dir, callback) {
|
|
150
|
+
if (typeof statfs !== 'function') { callback(null, null); return; } // Older node
|
|
151
|
+
statfs(dir, (err, st) => {
|
|
152
|
+
if (err || !st || !st.blocks) { callback(null, null); return; }
|
|
153
|
+
callback(null, (st.bavail / st.blocks) * 100);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* One pass. Exported so it can be run on demand and tested without waiting a day.
|
|
159
|
+
* config: { compressafterdays, deleteafterdays, freespacepercent }
|
|
160
|
+
* callback(err, summary)
|
|
161
|
+
*/
|
|
162
|
+
export function housekeep(datadir, config, callback) {
|
|
163
|
+
callback = callback || (() => {});
|
|
164
|
+
let compressAfter = (config.compressafterdays === undefined) ? 2 : config.compressafterdays;
|
|
165
|
+
let deleteAfter = config.deleteafterdays || 0; // 0 = keep readings for ever
|
|
166
|
+
let freeWanted = (config.freespacepercent === undefined) ? 10 : config.freespacepercent;
|
|
167
|
+
|
|
168
|
+
findDataFiles(datadir, (err, files) => {
|
|
169
|
+
if (err) { console.error("Housekeeping could not read", datadir, "-", err.message); callback(err); return; }
|
|
170
|
+
if (!files.length) { callback(null, {files: 0}); return; }
|
|
171
|
+
|
|
172
|
+
// Old enough that the logger has certainly finished with it
|
|
173
|
+
let finished = files.filter((f) => f.ageDays >= MINIMUM_AGE_DAYS);
|
|
174
|
+
// Oldest first, so that freeing space takes the least useful days
|
|
175
|
+
finished.sort((a, b) => (a.day < b.day) ? -1 : (a.day > b.day) ? 1 : 0);
|
|
176
|
+
|
|
177
|
+
let byAge = deleteAfter ? finished.filter((f) => f.ageDays >= deleteAfter) : [];
|
|
178
|
+
|
|
179
|
+
deleteFiles(byAge, (err1, removedByAge, freedByAge) => {
|
|
180
|
+
// A Set, not "byAge.includes": there can be tens of thousands of these files
|
|
181
|
+
let gone = new Set(byAge.map((f) => f.filepath));
|
|
182
|
+
let left = finished.filter((f) => !gone.has(f.filepath));
|
|
183
|
+
freePercent(datadir, (err2, free) => {
|
|
184
|
+
// Nothing to do about the disk if the amount free cannot be established
|
|
185
|
+
if ((free === null) || (free >= freeWanted)) {
|
|
186
|
+
finish(removedByAge, freedByAge, 0, free);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Delete oldest days until there is enough room. The shortfall is worked out in bytes so
|
|
190
|
+
// that the number of days removed matches what is actually needed.
|
|
191
|
+
console.log(`Housekeeping: ${free.toFixed(1)}% of the disk free, below the ${freeWanted}% wanted - removing the oldest readings`);
|
|
192
|
+
statfs(datadir, (err3, st) => {
|
|
193
|
+
let shortfall = (err3 || !st) ? 0 : ((freeWanted / 100) * st.blocks - st.bavail) * st.bsize;
|
|
194
|
+
let toRemove = [];
|
|
195
|
+
let planned = 0;
|
|
196
|
+
for (let f of left) {
|
|
197
|
+
if (planned >= shortfall) break;
|
|
198
|
+
toRemove.push(f);
|
|
199
|
+
planned += f.size;
|
|
200
|
+
}
|
|
201
|
+
deleteFiles(toRemove, (err4, removedForSpace, freedForSpace) => {
|
|
202
|
+
if (toRemove.length === left.length) {
|
|
203
|
+
console.error("Housekeeping deleted every reading it was allowed to and the disk is still short of space");
|
|
204
|
+
}
|
|
205
|
+
finish(removedByAge, freedByAge + freedForSpace, removedForSpace, free);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
function finish(removedByAge, freed, removedForSpace, free) {
|
|
211
|
+
// Compress last, so that anything about to be deleted is not compressed first for nothing
|
|
212
|
+
findDataFiles(datadir, (err5, remaining) => {
|
|
213
|
+
compressOldFiles(remaining || [], compressAfter, (err6, compressed) => {
|
|
214
|
+
let summary = {
|
|
215
|
+
files: files.length,
|
|
216
|
+
compressed: compressed,
|
|
217
|
+
deletedByAge: removedByAge,
|
|
218
|
+
deletedForSpace: removedForSpace,
|
|
219
|
+
freedBytes: freed,
|
|
220
|
+
freePercent: free,
|
|
221
|
+
};
|
|
222
|
+
if (compressed || removedByAge || removedForSpace) {
|
|
223
|
+
console.log("Housekeeping:", compressed, "day(s) compressed,",
|
|
224
|
+
removedByAge + removedForSpace, "deleted,",
|
|
225
|
+
(freed / 1048576).toFixed(1), "MB freed");
|
|
226
|
+
}
|
|
227
|
+
callback(null, summary);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/*
|
|
236
|
+
* Run it now and then once a day. Called from the server at startup.
|
|
237
|
+
* The first pass is delayed, because startup is the busiest moment on a small board and none of
|
|
238
|
+
* this is urgent.
|
|
239
|
+
*/
|
|
240
|
+
export function startHousekeeping(datadir, config) {
|
|
241
|
+
config = config || {};
|
|
242
|
+
if (config.enabled === false) {
|
|
243
|
+
console.log("Housekeeping of", datadir, "turned off in config.d/server.yaml");
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
let run = () => housekeep(datadir, config);
|
|
247
|
+
setTimeout(run, 5 * 60 * 1000).unref();
|
|
248
|
+
let timer = setInterval(run, DAY_MS);
|
|
249
|
+
timer.unref(); // Never a reason to keep the server alive just for this
|
|
250
|
+
return timer;
|
|
251
|
+
}
|
package/lib/logger-client.js
CHANGED
|
@@ -267,6 +267,22 @@ export class LoggerClient {
|
|
|
267
267
|
const cacheKey = `${org}/${project}/${deviceId}`;
|
|
268
268
|
this.schemaCache.delete(cacheKey);
|
|
269
269
|
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Write out readings the logger is still holding in memory.
|
|
273
|
+
*
|
|
274
|
+
* The logger collects readings and writes them in batches, because writing each one as it
|
|
275
|
+
* arrives wears out an SD card. Call this before reading the data back, or the most recent
|
|
276
|
+
* few minutes will be missing from the files.
|
|
277
|
+
*
|
|
278
|
+
* @returns {Promise<void>} Resolves once everything waiting has been written
|
|
279
|
+
*/
|
|
280
|
+
flush() {
|
|
281
|
+
// The guard is for an installation whose logger predates flush() - better to answer with data a
|
|
282
|
+
// few minutes out of date than to fail the request
|
|
283
|
+
if (!this.mqttLogger.flush) return Promise.resolve();
|
|
284
|
+
return new Promise((resolve) => this.mqttLogger.flush(resolve));
|
|
285
|
+
}
|
|
270
286
|
}
|
|
271
287
|
|
|
272
288
|
/**
|
package/package.json
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@gebsl/senml-js": "^1.1.0",
|
|
10
10
|
"array.prototype.tospliced": "^1.1.5",
|
|
11
|
+
"async": "^3.2.6",
|
|
11
12
|
"cookie-parser": "^1.4.7",
|
|
12
13
|
"express": "^5.2.1",
|
|
13
14
|
"express-session": "^1.19.0",
|
|
14
|
-
"frugal-iot-client": "^1.3.
|
|
15
|
-
"frugal-iot-logger": "^1.1.
|
|
15
|
+
"frugal-iot-client": "^1.3.12",
|
|
16
|
+
"frugal-iot-logger": "^1.1.18",
|
|
16
17
|
"hash-wasm": "^4.12.0",
|
|
18
|
+
"js-yaml": "^4.2.0",
|
|
17
19
|
"morgan": "^1.11.0",
|
|
18
20
|
"multer": "^2.1.1",
|
|
19
21
|
"passport": "^0.7.0",
|
|
@@ -24,7 +26,8 @@
|
|
|
24
26
|
"frugal-iot-server": "frugal-iot-server.js",
|
|
25
27
|
"frugal-iot-init": "scripts/init.zsh",
|
|
26
28
|
"frugal-iot-addorganization": "scripts/addorganization.zsh",
|
|
27
|
-
"frugal-iot-setpassword": "scripts/setpassword.zsh"
|
|
29
|
+
"frugal-iot-setpassword": "scripts/setpassword.zsh",
|
|
30
|
+
"frugal-iot-diagnostic": "scripts/diagnostic.zsh"
|
|
28
31
|
},
|
|
29
32
|
"description": "Frugal IoT client and server",
|
|
30
33
|
"//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",
|
|
@@ -63,8 +66,10 @@
|
|
|
63
66
|
"scripts": {
|
|
64
67
|
"test": "vitest run",
|
|
65
68
|
"test:watch": "vitest",
|
|
66
|
-
"test:coverage": "vitest run --coverage"
|
|
69
|
+
"test:coverage": "vitest run --coverage",
|
|
70
|
+
"check-schema": "node scripts/check-schema.js",
|
|
71
|
+
"prerelease": "zsh scripts/prerelease.zsh"
|
|
67
72
|
},
|
|
68
73
|
"type": "module",
|
|
69
|
-
"version": "0.3.
|
|
74
|
+
"version": "0.3.6"
|
|
70
75
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Change this version number if want existing installs of PWA to refresh their cache
|
|
2
2
|
// TODO-28 - probably make this the same as the app version in frugal-iot-client
|
|
3
|
-
const CACHE_NAME = 'frugal-iot-cache-1.3.
|
|
3
|
+
const CACHE_NAME = 'frugal-iot-cache-1.3.12';
|
|
4
4
|
const urlsToCache = [
|
|
5
5
|
'/',
|
|
6
6
|
'/index.html',
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
#
|
|
3
3
|
# Add a new organization to Frugal IoT: config file, user account, permissions, and mqtt credentials.
|
|
4
4
|
# Run from the server's own directory, the one holding frugal-iot.db (all paths below are relative
|
|
5
|
-
# to there) - prepare it first with "npx frugal-iot-init".
|
|
5
|
+
# to there) - prepare it first with "npx --no frugal-iot-init".
|
|
6
6
|
#
|
|
7
7
|
# Usage:
|
|
8
|
-
# npx frugal-iot-addorganization <org-id> <org-name> <email> <phone> <password>
|
|
8
|
+
# npx --no frugal-iot-addorganization <org-id> <org-name> <email> <phone> <password>
|
|
9
9
|
# scripts/addorganization.zsh <org-id> <org-name> <email> <phone> <password>
|
|
10
10
|
# Example:
|
|
11
|
-
# npx frugal-iot-addorganization abc "Clever People" foo@abc.org +61123456 "secret!123"
|
|
11
|
+
# npx --no frugal-iot-addorganization abc "Clever People" foo@abc.org +61123456 "secret!123"
|
|
12
12
|
|
|
13
13
|
set -euo pipefail
|
|
14
14
|
|
|
@@ -59,7 +59,7 @@ fi
|
|
|
59
59
|
|
|
60
60
|
if [[ ! -f "$DB" ]]; then
|
|
61
61
|
echo "Error: database ${DB} not found in this directory - prepare the directory first with:" >&2
|
|
62
|
-
echo " npx frugal-iot-init" >&2
|
|
62
|
+
echo " npx --no frugal-iot-init" >&2
|
|
63
63
|
exit 1
|
|
64
64
|
fi
|
|
65
65
|
|
|
@@ -75,13 +75,50 @@ if ! command -v mosquitto_passwd >/dev/null; then
|
|
|
75
75
|
MOSQUITTO_PASSWD_MISSING=1
|
|
76
76
|
fi
|
|
77
77
|
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
78
|
+
# Ask the broker's own configuration where its password file is, rather than guessing - that way
|
|
79
|
+
# this works whatever layout a machine uses, including installations older than this script.
|
|
80
|
+
# Mosquitto reads /etc/mosquitto/mosquitto.conf and then the .conf files in conf.d, last one wins.
|
|
81
|
+
MOSQUITTO_PASSWD_FILE=$(grep -hE '^[[:space:]]*password_file[[:space:]]' \
|
|
82
|
+
/etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N) 2>/dev/null | tail -1 | awk '{print $2}')
|
|
81
83
|
USED_LOCAL_MOSQUITTO_FILE=0
|
|
82
|
-
if [[
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
if [[ -n "$MOSQUITTO_PASSWD_FILE" ]]; then
|
|
85
|
+
MOSQUITTO_PASSWD_FROM_CONFIG=1
|
|
86
|
+
else
|
|
87
|
+
MOSQUITTO_PASSWD_FROM_CONFIG=0
|
|
88
|
+
# No mosquitto configuration here - fall back to the usual locations, and then to the copy under
|
|
89
|
+
# extras/ so that a machine without mosquitto installed still gets something useful.
|
|
90
|
+
for f in /var/lib/mosquitto/passwords /etc/mosquitto/mosquitto_passwords; do
|
|
91
|
+
if [[ -e "$f" ]]; then
|
|
92
|
+
MOSQUITTO_PASSWD_FILE="$f"
|
|
93
|
+
break
|
|
94
|
+
fi
|
|
95
|
+
done
|
|
96
|
+
if [[ -z "$MOSQUITTO_PASSWD_FILE" ]]; then
|
|
97
|
+
if [[ -e "extras/mosquitto_passwords" ]]; then
|
|
98
|
+
MOSQUITTO_PASSWD_FILE="extras/mosquitto_passwords"
|
|
99
|
+
USED_LOCAL_MOSQUITTO_FILE=1
|
|
100
|
+
else
|
|
101
|
+
MOSQUITTO_PASSWD_FILE="/var/lib/mosquitto/passwords"
|
|
102
|
+
fi
|
|
103
|
+
fi
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# Run mosquitto_passwd as whoever owns that file. Mosquitto warns unless the file belongs to the user
|
|
107
|
+
# opening it, and mosquitto_passwd writes a temporary backup beside it, so it needs the directory too
|
|
108
|
+
# - neither of which we get by running as ourselves against a file owned by mosquitto or by root.
|
|
109
|
+
# On Raspberry Pi OS the first user has passwordless sudo, so this is invisible.
|
|
110
|
+
MOSQUITTO_PASSWD_CMD=(mosquitto_passwd)
|
|
111
|
+
if [[ ! -w "${MOSQUITTO_PASSWD_FILE:h}" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MOSQUITTO_PASSWD_FILE" ) ]]; then
|
|
112
|
+
if command -v sudo >/dev/null; then
|
|
113
|
+
# stat's spelling differs between Linux (-c) and BSD/macOS (-f)
|
|
114
|
+
MOSQUITTO_PASSWD_OWNER=$(stat -c '%U' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
|
|
115
|
+
|| stat -f '%Su' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
|
|
116
|
+
if [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "$(id -un)" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]]; then
|
|
117
|
+
MOSQUITTO_PASSWD_CMD=(sudo -u "$MOSQUITTO_PASSWD_OWNER" mosquitto_passwd)
|
|
118
|
+
else
|
|
119
|
+
MOSQUITTO_PASSWD_CMD=(sudo mosquitto_passwd)
|
|
120
|
+
fi
|
|
121
|
+
fi
|
|
85
122
|
fi
|
|
86
123
|
|
|
87
124
|
# ---- Escaping helpers ----
|
|
@@ -168,17 +205,24 @@ MOSQUITTO_PASSWD_FAILED=0
|
|
|
168
205
|
MOSQUITTO_PASSWD_ERROR=""
|
|
169
206
|
if [[ "$MOSQUITTO_PASSWD_MISSING" -eq 1 ]]; then
|
|
170
207
|
echo "Warning: mosquitto_passwd command not found - skipped setting MQTT broker password" >&2
|
|
208
|
+
elif [[ "$MOSQUITTO_PASSWD_FROM_CONFIG" -eq 1 && ! -e "$MOSQUITTO_PASSWD_FILE" ]]; then
|
|
209
|
+
# Nothing to add an account to - and mosquitto will not be running either, for the same reason.
|
|
210
|
+
echo "Warning: mosquitto is configured to use ${MOSQUITTO_PASSWD_FILE}, but that file does not exist," >&2
|
|
211
|
+
echo "so the MQTT broker password was NOT set. Create it, then add this organization to it:" >&2
|
|
212
|
+
echo " sudo install -o mosquitto -g mosquitto -m 600 /dev/null \"${MOSQUITTO_PASSWD_FILE}\"" >&2
|
|
213
|
+
echo " sudo -u mosquitto mosquitto_passwd -b \"${MOSQUITTO_PASSWD_FILE}\" \"${ORG_ID}\" \"${PASSWORD}\"" >&2
|
|
214
|
+
MOSQUITTO_PASSWD_FAILED=1
|
|
171
215
|
else
|
|
172
216
|
# Capture stderr without letting `set -e` abort the whole script on failure - the org's config file
|
|
173
217
|
# and DB rows are already written by this point, so a failure here should be reported, not fatal.
|
|
174
|
-
MOSQUITTO_PASSWD_ERROR=$(
|
|
218
|
+
MOSQUITTO_PASSWD_ERROR=$("${MOSQUITTO_PASSWD_CMD[@]}" -b "$MOSQUITTO_PASSWD_FILE" "${ORG_ID}" "${PASSWORD}" 2>&1 >/dev/null) || MOSQUITTO_PASSWD_FAILED=1
|
|
175
219
|
if [[ "$MOSQUITTO_PASSWD_FAILED" -eq 1 ]]; then
|
|
176
220
|
echo "Warning: mosquitto_passwd failed to set the MQTT broker password:" >&2
|
|
177
221
|
echo " ${MOSQUITTO_PASSWD_ERROR}" >&2
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
222
|
+
echo "Everything else was done, so set just the broker password by hand:" >&2
|
|
223
|
+
echo " sudo mosquitto_passwd -b \"${MOSQUITTO_PASSWD_FILE}\" \"${ORG_ID}\" \"${PASSWORD}\"" >&2
|
|
224
|
+
echo "If it complains about creating a backup file, the directory holding that file is not" >&2
|
|
225
|
+
echo "writable by you - which is why the command above uses sudo." >&2
|
|
182
226
|
else
|
|
183
227
|
echo "Set mosquitto password for ${ORG_ID} in ${MOSQUITTO_PASSWD_FILE}"
|
|
184
228
|
fi
|