frugal-iot-server 0.3.9 → 2.0.1

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.
@@ -468,14 +468,93 @@ ok "mosquitto $(mosquitto -h 2>&1 | head -1 | awk '{print $3}' || true)"
468
468
  step "Configuring the broker"
469
469
  # Two listeners: 1883 for the sensor nodes, 9012 websockets for the browser. Shipped with the
470
470
  # server, so it is copied rather than written here.
471
- SRC_CONF="${INSTALL_DIR}/extras/mosquitto.conf"
472
- [[ -f "$SRC_CONF" ]] || { echo "Expected ${SRC_CONF} to exist after frugal-iot-init" >&2; exit 1; }
473
- if cmp -s "$SRC_CONF" /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
471
+ # Read the broker configuration from the INSTALLED PACKAGE, not from ${INSTALL_DIR}/extras.
472
+ #
473
+ # frugal-iot-init copies extras/ into the install directory but never overwrites what is already
474
+ # there - it prints "kept ... (DIFFERS from this release)" and leaves it. That is right for a file
475
+ # somebody may have edited, and wrong for this one: it means a security-relevant change to the
476
+ # shipped broker configuration does not reach /etc on an upgrade, however many times this script is
477
+ # re-run. That is how a broker with no acl_file survived several releases.
478
+ #
479
+ # The package's own copy is authoritative, so an upgrade followed by a re-run of this script applies
480
+ # the current configuration. ${INSTALL_DIR}/extras stays as the fallback for an installation whose
481
+ # layout predates this.
482
+ PKG_DIR="${INSTALL_DIR}/node_modules/frugal-iot-server"
483
+ SRC_CONF="${PKG_DIR}/extras/mosquitto.conf"
484
+ [[ -f "$SRC_CONF" ]] || SRC_CONF="${INSTALL_DIR}/extras/mosquitto.conf"
485
+ [[ -f "$SRC_CONF" ]] || { echo "Expected extras/mosquitto.conf in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1; }
486
+
487
+ # The ACL file goes in FIRST, because the configuration copied below names it and mosquitto will not
488
+ # start if it is missing. Owned by mosquitto, mode 600: the broker reads it after dropping
489
+ # privileges, and 2.0.21 warns that a file it does not own, or one that is world readable, will be
490
+ # refused by a future version. Same rule as the password file below.
491
+ SRC_ACL="${PKG_DIR}/extras/aclfile"
492
+ [[ -f "$SRC_ACL" ]] || SRC_ACL="${INSTALL_DIR}/extras/aclfile"
493
+ if [[ -f "$SRC_ACL" ]]; then
494
+ if cmp -s "$SRC_ACL" /etc/mosquitto/aclfile 2>/dev/null; then
495
+ skip "/etc/mosquitto/aclfile already current"
496
+ else
497
+ sudo_ install -o mosquitto -g mosquitto -m 600 "$SRC_ACL" /etc/mosquitto/aclfile
498
+ ok "access control list installed at /etc/mosquitto/aclfile"
499
+ fi
500
+ else
501
+ echo "Expected extras/aclfile in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1
502
+ fi
503
+
504
+ # The dynamic security plugin. Its state file has to exist before the configuration naming it is
505
+ # installed, or the broker will not start - same ordering as the ACL above.
506
+ #
507
+ # The plugin path varies by distribution, so it is found rather than assumed: Debian and Raspberry Pi
508
+ # OS ship it as /usr/lib/<arch>/mosquitto_dynamic_security.so, others use a mosquitto/ subdirectory.
509
+ DYNSEC_SO=""
510
+ for cand in /usr/lib/*/mosquitto_dynamic_security.so /usr/lib/mosquitto_dynamic_security.so /usr/lib/*/mosquitto/mosquitto_dynamic_security.so /usr/lib/mosquitto/mosquitto_dynamic_security.so; do
511
+ [[ -f "$cand" ]] && { DYNSEC_SO="$cand"; break; }
512
+ done
513
+ DYNSEC_JSON=/var/lib/mosquitto/dynamic-security.json
514
+ if [[ -z "$DYNSEC_SO" ]]; then
515
+ warn "mosquitto's dynamic security plugin was not found - per-user broker accounts will not work."
516
+ warn " Looked in /usr/lib. The broker will still run on the organization passwords."
517
+ elif [[ -f "$DYNSEC_JSON" ]]; then
518
+ skip "dynamic security already initialised at ${DYNSEC_JSON}"
519
+ else
520
+ # dynsec init asks for the admin password twice on stdin. The password is generated here and kept
521
+ # in the server's own config.d/secrets.yaml, which is never served to a browser.
522
+ DYNSEC_PW="$(randpw)"
523
+ if printf '%s\n%s\n' "$DYNSEC_PW" "$DYNSEC_PW" | sudo_ mosquitto_ctrl dynsec init "$DYNSEC_JSON" frugal-admin >/dev/null 2>&1 && [[ -s "$DYNSEC_JSON" ]]; then
524
+ # Written after dropping privileges, so owned by mosquitto like the password and ACL files
525
+ sudo_ chown mosquitto:mosquitto "$DYNSEC_JSON"
526
+ sudo_ chmod 600 "$DYNSEC_JSON"
527
+ {
528
+ echo ""
529
+ echo "# The broker account the server uses to create and remove other accounts, over"
530
+ echo "# \$CONTROL/dynamic-security/v1. Created by install-pi.sh."
531
+ echo "dynsec_admin_user: \"frugal-admin\""
532
+ echo "dynsec_admin_password: \"${DYNSEC_PW}\""
533
+ } >> "${INSTALL_DIR}/config.d/secrets.yaml"
534
+ chmod 600 "${INSTALL_DIR}/config.d/secrets.yaml"
535
+ ok "dynamic security initialised; admin credential saved to config.d/secrets.yaml"
536
+ else
537
+ warn "mosquitto_ctrl dynsec init failed - continuing without it"
538
+ sudo_ rm -f "$DYNSEC_JSON"
539
+ DYNSEC_SO=""
540
+ fi
541
+ fi
542
+
543
+ # The shipped configuration carries a placeholder for the plugin path, because it differs per
544
+ # distribution. Substitute it here - or comment the plugin out entirely if there is no plugin.
545
+ TMP_CONF="$(mktemp)"
546
+ if [[ -n "$DYNSEC_SO" ]]; then
547
+ sed "s|^plugin PLUGIN_PATH_SET_BY_INSTALLER|plugin ${DYNSEC_SO}|" "$SRC_CONF" > "$TMP_CONF"
548
+ else
549
+ sed -e "s|^plugin PLUGIN_PATH_SET_BY_INSTALLER|#plugin (not installed)|" -e "s|^plugin_opt_config_file|#plugin_opt_config_file|" "$SRC_CONF" > "$TMP_CONF"
550
+ fi
551
+ if cmp -s "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
474
552
  skip "/etc/mosquitto/conf.d/frugal-iot.conf already current"
475
553
  else
476
- sudo_ cp "$SRC_CONF" /etc/mosquitto/conf.d/frugal-iot.conf
554
+ sudo_ cp "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf
477
555
  ok "configuration copied to /etc/mosquitto/conf.d/frugal-iot.conf"
478
556
  fi
557
+ rm -f "$TMP_CONF"
479
558
  # The broker refuses to start if the password file it is told about does not exist. It has to belong
480
559
  # to the mosquitto user, because both the broker and mosquitto_passwd warn unless the file belongs to
481
560
  # whoever opened it - and mosquitto_passwd writes a temporary file beside it, so the directory has to
@@ -0,0 +1,21 @@
1
+ -- Add permissions.project to a database created before it existed.
2
+ --
3
+ -- Run only when the column is absent - scripts/init.zsh checks. Rebuilding the table rather than
4
+ -- ALTER TABLE ADD COLUMN because the UNIQUE constraint has to change too, and SQLite cannot alter a
5
+ -- constraint. Wrapped in a transaction so a failure leaves the old table in place.
6
+ --
7
+ -- Existing rows get project = '', meaning organization-wide, so every permission keeps behaving
8
+ -- exactly as it did and nothing needs re-granting.
9
+ BEGIN;
10
+ CREATE TABLE `permissions_new` (
11
+ `id` INTEGER NOT NULL,
12
+ `capability` TEXT NOT NULL,
13
+ `org` TEXT NOT NULL,
14
+ `project` TEXT NOT NULL DEFAULT '',
15
+ UNIQUE(`id`, `capability`, `org`, `project`)
16
+ );
17
+ INSERT INTO `permissions_new` (`id`, `capability`, `org`, `project`)
18
+ SELECT `id`, `capability`, `org`, '' FROM `permissions`;
19
+ DROP TABLE `permissions`;
20
+ ALTER TABLE `permissions_new` RENAME TO `permissions`;
21
+ COMMIT;
@@ -25,3 +25,8 @@ test/**
25
25
  # --- Placeholders ---
26
26
  # Keeps the ota directory present in a clone; a real installation gets one from frugal-iot-init
27
27
  ota/README.md
28
+
29
+ # Describes how the security works, and would be useful in an installation - but it ends with an
30
+ # open-issues section listing what is currently exploitable, which must not ship to everyone who
31
+ # installs the package. Published by hand, without that section.
32
+ SECURITY.md
@@ -4,6 +4,18 @@
4
4
  # afterwards. Run it, read what it says, then publish:
5
5
  #
6
6
  # npm run prerelease
7
+ # zsh scripts/prerelease.zsh --release-version 2.1.0
8
+ # zsh scripts/prerelease.zsh -q
9
+ #
10
+ # -q (--quiet) prints only findings and changes, so a clean run prints nothing at all. That is what
11
+ # the release script uses; run it without -q to see each check confirm itself.
12
+ #
13
+ # --release-version says which version this release is about to publish everywhere, and is what the
14
+ # release script in frugal-iot-demo passes. Without it, "requires a version nobody has published"
15
+ # is a blocker, because that is what it means on an ordinary day. During a release it is the normal
16
+ # state of affairs - the logger and client are about to be published at that version and have not
17
+ # been yet - so with it, a range that only the version being released satisfies is reported and
18
+ # allowed, and CACHE_NAME is set to that version rather than to whatever is on npm today.
7
19
  #
8
20
  # It checks the sensor schema and copies it into the examples that ship with frugal-iot-logger, sets
9
21
  # the service worker's cache version, and looks for the package being wired to a checkout on this
@@ -21,27 +33,52 @@
21
33
 
22
34
  set -euo pipefail
23
35
 
36
+ RELEASE_VERSION=""
37
+ QUIET=0
38
+ while (( $# )); do
39
+ case "$1" in
40
+ --release-version) RELEASE_VERSION="${2:-}"; shift 2 || true ;;
41
+ --release-version=*) RELEASE_VERSION="${1#*=}"; shift ;;
42
+ -q|--quiet) QUIET=1; shift ;;
43
+ -h|--help) sed -n '2,34p' "$0" | sed 's/^# \?//'; exit 0 ;;
44
+ *) print -u2 "Unknown option: $1 (try --release-version <version>, -q, or -h)"; exit 2 ;;
45
+ esac
46
+ done
47
+
48
+ # A check confirming itself, as opposed to a finding or a change: silent under -q
49
+ say() { (( QUIET )) || print -r -- "$@" }
50
+
51
+ # Passed on to the scripts that take the same flag
52
+ QARG=()
53
+ (( QUIET )) && QARG=(-q)
54
+
24
55
  HERE="${0:A:h:h}" # The frugal-iot-server checkout this script is in
25
56
  SCHEMA="${HERE}/config.d/schema"
26
57
 
58
+ # Does the version being released satisfy this range? Only asked when --release-version was given.
59
+ satisfied_by_release() {
60
+ [[ -n "$RELEASE_VERSION" ]] || return 1
61
+ node -e "process.exit(require('semver').satisfies('${RELEASE_VERSION}', process.argv[1]) ? 0 : 1)" "$1" 2>/dev/null
62
+ }
63
+
27
64
  cd "$HERE"
28
65
 
29
66
  BLOCKERS=() # never right in a published package - this script exits non-zero for these
30
67
  WARNINGS=() # probably worth doing something about, but yours to judge
31
68
 
32
- echo "=== Checking ${SCHEMA} ==="
33
- node scripts/check-schema.js config.d/schema || true
69
+ say "=== Checking ${SCHEMA} ==="
70
+ node scripts/check-schema.js "${QARG[@]}" config.d/schema || true
34
71
 
35
- echo
36
- echo "=== Copying the schema into the frugal-iot-logger examples ==="
37
- zsh scripts/copy-schema-to-examples.zsh
72
+ say
73
+ say "=== Copying the schema into the frugal-iot-logger examples ==="
74
+ zsh scripts/copy-schema-to-examples.zsh "${QARG[@]}"
38
75
 
39
76
  # ---- Is this package wired to a checkout on this machine? ----
40
77
  # Developing the server, logger and client together means pointing the server at sibling checkouts,
41
78
  # and every way of doing that is invisible once you have got used to it. Published, each one either
42
79
  # breaks every install outright or ships code nobody else has.
43
- echo
44
- echo "=== Checking nothing is wired to a local checkout ==="
80
+ say
81
+ say "=== Checking nothing is wired to a local checkout ==="
45
82
 
46
83
  # 1. "file:" or "link:" dependencies - these would make "npm install frugal-iot-server" fail for
47
84
  # everybody, because the path they name does not exist on anyone else's machine
@@ -59,7 +96,7 @@ if [[ -n "$LOCALDEPS" ]]; then
59
96
  print -r -- "$LOCALDEPS" | sed 's/^/ /'
60
97
  BLOCKERS+=("package.json names a dependency by path - publishing that breaks every install. Put the version range back.")
61
98
  else
62
- echo " package.json names no dependencies by path"
99
+ say " package.json names no dependencies by path"
63
100
  fi
64
101
 
65
102
  # 2. npm link - node_modules/<pkg> being a symlink means what you have been testing is your
@@ -74,8 +111,9 @@ if (( ${#LINKED} )); then
74
111
  for l in $LINKED; do echo " linked: $l"; done
75
112
  echo " What you have been running is that checkout, not the published package."
76
113
  echo " Publish the linked package first, then require the new version here."
114
+ WARNINGS+=("node_modules links to a checkout (${(j:, :)LINKED}) - what the tests exercised is that checkout, not the published package.")
77
115
  else
78
- echo " node_modules holds published packages, not links to checkouts"
116
+ say " node_modules holds published packages, not links to checkouts"
79
117
  fi
80
118
 
81
119
  # 3. The development alternatives inside the source, which are switched by editing rather than by
@@ -83,12 +121,12 @@ fi
83
121
  if grep -qE '^\s*import .*from\s+"\.\./frugal-iot-logger' frugal-iot-server.js 2>/dev/null; then
84
122
  BLOCKERS+=("frugal-iot-server.js is importing the logger from ../frugal-iot-logger - comment that line out and uncomment the \"frugal-iot-logger\" one.")
85
123
  else
86
- echo " frugal-iot-server.js imports the logger as a package, not by path"
124
+ say " frugal-iot-server.js imports the logger as a package, not by path"
87
125
  fi
88
126
  if grep -qE '^\s*(htmldir|nodemodulesdir):\s*\.\./' config.d/server.yaml 2>/dev/null; then
89
127
  BLOCKERS+=("config.d/server.yaml points htmldir or nodemodulesdir at ../frugal-iot-client - that is the development setting and ships to everyone who installs.")
90
128
  else
91
- echo " config.d/server.yaml points at ./node_modules, not at a sibling checkout"
129
+ say " config.d/server.yaml points at ./node_modules, not at a sibling checkout"
92
130
  fi
93
131
 
94
132
  # ---- Are the versions this requires actually available? ----
@@ -96,8 +134,8 @@ fi
96
134
  # on a Pi as well as here, and nothing in the repo shows it. Asking for one that is published but
97
135
  # not the newest is a lesser problem - a fresh install resolves to the newest anyway, but an
98
136
  # existing one is free to stay where it is, so your changes reach nobody who already has it.
99
- echo
100
- echo "=== Checking the versions this requires have been published ==="
137
+ say
138
+ say "=== Checking the versions this requires have been published ==="
101
139
 
102
140
  # Highest published version of $1 satisfying range $2, or empty if none - and it prints nothing and
103
141
  # exits 1 when nothing matches, which is an answer rather than a failure, hence the "|| true"
@@ -110,15 +148,19 @@ for p in frugal-iot-logger frugal-iot-client; do
110
148
  RANGE=$(node -e "console.log(require('./package.json').dependencies['$p'] || '')")
111
149
  if [[ -z "$RANGE" ]]; then continue; fi
112
150
  if ! LATEST=$(npm view "$p" version 2>/dev/null); then
113
- echo " $p: could not ask npm (offline?) - requires ${RANGE}, check it yourself"
151
+ say " $p: could not ask npm (offline?) - requires ${RANGE}, check it yourself"
114
152
  continue
115
153
  fi
116
154
  MATCH=$(published_match "$p" "$RANGE")
117
- if [[ -z "$MATCH" ]]; then
118
- echo " $p: requires ${RANGE}, but the newest published is ${LATEST}"
155
+ if [[ -z "$MATCH" ]] && satisfied_by_release "$RANGE"; then
156
+ # Mid-release: this range was raised to the version being published, and $p has not been
157
+ # published at it yet. Expected, and the publishing step is about to deal with it.
158
+ say " $p: requires ${RANGE}, awaiting the ${RELEASE_VERSION} publish (newest so far is ${LATEST})"
159
+ elif [[ -z "$MATCH" ]]; then
160
+ say " $p: requires ${RANGE}, but the newest published is ${LATEST}"
119
161
  BLOCKERS+=("Nothing published satisfies ${p}@${RANGE} (newest is ${LATEST}) - publish ${p} first, or every \"npm install frugal-iot-server\" fails.")
120
162
  elif [[ "$MATCH" != "$LATEST" ]]; then
121
- echo " $p: requires ${RANGE} -> ${MATCH}, but ${LATEST} is published"
163
+ say " $p: requires ${RANGE} -> ${MATCH}, but ${LATEST} is published"
122
164
  WARNINGS+=("${p}@${RANGE} resolves to ${MATCH}, not the newest published ${LATEST} - bump the range if you meant installs to pick up ${LATEST}.")
123
165
  else
124
166
  # The range allows the newest, but if its floor is older then an existing install can sit on
@@ -126,10 +168,10 @@ for p in frugal-iot-logger frugal-iot-client; do
126
168
  FLOOR=${RANGE#[\^~>=]}
127
169
  FLOOR=${FLOOR#=}
128
170
  if [[ -n "$FLOOR" && "$FLOOR" != "$LATEST" ]]; then
129
- echo " $p: requires ${RANGE}, newest published ${LATEST} (an existing install may stay on ${FLOOR})"
171
+ say " $p: requires ${RANGE}, newest published ${LATEST} (an existing install may stay on ${FLOOR})"
130
172
  WARNINGS+=("${p} requires ${RANGE}, so an install that already has ${FLOOR} is not obliged to move to ${LATEST}. Raise the range to ^${LATEST} if this release needs it.")
131
173
  else
132
- echo " $p: requires ${RANGE}, newest published ${LATEST} - in step"
174
+ say " $p: requires ${RANGE}, newest published ${LATEST} - in step"
133
175
  fi
134
176
  fi
135
177
  done
@@ -139,27 +181,33 @@ done
139
181
  # installed PWA fetch it again. Left behind, people keep running the client they already had, which
140
182
  # looks exactly like a release that did nothing. It tracks the client version, so set it from
141
183
  # whichever published client this release will actually resolve to.
142
- echo
143
- echo "=== Checking the service worker's cache version ==="
184
+ say
185
+ say "=== Checking the service worker's cache version ==="
144
186
  SW=public/service-worker.js
145
187
  CLIENT_RANGE=$(node -e "console.log(require('./package.json').dependencies['frugal-iot-client'] || '')")
146
188
  if [[ ! -f "$SW" ]]; then
147
- echo " no ${SW} here"
189
+ say " no ${SW} here"
148
190
  elif [[ -z "$CLIENT_RANGE" ]]; then
149
- echo " package.json has no frugal-iot-client dependency to match against"
191
+ say " package.json has no frugal-iot-client dependency to match against"
150
192
  else
151
193
  SW_VERSION=$(sed -n "s/^const CACHE_NAME = 'frugal-iot-cache-\([^']*\)'.*/\1/p" "$SW" | head -1)
152
194
  CLIENT_LATEST=$(npm view frugal-iot-client version 2>/dev/null || true)
153
195
  CLIENT_WANTED=$(published_match frugal-iot-client "$CLIENT_RANGE")
196
+ # Mid-release the client this ships with is the one about to be published, not the newest on npm
197
+ # today, so follow that instead - otherwise CACHE_NAME would be set back to last month's version.
198
+ if satisfied_by_release "$CLIENT_RANGE"; then
199
+ CLIENT_WANTED="$RELEASE_VERSION"
200
+ CLIENT_LATEST="$RELEASE_VERSION"
201
+ fi
154
202
  if [[ -z "$SW_VERSION" ]]; then
155
- echo " could not find a CACHE_NAME line of the form frugal-iot-cache-<version> in ${SW}"
203
+ say " could not find a CACHE_NAME line of the form frugal-iot-cache-<version> in ${SW}"
156
204
  WARNINGS+=("Could not read CACHE_NAME from ${SW} - check by hand that it changes when the client does.")
157
205
  elif [[ -z "$CLIENT_WANTED" ]]; then
158
- echo " CACHE_NAME says ${SW_VERSION}, and nothing published satisfies ${CLIENT_RANGE}"
206
+ say " CACHE_NAME says ${SW_VERSION}, and nothing published satisfies ${CLIENT_RANGE}"
159
207
  BLOCKERS+=("Cannot set CACHE_NAME: no published frugal-iot-client satisfies ${CLIENT_RANGE}.")
160
208
  else
161
209
  if [[ "$SW_VERSION" == "$CLIENT_WANTED" ]]; then
162
- echo " CACHE_NAME is ${SW_VERSION}, matching the client this release installs"
210
+ say " CACHE_NAME is ${SW_VERSION}, matching the client this release installs"
163
211
  else
164
212
  # An in-place edit rather than a warning: there is only one right answer, and a stale cache
165
213
  # name is invisible until somebody's browser keeps serving them last month's dashboard
@@ -174,16 +222,19 @@ else
174
222
  fi
175
223
 
176
224
  # ---- What would and would not be published ----
177
- echo
178
- echo "=== Checking what the package would contain ==="
179
- if ! node scripts/check-published-files.js; then
225
+ say
226
+ say "=== Checking what the package would contain ==="
227
+ if ! FILESOUT=$(node scripts/check-published-files.js 2>&1); then
228
+ print -r -- "$FILESOUT"
180
229
  BLOCKERS+=("Something git ignores would be published - see above.")
230
+ else
231
+ say "$FILESOUT"
181
232
  fi
182
233
 
183
- echo
184
- echo "=== Reminders ==="
185
- echo " - Is the version in package.json the one you mean to publish? $(node -e 'console.log(require("./package.json").version)')"
186
- echo " - Nothing here is committed or published - do that yourself."
234
+ say
235
+ say "=== Reminders ==="
236
+ say " - Is the version in package.json the one you mean to publish? $(node -e 'console.log(require("./package.json").version)')"
237
+ say " - Nothing here is committed or published - do that yourself."
187
238
 
188
239
  if (( ${#WARNINGS} )); then
189
240
  echo
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Make the broker's dynamic-security state match this server's database, or just report where it
4
+ * differs.
5
+ *
6
+ * npx --no frugal-iot-rebuild-dynsec apply
7
+ * npx --no frugal-iot-rebuild-dynsec check report only, change nothing
8
+ *
9
+ * Run from the server's own directory. Everything it writes is either stored in the database or
10
+ * derivable from it, so it is safe to re-run: after restoring a database, after a release that adds
11
+ * a rule, or when something looks wrong. "check" is what scripts/diagnostic.zsh calls.
12
+ *
13
+ * "check", not "--check": npm parses the whole command line itself and swallows any --flag it does
14
+ * not recognise, so "npx --no frugal-iot-rebuild-dynsec --check" arrives here with no arguments and
15
+ * would silently APPLY instead of reporting. That is the npx trap in claude.md, and this command hit
16
+ * it - "--check" is still accepted for a direct "node scripts/rebuild-dynsec.js --check".
17
+ */
18
+
19
+ import sqlite3 from 'sqlite3';
20
+ import { MqttLogger } from 'frugal-iot-logger';
21
+ import { dynsecConnect } from '../lib/dynsec.js';
22
+ import {
23
+ readScopes, readPublics, readProjects, readUserRows,
24
+ applyRolesAndGroups, applyUser, applyNode, applyLogger, applyOrgAdmin, checkRolesAndGroups,
25
+ } from '../lib/dynsec-sync.js';
26
+ import { names } from '../lib/dynsec-plan.js';
27
+
28
+ const userClientName = names.userClient;
29
+
30
+ const ARGS = process.argv.slice(2);
31
+ const CHECK = ARGS.includes('check') || ARGS.includes('--check');
32
+ const DB = './frugal-iot.db';
33
+
34
+ function fail(msg, code = 1) {
35
+ console.error(msg);
36
+ process.exit(code);
37
+ }
38
+
39
+ // The same reader the server uses, so this sees exactly the configuration the server sees -
40
+ // config.yaml plus config.d, including config.d/secrets.yaml.
41
+ //
42
+ // It logs a line per file it reads, which is fine at server startup and noise in a command whose
43
+ // output scripts/diagnostic.zsh includes - so it is quietened for the duration of the read only.
44
+ const realLog = console.log;
45
+ console.log = () => {};
46
+ new MqttLogger().readYamlConfig('.', (err, config) => {
47
+ console.log = realLog;
48
+ if (err) return fail(`Could not read the configuration: ${err.message}`);
49
+ const orgs = Object.keys((config && config.organizations) || {});
50
+ const userSecret = config.secrets && config.secrets.user_secret;
51
+ if (!CHECK && !userSecret) {
52
+ return fail('No user_secret in config.d/secrets.yaml - run: npx --no frugal-iot-init');
53
+ }
54
+
55
+ const db = new sqlite3.Database(DB, sqlite3.OPEN_READONLY, (dberr) => {
56
+ if (dberr) return fail(`Could not open ${DB}: ${dberr.message}. Run this from the server's own directory.`);
57
+
58
+ dynsecConnect(config, (cerr, dynsec) => {
59
+ if (cerr) {
60
+ // Being unable to reach the plugin is a normal state on a broker that has none, so say what
61
+ // it means rather than only what failed.
62
+ console.error(`Cannot reach the dynamic security plugin: ${cerr.message}`);
63
+ console.error('If this broker has no plugin, per-user accounts are unavailable and that is');
64
+ console.error('all this means. frugal-iot-diagnostic reports which case you are in.');
65
+ process.exit(2);
66
+ }
67
+
68
+ readScopes(db, orgs, (e1, scopes) => {
69
+ if (e1) return fail(e1.message);
70
+ readPublics(db, (e2, publics) => {
71
+ if (e2) return fail(e2.message);
72
+ readProjects(db, (e2b, projects) => {
73
+ if (e2b) return fail(e2b.message);
74
+
75
+ if (CHECK) {
76
+ checkRolesAndGroups(dynsec, scopes, publics, projects, (e3, differences) => {
77
+ if (e3) return fail(e3.message);
78
+ db.all('SELECT id, username FROM users WHERE id > 0 AND username IS NOT NULL', [], (e4, users) => {
79
+ if (e4) return fail(e4.message);
80
+ dynsec.listClients((e5, clients) => {
81
+ if (e5) return fail(e5.message);
82
+ for (const u of users) {
83
+ const want = userClientName(u.username);
84
+ if (!clients.includes(want)) differences.push(`client missing: ${want}`);
85
+ }
86
+ for (const org of orgs) {
87
+ const want = names.loggerClient(org);
88
+ if (!clients.includes(want)) differences.push(`client missing: ${want}`);
89
+ }
90
+ report(differences);
91
+ dynsec.end(() => process.exit(differences.length ? 3 : 0));
92
+ });
93
+ });
94
+ });
95
+ return;
96
+ }
97
+
98
+ applyRolesAndGroups(dynsec, scopes, publics, projects, (e3) => {
99
+ if (e3) return fail(`Setting up roles and groups: ${e3.message}`);
100
+ console.log(`Roles and groups for ${scopes.length} organization/project scope(s), ` +
101
+ `${projects.length} project(s), ${publics.length} publicly readable`);
102
+ syncUsers(db, dynsec, userSecret, (e4, n) => {
103
+ if (e4) return fail(`Syncing users: ${e4.message}`);
104
+ console.log(`${n} user account(s)`);
105
+ syncNodes(db, dynsec, (e5, m) => {
106
+ if (e5) return fail(`Syncing nodes: ${e5.message}`);
107
+ console.log(`${m} node account(s)`);
108
+ syncLoggers(dynsec, orgs, userSecret, (e6, k) => {
109
+ if (e6) return fail(`Syncing logger accounts: ${e6.message}`);
110
+ console.log(`${k} logger account(s)`);
111
+ syncOrgAdmins(dynsec, orgs, userSecret, (e7, j) => {
112
+ if (e7) return fail(`Syncing admin accounts: ${e7.message}`);
113
+ console.log(`${j} server admin account(s)`);
114
+ dynsec.end(() => process.exit(0));
115
+ });
116
+ });
117
+ });
118
+ });
119
+ });
120
+ });
121
+ });
122
+ });
123
+ });
124
+ });
125
+ });
126
+
127
+ function report(differences) {
128
+ if (!differences.length) {
129
+ console.log('The broker matches the database.');
130
+ } else {
131
+ console.log(`${differences.length} difference(s) between the database and the broker:`);
132
+ for (const d of differences) console.log(` ${d}`);
133
+ console.log('Run without --check to correct them.');
134
+ }
135
+ }
136
+
137
+ // One at a time on purpose: a Pi Zero has one core, there is no hurry, and a batch that fails
138
+ // half-way is harder to reason about than a sequence that stops.
139
+ function syncUsers(db, dynsec, userSecret, cb) {
140
+ db.all('SELECT id, username, hashed_password FROM users WHERE id > 0 AND username IS NOT NULL', [], (err, users) => {
141
+ if (err) return cb(err);
142
+ let i = 0;
143
+ const next = () => {
144
+ if (i >= users.length) return cb(null, users.length);
145
+ const u = users[i++];
146
+ readUserRows(db, u.id, (e, rows) => {
147
+ if (e) return cb(e);
148
+ applyUser(dynsec, { username: u.username, hashedPassword: u.hashed_password, rows, userSecret },
149
+ (e2) => (e2 ? cb(e2) : next()));
150
+ });
151
+ };
152
+ next();
153
+ });
154
+ }
155
+
156
+ function syncLoggers(dynsec, orgs, userSecret, cb) {
157
+ let i = 0;
158
+ const next = () => {
159
+ if (i >= orgs.length) return cb(null, orgs.length);
160
+ applyLogger(dynsec, { org: orgs[i++], userSecret }, (e) => (e ? cb(e) : next()));
161
+ };
162
+ next();
163
+ }
164
+
165
+ function syncOrgAdmins(dynsec, orgs, userSecret, cb) {
166
+ let i = 0;
167
+ const next = () => {
168
+ if (i >= orgs.length) return cb(null, orgs.length);
169
+ applyOrgAdmin(dynsec, { org: orgs[i++], userSecret }, (e) => (e ? cb(e) : next()));
170
+ };
171
+ next();
172
+ }
173
+
174
+ function syncNodes(db, dynsec, cb) {
175
+ // The nodes table arrives with S6; until then there is nothing to do and that is not an error.
176
+ db.all("SELECT name FROM sqlite_master WHERE type='table' AND name='nodes'", [], (err, t) => {
177
+ if (err) return cb(err);
178
+ if (!t.length) return cb(null, 0);
179
+ db.all('SELECT org, project, nodeid, password, lora FROM nodes', [], (e, nodes) => {
180
+ if (e) return cb(e);
181
+ let i = 0;
182
+ const next = () => {
183
+ if (i >= nodes.length) return cb(null, nodes.length);
184
+ const n = nodes[i++];
185
+ applyNode(dynsec, { ...n, lora: !!n.lora }, (e2) => (e2 ? cb(e2) : next()));
186
+ };
187
+ next();
188
+ });
189
+ });
190
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Forget a node, so it can enrol again.
4
+ *
5
+ * npx --no frugal-iot-resetnode <org> <project> <nodeid>
6
+ *
7
+ * Run from the server's own directory.
8
+ *
9
+ * A node proves it is itself by presenting the credential it already holds, so only the node can be
10
+ * issued a new one - otherwise anyone with the organization's enrolment secret could take over any
11
+ * node by quoting its id, which is public (it comes from the chip's MAC). The cost of that is this
12
+ * command: a node whose filesystem has been erased can prove nothing, and somebody has to say "yes,
13
+ * that really is my node".
14
+ *
15
+ * Rare in the field. Common while developing, where erasing flash is routine - two ways to avoid it
16
+ * there are to keep using the older configure_mqtt(host, user, password) form, which does not enrol
17
+ * at all, or to give a development organization its own looser policy.
18
+ *
19
+ * Positional arguments, no flags: npm swallows any --flag it does not recognise, so a flag would
20
+ * arrive here as nothing at all. See the npx note in claude.md.
21
+ */
22
+
23
+ import sqlite3 from 'sqlite3';
24
+ import { MqttLogger } from 'frugal-iot-logger';
25
+ import { forgetNode } from '../lib/enrol.js';
26
+ import { dropNode } from '../lib/dynsec-server.js';
27
+
28
+ const [org, project, nodeid] = process.argv.slice(2);
29
+ if (!org || !project || !nodeid) {
30
+ console.error('Usage: npx --no frugal-iot-resetnode <org> <project> <nodeid>');
31
+ console.error('Example: npx --no frugal-iot-resetnode myfarm lotus esp32-abc123');
32
+ process.exit(1);
33
+ }
34
+
35
+ const realLog = console.log;
36
+ console.log = () => {}; // the config reader logs a line per file
37
+ new MqttLogger().readYamlConfig('.', (err, config) => {
38
+ console.log = realLog;
39
+ if (err) {
40
+ console.error(`Could not read the configuration: ${err.message}`);
41
+ process.exit(1);
42
+ }
43
+ const db = new sqlite3.Database('./frugal-iot.db', (dberr) => {
44
+ if (dberr) {
45
+ console.error(`Could not open ./frugal-iot.db: ${dberr.message}`);
46
+ console.error("Run this from the server's own directory.");
47
+ process.exit(1);
48
+ }
49
+ forgetNode(db, org, project, nodeid, (ferr, changes) => {
50
+ if (ferr) { console.error(ferr.message); process.exit(1); }
51
+ if (!changes) {
52
+ console.log(`No enrolled node ${org}/${project}/${nodeid} - nothing to forget.`);
53
+ console.log('It can enrol as soon as it asks. Check the spelling if you expected one.');
54
+ process.exit(0);
55
+ }
56
+ console.log(`Forgot ${org}/${project}/${nodeid} - it will enrol again on its next attempt.`);
57
+ // Remove the broker account too, so nothing is left that the database no longer knows about.
58
+ // Best effort: enrolling recreates it, and a broker that cannot be reached now is reported by
59
+ // frugal-iot-diagnostic as drift.
60
+ dropNode(config, org, project, nodeid, (derr) => {
61
+ if (derr) {
62
+ console.log(`The broker account was not removed: ${derr.message}`);
63
+ console.log('Harmless - re-enrolling replaces it. frugal-iot-rebuild-dynsec tidies up.');
64
+ }
65
+ db.close(() => process.exit(0));
66
+ });
67
+ });
68
+ });
69
+ });