frugal-iot-server 2.0.8 → 2.0.10
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/README.md +3 -3
- package/config.d/email.yaml +1 -1
- package/config.d/opensearch.yaml +19 -15
- package/config.d/schema/modules.yaml +13 -0
- package/config.d/schema/topics.yaml +50 -0
- package/{GRAFANA.md → docs/GRAFANA.md} +37 -6
- package/{INSTALL-OPENSEARCH.md → docs/INSTALL-OPENSEARCH.md} +183 -97
- package/docs/OPENWRT.md +208 -0
- package/extras/frugaliot.service +1 -1
- package/extras/mosquitto.conf +1 -1
- package/frugal-iot-createdb.sql +10 -1
- package/frugal-iot-server.js +28 -7
- package/lib/forwarders.js +49 -10
- package/lib/grafana.js +2 -2
- package/lib/language-requests.js +50 -0
- package/lib/opensearch-access.js +9 -2
- package/package.json +7 -6
- package/public/service-worker.js +10 -2
- package/scripts/addbridge-pi.zsh +1 -1
- package/scripts/addbridge-prod.zsh +2 -2
- package/scripts/addorganization.zsh +11 -2
- package/scripts/diagnostic.zsh +4 -4
- package/scripts/grafana-token.js +1 -1
- package/scripts/init.zsh +5 -1
- package/scripts/install-openwrt.sh +709 -0
- package/scripts/install-pi.sh +46 -12
- package/scripts/openwrt-preflight.sh +363 -0
- /package/{INSTALLATION.md → docs/INSTALLATION.md} +0 -0
package/frugal-iot-createdb.sql
CHANGED
|
@@ -108,7 +108,7 @@ CREATE TABLE IF NOT EXISTS `forwarders` (
|
|
|
108
108
|
CREATE INDEX IF NOT EXISTS `forwarders_org` ON `forwarders` (`org`);
|
|
109
109
|
|
|
110
110
|
-- A credential for Grafana (or anything else that reads history over HTTP), see lib/grafana.js and
|
|
111
|
-
-- GRAFANA.md. Read-only, and scoped to one organization: it can fetch that organization's readings
|
|
111
|
+
-- docs/GRAFANA.md. Read-only, and scoped to one organization: it can fetch that organization's readings
|
|
112
112
|
-- through /grafana/:org/ and nothing else.
|
|
113
113
|
--
|
|
114
114
|
-- Only a SHA-256 of the token is kept, so a copy of this database does not hand out working
|
|
@@ -269,3 +269,12 @@ CREATE TABLE IF NOT EXISTS `node_attempts` (
|
|
|
269
269
|
`count` INTEGER NOT NULL DEFAULT 1,
|
|
270
270
|
UNIQUE(`org`, `nodeid`)
|
|
271
271
|
);
|
|
272
|
+
|
|
273
|
+
-- Which languages browsers ask for, counted at each sign-in, to choose what to translate next - see
|
|
274
|
+
-- lib/language-requests.js. Codes only: nothing records who asked.
|
|
275
|
+
CREATE TABLE IF NOT EXISTS `language_requests` (
|
|
276
|
+
`code` TEXT PRIMARY KEY, -- lower-case, as the browser sent it: hi-in, pt-br
|
|
277
|
+
`count` INTEGER NOT NULL,
|
|
278
|
+
`last_seen` INTEGER NOT NULL,
|
|
279
|
+
`supported` INTEGER NOT NULL -- 1 if the client had a file for it at the last count
|
|
280
|
+
);
|
package/frugal-iot-server.js
CHANGED
|
@@ -101,7 +101,7 @@ import morgan from 'morgan'; // https://www.npmjs.com/package/morgan - http requ
|
|
|
101
101
|
|
|
102
102
|
// If you are developing comment out the Production line, and uncomment the Development line
|
|
103
103
|
// Production
|
|
104
|
-
import { MqttLogger } from "frugal-iot-logger"; // https://github.com/mitra42/frugal-iot-logger
|
|
104
|
+
import { MqttLogger, backfillOpenSearch } from "frugal-iot-logger"; // https://github.com/mitra42/frugal-iot-logger
|
|
105
105
|
// Development of Logger
|
|
106
106
|
// import { MqttLogger } from "../frugal-iot-logger/index.js"; // https://github.com/mitra42/frugal-iot-logger
|
|
107
107
|
|
|
@@ -140,6 +140,7 @@ import crypto from 'crypto'; /* https://nodejs.org/api/crypto.html */
|
|
|
140
140
|
import {waterfall, each} from 'async';
|
|
141
141
|
import { mailInit, mailConfigured, sendMail } from './lib/mailer.js';
|
|
142
142
|
import { resetCodeMake, resetCodeCheck, resetRateOk } from './lib/resetcode.js';
|
|
143
|
+
import { countLanguages, parseBrowserLanguages } from './lib/language-requests.js';
|
|
143
144
|
// import { openDB } from 'sqlite-express-package'; /* appContent, appSelect, validateId, validateAlias, tagCloud, atom, rss,*/
|
|
144
145
|
|
|
145
146
|
export let config; // Live binding - lib/api-routes.js reads the current value at request time
|
|
@@ -155,8 +156,16 @@ function loginRedirect(res, { mode = 'signin', message, messagetype, url }) {
|
|
|
155
156
|
if (message) { q.set('message', message); }
|
|
156
157
|
if (messagetype) { q.set('messagetype', messagetype); }
|
|
157
158
|
if (url) { q.set('url', url); } // Encoded here: a return url usually has a query string of its own
|
|
159
|
+
const lang = langOf(url);
|
|
160
|
+
if (lang) { q.set('lang', lang); }
|
|
158
161
|
res.redirect(`${loginUrl}?${q}`);
|
|
159
162
|
}
|
|
163
|
+
// The language of the page being returned to. login.html reads only its own ?lang=, and without it
|
|
164
|
+
// falls back to the browser's language rather than the one picked before signing in.
|
|
165
|
+
function langOf(url) {
|
|
166
|
+
if (!url) return null;
|
|
167
|
+
try { return new URL(url, 'http://localhost').searchParams.get('lang'); } catch (e) { return null; }
|
|
168
|
+
}
|
|
160
169
|
// A password reset link carries its token in the query string, and everything that logs a URL goes
|
|
161
170
|
// to the journal, which outlives the ten minutes the token is good for.
|
|
162
171
|
function redactUrl(url) {
|
|
@@ -1241,13 +1250,13 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1241
1250
|
app.use('/api', apiRouter);
|
|
1242
1251
|
console.log("Mounted API routes at /api");
|
|
1243
1252
|
|
|
1244
|
-
// History for Grafana, by bearer token - see lib/grafana.js and GRAFANA.md
|
|
1253
|
+
// History for Grafana, by bearer token - see lib/grafana.js and docs/GRAFANA.md
|
|
1245
1254
|
const grafana = createGrafanaRouters(db, config.server.datadir, loggerClient, { loggedInOrFail, can_READ });
|
|
1246
1255
|
app.use('/grafana', grafana.data);
|
|
1247
1256
|
app.use('/grafana_token', grafana.admin);
|
|
1248
1257
|
|
|
1249
1258
|
// Where each organization's readings are forwarded, edited from the admin tab - lib/forwarders.js
|
|
1250
|
-
app.use('/forwarders', createForwardersRouter(db, mqttLogger, () => config.organizations || {}, { loggedInOrFail, can_ADMIN }, { localOpenSearch: () => config.opensearch }));
|
|
1259
|
+
app.use('/forwarders', createForwardersRouter(db, mqttLogger, () => config.organizations || {}, { loggedInOrFail, can_ADMIN }, { localOpenSearch: () => config.opensearch, backfill: backfillOpenSearch, dataDir: config.server.datadir }));
|
|
1251
1260
|
app.use('/opensearch_access', createAccessRouter(() => config.organizations || {}, { loggedInOrFail, can_READ }, () => config.opensearch));
|
|
1252
1261
|
console.log("Mounted Grafana routes at /grafana");
|
|
1253
1262
|
|
|
@@ -1273,21 +1282,33 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1273
1282
|
(req,res,next) => {
|
|
1274
1283
|
console.log("Trying to login with redirect to",req.body.url);
|
|
1275
1284
|
// This is ugly, but I cannot see how to pass the URL to passport.authenticate options
|
|
1276
|
-
// Default the destination rather than trusting every caller to supply one
|
|
1277
|
-
//
|
|
1278
|
-
// "Cannot POST /login" - a confusing way to report a missing form field.
|
|
1285
|
+
// Default the destination rather than trusting every caller to supply one - a missing
|
|
1286
|
+
// form field should still land somewhere, here and in the success handler below.
|
|
1279
1287
|
const returnTo = req.body.url || '/dashboard';
|
|
1280
1288
|
const failureQuery = new URLSearchParams({
|
|
1281
1289
|
mode: 'signin', messagetype: 'error',
|
|
1282
1290
|
message: 'Incorrect username or password', url: returnTo });
|
|
1291
|
+
const lang = langOf(returnTo);
|
|
1292
|
+
if (lang) { failureQuery.set('lang', lang); }
|
|
1283
1293
|
passport.authenticate('local', {
|
|
1284
1294
|
session: true,
|
|
1285
1295
|
//failWithError: true,
|
|
1286
1296
|
failureRedirect: `${loginUrl}?${failureQuery}`,
|
|
1287
|
-
successRedirect:
|
|
1297
|
+
// No successRedirect: on success passport calls next(), which counts and then redirects
|
|
1288
1298
|
// In failure case will also be messages in the session which need clearing out TODO-N89
|
|
1289
1299
|
//failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${req.body.url}`,
|
|
1290
1300
|
})(req, res, next);
|
|
1301
|
+
},
|
|
1302
|
+
// Counted only once signed in, so failed attempts cannot pad the numbers. Not waited for.
|
|
1303
|
+
(req, res) => {
|
|
1304
|
+
const codes = parseBrowserLanguages(req.body.browserlangs);
|
|
1305
|
+
if (codes.length) {
|
|
1306
|
+
console.log("Browser languages", codes.join(','));
|
|
1307
|
+
countLanguages(db, config.server.htmldir, codes, (err) => {
|
|
1308
|
+
if (err) console.error("Could not count browser languages:", err.message);
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
res.redirect(req.body.url || '/dashboard');
|
|
1291
1312
|
}
|
|
1292
1313
|
);
|
|
1293
1314
|
app.post('/register', (req, res) => {
|
package/lib/forwarders.js
CHANGED
|
@@ -212,15 +212,17 @@ export function typesFor(localOpenSearch) {
|
|
|
212
212
|
// configuration (config.d/opensearch.yaml), so those never pass through the browser or the database.
|
|
213
213
|
// A local row on a server that no longer has one is left out.
|
|
214
214
|
export async function enabledConfigs(db, org, localOpenSearch) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
return
|
|
215
|
+
return (await listRows(db, org)).filter((x) => x.enabled)
|
|
216
|
+
.map((r) => loggerConfig(r, localOpenSearch)).filter(Boolean);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// One row as the logger takes it, or undefined for a local row on a server that no longer has one
|
|
220
|
+
export function loggerConfig(row, localOpenSearch) {
|
|
221
|
+
const config = JSON.parse(row.config);
|
|
222
|
+
if (row.type !== 'opensearch-local') return { type: row.type, ...config };
|
|
223
|
+
if (!localOpenSearch || !localOpenSearch.node) return undefined;
|
|
224
|
+
const { node, username, password, apiKey, insecure } = localOpenSearch;
|
|
225
|
+
return { type: 'opensearch', node, username, password, apiKey, insecure, ...config };
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
/**
|
|
@@ -229,8 +231,9 @@ export async function enabledConfigs(db, org, localOpenSearch) {
|
|
|
229
231
|
* @param orgs () => the organizations configuration, { org: {...} }
|
|
230
232
|
* @param auth { loggedInOrFail, can_ADMIN } from frugal-iot-server.js, passed in so tests can stand in
|
|
231
233
|
* @param localOpenSearch () => the server's own OpenSearch settings, or undefined (config.d/opensearch.yaml)
|
|
234
|
+
* @param backfill the logger's backfillOpenSearch, and dataDir where the readings are (tests stand in)
|
|
232
235
|
*/
|
|
233
|
-
export function createForwardersRouter(db, mqttLogger, orgs, auth, { lookup, localOpenSearch = () => undefined } = {}) {
|
|
236
|
+
export function createForwardersRouter(db, mqttLogger, orgs, auth, { lookup, localOpenSearch = () => undefined, backfill, dataDir } = {}) {
|
|
234
237
|
const router = Router();
|
|
235
238
|
// Whole-organization ADMIN: a forwarder sends every project's readings
|
|
236
239
|
const guard = [auth.loggedInOrFail, auth.can_ADMIN, orgWideAdmin];
|
|
@@ -308,5 +311,41 @@ export function createForwardersRouter(db, mqttLogger, orgs, auth, { lookup, loc
|
|
|
308
311
|
} catch (err) { next(err); }
|
|
309
312
|
});
|
|
310
313
|
|
|
314
|
+
// Sending the readings already on disk to an OpenSearch forwarder. It can take minutes, so the request
|
|
315
|
+
// only starts it; the card asks how it is getting on. One at a time per forwarder, and the last
|
|
316
|
+
// outcome is kept (in memory - it is gone at a restart) for the card to show.
|
|
317
|
+
const jobs = new Map();
|
|
318
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
319
|
+
|
|
320
|
+
router.post('/:org/backfill', ...guard, orgExists, jsonOnly, express.json({ limit: '1kb' }), async (req, res, next) => {
|
|
321
|
+
try {
|
|
322
|
+
const org = req.params.org;
|
|
323
|
+
if (!backfill || !dataDir) return fail(res, 501, 'This server cannot send earlier readings');
|
|
324
|
+
const { id, since, dryRun } = req.body || {};
|
|
325
|
+
if (since !== undefined && since !== '' && (!DAY.test(since) || Number.isNaN(Date.parse(since)) || new Date(since).toISOString().slice(0, 10) !== since)) return fail(res, 400, 'Give the date as YYYY-MM-DD');
|
|
326
|
+
const row = await get(db, 'SELECT * FROM forwarders WHERE id = ? AND org = ?', [id, org]);
|
|
327
|
+
if (!row) return fail(res, 404, 'No such forwarder');
|
|
328
|
+
if (!['opensearch', 'opensearch-local'].includes(row.type)) return fail(res, 400, 'Only OpenSearch forwarders can be sent earlier readings');
|
|
329
|
+
if (!row.enabled) return fail(res, 400, 'Make the forwarder active first');
|
|
330
|
+
const config = loggerConfig(row, localOpenSearch());
|
|
331
|
+
if (!config) return fail(res, 400, 'This server has no OpenSearch of its own any more');
|
|
332
|
+
const key = `${org}:${row.id}`;
|
|
333
|
+
if (jobs.has(key) && jobs.get(key).running) return fail(res, 409, 'Already sending - wait for it to finish');
|
|
334
|
+
const job = { running: true, sent: 0, dryRun: !!dryRun, since: since || null, startedAt: Date.now() };
|
|
335
|
+
jobs.set(key, job);
|
|
336
|
+
backfill({ config, org, dataDir, since: since || undefined, dryRun: !!dryRun, progress: (n) => { job.sent = n; } })
|
|
337
|
+
.then((r) => { job.sent = r.sent; job.skippedFiles = r.skippedFiles; })
|
|
338
|
+
.catch((err) => { job.error = err.message; console.error(`Backfill for ${org} failed:`, err); })
|
|
339
|
+
.finally(() => { job.running = false; job.finishedAt = Date.now(); });
|
|
340
|
+
console.log('Backfill started by', req.user.username, '-', org, JSON.stringify(row.name), since || 'all', dryRun ? 'dry run' : '');
|
|
341
|
+
res.status(202).json({ started: true });
|
|
342
|
+
} catch (err) { next(err); }
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
router.get('/:org/backfill/:id', ...guard, orgExists, (req, res) => {
|
|
346
|
+
res.set('Cache-Control', 'no-store');
|
|
347
|
+
res.json(jobs.get(`${req.params.org}:${req.params.id}`) || { running: false });
|
|
348
|
+
});
|
|
349
|
+
|
|
311
350
|
return router;
|
|
312
351
|
}
|
package/lib/grafana.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* GET /grafana_token/:org list names and when last used (yours; all, for an org-wide administrator)
|
|
22
22
|
* POST /grafana_token/:org/revoke {name} yours; any, for an org-wide administrator
|
|
23
23
|
*
|
|
24
|
-
* See GRAFANA.md for setting up Grafana to use these.
|
|
24
|
+
* See docs/GRAFANA.md for setting up Grafana to use these.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import crypto from 'crypto';
|
|
@@ -398,7 +398,7 @@ export function createGrafanaRouters(db, dataDir, loggerClient, auth) {
|
|
|
398
398
|
res.json(rows.map(({ user_id, ...t }) => ({ ...t, mine: user_id === Number(req.user.id) })));
|
|
399
399
|
} catch (err) { next(err); }
|
|
400
400
|
});
|
|
401
|
-
// The starter dashboard, to save and import into Grafana (GRAFANA.md). Nothing in it is secret.
|
|
401
|
+
// The starter dashboard, to save and import into Grafana (docs/GRAFANA.md). Nothing in it is secret.
|
|
402
402
|
admin.get('/:org/dashboard', auth.loggedInOrFail, auth.can_READ, async (req, res, next) => {
|
|
403
403
|
try {
|
|
404
404
|
const dash = await dashboardFor(req.params.org, String(req.query.server || ''));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which languages people's browsers ask for, counted at each sign-in, so the next language to
|
|
3
|
+
* translate is the one most asked for (frugal-iot-client docs/LANGUAGES_PLAN.md, Phase 3). Codes
|
|
4
|
+
* only - nothing records who asked.
|
|
5
|
+
*
|
|
6
|
+
* To read it: SELECT * FROM language_requests ORDER BY supported, count DESC;
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
// A browser sends a handful; anything longer is not a browser
|
|
12
|
+
const MAX_CODES = 10;
|
|
13
|
+
const CODE = /^[a-z]{2,3}(-[a-z0-9]{1,8}){0,3}$/;
|
|
14
|
+
// As languageAliases in the client's core.js: browsers call Tagalog "fil", and its file is tl.yaml
|
|
15
|
+
const ALIASES = { fil: 'tl' };
|
|
16
|
+
|
|
17
|
+
// The form field is untrusted: keep only what looks like a language tag, lower-cased, once each
|
|
18
|
+
export function parseBrowserLanguages(value) {
|
|
19
|
+
if (typeof value !== 'string') return [];
|
|
20
|
+
const codes = value.split(',').map((c) => c.trim().toLowerCase()).filter((c) => CODE.test(c));
|
|
21
|
+
return [...new Set(codes)].slice(0, MAX_CODES);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The client has a file per language it supports, so whether one exists is the answer, and the
|
|
25
|
+
// server keeps no list of its own. fr-CA is served by fr, as the client matches it; English is the
|
|
26
|
+
// keys themselves, so it has no file.
|
|
27
|
+
export function isSupported(htmldir, code) {
|
|
28
|
+
const primary = ALIASES[code.split('-')[0]] || code.split('-')[0];
|
|
29
|
+
if (primary === 'en') return true;
|
|
30
|
+
return [code, primary].some((c) => existsSync(join(htmldir, 'languages', `${c}.yaml`)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One upsert per code. cb(err) - a failure to count must never stand in the way of a login
|
|
34
|
+
export function countLanguages(db, htmldir, codes, cb = () => {}) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
let pending = codes.length;
|
|
37
|
+
let failed = null;
|
|
38
|
+
if (!pending) { cb(null); return; }
|
|
39
|
+
codes.forEach((code) => {
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO language_requests (code, count, last_seen, supported) VALUES (?, 1, ?, ?)
|
|
42
|
+
ON CONFLICT(code) DO UPDATE SET count = count + 1, last_seen = excluded.last_seen,
|
|
43
|
+
supported = excluded.supported`,
|
|
44
|
+
[code, now, isSupported(htmldir, code) ? 1 : 0],
|
|
45
|
+
(err) => {
|
|
46
|
+
failed = failed || err;
|
|
47
|
+
if (--pending === 0) cb(failed);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
package/lib/opensearch-access.js
CHANGED
|
@@ -60,12 +60,14 @@ export const namesFor = (org, username) => ({
|
|
|
60
60
|
export function roleFor(org) {
|
|
61
61
|
const n = namesFor(org);
|
|
62
62
|
return {
|
|
63
|
-
cluster_permissions: ['cluster_composite_ops', 'cluster:monitor/main', 'cluster:monitor/health'],
|
|
63
|
+
cluster_permissions: ['cluster_composite_ops', 'cluster:monitor/main', 'cluster:monitor/health', 'cluster:monitor/nodes/info', 'cluster:monitor/state'],
|
|
64
64
|
index_permissions: [
|
|
65
65
|
{ index_patterns: [n.dataPattern],
|
|
66
66
|
allowed_actions: ['read', 'indices:admin/get', 'indices:admin/mappings/get',
|
|
67
67
|
'indices:admin/resolve/index', 'indices:monitor/settings/get'] },
|
|
68
68
|
{ index_patterns: [n.savedIndex, `${n.savedIndex}_*`], allowed_actions: ['indices_all'] },
|
|
69
|
+
// Dashboards looks an alias up before it makes one, and OpenSearch checks that against every index
|
|
70
|
+
{ index_patterns: ['*'], allowed_actions: ['indices:admin/aliases/get'] },
|
|
69
71
|
],
|
|
70
72
|
};
|
|
71
73
|
}
|
|
@@ -156,7 +158,11 @@ export function dashboardsConfig(cfg, org, username, password) {
|
|
|
156
158
|
`opensearch.username: ${JSON.stringify(n.user)}`,
|
|
157
159
|
`opensearch.password: ${JSON.stringify(password)}`,
|
|
158
160
|
`opensearchDashboards.index: ${JSON.stringify(n.savedIndex)}`,
|
|
159
|
-
|
|
161
|
+
// Dashboards uses the login above only for its own housekeeping, and leaves it off requests made for a
|
|
162
|
+
// browser - which would then ask for one. A header set here goes on every request and a browser cannot
|
|
163
|
+
// override it, so nobody is asked.
|
|
164
|
+
'opensearch.requestHeadersAllowlist: []',
|
|
165
|
+
`opensearch.customHeaders: { authorization: ${JSON.stringify('Basic ' + Buffer.from(`${n.user}:${password}`).toString('base64'))} }`,
|
|
160
166
|
].join('\n');
|
|
161
167
|
}
|
|
162
168
|
|
|
@@ -200,6 +206,7 @@ export function createAccessRouter(orgs, auth, localOpenSearch = () => undefined
|
|
|
200
206
|
all: has(req, 'ADMIN') ? all : undefined });
|
|
201
207
|
} catch (err) {
|
|
202
208
|
if (err instanceof AccessError) return res.json({ available: false, reason: err.message });
|
|
209
|
+
console.error('OpenSearch Dashboards access: could not reach', cfg.node, '-', err.code || '', err.message);
|
|
203
210
|
return res.json({ available: false, reason: 'OpenSearch did not answer' });
|
|
204
211
|
}
|
|
205
212
|
} catch (err) { next(err); }
|
package/package.json
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"cookie-parser": "^1.4.7",
|
|
13
13
|
"express": "^5.2.1",
|
|
14
14
|
"express-session": "^1.19.0",
|
|
15
|
-
"frugal-iot-client": "^2.0.
|
|
16
|
-
"frugal-iot-logger": "^2.0.
|
|
15
|
+
"frugal-iot-client": "^2.0.10",
|
|
16
|
+
"frugal-iot-logger": "^2.0.10",
|
|
17
17
|
"hash-wasm": "^4.12.0",
|
|
18
18
|
"js-yaml": "^4.2.0",
|
|
19
19
|
"morgan": "^1.11.0",
|
|
@@ -64,9 +64,10 @@
|
|
|
64
64
|
"extras/frugaliot-ap-watchdog.service",
|
|
65
65
|
"extras/frugaliot-ap-watchdog.timer",
|
|
66
66
|
"API.md",
|
|
67
|
-
"GRAFANA.md",
|
|
68
|
-
"INSTALL-OPENSEARCH.md",
|
|
69
|
-
"INSTALLATION.md"
|
|
67
|
+
"docs/GRAFANA.md",
|
|
68
|
+
"docs/INSTALL-OPENSEARCH.md",
|
|
69
|
+
"docs/INSTALLATION.md",
|
|
70
|
+
"docs/OPENWRT.md"
|
|
70
71
|
],
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"supertest": "^7.2.2",
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
"check-files": "node scripts/check-published-files.js"
|
|
94
95
|
},
|
|
95
96
|
"type": "module",
|
|
96
|
-
"version": "2.0.
|
|
97
|
+
"version": "2.0.10",
|
|
97
98
|
"overrides": {
|
|
98
99
|
"uuid": "^11.1.1"
|
|
99
100
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// it, an installed browser keeps serving the files it already cached - and since the list now covers
|
|
7
7
|
// the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
|
|
8
8
|
// needs this bumped, or it does not reach anyone who already has the old one.
|
|
9
|
-
const CACHE_NAME = 'frugal-iot-cache-2.0.
|
|
9
|
+
const CACHE_NAME = 'frugal-iot-cache-2.0.10';
|
|
10
10
|
/*
|
|
11
11
|
* Everything the app needs, so that a phone on a poor or expensive link fetches it once.
|
|
12
12
|
*
|
|
@@ -100,6 +100,14 @@ self.addEventListener('fetch', event => {
|
|
|
100
100
|
// Scoped to CACHE_NAME: a bare caches.match searches every cache in the origin, so a
|
|
101
101
|
// leftover older cache answers first and the version bump achieves nothing
|
|
102
102
|
caches.match(event.request, {cacheName: CACHE_NAME})
|
|
103
|
-
.then(response => response || fetch(event.request)
|
|
103
|
+
.then(response => response || fetch(event.request).then(fetched => {
|
|
104
|
+
// A language is cached when first used, not at install: there are many, and a
|
|
105
|
+
// phone wants one
|
|
106
|
+
if (fetched.ok && new URL(event.request.url).pathname.startsWith('/dashboard/languages/')) {
|
|
107
|
+
const copy = fetched.clone();
|
|
108
|
+
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
|
|
109
|
+
}
|
|
110
|
+
return fetched;
|
|
111
|
+
}))
|
|
104
112
|
);
|
|
105
113
|
});
|
package/scripts/addbridge-pi.zsh
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# After this, the Pi's readings also appear on the production server, and its nodes can be
|
|
6
6
|
# controlled from there - while the Pi carries on recording and serving on its own whenever the
|
|
7
7
|
# link is down. Readings taken during an outage stay on the Pi: production gets a gap rather than
|
|
8
|
-
# a backlog delivered later with the wrong timestamps. INSTALLATION.md step 11 explains why.
|
|
8
|
+
# a backlog delivered later with the wrong timestamps. docs/INSTALLATION.md step 11 explains why.
|
|
9
9
|
#
|
|
10
10
|
# The relaying is done by the broker, so the server, the logger and the nodes are untouched.
|
|
11
11
|
#
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
# printed - it is needed once, to type into the Pi.
|
|
25
25
|
#
|
|
26
26
|
# This assumes the server has already been set up to accept bridges at all: a TLS listener on 8883
|
|
27
|
-
# with a certificate. That is a one-off, and INSTALLATION.md step 11 covers it.
|
|
27
|
+
# with a certificate. That is a one-off, and docs/INSTALLATION.md step 11 covers it.
|
|
28
28
|
|
|
29
29
|
set -euo pipefail
|
|
30
30
|
|
|
@@ -255,7 +255,7 @@ else
|
|
|
255
255
|
echo ""
|
|
256
256
|
echo "NOTE: this broker has no acl_file, so ${ACCOUNT} can reach every topic on it, not just"
|
|
257
257
|
echo " ${ORG_ID}/#. That is how the broker already treats every other account, so this is no"
|
|
258
|
-
echo " worse than what is there - but see INSTALLATION.md step 11 for restricting it."
|
|
258
|
+
echo " worse than what is there - but see docs/INSTALLATION.md step 11 for restricting it."
|
|
259
259
|
fi
|
|
260
260
|
|
|
261
261
|
# password_file and acl_file are both re-read on a reload, so nothing needs to be disconnected.
|
|
@@ -133,11 +133,20 @@ if [[ ! -w "$MOSQUITTO_PASSWD_DIR" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MO
|
|
|
133
133
|
fi
|
|
134
134
|
fi
|
|
135
135
|
fi
|
|
136
|
+
# Run as root there is no sudo to go through (OpenWrt has none, and runs everything as root), and
|
|
137
|
+
# mosquitto_passwd then rewrites the file as root - which the broker, running as its own account,
|
|
138
|
+
# cannot read. So when root is doing it and somebody else owns the file, hand it back afterwards.
|
|
139
|
+
if (( EUID == 0 )) && [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]]; then
|
|
140
|
+
MOSQUITTO_RESTORE_OWNER=1
|
|
141
|
+
fi
|
|
136
142
|
# Called after any successful mosquitto_passwd run below
|
|
137
143
|
restore_passwd_owner() {
|
|
138
144
|
(( MOSQUITTO_RESTORE_OWNER )) || return 0
|
|
139
|
-
sudo
|
|
140
|
-
|
|
145
|
+
local SUDO=(sudo)
|
|
146
|
+
(( EUID == 0 )) && SUDO=()
|
|
147
|
+
"${SUDO[@]}" chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
|
|
148
|
+
[[ -n "$MOSQUITTO_PASSWD_MODE" ]] && "${SUDO[@]}" chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
|
|
149
|
+
return 0
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
# ---- Escaping helpers ----
|
package/scripts/diagnostic.zsh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env zsh
|
|
2
2
|
#
|
|
3
|
-
# Report the state of a Frugal IoT installation, for working out why something in INSTALLATION.md
|
|
3
|
+
# Report the state of a Frugal IoT installation, for working out why something in docs/INSTALLATION.md
|
|
4
4
|
# did not do what it said it would. It only looks - it changes nothing.
|
|
5
5
|
#
|
|
6
6
|
# Run it from the directory the server is installed in (the one holding frugal-iot.db):
|
|
@@ -85,7 +85,7 @@ for c in node npm sqlite3 zsh mosquitto mosquitto_passwd mosquitto_sub; do
|
|
|
85
85
|
esac
|
|
86
86
|
else
|
|
87
87
|
item "$c: NOT INSTALLED"
|
|
88
|
-
[[ $c == (node|npm|sqlite3) ]] && problem "$c is not installed - see INSTALLATION.md step 3"
|
|
88
|
+
[[ $c == (node|npm|sqlite3) ]] && problem "$c is not installed - see docs/INSTALLATION.md step 3"
|
|
89
89
|
fi
|
|
90
90
|
done
|
|
91
91
|
|
|
@@ -355,7 +355,7 @@ broker_setting() { # name -> value from the broker's own configuration, sudo on
|
|
|
355
355
|
broker_setting acl_file; ACLFILE="$BROKER_SETTING"
|
|
356
356
|
if [[ -z "$ACLFILE" ]]; then
|
|
357
357
|
item "acl_file: NOT SET - every account that can log in can reach every topic, across all organizations"
|
|
358
|
-
problem "The broker has no acl_file. Re-run the install script, or see INSTALLATION.md 'Turn on access control'"
|
|
358
|
+
problem "The broker has no acl_file. Re-run the install script, or see docs/INSTALLATION.md 'Turn on access control'"
|
|
359
359
|
else
|
|
360
360
|
item "acl_file: $ACLFILE"
|
|
361
361
|
if [[ -r "$ACLFILE" ]] || { have sudo && sudo -n test -r "$ACLFILE" 2>/dev/null; }; then
|
|
@@ -608,7 +608,7 @@ else
|
|
|
608
608
|
fi
|
|
609
609
|
|
|
610
610
|
# Swapping to an SD card wears it fast. On a Pi 4 there should be little or none; a Pi Zero W may
|
|
611
|
-
# need some. See INSTALLATION.md, "Wear and tear on the SD card".
|
|
611
|
+
# need some. See docs/INSTALLATION.md, "Wear and tear on the SD card".
|
|
612
612
|
if have free; then
|
|
613
613
|
item "swap: $(free -h | awk '/^Swap:/ {print $3 " used of " $2}')"
|
|
614
614
|
fi
|
package/scripts/grafana-token.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
|
-
* Issue, list or revoke the tokens Grafana uses to read an organization's history (GRAFANA.md).
|
|
3
|
+
* Issue, list or revoke the tokens Grafana uses to read an organization's history (docs/GRAFANA.md).
|
|
4
4
|
*
|
|
5
5
|
* npx --no frugal-iot-grafana-token add <org> <username> <name> prints the token - once, it is not kept
|
|
6
6
|
* npx --no frugal-iot-grafana-token list <org>
|
package/scripts/init.zsh
CHANGED
|
@@ -48,7 +48,11 @@ copy_if_missing() {
|
|
|
48
48
|
fi
|
|
49
49
|
elif [[ -e "$from" ]]; then
|
|
50
50
|
mkdir -p "${to:h}"
|
|
51
|
-
cp
|
|
51
|
+
# extras/ holds a directory (grafana/) as well as files, and a plain cp refuses a directory -
|
|
52
|
+
# which under "set -e" ended the whole init, after server.yaml had been created but before it
|
|
53
|
+
# was pointed at node_modules. The re-run then saw an existing server.yaml and left it alone, so
|
|
54
|
+
# "/" 404ed. -R for a directory, so a failure here cannot strand a half-initialised install.
|
|
55
|
+
if [[ -d "$from" ]]; then cp -R "$from" "$to"; else cp "$from" "$to"; fi
|
|
52
56
|
COPIED+=("$to")
|
|
53
57
|
echo " created ${to}"
|
|
54
58
|
fi
|