frugal-iot-server 2.0.6 → 2.0.8

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.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Forwarders added from the dashboard: where an organization's readings are sent besides the CSV
3
+ * files - OpenSearch, Google Sheets. See frugal-iot-logger for what each does.
4
+ *
5
+ * In the short term an organization's YAML file can still carry "forwarders:", and those keep working;
6
+ * what is stored here is added to them. Forwawrders in YAML will be deprecated by end 2026
7
+ *
8
+ * An administrator can add, change, switch off and delete these, and
9
+ * the change takes effect at once: the logger stops the old set and starts the new, without a
10
+ * restart (MqttLogger.setForwarders).
11
+ *
12
+ * GET /forwarders/:org the types that can be added and what fields each takes, the ones
13
+ * stored here, and the ones that come from the file (read-only)
14
+ * POST /forwarders/:org {id?, type, name, enabled, config} add, or change if id is given
15
+ * POST /forwarders/:org/delete {id}
16
+ *
17
+ * Organization administrators only. Secrets (a password, an API key, the Google Sheets URL) are
18
+ * never sent back: a forwarder is listed with which of its secrets are set, and leaving a secret
19
+ * blank when changing one keeps what was there.
20
+ *
21
+ * Only some of what the logger accepts in YAML is offered here, on purpose. The server makes these
22
+ * connections, and an organization's administrator is not the server's - so nothing that names a
23
+ * file on this machine (a CA certificate, a Firebase service account) can be set from a browser,
24
+ * and neither can the index prefix, which would let one organization write into another's index.
25
+ * Those stay in the YAML file, which only whoever runs the server can edit.
26
+ */
27
+
28
+ import express, { Router } from 'express';
29
+ import { jsonOnly, orgWideAdmin } from './admin-guards.js';
30
+ import { checkDestination } from './destinations.js';
31
+
32
+ // The dashboard's POST reports an error from "message", and the rest of this server's JSON replies say
33
+ // "error" - so send both, or a reason such as "OpenSearch URL is required" shows up as just "400".
34
+ const fail = (res, status, msg) => res.status(status).json({ error: msg, message: msg });
35
+
36
+ const run = (db, sql, args) => new Promise((res, rej) => db.run(sql, args, function (e) { e ? rej(e) : res(this); }));
37
+ const get = (db, sql, args) => new Promise((res, rej) => db.get(sql, args, (e, r) => (e ? rej(e) : res(r))));
38
+ const all = (db, sql, args) => new Promise((res, rej) => db.all(sql, args, (e, r) => (e ? rej(e) : res(r))));
39
+
40
+ // Topic patterns, as in the logger: levels split on "/", "+" for one level, a trailing "#" for the rest.
41
+ const TOPIC_PATTERN = /^[A-Za-z0-9_.+#/-]{1,200}$/;
42
+
43
+ const isUrl = (v, protocols) => {
44
+ try { return protocols.includes(new URL(v).protocol); } catch { return false; }
45
+ };
46
+
47
+ // Where a URL may point is decided by lib/destinations.js, which looks at what an address IS rather than
48
+ // how it is spelled.
49
+ export const MAX_FORWARDERS_PER_ORG = 10;
50
+ // Every logged message is matched against each pattern, in the process the whole logger runs in
51
+ const MAX_TOPICS = 100;
52
+
53
+ // What can be added from the dashboard, and the fields each takes. The dashboard builds its form
54
+ // from this, so a new field or type is added here and nowhere in the browser.
55
+ // kind: string | url | boolean | int | topics secret: never sent back to the browser
56
+ export const FORWARDER_TYPES = {
57
+ // This server's own OpenSearch, which the server operator set up (config.d/opensearch.yaml): there is
58
+ // no address or password to type, and it is the ONLY way to reach OpenSearch on this machine. Offered
59
+ // only on a server that has one (see typesFor).
60
+ 'opensearch-local': {
61
+ label: 'OpenSearch on this server',
62
+ help: 'Sends each logged reading to the OpenSearch run by this server, to browse in OpenSearch ' +
63
+ 'Dashboards (see the Dashboards access card) or Grafana.',
64
+ fields: [
65
+ { key: 'topics', label: 'Only these topics', kind: 'topics',
66
+ help: 'One per line, e.g. dev/lotus/+/sht/#. Leave empty to send everything that is logged.' },
67
+ { key: 'flushSeconds', label: 'Send every (seconds)', kind: 'int', min: 1, max: 3600 },
68
+ ],
69
+ },
70
+ // Somebody else's OpenSearch, or your own on another machine, reached over the internet. Never an
71
+ // address on this machine or a private network - see lib/destinations.js.
72
+ opensearch: {
73
+ label: 'OpenSearch on another server',
74
+ help: 'Sends each logged reading to an OpenSearch you run elsewhere. It must be reachable from this ' +
75
+ 'server over the internet, with https and a login or API key.',
76
+ fields: [
77
+ { key: 'node', label: 'OpenSearch URL', kind: 'url', protocols: ['https:'], required: true,
78
+ help: 'e.g. https://search.example.org' },
79
+ { key: 'username', label: 'Username', kind: 'string', max: 100 },
80
+ { key: 'password', label: 'Password', kind: 'string', secret: true, max: 200 },
81
+ { key: 'apiKey', label: 'API key', kind: 'string', secret: true, max: 500,
82
+ help: 'Instead of a username and password' },
83
+ { key: 'topics', label: 'Only these topics', kind: 'topics',
84
+ help: 'One per line, e.g. dev/lotus/+/sht/#. Leave empty to send everything that is logged.' },
85
+ { key: 'flushSeconds', label: 'Send every (seconds)', kind: 'int', min: 1, max: 3600 },
86
+ ],
87
+ },
88
+ gsheet: {
89
+ label: 'Google Sheets',
90
+ help: 'Appends a row of the latest values of the chosen topics to a spreadsheet, on a timer. ' +
91
+ 'Set the sheet up first: open the script below, copy it into the sheet\'s Apps Script, and follow the ' +
92
+ 'instructions at the top of the file. They end with the Web app URL to paste here.',
93
+ // Shown under the description. Fixed here, not taken from a request.
94
+ links: [{ label: 'The Apps Script to copy, with setup instructions',
95
+ url: 'https://raw.githubusercontent.com/mitra42/frugal-iot-logger/main/examples/gsheets/gsheet_app' }],
96
+ fields: [
97
+ { key: 'url', label: 'Web app URL', kind: 'url', protocols: ['https:'], required: true, secret: true,
98
+ help: 'The Apps Script web app URL. Anyone holding it can write to the sheet.' },
99
+ { key: 'sheet', label: 'Sheet name', kind: 'string', max: 100 },
100
+ { key: 'intervalSeconds', label: 'Add a row every (seconds)', kind: 'int', min: 60, max: 86400, required: true },
101
+ { key: 'topics', label: 'Topics (one column each)', kind: 'topics', required: true,
102
+ help: 'One full topic per line, in column order, e.g. dev/lotus/esp32-3c7ab8/sht/humidity' },
103
+ ],
104
+ },
105
+ };
106
+
107
+ // A cleaned config, or an Error saying what is wrong. `existing` is the config being changed, so that
108
+ // a secret left blank keeps its value. Anything not in the type's field list is refused rather than
109
+ // dropped, so a typo does not look like it worked.
110
+ export function validateConfig(type, input, existing = {}) {
111
+ const def = FORWARDER_TYPES[type];
112
+ if (!def) return new Error(`Unknown type "${type}"`);
113
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return new Error('config must be an object');
114
+ const known = new Set(def.fields.map((f) => f.key));
115
+ const stray = Object.keys(input).filter((k) => !known.has(k));
116
+ if (stray.length) return new Error(`Not a setting of ${def.label}: ${stray.join(', ')}`);
117
+ const out = {};
118
+ for (const f of def.fields) {
119
+ let v = input[f.key];
120
+ const blank = v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
121
+ if (blank) {
122
+ if (f.secret && existing[f.key] !== undefined) { out[f.key] = existing[f.key]; continue; }
123
+ if (f.required) return new Error(`${f.label} is required`);
124
+ continue;
125
+ }
126
+ switch (f.kind) {
127
+ case 'string':
128
+ if (typeof v !== 'string' || v.length > (f.max || 200)) return new Error(`${f.label} is not valid`);
129
+ break;
130
+ case 'url':
131
+ if (typeof v !== 'string' || v.length > 2000 || !isUrl(v, f.protocols)) {
132
+ return new Error(`${f.label} must be a ${f.protocols.map((p) => p.replace(':', '')).join(' or ')} URL`);
133
+ }
134
+ break;
135
+ case 'boolean':
136
+ if (typeof v !== 'boolean') return new Error(`${f.label} must be true or false`);
137
+ if (!v) continue; // off is the same as not set
138
+ break;
139
+ case 'int':
140
+ v = typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : v;
141
+ if (!Number.isInteger(v) || v < f.min || v > f.max) return new Error(`${f.label} must be a whole number from ${f.min} to ${f.max}`);
142
+ break;
143
+ case 'topics':
144
+ if (!Array.isArray(v) || v.length > MAX_TOPICS || !v.every((t) => typeof t === 'string' && TOPIC_PATTERN.test(t))) {
145
+ return new Error(`${f.label}: each must be a topic, such as dev/lotus/+/sht/#`);
146
+ }
147
+ break;
148
+ }
149
+ out[f.key] = v;
150
+ }
151
+ // Rules that look at more than one field
152
+ for (const f of def.fields.filter((x) => x.kind === 'url' && out[x.key])) {
153
+ const u = new URL(out[f.key]);
154
+ if (u.username || u.password) return new Error(`${f.label} must not contain a login - use the username and password settings`);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // Whether the server may connect to where a config points - see lib/destinations.js. Nothing on this
160
+ // machine or a private network, for any type: this server's own OpenSearch is "opensearch-local", whose
161
+ // address is the operator's and not taken from a request. Async, since a name is looked up. Returns a
162
+ // message or null.
163
+ export async function checkDestinations(type, config, { lookup } = {}) {
164
+ const def = FORWARDER_TYPES[type];
165
+ for (const f of def.fields.filter((x) => x.kind === 'url' && config[x.key])) {
166
+ const why = await checkDestination(config[f.key], { lookup });
167
+ if (why) return `${f.label} ${why}`;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ // What the browser may be told about a stored config: everything except the secrets, plus which are set
173
+ export function publicConfig(type, config) {
174
+ const def = FORWARDER_TYPES[type];
175
+ const out = { config: {}, secretsSet: [] };
176
+ for (const f of (def ? def.fields : [])) {
177
+ if (config[f.key] === undefined) continue;
178
+ if (f.secret) out.secretsSet.push(f.key); else out.config[f.key] = config[f.key];
179
+ }
180
+ return out;
181
+ }
182
+
183
+ // The same for an entry from the YAML file. Only what the type is known to offer, and not its secrets:
184
+ // the file can hold anything (a CA certificate path, a key nobody told this code about), and a list of
185
+ // what to hide fails the day somebody adds a field to it.
186
+ function fileEntry(entry, index) {
187
+ const def = FORWARDER_TYPES[entry && entry.type];
188
+ const shown = {};
189
+ for (const f of (def ? def.fields : [])) {
190
+ if (!f.secret && entry[f.key] !== undefined) shown[f.key] = entry[f.key];
191
+ }
192
+ return { source: 'file', index, type: entry && entry.type, name: `From the configuration file (#${index + 1})`, enabled: true, config: shown };
193
+ }
194
+
195
+ const rowToApi = (r) => ({
196
+ source: 'db', id: r.id, type: r.type, name: r.name, enabled: !!r.enabled,
197
+ ...publicConfig(r.type, JSON.parse(r.config)),
198
+ updated_at: r.updated_at,
199
+ });
200
+
201
+ export const listRows = (db, org) => all(db, 'SELECT * FROM forwarders WHERE org = ? ORDER BY id', [org]);
202
+
203
+ // The types an administrator may add on this server: "opensearch-local" only where the operator has
204
+ // set one up.
205
+ export function typesFor(localOpenSearch) {
206
+ return Object.fromEntries(Object.entries(FORWARDER_TYPES)
207
+ .filter(([k]) => k !== 'opensearch-local' || (localOpenSearch && localOpenSearch.node)));
208
+ }
209
+
210
+ // What the logger is given: each enabled row as a YAML-style entry. "opensearch-local" becomes an
211
+ // ordinary opensearch forwarder carrying the operator's address and credentials from the server's own
212
+ // configuration (config.d/opensearch.yaml), so those never pass through the browser or the database.
213
+ // A local row on a server that no longer has one is left out.
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;
224
+ }
225
+
226
+ /**
227
+ * @param db sqlite3 database
228
+ * @param mqttLogger has setForwarders(org, configs)
229
+ * @param orgs () => the organizations configuration, { org: {...} }
230
+ * @param auth { loggedInOrFail, can_ADMIN } from frugal-iot-server.js, passed in so tests can stand in
231
+ * @param localOpenSearch () => the server's own OpenSearch settings, or undefined (config.d/opensearch.yaml)
232
+ */
233
+ export function createForwardersRouter(db, mqttLogger, orgs, auth, { lookup, localOpenSearch = () => undefined } = {}) {
234
+ const router = Router();
235
+ // Whole-organization ADMIN: a forwarder sends every project's readings
236
+ const guard = [auth.loggedInOrFail, auth.can_ADMIN, orgWideAdmin];
237
+
238
+ const orgExists = (req, res, next) => (orgs()[req.params.org]
239
+ ? next() : fail(res, 404, 'No such organization'));
240
+
241
+ // Tell the logger. A failure here must not look like the save failed - the row is stored, and is
242
+ // picked up at the next restart - so it is reported alongside.
243
+ async function apply(org) {
244
+ try {
245
+ await mqttLogger.setForwarders(org, await enabledConfigs(db, org, localOpenSearch()));
246
+ return { applied: true };
247
+ } catch (err) {
248
+ console.error(`Forwarders for ${org} saved but could not be applied:`, err);
249
+ return { applied: false, applyError: err.message };
250
+ }
251
+ }
252
+
253
+ router.get('/:org', ...guard, orgExists, async (req, res, next) => {
254
+ try {
255
+ const org = req.params.org;
256
+ const file = (orgs()[org].forwarders || []).map(fileEntry);
257
+ res.json({
258
+ types: Object.fromEntries(Object.entries(typesFor(localOpenSearch())).map(([k, d]) => [k, { label: d.label, help: d.help, links: d.links || [], fields: d.fields }])),
259
+ forwarders: (await listRows(db, org)).map(rowToApi),
260
+ fromFile: file,
261
+ });
262
+ } catch (err) { next(err); }
263
+ });
264
+
265
+ router.post('/:org', ...guard, orgExists, jsonOnly, express.json({ limit: '64kb' }), async (req, res, next) => {
266
+ try {
267
+ const org = req.params.org;
268
+ const { id, type, config } = req.body || {};
269
+ const name = String((req.body && req.body.name) || '').trim();
270
+ const enabled = req.body && req.body.enabled === false ? 0 : 1;
271
+ if (!/^.{1,60}$/.test(name)) return fail(res, 400, 'Give it a name of up to 60 characters');
272
+ const now = Date.now();
273
+ if (id !== undefined) {
274
+ const row = await get(db, 'SELECT * FROM forwarders WHERE id = ? AND org = ?', [id, org]);
275
+ if (!row) return fail(res, 404, 'No such forwarder');
276
+ if (type !== undefined && type !== row.type) return fail(res, 400, 'A forwarder cannot change type - add a new one');
277
+ const clean = validateConfig(row.type, config, JSON.parse(row.config));
278
+ if (clean instanceof Error) return fail(res, 400, clean.message);
279
+ const whyNot = await checkDestinations(row.type, clean, { lookup });
280
+ if (whyNot) return fail(res, 400, whyNot);
281
+ await run(db, 'UPDATE forwarders SET name = ?, enabled = ?, config = ?, updated_at = ? WHERE id = ?',
282
+ [name, enabled, JSON.stringify(clean), now, id]);
283
+ console.log('Forwarder changed by', req.user.username, '-', org, row.type, JSON.stringify(name));
284
+ return res.json({ id, ...(await apply(org)) });
285
+ }
286
+ if (!typesFor(localOpenSearch())[type]) return fail(res, 400, `Unknown type "${type}"`);
287
+ const clean = validateConfig(type, config);
288
+ if (clean instanceof Error) return fail(res, 400, clean.message);
289
+ const whyNot = await checkDestinations(type, clean, { lookup });
290
+ if (whyNot) return fail(res, 400, whyNot);
291
+ // The limit is part of the insert, so two requests at once cannot both slip under it
292
+ const r = await run(db, `INSERT INTO forwarders (org, type, name, enabled, config, created_at, updated_at)
293
+ SELECT ?, ?, ?, ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM forwarders WHERE org = ?) < ?`,
294
+ [org, type, name, enabled, JSON.stringify(clean), now, now, org, MAX_FORWARDERS_PER_ORG]);
295
+ if (!r.changes) return fail(res, 400, `An organization can have up to ${MAX_FORWARDERS_PER_ORG} forwarders`);
296
+ console.log('Forwarder added by', req.user.username, '-', org, type, JSON.stringify(name));
297
+ res.status(201).json({ id: r.lastID, ...(await apply(org)) });
298
+ } catch (err) { next(err); }
299
+ });
300
+
301
+ router.post('/:org/delete', ...guard, orgExists, jsonOnly, express.json({ limit: '1kb' }), async (req, res, next) => {
302
+ try {
303
+ const org = req.params.org;
304
+ const r = await run(db, 'DELETE FROM forwarders WHERE id = ? AND org = ?', [req.body && req.body.id, org]);
305
+ if (!r.changes) return fail(res, 404, 'No such forwarder');
306
+ console.log('Forwarder deleted by', req.user.username, '-', org, JSON.stringify(req.body.id));
307
+ res.json({ deleted: true, ...(await apply(org)) });
308
+ } catch (err) { next(err); }
309
+ });
310
+
311
+ return router;
312
+ }