frugal-iot-server 0.3.8 → 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 +219 -61
- package/config.d/email.yaml +23 -0
- package/config.d/schema/devices.yaml +0 -2
- package/config.d/schema/modules.yaml +344 -2
- package/config.d/schema/topics.yaml +140 -46
- package/extras/aclfile +38 -0
- package/extras/mosquitto.conf +29 -0
- package/frugal-iot-createdb.sql +82 -1
- package/frugal-iot-server.js +692 -80
- 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/mailer.js +43 -0
- package/lib/replica.js +246 -0
- package/lib/resetcode.js +103 -0
- package/lib/retained.js +110 -0
- package/lib/secrets.js +148 -0
- package/package.json +12 -5
- package/public/index.html +8 -8
- package/public/service-worker.js +76 -27
- package/scripts/addbridge-pi.zsh +91 -7
- package/scripts/addbridge-prod.zsh +57 -3
- package/scripts/addorganization.zsh +5 -0
- package/scripts/clearretained.js +185 -0
- package/scripts/diagnostic.zsh +121 -2
- package/scripts/dynsec-init.js +142 -0
- package/scripts/init.zsh +64 -1
- 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/scripts/clearretained.zsh +0 -153
package/package.json
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
"cookie-parser": "^1.4.7",
|
|
13
13
|
"express": "^5.2.1",
|
|
14
14
|
"express-session": "^1.19.0",
|
|
15
|
-
"frugal-iot-client": "^
|
|
16
|
-
"frugal-iot-logger": "^
|
|
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",
|
|
20
|
+
"mqtt": "^5.15.2",
|
|
20
21
|
"multer": "^2.1.1",
|
|
22
|
+
"nodemailer": "^9.0.6",
|
|
21
23
|
"passport": "^0.7.0",
|
|
22
24
|
"passport-local": "^1.0.0",
|
|
23
25
|
"sqlite3": "^5.1.7"
|
|
@@ -28,9 +30,12 @@
|
|
|
28
30
|
"frugal-iot-addorganization": "scripts/addorganization.zsh",
|
|
29
31
|
"frugal-iot-setpassword": "scripts/setpassword.zsh",
|
|
30
32
|
"frugal-iot-diagnostic": "scripts/diagnostic.zsh",
|
|
31
|
-
"frugal-iot-clearretained": "scripts/clearretained.
|
|
33
|
+
"frugal-iot-clearretained": "scripts/clearretained.js",
|
|
32
34
|
"frugal-iot-addbridge-prod": "scripts/addbridge-prod.zsh",
|
|
33
|
-
"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"
|
|
34
39
|
},
|
|
35
40
|
"description": "Frugal IoT client and server",
|
|
36
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",
|
|
@@ -38,6 +43,7 @@
|
|
|
38
43
|
"frugal-iot-server.js",
|
|
39
44
|
"frugal-iot-createdb.sql",
|
|
40
45
|
"config.yaml",
|
|
46
|
+
"config.d/email.yaml",
|
|
41
47
|
"config.d/logger.yaml",
|
|
42
48
|
"config.d/mqtt.yaml",
|
|
43
49
|
"config.d/server.yaml",
|
|
@@ -47,6 +53,7 @@
|
|
|
47
53
|
"private/",
|
|
48
54
|
"scripts/",
|
|
49
55
|
"extras/mosquitto.conf",
|
|
56
|
+
"extras/aclfile",
|
|
50
57
|
"extras/mosquitto-bridge.conf.example",
|
|
51
58
|
"extras/frugaliot.service",
|
|
52
59
|
"API.md",
|
|
@@ -76,5 +83,5 @@
|
|
|
76
83
|
"check-files": "node scripts/check-published-files.js"
|
|
77
84
|
},
|
|
78
85
|
"type": "module",
|
|
79
|
-
"version": "0.
|
|
86
|
+
"version": "2.0.0"
|
|
80
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">
|
|
@@ -197,6 +189,14 @@
|
|
|
197
189
|
</div>
|
|
198
190
|
<script>
|
|
199
191
|
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
|
192
|
+
// The worker calls skipWaiting(), so a new one can take over a page whose scripts came
|
|
193
|
+
// from the old one. Reload once to get a consistent set - but not on a first install,
|
|
194
|
+
// where there was no previous controller and nothing is stale.
|
|
195
|
+
const hadController = !!navigator.serviceWorker.controller;
|
|
196
|
+
let reloading = false;
|
|
197
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
198
|
+
if (hadController && !reloading) { reloading = true; location.reload(); }
|
|
199
|
+
});
|
|
200
200
|
navigator.serviceWorker.register('/service-worker.js')
|
|
201
201
|
.then(reg => console.log('Service Worker registered'))
|
|
202
202
|
.catch(err => console.log('Service Worker registration failed', err));
|
package/public/service-worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
];
|
|
@@ -20,37 +70,36 @@ const urlsToCache = [
|
|
|
20
70
|
self.addEventListener('install', event => {
|
|
21
71
|
event.waitUntil(
|
|
22
72
|
caches.open(CACHE_NAME)
|
|
23
|
-
.then(cache =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.then(response => {
|
|
34
|
-
if (response) {
|
|
35
|
-
return response;
|
|
36
|
-
}
|
|
37
|
-
return fetch(event.request);
|
|
38
|
-
})
|
|
73
|
+
.then(cache => Promise.all(urlsToCache.map(url =>
|
|
74
|
+
// 'reload' bypasses the HTTP cache, which serves /node_modules immutable for a day -
|
|
75
|
+
// without it a same-day release caches the library it was meant to replace
|
|
76
|
+
fetch(new Request(url, {cache: 'reload'}))
|
|
77
|
+
.then(response => response.ok
|
|
78
|
+
? cache.put(url, response)
|
|
79
|
+
: Promise.reject(new Error(url + ' -> ' + response.status))))))
|
|
80
|
+
// Without this the new worker waits until every tab of the origin is closed at once.
|
|
81
|
+
// Reloading does not release the old one, so a release can stay invisible for days.
|
|
82
|
+
.then(() => self.skipWaiting())
|
|
39
83
|
);
|
|
40
84
|
});
|
|
41
85
|
|
|
42
86
|
// Delete old caches during activation
|
|
43
87
|
self.addEventListener('activate', event => {
|
|
44
|
-
const cacheWhitelist = [CACHE_NAME];
|
|
45
88
|
event.waitUntil(
|
|
46
|
-
caches.keys()
|
|
47
|
-
|
|
48
|
-
cacheNames.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
89
|
+
caches.keys()
|
|
90
|
+
.then(cacheNames => Promise.all(
|
|
91
|
+
cacheNames.filter(cacheName => cacheName !== CACHE_NAME)
|
|
92
|
+
.map(cacheName => caches.delete(cacheName))))
|
|
93
|
+
// Take over the pages that are already open, rather than only ones opened from now on
|
|
94
|
+
.then(() => self.clients.claim())
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
self.addEventListener('fetch', event => {
|
|
99
|
+
event.respondWith(
|
|
100
|
+
// Scoped to CACHE_NAME: a bare caches.match searches every cache in the origin, so a
|
|
101
|
+
// leftover older cache answers first and the version bump achieves nothing
|
|
102
|
+
caches.match(event.request, {cacheName: CACHE_NAME})
|
|
103
|
+
.then(response => response || fetch(event.request))
|
|
55
104
|
);
|
|
56
105
|
});
|
package/scripts/addbridge-pi.zsh
CHANGED
|
@@ -15,9 +15,14 @@
|
|
|
15
15
|
# Run from the server's own directory, the one holding frugal-iot.db.
|
|
16
16
|
#
|
|
17
17
|
# Usage:
|
|
18
|
-
# npx --no frugal-iot-addbridge-pi
|
|
18
|
+
# npx --no frugal-iot-addbridge-pi <org-id> <prod-host> <account>
|
|
19
19
|
# scripts/addbridge-pi.zsh --org myfarm --host prod.example.org --account bridge-northfield
|
|
20
20
|
#
|
|
21
|
+
# Both forms work. The positional one is the one to use with npx, because npm swallows any --flag
|
|
22
|
+
# it does not recognise ("Unknown cli config") and passes only the values through, so
|
|
23
|
+
# "npx ... --org myfarm" reaches this script as just "myfarm". Putting "--" before the flags also
|
|
24
|
+
# works, but the positional form is harder to get wrong.
|
|
25
|
+
#
|
|
21
26
|
# The password is asked for rather than passed on the command line, so it does not end up in your
|
|
22
27
|
# shell history. Give it with --password only for an unattended run.
|
|
23
28
|
|
|
@@ -26,12 +31,17 @@ set -euo pipefail
|
|
|
26
31
|
SCRIPT_NAME=$0
|
|
27
32
|
|
|
28
33
|
usage() {
|
|
29
|
-
echo "Usage: ${SCRIPT_NAME}
|
|
30
|
-
echo "
|
|
34
|
+
echo "Usage: ${SCRIPT_NAME} <org-id> <prod-host> <account> [password]" >&2
|
|
35
|
+
echo " or: ${SCRIPT_NAME} --org <org-id> --host <prod-host> --account <account> [--port N] [--password PW] [--replica-token T] [--replace]" >&2
|
|
36
|
+
echo "Example: ${SCRIPT_NAME} myfarm prod.example.org bridge-northfield" >&2
|
|
37
|
+
echo "" >&2
|
|
38
|
+
echo "Through npx, use the positional form: npm swallows unrecognised --flags and passes only" >&2
|
|
39
|
+
echo "their values on, so '--org myfarm' arrives here as just 'myfarm'." >&2
|
|
31
40
|
exit 1
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
ORG_ID=""; PROD_HOST=""; ACCOUNT=""; PASSWORD=""; PROD_PORT=8883; REPLACE=false
|
|
43
|
+
ORG_ID=""; PROD_HOST=""; ACCOUNT=""; PASSWORD=""; PROD_PORT=8883; REPLACE=false; REPLICA_TOKEN=""
|
|
44
|
+
POSITIONAL=()
|
|
35
45
|
|
|
36
46
|
while [[ $# -gt 0 ]]; do
|
|
37
47
|
case "$1" in
|
|
@@ -39,13 +49,33 @@ while [[ $# -gt 0 ]]; do
|
|
|
39
49
|
--host) PROD_HOST=${2:-}; shift 2 ;;
|
|
40
50
|
--account) ACCOUNT=${2:-}; shift 2 ;;
|
|
41
51
|
--password) PASSWORD=${2:-}; shift 2 ;;
|
|
52
|
+
--replica-token) REPLICA_TOKEN=${2:-}; shift 2 ;;
|
|
42
53
|
--port) PROD_PORT=${2:-}; shift 2 ;;
|
|
43
54
|
--replace) REPLACE=true; shift ;;
|
|
44
|
-
|
|
55
|
+
-*) echo "Error: unknown option '$1'" >&2; usage ;;
|
|
56
|
+
*) POSITIONAL+=("$1"); shift ;;
|
|
45
57
|
esac
|
|
46
58
|
done
|
|
47
59
|
|
|
48
|
-
|
|
60
|
+
# Fill anything not given as a flag from the positional arguments, in the order the usage line
|
|
61
|
+
# shows. This is what makes the npx form work: npm eats "--org" and hands us a bare "myfarm".
|
|
62
|
+
# Written as "if" rather than "[[ ... ]] && X=Y", whose false case is a non-zero last command and
|
|
63
|
+
# would end the script under "set -e".
|
|
64
|
+
# Fill whichever are still empty, in the order the usage line shows them, consuming the
|
|
65
|
+
# positionals in turn rather than by fixed position - so a half-and-half command line still lands
|
|
66
|
+
# correctly. Done inline, not through a function returning its value: a command substitution runs
|
|
67
|
+
# in a subshell, where the counter would advance and then be thrown away, giving every field the
|
|
68
|
+
# same first positional.
|
|
69
|
+
POS_N=1
|
|
70
|
+
for _field in ORG_ID PROD_HOST ACCOUNT PASSWORD; do
|
|
71
|
+
if [[ -z "${(P)_field}" ]] && (( POS_N <= ${#POSITIONAL} )); then
|
|
72
|
+
typeset -g "${_field}"="${POSITIONAL[POS_N]}"
|
|
73
|
+
POS_N=$((POS_N + 1))
|
|
74
|
+
fi
|
|
75
|
+
done
|
|
76
|
+
unset _field
|
|
77
|
+
|
|
78
|
+
if [[ -z "$ORG_ID" || -z "$PROD_HOST" || -z "$ACCOUNT" ]]; then usage; fi
|
|
49
79
|
|
|
50
80
|
BRIDGE_CONF=/etc/mosquitto/conf.d/frugal-iot-bridge.conf
|
|
51
81
|
|
|
@@ -60,7 +90,8 @@ fi
|
|
|
60
90
|
|
|
61
91
|
if [[ -e "$BRIDGE_CONF" && "$REPLACE" != true ]]; then
|
|
62
92
|
echo "Error: ${BRIDGE_CONF} already exists - this Pi is already bridged." >&2
|
|
63
|
-
echo "Add --replace to overwrite it, or edit it by hand." >&2
|
|
93
|
+
echo "Add --replace to overwrite it, or edit it by hand. Through npx that flag needs a '--'" >&2
|
|
94
|
+
echo "ahead of it - 'npx --no frugal-iot-addbridge-pi -- --org ... --replace' - or npm eats it." >&2
|
|
64
95
|
exit 1
|
|
65
96
|
fi
|
|
66
97
|
|
|
@@ -160,5 +191,58 @@ else
|
|
|
160
191
|
echo " sudo tail -20 /var/log/mosquitto/mosquitto.log"
|
|
161
192
|
echo "and the state is reported by: npx --no frugal-iot-diagnostic"
|
|
162
193
|
fi
|
|
194
|
+
# ---------------------------------------------------------------------------------------------
|
|
195
|
+
# Sharing production's logins (SECURITY.md S11).
|
|
196
|
+
#
|
|
197
|
+
# The bridge above relays topics, not accounts, so without this a person registered on production
|
|
198
|
+
# has no account on this Pi's broker - and this Pi is what goes on working when the link is down.
|
|
199
|
+
# With it, the server pulls their logins and permissions on a timer and issues its OWN broker
|
|
200
|
+
# credentials from its own user_secret; nothing derived from a password travels either way.
|
|
201
|
+
#
|
|
202
|
+
# Optional: a bridge is useful without it, so a missing token is not an error.
|
|
203
|
+
if [[ -z "$REPLICA_TOKEN" ]]; then
|
|
204
|
+
echo -n "Replica token from the production server (blank to skip): "
|
|
205
|
+
read -r REPLICA_TOKEN
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
if [[ -n "$REPLICA_TOKEN" ]]; then
|
|
209
|
+
# https, and deliberately not asked about: the token is a bearer credential, and over http it
|
|
210
|
+
# would be readable by anything between here and production.
|
|
211
|
+
cat > config.d/replica.yaml <<YAML
|
|
212
|
+
# Which production server this Pi takes its logins from, and how often (SECURITY.md S11).
|
|
213
|
+
# Written by frugal-iot-addbridge-pi. The token itself lives in config.d/secrets.yaml, which is
|
|
214
|
+
# never served to a browser - nothing secret may go in this file.
|
|
215
|
+
url: https://${PROD_HOST}
|
|
216
|
+
organizations:
|
|
217
|
+
- ${ORG_ID}
|
|
218
|
+
intervalSeconds: 900
|
|
219
|
+
YAML
|
|
220
|
+
# Appended, or the one line replaced: that file holds this server's own secrets too.
|
|
221
|
+
if grep -q '^replica_token:' config.d/secrets.yaml 2>/dev/null; then
|
|
222
|
+
python3 - "$REPLICA_TOKEN" <<'PYEOF'
|
|
223
|
+
import io, re, sys
|
|
224
|
+
p = 'config.d/secrets.yaml'
|
|
225
|
+
s = io.open(p, encoding='utf-8').read()
|
|
226
|
+
io.open(p, 'w', encoding='utf-8').write(
|
|
227
|
+
re.sub(r'^replica_token:.*$', 'replica_token: "%s"' % sys.argv[1], s, count=1, flags=re.M))
|
|
228
|
+
PYEOF
|
|
229
|
+
else
|
|
230
|
+
{
|
|
231
|
+
echo ""
|
|
232
|
+
echo "# Pulls this Pi's users from the production server named in config.d/replica.yaml."
|
|
233
|
+
echo "# Issued by frugal-iot-addbridge-prod; re-running that command revokes this one."
|
|
234
|
+
echo "replica_token: \"${REPLICA_TOKEN}\""
|
|
235
|
+
} >> config.d/secrets.yaml
|
|
236
|
+
fi
|
|
237
|
+
chmod 600 config.d/secrets.yaml
|
|
238
|
+
echo ""
|
|
239
|
+
echo "Wrote config.d/replica.yaml and stored the token in config.d/secrets.yaml."
|
|
240
|
+
echo "Restart the server to start pulling: sudo systemctl restart frugaliot"
|
|
241
|
+
else
|
|
242
|
+
echo ""
|
|
243
|
+
echo "No replica token given - the bridge relays readings, but people registered on production"
|
|
244
|
+
echo "will not be able to log in on this Pi. Re-run this script with the token to add it."
|
|
245
|
+
fi
|
|
246
|
+
|
|
163
247
|
echo ""
|
|
164
248
|
echo "The readings this Pi has already recorded stay here - only new ones are relayed."
|
|
@@ -196,10 +196,53 @@ sudo systemctl reload mosquitto || {
|
|
|
196
196
|
echo " Try: sudo systemctl restart mosquitto" >&2
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
# ---------------------------------------------------------------------------------------------
|
|
200
|
+
# A token for the Pi to pull this organization's logins with (SECURITY.md S11).
|
|
201
|
+
#
|
|
202
|
+
# The bridge relays topics, not accounts, so without this a person who logs in here has no account
|
|
203
|
+
# on the Pi's broker - and the Pi is the machine that goes on working when the link is down.
|
|
204
|
+
#
|
|
205
|
+
# It reads logins, hashes and permissions for THIS organization and nothing else. Nothing derived
|
|
206
|
+
# from a password travels: the Pi checks logins against the replicated hash and derives its own
|
|
207
|
+
# broker credentials from its own user_secret, so the two brokers issue different passwords for the
|
|
208
|
+
# same person.
|
|
209
|
+
#
|
|
210
|
+
# Done in node rather than sqlite3, because the sqlite3 command-line tool is not installed
|
|
211
|
+
# everywhere the server runs - the same reason scripts/clearretained.js talks MQTT through a library.
|
|
212
|
+
REPLICA_TOKEN=$(node -e '
|
|
213
|
+
const { randomBytes } = require("crypto");
|
|
214
|
+
const sqlite3 = require("sqlite3");
|
|
215
|
+
const [org, site] = process.argv.slice(1);
|
|
216
|
+
const token = randomBytes(32).toString("base64url");
|
|
217
|
+
const db = new sqlite3.Database("./frugal-iot.db", (err) => {
|
|
218
|
+
if (err) { console.error(err.message); process.exit(1); }
|
|
219
|
+
db.run(`CREATE TABLE IF NOT EXISTS bridges (org TEXT NOT NULL, site TEXT NOT NULL,
|
|
220
|
+
token TEXT NOT NULL, created_at INTEGER NOT NULL, last_pull INTEGER,
|
|
221
|
+
UNIQUE(org, site))`, () => {
|
|
222
|
+
db.run(`INSERT INTO bridges (org, site, token, created_at) VALUES (?, ?, ?, ?)
|
|
223
|
+
ON CONFLICT(org, site) DO UPDATE SET token = excluded.token, created_at = excluded.created_at`,
|
|
224
|
+
[org, site, token, Date.now()], (e) => {
|
|
225
|
+
if (e) { console.error(e.message); process.exit(1); }
|
|
226
|
+
process.stdout.write(token);
|
|
227
|
+
db.close();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
' "$ORG_ID" "$SITE") || true
|
|
232
|
+
|
|
233
|
+
if [[ -z "$REPLICA_TOKEN" ]]; then
|
|
234
|
+
echo ""
|
|
235
|
+
echo "Note: could not create a replica token, so the Pi will not be able to share this server's"
|
|
236
|
+
echo "logins. The broker bridge above is unaffected. Re-run this script to try again."
|
|
237
|
+
fi
|
|
238
|
+
|
|
199
239
|
echo ""
|
|
200
240
|
echo "Done. On the Pi at site '${SITE}', run:"
|
|
201
241
|
echo ""
|
|
202
|
-
echo " npx --no frugal-iot-addbridge-pi
|
|
242
|
+
echo " npx --no frugal-iot-addbridge-pi ${ORG_ID} <this server's public name> ${ACCOUNT}"
|
|
243
|
+
echo ""
|
|
244
|
+
echo "Positional arguments on purpose: npm swallows any --flag it does not recognise and passes"
|
|
245
|
+
echo "only the value on, so the --org form would arrive at that script as a bare word."
|
|
203
246
|
echo ""
|
|
204
247
|
echo "It will ask for this password:"
|
|
205
248
|
echo ""
|
|
@@ -211,5 +254,16 @@ if [[ "$GENERATED" == true ]]; then
|
|
|
211
254
|
echo " sudo -u \"\$(stat -c '%U' ${PWFILE})\" mosquitto_passwd -b ${PWFILE} ${ACCOUNT} '<new password>'"
|
|
212
255
|
echo "and use that on the Pi instead."
|
|
213
256
|
fi
|
|
214
|
-
|
|
215
|
-
echo "
|
|
257
|
+
if [[ -n "$REPLICA_TOKEN" ]]; then
|
|
258
|
+
echo "And this replica token, so people registered here can log in on the Pi as well:"
|
|
259
|
+
echo ""
|
|
260
|
+
echo " ${REPLICA_TOKEN}"
|
|
261
|
+
echo ""
|
|
262
|
+
echo "A new one replaces the old, so re-running this script revokes the Pi's previous token."
|
|
263
|
+
echo ""
|
|
264
|
+
fi
|
|
265
|
+
echo "Fill in the public name yourself - it is deliberately not guessed here, because what this"
|
|
266
|
+
echo "machine calls itself is usually not it. This host answers to \"$(hostname -f 2>/dev/null || hostname)\","
|
|
267
|
+
echo "which is only the right answer if that is also the DNS name the Pi can reach and the name on"
|
|
268
|
+
echo "the TLS certificate. The Pi verifies the certificate against whatever you put here, so a"
|
|
269
|
+
echo "local hostname, or a domain the certificate does not cover, fails to connect."
|
|
@@ -190,12 +190,17 @@ echo "Created user '${ORG_ID}' (id=${NEW_ID})"
|
|
|
190
190
|
# OR IGNORE because permissions has a UNIQUE(id, capability, org) constraint, so a repeat of any of
|
|
191
191
|
# these rows (e.g. on a database where id 1 is also the org's own account) would otherwise abort
|
|
192
192
|
# the whole statement part-way through, leaving the organization with only some of its permissions.
|
|
193
|
+
#
|
|
194
|
+
# WRITE is deliberately NOT given to id 0 "everyone": that row would hand write access to every account
|
|
195
|
+
# on the server, including a self-registered one, which is what can_WRITE exists to prevent.
|
|
193
196
|
sqlite3 "$DB" "
|
|
194
197
|
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'ADMIN', '${ORG_ID}');
|
|
195
198
|
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'READ', '${ORG_ID}');
|
|
199
|
+
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'WRITE', '${ORG_ID}');
|
|
196
200
|
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (0, 'READ', '${ORG_ID}');
|
|
197
201
|
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (1, 'ADMIN', '${ORG_ID}');
|
|
198
202
|
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (1, 'READ', '${ORG_ID}');
|
|
203
|
+
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (1, 'WRITE', '${ORG_ID}');
|
|
199
204
|
INSERT OR IGNORE INTO permissions (id, capability, org) VALUES (${NEW_ID}, 'OTAUPDATE', '${ORG_ID}');
|
|
200
205
|
"
|
|
201
206
|
echo "Added permissions for organization ${ORG_ID}"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* List, and optionally delete, retained MQTT messages.
|
|
4
|
+
*
|
|
5
|
+
* Nodes publish nearly everything retained, so the broker keeps the last value of every topic and
|
|
6
|
+
* hands it to each new subscriber - which is how a dashboard can show a reading straight away
|
|
7
|
+
* instead of waiting for the next one. The cost is that a topic published by mistake outlives the
|
|
8
|
+
* mistake: rename a module, misspell a field, test a node with the wrong id, and that topic stays
|
|
9
|
+
* on the broker for ever, appearing on every dashboard. Restarting the broker does not clear it and
|
|
10
|
+
* neither does fixing the node. The only way to remove one is to publish an empty message to that
|
|
11
|
+
* exact topic, which is what this does.
|
|
12
|
+
*
|
|
13
|
+
* Run from the server's own directory, the one holding frugal-iot.db, so that the organization's
|
|
14
|
+
* broker password can be read from config.d/organizations/.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* npx --no frugal-iot-clearretained '<topic-pattern>' # list what is retained
|
|
18
|
+
* npx --no frugal-iot-clearretained '<topic-pattern>' delete # delete it
|
|
19
|
+
*
|
|
20
|
+
* Quote the pattern, or the shell expands the wildcards before this ever sees them. In MQTT
|
|
21
|
+
* patterns "+" matches exactly one level and "#" matches the rest:
|
|
22
|
+
* 'myfarm/#' everything the organization holds - look before deleting
|
|
23
|
+
* 'myfarm/lotus/esp8266-fb94bb/#' one node
|
|
24
|
+
* 'myfarm/lotus/+/sht/temperture/#' a misspelled field, on every node in the project
|
|
25
|
+
*
|
|
26
|
+
* "delete" is a bare word rather than "--delete" because npm parses the whole command line of an
|
|
27
|
+
* "npx" invocation itself: it treats any --flag it does not recognise as an unknown config setting
|
|
28
|
+
* and passes only the values on, so "--delete" would silently never reach this script and it would
|
|
29
|
+
* quietly list instead of deleting. "--delete" is still accepted for running it directly.
|
|
30
|
+
*
|
|
31
|
+
* This talks to the broker through the "mqtt" library rather than mosquitto_sub and mosquitto_pub,
|
|
32
|
+
* so it works anywhere the server itself runs. On the production server those commands cannot be
|
|
33
|
+
* installed at all: its broker came from the mosquitto PPA built for an older Ubuntu, and the
|
|
34
|
+
* mosquitto-clients in the distribution demand an exactly-matching libmosquitto1, so apt would have
|
|
35
|
+
* to downgrade the library out from under the running broker.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync } from 'node:fs';
|
|
39
|
+
import yaml from 'js-yaml';
|
|
40
|
+
import mqtt from 'mqtt';
|
|
41
|
+
|
|
42
|
+
const SCRIPT = 'frugal-iot-clearretained';
|
|
43
|
+
|
|
44
|
+
function usage(message) {
|
|
45
|
+
if (message) console.error(`Error: ${message}`);
|
|
46
|
+
console.error(`Usage: ${SCRIPT} '<topic-pattern>' [delete] [--host H] [--port N] [--user U] [--password P] [--wait S]`);
|
|
47
|
+
console.error(`Example: ${SCRIPT} 'myfarm/lotus/+/sht/temperture/#'`);
|
|
48
|
+
console.error(` ${SCRIPT} 'myfarm/lotus/+/sht/temperture/#' delete`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- arguments ----
|
|
53
|
+
const opts = { host: 'localhost', port: 1883, user: null, password: null, wait: 3 };
|
|
54
|
+
let pattern = null;
|
|
55
|
+
let doDelete = false;
|
|
56
|
+
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
switch (a) {
|
|
61
|
+
case '--delete': doDelete = true; break;
|
|
62
|
+
case '--host': opts.host = argv[++i]; break;
|
|
63
|
+
case '--port': opts.port = Number(argv[++i]); break;
|
|
64
|
+
case '--user': opts.user = argv[++i]; break;
|
|
65
|
+
case '--password': opts.password = argv[++i]; break;
|
|
66
|
+
case '--wait': opts.wait = Number(argv[++i]); break;
|
|
67
|
+
default:
|
|
68
|
+
if (a.startsWith('-')) usage(`unknown option '${a}'`);
|
|
69
|
+
else if (pattern === null) pattern = a;
|
|
70
|
+
else if (a === 'delete') doDelete = true;
|
|
71
|
+
else usage(`unexpected argument '${a}'`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!pattern) usage('no topic pattern given');
|
|
75
|
+
|
|
76
|
+
// A bare "#" would be every topic on the broker, across every organization. Almost always a
|
|
77
|
+
// mistake, and the one case where a slip is unrecoverable, so it is not accepted at all.
|
|
78
|
+
if (pattern === '#' || pattern === '+/#') {
|
|
79
|
+
console.error(`Error: refusing the pattern '${pattern}' - that is every topic on the broker.`);
|
|
80
|
+
console.error("Name the organization at least, as in 'myfarm/#'.");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- credentials ----
|
|
85
|
+
// The broker requires them, and the organization's are in its config file - the same place the
|
|
86
|
+
// server reads them from. The first element of the topic pattern is the organization.
|
|
87
|
+
if (!opts.user) {
|
|
88
|
+
opts.user = pattern.split('/')[0];
|
|
89
|
+
const configFile = `config.d/organizations/${opts.user}.yaml`;
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = yaml.load(readFileSync(configFile, 'utf8'));
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(`Error: cannot read ${configFile} (${e.code || e.message}), so the broker password`);
|
|
95
|
+
console.error(`for '${opts.user}' cannot be found. Run this from the server's own directory, or`);
|
|
96
|
+
console.error('give credentials with --user and --password.');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
opts.password = parsed && parsed.mqtt_password;
|
|
100
|
+
if (!opts.password) {
|
|
101
|
+
console.error(`Error: no mqtt_password in ${configFile}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- collect what is retained ----
|
|
107
|
+
console.log(`Looking for retained messages matching '${pattern}' on ${opts.host}:${opts.port} as ${opts.user} ...`);
|
|
108
|
+
|
|
109
|
+
const client = mqtt.connect(`mqtt://${opts.host}:${opts.port}`, {
|
|
110
|
+
username: opts.user,
|
|
111
|
+
password: opts.password,
|
|
112
|
+
connectTimeout: 5000,
|
|
113
|
+
// A fresh session every time: we want the retained messages delivered on subscribe, not a
|
|
114
|
+
// continuation of some earlier one.
|
|
115
|
+
clean: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const topics = new Set();
|
|
119
|
+
|
|
120
|
+
client.on('error', (err) => {
|
|
121
|
+
// ECONNREFUSED arrives with an empty message, so fall back to the code - otherwise this prints
|
|
122
|
+
// "Error:" and nothing, which says neither "wrong password" nor "nothing listening there".
|
|
123
|
+
console.error(`Error: could not talk to the broker: ${err.message || err.code || err}`);
|
|
124
|
+
client.end(true);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
client.on('message', (topic, _payload, packet) => {
|
|
129
|
+
// Only messages the broker had already kept. A node reporting normally while we listen arrives
|
|
130
|
+
// with the retain flag clear, and clearing that would be removing something nobody asked us to.
|
|
131
|
+
if (packet.retain) topics.add(topic);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
client.on('connect', () => {
|
|
135
|
+
client.subscribe(pattern, { qos: 0 }, (err) => {
|
|
136
|
+
if (err) {
|
|
137
|
+
console.error(`Error: could not subscribe to '${pattern}': ${err.message}`);
|
|
138
|
+
client.end(true);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
// Retained messages arrive immediately on subscribing, so this only has to cover the round
|
|
142
|
+
// trip; a busy broker on a slow Pi may want more, hence --wait.
|
|
143
|
+
setTimeout(finish, opts.wait * 1000);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
async function finish() {
|
|
148
|
+
const list = [...topics].sort();
|
|
149
|
+
if (list.length === 0) {
|
|
150
|
+
console.log('Nothing retained matches that pattern.');
|
|
151
|
+
client.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const t of list) console.log(` ${t}`);
|
|
155
|
+
console.log(`${list.length} retained topic(s).`);
|
|
156
|
+
|
|
157
|
+
if (!doDelete) {
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(`Nothing has been changed. To delete these, run the same command again with "delete"`);
|
|
160
|
+
console.log('as the last word.');
|
|
161
|
+
client.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Deleting one means publishing a zero-length retained message to it, which is how MQTT spells
|
|
166
|
+
// "forget this". At QoS 1 so the broker acknowledges each rather than dropping it under load.
|
|
167
|
+
console.log(`Deleting ${list.length} retained topic(s) ...`);
|
|
168
|
+
let failed = 0;
|
|
169
|
+
for (const t of list) {
|
|
170
|
+
try {
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
client.publish(t, '', { retain: true, qos: 1 }, (err) => (err ? reject(err) : resolve()));
|
|
173
|
+
});
|
|
174
|
+
console.log(` cleared ${t}`);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error(` FAILED ${t}: ${e.message}`);
|
|
177
|
+
failed++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
console.log('');
|
|
181
|
+
console.log('Done. Run the same command without "delete" - it should report nothing.');
|
|
182
|
+
console.log('If a topic comes back, a node is still publishing it: fix or restart the node, then re-run.');
|
|
183
|
+
// A bridged broker forwards these deletions to the other end, so clearing here clears there too.
|
|
184
|
+
client.end(false, {}, () => process.exit(failed ? 1 : 0));
|
|
185
|
+
}
|