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.
@@ -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
@@ -56,7 +56,7 @@ copy_if_missing() {
56
56
 
57
57
  echo "Configuration:"
58
58
  copy_if_missing "${PKG}/config.yaml" "./config.yaml"
59
- for f in logger.yaml mqtt.yaml server.yaml; do
59
+ for f in logger.yaml mqtt.yaml server.yaml email.yaml; do
60
60
  copy_if_missing "${PKG}/config.d/${f}" "./config.d/${f}"
61
61
  done
62
62
  # The schema describes the sensor types the software understands, so a release changing it matters
@@ -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;
@@ -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