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/INSTALLATION.md +77 -18
- package/config.d/schema/modules.yaml +15 -0
- package/config.d/schema/topics.yaml +23 -0
- package/extras/aclfile +38 -0
- package/extras/mosquitto.conf +29 -0
- package/frugal-iot-createdb.sql +82 -1
- package/frugal-iot-server.js +552 -72
- package/lib/api-routes.js +7 -7
- package/lib/config-for-user.js +115 -0
- package/lib/dynsec-plan.js +246 -0
- package/lib/dynsec-server.js +204 -0
- package/lib/dynsec-sync.js +209 -0
- package/lib/dynsec.js +166 -0
- package/lib/enrol.js +288 -0
- package/lib/replica.js +246 -0
- package/lib/retained.js +110 -0
- package/lib/secrets.js +148 -0
- package/package.json +8 -4
- package/public/index.html +0 -8
- package/public/service-worker.js +51 -1
- package/scripts/addbridge-pi.zsh +56 -2
- package/scripts/addbridge-prod.zsh +48 -0
- package/scripts/addorganization.zsh +5 -0
- package/scripts/diagnostic.zsh +121 -2
- package/scripts/dynsec-init.js +142 -0
- package/scripts/init.zsh +63 -0
- package/scripts/install-pi.sh +83 -4
- package/scripts/migrate-permissions-project.sql +21 -0
- package/scripts/not-published.txt +5 -0
- package/scripts/rebuild-dynsec.js +190 -0
- package/scripts/resetnode.js +69 -0
|
@@ -25,3 +25,8 @@ test/**
|
|
|
25
25
|
# --- Placeholders ---
|
|
26
26
|
# Keeps the ota directory present in a clone; a real installation gets one from frugal-iot-init
|
|
27
27
|
ota/README.md
|
|
28
|
+
|
|
29
|
+
# Describes how the security works, and would be useful in an installation - but it ends with an
|
|
30
|
+
# open-issues section listing what is currently exploitable, which must not ship to everyone who
|
|
31
|
+
# installs the package. Published by hand, without that section.
|
|
32
|
+
SECURITY.md
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Make the broker's dynamic-security state match this server's database, or just report where it
|
|
4
|
+
* differs.
|
|
5
|
+
*
|
|
6
|
+
* npx --no frugal-iot-rebuild-dynsec apply
|
|
7
|
+
* npx --no frugal-iot-rebuild-dynsec check report only, change nothing
|
|
8
|
+
*
|
|
9
|
+
* Run from the server's own directory. Everything it writes is either stored in the database or
|
|
10
|
+
* derivable from it, so it is safe to re-run: after restoring a database, after a release that adds
|
|
11
|
+
* a rule, or when something looks wrong. "check" is what scripts/diagnostic.zsh calls.
|
|
12
|
+
*
|
|
13
|
+
* "check", not "--check": npm parses the whole command line itself and swallows any --flag it does
|
|
14
|
+
* not recognise, so "npx --no frugal-iot-rebuild-dynsec --check" arrives here with no arguments and
|
|
15
|
+
* would silently APPLY instead of reporting. That is the npx trap in claude.md, and this command hit
|
|
16
|
+
* it - "--check" is still accepted for a direct "node scripts/rebuild-dynsec.js --check".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import sqlite3 from 'sqlite3';
|
|
20
|
+
import { MqttLogger } from 'frugal-iot-logger';
|
|
21
|
+
import { dynsecConnect } from '../lib/dynsec.js';
|
|
22
|
+
import {
|
|
23
|
+
readScopes, readPublics, readProjects, readUserRows,
|
|
24
|
+
applyRolesAndGroups, applyUser, applyNode, applyLogger, applyOrgAdmin, checkRolesAndGroups,
|
|
25
|
+
} from '../lib/dynsec-sync.js';
|
|
26
|
+
import { names } from '../lib/dynsec-plan.js';
|
|
27
|
+
|
|
28
|
+
const userClientName = names.userClient;
|
|
29
|
+
|
|
30
|
+
const ARGS = process.argv.slice(2);
|
|
31
|
+
const CHECK = ARGS.includes('check') || ARGS.includes('--check');
|
|
32
|
+
const DB = './frugal-iot.db';
|
|
33
|
+
|
|
34
|
+
function fail(msg, code = 1) {
|
|
35
|
+
console.error(msg);
|
|
36
|
+
process.exit(code);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The same reader the server uses, so this sees exactly the configuration the server sees -
|
|
40
|
+
// config.yaml plus config.d, including config.d/secrets.yaml.
|
|
41
|
+
//
|
|
42
|
+
// It logs a line per file it reads, which is fine at server startup and noise in a command whose
|
|
43
|
+
// output scripts/diagnostic.zsh includes - so it is quietened for the duration of the read only.
|
|
44
|
+
const realLog = console.log;
|
|
45
|
+
console.log = () => {};
|
|
46
|
+
new MqttLogger().readYamlConfig('.', (err, config) => {
|
|
47
|
+
console.log = realLog;
|
|
48
|
+
if (err) return fail(`Could not read the configuration: ${err.message}`);
|
|
49
|
+
const orgs = Object.keys((config && config.organizations) || {});
|
|
50
|
+
const userSecret = config.secrets && config.secrets.user_secret;
|
|
51
|
+
if (!CHECK && !userSecret) {
|
|
52
|
+
return fail('No user_secret in config.d/secrets.yaml - run: npx --no frugal-iot-init');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const db = new sqlite3.Database(DB, sqlite3.OPEN_READONLY, (dberr) => {
|
|
56
|
+
if (dberr) return fail(`Could not open ${DB}: ${dberr.message}. Run this from the server's own directory.`);
|
|
57
|
+
|
|
58
|
+
dynsecConnect(config, (cerr, dynsec) => {
|
|
59
|
+
if (cerr) {
|
|
60
|
+
// Being unable to reach the plugin is a normal state on a broker that has none, so say what
|
|
61
|
+
// it means rather than only what failed.
|
|
62
|
+
console.error(`Cannot reach the dynamic security plugin: ${cerr.message}`);
|
|
63
|
+
console.error('If this broker has no plugin, per-user accounts are unavailable and that is');
|
|
64
|
+
console.error('all this means. frugal-iot-diagnostic reports which case you are in.');
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
readScopes(db, orgs, (e1, scopes) => {
|
|
69
|
+
if (e1) return fail(e1.message);
|
|
70
|
+
readPublics(db, (e2, publics) => {
|
|
71
|
+
if (e2) return fail(e2.message);
|
|
72
|
+
readProjects(db, (e2b, projects) => {
|
|
73
|
+
if (e2b) return fail(e2b.message);
|
|
74
|
+
|
|
75
|
+
if (CHECK) {
|
|
76
|
+
checkRolesAndGroups(dynsec, scopes, publics, projects, (e3, differences) => {
|
|
77
|
+
if (e3) return fail(e3.message);
|
|
78
|
+
db.all('SELECT id, username FROM users WHERE id > 0 AND username IS NOT NULL', [], (e4, users) => {
|
|
79
|
+
if (e4) return fail(e4.message);
|
|
80
|
+
dynsec.listClients((e5, clients) => {
|
|
81
|
+
if (e5) return fail(e5.message);
|
|
82
|
+
for (const u of users) {
|
|
83
|
+
const want = userClientName(u.username);
|
|
84
|
+
if (!clients.includes(want)) differences.push(`client missing: ${want}`);
|
|
85
|
+
}
|
|
86
|
+
for (const org of orgs) {
|
|
87
|
+
const want = names.loggerClient(org);
|
|
88
|
+
if (!clients.includes(want)) differences.push(`client missing: ${want}`);
|
|
89
|
+
}
|
|
90
|
+
report(differences);
|
|
91
|
+
dynsec.end(() => process.exit(differences.length ? 3 : 0));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
applyRolesAndGroups(dynsec, scopes, publics, projects, (e3) => {
|
|
99
|
+
if (e3) return fail(`Setting up roles and groups: ${e3.message}`);
|
|
100
|
+
console.log(`Roles and groups for ${scopes.length} organization/project scope(s), ` +
|
|
101
|
+
`${projects.length} project(s), ${publics.length} publicly readable`);
|
|
102
|
+
syncUsers(db, dynsec, userSecret, (e4, n) => {
|
|
103
|
+
if (e4) return fail(`Syncing users: ${e4.message}`);
|
|
104
|
+
console.log(`${n} user account(s)`);
|
|
105
|
+
syncNodes(db, dynsec, (e5, m) => {
|
|
106
|
+
if (e5) return fail(`Syncing nodes: ${e5.message}`);
|
|
107
|
+
console.log(`${m} node account(s)`);
|
|
108
|
+
syncLoggers(dynsec, orgs, userSecret, (e6, k) => {
|
|
109
|
+
if (e6) return fail(`Syncing logger accounts: ${e6.message}`);
|
|
110
|
+
console.log(`${k} logger account(s)`);
|
|
111
|
+
syncOrgAdmins(dynsec, orgs, userSecret, (e7, j) => {
|
|
112
|
+
if (e7) return fail(`Syncing admin accounts: ${e7.message}`);
|
|
113
|
+
console.log(`${j} server admin account(s)`);
|
|
114
|
+
dynsec.end(() => process.exit(0));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
function report(differences) {
|
|
128
|
+
if (!differences.length) {
|
|
129
|
+
console.log('The broker matches the database.');
|
|
130
|
+
} else {
|
|
131
|
+
console.log(`${differences.length} difference(s) between the database and the broker:`);
|
|
132
|
+
for (const d of differences) console.log(` ${d}`);
|
|
133
|
+
console.log('Run without --check to correct them.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// One at a time on purpose: a Pi Zero has one core, there is no hurry, and a batch that fails
|
|
138
|
+
// half-way is harder to reason about than a sequence that stops.
|
|
139
|
+
function syncUsers(db, dynsec, userSecret, cb) {
|
|
140
|
+
db.all('SELECT id, username, hashed_password FROM users WHERE id > 0 AND username IS NOT NULL', [], (err, users) => {
|
|
141
|
+
if (err) return cb(err);
|
|
142
|
+
let i = 0;
|
|
143
|
+
const next = () => {
|
|
144
|
+
if (i >= users.length) return cb(null, users.length);
|
|
145
|
+
const u = users[i++];
|
|
146
|
+
readUserRows(db, u.id, (e, rows) => {
|
|
147
|
+
if (e) return cb(e);
|
|
148
|
+
applyUser(dynsec, { username: u.username, hashedPassword: u.hashed_password, rows, userSecret },
|
|
149
|
+
(e2) => (e2 ? cb(e2) : next()));
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
next();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function syncLoggers(dynsec, orgs, userSecret, cb) {
|
|
157
|
+
let i = 0;
|
|
158
|
+
const next = () => {
|
|
159
|
+
if (i >= orgs.length) return cb(null, orgs.length);
|
|
160
|
+
applyLogger(dynsec, { org: orgs[i++], userSecret }, (e) => (e ? cb(e) : next()));
|
|
161
|
+
};
|
|
162
|
+
next();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function syncOrgAdmins(dynsec, orgs, userSecret, cb) {
|
|
166
|
+
let i = 0;
|
|
167
|
+
const next = () => {
|
|
168
|
+
if (i >= orgs.length) return cb(null, orgs.length);
|
|
169
|
+
applyOrgAdmin(dynsec, { org: orgs[i++], userSecret }, (e) => (e ? cb(e) : next()));
|
|
170
|
+
};
|
|
171
|
+
next();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function syncNodes(db, dynsec, cb) {
|
|
175
|
+
// The nodes table arrives with S6; until then there is nothing to do and that is not an error.
|
|
176
|
+
db.all("SELECT name FROM sqlite_master WHERE type='table' AND name='nodes'", [], (err, t) => {
|
|
177
|
+
if (err) return cb(err);
|
|
178
|
+
if (!t.length) return cb(null, 0);
|
|
179
|
+
db.all('SELECT org, project, nodeid, password, lora FROM nodes', [], (e, nodes) => {
|
|
180
|
+
if (e) return cb(e);
|
|
181
|
+
let i = 0;
|
|
182
|
+
const next = () => {
|
|
183
|
+
if (i >= nodes.length) return cb(null, nodes.length);
|
|
184
|
+
const n = nodes[i++];
|
|
185
|
+
applyNode(dynsec, { ...n, lora: !!n.lora }, (e2) => (e2 ? cb(e2) : next()));
|
|
186
|
+
};
|
|
187
|
+
next();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Forget a node, so it can enrol again.
|
|
4
|
+
*
|
|
5
|
+
* npx --no frugal-iot-resetnode <org> <project> <nodeid>
|
|
6
|
+
*
|
|
7
|
+
* Run from the server's own directory.
|
|
8
|
+
*
|
|
9
|
+
* A node proves it is itself by presenting the credential it already holds, so only the node can be
|
|
10
|
+
* issued a new one - otherwise anyone with the organization's enrolment secret could take over any
|
|
11
|
+
* node by quoting its id, which is public (it comes from the chip's MAC). The cost of that is this
|
|
12
|
+
* command: a node whose filesystem has been erased can prove nothing, and somebody has to say "yes,
|
|
13
|
+
* that really is my node".
|
|
14
|
+
*
|
|
15
|
+
* Rare in the field. Common while developing, where erasing flash is routine - two ways to avoid it
|
|
16
|
+
* there are to keep using the older configure_mqtt(host, user, password) form, which does not enrol
|
|
17
|
+
* at all, or to give a development organization its own looser policy.
|
|
18
|
+
*
|
|
19
|
+
* Positional arguments, no flags: npm swallows any --flag it does not recognise, so a flag would
|
|
20
|
+
* arrive here as nothing at all. See the npx note in claude.md.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import sqlite3 from 'sqlite3';
|
|
24
|
+
import { MqttLogger } from 'frugal-iot-logger';
|
|
25
|
+
import { forgetNode } from '../lib/enrol.js';
|
|
26
|
+
import { dropNode } from '../lib/dynsec-server.js';
|
|
27
|
+
|
|
28
|
+
const [org, project, nodeid] = process.argv.slice(2);
|
|
29
|
+
if (!org || !project || !nodeid) {
|
|
30
|
+
console.error('Usage: npx --no frugal-iot-resetnode <org> <project> <nodeid>');
|
|
31
|
+
console.error('Example: npx --no frugal-iot-resetnode myfarm lotus esp32-abc123');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const realLog = console.log;
|
|
36
|
+
console.log = () => {}; // the config reader logs a line per file
|
|
37
|
+
new MqttLogger().readYamlConfig('.', (err, config) => {
|
|
38
|
+
console.log = realLog;
|
|
39
|
+
if (err) {
|
|
40
|
+
console.error(`Could not read the configuration: ${err.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const db = new sqlite3.Database('./frugal-iot.db', (dberr) => {
|
|
44
|
+
if (dberr) {
|
|
45
|
+
console.error(`Could not open ./frugal-iot.db: ${dberr.message}`);
|
|
46
|
+
console.error("Run this from the server's own directory.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
forgetNode(db, org, project, nodeid, (ferr, changes) => {
|
|
50
|
+
if (ferr) { console.error(ferr.message); process.exit(1); }
|
|
51
|
+
if (!changes) {
|
|
52
|
+
console.log(`No enrolled node ${org}/${project}/${nodeid} - nothing to forget.`);
|
|
53
|
+
console.log('It can enrol as soon as it asks. Check the spelling if you expected one.');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
console.log(`Forgot ${org}/${project}/${nodeid} - it will enrol again on its next attempt.`);
|
|
57
|
+
// Remove the broker account too, so nothing is left that the database no longer knows about.
|
|
58
|
+
// Best effort: enrolling recreates it, and a broker that cannot be reached now is reported by
|
|
59
|
+
// frugal-iot-diagnostic as drift.
|
|
60
|
+
dropNode(config, org, project, nodeid, (derr) => {
|
|
61
|
+
if (derr) {
|
|
62
|
+
console.log(`The broker account was not removed: ${derr.message}`);
|
|
63
|
+
console.log('Harmless - re-enrolling replaces it. frugal-iot-rebuild-dynsec tidies up.');
|
|
64
|
+
}
|
|
65
|
+
db.close(() => process.exit(0));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|