frugal-iot-server 0.3.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALLATION.md +219 -61
- package/config.d/email.yaml +23 -0
- package/config.d/schema/devices.yaml +0 -2
- package/config.d/schema/modules.yaml +344 -2
- package/config.d/schema/topics.yaml +140 -46
- package/extras/aclfile +38 -0
- package/extras/mosquitto.conf +29 -0
- package/frugal-iot-createdb.sql +82 -1
- package/frugal-iot-server.js +692 -80
- package/lib/api-routes.js +7 -7
- package/lib/config-for-user.js +115 -0
- package/lib/dynsec-plan.js +246 -0
- package/lib/dynsec-server.js +204 -0
- package/lib/dynsec-sync.js +209 -0
- package/lib/dynsec.js +166 -0
- package/lib/enrol.js +288 -0
- package/lib/mailer.js +43 -0
- package/lib/replica.js +246 -0
- package/lib/resetcode.js +103 -0
- package/lib/retained.js +110 -0
- package/lib/secrets.js +148 -0
- package/package.json +12 -5
- package/public/index.html +8 -8
- package/public/service-worker.js +76 -27
- package/scripts/addbridge-pi.zsh +91 -7
- package/scripts/addbridge-prod.zsh +57 -3
- package/scripts/addorganization.zsh +5 -0
- package/scripts/clearretained.js +185 -0
- package/scripts/diagnostic.zsh +121 -2
- package/scripts/dynsec-init.js +142 -0
- package/scripts/init.zsh +64 -1
- package/scripts/install-pi.sh +83 -4
- package/scripts/migrate-permissions-project.sql +21 -0
- package/scripts/not-published.txt +5 -0
- package/scripts/rebuild-dynsec.js +190 -0
- package/scripts/resetnode.js +69 -0
- package/scripts/clearretained.zsh +0 -153
package/scripts/diagnostic.zsh
CHANGED
|
@@ -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
|
-
|
|
221
|
-
|
|
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:"
|
package/scripts/install-pi.sh
CHANGED
|
@@ -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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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 "$
|
|
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
|