frugal-iot-server 2.0.7 → 2.0.9

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.
@@ -9,6 +9,7 @@
9
9
  */
10
10
  const ADDED = [
11
11
  { table: 'bridges', column: 'credential', type: 'TEXT' },
12
+ { table: 'grafana_tokens', column: 'user_id', type: 'INTEGER' },
12
13
  ];
13
14
 
14
15
  export function addMissingColumns(db, cb) {
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.7",
16
- "frugal-iot-logger": "^2.0.7",
15
+ "frugal-iot-client": "^2.0.9",
16
+ "frugal-iot-logger": "^2.0.9",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -35,7 +35,9 @@
35
35
  "frugal-iot-addbridge-pi": "scripts/addbridge-pi.zsh",
36
36
  "frugal-iot-rebuild-dynsec": "scripts/rebuild-dynsec.js",
37
37
  "frugal-iot-dynsec-init": "scripts/dynsec-init.js",
38
- "frugal-iot-resetnode": "scripts/resetnode.js"
38
+ "frugal-iot-resetnode": "scripts/resetnode.js",
39
+ "frugal-iot-grafana-token": "scripts/grafana-token.js",
40
+ "frugal-iot-backfill-opensearch": "scripts/backfill-opensearch.js"
39
41
  },
40
42
  "description": "Frugal IoT client and server",
41
43
  "//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",
@@ -46,12 +48,14 @@
46
48
  "config.d/email.yaml",
47
49
  "config.d/logger.yaml",
48
50
  "config.d/mqtt.yaml",
51
+ "config.d/opensearch.yaml",
49
52
  "config.d/server.yaml",
50
53
  "config.d/schema/",
51
54
  "lib/",
52
55
  "public/",
53
56
  "private/",
54
57
  "scripts/",
58
+ "extras/grafana/",
55
59
  "extras/mosquitto.conf",
56
60
  "extras/aclfile",
57
61
  "extras/mosquitto-bridge.conf.example",
@@ -60,6 +64,8 @@
60
64
  "extras/frugaliot-ap-watchdog.service",
61
65
  "extras/frugaliot-ap-watchdog.timer",
62
66
  "API.md",
67
+ "GRAFANA.md",
68
+ "INSTALL-OPENSEARCH.md",
63
69
  "INSTALLATION.md"
64
70
  ],
65
71
  "devDependencies": {
@@ -87,7 +93,7 @@
87
93
  "check-files": "node scripts/check-published-files.js"
88
94
  },
89
95
  "type": "module",
90
- "version": "2.0.7",
96
+ "version": "2.0.9",
91
97
  "overrides": {
92
98
  "uuid": "^11.1.1"
93
99
  }
@@ -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.7';
9
+ const CACHE_NAME = 'frugal-iot-cache-2.0.9';
10
10
  /*
11
11
  * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
12
  *
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Load an organization's recorded readings into OpenSearch - the history from before its OpenSearch
4
+ * forwarder was switched on, or a gap from while OpenSearch was down.
5
+ *
6
+ * npx --no frugal-iot-backfill-opensearch <org> everything
7
+ * npx --no frugal-iot-backfill-opensearch <org> 2026-09-01 only files from that day on
8
+ * npx --no frugal-iot-backfill-opensearch <org> dry-run count, and send nothing
9
+ *
10
+ * Run from the server's own directory. It uses the organization's OpenSearch forwarder as set up on
11
+ * the dashboard's Forwarders card (a switched-off one is not used), and its "Only these topics" list.
12
+ * Safe to repeat, and to run while the server is: a reading's id comes from its topic and time, so one
13
+ * already in OpenSearch is replaced rather than doubled.
14
+ *
15
+ * Positional arguments, no flags: npm swallows any --flag it does not recognise. See the npx note in
16
+ * claude.md.
17
+ */
18
+
19
+ import sqlite3 from 'sqlite3';
20
+ import { MqttLogger, backfillOpenSearch } from 'frugal-iot-logger';
21
+ import { initializeSchema } from '../lib/database.js';
22
+ import { enabledConfigs } from '../lib/forwarders.js';
23
+
24
+ const [org, ...rest] = process.argv.slice(2);
25
+ const dryRun = rest.includes('dry-run');
26
+ const since = rest.find((a) => /^\d{4}-\d{2}-\d{2}$/.test(a));
27
+ if (!org || rest.some((a) => a !== 'dry-run' && a !== since)) {
28
+ console.error('Usage: npx --no frugal-iot-backfill-opensearch <org> [YYYY-MM-DD] [dry-run]');
29
+ process.exit(1);
30
+ }
31
+
32
+ const realLog = console.log;
33
+ console.log = () => {}; // the config reader logs a line per file
34
+ new MqttLogger().readYamlConfig('.', (err, config) => {
35
+ console.log = realLog;
36
+ if (err) { console.error(`Could not read the configuration: ${err.message}`); process.exit(1); }
37
+ if (!(config.organizations || {})[org]) { console.error(`No organization "${org}"`); process.exit(1); }
38
+ const db = new sqlite3.Database('./frugal-iot.db', async (dberr) => {
39
+ if (dberr) { console.error(`Could not open ./frugal-iot.db: ${dberr.message}`); process.exit(1); }
40
+ try {
41
+ await initializeSchema(db); // makes the table if this database is from before it existed
42
+ const fromDb = (await enabledConfigs(db, org, config.opensearch)).filter((c) => c.type === 'opensearch');
43
+ // Still honoured while YAML forwarders are supported, so a server not yet moved over works
44
+ const fromFile = (config.organizations[org].forwarders || []).filter((c) => c && c.type === 'opensearch');
45
+ const forwarders = [...fromDb, ...fromFile];
46
+ if (forwarders.length === 0) {
47
+ console.error(`${org} has no OpenSearch forwarder - add one on the dashboard's Forwarders card first`);
48
+ process.exitCode = 1;
49
+ return;
50
+ }
51
+ for (const fconfig of forwarders) {
52
+ const { sent, skippedFiles } = await backfillOpenSearch({
53
+ config: fconfig, org, dataDir: config.server.datadir, since, dryRun,
54
+ progress: (n) => process.stdout.write(`\r${n} readings`),
55
+ });
56
+ console.log(`\n${dryRun ? 'Would send' : 'Sent'} ${sent} readings to ${fconfig.node}` +
57
+ (skippedFiles ? `; ${skippedFiles} files skipped by its topics list` : ''));
58
+ }
59
+ } catch (e) {
60
+ console.error(e.message);
61
+ process.exitCode = 1;
62
+ } finally {
63
+ db.close();
64
+ }
65
+ });
66
+ });
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Issue, list or revoke the tokens Grafana uses to read an organization's history (GRAFANA.md).
4
+ *
5
+ * npx --no frugal-iot-grafana-token add <org> <username> <name> prints the token - once, it is not kept
6
+ * npx --no frugal-iot-grafana-token list <org>
7
+ * npx --no frugal-iot-grafana-token revoke <org> <name>
8
+ *
9
+ * Run from the server's own directory. Adding a name that exists replaces its token, which is how
10
+ * one is rotated. A token is read-only, for that one organization, and acts as <username>: it sees
11
+ * what that user may READ, and stops working when they lose access.
12
+ *
13
+ * Positional arguments, no flags: npm swallows any --flag it does not recognise. See the npx note
14
+ * in claude.md.
15
+ */
16
+
17
+ import sqlite3 from 'sqlite3';
18
+ import { initializeSchema } from '../lib/database.js';
19
+ import { addMissingColumns } from '../lib/schema-columns.js';
20
+ import { issueToken, listTokens, revokeToken } from '../lib/grafana.js';
21
+
22
+ const [action, org, ...rest] = process.argv.slice(2);
23
+ const [username, name] = action === 'add' ? rest : [undefined, rest[0]];
24
+ const usage = () => {
25
+ console.error('Usage: npx --no frugal-iot-grafana-token add <org> <username> <name>');
26
+ console.error(' npx --no frugal-iot-grafana-token list <org>');
27
+ console.error(' npx --no frugal-iot-grafana-token revoke <org> <name>');
28
+ process.exit(1);
29
+ };
30
+ if (!['add', 'list', 'revoke'].includes(action) || !org || (action === 'add' && !username) || (action !== 'list' && !name)) usage();
31
+ if (name && !/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,39}$/.test(name)) {
32
+ console.error('The name must be 1-40 characters: letters, digits, space, _ . -');
33
+ process.exit(1);
34
+ }
35
+
36
+ const db = new sqlite3.Database('./frugal-iot.db', async (err) => {
37
+ if (err) { console.error(`Could not open ./frugal-iot.db: ${err.message}`); process.exit(1); }
38
+ try {
39
+ await initializeSchema(db); // adds the table to a database from before it existed; changes nothing else
40
+ await new Promise((res, rej) => addMissingColumns(db, (e) => (e ? rej(e) : res())));
41
+ if (action === 'add') {
42
+ const user = await new Promise((res, rej) => db.get('SELECT id FROM users WHERE username = ?', [username], (e, r) => (e ? rej(e) : res(r))));
43
+ if (!user) throw new Error(`No user "${username}"`);
44
+ const token = await issueToken(db, org, name, user.id);
45
+ console.log(`Token for ${org} "${name}" (acting as ${username}) - shown once, put it in Grafana now:\n\n${token}\n`);
46
+ } else if (action === 'list') {
47
+ const rows = await listTokens(db, org);
48
+ if (rows.length === 0) console.log(`No Grafana tokens for ${org}`);
49
+ for (const r of rows) {
50
+ console.log(`${r.name}\tissued ${new Date(r.created_at).toISOString()}\tlast used ${r.last_used ? new Date(r.last_used).toISOString() : 'never'}`);
51
+ }
52
+ } else {
53
+ const done = await revokeToken(db, org, name);
54
+ console.log(done ? `Revoked "${name}" for ${org}` : `No token "${name}" for ${org}`);
55
+ if (!done) process.exitCode = 1;
56
+ }
57
+ } catch (e) {
58
+ console.error(e.message);
59
+ process.exitCode = 1;
60
+ }
61
+ db.close();
62
+ });