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.
- 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/dynsec-server.js +34 -1
- 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/check-schema.js +24 -4
- package/scripts/copy-schema-to-examples.zsh +18 -3
- package/scripts/diagnostic.zsh +149 -27
- package/scripts/init.zsh +106 -6
- package/scripts/install-pi.sh +269 -44
- package/scripts/prerelease.zsh +84 -33
package/lib/dynsec-server.js
CHANGED
|
@@ -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 =
|
|
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
|
package/scripts/check-schema.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
say " same ${d#${LOGGER}/}/${f}"
|
|
48
63
|
else
|
|
49
64
|
cp "${SCHEMA}/${f}" "${d}/${f}"
|
|
50
65
|
echo " updated ${d#${LOGGER}/}/${f}"
|