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/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 = 20, perNode = 5, windowMs = 3600000, now = Date.now } = {}) {
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
- * In memory, and bounded. The nodes table deliberately has no last_seen column because that would
109
- * be a database write per reading on an SD card; a write per enrolment attempt is the same mistake
110
- * on a smaller scale. Losing the list on restart costs nothing: a node that is still retrying
111
- * reappears within minutes.
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
- record({ org, project, nodeid, from, reason }) {
117
- const key = `${org}/${nodeid}`;
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
- rows.set(key, {
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))) rows.delete(`${r.org}/${r.nodeid}`);
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) { rows.delete(`${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
- if (!NAME.test(org) || !NAME.test(project) || !NODEID.test(nodeid)) {
186
- return cb(new EnrolError(ENROL.BAD_REQUEST, 'org, project and nodeid are required and must be plain names'));
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 (limiter && !limiter.allow(org, nodeid)) {
190
- return refuse(ENROL.RATE_LIMITED, 'Too many enrolment attempts - try later');
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.1",
16
- "frugal-iot-logger": "^2.0.1",
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": "https://github.com/mitra42/frugal-iot-server",
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.1"
89
+ "version": "2.0.2"
87
90
  }
@@ -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.1';
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 "${PWFILE:h}" || ( -e "$PWFILE" && ! -w "$PWFILE" ) ]]; then
161
+ if [[ ! -w "$PWDIR" || ( -e "$PWFILE" && ! -w "$PWFILE" ) ]]; then
149
162
  if command -v sudo >/dev/null; then
150
- # stat's spelling differs between Linux (-c) and BSD/macOS (-f)
151
- PWFILE_OWNER=$(stat -c '%U' "$PWFILE" 2>/dev/null || stat -f '%Su' "$PWFILE" 2>/dev/null || true)
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. On the Pi at site '${SITE}', run:"
259
+ echo "Done. Collect these, then run the command at the bottom on the Pi."
241
260
  echo ""
242
- echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <this server's public name> ${ACCOUNT}"
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 "Positional arguments on purpose: npm swallows any --flag it does not recognise and passes"
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 "It will ask for this password:"
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 "And this replica token, so people registered here can log in on the Pi as well:"
282
+ echo "3. THE REPLICA TOKEN, so people registered here can log in on the Pi as well:"
259
283
  echo ""
260
- echo " ${REPLICA_TOKEN}"
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 "Fill in the public name yourself - it is deliberately not guessed here, because what this"
266
- echo "machine calls itself is usually not it. This host answers to \"$(hostname -f 2>/dev/null || hostname)\","
267
- echo "which is only the right answer if that is also the DNS name the Pi can reach and the name on"
268
- echo "the TLS certificate. The Pi verifies the certificate against whatever you put here, so a"
269
- echo "local hostname, or a domain the certificate does not cover, fails to connect."
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 "${MOSQUITTO_PASSWD_FILE:h}" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MOSQUITTO_PASSWD_FILE" ) ]]; then
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
- # stat's spelling differs between Linux (-c) and BSD/macOS (-f)
114
- MOSQUITTO_PASSWD_OWNER=$(stat -c '%U' "$MOSQUITTO_PASSWD_FILE" 2>/dev/null \
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