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.
@@ -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
+ );
@@ -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: with no
1277
- // successRedirect, passport calls next() on success and the request 404s as
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: returnTo,
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
- const out = [];
216
- for (const r of (await listRows(db, org)).filter((x) => x.enabled)) {
217
- const config = JSON.parse(r.config);
218
- if (r.type !== 'opensearch-local') { out.push({ type: r.type, ...config }); continue; }
219
- if (!localOpenSearch || !localOpenSearch.node) continue;
220
- const { node, username, password, apiKey, insecure } = localOpenSearch;
221
- out.push({ type: 'opensearch', node, username, password, apiKey, insecure, ...config });
222
- }
223
- return out;
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
+ }
@@ -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
- 'opensearch.requestHeadersAllowlist: [ authorization ]',
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.8",
16
- "frugal-iot-logger": "^2.0.8",
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.8",
97
+ "version": "2.0.10",
97
98
  "overrides": {
98
99
  "uuid": "^11.1.1"
99
100
  }
@@ -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.8';
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
  });
@@ -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 chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
140
- [[ -n "$MOSQUITTO_PASSWD_MODE" ]] && sudo chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
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 ----
@@ -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
@@ -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 "$from" "$to"
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