frugal-iot-server 0.3.9 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/replica.js ADDED
@@ -0,0 +1,246 @@
1
+ /*
2
+ * Sharing a production server's logins with a bridged Pi (SECURITY.md S11).
3
+ *
4
+ * The problem: a bridge relays TOPICS, not accounts. A person who logs into production therefore has
5
+ * no account on the Pi's broker, and the Pi is often the only thing reachable - it goes on working
6
+ * when the link is down, which is why it is there.
7
+ *
8
+ * What travels: username, the stored password hash, its salt, and the permission rows for one
9
+ * organization. Nothing derived from a password, and no secret shared - the Pi authenticates the
10
+ * login itself against that hash (the same pbkdf2 comparison production does) and derives its own
11
+ * broker credential from its OWN user_secret. So the two brokers hand out different passwords for
12
+ * the same person, and neither could compute the other's.
13
+ *
14
+ * Direction: the Pi PULLS. It sits behind a home router that production cannot reach, and its
15
+ * broker bridge is already an outbound connection for the same reason.
16
+ *
17
+ * Revocation: each pull REPLACES that organization's replicated rows. A permission that stops being
18
+ * sent stops existing, with nothing to diff and no way for a stale row to survive.
19
+ */
20
+
21
+ import { randomBytes } from 'crypto';
22
+
23
+ /*
24
+ * Replicated users take local id OFFSET + their production id.
25
+ *
26
+ * They cannot use production's ids directly: addorganization creates a login on the Pi too, and
27
+ * both machines allocate from AUTOINCREMENT starting at 2, so the ids would collide. The offset
28
+ * holds only while no installation has OFFSET-2 local users of its own, which is a safe bet for a
29
+ * farm and is asserted below rather than assumed.
30
+ */
31
+ export const REPLICA_ID_OFFSET = 1000;
32
+
33
+ export function isReplicatedId(id) { return Number(id) >= REPLICA_ID_OFFSET; }
34
+
35
+ export function newBridgeToken() { return randomBytes(32).toString('base64url'); }
36
+
37
+ // ---- production side ---------------------------------------------------------------------------
38
+ /*
39
+ * What one organization's bridge is allowed to know: its people and what they may do.
40
+ *
41
+ * Deliberately not the whole users table - a Pi hosting one organization has no business knowing
42
+ * about anyone else's. id 0 ("everyone") is included, because the Pi's own permission checks read
43
+ * "id = ? or id = 0" and would otherwise disagree with production about what is public.
44
+ */
45
+ export function replicaFor(db, org, cb) {
46
+ db.all(`SELECT DISTINCT id FROM permissions WHERE org = ? AND id > 0`, [org], (err, ids) => {
47
+ if (err) return cb(err);
48
+ const list = ids.map((r) => r.id);
49
+ const marks = list.map(() => '?').join(',') || 'NULL';
50
+ db.all(`SELECT id, username, hashed_password, salt, name, email
51
+ FROM users WHERE id IN (${marks}) AND username IS NOT NULL`, list, (uerr, users) => {
52
+ if (uerr) return cb(uerr);
53
+ db.all('SELECT id, capability, org, project FROM permissions WHERE org = ? AND (id > 0 OR id = 0)',
54
+ [org], (perr, permissions) => {
55
+ if (perr) return cb(perr);
56
+ cb(null, {
57
+ org,
58
+ at: Date.now(),
59
+ // Buffers do not survive JSON, and the Pi needs them back as BLOBs or its
60
+ // timingSafeEqual comparison throws instead of failing
61
+ users: users.map((u) => ({
62
+ id: u.id,
63
+ username: u.username,
64
+ hashed_password: u.hashed_password ? Buffer.from(u.hashed_password).toString('base64') : null,
65
+ salt: u.salt ? Buffer.from(u.salt).toString('base64') : null,
66
+ name: u.name,
67
+ email: u.email,
68
+ })),
69
+ permissions,
70
+ });
71
+ });
72
+ });
73
+ });
74
+ }
75
+
76
+ // Which bridge a token belongs to, or nothing. Constant-timeish is not the concern here: a token is
77
+ // 32 random bytes, and the lookup is by exact match.
78
+ export function bridgeForToken(db, token, cb) {
79
+ if (!token) return cb(null, null);
80
+ db.get('SELECT org, site FROM bridges WHERE token = ?', [token], cb);
81
+ }
82
+
83
+ export function noteBridgePull(db, org, site, cb) {
84
+ cb = cb || (() => {});
85
+ db.run('UPDATE bridges SET last_pull = ? WHERE org = ? AND site = ?', [Date.now(), org, site], cb);
86
+ }
87
+
88
+ // ---- the Pi's side -----------------------------------------------------------------------------
89
+ /*
90
+ * Apply one organization's replica, replacing whatever was there.
91
+ *
92
+ * cb(err, {users, permissions, skipped}) - skipped names logins that could not be taken because a
93
+ * LOCAL account already uses that name. Overwriting one would silently change who can log in as
94
+ * whom, so it is refused and reported.
95
+ */
96
+ export function applyReplica(db, org, payload, cb) {
97
+ if (!payload || payload.org !== org || !Array.isArray(payload.users) || !Array.isArray(payload.permissions)) {
98
+ return cb(new Error('Not a replica for this organization'));
99
+ }
100
+ // A feed that named other organizations would be granting permissions the Pi does not host
101
+ const foreign = payload.permissions.find((p) => p.org !== org);
102
+ if (foreign) return cb(new Error(`Replica for ${org} carried a permission for ${foreign.org}`));
103
+
104
+ const skipped = [];
105
+ db.serialize(() => {
106
+ db.run('BEGIN IMMEDIATE');
107
+ // Everything replicated for THIS organization goes; other organizations, and local rows, stay.
108
+ db.run('DELETE FROM permissions WHERE org = ? AND id >= ?', [org, REPLICA_ID_OFFSET]);
109
+
110
+ let pending = payload.users.length;
111
+ const afterUsers = () => {
112
+ let left = payload.permissions.length;
113
+ const done = (err) => {
114
+ if (err) { return db.run('ROLLBACK', () => cb(err)); }
115
+ // A replicated user with no permissions left anywhere is nobody on this machine
116
+ db.run(`DELETE FROM users WHERE id >= ? AND id NOT IN (SELECT id FROM permissions)`,
117
+ [REPLICA_ID_OFFSET], (derr) => {
118
+ if (derr) { return db.run('ROLLBACK', () => cb(derr)); }
119
+ db.run('COMMIT', (cerr) => cb(cerr, {
120
+ users: payload.users.length - skipped.length,
121
+ permissions: payload.permissions.length,
122
+ skipped,
123
+ }));
124
+ });
125
+ };
126
+ if (!left) return done(null);
127
+ for (const p of payload.permissions) {
128
+ // id 0 is "everyone" and is local on every machine - never replicated over the top of it
129
+ const id = Number(p.id) === 0 ? 0 : REPLICA_ID_OFFSET + Number(p.id);
130
+ db.run(`INSERT OR IGNORE INTO permissions (id, capability, org, project)
131
+ VALUES (?, ?, ?, ?)`,
132
+ [id, p.capability, p.org, p.project || ''], (err) => {
133
+ if (err && left > 0) { left = -1; return done(err); }
134
+ if (--left === 0) done(null);
135
+ });
136
+ }
137
+ };
138
+ if (!pending) return afterUsers();
139
+ for (const u of payload.users) {
140
+ const id = REPLICA_ID_OFFSET + Number(u.id);
141
+ if (!Number.isFinite(Number(u.id)) || Number(u.id) <= 0) {
142
+ skipped.push(u.username); if (--pending === 0) afterUsers(); continue;
143
+ }
144
+ const hash = u.hashed_password ? Buffer.from(u.hashed_password, 'base64') : null;
145
+ const salt = u.salt ? Buffer.from(u.salt, 'base64') : null;
146
+ // Take the row only if this name is free or already replicated. A local account of the same
147
+ // name is left alone: replacing it would change who can log in as that person.
148
+ db.get('SELECT id FROM users WHERE username = ?', [u.username], (gerr, existing) => {
149
+ if (gerr) { skipped.push(u.username); if (--pending === 0) afterUsers(); return; }
150
+ if (existing && !isReplicatedId(existing.id)) {
151
+ skipped.push(u.username);
152
+ if (--pending === 0) afterUsers();
153
+ return;
154
+ }
155
+ db.run(`INSERT INTO users (id, username, hashed_password, salt, organization, name, email)
156
+ VALUES (?, ?, ?, ?, ?, ?, ?)
157
+ ON CONFLICT(id) DO UPDATE SET username = excluded.username,
158
+ hashed_password = excluded.hashed_password, salt = excluded.salt,
159
+ name = excluded.name, email = excluded.email`,
160
+ [id, u.username, hash, salt, org, u.name || u.username, u.email || null], () => {
161
+ if (--pending === 0) afterUsers();
162
+ });
163
+ });
164
+ }
165
+ });
166
+ }
167
+
168
+ /*
169
+ * Fetch one organization's replica from production and apply it.
170
+ *
171
+ * Configured in config.d/replica.yaml on the Pi:
172
+ *
173
+ * url: https://frugaliot.example.org the production server
174
+ * organizations: [myfarm] the ones this Pi hosts
175
+ * intervalSeconds: 900 how often to re-pull
176
+ *
177
+ * with the token in config.d/secrets.yaml as replica_token - a secret, and so not in config.d
178
+ * anywhere a browser could reach it (lib/config-for-user.js withholds that whole section).
179
+ *
180
+ * Never fatal. A Pi whose link is down keeps the replica it already has, and people go on logging
181
+ * in against it: working without the internet is the reason the Pi exists, so a failed pull is an
182
+ * expected state rather than an error.
183
+ */
184
+ export function pullReplica(config, db, { onUser } = {}, cb) {
185
+ cb = cb || (() => {});
186
+ const settings = (config && config.replica) || {};
187
+ const token = config && config.secrets && config.secrets.replica_token;
188
+ const orgs = settings.organizations || [];
189
+ if (!settings.url || !token || !orgs.length) {
190
+ return cb(null, { skipped: 'not configured' });
191
+ }
192
+ const base = String(settings.url).replace(/\/+$/, '');
193
+ let i = 0;
194
+ const results = [];
195
+ const next = () => {
196
+ if (i >= orgs.length) return cb(null, { orgs: results });
197
+ const org = orgs[i++];
198
+ fetch(`${base}/replica/${encodeURIComponent(org)}`, {
199
+ headers: { Authorization: `Bearer ${token}` },
200
+ signal: AbortSignal.timeout(30000),
201
+ })
202
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status} ${r.statusText}`))))
203
+ .then((payload) => new Promise((res, rej) =>
204
+ applyReplica(db, org, payload, (err, result) => (err ? rej(err) : res(result)))))
205
+ .then((result) => {
206
+ results.push({ org, ...result });
207
+ console.log(`Replica ${org}: ${result.users} user(s), ${result.permissions} permission(s)` +
208
+ (result.skipped.length ? `, skipped ${result.skipped.join(', ')} (a local account has that name)` : ''));
209
+ // Each replicated user's broker account, so they can connect here as well as on production
210
+ if (onUser) {
211
+ db.all('SELECT DISTINCT id FROM permissions WHERE org = ? AND id >= ?',
212
+ [org, REPLICA_ID_OFFSET], (err, ids) => {
213
+ for (const r of (ids || [])) onUser(r.id);
214
+ next();
215
+ });
216
+ } else {
217
+ next();
218
+ }
219
+ })
220
+ .catch((err) => {
221
+ // Expected whenever the link is down; the existing replica stays in place
222
+ console.log(`Replica ${org} not refreshed: ${err.message}`);
223
+ results.push({ org, error: err.message });
224
+ next();
225
+ });
226
+ };
227
+ next();
228
+ }
229
+
230
+ /*
231
+ * Pull now, and then on a timer.
232
+ *
233
+ * The timer lives in the server rather than in cron: it is part of being a bridge, so it should
234
+ * start and stop with the server rather than being a separate thing to install and remember.
235
+ */
236
+ export function startReplica(config, db, deps) {
237
+ const settings = (config && config.replica) || {};
238
+ if (!settings.url) return null;
239
+ const seconds = Number(settings.intervalSeconds) || 900;
240
+ console.log(`Replicating users from ${settings.url} every ${seconds}s:`,
241
+ (settings.organizations || []).join(', ') || '(no organizations listed)');
242
+ pullReplica(config, db, deps);
243
+ const timer = setInterval(() => pullReplica(config, db, deps), seconds * 1000);
244
+ timer.unref(); // a pending timer should not hold the process open at shutdown
245
+ return timer;
246
+ }
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Deleting retained messages, on the server's behalf rather than the browser's.
3
+ *
4
+ * A retained topic is removed by publishing an empty payload to it, and there is no other way: the
5
+ * broker keeps the last value of every topic for ever, so a misspelled field or a node tested with
6
+ * the wrong id goes on appearing on every dashboard after the node is fixed (see
7
+ * scripts/clearretained.js, which does the same thing from the command line).
8
+ *
9
+ * This used to be done by the browser, using the organization's shared broker credential. S4 gave
10
+ * each user their own credential instead, which may publish to "set/" topics and nothing else - so
11
+ * the browser's delete stopped working, and worse, stopped working SILENTLY: an MQTT 3.1.1 broker
12
+ * acknowledges a QoS 1 publish it is about to discard, so the client saw success and the topics
13
+ * stayed. That is the regression this file exists to fix.
14
+ *
15
+ * The obvious repair - let a browser publish anywhere in its organization - would hand back exactly
16
+ * the capability S4 removed, because the broker cannot tell "forget this reading" from "here is a
17
+ * reading I invented". So the publishing is done here, by the server, with an account only the
18
+ * server holds (names.orgAdminClient), after checking that every topic named is inside the
19
+ * organization the user has ADMIN of.
20
+ */
21
+
22
+ import mqtt from 'mqtt';
23
+ import { deriveOrgAdminPassword, names } from './dynsec-plan.js';
24
+
25
+ const NAME = /^[a-z0-9]{1,32}$/;
26
+ // One topic per node module field, and a busy organization has a few thousand. Well above anything
27
+ // legitimate, and low enough that a mistake cannot tie the broker up for minutes.
28
+ const MAX_TOPICS = 5000;
29
+
30
+ /*
31
+ * Which credential to publish as.
32
+ *
33
+ * The derived per-organization admin account when there is a user_secret and the broker has the
34
+ * plugin; otherwise the organization's own shared account, which still exists until S8 retires it.
35
+ * The fallback is what makes this work on a server whose broker has no dynamic security - the same
36
+ * arrangement the logger uses.
37
+ */
38
+ export function adminCredentialFor(config, org) {
39
+ const userSecret = config && config.secrets && config.secrets.user_secret;
40
+ if (userSecret) {
41
+ return { username: names.orgAdminClient(org), password: deriveOrgAdminPassword(userSecret, org) };
42
+ }
43
+ const o = (config && config.organizations && config.organizations[org]) || {};
44
+ if (o.mqtt_password) return { username: org, password: o.mqtt_password };
45
+ return null;
46
+ }
47
+
48
+ /*
49
+ * Publish an empty retained message to each of `topics`.
50
+ *
51
+ * cb(err, {deleted}) - err.status is an HTTP status for the route to use, because every way this
52
+ * can be refused is a different one.
53
+ */
54
+ export function deleteRetained(config, org, topics, cb) {
55
+ if (!NAME.test(String(org || ''))) return refuse(cb, 400, 'Not an organization name');
56
+ if (!Array.isArray(topics) || !topics.length) return refuse(cb, 400, 'No topics given');
57
+ if (topics.length > MAX_TOPICS) return refuse(cb, 400, `More than ${MAX_TOPICS} topics at once`);
58
+
59
+ const prefix = `${org}/`;
60
+ for (const t of topics) {
61
+ if (typeof t !== 'string' || !t.length) return refuse(cb, 400, 'A topic was not a string');
62
+ // Inside this organization, and one exact topic - a wildcard would let "delete these" mean
63
+ // "delete everything", and unlike the command-line tool there is nobody here to look first.
64
+ if (!t.startsWith(prefix)) return refuse(cb, 403, `${t} is not in ${org}`);
65
+ if (t.includes('+') || t.includes('#')) return refuse(cb, 400, `${t} contains a wildcard`);
66
+ }
67
+
68
+ const broker = config && config.mqtt && config.mqtt.broker;
69
+ if (!broker) return refuse(cb, 500, 'No mqtt.broker in the configuration');
70
+ const cred = adminCredentialFor(config, org);
71
+ if (!cred) return refuse(cb, 500, `No broker credential for ${org}`);
72
+
73
+ const client = mqtt.connect(broker, {
74
+ username: cred.username, password: cred.password,
75
+ connectTimeout: 5000,
76
+ reconnectPeriod: 0, // one-shot: a retry would silently double every publish
77
+ });
78
+
79
+ let settled = false;
80
+ const finish = (err, res) => {
81
+ if (settled) return;
82
+ settled = true;
83
+ clearTimeout(timer);
84
+ client.end(!!err, () => cb(err, res));
85
+ };
86
+ // Long enough for a few thousand publishes on a Pi, short enough that a wedged broker answers.
87
+ const timer = setTimeout(() => finish(err500('The broker did not answer')), 30000);
88
+
89
+ client.on('error', (err) => finish(refusal(502, `Could not reach the broker: ${err.message}`)));
90
+ client.on('connect', () => {
91
+ let left = topics.length;
92
+ let failed = 0;
93
+ for (const t of topics) {
94
+ // An empty payload is how MQTT spells "forget this topic". QoS 1 so the broker acknowledges
95
+ // it - though see the file header: the acknowledgement says "received", not "allowed".
96
+ client.publish(t, '', { retain: true, qos: 1 }, (err) => {
97
+ if (err) failed++;
98
+ if (--left === 0) finish(null, { deleted: topics.length - failed, failed });
99
+ });
100
+ }
101
+ });
102
+ }
103
+
104
+ function refusal(status, message) {
105
+ const e = new Error(message);
106
+ e.status = status;
107
+ return e;
108
+ }
109
+ function err500(message) { return refusal(500, message); }
110
+ function refuse(cb, status, message) { return cb(refusal(status, message)); }
package/lib/secrets.js ADDED
@@ -0,0 +1,148 @@
1
+ /*
2
+ * Secrets this server generates for itself, in config.d/secrets.yaml.
3
+ *
4
+ * frugal-iot-init writes that file, but a server upgraded from a release that predates it has none,
5
+ * and telling the operator to go and run init is a poor answer: the symptom (everybody logged out
6
+ * on every restart) does not point at the cause, so it would be lived with rather than fixed. So
7
+ * generate what is missing and write it down, which fixes itself once and stays fixed.
8
+ *
9
+ * Synchronous on purpose. This runs once, before the server listens, and doing it with callbacks
10
+ * would mean restructuring the startup sequence around a single small file write.
11
+ */
12
+
13
+ import { randomBytes } from 'crypto';
14
+ import { appendFileSync, chmodSync, existsSync, readFileSync, writeFileSync } from 'fs';
15
+
16
+ const HEADER = `# Secrets belonging to this server. Never served to a browser - lib/config-for-user.js
17
+ # withholds this whole section. Not in git, and worth backing up alongside frugal-iot.db.
18
+ `;
19
+
20
+ // What each secret is for, written into the file beside it so whoever reads the file later knows
21
+ // what rotating it would cost.
22
+ const NOTES = {
23
+ session_secret: 'Signs the session cookie. Changing it ends every session.',
24
+ user_secret: "Mixed into each user's derived MQTT password. Changing it invalidates every browser's\n# broker credential until its next login, and nothing else.",
25
+ };
26
+
27
+ // An organization's enrolment secret is written as a LIST, because rotating it means adding a new
28
+ // one while the old is still accepted - otherwise every node already flashed with the old value,
29
+ // and not yet enrolled, is stranded. Delete a line to withdraw that secret.
30
+ const LIST_NOTE = (name) => {
31
+ const org = name.replace(/^enrolment_/, '');
32
+ return `Enrolment secrets for organization "${org}" - any of them is accepted by POST /enrol.
33
+ # A node presents one once, to be issued its own broker credential; it grants nothing else, no read
34
+ # and no write. To rotate: add a new line above and leave the old one until every node flashed with
35
+ # it has enrolled, then delete it. To withdraw one immediately, delete its line.`;
36
+ };
37
+ const isList = (name) => name.startsWith('enrolment_');
38
+
39
+ function generate() {
40
+ return randomBytes(32).toString('base64url');
41
+ }
42
+
43
+ /*
44
+ * Make sure every name in `names` has a value, generating and persisting any that do not.
45
+ *
46
+ * Returns { secrets, generated, written, error }:
47
+ * secrets - name -> value, always complete, so the caller can use it unconditionally
48
+ * generated - names that had to be made up on this run
49
+ * written - true if they reached the file, false if they exist only for this process
50
+ * error - why the write failed, when it did
51
+ *
52
+ * A failed write is not fatal: the server works, and its sessions simply end when it restarts.
53
+ * That can happen legitimately - a read-only install directory, or running as a user who does not
54
+ * own it - so it is reported rather than thrown.
55
+ */
56
+ export function ensureSecrets(existing, configDir, names) {
57
+ const secrets = { ...(existing || {}) };
58
+ const generated = names.filter((n) => !secrets[n]);
59
+ if (!generated.length) return { secrets, generated, written: true };
60
+
61
+ for (const n of generated) secrets[n] = isList(n) ? [generate()] : generate();
62
+
63
+ const path = `${configDir}/secrets.yaml`;
64
+ const body = generated.map((n) => (isList(n)
65
+ ? `\n# ${LIST_NOTE(n)}\n${n}:\n - "${secrets[n][0]}"\n`
66
+ : `\n# ${NOTES[n] || n}\n${n}: "${secrets[n]}"\n`)).join('');
67
+ try {
68
+ if (existsSync(path)) {
69
+ appendFileSync(path, body);
70
+ } else {
71
+ writeFileSync(path, HEADER + body);
72
+ }
73
+ chmodSync(path, 0o600);
74
+ return { secrets, generated, written: true };
75
+ } catch (e) {
76
+ return { secrets, generated, written: false, error: e.message };
77
+ }
78
+ }
79
+
80
+ /*
81
+ * Adding and withdrawing an organization's enrolment secrets, for the dashboard (SECURITY.md S10).
82
+ *
83
+ * Rewrites just that organization's block, leaving the rest of the file - and its comments, which
84
+ * say what each secret costs to rotate - untouched. Loading and re-emitting the whole file as YAML
85
+ * would be less code and would throw those comments away.
86
+ *
87
+ * Synchronous, like ensureSecrets: one small file, changed by hand at human speed.
88
+ */
89
+
90
+ const ENROL_NAME = (org) => `enrolment_${org}`;
91
+
92
+ // The organization's block, whether or not it is there: "enrolment_x:" and the indented list items
93
+ // under it. Anything else - a comment, another secret - ends the match.
94
+ const blockFor = (org) =>
95
+ new RegExp(`^${ENROL_NAME(org)}:[ \\t]*\\n(?:[ \\t]+-[^\\n]*\\n)*`, 'm');
96
+
97
+ function renderBlock(org, list) {
98
+ const items = list.map((v) => ` - "${v}"`).join('\n');
99
+ return `${ENROL_NAME(org)}:\n${items}${items ? '\n' : ''}`;
100
+ }
101
+
102
+ /*
103
+ * Write `list` as the organization's enrolment secrets.
104
+ *
105
+ * cb(err) - callers report and carry on. An unwritable file means the change lasts only until the
106
+ * next restart, which is worth saying rather than pretending it was saved.
107
+ */
108
+ function writeEnrolmentSecrets(configDir, org, list, cb) {
109
+ const path = `${configDir}/secrets.yaml`;
110
+ try {
111
+ const block = renderBlock(org, list);
112
+ if (!existsSync(path)) {
113
+ writeFileSync(path, HEADER + `\n# ${LIST_NOTE(ENROL_NAME(org))}\n${block}`);
114
+ } else {
115
+ const text = readFileSync(path, 'utf8');
116
+ const re = blockFor(org);
117
+ if (re.test(text)) {
118
+ writeFileSync(path, text.replace(re, block));
119
+ } else {
120
+ // No block of its own yet - an organization added since the file was written
121
+ appendFileSync(path, `\n# ${LIST_NOTE(ENROL_NAME(org))}\n${block}`);
122
+ }
123
+ }
124
+ chmodSync(path, 0o600);
125
+ cb(null);
126
+ } catch (e) {
127
+ cb(e);
128
+ }
129
+ }
130
+
131
+ /*
132
+ * Add a secret, keeping the existing ones.
133
+ *
134
+ * That is the whole point of a list: a node already flashed with the old value, and not yet
135
+ * enrolled, would be stranded if adding replaced it. cb(err, {secret, list}).
136
+ */
137
+ export function addEnrolmentSecret(configDir, org, current, cb) {
138
+ const list = [generate(), ...(current || [])];
139
+ writeEnrolmentSecrets(configDir, org, list, (err) => cb(err, { secret: list[0], list }));
140
+ }
141
+
142
+ // Withdraw one secret, by value. cb(err, {list, removed}).
143
+ export function removeEnrolmentSecret(configDir, org, secret, current, cb) {
144
+ const list = (current || []).filter((v) => v !== secret);
145
+ const removed = list.length !== (current || []).length;
146
+ if (!removed) return cb(null, { list, removed });
147
+ writeEnrolmentSecrets(configDir, org, list, (err) => cb(err, { list, removed }));
148
+ }
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": "^1.3.15",
16
- "frugal-iot-logger": "^1.1.21",
15
+ "frugal-iot-client": "^2.0.0",
16
+ "frugal-iot-logger": "^2.0.0",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -32,7 +32,10 @@
32
32
  "frugal-iot-diagnostic": "scripts/diagnostic.zsh",
33
33
  "frugal-iot-clearretained": "scripts/clearretained.js",
34
34
  "frugal-iot-addbridge-prod": "scripts/addbridge-prod.zsh",
35
- "frugal-iot-addbridge-pi": "scripts/addbridge-pi.zsh"
35
+ "frugal-iot-addbridge-pi": "scripts/addbridge-pi.zsh",
36
+ "frugal-iot-rebuild-dynsec": "scripts/rebuild-dynsec.js",
37
+ "frugal-iot-dynsec-init": "scripts/dynsec-init.js",
38
+ "frugal-iot-resetnode": "scripts/resetnode.js"
36
39
  },
37
40
  "description": "Frugal IoT client and server",
38
41
  "//files": "An allowlist, not an ignore list - anything not named here is never published, so a stray file (a database backup, a credential) cannot leak by default. Check with: npm pack --dry-run",
@@ -50,6 +53,7 @@
50
53
  "private/",
51
54
  "scripts/",
52
55
  "extras/mosquitto.conf",
56
+ "extras/aclfile",
53
57
  "extras/mosquitto-bridge.conf.example",
54
58
  "extras/frugaliot.service",
55
59
  "API.md",
@@ -79,5 +83,5 @@
79
83
  "check-files": "node scripts/check-published-files.js"
80
84
  },
81
85
  "type": "module",
82
- "version": "0.3.9"
86
+ "version": "2.0.0"
83
87
  }
package/public/index.html CHANGED
@@ -167,14 +167,6 @@
167
167
  <div class="nav-section">
168
168
  <h2>Other examples</h2>
169
169
  <div class="box-grid">
170
- <!-- The display the Dashboard used before the cards. Kept reachable while people get used
171
- to the new one; it will go once nothing needs it. -->
172
- <div class="nav-box">
173
- <div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
174
- <div class="nav-box-content">
175
- <a href="/dashboard/index-old.html">Previous dashboard</a>
176
- </div>
177
- </div>
178
170
  <div class="nav-box">
179
171
  <div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
180
172
  <div class="nav-box-content">
@@ -1,18 +1,68 @@
1
1
  // Changing this version is what makes an already installed PWA fetch the app again instead of
2
2
  // serving what it cached. It follows the version of frugal-iot-client this release installs, and
3
3
  // "npm run prerelease" sets it - so there is normally no reason to edit it by hand.
4
- const CACHE_NAME = 'frugal-iot-cache-1.3.15';
4
+ //
5
+ // The suffix is here because urlsToCache below CHANGED without the client version changing. Without
6
+ // it, an installed browser keeps serving the files it already cached - and since the list now covers
7
+ // the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
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';
10
+ /*
11
+ * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
+ *
13
+ * The dashboard's OWN code was missing from this list, which was most of the point of having one:
14
+ * eight files - the entry point, five modules and the stylesheet - were fetched from the network on
15
+ * every visit while the libraries beside them came from the cache.
16
+ *
17
+ * Two rules for anything added here:
18
+ *
19
+ * 1. It must return 200 WITHOUT a session. install() rejects the whole cache if any single entry
20
+ * fails, so one 404 or one redirect-to-login means this PWA has no cache at all - and the app
21
+ * goes on working online, so nothing announces it. /dashboard/*.js and the stylesheet do serve
22
+ * unauthenticated; /dashboard/index.html does NOT (it 307s to the login page).
23
+ * 2. It must not be a session-gated PAGE. Beyond breaking install, a cached /dashboard/index.html
24
+ * would be served to a logged-out visitor - which is the "accessing from service worker which
25
+ * has /dashboard cached" case admin.js already had to handle.
26
+ *
27
+ * Deliberately absent:
28
+ * /dashboard/index.html session-gated, see above
29
+ * /node_modules/esptool-js/* ~1MB, wanted on one screen, and not by the phone users this list
30
+ * exists for
31
+ *
32
+ * Every URL below was checked against a running server. Two entries in index.html's importmap were
33
+ * not, and are dead: "chart.js" points at dist/Chart.js, which does not exist (the file is
34
+ * lowercase, and graph.js imports the full path anyway), and "chartjs-adapter-luxon" points into a
35
+ * package that is not installed at all - its code was copied into core.js instead. Neither is
36
+ * reachable today; both would 404 the moment somebody used the bare specifier.
37
+ */
5
38
  const urlsToCache = [
39
+ // The public landing page and the PWA's own furniture
6
40
  '/',
7
41
  '/index.html',
42
+ '/manifest.json',
43
+ '/favicon.ico',
8
44
  '/images/icon-192x192.png',
9
45
  '/images/icon-512x512.png',
46
+ // The dashboard: its stylesheet, its entry point, and the modules dashboard.js imports
47
+ '/dashboard/frugaliot.css',
48
+ '/dashboard/dashboard.js',
49
+ '/dashboard/core.js',
50
+ '/dashboard/widgets.js',
51
+ '/dashboard/graph.js',
52
+ '/dashboard/admin.js',
53
+ '/dashboard/flash.js',
54
+ '/dashboard/cards.js',
55
+ // Logging in - the page a visitor lands on when the session has gone
56
+ '/dashboard/login.html',
57
+ '/dashboard/login.js',
58
+ // Libraries, at the paths index.html's importmap and the modules actually use
10
59
  '/node_modules/html-element-extended/htmlelementextended.js',
11
60
  '/node_modules/mqtt/dist/mqtt.esm.js',
12
61
  '/node_modules/js-yaml/dist/js-yaml.mjs',
13
62
  '/node_modules/async/dist/async.mjs',
14
63
  '/node_modules/csv-parse/dist/esm/index.js',
15
64
  '/node_modules/chart.js/dist/chart.js',
65
+ '/node_modules/@kurkle/color/dist/color.esm.js', // chart.js needs it; was missing
16
66
  '/node_modules/dial-gauge/dial-gauge.js',
17
67
  '/node_modules/luxon/src/luxon.js'
18
68
  ];