frugal-iot-server 0.3.9 → 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.
@@ -32,7 +32,7 @@ SCRIPT_NAME=$0
32
32
 
33
33
  usage() {
34
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] [--replace]" >&2
35
+ echo " or: ${SCRIPT_NAME} --org <org-id> --host <prod-host> --account <account> [--port N] [--password PW] [--replica-token T] [--replace]" >&2
36
36
  echo "Example: ${SCRIPT_NAME} myfarm prod.example.org bridge-northfield" >&2
37
37
  echo "" >&2
38
38
  echo "Through npx, use the positional form: npm swallows unrecognised --flags and passes only" >&2
@@ -40,7 +40,7 @@ usage() {
40
40
  exit 1
41
41
  }
42
42
 
43
- 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
44
  POSITIONAL=()
45
45
 
46
46
  while [[ $# -gt 0 ]]; do
@@ -49,6 +49,7 @@ while [[ $# -gt 0 ]]; do
49
49
  --host) PROD_HOST=${2:-}; shift 2 ;;
50
50
  --account) ACCOUNT=${2:-}; shift 2 ;;
51
51
  --password) PASSWORD=${2:-}; shift 2 ;;
52
+ --replica-token) REPLICA_TOKEN=${2:-}; shift 2 ;;
52
53
  --port) PROD_PORT=${2:-}; shift 2 ;;
53
54
  --replace) REPLACE=true; shift ;;
54
55
  -*) echo "Error: unknown option '$1'" >&2; usage ;;
@@ -190,5 +191,58 @@ else
190
191
  echo " sudo tail -20 /var/log/mosquitto/mosquitto.log"
191
192
  echo "and the state is reported by: npx --no frugal-iot-diagnostic"
192
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
+
193
247
  echo ""
194
248
  echo "The readings this Pi has already recorded stay here - only new ones are relayed."
@@ -196,6 +196,46 @@ 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 ""
@@ -214,6 +254,14 @@ if [[ "$GENERATED" == true ]]; then
214
254
  echo " sudo -u \"\$(stat -c '%U' ${PWFILE})\" mosquitto_passwd -b ${PWFILE} ${ACCOUNT} '<new password>'"
215
255
  echo "and use that on the Pi instead."
216
256
  fi
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
217
265
  echo "Fill in the public name yourself - it is deliberately not guessed here, because what this"
218
266
  echo "machine calls itself is usually not it. This host answers to \"$(hostname -f 2>/dev/null || hostname)\","
219
267
  echo "which is only the right answer if that is also the DNS name the Pi can reach and the name on"
@@ -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}"
@@ -217,8 +217,19 @@ BROKER_URL=$(grep -h '^broker:' config.d/mqtt.yaml 2>/dev/null | awk '{print $2}
217
217
  BROKER_HOST=${${${BROKER_URL#*://}%%/*}%%:*}
218
218
  if [[ -n "$BROKER_HOST" ]]; then
219
219
  item "broker host: $BROKER_HOST"
220
- if have getent && BROKER_IP=$(getent hosts "$BROKER_HOST" 2>/dev/null | head -1 | awk '{print $1}') && [[ -n "$BROKER_IP" ]]; then
221
- item " resolves to $BROKER_IP from this machine"
220
+ # EVERY address, both families. A name that also offers an IPv6 address is the one way this has
221
+ # actually bitten: a client may prefer the AAAA, and if that address is transient - a global
222
+ # prefix from the router coming and going - connections stall while the name still resolves and
223
+ # nothing is wrong at either end. Seen on the test Pi, where avahi registered and withdrew a
224
+ # global IPv6 address every few minutes; `journalctl -u avahi-daemon` is where that shows.
225
+ BROKER_ADDRS=""
226
+ have getent && BROKER_ADDRS=$(getent ahosts "$BROKER_HOST" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')
227
+ if [[ -n "$BROKER_ADDRS" ]]; then
228
+ item " resolves to $BROKER_ADDRS from this machine"
229
+ if print -r -- "$BROKER_ADDRS" | grep -q ':'; then
230
+ item " it offers an IPv6 address as well: a client that picks that one fails on its own if"
231
+ item " the address is transient. Check 'journalctl -u avahi-daemon' for it being withdrawn."
232
+ fi
222
233
  elif have ping && ping -c1 -W2 "$BROKER_HOST" >/dev/null 2>&1; then
223
234
  item " answers ping from this machine"
224
235
  else
@@ -230,6 +241,110 @@ fi
230
241
  MYADDRS=$( (have ip && ip -4 -o addr show scope global | awk '{print $2"="$4}') 2>/dev/null | tr '\n' ' ')
231
242
  item "this machine's addresses: ${MYADDRS:-(could not determine)}"
232
243
 
244
+ section "Who is connected to the broker"
245
+ # The single most useful line when a node or a browser says it cannot connect: if the server's own
246
+ # logger is connected, the broker is up, listening and authenticating, and the problem is at the
247
+ # other end. That is what settled it on 7 Sep 2026, when a node reporting a failed TCP connect and a
248
+ # browser reporting a failed WebSocket handshake both turned out to have been asleep.
249
+ BROKER_CONNS=""
250
+ if have ss; then
251
+ # Only the sockets whose LOCAL port is the broker's. A connection with both ends on this machine -
252
+ # which is what the server's own logger is - otherwise appears twice, once from each side, and the
253
+ # count comes out too high. With "state established" ss prints no State column, so the local
254
+ # address is $3 and the peer $4.
255
+ BROKER_CONNS=$(ss -tn state established 2>/dev/null |
256
+ awk '$3 ~ /:(1883|9012)$/ {print $4 " -> " $3}' || true)
257
+ CONN_COUNT=$(print -r -- "$BROKER_CONNS" | grep -c . || true)
258
+ item "clients connected to 1883/9012: ${CONN_COUNT:-0}"
259
+ if [[ -n "$BROKER_CONNS" ]]; then
260
+ print -r -- "$BROKER_CONNS" | sed 's/^/ /'
261
+ else
262
+ item " nothing is connected - not even this server's own logger"
263
+ problem "No client is connected to the broker. If the frugaliot service is running, its logger should be - check its journal above for 'mqtt <org> connect'"
264
+ fi
265
+ else
266
+ item "ss not available - cannot list connections"
267
+ fi
268
+
269
+ section "Broker access control"
270
+ # An acl_file confines each account to its own organization's topics; without one, every account
271
+ # that can log in reaches every topic on the broker. Reported here because a broker installed before
272
+ # the ACL shipped will not have one, and nothing else says so.
273
+ BROKER_CONF_FILES=(/etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N))
274
+ BROKER_SETTING=""
275
+ broker_setting() { # name -> value from the broker's own configuration, sudo only if needed
276
+ local v
277
+ v=$(grep -hE "^[[:space:]]*$1[[:space:]]" "${BROKER_CONF_FILES[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
278
+ if [[ -z "$v" ]] && have sudo; then
279
+ v=$(sudo -n grep -hE "^[[:space:]]*$1[[:space:]]" "${BROKER_CONF_FILES[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
280
+ fi
281
+ BROKER_SETTING="$v"
282
+ }
283
+ broker_setting acl_file; ACLFILE="$BROKER_SETTING"
284
+ if [[ -z "$ACLFILE" ]]; then
285
+ item "acl_file: NOT SET - every account that can log in can reach every topic, across all organizations"
286
+ problem "The broker has no acl_file. Re-run the install script, or see INSTALLATION.md 'Turn on access control'"
287
+ else
288
+ item "acl_file: $ACLFILE"
289
+ if [[ -r "$ACLFILE" ]] || { have sudo && sudo -n test -r "$ACLFILE" 2>/dev/null; }; then
290
+ ACLOWNER=$( (stat -c '%U:%G %a' "$ACLFILE" 2>/dev/null || sudo -n stat -c '%U:%G %a' "$ACLFILE" 2>/dev/null) || true)
291
+ item " owner/mode: ${ACLOWNER:-unknown} (mosquitto reads it after dropping privileges, so it wants mosquitto:mosquitto 600)"
292
+ fi
293
+ fi
294
+
295
+ # The dynamic security plugin. install-pi.sh comments it out and carries on if the plugin is not
296
+ # installed, so a broker can be working correctly and still not support per-user accounts - which is
297
+ # worth saying plainly rather than leaving somebody to wonder why they cannot be created.
298
+ broker_setting plugin; DYNPLUGIN="$BROKER_SETTING"
299
+ broker_setting plugin_opt_config_file; DYNSTATE="$BROKER_SETTING"
300
+ if [[ -z "$DYNPLUGIN" ]]; then
301
+ item "dynamic security: not configured - running on shared organization passwords (the fallback)"
302
+ item " per-user and per-node broker accounts are unavailable until the plugin is enabled"
303
+ if [[ -n "$(print -rl -- /usr/lib/*/mosquitto_dynamic_security.so /usr/lib/mosquitto_dynamic_security.so(N) 2>/dev/null)" ]]; then
304
+ item " the plugin IS installed on this machine, so re-running the install script would enable it"
305
+ problem "mosquitto's dynamic security plugin is installed but not configured - re-run install-pi.sh"
306
+ else
307
+ item " the plugin is not installed on this machine either (mosquitto package does not ship it here)"
308
+ fi
309
+ else
310
+ item "dynamic security: $DYNPLUGIN"
311
+ if [[ -n "$DYNSTATE" ]]; then
312
+ if [[ -e "$DYNSTATE" ]] || { have sudo && sudo -n test -e "$DYNSTATE" 2>/dev/null; }; then
313
+ item " state file: $DYNSTATE"
314
+ else
315
+ item " state file: $DYNSTATE MISSING - the broker will not start"
316
+ problem "The broker names a dynamic-security state file that does not exist: $DYNSTATE"
317
+ fi
318
+ fi
319
+ # Can the server actually drive it? That needs the admin credential frugal-iot-init recorded.
320
+ DYNUSER=$(sed -n 's/^dynsec_admin_user:[[:space:]]*//p' config.d/secrets.yaml 2>/dev/null | tr -d '"'"'"'' | head -1)
321
+ DYNPW=$(sed -n 's/^dynsec_admin_password:[[:space:]]*//p' config.d/secrets.yaml 2>/dev/null | tr -d '"'"'"'' | head -1)
322
+ if [[ -z "$DYNUSER" || -z "$DYNPW" ]]; then
323
+ item " admin credential: not in config.d/secrets.yaml - the server cannot create accounts"
324
+ problem "dynamic security is enabled but config.d/secrets.yaml has no dynsec_admin_user/password"
325
+ elif have mosquitto_ctrl; then
326
+ if DYNOUT=$(mosquitto_ctrl -h localhost -u "$DYNUSER" -P "$DYNPW" dynsec listClients 2>&1 | grep -viE 'without encryption|visible on the network'); then
327
+ item " admin credential works ($(print -r -- $DYNOUT | tr '\n' ' ' | cut -c1-60))"
328
+ else
329
+ item " admin credential REJECTED by the broker"
330
+ problem "The dynsec admin credential in config.d/secrets.yaml does not work - the server cannot create accounts"
331
+ fi
332
+ else
333
+ item " mosquitto_ctrl not installed - cannot test the admin credential"
334
+ fi
335
+ # Does the broker's state still match the database? Reporting drift is useful far more often than
336
+ # repairing it, which is why this runs "check" and never applies anything.
337
+ if [[ -f node_modules/frugal-iot-server/scripts/rebuild-dynsec.js ]]; then
338
+ DYNCHECK=$(node node_modules/frugal-iot-server/scripts/rebuild-dynsec.js check 2>&1) || true
339
+ if print -r -- "$DYNCHECK" | grep -q "matches the database"; then
340
+ item " broker state matches the database"
341
+ else
342
+ print -r -- "$DYNCHECK" | sed 's/^/ /'
343
+ problem "The broker's accounts do not match the database - run: npx --no frugal-iot-rebuild-dynsec"
344
+ fi
345
+ fi
346
+ fi
347
+
233
348
  section "Broker authentication (steps 5, 6)"
234
349
  if have mosquitto_sub; then
235
350
  # A wrong password must be refused - that is the check in step 5
@@ -245,6 +360,10 @@ if have mosquitto_sub; then
245
360
  fi
246
361
  # Then each configured organization should be able to connect with its own credentials.
247
362
  # The password is read from the config and used, never printed.
363
+ #
364
+ # This is the SHARED organization account, which every node and dashboard used to use. Each of
365
+ # them now has its own (see SECURITY.md), and this account is removed by S8 - after which "no
366
+ # mqtt_password" below is the right answer rather than a fault.
248
367
  for f in config.d/organizations/*.yaml(N); do
249
368
  ORG=${f:t:r}
250
369
  ORG_PW=$(sed -n 's/^mqtt_password:[[:space:]]*//p' "$f" 2>/dev/null | head -1 | tr -d '"'"'"'')
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Create the broker's initial dynamic-security state, without mosquitto_ctrl.
4
+ *
5
+ * npx --no frugal-iot-dynsec-init [output-path]
6
+ *
7
+ * Run from the server's own directory, the one holding frugal-iot.db.
8
+ *
9
+ * Why this exists. `mosquitto_ctrl dynsec init` does the same job and is what install-pi.sh uses,
10
+ * but it comes from the mosquitto-clients package, and on the production server that package cannot
11
+ * be installed: its broker was built for an older Ubuntu from the mosquitto PPA, and the
12
+ * mosquitto-clients in the distribution demands an exactly-matching libmosquitto1 - so apt would
13
+ * downgrade the library out from under the running broker. Without this, per-user broker accounts
14
+ * could be set up on a Pi and not on production.
15
+ *
16
+ * What it writes is the file the plugin keeps its state in: one admin client, the admin role that
17
+ * lets it drive $CONTROL, and the defaults mosquitto_ctrl sets. Everything else - every user, node,
18
+ * logger, group and role - is created by the server from the database afterwards, so this file is a
19
+ * starting point rather than something to maintain (npx --no frugal-iot-rebuild-dynsec).
20
+ *
21
+ * It does NOT install the file: writing to /var/lib/mosquitto needs root, and running this whole
22
+ * script under sudo would leave config.d/secrets.yaml owned by root, which the server then could
23
+ * not rewrite. So it writes here, as you, and prints the one command that moves it into place.
24
+ */
25
+
26
+ import { pbkdf2Sync, randomBytes } from 'crypto';
27
+ import { existsSync, writeFileSync, appendFileSync, chmodSync, readFileSync } from 'fs';
28
+
29
+ const SECRETS = './config.d/secrets.yaml';
30
+ const ADMIN = 'frugal-admin';
31
+ const TARGET = '/var/lib/mosquitto/dynamic-security.json';
32
+ const out = process.argv[2] || './dynamic-security.json';
33
+
34
+ /*
35
+ * How mosquitto stores a dynsec password, confirmed by reproducing a file mosquitto_ctrl wrote:
36
+ * PBKDF2-HMAC-SHA512, the iteration count from the file, a 12-byte salt, 64 bytes out, both salt
37
+ * and hash base64. The 101 iterations are mosquitto's own default and are not a typo - this file is
38
+ * read only at startup and by the plugin itself, so it is not a password-store hash in the usual
39
+ * sense; changing the number here would be harmless but pointless.
40
+ */
41
+ function hash(password, iterations = 101) {
42
+ const salt = randomBytes(12);
43
+ return {
44
+ salt: salt.toString('base64'),
45
+ password: pbkdf2Sync(password, salt, iterations, 64, 'sha512').toString('base64'),
46
+ iterations,
47
+ };
48
+ }
49
+
50
+ function fail(message, ...rest) {
51
+ console.error(message);
52
+ for (const line of rest) console.error(line);
53
+ process.exit(1);
54
+ }
55
+
56
+ if (existsSync(out)) {
57
+ fail(`${out} already exists - not overwriting it.`,
58
+ 'If the broker is using it, replacing it would delete every account it knows about and',
59
+ 'every node would stop connecting. Move it aside first if you really mean to start again.');
60
+ }
61
+ if (!existsSync(SECRETS)) {
62
+ fail(`No ${SECRETS}.`,
63
+ "If you are in the server's own directory - the one holding frugal-iot.db - then this",
64
+ 'installation predates that file. It is created by: npx --no frugal-iot-init',
65
+ '',
66
+ 'Run that first. Do not create the file by hand and run this before upgrading the server:',
67
+ 'a release that predates config-for-user.js serves the whole configuration to any logged-in',
68
+ 'browser, so the admin credential this writes would go out with it.');
69
+ }
70
+ if (/^dynsec_admin_user:/m.test(readFileSync(SECRETS, 'utf8'))) {
71
+ fail(`${SECRETS} already has a dynsec_admin_user.`,
72
+ 'This server already has an admin credential, so the broker most likely already has its',
73
+ 'state file. Check with: npx --no frugal-iot-diagnostic');
74
+ }
75
+
76
+ const password = randomBytes(24).toString('base64url');
77
+ const { salt, password: hashed, iterations } = hash(password);
78
+
79
+ // The shape mosquitto_ctrl produces. Only the admin client and its role: the server creates
80
+ // everything else from the database.
81
+ const state = {
82
+ defaultACLAccess: {
83
+ publishClientSend: false,
84
+ publishClientReceive: true,
85
+ subscribe: false,
86
+ unsubscribe: true,
87
+ },
88
+ clients: [{
89
+ username: ADMIN,
90
+ textname: 'Dynsec admin user',
91
+ roles: [{ rolename: 'admin' }],
92
+ password: hashed,
93
+ salt,
94
+ iterations,
95
+ }],
96
+ groups: [],
97
+ roles: [{
98
+ rolename: 'admin',
99
+ acls: [
100
+ { acltype: 'publishClientSend', topic: '$CONTROL/dynamic-security/#', priority: 0, allow: true },
101
+ { acltype: 'publishClientReceive', topic: '$CONTROL/dynamic-security/#', priority: 0, allow: true },
102
+ { acltype: 'publishClientReceive', topic: '$SYS/#', priority: 0, allow: true },
103
+ { acltype: 'subscribePattern', topic: '$CONTROL/dynamic-security/#', priority: 0, allow: true },
104
+ { acltype: 'subscribePattern', topic: '$SYS/#', priority: 0, allow: true },
105
+ ],
106
+ }],
107
+ };
108
+
109
+ writeFileSync(out, JSON.stringify(state, null, 2) + '\n');
110
+ chmodSync(out, 0o600);
111
+
112
+ appendFileSync(SECRETS, [
113
+ '',
114
+ '# The broker account the server uses to create and remove other accounts, over',
115
+ '# $CONTROL/dynamic-security/v1. Created by frugal-iot-dynsec-init.',
116
+ `dynsec_admin_user: "${ADMIN}"`,
117
+ `dynsec_admin_password: "${password}"`,
118
+ '',
119
+ ].join('\n'));
120
+ chmodSync(SECRETS, 0o600);
121
+
122
+ console.log(`Wrote ${out}, and the admin credential to ${SECRETS}.`);
123
+ console.log('');
124
+ console.log('The password is not printed: it is in that file, which is never served to a browser.');
125
+ console.log('');
126
+ console.log('Next, as root - the broker reads this file AFTER dropping privileges, so it has to');
127
+ console.log('belong to the mosquitto user or the broker will not start:');
128
+ console.log('');
129
+ console.log(` sudo install -o mosquitto -g mosquitto -m 600 ${out} ${TARGET}`);
130
+ console.log('');
131
+ console.log('Then add these two lines to the broker configuration - the plugin path differs by');
132
+ console.log('distribution, so find yours with: ls /usr/lib/*/mosquitto_dynamic_security.so');
133
+ console.log('');
134
+ console.log(' plugin /usr/lib/<arch>/mosquitto_dynamic_security.so');
135
+ console.log(` plugin_opt_config_file ${TARGET}`);
136
+ console.log('');
137
+ console.log('and restart the broker, then build the accounts from the database:');
138
+ console.log('');
139
+ console.log(' sudo systemctl restart mosquitto');
140
+ console.log(' sudo systemctl restart frugaliot');
141
+ console.log(' npx --no frugal-iot-rebuild-dynsec');
142
+ console.log(' npx --no frugal-iot-rebuild-dynsec check');
package/scripts/init.zsh CHANGED
@@ -69,6 +69,48 @@ for f in "${PKG}"/extras/*(N); do
69
69
  copy_if_missing "$f" "./extras/${f:t}" compare
70
70
  done
71
71
 
72
+ # ---- 1b. Secrets this server generates for itself ----
73
+ # Kept in config.d like everything else - that directory is not served (the static mounts are
74
+ # publicdir, htmldir, datadir, privatedir and node_modules), and what does reach a browser goes
75
+ # through unsafeCopyConfigFor, which withholds this section. Mode 600 because these are secrets
76
+ # whatever else is true.
77
+ #
78
+ # Never regenerated: rotating session_secret logs everyone out, and rotating user_secret invalidates
79
+ # every browser's broker credential until its next login. Both are recoverable, neither should
80
+ # happen because somebody re-ran init.
81
+ #
82
+ # Written here as well as by the server (lib/secrets.js fills in anything missing at startup) so
83
+ # that a permissions problem with this directory shows up now, while somebody is watching the
84
+ # install, rather than in the journal at first boot. The server appends only what it does not find,
85
+ # so the two cannot fight, and a release adding a new secret needs no change here.
86
+ if [[ ! -e ./config.d/secrets.yaml ]]; then
87
+ # ( set +o pipefail ) because head closing the pipe makes tr fail, which under pipefail would
88
+ # end the script - see the note in claude.md about this exact trap.
89
+ randsecret() { ( set +o pipefail; LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 43 ); }
90
+ mkdir -p ./config.d
91
+ # No "secrets:" wrapper inside the file: readConfigFromDir keys each config.d file by its own
92
+ # name, so this file's contents land at config.secrets - the same way server.yaml's contents land
93
+ # at config.server. A wrapper here would give config.secrets.secrets.
94
+ cat > ./config.d/secrets.yaml <<YAML
95
+ # Secrets belonging to this server. Generated by frugal-iot-init; never served to a browser
96
+ # (unsafeCopyConfigFor withholds this whole file's section). Not in git, and worth backing up
97
+ # alongside frugal-iot.db.
98
+
99
+ # Signs the session cookie. Changing it ends every session.
100
+ session_secret: "$(randsecret)"
101
+
102
+ # Mixed into each user's derived MQTT password. Changing it invalidates every browser's broker
103
+ # credential until its next login, and nothing else.
104
+ user_secret: "$(randsecret)"
105
+ YAML
106
+ chmod 600 ./config.d/secrets.yaml
107
+ COPIED+=("./config.d/secrets.yaml")
108
+ echo " created ./config.d/secrets.yaml (generated secrets, mode 600)"
109
+ else
110
+ chmod 600 ./config.d/secrets.yaml
111
+ echo " kept ./config.d/secrets.yaml (already present)"
112
+ fi
113
+
72
114
  # ---- 1a. Point server.yaml at the web client this instance actually has ----
73
115
  # The packaged server.yaml may have been published from a development checkout, where the client is
74
116
  # a sibling directory (../frugal-iot-client). When installed with npm the client is a dependency,
@@ -119,6 +161,27 @@ else
119
161
  echo " created ${DB}"
120
162
  fi
121
163
 
164
+ # ---- 3a. Schema changes that CREATE TABLE IF NOT EXISTS cannot make ----
165
+ # Adding permissions.project needs the UNIQUE constraint changed as well, and SQLite cannot alter a
166
+ # constraint - so the table is rebuilt. Guarded on the column being absent, because init runs on
167
+ # every upgrade. See scripts/migrate-permissions-project.sql for what it does and why.
168
+ PERM_COLS=$(sqlite3 "$DB" "pragma table_info(permissions);" | cut -d'|' -f2) || true
169
+ if ! print -r -- "$PERM_COLS" | grep -qx project; then
170
+ # A rebuild is the one change here that is not a no-op if it goes wrong, so keep a copy first.
171
+ DB_BACKUP="${DB}.bak-$(date +%Y%m%d%H%M%S)"
172
+ cp "$DB" "$DB_BACKUP"
173
+ if sqlite3 "$DB" < "${PKG}/scripts/migrate-permissions-project.sql"; then
174
+ echo " updated ${DB} (added permissions.project; existing rows are organization-wide)"
175
+ echo " previous copy kept at ${DB_BACKUP}"
176
+ else
177
+ echo " ERROR: failed to add permissions.project to ${DB}" >&2
178
+ echo " the database is unchanged; a copy from before the attempt is at ${DB_BACKUP}" >&2
179
+ exit 1
180
+ fi
181
+ else
182
+ echo " kept ${DB} permissions.project (already present)"
183
+ fi
184
+
122
185
  if (( ${#DIFFER_TO} )); then
123
186
  echo
124
187
  echo "These files were left as you have them, but this release ships a different version:"
@@ -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;