frugal-iot-server 0.3.9 → 2.0.1
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 +237 -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/check-schema.js +24 -4
- package/scripts/copy-schema-to-examples.zsh +18 -3
- 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/prerelease.zsh +84 -33
- package/scripts/rebuild-dynsec.js +190 -0
- package/scripts/resetnode.js +69 -0
package/scripts/install-pi.sh
CHANGED
|
@@ -468,14 +468,93 @@ ok "mosquitto $(mosquitto -h 2>&1 | head -1 | awk '{print $3}' || true)"
|
|
|
468
468
|
step "Configuring the broker"
|
|
469
469
|
# Two listeners: 1883 for the sensor nodes, 9012 websockets for the browser. Shipped with the
|
|
470
470
|
# server, so it is copied rather than written here.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
471
|
+
# Read the broker configuration from the INSTALLED PACKAGE, not from ${INSTALL_DIR}/extras.
|
|
472
|
+
#
|
|
473
|
+
# frugal-iot-init copies extras/ into the install directory but never overwrites what is already
|
|
474
|
+
# there - it prints "kept ... (DIFFERS from this release)" and leaves it. That is right for a file
|
|
475
|
+
# somebody may have edited, and wrong for this one: it means a security-relevant change to the
|
|
476
|
+
# shipped broker configuration does not reach /etc on an upgrade, however many times this script is
|
|
477
|
+
# re-run. That is how a broker with no acl_file survived several releases.
|
|
478
|
+
#
|
|
479
|
+
# The package's own copy is authoritative, so an upgrade followed by a re-run of this script applies
|
|
480
|
+
# the current configuration. ${INSTALL_DIR}/extras stays as the fallback for an installation whose
|
|
481
|
+
# layout predates this.
|
|
482
|
+
PKG_DIR="${INSTALL_DIR}/node_modules/frugal-iot-server"
|
|
483
|
+
SRC_CONF="${PKG_DIR}/extras/mosquitto.conf"
|
|
484
|
+
[[ -f "$SRC_CONF" ]] || SRC_CONF="${INSTALL_DIR}/extras/mosquitto.conf"
|
|
485
|
+
[[ -f "$SRC_CONF" ]] || { echo "Expected extras/mosquitto.conf in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1; }
|
|
486
|
+
|
|
487
|
+
# The ACL file goes in FIRST, because the configuration copied below names it and mosquitto will not
|
|
488
|
+
# start if it is missing. Owned by mosquitto, mode 600: the broker reads it after dropping
|
|
489
|
+
# privileges, and 2.0.21 warns that a file it does not own, or one that is world readable, will be
|
|
490
|
+
# refused by a future version. Same rule as the password file below.
|
|
491
|
+
SRC_ACL="${PKG_DIR}/extras/aclfile"
|
|
492
|
+
[[ -f "$SRC_ACL" ]] || SRC_ACL="${INSTALL_DIR}/extras/aclfile"
|
|
493
|
+
if [[ -f "$SRC_ACL" ]]; then
|
|
494
|
+
if cmp -s "$SRC_ACL" /etc/mosquitto/aclfile 2>/dev/null; then
|
|
495
|
+
skip "/etc/mosquitto/aclfile already current"
|
|
496
|
+
else
|
|
497
|
+
sudo_ install -o mosquitto -g mosquitto -m 600 "$SRC_ACL" /etc/mosquitto/aclfile
|
|
498
|
+
ok "access control list installed at /etc/mosquitto/aclfile"
|
|
499
|
+
fi
|
|
500
|
+
else
|
|
501
|
+
echo "Expected extras/aclfile in ${PKG_DIR} or ${INSTALL_DIR}" >&2; exit 1
|
|
502
|
+
fi
|
|
503
|
+
|
|
504
|
+
# The dynamic security plugin. Its state file has to exist before the configuration naming it is
|
|
505
|
+
# installed, or the broker will not start - same ordering as the ACL above.
|
|
506
|
+
#
|
|
507
|
+
# The plugin path varies by distribution, so it is found rather than assumed: Debian and Raspberry Pi
|
|
508
|
+
# OS ship it as /usr/lib/<arch>/mosquitto_dynamic_security.so, others use a mosquitto/ subdirectory.
|
|
509
|
+
DYNSEC_SO=""
|
|
510
|
+
for cand in /usr/lib/*/mosquitto_dynamic_security.so /usr/lib/mosquitto_dynamic_security.so /usr/lib/*/mosquitto/mosquitto_dynamic_security.so /usr/lib/mosquitto/mosquitto_dynamic_security.so; do
|
|
511
|
+
[[ -f "$cand" ]] && { DYNSEC_SO="$cand"; break; }
|
|
512
|
+
done
|
|
513
|
+
DYNSEC_JSON=/var/lib/mosquitto/dynamic-security.json
|
|
514
|
+
if [[ -z "$DYNSEC_SO" ]]; then
|
|
515
|
+
warn "mosquitto's dynamic security plugin was not found - per-user broker accounts will not work."
|
|
516
|
+
warn " Looked in /usr/lib. The broker will still run on the organization passwords."
|
|
517
|
+
elif [[ -f "$DYNSEC_JSON" ]]; then
|
|
518
|
+
skip "dynamic security already initialised at ${DYNSEC_JSON}"
|
|
519
|
+
else
|
|
520
|
+
# dynsec init asks for the admin password twice on stdin. The password is generated here and kept
|
|
521
|
+
# in the server's own config.d/secrets.yaml, which is never served to a browser.
|
|
522
|
+
DYNSEC_PW="$(randpw)"
|
|
523
|
+
if printf '%s\n%s\n' "$DYNSEC_PW" "$DYNSEC_PW" | sudo_ mosquitto_ctrl dynsec init "$DYNSEC_JSON" frugal-admin >/dev/null 2>&1 && [[ -s "$DYNSEC_JSON" ]]; then
|
|
524
|
+
# Written after dropping privileges, so owned by mosquitto like the password and ACL files
|
|
525
|
+
sudo_ chown mosquitto:mosquitto "$DYNSEC_JSON"
|
|
526
|
+
sudo_ chmod 600 "$DYNSEC_JSON"
|
|
527
|
+
{
|
|
528
|
+
echo ""
|
|
529
|
+
echo "# The broker account the server uses to create and remove other accounts, over"
|
|
530
|
+
echo "# \$CONTROL/dynamic-security/v1. Created by install-pi.sh."
|
|
531
|
+
echo "dynsec_admin_user: \"frugal-admin\""
|
|
532
|
+
echo "dynsec_admin_password: \"${DYNSEC_PW}\""
|
|
533
|
+
} >> "${INSTALL_DIR}/config.d/secrets.yaml"
|
|
534
|
+
chmod 600 "${INSTALL_DIR}/config.d/secrets.yaml"
|
|
535
|
+
ok "dynamic security initialised; admin credential saved to config.d/secrets.yaml"
|
|
536
|
+
else
|
|
537
|
+
warn "mosquitto_ctrl dynsec init failed - continuing without it"
|
|
538
|
+
sudo_ rm -f "$DYNSEC_JSON"
|
|
539
|
+
DYNSEC_SO=""
|
|
540
|
+
fi
|
|
541
|
+
fi
|
|
542
|
+
|
|
543
|
+
# The shipped configuration carries a placeholder for the plugin path, because it differs per
|
|
544
|
+
# distribution. Substitute it here - or comment the plugin out entirely if there is no plugin.
|
|
545
|
+
TMP_CONF="$(mktemp)"
|
|
546
|
+
if [[ -n "$DYNSEC_SO" ]]; then
|
|
547
|
+
sed "s|^plugin PLUGIN_PATH_SET_BY_INSTALLER|plugin ${DYNSEC_SO}|" "$SRC_CONF" > "$TMP_CONF"
|
|
548
|
+
else
|
|
549
|
+
sed -e "s|^plugin PLUGIN_PATH_SET_BY_INSTALLER|#plugin (not installed)|" -e "s|^plugin_opt_config_file|#plugin_opt_config_file|" "$SRC_CONF" > "$TMP_CONF"
|
|
550
|
+
fi
|
|
551
|
+
if cmp -s "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
|
|
474
552
|
skip "/etc/mosquitto/conf.d/frugal-iot.conf already current"
|
|
475
553
|
else
|
|
476
|
-
sudo_ cp "$
|
|
554
|
+
sudo_ cp "$TMP_CONF" /etc/mosquitto/conf.d/frugal-iot.conf
|
|
477
555
|
ok "configuration copied to /etc/mosquitto/conf.d/frugal-iot.conf"
|
|
478
556
|
fi
|
|
557
|
+
rm -f "$TMP_CONF"
|
|
479
558
|
# The broker refuses to start if the password file it is told about does not exist. It has to belong
|
|
480
559
|
# to the mosquitto user, because both the broker and mosquitto_passwd warn unless the file belongs to
|
|
481
560
|
# whoever opened it - and mosquitto_passwd writes a temporary file beside it, so the directory has to
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- Add permissions.project to a database created before it existed.
|
|
2
|
+
--
|
|
3
|
+
-- Run only when the column is absent - scripts/init.zsh checks. Rebuilding the table rather than
|
|
4
|
+
-- ALTER TABLE ADD COLUMN because the UNIQUE constraint has to change too, and SQLite cannot alter a
|
|
5
|
+
-- constraint. Wrapped in a transaction so a failure leaves the old table in place.
|
|
6
|
+
--
|
|
7
|
+
-- Existing rows get project = '', meaning organization-wide, so every permission keeps behaving
|
|
8
|
+
-- exactly as it did and nothing needs re-granting.
|
|
9
|
+
BEGIN;
|
|
10
|
+
CREATE TABLE `permissions_new` (
|
|
11
|
+
`id` INTEGER NOT NULL,
|
|
12
|
+
`capability` TEXT NOT NULL,
|
|
13
|
+
`org` TEXT NOT NULL,
|
|
14
|
+
`project` TEXT NOT NULL DEFAULT '',
|
|
15
|
+
UNIQUE(`id`, `capability`, `org`, `project`)
|
|
16
|
+
);
|
|
17
|
+
INSERT INTO `permissions_new` (`id`, `capability`, `org`, `project`)
|
|
18
|
+
SELECT `id`, `capability`, `org`, '' FROM `permissions`;
|
|
19
|
+
DROP TABLE `permissions`;
|
|
20
|
+
ALTER TABLE `permissions_new` RENAME TO `permissions`;
|
|
21
|
+
COMMIT;
|
|
@@ -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
|
package/scripts/prerelease.zsh
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
# afterwards. Run it, read what it says, then publish:
|
|
5
5
|
#
|
|
6
6
|
# npm run prerelease
|
|
7
|
+
# zsh scripts/prerelease.zsh --release-version 2.1.0
|
|
8
|
+
# zsh scripts/prerelease.zsh -q
|
|
9
|
+
#
|
|
10
|
+
# -q (--quiet) prints only findings and changes, so a clean run prints nothing at all. That is what
|
|
11
|
+
# the release script uses; run it without -q to see each check confirm itself.
|
|
12
|
+
#
|
|
13
|
+
# --release-version says which version this release is about to publish everywhere, and is what the
|
|
14
|
+
# release script in frugal-iot-demo passes. Without it, "requires a version nobody has published"
|
|
15
|
+
# is a blocker, because that is what it means on an ordinary day. During a release it is the normal
|
|
16
|
+
# state of affairs - the logger and client are about to be published at that version and have not
|
|
17
|
+
# been yet - so with it, a range that only the version being released satisfies is reported and
|
|
18
|
+
# allowed, and CACHE_NAME is set to that version rather than to whatever is on npm today.
|
|
7
19
|
#
|
|
8
20
|
# It checks the sensor schema and copies it into the examples that ship with frugal-iot-logger, sets
|
|
9
21
|
# the service worker's cache version, and looks for the package being wired to a checkout on this
|
|
@@ -21,27 +33,52 @@
|
|
|
21
33
|
|
|
22
34
|
set -euo pipefail
|
|
23
35
|
|
|
36
|
+
RELEASE_VERSION=""
|
|
37
|
+
QUIET=0
|
|
38
|
+
while (( $# )); do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--release-version) RELEASE_VERSION="${2:-}"; shift 2 || true ;;
|
|
41
|
+
--release-version=*) RELEASE_VERSION="${1#*=}"; shift ;;
|
|
42
|
+
-q|--quiet) QUIET=1; shift ;;
|
|
43
|
+
-h|--help) sed -n '2,34p' "$0" | sed 's/^# \?//'; exit 0 ;;
|
|
44
|
+
*) print -u2 "Unknown option: $1 (try --release-version <version>, -q, or -h)"; exit 2 ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
|
|
48
|
+
# A check confirming itself, as opposed to a finding or a change: silent under -q
|
|
49
|
+
say() { (( QUIET )) || print -r -- "$@" }
|
|
50
|
+
|
|
51
|
+
# Passed on to the scripts that take the same flag
|
|
52
|
+
QARG=()
|
|
53
|
+
(( QUIET )) && QARG=(-q)
|
|
54
|
+
|
|
24
55
|
HERE="${0:A:h:h}" # The frugal-iot-server checkout this script is in
|
|
25
56
|
SCHEMA="${HERE}/config.d/schema"
|
|
26
57
|
|
|
58
|
+
# Does the version being released satisfy this range? Only asked when --release-version was given.
|
|
59
|
+
satisfied_by_release() {
|
|
60
|
+
[[ -n "$RELEASE_VERSION" ]] || return 1
|
|
61
|
+
node -e "process.exit(require('semver').satisfies('${RELEASE_VERSION}', process.argv[1]) ? 0 : 1)" "$1" 2>/dev/null
|
|
62
|
+
}
|
|
63
|
+
|
|
27
64
|
cd "$HERE"
|
|
28
65
|
|
|
29
66
|
BLOCKERS=() # never right in a published package - this script exits non-zero for these
|
|
30
67
|
WARNINGS=() # probably worth doing something about, but yours to judge
|
|
31
68
|
|
|
32
|
-
|
|
33
|
-
node scripts/check-schema.js config.d/schema || true
|
|
69
|
+
say "=== Checking ${SCHEMA} ==="
|
|
70
|
+
node scripts/check-schema.js "${QARG[@]}" config.d/schema || true
|
|
34
71
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
zsh scripts/copy-schema-to-examples.zsh
|
|
72
|
+
say
|
|
73
|
+
say "=== Copying the schema into the frugal-iot-logger examples ==="
|
|
74
|
+
zsh scripts/copy-schema-to-examples.zsh "${QARG[@]}"
|
|
38
75
|
|
|
39
76
|
# ---- Is this package wired to a checkout on this machine? ----
|
|
40
77
|
# Developing the server, logger and client together means pointing the server at sibling checkouts,
|
|
41
78
|
# and every way of doing that is invisible once you have got used to it. Published, each one either
|
|
42
79
|
# breaks every install outright or ships code nobody else has.
|
|
43
|
-
|
|
44
|
-
|
|
80
|
+
say
|
|
81
|
+
say "=== Checking nothing is wired to a local checkout ==="
|
|
45
82
|
|
|
46
83
|
# 1. "file:" or "link:" dependencies - these would make "npm install frugal-iot-server" fail for
|
|
47
84
|
# everybody, because the path they name does not exist on anyone else's machine
|
|
@@ -59,7 +96,7 @@ if [[ -n "$LOCALDEPS" ]]; then
|
|
|
59
96
|
print -r -- "$LOCALDEPS" | sed 's/^/ /'
|
|
60
97
|
BLOCKERS+=("package.json names a dependency by path - publishing that breaks every install. Put the version range back.")
|
|
61
98
|
else
|
|
62
|
-
|
|
99
|
+
say " package.json names no dependencies by path"
|
|
63
100
|
fi
|
|
64
101
|
|
|
65
102
|
# 2. npm link - node_modules/<pkg> being a symlink means what you have been testing is your
|
|
@@ -74,8 +111,9 @@ if (( ${#LINKED} )); then
|
|
|
74
111
|
for l in $LINKED; do echo " linked: $l"; done
|
|
75
112
|
echo " What you have been running is that checkout, not the published package."
|
|
76
113
|
echo " Publish the linked package first, then require the new version here."
|
|
114
|
+
WARNINGS+=("node_modules links to a checkout (${(j:, :)LINKED}) - what the tests exercised is that checkout, not the published package.")
|
|
77
115
|
else
|
|
78
|
-
|
|
116
|
+
say " node_modules holds published packages, not links to checkouts"
|
|
79
117
|
fi
|
|
80
118
|
|
|
81
119
|
# 3. The development alternatives inside the source, which are switched by editing rather than by
|
|
@@ -83,12 +121,12 @@ fi
|
|
|
83
121
|
if grep -qE '^\s*import .*from\s+"\.\./frugal-iot-logger' frugal-iot-server.js 2>/dev/null; then
|
|
84
122
|
BLOCKERS+=("frugal-iot-server.js is importing the logger from ../frugal-iot-logger - comment that line out and uncomment the \"frugal-iot-logger\" one.")
|
|
85
123
|
else
|
|
86
|
-
|
|
124
|
+
say " frugal-iot-server.js imports the logger as a package, not by path"
|
|
87
125
|
fi
|
|
88
126
|
if grep -qE '^\s*(htmldir|nodemodulesdir):\s*\.\./' config.d/server.yaml 2>/dev/null; then
|
|
89
127
|
BLOCKERS+=("config.d/server.yaml points htmldir or nodemodulesdir at ../frugal-iot-client - that is the development setting and ships to everyone who installs.")
|
|
90
128
|
else
|
|
91
|
-
|
|
129
|
+
say " config.d/server.yaml points at ./node_modules, not at a sibling checkout"
|
|
92
130
|
fi
|
|
93
131
|
|
|
94
132
|
# ---- Are the versions this requires actually available? ----
|
|
@@ -96,8 +134,8 @@ fi
|
|
|
96
134
|
# on a Pi as well as here, and nothing in the repo shows it. Asking for one that is published but
|
|
97
135
|
# not the newest is a lesser problem - a fresh install resolves to the newest anyway, but an
|
|
98
136
|
# existing one is free to stay where it is, so your changes reach nobody who already has it.
|
|
99
|
-
|
|
100
|
-
|
|
137
|
+
say
|
|
138
|
+
say "=== Checking the versions this requires have been published ==="
|
|
101
139
|
|
|
102
140
|
# Highest published version of $1 satisfying range $2, or empty if none - and it prints nothing and
|
|
103
141
|
# exits 1 when nothing matches, which is an answer rather than a failure, hence the "|| true"
|
|
@@ -110,15 +148,19 @@ for p in frugal-iot-logger frugal-iot-client; do
|
|
|
110
148
|
RANGE=$(node -e "console.log(require('./package.json').dependencies['$p'] || '')")
|
|
111
149
|
if [[ -z "$RANGE" ]]; then continue; fi
|
|
112
150
|
if ! LATEST=$(npm view "$p" version 2>/dev/null); then
|
|
113
|
-
|
|
151
|
+
say " $p: could not ask npm (offline?) - requires ${RANGE}, check it yourself"
|
|
114
152
|
continue
|
|
115
153
|
fi
|
|
116
154
|
MATCH=$(published_match "$p" "$RANGE")
|
|
117
|
-
if [[ -z "$MATCH" ]]; then
|
|
118
|
-
|
|
155
|
+
if [[ -z "$MATCH" ]] && satisfied_by_release "$RANGE"; then
|
|
156
|
+
# Mid-release: this range was raised to the version being published, and $p has not been
|
|
157
|
+
# published at it yet. Expected, and the publishing step is about to deal with it.
|
|
158
|
+
say " $p: requires ${RANGE}, awaiting the ${RELEASE_VERSION} publish (newest so far is ${LATEST})"
|
|
159
|
+
elif [[ -z "$MATCH" ]]; then
|
|
160
|
+
say " $p: requires ${RANGE}, but the newest published is ${LATEST}"
|
|
119
161
|
BLOCKERS+=("Nothing published satisfies ${p}@${RANGE} (newest is ${LATEST}) - publish ${p} first, or every \"npm install frugal-iot-server\" fails.")
|
|
120
162
|
elif [[ "$MATCH" != "$LATEST" ]]; then
|
|
121
|
-
|
|
163
|
+
say " $p: requires ${RANGE} -> ${MATCH}, but ${LATEST} is published"
|
|
122
164
|
WARNINGS+=("${p}@${RANGE} resolves to ${MATCH}, not the newest published ${LATEST} - bump the range if you meant installs to pick up ${LATEST}.")
|
|
123
165
|
else
|
|
124
166
|
# The range allows the newest, but if its floor is older then an existing install can sit on
|
|
@@ -126,10 +168,10 @@ for p in frugal-iot-logger frugal-iot-client; do
|
|
|
126
168
|
FLOOR=${RANGE#[\^~>=]}
|
|
127
169
|
FLOOR=${FLOOR#=}
|
|
128
170
|
if [[ -n "$FLOOR" && "$FLOOR" != "$LATEST" ]]; then
|
|
129
|
-
|
|
171
|
+
say " $p: requires ${RANGE}, newest published ${LATEST} (an existing install may stay on ${FLOOR})"
|
|
130
172
|
WARNINGS+=("${p} requires ${RANGE}, so an install that already has ${FLOOR} is not obliged to move to ${LATEST}. Raise the range to ^${LATEST} if this release needs it.")
|
|
131
173
|
else
|
|
132
|
-
|
|
174
|
+
say " $p: requires ${RANGE}, newest published ${LATEST} - in step"
|
|
133
175
|
fi
|
|
134
176
|
fi
|
|
135
177
|
done
|
|
@@ -139,27 +181,33 @@ done
|
|
|
139
181
|
# installed PWA fetch it again. Left behind, people keep running the client they already had, which
|
|
140
182
|
# looks exactly like a release that did nothing. It tracks the client version, so set it from
|
|
141
183
|
# whichever published client this release will actually resolve to.
|
|
142
|
-
|
|
143
|
-
|
|
184
|
+
say
|
|
185
|
+
say "=== Checking the service worker's cache version ==="
|
|
144
186
|
SW=public/service-worker.js
|
|
145
187
|
CLIENT_RANGE=$(node -e "console.log(require('./package.json').dependencies['frugal-iot-client'] || '')")
|
|
146
188
|
if [[ ! -f "$SW" ]]; then
|
|
147
|
-
|
|
189
|
+
say " no ${SW} here"
|
|
148
190
|
elif [[ -z "$CLIENT_RANGE" ]]; then
|
|
149
|
-
|
|
191
|
+
say " package.json has no frugal-iot-client dependency to match against"
|
|
150
192
|
else
|
|
151
193
|
SW_VERSION=$(sed -n "s/^const CACHE_NAME = 'frugal-iot-cache-\([^']*\)'.*/\1/p" "$SW" | head -1)
|
|
152
194
|
CLIENT_LATEST=$(npm view frugal-iot-client version 2>/dev/null || true)
|
|
153
195
|
CLIENT_WANTED=$(published_match frugal-iot-client "$CLIENT_RANGE")
|
|
196
|
+
# Mid-release the client this ships with is the one about to be published, not the newest on npm
|
|
197
|
+
# today, so follow that instead - otherwise CACHE_NAME would be set back to last month's version.
|
|
198
|
+
if satisfied_by_release "$CLIENT_RANGE"; then
|
|
199
|
+
CLIENT_WANTED="$RELEASE_VERSION"
|
|
200
|
+
CLIENT_LATEST="$RELEASE_VERSION"
|
|
201
|
+
fi
|
|
154
202
|
if [[ -z "$SW_VERSION" ]]; then
|
|
155
|
-
|
|
203
|
+
say " could not find a CACHE_NAME line of the form frugal-iot-cache-<version> in ${SW}"
|
|
156
204
|
WARNINGS+=("Could not read CACHE_NAME from ${SW} - check by hand that it changes when the client does.")
|
|
157
205
|
elif [[ -z "$CLIENT_WANTED" ]]; then
|
|
158
|
-
|
|
206
|
+
say " CACHE_NAME says ${SW_VERSION}, and nothing published satisfies ${CLIENT_RANGE}"
|
|
159
207
|
BLOCKERS+=("Cannot set CACHE_NAME: no published frugal-iot-client satisfies ${CLIENT_RANGE}.")
|
|
160
208
|
else
|
|
161
209
|
if [[ "$SW_VERSION" == "$CLIENT_WANTED" ]]; then
|
|
162
|
-
|
|
210
|
+
say " CACHE_NAME is ${SW_VERSION}, matching the client this release installs"
|
|
163
211
|
else
|
|
164
212
|
# An in-place edit rather than a warning: there is only one right answer, and a stale cache
|
|
165
213
|
# name is invisible until somebody's browser keeps serving them last month's dashboard
|
|
@@ -174,16 +222,19 @@ else
|
|
|
174
222
|
fi
|
|
175
223
|
|
|
176
224
|
# ---- What would and would not be published ----
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if ! node scripts/check-published-files.js; then
|
|
225
|
+
say
|
|
226
|
+
say "=== Checking what the package would contain ==="
|
|
227
|
+
if ! FILESOUT=$(node scripts/check-published-files.js 2>&1); then
|
|
228
|
+
print -r -- "$FILESOUT"
|
|
180
229
|
BLOCKERS+=("Something git ignores would be published - see above.")
|
|
230
|
+
else
|
|
231
|
+
say "$FILESOUT"
|
|
181
232
|
fi
|
|
182
233
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
234
|
+
say
|
|
235
|
+
say "=== Reminders ==="
|
|
236
|
+
say " - Is the version in package.json the one you mean to publish? $(node -e 'console.log(require("./package.json").version)')"
|
|
237
|
+
say " - Nothing here is committed or published - do that yourself."
|
|
187
238
|
|
|
188
239
|
if (( ${#WARNINGS} )); then
|
|
189
240
|
echo
|
|
@@ -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
|
+
});
|