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
package/lib/api-routes.js
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
createSuccessResponse
|
|
27
27
|
} from './api-errors.js';
|
|
28
28
|
// Reused directly from the main server rather than reimplemented - see frugal-iot-server.js
|
|
29
|
-
import { loggedInOrFail, can_ADMIN_JSON, can_ADMIN_SOME, can_READ, add_project } from '../frugal-iot-server.js';
|
|
29
|
+
import { loggedInOrFail, can_ADMIN_JSON, can_ADMIN_SOME, can_READ, can_WRITE, add_project } from '../frugal-iot-server.js';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Create API router for farm-platform endpoints
|
|
@@ -330,7 +330,7 @@ export function createAPIRouter(db, dataDir, loggerClient, pushManager = null) {
|
|
|
330
330
|
*/
|
|
331
331
|
router.post('/devices/action',
|
|
332
332
|
(req, res, next) => {
|
|
333
|
-
//
|
|
333
|
+
// can_WRITE (reused from frugal-iot-server.js) reads the org from res.locals.org, so set it first.
|
|
334
334
|
const deviceId = req.body && req.body['device-id'];
|
|
335
335
|
if (!deviceId) {
|
|
336
336
|
return next(new APIError('invalid_request', 'Missing required field: device-id'));
|
|
@@ -339,7 +339,7 @@ export function createAPIRouter(db, dataDir, loggerClient, pushManager = null) {
|
|
|
339
339
|
next();
|
|
340
340
|
},
|
|
341
341
|
loggedInOrFail,
|
|
342
|
-
|
|
342
|
+
can_WRITE,
|
|
343
343
|
async (req, res, next) => {
|
|
344
344
|
try {
|
|
345
345
|
const {
|
|
@@ -376,7 +376,7 @@ export function createAPIRouter(db, dataDir, loggerClient, pushManager = null) {
|
|
|
376
376
|
*/
|
|
377
377
|
router.get('/devices/action',
|
|
378
378
|
(req, res, next) => {
|
|
379
|
-
//
|
|
379
|
+
// can_WRITE (reused from frugal-iot-server.js) reads the org from res.locals.org, so set it first.
|
|
380
380
|
const { deviceId } = req.query;
|
|
381
381
|
if (!deviceId) {
|
|
382
382
|
return next(new APIError('invalid_request', 'Missing required parameter: deviceId'));
|
|
@@ -385,7 +385,7 @@ export function createAPIRouter(db, dataDir, loggerClient, pushManager = null) {
|
|
|
385
385
|
next();
|
|
386
386
|
},
|
|
387
387
|
loggedInOrFail,
|
|
388
|
-
|
|
388
|
+
can_WRITE,
|
|
389
389
|
async (req, res, next) => {
|
|
390
390
|
try {
|
|
391
391
|
const {deviceId, action, value} = req.query;
|
|
@@ -479,7 +479,7 @@ export function createAPIRouter(db, dataDir, loggerClient, pushManager = null) {
|
|
|
479
479
|
*/
|
|
480
480
|
router.put('/devices/property',
|
|
481
481
|
(req, res, next) => {
|
|
482
|
-
//
|
|
482
|
+
// can_WRITE (reused from frugal-iot-server.js) reads the org from res.locals.org, so set it first.
|
|
483
483
|
const { deviceId } = req.query;
|
|
484
484
|
if (!deviceId) {
|
|
485
485
|
return next(new APIError('invalid_request', 'Missing required parameter: deviceId'));
|
|
@@ -488,7 +488,7 @@ export function createAPIRouter(db, dataDir, loggerClient, pushManager = null) {
|
|
|
488
488
|
next();
|
|
489
489
|
},
|
|
490
490
|
loggedInOrFail,
|
|
491
|
-
|
|
491
|
+
can_WRITE,
|
|
492
492
|
async (req, res, next) => {
|
|
493
493
|
try {
|
|
494
494
|
const { deviceId, property } = req.query;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What of the configuration a given user is allowed to see.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of frugal-iot-server.js so it can be tested without starting a server: importing that
|
|
5
|
+
* file reads config.yaml, connects to the broker and starts listening, none of which a unit test
|
|
6
|
+
* wants. These two functions are pure - given a config and a user they return an object - which is
|
|
7
|
+
* what makes the "no secret ever reaches a browser" test in test/unit/config-for-user.test.js
|
|
8
|
+
* possible.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Sections of the configuration that are never sent to a browser, whatever permissions the user has.
|
|
12
|
+
// config.d itself is not served - the static mounts are publicdir, htmldir, datadir, privatedir and
|
|
13
|
+
// node_modules, so config.d/schema/devices.yaml is a 404 - and buildConfigFor is the only route by
|
|
14
|
+
// which anything under it reaches a browser. So this is the one place the decision belongs.
|
|
15
|
+
//
|
|
16
|
+
// A deny list rather than an allowlist on purpose: nearly every setting here is public (the schema,
|
|
17
|
+
// the broker URL, the logger URL), so an allowlist would be mostly bookkeeping and would break new
|
|
18
|
+
// public fields by forgetting them. The risk that comes with that choice - a future secret field
|
|
19
|
+
// being served because nobody remembered to deny it - is caught by the test suite instead, which
|
|
20
|
+
// asserts that nothing secret-looking appears anywhere in the result.
|
|
21
|
+
export const configSectionsNeverServed = [
|
|
22
|
+
'secrets', // config.d/secrets.yaml - session_secret, user_secret
|
|
23
|
+
// config.d/email.yaml holds "pass", the SMTP (typically Gmail app) password. It was being served
|
|
24
|
+
// to every logged-in user, which on a server with mail configured is a working credential to send
|
|
25
|
+
// mail as that account - and registration is open, so "logged in" is not much of a bar. Nothing in
|
|
26
|
+
// the client reads any of it: whether password reset is available is decided server-side by
|
|
27
|
+
// mailConfigured(). Found by the "nothing secret-looking" test below, on its first run.
|
|
28
|
+
'email',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Fields within an organization's own config that are not sent.
|
|
32
|
+
//
|
|
33
|
+
// mqtt_password is the organization's shared broker credential - readwrite over its whole topic
|
|
34
|
+
// tree, the same one compiled into every node. Serving it to every user with READ was SEC-1: it
|
|
35
|
+
// made "may read" mean "may publish anything", and there was no way to revoke it. Browsers now get
|
|
36
|
+
// their own derived credential in user.mqtt_password instead (S4), so this can finally go.
|
|
37
|
+
//
|
|
38
|
+
// Still in config.d/organizations/<org>.yaml, because the nodes and the logger use it until S8
|
|
39
|
+
// retires it - it just never leaves the server.
|
|
40
|
+
export const orgFieldsNeverServed = [
|
|
41
|
+
'mqtt_password',
|
|
42
|
+
// The logger's own derived credential, put into the in-memory config at startup so the logger can
|
|
43
|
+
// pick it up. It can read the whole organization, so it is at least as sensitive as the shared
|
|
44
|
+
// password was.
|
|
45
|
+
'logger_password',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Does this user hold this capability? Two different questions, depending on whether a project is
|
|
50
|
+
* named, and they are both wanted:
|
|
51
|
+
*
|
|
52
|
+
* hasPermissions(u, 'dev', 'READ') "anywhere in dev?" - one project is enough. This
|
|
53
|
+
* is what decides whether dev is shown at all.
|
|
54
|
+
* hasPermissions(u, 'dev', 'READ', 'lotus') "on dev/lotus?" - satisfied by a row for lotus
|
|
55
|
+
* or by an organization-wide row.
|
|
56
|
+
*
|
|
57
|
+
* A row's project is '' for organization-wide (see frugal-iot-createdb.sql for why empty string
|
|
58
|
+
* rather than NULL), and an organization-wide row answers yes to both questions.
|
|
59
|
+
*/
|
|
60
|
+
export function hasPermissions(user, org, permission, project) {
|
|
61
|
+
if (!user || !user.permissions) return false;
|
|
62
|
+
return user.permissions.some((x) =>
|
|
63
|
+
(x.capability === permission) && (x.org === org)
|
|
64
|
+
&& (!project || !x.project || (x.project === project)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* Produce a copy of config holding only what this user may see. A subset of config that points at
|
|
69
|
+
* the same objects where it can, rather than copying them - do not change the result.
|
|
70
|
+
*
|
|
71
|
+
* Two levels of filtering, because a permission can name a project:
|
|
72
|
+
* - an organization appears if the user may read it anywhere (any project is enough)
|
|
73
|
+
* - within it, only the projects they may actually read
|
|
74
|
+
* So a user with READ on dev/lotus alone sees organization dev containing lotus, and is not even
|
|
75
|
+
* told the other projects exist.
|
|
76
|
+
*/
|
|
77
|
+
export function buildConfigFor(config, user) {
|
|
78
|
+
let oo = {
|
|
79
|
+
organizations: {},
|
|
80
|
+
user: user, // All data in user and permissions is visible to the user
|
|
81
|
+
};
|
|
82
|
+
Object.entries(config).forEach(([key, value]) => {
|
|
83
|
+
if (configSectionsNeverServed.includes(key)) {
|
|
84
|
+
// Deliberately absent from the result
|
|
85
|
+
} else if (key === 'organizations') {
|
|
86
|
+
Object.entries(value).forEach(([orgid, org]) => {
|
|
87
|
+
if (hasPermissions(user, orgid, 'READ')) {
|
|
88
|
+
oo.organizations[orgid] = orgForUser(user, orgid, org);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
oo[key] = value;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return oo;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// One organization, with the withheld fields gone and its projects narrowed to those this user may
|
|
99
|
+
// read. An organization-wide READ satisfies every project, so the common case keeps everything.
|
|
100
|
+
function orgForUser(user, orgid, org) {
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const [k, v] of Object.entries(org)) {
|
|
103
|
+
if (orgFieldsNeverServed.includes(k)) continue;
|
|
104
|
+
if (k === 'projects' && v && typeof v === 'object') {
|
|
105
|
+
const projects = {};
|
|
106
|
+
for (const [projectid, project] of Object.entries(v)) {
|
|
107
|
+
if (hasPermissions(user, orgid, 'READ', projectid)) projects[projectid] = project;
|
|
108
|
+
}
|
|
109
|
+
out[k] = projects;
|
|
110
|
+
} else {
|
|
111
|
+
out[k] = v;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What the broker's dynamic-security state should look like, given the database.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no MQTT, no sqlite, no clock. Given rows it returns the objects that should exist and the
|
|
5
|
+
* commands to make it so - which is what lets the whole mapping be tested without a broker, and
|
|
6
|
+
* what makes "rebuild dynsec from the database" and "report what differs" the same code path.
|
|
7
|
+
*
|
|
8
|
+
* The shapes here follow what was measured on mosquitto 2.0.21 - see SECURITY-REVIEW.md section 8:
|
|
9
|
+
* - %u expands in a dynsec role ACL, so one rule covers every node
|
|
10
|
+
* - a client can be in several groups and gets the union
|
|
11
|
+
* - a role can be attached straight to a client, which is how public read is done without
|
|
12
|
+
* adding every user to a group
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHmac } from 'crypto';
|
|
16
|
+
|
|
17
|
+
// A permission row's project is '' for organization-wide - see frugal-iot-createdb.sql.
|
|
18
|
+
const ORGWIDE = '';
|
|
19
|
+
|
|
20
|
+
// Names. Kept in one place because both the plan and the diff have to agree on them.
|
|
21
|
+
export const names = {
|
|
22
|
+
readRole: (org, project) => (project ? `${org}-${project}-read` : `${org}-read`),
|
|
23
|
+
writeRole: (org, project) => (project ? `${org}-${project}-write` : `${org}-write`),
|
|
24
|
+
readGroup: (org, project) => (project ? `${org}-${project}-read` : `${org}-read`),
|
|
25
|
+
writeGroup: (org, project) => (project ? `${org}-${project}-write` : `${org}-write`),
|
|
26
|
+
nodesGroup: (org) => `${org}-nodes`,
|
|
27
|
+
gatewayGroup:(org) => `${org}-gateways`,
|
|
28
|
+
gatewayRole: (org) => `${org}-gateway`,
|
|
29
|
+
discoverRole:(org) => `${org}-discover`,
|
|
30
|
+
ownSubtreeRole: 'own-subtree', // one for the whole broker: publishClientSend %u/#
|
|
31
|
+
publicRole: 'public-read', // one for the whole broker: the id-0 "everyone" rows
|
|
32
|
+
publicGroup: 'public', // what setAnonymousGroup points at
|
|
33
|
+
nodeClient: (org, project, nodeid) => `${org}/${project}/${nodeid}`,
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* A user's broker account name. Prefixed, and it has to be.
|
|
37
|
+
*
|
|
38
|
+
* addorganization.zsh creates a LOGIN USER named after the organization ("myfarm"), and the
|
|
39
|
+
* organization's own broker account in the password file has that same name. A dynsec client
|
|
40
|
+
* called "myfarm" would therefore take over that name - and because a dynsec answer is final for
|
|
41
|
+
* a client it knows (SECURITY-REVIEW.md section 8, Q4), the organization's shared password would
|
|
42
|
+
* stop working, taking every node, browser and the logger with it. Namespacing the user keeps the
|
|
43
|
+
* two apart until S8 retires the shared account deliberately.
|
|
44
|
+
*
|
|
45
|
+
* "user/" also cannot collide with a node, which is "org/project/nodeid" - no organization id may
|
|
46
|
+
* be "user", because addorganization allows only lower-case letters and digits... which "user"
|
|
47
|
+
* satisfies. So an organization literally called "user" would collide; rejected in
|
|
48
|
+
* add_permission's caller rather than here, where it would be invisible.
|
|
49
|
+
*/
|
|
50
|
+
userClient: (username) => `user/${username}`,
|
|
51
|
+
|
|
52
|
+
// The logger's own account, one per organization. Cannot collide with anything: an organization
|
|
53
|
+
// id is 1-10 lower-case letters and digits (addorganization.zsh enforces it), so no org is called
|
|
54
|
+
// "<org>-logger", and clients and groups are separate namespaces in dynsec anyway.
|
|
55
|
+
loggerClient: (org) => `${org}-logger`,
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* The server's own account for administrative publishes into an organization - at present just
|
|
59
|
+
* clearing retained messages.
|
|
60
|
+
*
|
|
61
|
+
* It can publish anywhere in the organization, which is to say it can forge a reading. That is
|
|
62
|
+
* unavoidable for clearing: "forget this topic" IS a publish (an empty retained payload), and the
|
|
63
|
+
* broker cannot tell one from the other. The point is WHO holds it - only the server, never a
|
|
64
|
+
* browser. Before this, clearing was done by the browser with the organization's shared
|
|
65
|
+
* credential, which is exactly the capability S4 took away.
|
|
66
|
+
*/
|
|
67
|
+
orgAdminClient: (org) => `${org}-admin`,
|
|
68
|
+
clearRole: (org) => `${org}-clear`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Topic filters, in one place for the same reason.
|
|
72
|
+
const topics = {
|
|
73
|
+
orgTree: (org, project) => (project ? `${org}/${project}/#` : `${org}/#`),
|
|
74
|
+
// A browser may command a device but must not forge a reading, so write is set/ only. The extra
|
|
75
|
+
// "+" for an organization-wide rule is the project level: org/project/node/set/...
|
|
76
|
+
setOnly: (org, project) => (project ? `${org}/${project}/+/set/#` : `${org}/+/+/set/#`),
|
|
77
|
+
ownSubtree: '%u/#',
|
|
78
|
+
// How a node announces itself: System_Discovery::quickAdvertise publishes its own node id to the
|
|
79
|
+
// two-level "<org>/<project>" topic, and that is how the dashboard learns the node exists at all.
|
|
80
|
+
//
|
|
81
|
+
// It is NOT covered by ownSubtree. In MQTT "a/b/#" matches "a/b" but not "a", so a node whose
|
|
82
|
+
// rule is "myfarm/lotus/esp32-abc/#" cannot publish to "myfarm/lotus" - which silently left the
|
|
83
|
+
// dashboard showing no devices while the readings themselves went through perfectly.
|
|
84
|
+
discovery: (org, project) => `${org}/${project}`,
|
|
85
|
+
bridgeState: '$SYS/broker/connection/+/state',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* The password a user's browser gets. Derived rather than stored or random - see SECURITY-REVIEW.md
|
|
90
|
+
* section 9: derived from the STORED HASH, so the server can recompute it at any time (a session
|
|
91
|
+
* restored from a cookie has no plaintext), it is the same for two concurrent logins, and a leak
|
|
92
|
+
* tells an attacker nothing about the login password.
|
|
93
|
+
*/
|
|
94
|
+
export function derivePassword(userSecret, username, hashedPassword) {
|
|
95
|
+
if (!userSecret) throw new Error('No user_secret - run frugal-iot-init');
|
|
96
|
+
const hp = Buffer.isBuffer(hashedPassword) ? hashedPassword : Buffer.from(String(hashedPassword || ''));
|
|
97
|
+
return createHmac('sha256', userSecret)
|
|
98
|
+
.update(String(username)).update(':').update(hp)
|
|
99
|
+
.digest('base64url').slice(0, 22); // 22 base64url chars = 132 bits
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* Which groups a user should be in, and which roles attached directly, from their permission rows.
|
|
104
|
+
* Rows are {capability, org, project}; id-0 rows must already be merged in by the caller, exactly as
|
|
105
|
+
* the session's own query does (WHERE id = ? or id = 0).
|
|
106
|
+
*/
|
|
107
|
+
export function groupsForUser(rows) {
|
|
108
|
+
const groups = new Set();
|
|
109
|
+
const roles = new Set();
|
|
110
|
+
for (const r of rows || []) {
|
|
111
|
+
const project = r.project || ORGWIDE;
|
|
112
|
+
if (r.capability === 'READ') groups.add(names.readGroup(r.org, project));
|
|
113
|
+
if (r.capability === 'WRITE') groups.add(names.writeGroup(r.org, project));
|
|
114
|
+
}
|
|
115
|
+
// Every user also gets whatever is public, as one role rather than per-organization membership.
|
|
116
|
+
roles.add(names.publicRole);
|
|
117
|
+
return { groups: [...groups].sort(), roles: [...roles].sort() };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* The roles and groups that should exist, given every organization/project in use and which
|
|
122
|
+
* organizations are publicly readable.
|
|
123
|
+
*
|
|
124
|
+
* scopes [{org, project}] - '' project meaning organization-wide
|
|
125
|
+
* publics [{org, project}] - the id-0 READ rows, as ACLs on the one shared public role
|
|
126
|
+
*/
|
|
127
|
+
export function desiredRolesAndGroups(scopes, publics, projects) {
|
|
128
|
+
const roles = {};
|
|
129
|
+
const groups = {};
|
|
130
|
+
|
|
131
|
+
// One rule for every node on the broker: publish only your own subtree.
|
|
132
|
+
roles[names.ownSubtreeRole] = [
|
|
133
|
+
{ acltype: 'publishClientSend', topic: topics.ownSubtree, allow: true },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// Public read, as ACL pairs on a single role - so withdrawing it is one removeRoleACL, for
|
|
137
|
+
// everybody, rather than removing every user from a group.
|
|
138
|
+
roles[names.publicRole] = [];
|
|
139
|
+
for (const { org, project } of publics || []) {
|
|
140
|
+
const t = topics.orgTree(org, project || ORGWIDE);
|
|
141
|
+
roles[names.publicRole].push({ acltype: 'subscribePattern', topic: t, allow: true });
|
|
142
|
+
roles[names.publicRole].push({ acltype: 'publishClientReceive', topic: t, allow: true });
|
|
143
|
+
}
|
|
144
|
+
groups[names.publicGroup] = { roles: [names.publicRole] };
|
|
145
|
+
|
|
146
|
+
const orgs = new Set();
|
|
147
|
+
for (const { org, project } of scopes || []) {
|
|
148
|
+
const p = project || ORGWIDE;
|
|
149
|
+
orgs.add(org);
|
|
150
|
+
|
|
151
|
+
roles[names.readRole(org, p)] = [
|
|
152
|
+
{ acltype: 'subscribePattern', topic: topics.orgTree(org, p), allow: true },
|
|
153
|
+
{ acltype: 'publishClientReceive', topic: topics.orgTree(org, p), allow: true },
|
|
154
|
+
];
|
|
155
|
+
roles[names.writeRole(org, p)] = [
|
|
156
|
+
{ acltype: 'publishClientSend', topic: topics.setOnly(org, p), allow: true },
|
|
157
|
+
];
|
|
158
|
+
groups[names.readGroup(org, p)] = { roles: [names.readRole(org, p)] };
|
|
159
|
+
groups[names.writeGroup(org, p)] = { roles: [names.writeRole(org, p)] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Per organization: nodes read the whole organization (cross-node controls) and publish only
|
|
163
|
+
// their own subtree; gateways additionally publish anywhere in the organization, because a
|
|
164
|
+
// gateway republishes other nodes' readings under its own account and MQTT cannot delegate.
|
|
165
|
+
/*
|
|
166
|
+
* Announcing on the project topic, one ACL per REGISTERED project.
|
|
167
|
+
*
|
|
168
|
+
* Deliberately not "<org>/+", which would let a node announce a project that does not exist - a
|
|
169
|
+
* name mistyped into a captive portal would then appear in the dashboard as a project, with a
|
|
170
|
+
* node in it. Enumerating the real projects means growth per project (not per node) and no way
|
|
171
|
+
* to invent one. Enrolment checks the same list, so the two cannot disagree.
|
|
172
|
+
*/
|
|
173
|
+
for (const { org, project } of projects || []) {
|
|
174
|
+
if (!project) continue;
|
|
175
|
+
const role = names.discoverRole(org);
|
|
176
|
+
roles[role] = roles[role] || [];
|
|
177
|
+
roles[role].push({ acltype: 'publishClientSend', topic: topics.discovery(org, project), allow: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const org of orgs) {
|
|
181
|
+
if (!roles[names.readRole(org, ORGWIDE)]) {
|
|
182
|
+
roles[names.readRole(org, ORGWIDE)] = [
|
|
183
|
+
{ acltype: 'subscribePattern', topic: topics.orgTree(org, ORGWIDE), allow: true },
|
|
184
|
+
{ acltype: 'publishClientReceive', topic: topics.orgTree(org, ORGWIDE), allow: true },
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
// Publishing anywhere in the organization, for clearing retained messages. Held by the server
|
|
188
|
+
// alone - there is no group for it, so nothing else can be put in it by accident.
|
|
189
|
+
roles[names.clearRole(org)] = [
|
|
190
|
+
{ acltype: 'publishClientSend', topic: topics.orgTree(org, ORGWIDE), allow: true },
|
|
191
|
+
];
|
|
192
|
+
roles[names.gatewayRole(org)] = [
|
|
193
|
+
{ acltype: 'publishClientSend', topic: topics.orgTree(org, ORGWIDE), allow: true },
|
|
194
|
+
];
|
|
195
|
+
// A node that never announces is invisible, so the discover role is part of being a node - but
|
|
196
|
+
// the role exists only if the organization has at least one registered project.
|
|
197
|
+
roles[names.discoverRole(org)] = roles[names.discoverRole(org)] || [];
|
|
198
|
+
groups[names.nodesGroup(org)] = {
|
|
199
|
+
roles: [names.readRole(org, ORGWIDE), names.ownSubtreeRole, names.discoverRole(org)] };
|
|
200
|
+
groups[names.gatewayGroup(org)] = { roles: [names.gatewayRole(org)] };
|
|
201
|
+
}
|
|
202
|
+
return { roles, groups };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* The logger's password for one organization. Derived, like a user's, so nothing has to be stored
|
|
207
|
+
* and the server can recompute it whenever it starts - which is when it hands it to the logger.
|
|
208
|
+
*
|
|
209
|
+
* A separate label in the HMAC input from a user's, so a logger account and a user account can
|
|
210
|
+
* never derive the same password even if the names lined up.
|
|
211
|
+
*/
|
|
212
|
+
export function deriveLoggerPassword(userSecret, org) {
|
|
213
|
+
if (!userSecret) throw new Error('No user_secret - run frugal-iot-init');
|
|
214
|
+
return createHmac('sha256', userSecret)
|
|
215
|
+
.update('logger:').update(String(org))
|
|
216
|
+
.digest('base64url').slice(0, 22);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* The logger reads everything in its organization and publishes ONLY set/ topics.
|
|
221
|
+
*
|
|
222
|
+
* It does need write: it is what publishes the platform API's device commands
|
|
223
|
+
* (frugal-iot-logger/index.js:1384). What it must not be able to do is publish a reading, because
|
|
224
|
+
* then a confused or compromised logger could invent sensor data - and the readings are the whole
|
|
225
|
+
* point of the system. The <org>-write role is set/-only, which is exactly that.
|
|
226
|
+
*/
|
|
227
|
+
// The server's administrative account for one organization. Derived, like the logger's, with its
|
|
228
|
+
// own label so the two can never collide.
|
|
229
|
+
export function deriveOrgAdminPassword(userSecret, org) {
|
|
230
|
+
if (!userSecret) throw new Error('No user_secret - run frugal-iot-init');
|
|
231
|
+
return createHmac('sha256', userSecret)
|
|
232
|
+
.update('orgadmin:').update(String(org))
|
|
233
|
+
.digest('base64url').slice(0, 22);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function groupsForLogger(org) {
|
|
237
|
+
return [names.readGroup(org, ORGWIDE), names.writeGroup(org, ORGWIDE)].sort();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Whether a node should be a gateway - declared by the firmware at enrolment, not by what it is
|
|
241
|
+
// doing at the time, because any node that sees WiFi can promote itself (see S7).
|
|
242
|
+
export function groupsForNode(org, project, { lora = false } = {}) {
|
|
243
|
+
const groups = [names.nodesGroup(org)];
|
|
244
|
+
if (lora) groups.push(names.gatewayGroup(org));
|
|
245
|
+
return groups.sort();
|
|
246
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The server's own use of dynamic security: keeping one user's broker account in step with the
|
|
3
|
+
* database, at the moments the database changes.
|
|
4
|
+
*
|
|
5
|
+
* Every function here is best-effort and never fatal. A broker with no plugin, or one that is
|
|
6
|
+
* briefly unreachable, must not stop somebody logging in or an administrator granting a permission -
|
|
7
|
+
* the dashboard is more useful with live data missing than not at all. Failures are logged with
|
|
8
|
+
* enough context to act on, and scripts/diagnostic.zsh reports the resulting drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { dynsecConnect } from './dynsec.js';
|
|
12
|
+
import { readUserRows, applyUser, applyLogger, applyNode, removeUser } from './dynsec-sync.js';
|
|
13
|
+
import { derivePassword, deriveLoggerPassword, names } from './dynsec-plan.js';
|
|
14
|
+
|
|
15
|
+
let unavailableReported = false;
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Has this server ever been set up to drive the plugin?
|
|
19
|
+
*
|
|
20
|
+
* The distinction matters when handing out a DERIVED credential. A derived password is correct
|
|
21
|
+
* whether or not the broker has been told about it *this second* - so when the plugin is merely
|
|
22
|
+
* unreachable for a moment, the account almost certainly exists from an earlier run and the
|
|
23
|
+
* credential works. But when this server has no admin credential at all, the account has never
|
|
24
|
+
* existed, and handing one out gives the holder something that cannot work: a logger authenticating
|
|
25
|
+
* as an account the broker has never heard of records nothing, and a browser shows an empty
|
|
26
|
+
* dashboard - both silently, and both worse than falling back to the shared credential that still
|
|
27
|
+
* works until S8 retires it.
|
|
28
|
+
*
|
|
29
|
+
* This is the state a server is in for the minutes between installing a release that expects the
|
|
30
|
+
* plugin and enabling the plugin, and that window is exactly when it must not break.
|
|
31
|
+
*/
|
|
32
|
+
function dynsecConfigured(config) {
|
|
33
|
+
const s = (config && config.secrets) || {};
|
|
34
|
+
return !!(s.dynsec_admin_user && s.dynsec_admin_password);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A connection per operation rather than one held open. These happen at login and at permission
|
|
38
|
+
// changes - rare, and never in a hot path - and a short-lived connection cannot go stale, which on
|
|
39
|
+
// a Pi that sleeps or loses its network it otherwise would.
|
|
40
|
+
function withDynsec(config, cb) {
|
|
41
|
+
dynsecConnect(config, (err, dynsec) => {
|
|
42
|
+
if (err) return cb(err);
|
|
43
|
+
cb(null, dynsec, () => dynsec.end(() => {}));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reportUnavailable(what, err) {
|
|
48
|
+
// Once per process for the ordinary "this broker has no plugin" case, so a server without one
|
|
49
|
+
// does not fill its journal - and on an SD card, its card. Everything else is worth a line.
|
|
50
|
+
const ordinary = /No dynsec_admin_user|did not answer|No answer from the broker/.test(err.message);
|
|
51
|
+
if (ordinary && unavailableReported) return;
|
|
52
|
+
if (ordinary) unavailableReported = true;
|
|
53
|
+
console.log(`Broker accounts not updated (${what}): ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Make the broker match the database for one user, and return the password their browser should
|
|
58
|
+
* use. Call this whenever what the database says about them changes: at login, when a permission is
|
|
59
|
+
* granted or revoked, and when they change their password (which changes the derived credential).
|
|
60
|
+
*
|
|
61
|
+
* cb(err, {username, password}) - err only for a caller that wants to know; callers here log and
|
|
62
|
+
* carry on.
|
|
63
|
+
*/
|
|
64
|
+
export function syncUser(db, config, user, cb) {
|
|
65
|
+
cb = cb || (() => {});
|
|
66
|
+
const userSecret = config.secrets && config.secrets.user_secret;
|
|
67
|
+
if (!userSecret) {
|
|
68
|
+
const err = new Error('No user_secret in config.d/secrets.yaml - run frugal-iot-init');
|
|
69
|
+
reportUnavailable(`user ${user.username}`, err);
|
|
70
|
+
return cb(err);
|
|
71
|
+
}
|
|
72
|
+
// Nothing to hand a browser on a server that has never driven the plugin: the account cannot
|
|
73
|
+
// exist, so the dashboard says live data is unavailable rather than failing to connect with a
|
|
74
|
+
// credential that was never real. See dynsecConfigured.
|
|
75
|
+
if (!dynsecConfigured(config)) {
|
|
76
|
+
reportUnavailable(`user ${user.username}`,
|
|
77
|
+
new Error('No dynsec_admin_user in config.d/secrets.yaml'));
|
|
78
|
+
return cb(null, null);
|
|
79
|
+
}
|
|
80
|
+
readUserRows(db, user.id, (err, rows) => {
|
|
81
|
+
if (err) return cb(err);
|
|
82
|
+
withDynsec(config, (cerr, dynsec, done) => {
|
|
83
|
+
if (cerr) {
|
|
84
|
+
reportUnavailable(`user ${user.username}`, cerr);
|
|
85
|
+
// Still hand back the credential: it is derived, so it is correct whether or not the broker
|
|
86
|
+
// has been told about it yet, and a later rebuild will make the broker agree.
|
|
87
|
+
return cb(null, {
|
|
88
|
+
username: `user/${user.username}`,
|
|
89
|
+
password: derivePassword(userSecret, user.username, user.hashed_password),
|
|
90
|
+
brokerUpdated: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
applyUser(dynsec, {
|
|
94
|
+
username: user.username, hashedPassword: user.hashed_password, rows, userSecret,
|
|
95
|
+
}, (aerr, res) => {
|
|
96
|
+
done();
|
|
97
|
+
if (aerr) {
|
|
98
|
+
console.error(`Could not update the broker account for ${user.username}: ${aerr.message}`);
|
|
99
|
+
return cb(aerr);
|
|
100
|
+
}
|
|
101
|
+
cb(null, { ...res, brokerUpdated: true });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Same, by id - for the permission routes, which have an id and no user object.
|
|
108
|
+
export function syncUserById(db, config, id, cb) {
|
|
109
|
+
cb = cb || (() => {});
|
|
110
|
+
db.get('SELECT id, username, hashed_password FROM users WHERE id = ?', [id], (err, user) => {
|
|
111
|
+
if (err) return cb(err);
|
|
112
|
+
// id 0 is "everyone" and is not an account anybody logs in as, so there is nothing to sync -
|
|
113
|
+
// but a change to its rows affects EVERY user, which only a rebuild can put right. Say so.
|
|
114
|
+
if (!user || !user.username) {
|
|
115
|
+
if (Number(id) === 0) {
|
|
116
|
+
console.log('A permission for "everyone" changed. Every user\'s broker account is affected:');
|
|
117
|
+
console.log(' run npx --no frugal-iot-rebuild-dynsec to apply it to all of them.');
|
|
118
|
+
}
|
|
119
|
+
return cb(null, null);
|
|
120
|
+
}
|
|
121
|
+
syncUser(db, config, user, cb);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* The logger's own account for every organization, and the credentials it should use.
|
|
127
|
+
*
|
|
128
|
+
* Called once at startup, BEFORE the logger connects - it cannot authenticate as an account that
|
|
129
|
+
* does not exist yet. Returns what to hand it either way: the password is derived, so it is correct
|
|
130
|
+
* whether or not the broker has been told, and a broker that is unreachable leaves the logger
|
|
131
|
+
* falling back to the organization's shared password, which still works until S8 retires it.
|
|
132
|
+
*/
|
|
133
|
+
export function syncLoggers(config, orgs, cb) {
|
|
134
|
+
cb = cb || (() => {});
|
|
135
|
+
const userSecret = config.secrets && config.secrets.user_secret;
|
|
136
|
+
const creds = {};
|
|
137
|
+
// No credentials at all means the logger falls back to the organization's shared password, which
|
|
138
|
+
// works until S8. That is the right answer whenever an account of its own could not exist.
|
|
139
|
+
if (!userSecret || !dynsecConfigured(config)) {
|
|
140
|
+
reportUnavailable('logger accounts',
|
|
141
|
+
new Error(userSecret ? 'No dynsec_admin_user in config.d/secrets.yaml'
|
|
142
|
+
: 'No user_secret in config.d/secrets.yaml'));
|
|
143
|
+
return cb(null, creds);
|
|
144
|
+
}
|
|
145
|
+
for (const org of orgs) {
|
|
146
|
+
creds[org] = { username: names.loggerClient(org), password: deriveLoggerPassword(userSecret, org) };
|
|
147
|
+
}
|
|
148
|
+
withDynsec(config, (cerr, dynsec, done) => {
|
|
149
|
+
// Configured but not answering: the accounts are almost certainly there from an earlier run,
|
|
150
|
+
// so the derived credentials are handed over and the logger reconnects when the broker returns.
|
|
151
|
+
if (cerr) { reportUnavailable('logger accounts', cerr); return cb(null, creds); }
|
|
152
|
+
let i = 0;
|
|
153
|
+
const next = () => {
|
|
154
|
+
if (i >= orgs.length) { done(); return cb(null, creds); }
|
|
155
|
+
const org = orgs[i++];
|
|
156
|
+
applyLogger(dynsec, { org, userSecret }, (err) => {
|
|
157
|
+
if (err) {
|
|
158
|
+
console.error(`Could not set up the logger account for ${org}: ${err.message}`);
|
|
159
|
+
// Same reasoning one organization down: do not hand over a credential this run failed to
|
|
160
|
+
// create, or that organization records nothing at all.
|
|
161
|
+
delete creds[org];
|
|
162
|
+
}
|
|
163
|
+
next();
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
next();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* One node's broker account, at enrolment. Unlike a user's, this is NOT best effort: a node stores
|
|
172
|
+
* whatever it is given and has no way to notice the broker was never told, so it would be left
|
|
173
|
+
* unable to connect and unable to re-enrol. enrol() refuses rather than issue such a credential,
|
|
174
|
+
* and the node retries.
|
|
175
|
+
*/
|
|
176
|
+
export function syncNode(config, node, cb) {
|
|
177
|
+
withDynsec(config, (cerr, dynsec, done) => {
|
|
178
|
+
if (cerr) { reportUnavailable(`node ${node.nodeid}`, cerr); return cb(cerr); }
|
|
179
|
+
applyNode(dynsec, node, (err) => { done(); cb(err); });
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Remove a node's broker account, for frugal-iot-resetnode. Best effort: the point of resetting is
|
|
184
|
+
// that the node will enrol again, which recreates it.
|
|
185
|
+
export function dropNode(config, org, project, nodeid, cb) {
|
|
186
|
+
cb = cb || (() => {});
|
|
187
|
+
withDynsec(config, (cerr, dynsec, done) => {
|
|
188
|
+
if (cerr) { reportUnavailable(`removing node ${nodeid}`, cerr); return cb(cerr); }
|
|
189
|
+
dynsec.raw([{ command: 'deleteClient', username: names.nodeClient(org, project, nodeid) }], (err) => {
|
|
190
|
+
done();
|
|
191
|
+
cb(err && !/not found/i.test(err.message) ? err : null);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// When an account goes away. Not wired up yet - nothing deletes users - but the rebuild tool cannot
|
|
197
|
+
// notice a user that is no longer in the database, so this is what the deletion path will need.
|
|
198
|
+
export function dropUser(config, username, cb) {
|
|
199
|
+
cb = cb || (() => {});
|
|
200
|
+
withDynsec(config, (cerr, dynsec, done) => {
|
|
201
|
+
if (cerr) { reportUnavailable(`removing ${username}`, cerr); return cb(cerr); }
|
|
202
|
+
removeUser(dynsec, username, (err) => { done(); cb(err); });
|
|
203
|
+
});
|
|
204
|
+
}
|