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.
@@ -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
@@ -1,4 +1,4 @@
1
- [Unit]
1
+ [Unit]
2
2
  Description=Smart Home Engine (she)
3
3
  Documentation=https://github.com/hobbyquaker/she
4
4
  After=network-online.target