frugal-iot-server 2.0.1 → 2.0.2
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 +149 -128
- package/README.md +3 -0
- package/frugal-iot-createdb.sql +34 -1
- package/frugal-iot-server.js +56 -4
- package/lib/dynsec-plan.js +24 -0
- package/lib/enrol.js +132 -29
- package/package.json +7 -4
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +48 -19
- package/scripts/addorganization.zsh +25 -5
- package/scripts/diagnostic.zsh +149 -27
- package/scripts/init.zsh +106 -6
- package/scripts/install-pi.sh +269 -44
package/lib/enrol.js
CHANGED
|
@@ -72,14 +72,40 @@ function sameSecret(a, b) {
|
|
|
72
72
|
* leaked secret filling the database with invented nodes, and per node id stops repeated attempts
|
|
73
73
|
* against one real node. In memory is enough - it survives as long as the process, a restart is a
|
|
74
74
|
* fair reset, and persisting it would mean a disk write per attempt.
|
|
75
|
+
*
|
|
76
|
+
* The numbers were set for a node that retries every half hour - twice an hour, well under a limit
|
|
77
|
+
* of five. THAT IS NOT WHAT A DEEP-SLEEPING NODE DOES. nextEnrolTime lives in RAM, which deep sleep
|
|
78
|
+
* discards, so such a node asks once per WAKE: at the default ten-minute cycle that is six an hour,
|
|
79
|
+
* over the old per-node limit, and three such nodes exhausted the whole organization's twenty. A
|
|
80
|
+
* fleet flashed together and waiting to be approved is the ordinary case, not an attack, and the
|
|
81
|
+
* limit turned it into `rate_limited` rows that hid the real reason each node was being refused.
|
|
82
|
+
*
|
|
83
|
+
* So: twelve per node (a five-minute cycle) and a hundred and twenty per organization (twenty such
|
|
84
|
+
* nodes). What this costs is small, because the limiter is no longer the only thing bounding the
|
|
85
|
+
* damage - makeAttemptLog evicts to perOrg rows, throttles repeats to one write a minute, and
|
|
86
|
+
* writes nothing at all for a rate-limited request. And the per-node number never protected much:
|
|
87
|
+
* the only thing it guards by counting is the `current_password` proof, which is 24 random bytes
|
|
88
|
+
* and is not reachable by guessing at five an hour or at fifty.
|
|
75
89
|
*/
|
|
76
|
-
export function makeRateLimiter({ perOrg =
|
|
90
|
+
export function makeRateLimiter({ perOrg = 120, perNode = 12, windowMs = 3600000, now = Date.now,
|
|
91
|
+
sweepAbove = 1000 } = {}) {
|
|
77
92
|
const hits = new Map(); // key -> [timestamps]
|
|
78
93
|
const record = (key, limit) => {
|
|
79
94
|
const t = now();
|
|
80
95
|
const kept = (hits.get(key) || []).filter((x) => x > t - windowMs);
|
|
81
96
|
kept.push(t);
|
|
82
97
|
hits.set(key, kept);
|
|
98
|
+
/*
|
|
99
|
+
* Sweep expired keys, rather than only expired timestamps within a key.
|
|
100
|
+
*
|
|
101
|
+
* Nothing removed a key once made, which was a slow leak even when every key was a valid node
|
|
102
|
+
* id, and is a faster one now that the limiter runs BEFORE the shape check and so sees whatever
|
|
103
|
+
* an unauthenticated caller sent. Only above a threshold, so the ordinary case - a handful of
|
|
104
|
+
* nodes in a handful of organizations - never walks the map at all.
|
|
105
|
+
*/
|
|
106
|
+
if (hits.size > sweepAbove) {
|
|
107
|
+
for (const [k, v] of hits) if (!v.length || v[v.length - 1] <= t - windowMs) hits.delete(k);
|
|
108
|
+
}
|
|
83
109
|
return kept.length <= limit;
|
|
84
110
|
};
|
|
85
111
|
return {
|
|
@@ -105,28 +131,87 @@ const NAME = /^[a-z0-9]{1,32}$/;
|
|
|
105
131
|
* are therefore UNTRUSTED - anyone can make an id appear by attempting enrolment - so each keeps
|
|
106
132
|
* when it asked and from where, for the admin to recognise rather than to trust.
|
|
107
133
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
134
|
+
* Bounded, and PERSISTED once useDatabase() is called - see the node_attempts table for why that
|
|
135
|
+
* changed. Briefly: "losing the list on a restart costs nothing, a node still retrying reappears
|
|
136
|
+
* within minutes" was wrong on the interval. A node retries every HALF HOUR, so a restart made a
|
|
137
|
+
* node that was asking indistinguishable from a node that was not asking at all, for up to thirty
|
|
138
|
+
* minutes - and that is exactly the question an administrator restarts a server to investigate.
|
|
112
139
|
*/
|
|
113
|
-
export function makeAttemptLog({ perOrg = 50, now = Date.now } = {}) {
|
|
114
|
-
const rows = new Map(); // "org/nodeid" -> {org, project, nodeid, at, from, reason, count}
|
|
140
|
+
export function makeAttemptLog({ perOrg = 50, now = Date.now, minWriteMs = 60000 } = {}) {
|
|
141
|
+
const rows = new Map(); // "org/nodeid" -> {org, project, nodeid, at, from, reason, count, written}
|
|
142
|
+
let db = null; // set by useDatabase(), because the database is opened after this is made
|
|
143
|
+
const keyOf = (org, nodeid) => `${org}/${nodeid}`;
|
|
144
|
+
|
|
145
|
+
const write = (r) => {
|
|
146
|
+
if (!db) return;
|
|
147
|
+
r.written = r.at;
|
|
148
|
+
db.run(`INSERT INTO node_attempts (org, project, nodeid, reason, from_addr, at, count)
|
|
149
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
150
|
+
ON CONFLICT(org, nodeid)
|
|
151
|
+
DO UPDATE SET project = excluded.project, reason = excluded.reason,
|
|
152
|
+
from_addr = excluded.from_addr, at = excluded.at, count = excluded.count`,
|
|
153
|
+
[r.org, r.project || '', r.nodeid, r.reason || '', r.from || '', r.at, r.count],
|
|
154
|
+
// Best effort: this is a diagnostic aid, and failing to record one must not turn an
|
|
155
|
+
// enrolment refusal into a 500.
|
|
156
|
+
(err) => { if (err) console.error("Could not record enrolment attempt:", err.message); });
|
|
157
|
+
};
|
|
158
|
+
const drop = (org, nodeid) => {
|
|
159
|
+
rows.delete(keyOf(org, nodeid));
|
|
160
|
+
if (db) db.run('DELETE FROM node_attempts WHERE org = ? AND nodeid = ?', [org, nodeid], () => {});
|
|
161
|
+
};
|
|
162
|
+
|
|
115
163
|
return {
|
|
116
|
-
|
|
117
|
-
|
|
164
|
+
/*
|
|
165
|
+
* Adopt a database: load what is already there, and write through from then on.
|
|
166
|
+
*
|
|
167
|
+
* Separate from the constructor only because of when each happens - the log is made at module
|
|
168
|
+
* level so that POST /enrol and GET /nodes_list share one, and the database is opened later.
|
|
169
|
+
* Until this is called the log behaves exactly as it used to, in memory alone, so a caller
|
|
170
|
+
* that never calls it (a test) needs no database.
|
|
171
|
+
*/
|
|
172
|
+
useDatabase(database, cb) {
|
|
173
|
+
db = database;
|
|
174
|
+
db.all('SELECT org, project, nodeid, reason, from_addr, at, count FROM node_attempts', [],
|
|
175
|
+
(err, saved) => {
|
|
176
|
+
if (err) return cb(err);
|
|
177
|
+
for (const r of saved || []) {
|
|
178
|
+
rows.set(keyOf(r.org, r.nodeid), {
|
|
179
|
+
org: r.org, project: r.project, nodeid: r.nodeid, from: r.from_addr,
|
|
180
|
+
reason: r.reason, at: r.at, count: r.count, written: r.at,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
cb(null, (saved || []).length);
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
/*
|
|
187
|
+
* durable: false records in memory but does not write. Used for a rate-limited request, which
|
|
188
|
+
* is the one kind that arrives faster than the limiter caps - the limiter refuses it but this
|
|
189
|
+
* is still called, so without that flag an unauthenticated flood would be a database write per
|
|
190
|
+
* request. A node that is genuinely retrying has already been written by its first few
|
|
191
|
+
* attempts, so nothing an administrator needs is lost.
|
|
192
|
+
*/
|
|
193
|
+
record({ org, project, nodeid, from, reason, durable = true }) {
|
|
194
|
+
const key = keyOf(org, nodeid);
|
|
118
195
|
const existing = rows.get(key);
|
|
119
|
-
|
|
196
|
+
const row = {
|
|
120
197
|
org, project, nodeid, from, reason,
|
|
121
198
|
at: now(),
|
|
122
199
|
count: existing ? existing.count + 1 : 1,
|
|
123
|
-
|
|
200
|
+
written: existing ? (existing.written || 0) : 0,
|
|
201
|
+
};
|
|
202
|
+
rows.set(key, row);
|
|
203
|
+
// A new node, or one whose refusal reason has changed, is written at once - those are what an
|
|
204
|
+
// administrator is waiting to see. A node repeating the same refusal is throttled, because
|
|
205
|
+
// the only thing changing is a timestamp and a count.
|
|
206
|
+
if (durable && (!existing || existing.reason !== reason || row.at - row.written >= minWriteMs)) {
|
|
207
|
+
write(row);
|
|
208
|
+
}
|
|
124
209
|
// Oldest out first, per organization, so one organization cannot crowd out another
|
|
125
210
|
const mine = [...rows.values()].filter((r) => r.org === org).sort((a, b) => a.at - b.at);
|
|
126
|
-
for (const r of mine.slice(0, Math.max(0, mine.length - perOrg)))
|
|
211
|
+
for (const r of mine.slice(0, Math.max(0, mine.length - perOrg))) drop(r.org, r.nodeid);
|
|
127
212
|
},
|
|
128
213
|
forOrg(org) { return [...rows.values()].filter((r) => r.org === org); },
|
|
129
|
-
forget(org, nodeid) {
|
|
214
|
+
forget(org, nodeid) { drop(org, nodeid); },
|
|
130
215
|
};
|
|
131
216
|
}
|
|
132
217
|
|
|
@@ -164,30 +249,48 @@ export function readGrants(db, org, cb) {
|
|
|
164
249
|
*/
|
|
165
250
|
export function enrol(deps, body, cb) {
|
|
166
251
|
const { db, config, limiter, syncNode, attempts, from } = deps;
|
|
167
|
-
// Every refusal past the shape check is worth showing an admin: it is the only way they learn
|
|
168
|
-
// that a node exists and is asking. The shape check is not - a request that is not even
|
|
169
|
-
// org/project/nodeid tells them nothing they could act on.
|
|
170
|
-
const refuse = (code, message) => {
|
|
171
|
-
if (attempts) {
|
|
172
|
-
attempts.record({
|
|
173
|
-
org: String((body && body.org) || ''), project: String((body && body.project) || ''),
|
|
174
|
-
nodeid: String((body && body.nodeid) || ''), from, reason: code,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
return cb(new EnrolError(code, message));
|
|
178
|
-
};
|
|
179
252
|
const org = String((body && body.org) || '');
|
|
180
253
|
const project = String((body && body.project) || '');
|
|
181
254
|
const nodeid = String((body && body.nodeid) || '');
|
|
182
255
|
const secret = String((body && body.enrolment_secret) || '');
|
|
183
256
|
const lora = !!(body && body.lora);
|
|
184
257
|
|
|
185
|
-
|
|
186
|
-
|
|
258
|
+
/*
|
|
259
|
+
* EVERY refusal is worth showing an admin, including a malformed one. It is the only way they
|
|
260
|
+
* learn that a node exists and is asking at all.
|
|
261
|
+
*
|
|
262
|
+
* The shape check used to be excluded, on the grounds that a request which is not even
|
|
263
|
+
* org/project/nodeid tells an admin nothing they could act on. That was wrong twice over: a node
|
|
264
|
+
* whose project is mistyped (a capital letter, a hyphen) fails the shape check, and then the ONE
|
|
265
|
+
* symptom of the typo - "nothing appears anywhere" - is identical to the node being dead, off the
|
|
266
|
+
* network, or pointed at a different server. Seeing the row is what distinguishes them, even
|
|
267
|
+
* though approving it would not help. What an admin can act on is the FIRMWARE.
|
|
268
|
+
*
|
|
269
|
+
* Two conditions on recording, both because the strings below the shape check are arbitrary input
|
|
270
|
+
* from an unauthenticated request:
|
|
271
|
+
* - the organization must be a nameable one, or there is no admin who could ever see the row
|
|
272
|
+
* and nothing bounds how many organizations an attacker can invent;
|
|
273
|
+
* - project and nodeid are truncated, since they are shown in a table.
|
|
274
|
+
*/
|
|
275
|
+
const refuse = (code, message, durable = true) => {
|
|
276
|
+
if (attempts && NAME.test(org)) {
|
|
277
|
+
attempts.record({
|
|
278
|
+
org, project: project.slice(0, 64), nodeid: nodeid.slice(0, 64), from, reason: code, durable,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return cb(new EnrolError(code, message));
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Before the shape check, not after it as it used to be. Now that a malformed request is recorded,
|
|
285
|
+
// it has to be capped the same way a well-formed one is, or a flood of invented node ids would
|
|
286
|
+
// reach the log unlimited.
|
|
287
|
+
if (limiter && !limiter.allow(org, nodeid.slice(0, 64))) {
|
|
288
|
+
// durable: false - the one refusal that by definition arrives faster than the limit allows
|
|
289
|
+
return refuse(ENROL.RATE_LIMITED, 'Too many enrolment attempts - try later', false);
|
|
187
290
|
}
|
|
188
291
|
|
|
189
|
-
if (
|
|
190
|
-
return refuse(ENROL.
|
|
292
|
+
if (!NAME.test(org) || !NAME.test(project) || !NODEID.test(nodeid)) {
|
|
293
|
+
return refuse(ENROL.BAD_REQUEST, 'org, project and nodeid are required and must be plain names');
|
|
191
294
|
}
|
|
192
295
|
|
|
193
296
|
/*
|
package/package.json
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"cookie-parser": "^1.4.7",
|
|
13
13
|
"express": "^5.2.1",
|
|
14
14
|
"express-session": "^1.19.0",
|
|
15
|
-
"frugal-iot-client": "^2.0.
|
|
16
|
-
"frugal-iot-logger": "^2.0.
|
|
15
|
+
"frugal-iot-client": "^2.0.2",
|
|
16
|
+
"frugal-iot-logger": "^2.0.2",
|
|
17
17
|
"hash-wasm": "^4.12.0",
|
|
18
18
|
"js-yaml": "^4.2.0",
|
|
19
19
|
"morgan": "^1.11.0",
|
|
@@ -73,7 +73,10 @@
|
|
|
73
73
|
"license": "MIT",
|
|
74
74
|
"main": "frugal-iot-server.js",
|
|
75
75
|
"name": "frugal-iot-server",
|
|
76
|
-
"repository":
|
|
76
|
+
"repository": {
|
|
77
|
+
"type": "git",
|
|
78
|
+
"url": "git+https://github.com/mitra42/frugal-iot-server.git"
|
|
79
|
+
},
|
|
77
80
|
"scripts": {
|
|
78
81
|
"test": "vitest run",
|
|
79
82
|
"test:watch": "vitest",
|
|
@@ -83,5 +86,5 @@
|
|
|
83
86
|
"check-files": "node scripts/check-published-files.js"
|
|
84
87
|
},
|
|
85
88
|
"type": "module",
|
|
86
|
-
"version": "2.0.
|
|
89
|
+
"version": "2.0.2"
|
|
87
90
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// it, an installed browser keeps serving the files it already cached - and since the list now covers
|
|
7
7
|
// the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
|
|
8
8
|
// needs this bumped, or it does not reach anyone who already has the old one.
|
|
9
|
-
const CACHE_NAME = 'frugal-iot-cache-2.0.
|
|
9
|
+
const CACHE_NAME = 'frugal-iot-cache-2.0.2';
|
|
10
10
|
/*
|
|
11
11
|
* Everything the app needs, so that a phone on a poor or expensive link fetches it once.
|
|
12
12
|
*
|
|
@@ -144,15 +144,28 @@ fi
|
|
|
144
144
|
# user *before* reading it - so the broker then cannot read its own password file and refuses to
|
|
145
145
|
# start, with an error that says nothing about ownership. mosquitto_passwd also writes a temporary
|
|
146
146
|
# backup beside the file, so it needs the directory and not only the file.
|
|
147
|
+
#
|
|
148
|
+
# Whoever runs it must be able to write the DIRECTORY, not merely the file, and the file's owner
|
|
149
|
+
# frequently cannot: a password file at /etc/mosquitto/mosquitto_passwords is owned by mosquitto
|
|
150
|
+
# inside a directory owned by root, and picking the owner then fails with "Error creating backup
|
|
151
|
+
# password file". So the last resort is root - and because that leaves the file owned by root,
|
|
152
|
+
# where mosquitto (which drops privileges before reading it) could no longer read it, the
|
|
153
|
+
# ownership and mode are put back immediately afterwards.
|
|
154
|
+
# stat's spelling differs between Linux (-c) and BSD/macOS (-f)
|
|
155
|
+
PWDIR="${PWFILE:h}"
|
|
156
|
+
PWFILE_OWNER=$(stat -c '%U' "$PWFILE" 2>/dev/null || stat -f '%Su' "$PWFILE" 2>/dev/null || true)
|
|
157
|
+
PWFILE_GROUP=$(stat -c '%G' "$PWFILE" 2>/dev/null || stat -f '%Sg' "$PWFILE" 2>/dev/null || true)
|
|
158
|
+
PWFILE_MODE=$(stat -c '%a' "$PWFILE" 2>/dev/null || stat -f '%Lp' "$PWFILE" 2>/dev/null || true)
|
|
159
|
+
RESTORE_OWNER=0
|
|
147
160
|
MOSQ_PASSWD_CMD=(mosquitto_passwd)
|
|
148
|
-
if [[ ! -w "$
|
|
161
|
+
if [[ ! -w "$PWDIR" || ( -e "$PWFILE" && ! -w "$PWFILE" ) ]]; then
|
|
149
162
|
if command -v sudo >/dev/null; then
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if [[ -n "$PWFILE_OWNER" && "$PWFILE_OWNER" != "$(id -un)" && "$PWFILE_OWNER" != "root" ]]; then
|
|
163
|
+
if [[ -n "$PWFILE_OWNER" && "$PWFILE_OWNER" != "$(id -un)" && "$PWFILE_OWNER" != "root" ]] \
|
|
164
|
+
&& sudo -u "$PWFILE_OWNER" test -w "$PWDIR" 2>/dev/null; then
|
|
153
165
|
MOSQ_PASSWD_CMD=(sudo -u "$PWFILE_OWNER" mosquitto_passwd)
|
|
154
166
|
else
|
|
155
167
|
MOSQ_PASSWD_CMD=(sudo mosquitto_passwd)
|
|
168
|
+
[[ -n "$PWFILE_OWNER" && "$PWFILE_OWNER" != "root" ]] && RESTORE_OWNER=1
|
|
156
169
|
fi
|
|
157
170
|
fi
|
|
158
171
|
fi
|
|
@@ -162,6 +175,12 @@ if ! $MOSQ_PASSWD_CMD -b "$PWFILE" "$ACCOUNT" "$PASSWORD"; then
|
|
|
162
175
|
echo "Error: mosquitto_passwd failed - nothing has been changed." >&2
|
|
163
176
|
exit 1
|
|
164
177
|
fi
|
|
178
|
+
if (( RESTORE_OWNER )); then
|
|
179
|
+
# Written as root, so hand it back before mosquitto next needs to read it
|
|
180
|
+
sudo chown "${PWFILE_OWNER}:${PWFILE_GROUP}" "$PWFILE"
|
|
181
|
+
[[ -n "$PWFILE_MODE" ]] && sudo chmod "$PWFILE_MODE" "$PWFILE"
|
|
182
|
+
echo " (written as root, then given back to ${PWFILE_OWNER}:${PWFILE_GROUP} mode ${PWFILE_MODE})"
|
|
183
|
+
fi
|
|
165
184
|
|
|
166
185
|
# The access control rule. Without one the account can connect and reach nothing at all, because a
|
|
167
186
|
# mosquitto acl_file is deny-by-default.
|
|
@@ -237,16 +256,21 @@ if [[ -z "$REPLICA_TOKEN" ]]; then
|
|
|
237
256
|
fi
|
|
238
257
|
|
|
239
258
|
echo ""
|
|
240
|
-
echo "Done.
|
|
259
|
+
echo "Done. Collect these, then run the command at the bottom on the Pi."
|
|
241
260
|
echo ""
|
|
242
|
-
echo "
|
|
261
|
+
echo "1. THE FQDN OF THIS SERVER - the DNS name on its TLS certificate."
|
|
262
|
+
echo " The Pi verifies the certificate against exactly what you type, so a local hostname, an IP"
|
|
263
|
+
echo " address, or a domain the certificate does not cover will fail to connect."
|
|
264
|
+
echo " TO TEST IT: open https://<name>/ in a browser. Loading with no certificate warning means"
|
|
265
|
+
echo " the name matches the certificate, which is the part that is easy to get wrong. The bridge"
|
|
266
|
+
echo " then uses port 8883 on that same host, so that port has to be open to the Pi as well."
|
|
267
|
+
echo " This host calls itself \"$(hostname -f 2>/dev/null || hostname)\" - use that only if it is"
|
|
268
|
+
echo " also the name on the certificate and one the Pi can resolve. Not guessed here, because"
|
|
269
|
+
echo " what a machine calls itself usually is not its public name."
|
|
243
270
|
echo ""
|
|
244
|
-
echo "
|
|
245
|
-
echo "only the value on, so the --org form would arrive at that script as a bare word."
|
|
271
|
+
echo "2. THE PASSWORD for ${ACCOUNT}, which the Pi's script will prompt for:"
|
|
246
272
|
echo ""
|
|
247
|
-
echo "
|
|
248
|
-
echo ""
|
|
249
|
-
echo " ${PASSWORD}"
|
|
273
|
+
echo " ${PASSWORD}"
|
|
250
274
|
echo ""
|
|
251
275
|
if [[ "$GENERATED" == true ]]; then
|
|
252
276
|
echo "That was generated just now and is not stored anywhere you can read it back - copy it before"
|
|
@@ -255,15 +279,20 @@ if [[ "$GENERATED" == true ]]; then
|
|
|
255
279
|
echo "and use that on the Pi instead."
|
|
256
280
|
fi
|
|
257
281
|
if [[ -n "$REPLICA_TOKEN" ]]; then
|
|
258
|
-
echo "
|
|
282
|
+
echo "3. THE REPLICA TOKEN, so people registered here can log in on the Pi as well:"
|
|
259
283
|
echo ""
|
|
260
|
-
echo "
|
|
284
|
+
echo " ${REPLICA_TOKEN}"
|
|
261
285
|
echo ""
|
|
262
|
-
echo "A new one replaces the old, so re-running this script revokes the Pi's previous token."
|
|
286
|
+
echo " A new one replaces the old, so re-running this script revokes the Pi's previous token."
|
|
263
287
|
echo ""
|
|
264
288
|
fi
|
|
265
|
-
echo "
|
|
266
|
-
echo "
|
|
267
|
-
|
|
268
|
-
echo "
|
|
269
|
-
|
|
289
|
+
echo "Then, on the Pi at site '${SITE}':"
|
|
290
|
+
echo ""
|
|
291
|
+
if [[ -n "$REPLICA_TOKEN" ]]; then
|
|
292
|
+
echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <fqdn-from-1-above> ${ACCOUNT} -- --replica-token <token-from-3>"
|
|
293
|
+
else
|
|
294
|
+
echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <fqdn-from-1-above> ${ACCOUNT}"
|
|
295
|
+
fi
|
|
296
|
+
echo ""
|
|
297
|
+
echo "The first three are positional on purpose: npm swallows any --flag it does not recognise and"
|
|
298
|
+
echo "passes only the value on, so the --org form would arrive at that script as a bare word."
|
|
@@ -107,19 +107,38 @@ fi
|
|
|
107
107
|
# opening it, and mosquitto_passwd writes a temporary backup beside it, so it needs the directory too
|
|
108
108
|
# - neither of which we get by running as ourselves against a file owned by mosquitto or by root.
|
|
109
109
|
# On Raspberry Pi OS the first user has passwordless sudo, so this is invisible.
|
|
110
|
+
#
|
|
111
|
+
# The owner is not always able to do it either: a file at /etc/mosquitto/mosquitto_passwords is
|
|
112
|
+
# owned by mosquitto inside a directory owned by root, and the backup then cannot be written -
|
|
113
|
+
# "Error creating backup password file". So check the chosen account can write the directory, and
|
|
114
|
+
# fall back to root, putting the ownership back afterwards so mosquitto can still read the file.
|
|
115
|
+
# stat's spelling differs between Linux (-c) and BSD/macOS (-f)
|
|
116
|
+
MOSQUITTO_PASSWD_DIR="${MOSQUITTO_PASSWD_FILE:h}"
|
|
117
|
+
MOSQUITTO_PASSWD_OWNER=$(stat -c '%U' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
|
|
118
|
+
|| stat -f '%Su' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
|
|
119
|
+
MOSQUITTO_PASSWD_GROUP=$(stat -c '%G' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
|
|
120
|
+
|| stat -f '%Sg' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
|
|
121
|
+
MOSQUITTO_PASSWD_MODE=$(stat -c '%a' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
|
|
122
|
+
|| stat -f '%Lp' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
|
|
123
|
+
MOSQUITTO_RESTORE_OWNER=0
|
|
110
124
|
MOSQUITTO_PASSWD_CMD=(mosquitto_passwd)
|
|
111
|
-
if [[ ! -w "$
|
|
125
|
+
if [[ ! -w "$MOSQUITTO_PASSWD_DIR" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MOSQUITTO_PASSWD_FILE" ) ]]; then
|
|
112
126
|
if command -v sudo >/dev/null; then
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|| stat -f '%Su' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null || true)
|
|
116
|
-
if [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "$(id -un)" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]]; then
|
|
127
|
+
if [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "$(id -un)" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]] \
|
|
128
|
+
&& sudo -u "$MOSQUITTO_PASSWD_OWNER" test -w "$MOSQUITTO_PASSWD_DIR" 2>/dev/null; then
|
|
117
129
|
MOSQUITTO_PASSWD_CMD=(sudo -u "$MOSQUITTO_PASSWD_OWNER" mosquitto_passwd)
|
|
118
130
|
else
|
|
119
131
|
MOSQUITTO_PASSWD_CMD=(sudo mosquitto_passwd)
|
|
132
|
+
[[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]] && MOSQUITTO_RESTORE_OWNER=1
|
|
120
133
|
fi
|
|
121
134
|
fi
|
|
122
135
|
fi
|
|
136
|
+
# Called after any successful mosquitto_passwd run below
|
|
137
|
+
restore_passwd_owner() {
|
|
138
|
+
(( MOSQUITTO_RESTORE_OWNER )) || return 0
|
|
139
|
+
sudo chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
|
|
140
|
+
[[ -n "$MOSQUITTO_PASSWD_MODE" ]] && sudo chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
|
|
141
|
+
}
|
|
123
142
|
|
|
124
143
|
# ---- Escaping helpers ----
|
|
125
144
|
# Escape a value for embedding in a double-quoted YAML string
|
|
@@ -229,6 +248,7 @@ else
|
|
|
229
248
|
echo "If it complains about creating a backup file, the directory holding that file is not" >&2
|
|
230
249
|
echo "writable by you - which is why the command above uses sudo." >&2
|
|
231
250
|
else
|
|
251
|
+
restore_passwd_owner
|
|
232
252
|
echo "Set mosquitto password for ${ORG_ID} in ${MOSQUITTO_PASSWD_FILE}"
|
|
233
253
|
fi
|
|
234
254
|
fi
|