frugal-iot-server 2.0.0 → 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.
@@ -34,6 +34,26 @@ function dynsecConfigured(config) {
34
34
  return !!(s.dynsec_admin_user && s.dynsec_admin_password);
35
35
  }
36
36
 
37
+ /*
38
+ * Another server owns this broker: derive credentials, never try to create them.
39
+ *
40
+ * For working on the server or the client against live data - a laptop pointed at the production
41
+ * broker. It has no admin credential and must not have one, but the accounts it needs already
42
+ * exist, created by the server that does. Given the same user_secret it derives exactly the same
43
+ * passwords, so what it hands a browser works.
44
+ *
45
+ * Declared in config.d/secrets.yaml rather than config.d/mqtt.yaml, for two reasons: that file is
46
+ * never committed, so this cannot be switched on by accident in a checkout; and it is where the
47
+ * borrowed user_secret has to go anyway, which is the real access control here. Without that
48
+ * secret a checkout of this repository cannot reach live data at all, whatever it sets.
49
+ *
50
+ * What does NOT work on such a server, by design: enrolling a node, resetting one, and
51
+ * frugal-iot-rebuild-dynsec - all of which create or delete accounts.
52
+ */
53
+ function brokerManagedElsewhere(config) {
54
+ return !!(config && config.secrets && config.secrets.broker_managed_elsewhere);
55
+ }
56
+
37
57
  // A connection per operation rather than one held open. These happen at login and at permission
38
58
  // changes - rare, and never in a hot path - and a short-lived connection cannot go stale, which on
39
59
  // a Pi that sleeps or loses its network it otherwise would.
@@ -69,6 +89,15 @@ export function syncUser(db, config, user, cb) {
69
89
  reportUnavailable(`user ${user.username}`, err);
70
90
  return cb(err);
71
91
  }
92
+ // Borrowing another server's broker: the account is already there, so derive and hand it over
93
+ // without trying to reach a plugin this server has no credential for.
94
+ if (brokerManagedElsewhere(config)) {
95
+ return cb(null, {
96
+ username: names.userClient(user.username),
97
+ password: derivePassword(userSecret, user.username, user.hashed_password),
98
+ brokerUpdated: false,
99
+ });
100
+ }
72
101
  // Nothing to hand a browser on a server that has never driven the plugin: the account cannot
73
102
  // exist, so the dashboard says live data is unavailable rather than failing to connect with a
74
103
  // credential that was never real. See dynsecConfigured.
@@ -136,7 +165,7 @@ export function syncLoggers(config, orgs, cb) {
136
165
  const creds = {};
137
166
  // No credentials at all means the logger falls back to the organization's shared password, which
138
167
  // works until S8. That is the right answer whenever an account of its own could not exist.
139
- if (!userSecret || !dynsecConfigured(config)) {
168
+ if (!userSecret || (!dynsecConfigured(config) && !brokerManagedElsewhere(config))) {
140
169
  reportUnavailable('logger accounts',
141
170
  new Error(userSecret ? 'No dynsec_admin_user in config.d/secrets.yaml'
142
171
  : 'No user_secret in config.d/secrets.yaml'));
@@ -145,6 +174,10 @@ export function syncLoggers(config, orgs, cb) {
145
174
  for (const org of orgs) {
146
175
  creds[org] = { username: names.loggerClient(org), password: deriveLoggerPassword(userSecret, org) };
147
176
  }
177
+ // Same as for a user: the accounts exist, this server just did not make them. Two loggers on one
178
+ // account is fine - mqtt.js generates a client id per connection, and MQTT evicts by client id,
179
+ // not by user - so a laptop recording alongside production does not disturb it.
180
+ if (brokerManagedElsewhere(config)) return cb(null, creds);
148
181
  withDynsec(config, (cerr, dynsec, done) => {
149
182
  // Configured but not answering: the accounts are almost certainly there from an earlier run,
150
183
  // so the derived credentials are handed over and the logger reconnects when the broker returns.
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.0",
16
- "frugal-iot-logger": "^2.0.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": "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.0"
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.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 "${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
@@ -25,7 +25,8 @@
25
25
  * is what decides how many decimals a reading is shown to; without it the UI guesses.
26
26
  *
27
27
  * - A numeric topic with no "units". Often right - plenty of readings are dimensionless - but
28
- * worth a look, because the UI has nothing to put after the number.
28
+ * worth a look, because the UI has nothing to put after the number. The topics that are
29
+ * deliberately dimensionless are listed in UNITS_EXEMPT below, and are not mentioned.
29
30
  *
30
31
  * And, separately, things that are never deliberate and so count as errors:
31
32
  *
@@ -35,8 +36,13 @@
35
36
  * Usage:
36
37
  * node scripts/check-schema.js # checks ./config.d/schema
37
38
  * node scripts/check-schema.js <dir> [<dir> ...] # each dir holding topics.yaml and modules.yaml
39
+ * node scripts/check-schema.js -q <dir> # say nothing unless there is something to say
38
40
  * node scripts/check-schema.js --resolve <otakey> # which devices.yaml entry that OTA key uses
39
41
  *
42
+ * -q (--quiet) prints nothing at all when a schema is clean, so it can run from a release script
43
+ * without burying the one line that matters. Warnings and errors print exactly as they otherwise
44
+ * would, and the exit code is the same either way.
45
+ *
40
46
  * Exits 0 for warnings, so it can run from a prerelease script without blocking a release for
41
47
  * something that may well be deliberate. Exits 1 for an error, or if it could not read a schema.
42
48
  * Both callers guard it with "|| true", so neither blocks on either.
@@ -51,6 +57,17 @@ import path from 'path';
51
57
  const TYPES_LOGGED_BY_DEFAULT = ['float', 'int', 'bool'];
52
58
  // The types with decimals to decide, and so the ones that need a width
53
59
  const NUMERIC_TYPES = ['float', 'exponential'];
60
+ /*
61
+ * Topics that are dimensionless on purpose, so "no units" is the right answer rather than
62
+ * something to look at. Without this the same three warnings appear at every release, which is
63
+ * exactly how a real one comes to be skimmed past. Add a name here only once you are sure the
64
+ * reading genuinely has no unit - not because you have not decided yet.
65
+ */
66
+ const UNITS_EXEMPT = [
67
+ 'controlfloat', // a control's value, in whatever the thing it controls is measured in
68
+ 'hdop', // dilution of precision - a ratio
69
+ 'loadcell', // raw ADC counts until a calibration turns them into grams
70
+ ];
54
71
 
55
72
  function loadYaml(file) {
56
73
  if (!existsSync(file)) return null;
@@ -111,7 +128,7 @@ function checkSchemaDir(dir) {
111
128
  + ` characters before any decimal point, so values will overflow it`);
112
129
  }
113
130
  }
114
- if (hasDecimals && (topic.units === undefined)) {
131
+ if (hasDecimals && (topic.units === undefined) && !UNITS_EXEMPT.includes(name)) {
115
132
  warnings.push(`topic "${name}" has no "units" - fine if it is dimensionless, but the UI has`
116
133
  + ` nothing to put after the number`);
117
134
  }
@@ -187,6 +204,7 @@ function resolveDeviceEntry(devices, otakey) {
187
204
  }
188
205
 
189
206
  const argv = process.argv.slice(2);
207
+ const quiet = argv.includes('-q') || argv.includes('--quiet');
190
208
  const resolveAt = argv.indexOf('--resolve');
191
209
  if (resolveAt !== -1) {
192
210
  const otakey = argv[resolveAt + 1];
@@ -201,7 +219,8 @@ if (resolveAt !== -1) {
201
219
  process.exit(0);
202
220
  }
203
221
 
204
- const dirs = argv.length ? argv : ['config.d/schema'];
222
+ const named = argv.filter((a) => !a.startsWith('-'));
223
+ const dirs = named.length ? named : ['config.d/schema'];
205
224
  let unreadable = 0;
206
225
  let totalWarnings = 0;
207
226
  let totalErrors = 0;
@@ -214,7 +233,8 @@ for (const dir of dirs) {
214
233
  continue;
215
234
  }
216
235
  if (!warnings.length && !errors.length) {
217
- console.log(`${dir}: nothing to report`);
236
+ // Under -q a clean schema says nothing, so whatever a release script does print is a finding
237
+ if (!quiet) console.log(`${dir}: nothing to report`);
218
238
  continue;
219
239
  }
220
240
  if (warnings.length) {
@@ -8,16 +8,31 @@
8
8
  # schema is maintained.
9
9
  #
10
10
  # Usage:
11
- # scripts/copy-schema-to-examples.zsh [<frugal-iot-logger directory>]
11
+ # scripts/copy-schema-to-examples.zsh [-q|--quiet] [<frugal-iot-logger directory>]
12
12
  #
13
13
  # With no argument it looks for frugal-iot-logger beside this checkout, which is how these are
14
14
  # developed. Says what it did and changes nothing else; committing is up to you.
15
+ #
16
+ # -q (--quiet) names only the files it changed. A run that copies nothing says nothing, so from a
17
+ # release script the output is the list of files you now have to commit.
15
18
 
16
19
  set -euo pipefail
17
20
 
21
+ QUIET=0
22
+ ARGS=()
23
+ for arg in "$@"; do
24
+ case "$arg" in
25
+ -q|--quiet) QUIET=1 ;;
26
+ *) ARGS+=("$arg") ;;
27
+ esac
28
+ done
29
+
30
+ # Progress, as opposed to a change or a problem: silent under -q
31
+ say() { (( QUIET )) || print -r -- "$@" }
32
+
18
33
  HERE="${0:A:h:h}" # The frugal-iot-server checkout this script is in
19
34
  SCHEMA="${HERE}/config.d/schema"
20
- LOGGER="${1:-${HERE:h}/frugal-iot-logger}"
35
+ LOGGER="${ARGS[1]:-${HERE:h}/frugal-iot-logger}"
21
36
 
22
37
  if [[ ! -d "$LOGGER" ]]; then
23
38
  echo " No frugal-iot-logger checkout at ${LOGGER}, so nothing copied."
@@ -44,7 +59,7 @@ COPIED=0
44
59
  for d in $DIRS; do
45
60
  for f in $FILES; do
46
61
  if cmp -s "${SCHEMA}/${f}" "${d}/${f}"; then
47
- echo " same ${d#${LOGGER}/}/${f}"
62
+ say " same ${d#${LOGGER}/}/${f}"
48
63
  else
49
64
  cp "${SCHEMA}/${f}" "${d}/${f}"
50
65
  echo " updated ${d#${LOGGER}/}/${f}"