frugal-iot-server 0.3.8 → 2.0.0

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/package.json CHANGED
@@ -12,12 +12,14 @@
12
12
  "cookie-parser": "^1.4.7",
13
13
  "express": "^5.2.1",
14
14
  "express-session": "^1.19.0",
15
- "frugal-iot-client": "^1.3.14",
16
- "frugal-iot-logger": "^1.1.20",
15
+ "frugal-iot-client": "^2.0.0",
16
+ "frugal-iot-logger": "^2.0.0",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
20
+ "mqtt": "^5.15.2",
20
21
  "multer": "^2.1.1",
22
+ "nodemailer": "^9.0.6",
21
23
  "passport": "^0.7.0",
22
24
  "passport-local": "^1.0.0",
23
25
  "sqlite3": "^5.1.7"
@@ -28,9 +30,12 @@
28
30
  "frugal-iot-addorganization": "scripts/addorganization.zsh",
29
31
  "frugal-iot-setpassword": "scripts/setpassword.zsh",
30
32
  "frugal-iot-diagnostic": "scripts/diagnostic.zsh",
31
- "frugal-iot-clearretained": "scripts/clearretained.zsh",
33
+ "frugal-iot-clearretained": "scripts/clearretained.js",
32
34
  "frugal-iot-addbridge-prod": "scripts/addbridge-prod.zsh",
33
- "frugal-iot-addbridge-pi": "scripts/addbridge-pi.zsh"
35
+ "frugal-iot-addbridge-pi": "scripts/addbridge-pi.zsh",
36
+ "frugal-iot-rebuild-dynsec": "scripts/rebuild-dynsec.js",
37
+ "frugal-iot-dynsec-init": "scripts/dynsec-init.js",
38
+ "frugal-iot-resetnode": "scripts/resetnode.js"
34
39
  },
35
40
  "description": "Frugal IoT client and server",
36
41
  "//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",
@@ -38,6 +43,7 @@
38
43
  "frugal-iot-server.js",
39
44
  "frugal-iot-createdb.sql",
40
45
  "config.yaml",
46
+ "config.d/email.yaml",
41
47
  "config.d/logger.yaml",
42
48
  "config.d/mqtt.yaml",
43
49
  "config.d/server.yaml",
@@ -47,6 +53,7 @@
47
53
  "private/",
48
54
  "scripts/",
49
55
  "extras/mosquitto.conf",
56
+ "extras/aclfile",
50
57
  "extras/mosquitto-bridge.conf.example",
51
58
  "extras/frugaliot.service",
52
59
  "API.md",
@@ -76,5 +83,5 @@
76
83
  "check-files": "node scripts/check-published-files.js"
77
84
  },
78
85
  "type": "module",
79
- "version": "0.3.8"
86
+ "version": "2.0.0"
80
87
  }
package/public/index.html CHANGED
@@ -167,14 +167,6 @@
167
167
  <div class="nav-section">
168
168
  <h2>Other examples</h2>
169
169
  <div class="box-grid">
170
- <!-- The display the Dashboard used before the cards. Kept reachable while people get used
171
- to the new one; it will go once nothing needs it. -->
172
- <div class="nav-box">
173
- <div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
174
- <div class="nav-box-content">
175
- <a href="/dashboard/index-old.html">Previous dashboard</a>
176
- </div>
177
- </div>
178
170
  <div class="nav-box">
179
171
  <div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
180
172
  <div class="nav-box-content">
@@ -197,6 +189,14 @@
197
189
  </div>
198
190
  <script>
199
191
  if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
192
+ // The worker calls skipWaiting(), so a new one can take over a page whose scripts came
193
+ // from the old one. Reload once to get a consistent set - but not on a first install,
194
+ // where there was no previous controller and nothing is stale.
195
+ const hadController = !!navigator.serviceWorker.controller;
196
+ let reloading = false;
197
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
198
+ if (hadController && !reloading) { reloading = true; location.reload(); }
199
+ });
200
200
  navigator.serviceWorker.register('/service-worker.js')
201
201
  .then(reg => console.log('Service Worker registered'))
202
202
  .catch(err => console.log('Service Worker registration failed', err));
@@ -1,18 +1,68 @@
1
1
  // Changing this version is what makes an already installed PWA fetch the app again instead of
2
2
  // serving what it cached. It follows the version of frugal-iot-client this release installs, and
3
3
  // "npm run prerelease" sets it - so there is normally no reason to edit it by hand.
4
- const CACHE_NAME = 'frugal-iot-cache-1.3.14';
4
+ //
5
+ // The suffix is here because urlsToCache below CHANGED without the client version changing. Without
6
+ // it, an installed browser keeps serving the files it already cached - and since the list now covers
7
+ // the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
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.0';
10
+ /*
11
+ * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
+ *
13
+ * The dashboard's OWN code was missing from this list, which was most of the point of having one:
14
+ * eight files - the entry point, five modules and the stylesheet - were fetched from the network on
15
+ * every visit while the libraries beside them came from the cache.
16
+ *
17
+ * Two rules for anything added here:
18
+ *
19
+ * 1. It must return 200 WITHOUT a session. install() rejects the whole cache if any single entry
20
+ * fails, so one 404 or one redirect-to-login means this PWA has no cache at all - and the app
21
+ * goes on working online, so nothing announces it. /dashboard/*.js and the stylesheet do serve
22
+ * unauthenticated; /dashboard/index.html does NOT (it 307s to the login page).
23
+ * 2. It must not be a session-gated PAGE. Beyond breaking install, a cached /dashboard/index.html
24
+ * would be served to a logged-out visitor - which is the "accessing from service worker which
25
+ * has /dashboard cached" case admin.js already had to handle.
26
+ *
27
+ * Deliberately absent:
28
+ * /dashboard/index.html session-gated, see above
29
+ * /node_modules/esptool-js/* ~1MB, wanted on one screen, and not by the phone users this list
30
+ * exists for
31
+ *
32
+ * Every URL below was checked against a running server. Two entries in index.html's importmap were
33
+ * not, and are dead: "chart.js" points at dist/Chart.js, which does not exist (the file is
34
+ * lowercase, and graph.js imports the full path anyway), and "chartjs-adapter-luxon" points into a
35
+ * package that is not installed at all - its code was copied into core.js instead. Neither is
36
+ * reachable today; both would 404 the moment somebody used the bare specifier.
37
+ */
5
38
  const urlsToCache = [
39
+ // The public landing page and the PWA's own furniture
6
40
  '/',
7
41
  '/index.html',
42
+ '/manifest.json',
43
+ '/favicon.ico',
8
44
  '/images/icon-192x192.png',
9
45
  '/images/icon-512x512.png',
46
+ // The dashboard: its stylesheet, its entry point, and the modules dashboard.js imports
47
+ '/dashboard/frugaliot.css',
48
+ '/dashboard/dashboard.js',
49
+ '/dashboard/core.js',
50
+ '/dashboard/widgets.js',
51
+ '/dashboard/graph.js',
52
+ '/dashboard/admin.js',
53
+ '/dashboard/flash.js',
54
+ '/dashboard/cards.js',
55
+ // Logging in - the page a visitor lands on when the session has gone
56
+ '/dashboard/login.html',
57
+ '/dashboard/login.js',
58
+ // Libraries, at the paths index.html's importmap and the modules actually use
10
59
  '/node_modules/html-element-extended/htmlelementextended.js',
11
60
  '/node_modules/mqtt/dist/mqtt.esm.js',
12
61
  '/node_modules/js-yaml/dist/js-yaml.mjs',
13
62
  '/node_modules/async/dist/async.mjs',
14
63
  '/node_modules/csv-parse/dist/esm/index.js',
15
64
  '/node_modules/chart.js/dist/chart.js',
65
+ '/node_modules/@kurkle/color/dist/color.esm.js', // chart.js needs it; was missing
16
66
  '/node_modules/dial-gauge/dial-gauge.js',
17
67
  '/node_modules/luxon/src/luxon.js'
18
68
  ];
@@ -20,37 +70,36 @@ const urlsToCache = [
20
70
  self.addEventListener('install', event => {
21
71
  event.waitUntil(
22
72
  caches.open(CACHE_NAME)
23
- .then(cache => {
24
- console.log('Opened cache');
25
- return cache.addAll(urlsToCache);
26
- })
27
- );
28
- });
29
-
30
- self.addEventListener('fetch', event => {
31
- event.respondWith(
32
- caches.match(event.request)
33
- .then(response => {
34
- if (response) {
35
- return response;
36
- }
37
- return fetch(event.request);
38
- })
73
+ .then(cache => Promise.all(urlsToCache.map(url =>
74
+ // 'reload' bypasses the HTTP cache, which serves /node_modules immutable for a day -
75
+ // without it a same-day release caches the library it was meant to replace
76
+ fetch(new Request(url, {cache: 'reload'}))
77
+ .then(response => response.ok
78
+ ? cache.put(url, response)
79
+ : Promise.reject(new Error(url + ' -> ' + response.status))))))
80
+ // Without this the new worker waits until every tab of the origin is closed at once.
81
+ // Reloading does not release the old one, so a release can stay invisible for days.
82
+ .then(() => self.skipWaiting())
39
83
  );
40
84
  });
41
85
 
42
86
  // Delete old caches during activation
43
87
  self.addEventListener('activate', event => {
44
- const cacheWhitelist = [CACHE_NAME];
45
88
  event.waitUntil(
46
- caches.keys().then(cacheNames => {
47
- return Promise.all(
48
- cacheNames.map(cacheName => {
49
- if (cacheWhitelist.indexOf(cacheName) === -1) {
50
- return caches.delete(cacheName);
51
- }
52
- })
53
- );
54
- })
89
+ caches.keys()
90
+ .then(cacheNames => Promise.all(
91
+ cacheNames.filter(cacheName => cacheName !== CACHE_NAME)
92
+ .map(cacheName => caches.delete(cacheName))))
93
+ // Take over the pages that are already open, rather than only ones opened from now on
94
+ .then(() => self.clients.claim())
95
+ );
96
+ });
97
+
98
+ self.addEventListener('fetch', event => {
99
+ event.respondWith(
100
+ // Scoped to CACHE_NAME: a bare caches.match searches every cache in the origin, so a
101
+ // leftover older cache answers first and the version bump achieves nothing
102
+ caches.match(event.request, {cacheName: CACHE_NAME})
103
+ .then(response => response || fetch(event.request))
55
104
  );
56
105
  });
@@ -15,9 +15,14 @@
15
15
  # Run from the server's own directory, the one holding frugal-iot.db.
16
16
  #
17
17
  # Usage:
18
- # npx --no frugal-iot-addbridge-pi --org <org-id> --host <prod-host> --account <account> [--replace]
18
+ # npx --no frugal-iot-addbridge-pi <org-id> <prod-host> <account>
19
19
  # scripts/addbridge-pi.zsh --org myfarm --host prod.example.org --account bridge-northfield
20
20
  #
21
+ # Both forms work. The positional one is the one to use with npx, because npm swallows any --flag
22
+ # it does not recognise ("Unknown cli config") and passes only the values through, so
23
+ # "npx ... --org myfarm" reaches this script as just "myfarm". Putting "--" before the flags also
24
+ # works, but the positional form is harder to get wrong.
25
+ #
21
26
  # The password is asked for rather than passed on the command line, so it does not end up in your
22
27
  # shell history. Give it with --password only for an unattended run.
23
28
 
@@ -26,12 +31,17 @@ set -euo pipefail
26
31
  SCRIPT_NAME=$0
27
32
 
28
33
  usage() {
29
- echo "Usage: ${SCRIPT_NAME} --org <org-id> --host <prod-host> --account <account> [--port N] [--password PW] [--replace]" >&2
30
- echo "Example: ${SCRIPT_NAME} --org myfarm --host prod.example.org --account bridge-northfield" >&2
34
+ echo "Usage: ${SCRIPT_NAME} <org-id> <prod-host> <account> [password]" >&2
35
+ echo " or: ${SCRIPT_NAME} --org <org-id> --host <prod-host> --account <account> [--port N] [--password PW] [--replica-token T] [--replace]" >&2
36
+ echo "Example: ${SCRIPT_NAME} myfarm prod.example.org bridge-northfield" >&2
37
+ echo "" >&2
38
+ echo "Through npx, use the positional form: npm swallows unrecognised --flags and passes only" >&2
39
+ echo "their values on, so '--org myfarm' arrives here as just 'myfarm'." >&2
31
40
  exit 1
32
41
  }
33
42
 
34
- ORG_ID=""; PROD_HOST=""; ACCOUNT=""; PASSWORD=""; PROD_PORT=8883; REPLACE=false
43
+ ORG_ID=""; PROD_HOST=""; ACCOUNT=""; PASSWORD=""; PROD_PORT=8883; REPLACE=false; REPLICA_TOKEN=""
44
+ POSITIONAL=()
35
45
 
36
46
  while [[ $# -gt 0 ]]; do
37
47
  case "$1" in
@@ -39,13 +49,33 @@ while [[ $# -gt 0 ]]; do
39
49
  --host) PROD_HOST=${2:-}; shift 2 ;;
40
50
  --account) ACCOUNT=${2:-}; shift 2 ;;
41
51
  --password) PASSWORD=${2:-}; shift 2 ;;
52
+ --replica-token) REPLICA_TOKEN=${2:-}; shift 2 ;;
42
53
  --port) PROD_PORT=${2:-}; shift 2 ;;
43
54
  --replace) REPLACE=true; shift ;;
44
- *) echo "Error: unexpected argument '$1'" >&2; usage ;;
55
+ -*) echo "Error: unknown option '$1'" >&2; usage ;;
56
+ *) POSITIONAL+=("$1"); shift ;;
45
57
  esac
46
58
  done
47
59
 
48
- [[ -z "$ORG_ID" || -z "$PROD_HOST" || -z "$ACCOUNT" ]] && usage
60
+ # Fill anything not given as a flag from the positional arguments, in the order the usage line
61
+ # shows. This is what makes the npx form work: npm eats "--org" and hands us a bare "myfarm".
62
+ # Written as "if" rather than "[[ ... ]] && X=Y", whose false case is a non-zero last command and
63
+ # would end the script under "set -e".
64
+ # Fill whichever are still empty, in the order the usage line shows them, consuming the
65
+ # positionals in turn rather than by fixed position - so a half-and-half command line still lands
66
+ # correctly. Done inline, not through a function returning its value: a command substitution runs
67
+ # in a subshell, where the counter would advance and then be thrown away, giving every field the
68
+ # same first positional.
69
+ POS_N=1
70
+ for _field in ORG_ID PROD_HOST ACCOUNT PASSWORD; do
71
+ if [[ -z "${(P)_field}" ]] && (( POS_N <= ${#POSITIONAL} )); then
72
+ typeset -g "${_field}"="${POSITIONAL[POS_N]}"
73
+ POS_N=$((POS_N + 1))
74
+ fi
75
+ done
76
+ unset _field
77
+
78
+ if [[ -z "$ORG_ID" || -z "$PROD_HOST" || -z "$ACCOUNT" ]]; then usage; fi
49
79
 
50
80
  BRIDGE_CONF=/etc/mosquitto/conf.d/frugal-iot-bridge.conf
51
81
 
@@ -60,7 +90,8 @@ fi
60
90
 
61
91
  if [[ -e "$BRIDGE_CONF" && "$REPLACE" != true ]]; then
62
92
  echo "Error: ${BRIDGE_CONF} already exists - this Pi is already bridged." >&2
63
- echo "Add --replace to overwrite it, or edit it by hand." >&2
93
+ echo "Add --replace to overwrite it, or edit it by hand. Through npx that flag needs a '--'" >&2
94
+ echo "ahead of it - 'npx --no frugal-iot-addbridge-pi -- --org ... --replace' - or npm eats it." >&2
64
95
  exit 1
65
96
  fi
66
97
 
@@ -160,5 +191,58 @@ else
160
191
  echo " sudo tail -20 /var/log/mosquitto/mosquitto.log"
161
192
  echo "and the state is reported by: npx --no frugal-iot-diagnostic"
162
193
  fi
194
+ # ---------------------------------------------------------------------------------------------
195
+ # Sharing production's logins (SECURITY.md S11).
196
+ #
197
+ # The bridge above relays topics, not accounts, so without this a person registered on production
198
+ # has no account on this Pi's broker - and this Pi is what goes on working when the link is down.
199
+ # With it, the server pulls their logins and permissions on a timer and issues its OWN broker
200
+ # credentials from its own user_secret; nothing derived from a password travels either way.
201
+ #
202
+ # Optional: a bridge is useful without it, so a missing token is not an error.
203
+ if [[ -z "$REPLICA_TOKEN" ]]; then
204
+ echo -n "Replica token from the production server (blank to skip): "
205
+ read -r REPLICA_TOKEN
206
+ fi
207
+
208
+ if [[ -n "$REPLICA_TOKEN" ]]; then
209
+ # https, and deliberately not asked about: the token is a bearer credential, and over http it
210
+ # would be readable by anything between here and production.
211
+ cat > config.d/replica.yaml <<YAML
212
+ # Which production server this Pi takes its logins from, and how often (SECURITY.md S11).
213
+ # Written by frugal-iot-addbridge-pi. The token itself lives in config.d/secrets.yaml, which is
214
+ # never served to a browser - nothing secret may go in this file.
215
+ url: https://${PROD_HOST}
216
+ organizations:
217
+ - ${ORG_ID}
218
+ intervalSeconds: 900
219
+ YAML
220
+ # Appended, or the one line replaced: that file holds this server's own secrets too.
221
+ if grep -q '^replica_token:' config.d/secrets.yaml 2>/dev/null; then
222
+ python3 - "$REPLICA_TOKEN" <<'PYEOF'
223
+ import io, re, sys
224
+ p = 'config.d/secrets.yaml'
225
+ s = io.open(p, encoding='utf-8').read()
226
+ io.open(p, 'w', encoding='utf-8').write(
227
+ re.sub(r'^replica_token:.*$', 'replica_token: "%s"' % sys.argv[1], s, count=1, flags=re.M))
228
+ PYEOF
229
+ else
230
+ {
231
+ echo ""
232
+ echo "# Pulls this Pi's users from the production server named in config.d/replica.yaml."
233
+ echo "# Issued by frugal-iot-addbridge-prod; re-running that command revokes this one."
234
+ echo "replica_token: \"${REPLICA_TOKEN}\""
235
+ } >> config.d/secrets.yaml
236
+ fi
237
+ chmod 600 config.d/secrets.yaml
238
+ echo ""
239
+ echo "Wrote config.d/replica.yaml and stored the token in config.d/secrets.yaml."
240
+ echo "Restart the server to start pulling: sudo systemctl restart frugaliot"
241
+ else
242
+ echo ""
243
+ echo "No replica token given - the bridge relays readings, but people registered on production"
244
+ echo "will not be able to log in on this Pi. Re-run this script with the token to add it."
245
+ fi
246
+
163
247
  echo ""
164
248
  echo "The readings this Pi has already recorded stay here - only new ones are relayed."
@@ -196,10 +196,53 @@ sudo systemctl reload mosquitto || {
196
196
  echo " Try: sudo systemctl restart mosquitto" >&2
197
197
  }
198
198
 
199
+ # ---------------------------------------------------------------------------------------------
200
+ # A token for the Pi to pull this organization's logins with (SECURITY.md S11).
201
+ #
202
+ # The bridge relays topics, not accounts, so without this a person who logs in here has no account
203
+ # on the Pi's broker - and the Pi is the machine that goes on working when the link is down.
204
+ #
205
+ # It reads logins, hashes and permissions for THIS organization and nothing else. Nothing derived
206
+ # from a password travels: the Pi checks logins against the replicated hash and derives its own
207
+ # broker credentials from its own user_secret, so the two brokers issue different passwords for the
208
+ # same person.
209
+ #
210
+ # Done in node rather than sqlite3, because the sqlite3 command-line tool is not installed
211
+ # everywhere the server runs - the same reason scripts/clearretained.js talks MQTT through a library.
212
+ REPLICA_TOKEN=$(node -e '
213
+ const { randomBytes } = require("crypto");
214
+ const sqlite3 = require("sqlite3");
215
+ const [org, site] = process.argv.slice(1);
216
+ const token = randomBytes(32).toString("base64url");
217
+ const db = new sqlite3.Database("./frugal-iot.db", (err) => {
218
+ if (err) { console.error(err.message); process.exit(1); }
219
+ db.run(`CREATE TABLE IF NOT EXISTS bridges (org TEXT NOT NULL, site TEXT NOT NULL,
220
+ token TEXT NOT NULL, created_at INTEGER NOT NULL, last_pull INTEGER,
221
+ UNIQUE(org, site))`, () => {
222
+ db.run(`INSERT INTO bridges (org, site, token, created_at) VALUES (?, ?, ?, ?)
223
+ ON CONFLICT(org, site) DO UPDATE SET token = excluded.token, created_at = excluded.created_at`,
224
+ [org, site, token, Date.now()], (e) => {
225
+ if (e) { console.error(e.message); process.exit(1); }
226
+ process.stdout.write(token);
227
+ db.close();
228
+ });
229
+ });
230
+ });
231
+ ' "$ORG_ID" "$SITE") || true
232
+
233
+ if [[ -z "$REPLICA_TOKEN" ]]; then
234
+ echo ""
235
+ echo "Note: could not create a replica token, so the Pi will not be able to share this server's"
236
+ echo "logins. The broker bridge above is unaffected. Re-run this script to try again."
237
+ fi
238
+
199
239
  echo ""
200
240
  echo "Done. On the Pi at site '${SITE}', run:"
201
241
  echo ""
202
- echo " npx --no frugal-iot-addbridge-pi --org ${ORG_ID} --host $(hostname -f 2>/dev/null || hostname) --account ${ACCOUNT}"
242
+ echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <this server's public name> ${ACCOUNT}"
243
+ echo ""
244
+ echo "Positional arguments on purpose: npm swallows any --flag it does not recognise and passes"
245
+ echo "only the value on, so the --org form would arrive at that script as a bare word."
203
246
  echo ""
204
247
  echo "It will ask for this password:"
205
248
  echo ""
@@ -211,5 +254,16 @@ if [[ "$GENERATED" == true ]]; then
211
254
  echo " sudo -u \"\$(stat -c '%U' ${PWFILE})\" mosquitto_passwd -b ${PWFILE} ${ACCOUNT} '<new password>'"
212
255
  echo "and use that on the Pi instead."
213
256
  fi
214
- echo "Check the host name above is one the Pi can reach, and that it is the name on the"
215
- echo "certificate - the Pi verifies it."
257
+ if [[ -n "$REPLICA_TOKEN" ]]; then
258
+ echo "And this replica token, so people registered here can log in on the Pi as well:"
259
+ echo ""
260
+ echo " ${REPLICA_TOKEN}"
261
+ echo ""
262
+ echo "A new one replaces the old, so re-running this script revokes the Pi's previous token."
263
+ echo ""
264
+ fi
265
+ echo "Fill in the public name yourself - it is deliberately not guessed here, because what this"
266
+ echo "machine calls itself is usually not it. This host answers to \"$(hostname -f 2>/dev/null || hostname)\","
267
+ echo "which is only the right answer if that is also the DNS name the Pi can reach and the name on"
268
+ echo "the TLS certificate. The Pi verifies the certificate against whatever you put here, so a"
269
+ echo "local hostname, or a domain the certificate does not cover, fails to connect."
@@ -190,12 +190,17 @@ echo "Created user '${ORG_ID}' (id=${NEW_ID})"
190
190
  # OR IGNORE because permissions has a UNIQUE(id, capability, org) constraint, so a repeat of any of
191
191
  # these rows (e.g. on a database where id 1 is also the org's own account) would otherwise abort
192
192
  # the whole statement part-way through, leaving the organization with only some of its permissions.
193
+ #
194
+ # WRITE is deliberately NOT given to id 0 "everyone": that row would hand write access to every account
195
+ # on the server, including a self-registered one, which is what can_WRITE exists to prevent.
193
196
  sqlite3 "$DB" "
194
197
  INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'ADMIN', '${ORG_ID}');
195
198
  INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'READ', '${ORG_ID}');
199
+ INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'WRITE', '${ORG_ID}');
196
200
  INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (0, 'READ', '${ORG_ID}');
197
201
  INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (1, 'ADMIN', '${ORG_ID}');
198
202
  INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (1, 'READ', '${ORG_ID}');
203
+ INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (1, 'WRITE', '${ORG_ID}');
199
204
  INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'OTAUPDATE', '${ORG_ID}');
200
205
  "
201
206
  echo "Added permissions for organization ${ORG_ID}"
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * List, and optionally delete, retained MQTT messages.
4
+ *
5
+ * Nodes publish nearly everything retained, so the broker keeps the last value of every topic and
6
+ * hands it to each new subscriber - which is how a dashboard can show a reading straight away
7
+ * instead of waiting for the next one. The cost is that a topic published by mistake outlives the
8
+ * mistake: rename a module, misspell a field, test a node with the wrong id, and that topic stays
9
+ * on the broker for ever, appearing on every dashboard. Restarting the broker does not clear it and
10
+ * neither does fixing the node. The only way to remove one is to publish an empty message to that
11
+ * exact topic, which is what this does.
12
+ *
13
+ * Run from the server's own directory, the one holding frugal-iot.db, so that the organization's
14
+ * broker password can be read from config.d/organizations/.
15
+ *
16
+ * Usage:
17
+ * npx --no frugal-iot-clearretained '<topic-pattern>' # list what is retained
18
+ * npx --no frugal-iot-clearretained '<topic-pattern>' delete # delete it
19
+ *
20
+ * Quote the pattern, or the shell expands the wildcards before this ever sees them. In MQTT
21
+ * patterns "+" matches exactly one level and "#" matches the rest:
22
+ * 'myfarm/#' everything the organization holds - look before deleting
23
+ * 'myfarm/lotus/esp8266-fb94bb/#' one node
24
+ * 'myfarm/lotus/+/sht/temperture/#' a misspelled field, on every node in the project
25
+ *
26
+ * "delete" is a bare word rather than "--delete" because npm parses the whole command line of an
27
+ * "npx" invocation itself: it treats any --flag it does not recognise as an unknown config setting
28
+ * and passes only the values on, so "--delete" would silently never reach this script and it would
29
+ * quietly list instead of deleting. "--delete" is still accepted for running it directly.
30
+ *
31
+ * This talks to the broker through the "mqtt" library rather than mosquitto_sub and mosquitto_pub,
32
+ * so it works anywhere the server itself runs. On the production server those commands cannot be
33
+ * installed at all: its broker came from the mosquitto PPA built for an older Ubuntu, and the
34
+ * mosquitto-clients in the distribution demand an exactly-matching libmosquitto1, so apt would have
35
+ * to downgrade the library out from under the running broker.
36
+ */
37
+
38
+ import { readFileSync } from 'node:fs';
39
+ import yaml from 'js-yaml';
40
+ import mqtt from 'mqtt';
41
+
42
+ const SCRIPT = 'frugal-iot-clearretained';
43
+
44
+ function usage(message) {
45
+ if (message) console.error(`Error: ${message}`);
46
+ console.error(`Usage: ${SCRIPT} '<topic-pattern>' [delete] [--host H] [--port N] [--user U] [--password P] [--wait S]`);
47
+ console.error(`Example: ${SCRIPT} 'myfarm/lotus/+/sht/temperture/#'`);
48
+ console.error(` ${SCRIPT} 'myfarm/lotus/+/sht/temperture/#' delete`);
49
+ process.exit(1);
50
+ }
51
+
52
+ // ---- arguments ----
53
+ const opts = { host: 'localhost', port: 1883, user: null, password: null, wait: 3 };
54
+ let pattern = null;
55
+ let doDelete = false;
56
+
57
+ const argv = process.argv.slice(2);
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const a = argv[i];
60
+ switch (a) {
61
+ case '--delete': doDelete = true; break;
62
+ case '--host': opts.host = argv[++i]; break;
63
+ case '--port': opts.port = Number(argv[++i]); break;
64
+ case '--user': opts.user = argv[++i]; break;
65
+ case '--password': opts.password = argv[++i]; break;
66
+ case '--wait': opts.wait = Number(argv[++i]); break;
67
+ default:
68
+ if (a.startsWith('-')) usage(`unknown option '${a}'`);
69
+ else if (pattern === null) pattern = a;
70
+ else if (a === 'delete') doDelete = true;
71
+ else usage(`unexpected argument '${a}'`);
72
+ }
73
+ }
74
+ if (!pattern) usage('no topic pattern given');
75
+
76
+ // A bare "#" would be every topic on the broker, across every organization. Almost always a
77
+ // mistake, and the one case where a slip is unrecoverable, so it is not accepted at all.
78
+ if (pattern === '#' || pattern === '+/#') {
79
+ console.error(`Error: refusing the pattern '${pattern}' - that is every topic on the broker.`);
80
+ console.error("Name the organization at least, as in 'myfarm/#'.");
81
+ process.exit(1);
82
+ }
83
+
84
+ // ---- credentials ----
85
+ // The broker requires them, and the organization's are in its config file - the same place the
86
+ // server reads them from. The first element of the topic pattern is the organization.
87
+ if (!opts.user) {
88
+ opts.user = pattern.split('/')[0];
89
+ const configFile = `config.d/organizations/${opts.user}.yaml`;
90
+ let parsed;
91
+ try {
92
+ parsed = yaml.load(readFileSync(configFile, 'utf8'));
93
+ } catch (e) {
94
+ console.error(`Error: cannot read ${configFile} (${e.code || e.message}), so the broker password`);
95
+ console.error(`for '${opts.user}' cannot be found. Run this from the server's own directory, or`);
96
+ console.error('give credentials with --user and --password.');
97
+ process.exit(1);
98
+ }
99
+ opts.password = parsed && parsed.mqtt_password;
100
+ if (!opts.password) {
101
+ console.error(`Error: no mqtt_password in ${configFile}`);
102
+ process.exit(1);
103
+ }
104
+ }
105
+
106
+ // ---- collect what is retained ----
107
+ console.log(`Looking for retained messages matching '${pattern}' on ${opts.host}:${opts.port} as ${opts.user} ...`);
108
+
109
+ const client = mqtt.connect(`mqtt://${opts.host}:${opts.port}`, {
110
+ username: opts.user,
111
+ password: opts.password,
112
+ connectTimeout: 5000,
113
+ // A fresh session every time: we want the retained messages delivered on subscribe, not a
114
+ // continuation of some earlier one.
115
+ clean: true,
116
+ });
117
+
118
+ const topics = new Set();
119
+
120
+ client.on('error', (err) => {
121
+ // ECONNREFUSED arrives with an empty message, so fall back to the code - otherwise this prints
122
+ // "Error:" and nothing, which says neither "wrong password" nor "nothing listening there".
123
+ console.error(`Error: could not talk to the broker: ${err.message || err.code || err}`);
124
+ client.end(true);
125
+ process.exit(1);
126
+ });
127
+
128
+ client.on('message', (topic, _payload, packet) => {
129
+ // Only messages the broker had already kept. A node reporting normally while we listen arrives
130
+ // with the retain flag clear, and clearing that would be removing something nobody asked us to.
131
+ if (packet.retain) topics.add(topic);
132
+ });
133
+
134
+ client.on('connect', () => {
135
+ client.subscribe(pattern, { qos: 0 }, (err) => {
136
+ if (err) {
137
+ console.error(`Error: could not subscribe to '${pattern}': ${err.message}`);
138
+ client.end(true);
139
+ process.exit(1);
140
+ }
141
+ // Retained messages arrive immediately on subscribing, so this only has to cover the round
142
+ // trip; a busy broker on a slow Pi may want more, hence --wait.
143
+ setTimeout(finish, opts.wait * 1000);
144
+ });
145
+ });
146
+
147
+ async function finish() {
148
+ const list = [...topics].sort();
149
+ if (list.length === 0) {
150
+ console.log('Nothing retained matches that pattern.');
151
+ client.end();
152
+ return;
153
+ }
154
+ for (const t of list) console.log(` ${t}`);
155
+ console.log(`${list.length} retained topic(s).`);
156
+
157
+ if (!doDelete) {
158
+ console.log('');
159
+ console.log(`Nothing has been changed. To delete these, run the same command again with "delete"`);
160
+ console.log('as the last word.');
161
+ client.end();
162
+ return;
163
+ }
164
+
165
+ // Deleting one means publishing a zero-length retained message to it, which is how MQTT spells
166
+ // "forget this". At QoS 1 so the broker acknowledges each rather than dropping it under load.
167
+ console.log(`Deleting ${list.length} retained topic(s) ...`);
168
+ let failed = 0;
169
+ for (const t of list) {
170
+ try {
171
+ await new Promise((resolve, reject) => {
172
+ client.publish(t, '', { retain: true, qos: 1 }, (err) => (err ? reject(err) : resolve()));
173
+ });
174
+ console.log(` cleared ${t}`);
175
+ } catch (e) {
176
+ console.error(` FAILED ${t}: ${e.message}`);
177
+ failed++;
178
+ }
179
+ }
180
+ console.log('');
181
+ console.log('Done. Run the same command without "delete" - it should report nothing.');
182
+ console.log('If a topic comes back, a node is still publishing it: fix or restart the node, then re-run.');
183
+ // A bridged broker forwards these deletions to the other end, so clearing here clears there too.
184
+ client.end(false, {}, () => process.exit(failed ? 1 : 0));
185
+ }