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.
@@ -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
- if (!config.morgan) {
682
- config.morgan = ':method :url :req[range] :status :res[content-length] :response-time ms :req[referer]'
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
- (req,res,next) => { console.log("/data handler authenticated by session for", req.url); next(); },
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
 
@@ -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
- const match = filename.match(/^(\d{4})-(\d{2})-(\d{2})\.csv$/);
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 files
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 = await readFile(filePath, 'utf-8');
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
+ }
@@ -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.11",
15
- "frugal-iot-logger": "^1.1.17",
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.4"
74
+ "version": "0.3.6"
70
75
  }
@@ -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.11';
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
- # Prefer the real system password file; if it's not present (e.g. mosquitto isn't installed on this
79
- # machine), fall back to editing the local copy under extras/ so the script still does something useful.
80
- MOSQUITTO_PASSWD_FILE="/etc/mosquitto/mosquitto_passwords"
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 [[ ! -e "$MOSQUITTO_PASSWD_FILE" && -e "extras/mosquitto_passwords" ]]; then
83
- MOSQUITTO_PASSWD_FILE="extras/mosquitto_passwords"
84
- USED_LOCAL_MOSQUITTO_FILE=1
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=$(mosquitto_passwd -b "$MOSQUITTO_PASSWD_FILE" "${ORG_ID}" "${PASSWORD}" 2>&1 >/dev/null) || MOSQUITTO_PASSWD_FAILED=1
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
- if [[ "$MOSQUITTO_PASSWD_ERROR" == *"Permission denied"* || ! -w "$MOSQUITTO_PASSWD_FILE" ]]; then
179
- echo "This looks like a permissions problem - re-run that command with sudo:" >&2
180
- echo " sudo mosquitto_passwd -b \"${MOSQUITTO_PASSWD_FILE}\" \"${ORG_ID}\" \"${PASSWORD}\"" >&2
181
- fi
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