smart-home-engine 1.21.0 → 1.34.3
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/README.md +6 -3
- package/dist/web/assets/index-BtBtSJng.js +271 -0
- package/dist/web/assets/index-CXLA5efM.css +1 -0
- package/dist/web/assets/{jsonMode-tUihOf0o.js → jsonMode-BRIqcXAV.js} +1 -1
- package/dist/web/assets/{monaco-langs-Dt8a9eeo.js → monaco-langs-DKJI_o9J.js} +1 -1
- package/dist/web/assets/{tsMode-BsFm8afo.js → tsMode-CFhaNPy5.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/service/install.sh +10 -1
- package/service/she-servicectl +638 -0
- package/service/smart-home-engine.service +1 -1
- package/src/index.js +46 -12
- package/src/lib/npm-registry.js +80 -0
- package/src/lib/secrets.js +320 -0
- package/src/lib/services-catalog.js +144 -0
- package/src/lib/services-host.js +275 -0
- package/src/lib/services-inventory.js +180 -0
- package/src/lib/ssh-deploy.js +13 -8
- package/src/web/scripts-api.js +1 -1
- package/src/web/secrets-api.js +61 -0
- package/src/web/server.js +12 -1
- package/src/web/services-api.js +1555 -0
- package/dist/web/assets/index-AcVonavW.js +0 -235
- package/dist/web/assets/index-BAau5K4k.css +0 -1
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# she-servicectl — the one privileged helper she uses to manage xyz2mqtt adapter
|
|
3
|
+
# instances on a host (roadmap I4, decision SV-4). Installed to /usr/local/bin and
|
|
4
|
+
# allowed for the she user (or the SSH user on remote hosts) via sudoers:
|
|
5
|
+
#
|
|
6
|
+
# she ALL=(root) NOPASSWD: /usr/local/bin/she-servicectl
|
|
7
|
+
#
|
|
8
|
+
# Everything she can do on this host is what this script allows. Every argument is
|
|
9
|
+
# validated against a fixed pattern before anything is executed; free-form data
|
|
10
|
+
# (env files, install options) travels on stdin, never on the command line.
|
|
11
|
+
#
|
|
12
|
+
# Adapters are npm packages built on mqtt-interfaces-core: a template unit
|
|
13
|
+
# /etc/systemd/system/<adapter>@.service (fingerprint: an EnvironmentFile line for
|
|
14
|
+
# /etc/<adapter>/%i.env — units written by older cores lack the shared broker.env
|
|
15
|
+
# line, "list" reports that per adapter), instances = /etc/<adapter>/<instance>.env,
|
|
16
|
+
# a wrapper or symlink /usr/local/bin/<adapter>.
|
|
17
|
+
#
|
|
18
|
+
# she-servicectl version
|
|
19
|
+
# she-servicectl list (adapters = template units + npm packages with mqttInterfaces)
|
|
20
|
+
# she-servicectl unit <adapter> <instance> start|stop|restart|enable|disable|status
|
|
21
|
+
# she-servicectl logs <adapter> <instance> [-n N] [--follow]
|
|
22
|
+
# she-servicectl env <adapter> <instance> read|write (write: file on stdin)
|
|
23
|
+
# she-servicectl broker-env read|write (write: file on stdin)
|
|
24
|
+
# she-servicectl schema <adapter>
|
|
25
|
+
# she-servicectl install <adapter> <instance> (options as KEY=VALUE lines on stdin)
|
|
26
|
+
# she-servicectl uninstall <adapter> <instance>
|
|
27
|
+
# she-servicectl npm <adapter> version|origin|install|update
|
|
28
|
+
# she-servicectl files <adapter> <instance> JSON listing of /etc/<adapter>/ and /var/lib/<adapter>/<instance>/
|
|
29
|
+
# she-servicectl file <adapter> <instance> read|write <path> a file inside those two directories (write: content on stdin)
|
|
30
|
+
# she-servicectl asset <adapter> <relpath> a file shipped in the adapter package (example, schema)
|
|
31
|
+
# she-servicectl self-update replace this script with the one on stdin (she ships it)
|
|
32
|
+
# she-servicectl remove-key drop the public key on stdin from the calling user's authorized_keys
|
|
33
|
+
# she-servicectl teardown [--force] remove she from this host: that key, the sudoers rule, this script,
|
|
34
|
+
# the she-services user (adapters and their instances stay untouched)
|
|
35
|
+
#
|
|
36
|
+
# Legacy units: an adapter installed the pre-core way runs as a plain <adapter>.service with its
|
|
37
|
+
# env file in /etc/default/<adapter>. "list" reports them under "legacy"; unit/logs/env accept "-"
|
|
38
|
+
# as the instance for that unit; "migrate <adapter> <name>" turns it into <adapter>@<name> via the
|
|
39
|
+
# adapter's own --install (which carries state such as pairing keys over) and retires the old unit.
|
|
40
|
+
|
|
41
|
+
set -eu
|
|
42
|
+
|
|
43
|
+
VERSION=8
|
|
44
|
+
BROKER_ENV=/etc/mqtt-interfaces/broker.env
|
|
45
|
+
UNIT_DIR=/etc/systemd/system
|
|
46
|
+
|
|
47
|
+
die() { printf 'she-servicectl: %s\n' "$*" >&2; exit 2; }
|
|
48
|
+
|
|
49
|
+
# ── validation ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
valid_adapter() { printf '%s' "$1" | grep -Eq '^[a-z0-9][a-z0-9._-]{0,213}$'; }
|
|
52
|
+
valid_instance() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9_.-]{1,64}$'; }
|
|
53
|
+
|
|
54
|
+
# a template unit written by mqtt-interfaces-core's installer: per-instance env file under /etc/<adapter>/
|
|
55
|
+
installed_adapter() {
|
|
56
|
+
[ -f "$UNIT_DIR/$1@.service" ] && grep -Eq "^EnvironmentFile=-?/etc/$1/%i\.env" "$UNIT_DIR/$1@.service"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# does the template unit read the shared /etc/mqtt-interfaces/broker.env? (cores since 0.1 write it; older units do not)
|
|
60
|
+
unit_reads_broker_env() {
|
|
61
|
+
grep -q "mqtt-interfaces/broker.env" "$UNIT_DIR/$1@.service" 2>/dev/null && printf 'true' || printf 'false'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
need_adapter() {
|
|
65
|
+
valid_adapter "$1" || die "invalid adapter name: $1"
|
|
66
|
+
}
|
|
67
|
+
need_installed() {
|
|
68
|
+
need_adapter "$1"
|
|
69
|
+
installed_adapter "$1" || die "no mqtt-interfaces template unit for $1"
|
|
70
|
+
}
|
|
71
|
+
need_instance() {
|
|
72
|
+
valid_instance "$1" || die "invalid instance name: $1"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# a plain <adapter>.service of an npm-installed adapter (pre-core layout)
|
|
76
|
+
legacy_unit() {
|
|
77
|
+
[ -f "$UNIT_DIR/$1.service" ] || return 1
|
|
78
|
+
grep -Eq "(/node_modules/$1/|/usr/local/bin/$1( |$))" "$UNIT_DIR/$1.service" 2>/dev/null
|
|
79
|
+
}
|
|
80
|
+
# the env file of a legacy unit: its EnvironmentFile= (leading - stripped), default /etc/default/<adapter>;
|
|
81
|
+
# only /etc/default/<adapter> or something inside /etc/<adapter>/ is accepted
|
|
82
|
+
legacy_env_file() {
|
|
83
|
+
f=$(sed -n 's/^EnvironmentFile=-\{0,1\}//p' "$UNIT_DIR/$1.service" 2>/dev/null | head -n 1)
|
|
84
|
+
[ -n "$f" ] || f="/etc/default/$1"
|
|
85
|
+
case "$f" in "/etc/default/$1"|"/etc/$1/"*) printf '%s' "$f" ;; *) return 1 ;; esac
|
|
86
|
+
}
|
|
87
|
+
# template instance (default) or, with instance "-", the legacy unit
|
|
88
|
+
unit_name() {
|
|
89
|
+
if [ "$2" = "-" ]; then
|
|
90
|
+
legacy_unit "$1" || die "no legacy unit $1.service"
|
|
91
|
+
printf '%s.service' "$1"
|
|
92
|
+
else
|
|
93
|
+
need_installed "$1"; need_instance "$2"
|
|
94
|
+
printf '%s@%s.service' "$1" "$2"
|
|
95
|
+
fi
|
|
96
|
+
}
|
|
97
|
+
need_any() {
|
|
98
|
+
need_adapter "$1"
|
|
99
|
+
installed_adapter "$1" || legacy_unit "$1" || die "no mqtt-interfaces unit for $1"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# ── helpers ─────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
json_escape() {
|
|
105
|
+
# minimal JSON string escaping for the few free-form values (timestamps, paths)
|
|
106
|
+
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\n\r'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# the adapter's command: /usr/local/bin/<adapter> (wrapper or symlink), else PATH
|
|
110
|
+
adapter_cmd() {
|
|
111
|
+
if [ -x "/usr/local/bin/$1" ]; then
|
|
112
|
+
printf '/usr/local/bin/%s' "$1"
|
|
113
|
+
else
|
|
114
|
+
command -v "$1" 2>/dev/null || return 1
|
|
115
|
+
fi
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# node the adapter runs with (SV-17): parsed from a wrapper script, else PATH
|
|
119
|
+
adapter_node() {
|
|
120
|
+
cmd=$(adapter_cmd "$1") || { command -v node; return; }
|
|
121
|
+
node=""
|
|
122
|
+
if [ ! -L "$cmd" ]; then
|
|
123
|
+
node=$(grep -o '[^" ]*/bin/node' "$cmd" 2>/dev/null | head -n 1 || true)
|
|
124
|
+
fi
|
|
125
|
+
if [ -n "$node" ] && [ -x "$node" ]; then
|
|
126
|
+
printf '%s' "$node"
|
|
127
|
+
else
|
|
128
|
+
command -v node
|
|
129
|
+
fi
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
adapter_npm() {
|
|
133
|
+
node=$(adapter_node "$1")
|
|
134
|
+
npm="$(dirname "$node")/npm"
|
|
135
|
+
if [ -x "$npm" ]; then printf '%s' "$npm"; else command -v npm; fi
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# where the adapter's package lives (resolved through wrapper/symlink)
|
|
139
|
+
adapter_dir() {
|
|
140
|
+
cmd=$(adapter_cmd "$1") || return 1
|
|
141
|
+
if [ -L "$cmd" ]; then
|
|
142
|
+
target=$(readlink -f "$cmd")
|
|
143
|
+
else
|
|
144
|
+
target=$(grep -o '[^" ]*\.js' "$cmd" 2>/dev/null | head -n 1 || true)
|
|
145
|
+
[ -n "$target" ] || target="$cmd"
|
|
146
|
+
fi
|
|
147
|
+
d=$(dirname "$target")
|
|
148
|
+
while [ "$d" != "/" ]; do
|
|
149
|
+
if [ -f "$d/package.json" ]; then printf '%s' "$d"; return 0; fi
|
|
150
|
+
d=$(dirname "$d")
|
|
151
|
+
done
|
|
152
|
+
return 1
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
adapter_version() {
|
|
156
|
+
cmd=$(adapter_cmd "$1") || { printf 'null'; return; }
|
|
157
|
+
v=$("$cmd" --version 2>/dev/null | tail -n 1 || true)
|
|
158
|
+
if [ -n "$v" ]; then printf '"%s"' "$(json_escape "$v")"; else printf 'null'; fi
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# registry: the global npm tree knows the package; manual: deployed by tarball (SV-16)
|
|
162
|
+
# registry: installed with npm install -g. Older npms list global packages in the hidden lockfile of the
|
|
163
|
+
# global root; npm 11 keeps none there. A manual deploy (tarball extracted by deploy.sh, then
|
|
164
|
+
# npm install --prefix <dir>) leaves a hidden lockfile *inside* the package — a registry install does not.
|
|
165
|
+
adapter_origin() {
|
|
166
|
+
npm=$(adapter_npm "$1")
|
|
167
|
+
root=$("$npm" root -g 2>/dev/null || true)
|
|
168
|
+
lock="$root/.package-lock.json"
|
|
169
|
+
if [ -n "$root" ] && [ -f "$lock" ] && grep -q "\"node_modules/$1\"" "$lock"; then
|
|
170
|
+
printf 'registry'
|
|
171
|
+
return
|
|
172
|
+
fi
|
|
173
|
+
dir=$(adapter_dir "$1" 2>/dev/null || true)
|
|
174
|
+
if [ -n "$dir" ] && [ -f "$dir/node_modules/.package-lock.json" ]; then
|
|
175
|
+
printf 'manual'
|
|
176
|
+
else
|
|
177
|
+
printf 'registry'
|
|
178
|
+
fi
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
adapter_user() {
|
|
182
|
+
if id -u "$1" >/dev/null 2>&1; then printf '%s' "$1"; else printf 'root'; fi
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# absolute path without control characters or ..
|
|
186
|
+
valid_path() { printf '%s' "$1" | grep -Eq '^/[^[:cntrl:]]+$' && ! printf '%s' "$1" | grep -q '\.\.'; }
|
|
187
|
+
|
|
188
|
+
# the two directories she may read and write instance files in
|
|
189
|
+
managed_dir() {
|
|
190
|
+
case "$3" in
|
|
191
|
+
etc) printf '/etc/%s' "$1" ;;
|
|
192
|
+
state) printf '/var/lib/%s/%s' "$1" "$2" ;;
|
|
193
|
+
esac
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# is $3 (an existing file, or a new file whose directory exists) inside the managed dirs of $1/$2?
|
|
197
|
+
inside_managed() {
|
|
198
|
+
if [ -e "$3" ]; then
|
|
199
|
+
real=$(realpath "$3" 2>/dev/null) || return 1
|
|
200
|
+
else
|
|
201
|
+
pdir=$(realpath "$(dirname "$3")" 2>/dev/null) || return 1
|
|
202
|
+
real="$pdir/$(basename "$3")"
|
|
203
|
+
fi
|
|
204
|
+
for d in "$(managed_dir "$1" "$2" etc)" "$(managed_dir "$1" "$2" state)"; do
|
|
205
|
+
rd=$(realpath "$d" 2>/dev/null) || continue
|
|
206
|
+
case "$real" in "$rd"/*) return 0 ;; esac
|
|
207
|
+
done
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# validate an env file on stdin (KEY=VALUE, comments, blank lines) into $1
|
|
212
|
+
read_env_stdin() {
|
|
213
|
+
tmp=$1
|
|
214
|
+
cat > "$tmp"
|
|
215
|
+
if grep -Evq '^([A-Z][A-Z0-9_]*=.*|#.*|[[:space:]]*)$' "$tmp"; then
|
|
216
|
+
rm -f "$tmp"
|
|
217
|
+
die "env file: only KEY=VALUE lines and comments are allowed"
|
|
218
|
+
fi
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# ── commands ────────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
# ── adapters installed with npm but without a template unit yet ─────────────────
|
|
224
|
+
# (a catalog install is `npm install -g <adapter>`; the unit appears with the first `--install`)
|
|
225
|
+
npm_roots() {
|
|
226
|
+
r=$(command -v npm >/dev/null 2>&1 && npm root -g 2>/dev/null || true)
|
|
227
|
+
printf '%s\n/usr/local/lib/node_modules\n' "$r" | grep -v '^$' | LC_ALL=C sort -u
|
|
228
|
+
}
|
|
229
|
+
npm_adapters() {
|
|
230
|
+
for root in $(npm_roots); do
|
|
231
|
+
for pkg in "$root"/*/package.json; do
|
|
232
|
+
[ -f "$pkg" ] || continue
|
|
233
|
+
a=$(basename "$(dirname "$pkg")")
|
|
234
|
+
valid_adapter "$a" || continue
|
|
235
|
+
grep -q '"mqttInterfaces"' "$pkg" 2>/dev/null || continue
|
|
236
|
+
adapter_cmd "$a" >/dev/null 2>&1 || continue
|
|
237
|
+
printf '%s\n' "$a"
|
|
238
|
+
done
|
|
239
|
+
done | LC_ALL=C sort -u
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
cmd_version() {
|
|
243
|
+
printf '%s\n' "$VERSION"
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
cmd_list() {
|
|
247
|
+
hostname=$(hostname 2>/dev/null || uname -n)
|
|
248
|
+
node=$(command -v node >/dev/null 2>&1 && node --version 2>/dev/null || true)
|
|
249
|
+
printf '{"helper":%s,"hostname":"%s","node":%s,"brokerEnv":%s,"adapters":[' \
|
|
250
|
+
"$VERSION" "$(json_escape "$hostname")" \
|
|
251
|
+
"$([ -n "$node" ] && printf '"%s"' "$node" || printf 'null')" \
|
|
252
|
+
"$([ -f "$BROKER_ENV" ] && printf 'true' || printf 'false')"
|
|
253
|
+
first=1
|
|
254
|
+
adapters=""
|
|
255
|
+
legacy=""
|
|
256
|
+
for unit in "$UNIT_DIR"/*.service; do
|
|
257
|
+
[ -f "$unit" ] || continue
|
|
258
|
+
a=$(basename "$unit" .service)
|
|
259
|
+
case "$a" in *@) continue ;; esac
|
|
260
|
+
valid_adapter "$a" || continue
|
|
261
|
+
legacy_unit "$a" || continue
|
|
262
|
+
installed_adapter "$a" && continue # the template unit lists it below
|
|
263
|
+
legacy="$legacy $a"
|
|
264
|
+
done
|
|
265
|
+
for unit in "$UNIT_DIR"/*@.service $(for a in $legacy; do printf '%s ' "$UNIT_DIR/$a@.service.legacy-placeholder"; done); do
|
|
266
|
+
case "$unit" in *.legacy-placeholder) a=$(basename "$unit" @.service.legacy-placeholder) ;; *) [ -f "$unit" ] || continue; a=$(basename "$unit" @.service); installed_adapter "$a" || continue ;; esac
|
|
267
|
+
valid_adapter "$a" || continue
|
|
268
|
+
adapters="$adapters $a"
|
|
269
|
+
[ $first -eq 1 ] || printf ','
|
|
270
|
+
first=0
|
|
271
|
+
dir=$(adapter_dir "$a" 2>/dev/null || true)
|
|
272
|
+
printf '{"name":"%s","version":%s,"origin":"%s","path":%s,"node":"%s","brokerEnv":%s}' \
|
|
273
|
+
"$a" "$(adapter_version "$a")" "$(adapter_origin "$a")" \
|
|
274
|
+
"$([ -n "$dir" ] && printf '"%s"' "$(json_escape "$dir")" || printf 'null')" \
|
|
275
|
+
"$(json_escape "$(adapter_node "$a")")" "$(unit_reads_broker_env "$a")"
|
|
276
|
+
done
|
|
277
|
+
for a in $(npm_adapters); do
|
|
278
|
+
case " $adapters " in *" $a "*) continue ;; esac
|
|
279
|
+
[ $first -eq 1 ] || printf ','
|
|
280
|
+
first=0
|
|
281
|
+
dir=$(adapter_dir "$a" 2>/dev/null || true)
|
|
282
|
+
printf '{"name":"%s","version":%s,"origin":"%s","path":%s,"node":"%s","brokerEnv":false,"unit":false}' \
|
|
283
|
+
"$a" "$(adapter_version "$a")" "$(adapter_origin "$a")" \
|
|
284
|
+
"$([ -n "$dir" ] && printf '"%s"' "$(json_escape "$dir")" || printf 'null')" \
|
|
285
|
+
"$(json_escape "$(adapter_node "$a")")"
|
|
286
|
+
done
|
|
287
|
+
printf '],"legacy":['
|
|
288
|
+
first=1
|
|
289
|
+
seenlegacy=""
|
|
290
|
+
for a in $legacy $(for a in $adapters; do legacy_unit "$a" && printf '%s ' "$a"; done | tr ' ' '\n' | LC_ALL=C sort -u); do
|
|
291
|
+
legacy_unit "$a" || continue
|
|
292
|
+
case " $seenlegacy " in *" $a "*) continue ;; esac
|
|
293
|
+
seenlegacy="$seenlegacy $a"
|
|
294
|
+
[ $first -eq 1 ] || printf ','
|
|
295
|
+
first=0
|
|
296
|
+
show=$(systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts "$a.service" 2>/dev/null || true)
|
|
297
|
+
active=$(printf '%s\n' "$show" | sed -n 's/^ActiveState=//p')
|
|
298
|
+
sub=$(printf '%s\n' "$show" | sed -n 's/^SubState=//p')
|
|
299
|
+
uf=$(printf '%s\n' "$show" | sed -n 's/^UnitFileState=//p')
|
|
300
|
+
since=$(printf '%s\n' "$show" | sed -n 's/^ExecMainStartTimestamp=//p')
|
|
301
|
+
nr=$(printf '%s\n' "$show" | sed -n 's/^NRestarts=//p')
|
|
302
|
+
envf=$(legacy_env_file "$a" 2>/dev/null || true)
|
|
303
|
+
printf '{"adapter":"%s","unit":"%s.service","active":"%s","sub":"%s","unitFile":"%s","since":"%s","restarts":%s,"envFile":%s}' \
|
|
304
|
+
"$a" "$a" "$(json_escape "$active")" "$(json_escape "$sub")" "$(json_escape "$uf")" \
|
|
305
|
+
"$(json_escape "$since")" "${nr:-0}" "$([ -n "$envf" ] && printf '"%s"' "$(json_escape "$envf")" || printf 'null')"
|
|
306
|
+
done
|
|
307
|
+
printf '],"instances":['
|
|
308
|
+
first=1
|
|
309
|
+
for a in $adapters; do
|
|
310
|
+
installed_adapter "$a" || continue
|
|
311
|
+
for envf in "/etc/$a"/*.env; do
|
|
312
|
+
[ -f "$envf" ] || continue
|
|
313
|
+
i=$(basename "$envf" .env)
|
|
314
|
+
valid_instance "$i" || continue
|
|
315
|
+
[ $first -eq 1 ] || printf ','
|
|
316
|
+
first=0
|
|
317
|
+
show=$(systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts "$a@$i.service" 2>/dev/null || true)
|
|
318
|
+
active=$(printf '%s\n' "$show" | sed -n 's/^ActiveState=//p')
|
|
319
|
+
sub=$(printf '%s\n' "$show" | sed -n 's/^SubState=//p')
|
|
320
|
+
uf=$(printf '%s\n' "$show" | sed -n 's/^UnitFileState=//p')
|
|
321
|
+
since=$(printf '%s\n' "$show" | sed -n 's/^ExecMainStartTimestamp=//p')
|
|
322
|
+
nr=$(printf '%s\n' "$show" | sed -n 's/^NRestarts=//p')
|
|
323
|
+
printf '{"adapter":"%s","instance":"%s","active":"%s","sub":"%s","unitFile":"%s","since":"%s","restarts":%s}' \
|
|
324
|
+
"$a" "$i" "$(json_escape "$active")" "$(json_escape "$sub")" "$(json_escape "$uf")" \
|
|
325
|
+
"$(json_escape "$since")" "${nr:-0}"
|
|
326
|
+
done
|
|
327
|
+
done
|
|
328
|
+
printf ']}\n'
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
cmd_unit() {
|
|
332
|
+
[ $# -eq 3 ] || die "usage: unit <adapter> <instance|-> <action>"
|
|
333
|
+
need_adapter "$1"
|
|
334
|
+
u=$(unit_name "$1" "$2")
|
|
335
|
+
case "$3" in
|
|
336
|
+
start|stop|restart|enable|disable) systemctl "$3" "$u" ;;
|
|
337
|
+
status) systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts -p MainPID "$u" ;;
|
|
338
|
+
*) die "unknown unit action: $3" ;;
|
|
339
|
+
esac
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
cmd_logs() {
|
|
343
|
+
[ $# -ge 2 ] || die "usage: logs <adapter> <instance|-> [-n N] [--follow]"
|
|
344
|
+
need_adapter "$1"
|
|
345
|
+
unit=$(unit_name "$1" "$2"); shift 2
|
|
346
|
+
lines=200; follow=0
|
|
347
|
+
while [ $# -gt 0 ]; do
|
|
348
|
+
case "$1" in
|
|
349
|
+
-n) shift; printf '%s' "${1:-}" | grep -Eq '^[0-9]{1,5}$' || die "-n needs a number"; lines=$1 ;;
|
|
350
|
+
--follow) follow=1 ;;
|
|
351
|
+
*) die "unknown logs option: $1" ;;
|
|
352
|
+
esac
|
|
353
|
+
shift
|
|
354
|
+
done
|
|
355
|
+
if [ $follow -eq 1 ]; then
|
|
356
|
+
exec journalctl -u "$unit" -n "$lines" -o json --no-pager -f
|
|
357
|
+
else
|
|
358
|
+
exec journalctl -u "$unit" -n "$lines" -o json --no-pager
|
|
359
|
+
fi
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
cmd_env() {
|
|
363
|
+
[ $# -eq 3 ] || die "usage: env <adapter> <instance|-> read|write"
|
|
364
|
+
need_adapter "$1"
|
|
365
|
+
if [ "$2" = "-" ]; then
|
|
366
|
+
legacy_unit "$1" || die "no legacy unit $1.service"
|
|
367
|
+
f=$(legacy_env_file "$1") || die "legacy env file of $1 is outside /etc/default and /etc/$1"
|
|
368
|
+
else
|
|
369
|
+
need_installed "$1"; need_instance "$2"
|
|
370
|
+
f="/etc/$1/$2.env"
|
|
371
|
+
fi
|
|
372
|
+
case "$3" in
|
|
373
|
+
read)
|
|
374
|
+
[ -f "$f" ] || die "no such instance: $1@$2"
|
|
375
|
+
cat "$f" ;;
|
|
376
|
+
write)
|
|
377
|
+
[ -f "$f" ] || die "no such instance: $1@$2 (use install)"
|
|
378
|
+
tmp=$(mktemp "$(dirname "$f")/.$(basename "$f").XXXXXX")
|
|
379
|
+
read_env_stdin "$tmp"
|
|
380
|
+
cp -p "$f" "$f.bak"
|
|
381
|
+
chown "root:$(adapter_user "$1")" "$tmp"
|
|
382
|
+
chmod 0640 "$tmp"
|
|
383
|
+
mv "$tmp" "$f"
|
|
384
|
+
printf 'wrote %s\n' "$f" ;;
|
|
385
|
+
*) die "unknown env action: $3" ;;
|
|
386
|
+
esac
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
cmd_broker_env() {
|
|
390
|
+
[ $# -eq 1 ] || die "usage: broker-env read|write"
|
|
391
|
+
case "$1" in
|
|
392
|
+
read)
|
|
393
|
+
if [ -f "$BROKER_ENV" ]; then cat "$BROKER_ENV"; fi ;;
|
|
394
|
+
write)
|
|
395
|
+
mkdir -p "$(dirname "$BROKER_ENV")"
|
|
396
|
+
tmp=$(mktemp "$(dirname "$BROKER_ENV")/.broker.env.XXXXXX")
|
|
397
|
+
read_env_stdin "$tmp"
|
|
398
|
+
[ -f "$BROKER_ENV" ] && cp -p "$BROKER_ENV" "$BROKER_ENV.bak"
|
|
399
|
+
chown root:root "$tmp"
|
|
400
|
+
chmod 0640 "$tmp"
|
|
401
|
+
mv "$tmp" "$BROKER_ENV"
|
|
402
|
+
printf 'wrote %s\n' "$BROKER_ENV" ;;
|
|
403
|
+
*) die "unknown broker-env action: $1" ;;
|
|
404
|
+
esac
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
cmd_schema() {
|
|
408
|
+
[ $# -eq 1 ] || die "usage: schema <adapter>"
|
|
409
|
+
need_adapter "$1"
|
|
410
|
+
cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
|
|
411
|
+
exec "$cmd" --config-schema
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
cmd_install() {
|
|
415
|
+
[ $# -eq 2 ] || die "usage: install <adapter> <instance> (KEY=VALUE lines on stdin)"
|
|
416
|
+
need_adapter "$1"; need_instance "$2"
|
|
417
|
+
cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
|
|
418
|
+
tmp=$(mktemp)
|
|
419
|
+
read_env_stdin "$tmp"
|
|
420
|
+
# options become environment variables; the core reads <PREFIX>_* as defaults and
|
|
421
|
+
# --install writes them to /etc/<adapter>/<instance>.env
|
|
422
|
+
set -a
|
|
423
|
+
# shellcheck disable=SC1090
|
|
424
|
+
. "$tmp"
|
|
425
|
+
set +a
|
|
426
|
+
rm -f "$tmp"
|
|
427
|
+
exec "$cmd" --install --name "$2"
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# legacy <adapter>.service → <adapter>@<name>: the adapter's own --install with the legacy env as
|
|
431
|
+
# environment (it carries state such as pairing keys over), then the old unit is retired
|
|
432
|
+
cmd_migrate() {
|
|
433
|
+
[ $# -eq 2 ] || die "usage: migrate <adapter> <name>"
|
|
434
|
+
need_adapter "$1"; need_instance "$2"
|
|
435
|
+
legacy_unit "$1" || die "no legacy unit $1.service"
|
|
436
|
+
cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
|
|
437
|
+
envf=$(legacy_env_file "$1") || die "legacy env file of $1 is outside /etc/default and /etc/$1"
|
|
438
|
+
[ -f "/etc/$1/$2.env" ] && die "instance $1@$2 already exists"
|
|
439
|
+
if [ -f "$envf" ]; then
|
|
440
|
+
set -a
|
|
441
|
+
# shellcheck disable=SC1090
|
|
442
|
+
. "$envf"
|
|
443
|
+
set +a
|
|
444
|
+
fi
|
|
445
|
+
"$cmd" --install --name "$2" || die "$1 --install failed"
|
|
446
|
+
systemctl disable --now "$1.service" >/dev/null 2>&1 || true
|
|
447
|
+
mv "$UNIT_DIR/$1.service" "$UNIT_DIR/$1.service.migrated"
|
|
448
|
+
[ -f "$envf" ] && mv "$envf" "$envf.migrated"
|
|
449
|
+
systemctl daemon-reload
|
|
450
|
+
printf 'migrated %s.service to %s@%s.service; old unit and env kept as .migrated\n' "$1" "$1" "$2"
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
cmd_uninstall() {
|
|
454
|
+
[ $# -eq 2 ] || die "usage: uninstall <adapter> <instance>"
|
|
455
|
+
need_installed "$1"; need_instance "$2"
|
|
456
|
+
cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
|
|
457
|
+
exec "$cmd" --uninstall --name "$2"
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
cmd_files() {
|
|
461
|
+
[ $# -eq 2 ] || die "usage: files <adapter> <instance>"
|
|
462
|
+
need_installed "$1"; need_instance "$2"
|
|
463
|
+
tmp=$(mktemp)
|
|
464
|
+
for d in "$(managed_dir "$1" "$2" etc)" "$(managed_dir "$1" "$2" state)"; do
|
|
465
|
+
[ -d "$d" ] || continue
|
|
466
|
+
# top level plus one level of subdirectories; no dotfiles
|
|
467
|
+
find "$d" -mindepth 1 -maxdepth 2 -not -name '.*' 2>/dev/null >> "$tmp"
|
|
468
|
+
done
|
|
469
|
+
printf '['
|
|
470
|
+
LC_ALL=C sort "$tmp" | while IFS= read -r f; do
|
|
471
|
+
[ -n "$f" ] || continue
|
|
472
|
+
if [ -d "$f" ]; then kind=dir; size=0; else kind=file; size=$(wc -c < "$f" | tr -d ' '); fi
|
|
473
|
+
mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo 0)
|
|
474
|
+
if [ -s "$tmp.sep" ]; then printf ','; else printf 1 > "$tmp.sep"; fi
|
|
475
|
+
printf '{"path":"%s","kind":"%s","size":%s,"mtime":%s}' "$(json_escape "$f")" "$kind" "$size" "$mtime"
|
|
476
|
+
done
|
|
477
|
+
rm -f "$tmp" "$tmp.sep"
|
|
478
|
+
printf ']\n'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
cmd_file() {
|
|
482
|
+
[ $# -eq 4 ] || die "usage: file <adapter> <instance> read|write <path>"
|
|
483
|
+
need_installed "$1"; need_instance "$2"
|
|
484
|
+
valid_path "$4" || die "invalid path: $4"
|
|
485
|
+
inside_managed "$1" "$2" "$4" || die "path is outside /etc/$1 and /var/lib/$1/$2: $4"
|
|
486
|
+
case "$3" in
|
|
487
|
+
read)
|
|
488
|
+
[ -f "$4" ] || die "no such file: $4"
|
|
489
|
+
cat "$4" ;;
|
|
490
|
+
write)
|
|
491
|
+
dir=$(dirname "$4")
|
|
492
|
+
[ -d "$dir" ] || die "directory does not exist: $dir"
|
|
493
|
+
tmp=$(mktemp "$dir/.she.XXXXXX")
|
|
494
|
+
cat > "$tmp"
|
|
495
|
+
if [ -f "$4" ]; then
|
|
496
|
+
cp -p "$4" "$4.bak"
|
|
497
|
+
chown --reference="$4" "$tmp" 2>/dev/null || true
|
|
498
|
+
chmod --reference="$4" "$tmp" 2>/dev/null || true
|
|
499
|
+
else
|
|
500
|
+
chown "root:$(adapter_user "$1")" "$tmp"
|
|
501
|
+
chmod 0640 "$tmp"
|
|
502
|
+
fi
|
|
503
|
+
mv "$tmp" "$4"
|
|
504
|
+
printf 'wrote %s\n' "$4" ;;
|
|
505
|
+
*) die "unknown file action: $3" ;;
|
|
506
|
+
esac
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
cmd_asset() {
|
|
510
|
+
[ $# -eq 2 ] || die "usage: asset <adapter> <relpath>"
|
|
511
|
+
need_adapter "$1"
|
|
512
|
+
printf '%s' "$2" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_./-]*$' || die "invalid asset path: $2"
|
|
513
|
+
printf '%s' "$2" | grep -q '\.\.' && die "invalid asset path: $2"
|
|
514
|
+
dir=$(adapter_dir "$1") || die "adapter not installed: $1"
|
|
515
|
+
f="$dir/$2"
|
|
516
|
+
[ -f "$f" ] || die "no such asset: $2"
|
|
517
|
+
real=$(realpath "$f"); rd=$(realpath "$dir")
|
|
518
|
+
case "$real" in "$rd"/*) ;; *) die "invalid asset path: $2" ;; esac
|
|
519
|
+
cat "$f"
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# she pushes a newer copy of this script through the existing sudo rule: checked before it replaces us
|
|
523
|
+
cmd_self_update() {
|
|
524
|
+
[ $# -eq 0 ] || die "usage: self-update (new script on stdin)"
|
|
525
|
+
self=$(realpath "$0" 2>/dev/null) || self=/usr/local/bin/she-servicectl
|
|
526
|
+
tmp=$(mktemp)
|
|
527
|
+
cat > "$tmp"
|
|
528
|
+
if ! head -n 3 "$tmp" | grep -q '^# she-servicectl'; then rm -f "$tmp"; die "self-update: not a she-servicectl script"; fi
|
|
529
|
+
if ! grep -Eq '^VERSION=[0-9]+$' "$tmp"; then rm -f "$tmp"; die "self-update: no VERSION line"; fi
|
|
530
|
+
if ! sh -n "$tmp" 2>/dev/null; then rm -f "$tmp"; die "self-update: syntax error in the new script"; fi
|
|
531
|
+
newver=$(sed -n 's/^VERSION=\([0-9]*\)$/\1/p' "$tmp" | head -n 1)
|
|
532
|
+
cp -p "$self" "$self.bak" 2>/dev/null || true
|
|
533
|
+
install -m 755 -o root -g root "$tmp" "$self"
|
|
534
|
+
rm -f "$tmp"
|
|
535
|
+
printf 'she-servicectl updated %s -> %s at %s\n' "$VERSION" "$newver" "$self"
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
# ── removing she from this host ─────────────────────────────────────────────────
|
|
539
|
+
# The account she logs in as is the sudo caller (root when she connects as root).
|
|
540
|
+
caller_user() { printf '%s' "${SUDO_USER:-$(id -un)}"; }
|
|
541
|
+
key_count() { grep -cE '^[^#[:space:]]' "$1" 2>/dev/null || true; }
|
|
542
|
+
|
|
543
|
+
# drop every authorized_keys line of user $1 whose key material equals the key in file $2;
|
|
544
|
+
# sets REMAINING to the number of keys left
|
|
545
|
+
remove_key_lines() {
|
|
546
|
+
home=$(getent passwd "$1" | cut -d: -f6)
|
|
547
|
+
[ -n "$home" ] || die "no home directory for $1"
|
|
548
|
+
auth="$home/.ssh/authorized_keys"
|
|
549
|
+
keymat=$(awk 'NF >= 2 && $1 ~ /^(ssh-|ecdsa-|sk-)/ { print $2; exit }' "$2")
|
|
550
|
+
[ -n "$keymat" ] || die "not a public key"
|
|
551
|
+
if [ ! -f "$auth" ]; then REMAINING=0; printf 'no authorized_keys for %s, nothing to remove\n' "$1"; return 0; fi
|
|
552
|
+
before=$(key_count "$auth")
|
|
553
|
+
tmp=$(mktemp)
|
|
554
|
+
awk -v k="$keymat" '{ keep = 1; for (i = 1; i <= NF; i++) if ($i == k) keep = 0 } keep' "$auth" > "$tmp"
|
|
555
|
+
cat "$tmp" > "$auth" # keeps owner and mode
|
|
556
|
+
rm -f "$tmp"
|
|
557
|
+
REMAINING=$(key_count "$auth")
|
|
558
|
+
printf 'removed %s key(s) from %s, %s remaining\n' "$((before - REMAINING))" "$auth" "$REMAINING"
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
cmd_remove_key() {
|
|
562
|
+
[ $# -eq 0 ] || die "usage: remove-key (public key on stdin)"
|
|
563
|
+
tmp=$(mktemp); cat > "$tmp"
|
|
564
|
+
remove_key_lines "$(caller_user)" "$tmp"
|
|
565
|
+
rm -f "$tmp"
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
cmd_teardown() {
|
|
569
|
+
force=0
|
|
570
|
+
if [ $# -eq 1 ] && [ "$1" = "--force" ]; then force=1; elif [ $# -ne 0 ]; then die "usage: teardown [--force] (she's public key on stdin, may be empty)"; fi
|
|
571
|
+
user=$(caller_user)
|
|
572
|
+
self=$(realpath "$0" 2>/dev/null) || self=/usr/local/bin/she-servicectl
|
|
573
|
+
REMAINING=0
|
|
574
|
+
tmp=$(mktemp); cat > "$tmp"
|
|
575
|
+
if grep -q . "$tmp"; then remove_key_lines "$user" "$tmp"; fi
|
|
576
|
+
rm -f "$tmp"
|
|
577
|
+
if [ "$user" != root ] && [ "$REMAINING" -gt 0 ] && [ $force -eq 0 ]; then
|
|
578
|
+
printf 'she-servicectl: teardown: %s other key(s) still in the authorized_keys of %s — another she instance may manage this host; remove everything anyway with --force\n' "$REMAINING" "$user" >&2
|
|
579
|
+
exit 3
|
|
580
|
+
fi
|
|
581
|
+
# sudoers: a file that allows nothing but the helper goes away, any other file loses the helper line
|
|
582
|
+
for f in /etc/sudoers.d/*; do
|
|
583
|
+
[ -f "$f" ] || continue
|
|
584
|
+
grep -qF "$self" "$f" || continue
|
|
585
|
+
if grep -vE '^[[:space:]]*(#|$)' "$f" | grep -qvF "$self"; then
|
|
586
|
+
tmp=$(mktemp); grep -vF "$self" "$f" > "$tmp"; cat "$tmp" > "$f"; rm -f "$tmp"
|
|
587
|
+
printf 'removed the she-servicectl line from %s\n' "$f"
|
|
588
|
+
else
|
|
589
|
+
rm -f "$f"; printf 'removed %s\n' "$f"
|
|
590
|
+
fi
|
|
591
|
+
done
|
|
592
|
+
# the she-services account is the bootstrap script's; no other account is ever removed
|
|
593
|
+
if [ "$user" = she-services ] && id she-services >/dev/null 2>&1; then
|
|
594
|
+
userdel -r -f she-services 2>/dev/null || userdel -f she-services
|
|
595
|
+
printf 'removed user she-services and its home directory\n'
|
|
596
|
+
fi
|
|
597
|
+
rm -f "$self.bak" "$self"
|
|
598
|
+
printf 'removed %s\n' "$self"
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
cmd_npm() {
|
|
602
|
+
[ $# -eq 2 ] || die "usage: npm <adapter> version|origin|install|update"
|
|
603
|
+
need_adapter "$1"
|
|
604
|
+
case "$2" in
|
|
605
|
+
version) adapter_version "$1"; printf '\n' ;;
|
|
606
|
+
origin) adapter_origin "$1"; printf '\n' ;;
|
|
607
|
+
install|update)
|
|
608
|
+
npm=$(adapter_npm "$1")
|
|
609
|
+
node=$(adapter_node "$1")
|
|
610
|
+
PATH="$(dirname "$node"):$PATH" exec "$npm" install -g "$1@latest" --no-audit --no-fund ;;
|
|
611
|
+
*) die "unknown npm action: $2" ;;
|
|
612
|
+
esac
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
# ── dispatch ────────────────────────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
[ $# -ge 1 ] || die "usage: she-servicectl <command> …"
|
|
618
|
+
c=$1; shift
|
|
619
|
+
case "$c" in
|
|
620
|
+
version) cmd_version "$@" ;;
|
|
621
|
+
list) cmd_list "$@" ;;
|
|
622
|
+
unit) cmd_unit "$@" ;;
|
|
623
|
+
logs) cmd_logs "$@" ;;
|
|
624
|
+
env) cmd_env "$@" ;;
|
|
625
|
+
broker-env) cmd_broker_env "$@" ;;
|
|
626
|
+
schema) cmd_schema "$@" ;;
|
|
627
|
+
install) cmd_install "$@" ;;
|
|
628
|
+
uninstall) cmd_uninstall "$@" ;;
|
|
629
|
+
npm) cmd_npm "$@" ;;
|
|
630
|
+
files) cmd_files "$@" ;;
|
|
631
|
+
file) cmd_file "$@" ;;
|
|
632
|
+
asset) cmd_asset "$@" ;;
|
|
633
|
+
self-update) cmd_self_update "$@" ;;
|
|
634
|
+
migrate) cmd_migrate "$@" ;;
|
|
635
|
+
remove-key) cmd_remove_key "$@" ;;
|
|
636
|
+
teardown) cmd_teardown "$@" ;;
|
|
637
|
+
*) die "unknown command: $c" ;;
|
|
638
|
+
esac
|