clavarium 0.0.2 → 0.1.0-next.2

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -6
  3. package/config/tools.json +172 -0
  4. package/deploy/appliance.sh +83 -0
  5. package/deploy/backup.sh +97 -0
  6. package/deploy/clavarium-host +720 -0
  7. package/deploy/clavarium.mjs +30 -0
  8. package/deploy/git-read +85 -0
  9. package/deploy/install.sh +659 -0
  10. package/deploy/move-to-mnt.sh +142 -0
  11. package/deploy/parley-archive.mjs +47 -0
  12. package/deploy/relay.sh +156 -0
  13. package/deploy/restore.sh +147 -0
  14. package/deploy/updater.mjs +600 -0
  15. package/npm-shrinkwrap.json +1951 -0
  16. package/package.json +53 -8
  17. package/server/alerts.mjs +83 -0
  18. package/server/answerer.mjs +326 -0
  19. package/server/api.mjs +458 -0
  20. package/server/bridge.mjs +271 -0
  21. package/server/capacity.mjs +220 -0
  22. package/server/conversations.mjs +895 -0
  23. package/server/dataformat.mjs +59 -0
  24. package/server/devices.mjs +257 -0
  25. package/server/door.mjs +235 -0
  26. package/server/files.mjs +455 -0
  27. package/server/gate.mjs +54 -0
  28. package/server/gitaccess.mjs +295 -0
  29. package/server/gitchecks.mjs +126 -0
  30. package/server/gitkeeper.mjs +719 -0
  31. package/server/gitpush.mjs +585 -0
  32. package/server/gitrunner.mjs +540 -0
  33. package/server/grants.mjs +110 -0
  34. package/server/guide.mjs +278 -0
  35. package/server/health.mjs +207 -0
  36. package/server/imap.mjs +75 -0
  37. package/server/mailbox.mjs +165 -0
  38. package/server/main.mjs +420 -0
  39. package/server/names.mjs +10 -0
  40. package/server/network.mjs +85 -0
  41. package/server/parley.mjs +62 -0
  42. package/server/resources.mjs +396 -0
  43. package/server/seats.mjs +24 -0
  44. package/server/secrets.mjs +118 -0
  45. package/server/setup.mjs +768 -0
  46. package/server/shelf.mjs +260 -0
  47. package/server/shortcuts.mjs +62 -0
  48. package/server/signin.mjs +185 -0
  49. package/server/smoke.mjs +26 -0
  50. package/server/store.mjs +48 -0
  51. package/server/talk.mjs +514 -0
  52. package/server/tmux.mjs +54 -0
  53. package/server/toolbox.mjs +168 -0
  54. package/server/tools.mjs +109 -0
  55. package/server/updates.mjs +116 -0
  56. package/server/vault.mjs +37 -0
  57. package/server/vm.mjs +139 -0
  58. package/server/voice.mjs +90 -0
  59. package/server/wakeups.mjs +320 -0
  60. package/web/app.js +4779 -0
  61. package/web/device.html +149 -0
  62. package/web/device.js +152 -0
  63. package/web/icon-192.png +0 -0
  64. package/web/icon-512.png +0 -0
  65. package/web/icon.svg +1 -0
  66. package/web/icons.js +9 -0
  67. package/web/index.html +784 -0
  68. package/web/links.js +995 -0
  69. package/web/manifest.webmanifest +30 -0
  70. package/web/prism-manual.js +3 -0
  71. package/web/style.css +1006 -0
  72. package/web/sw.js +26 -0
  73. package/web/voice-core.js +107 -0
  74. package/web/voice.js +56 -0
  75. package/keep.mjs +0 -8
@@ -0,0 +1,720 @@
1
+ #!/bin/bash
2
+ # ---------------------------------------------------------------------------
3
+ # THE ONLY PRIVILEGED THING THE SERVER MAY DO.
4
+ #
5
+ # The server runs as an account with no rights. Everything that needs root --
6
+ # driving the VM manager, making a read-only view, loading network rules --
7
+ # goes through this one program, called with sudo, and the sudoers rule names
8
+ # this program and nothing else.
9
+ #
10
+ # It accepts a FIXED LIST of verbs, in the exact forms the server makes, and
11
+ # refuses everything else by name. It is not a general tool and must not
12
+ # become one: each verb added here is a thing a compromised server can do.
13
+ #
14
+ # WHAT IT WILL NOT DO, whatever is asked:
15
+ # - mount anything but a shared folder, a view of one, a work folder or
16
+ # the read-only view of the tools folder into a VM. A mount is served as root: any other path would be a way to
17
+ # read the host's files;
18
+ # - follow a path the server could have bent. A compromised server could
19
+ # replace a folder by a link to /etc and ask for it to be mounted, or
20
+ # swap a directory between our check and the mount. Hence: everything
21
+ # this program mounts, unmounts or moves lives under $MNT (below), on a
22
+ # chain root owns; the entries are made and moved by this program only,
23
+ # and the chain is checked before each use;
24
+ # - touch a VM whose name is not one of ours ("clv-" + a conversation name,
25
+ # or "clvshelf-" + a date for a ready-made machine: see "shelf" below);
26
+ # - load a network rule that is not, line for line, one of the known shapes.
27
+ #
28
+ # Every name is checked HERE, even though the server checked it already: this
29
+ # is where a string becomes a root command.
30
+ #
31
+ # --say-only print the command instead of running it. For tests: a flag,
32
+ # never an environment variable -- in a privileged program, a
33
+ # variable that changes what runs is the hole being closed.
34
+ # --data DIR the data directory; honoured ONLY with --say-only. Otherwise
35
+ # it is read from /etc/clavarium/host.conf, which root owns.
36
+ # --mnt DIR where the shared folders live ($MNT, see below); the same
37
+ # rule: --say-only only, else MNT= in host.conf.
38
+ #
39
+ # WHY THE SHARED FOLDERS ARE NOT IN THE DATA DIRECTORY. The data directory
40
+ # belongs to the server's account, so a compromised server could rename any
41
+ # of its directories between our check and the moment mount or systemd walks
42
+ # the path. So everything that gets mounted, unmounted or moved as root lives
43
+ # under $MNT instead -- resources/ (the folders), views/ (their read-only
44
+ # views), work/ and work-disks/ (the conversations' work disks), removed/
45
+ # (what was put aside), toolbox/ (the tools every machine sees at /tools) -- a chain of directories that root owns from / down:
46
+ # no component of it can be swapped by the server (renaming needs write
47
+ # access to the parent). `held` checks that chain before each use. The data
48
+ # directory keeps only the server's own files, which root never touches.
49
+ #
50
+ # Exit codes: the command's own; 64 usage; 78 refused, with the reason.
51
+ # "git read" has its own fixed codes, listed at that verb.
52
+ # ---------------------------------------------------------------------------
53
+ set -euo pipefail
54
+
55
+ MULTIPASS="multipass"
56
+ IMAGE="24.04"
57
+ DISK="8G"
58
+ LAUNCH_SECONDS="900"
59
+ BRIDGE="mpqemubr0"
60
+ TABLE="clavarium"
61
+ VM_ID="1000"
62
+ # The size of the disk a conversation works in, in megabytes: 10 GB unless
63
+ # /etc/clavarium/host.conf says otherwise (WORK_MB=...). Room on the host is
64
+ # taken only as the disk is filled, and given back when files are removed.
65
+ WORK_MB="10240"
66
+ # THE READY-MADE MACHINE ("shelf"): a machine prepared in advance -- system
67
+ # updated, shared folders possible, Node.js and the tools installed -- then
68
+ # stopped, and COPIED for each new conversation with `multipass clone`
69
+ # (Multipass 1.15 and later). A copy boots in a minute or two instead of being
70
+ # built for six. It is only ever a source: no conversation runs in it.
71
+ SHELF_MEMORY="1536M"
72
+ # Where the VM manager keeps its machines' disks: a rebuild is refused when
73
+ # less than this is free there, so that a new copy cannot fill the host.
74
+ SHELF_STORE="/var/snap/multipass/common"
75
+ SHELF_INSTANCES="$SHELF_STORE/data/multipassd/vault/instances"
76
+ SHELF_MIN_FREE_MB="6144"
77
+ CONF="/etc/clavarium/host.conf"
78
+ # THE GIT READER (verb "git read"): an empty root-owned directory used as the
79
+ # sandbox's root, and the fixed script run inside it. Both made by install.sh.
80
+ RUNNER_ROOT="/var/lib/clavarium/runner-root"
81
+ GIT_READ="/usr/lib/clavarium/git-read"
82
+ LOCKS="/run/clavarium"
83
+ # THE UPDATER (verbs "update ..."): the release that runs, its Node.js and its
84
+ # updater, all root's (install.sh, deploy/updater.mjs). A request for the
85
+ # updater's service is written where only root writes.
86
+ CODE="/opt/clavarium"
87
+ UPDATER_NODE="$CODE/current/node/bin/node"
88
+ UPDATER="$CODE/current/app/deploy/updater.mjs"
89
+ UPDATE_STATE="/var/lib/clavarium/update"
90
+
91
+ refuse() { echo "REFUSED: $*" >&2; exit 78; }
92
+ usage() { echo "usage: clavarium-host [--say-only [--data DIR] [--mnt DIR]] <vm|shelf|folder|work|view|tools|net|git|server|update> <verb> [arguments]" >&2; exit 64; }
93
+
94
+ SAY="no"; DATA=""; MNT=""
95
+ while [ $# -gt 0 ]; do
96
+ case "$1" in
97
+ --say-only) SAY="yes"; shift ;;
98
+ --data) [ "$SAY" = "yes" ] || refuse "--data is only for --say-only"; DATA="${2:-}"; shift 2 ;;
99
+ --mnt) [ "$SAY" = "yes" ] || refuse "--mnt is only for --say-only"; MNT="${2:-}"; shift 2 ;;
100
+ *) break ;;
101
+ esac
102
+ done
103
+ if [ -z "$DATA" ]; then
104
+ [ -r "$CONF" ] || refuse "$CONF is missing: the data directory is not declared"
105
+ DATA="$(sed -n 's/^DATA=//p' "$CONF" | head -1)"
106
+ fi
107
+ if [ -z "$MNT" ]; then
108
+ if [ "$SAY" = "yes" ]; then MNT="$DATA"
109
+ else
110
+ MNT="$(sed -n 's/^MNT=//p' "$CONF" | head -1)"
111
+ [ -n "$MNT" ] || refuse "MNT is not declared in $CONF: run install.sh again"
112
+ fi
113
+ fi
114
+ if [ -r "$CONF" ]; then
115
+ asked="$(sed -n 's/^WORK_MB=//p' "$CONF" | head -1)"
116
+ if [ -n "$asked" ]; then
117
+ [[ "$asked" =~ ^[0-9]{2,7}$ ]] && [ "$asked" -ge 64 ] || refuse "WORK_MB in $CONF must be a number of megabytes, 64 at least"
118
+ WORK_MB="$asked"
119
+ fi
120
+ fi
121
+ case "$DATA" in /*) : ;; *) refuse "the data directory must be an absolute path" ;; esac
122
+ case "$DATA" in *[!A-Za-z0-9/._-]*|*..*) refuse "the data directory has an unexpected character" ;; esac
123
+ # MNT goes into a systemd property list (git read): a space or a colon there
124
+ # would split it. The same characters as DATA, nothing else.
125
+ case "$MNT" in /*) : ;; *) refuse "MNT must be an absolute path" ;; esac
126
+ case "$MNT" in *[!A-Za-z0-9/._-]*|*..*) refuse "MNT has an unexpected character" ;; esac
127
+
128
+ run() { if [ "$SAY" = "yes" ]; then printf '%s\n' "$*"; else exec "$@"; fi; }
129
+
130
+ # A conversation or folder name: lower-case letters, digits, hyphens; starts
131
+ # with a letter; no trailing hyphen; 32 at most. The same rule as the server.
132
+ is_name() { [[ "$1" =~ ^[a-z]([a-z0-9-]{0,30}[a-z0-9])?$ ]]; }
133
+ vm_of() { is_name "${1#clv-}" && [ "$1" != "${1#clv-}" ] || refuse "not one of our VMs: ${1:-<empty>}"; }
134
+ number() { [[ "$1" =~ ^[0-9]{1,6}$ ]] || refuse "not a number: ${1:-<empty>}"; }
135
+ # A ready-made machine: "clvshelf-" and the moment it was started, in UTC.
136
+ # Never "clv-...": no conversation verb can reach it, and no shelf verb can
137
+ # reach a conversation.
138
+ shelf_of() { [[ "${1:-}" =~ ^clvshelf-[0-9]{8}-[0-9]{6}$ ]] || refuse "not one of our ready-made machines: ${1:-<empty>}"; }
139
+ memory_ok() { number "$1"; [ "$1" -ge 512 ] && [ "$1" -le 65536 ] || refuse "memory out of bounds: $1"; }
140
+ # Megabytes free where the VM manager keeps its disks.
141
+ store_free() { local at="$SHELF_STORE"; [ -d "$at" ] || at="/var"; df -m --output=avail "$at" | tail -1 | tr -d ' '; }
142
+ # The largest disk a conversation's machine may have, in gigabytes: the
143
+ # server's own bound (DISK_MAX_GB in server/conversations.mjs).
144
+ DISK_MAX_GB="256"
145
+ # Whole gigabytes of a size as the VM manager writes it ("8.0GiB", "16G",
146
+ # "16384M", bytes), rounded down; nothing when it has another shape.
147
+ size_gb() {
148
+ [[ "${1:-}" =~ ^([0-9]{1,12})(\.[0-9]{1,3})?([KMGT]?)(i?B)?$ ]] || return 0
149
+ local n="${BASH_REMATCH[1]}"
150
+ case "${BASH_REMATCH[3]}" in T) echo $((n * 1024)) ;; G) echo "$n" ;; M) echo $((n / 1024)) ;; K) echo $((n / 1048576)) ;; *) echo $((n / 1073741824)) ;; esac
151
+ }
152
+ # Room to grow a disk from <has> to <wants> gigabytes, with <free> megabytes
153
+ # free where the VM manager keeps its disks: what it grows by may all be
154
+ # filled, and the same minimum as for a ready-made machine must stay free.
155
+ grow_room() {
156
+ local has="$1" wants="$2" free="$3" need
157
+ need=$(( (wants > has ? wants - has : 0) * 1024 + SHELF_MIN_FREE_MB ))
158
+ [ "$free" -ge "$need" ] || refuse "only ${free} MB free for machines; growing this disk from ${has} to ${wants} GB needs ${need} MB"
159
+ }
160
+
161
+ # HELD: the path cannot be bent by anyone but root. Every component from /
162
+ # down to the entry's parent is a real directory (not a link), belongs to
163
+ # root, and is writable by nobody else; the entry itself is a real directory.
164
+ # Then what systemd or mount walks a moment later is the path checked here:
165
+ # swapping a component (rename, RENAME_EXCHANGE) needs write access to its
166
+ # parent, which only root has. The entry itself may be the server's (a
167
+ # folder it fills); what is inside it is the agent's business, not a path.
168
+ held() { [ "$SAY" = "yes" ] || held_check "$1"; }
169
+ held_check() {
170
+ local path="$1" at="" part
171
+ case "$path" in /*) : ;; *) refuse "not an absolute path: $path" ;; esac
172
+ local parent="${path%/*}"
173
+ IFS='/' read -r -a parts <<< "${parent#/}"
174
+ [ -d / ] && [ "$(stat -c %u /)" = "0" ] || refuse "/ is not root's"
175
+ for part in "${parts[@]}"; do
176
+ [ -n "$part" ] || continue
177
+ at="$at/$part"
178
+ [ -d "$at" ] && [ ! -L "$at" ] || refuse "$at is not a plain directory"
179
+ [ "$(stat -c %u "$at")" = "0" ] || refuse "$at does not belong to root"
180
+ case "$(stat -c %A "$at")" in ?????w????|????????w?) refuse "$at may be written by others than root" ;; esac
181
+ # The mode bits are not the whole story: an access list can give
182
+ # another account write access while they still read 0755. ls marks
183
+ # such a directory with a '+' after the mode.
184
+ acl_free "$at" || refuse "$at has an access list (getfacl): only root may write it"
185
+ done
186
+ [ -d "$path" ] && [ ! -L "$path" ] || refuse "not a plain directory: $path"
187
+ }
188
+ acl_free() { local mode; mode="$(ls -ld -- "$1" | cut -d' ' -f1)"; [ "${#mode}" -eq 10 ]; }
189
+ # One of the directories under $MNT that only root may arrange (resources,
190
+ # views, work, work-disks, removed, toolbox): made if missing, refused if it is a link
191
+ # or belongs to anyone else -- then its entries cannot be trusted either --
192
+ # and HELD (above), with the whole chain above it.
193
+ kept() {
194
+ [ "$SAY" = "yes" ] && return 0
195
+ case "$1" in resources|views|work|work-disks|removed|toolbox) : ;; *) refuse "not one of root's directories: $1" ;; esac
196
+ local d="$MNT/$1"
197
+ [ -e "$d" ] || [ -L "$d" ] || install -d -o root -g root -m 0755 "$d"
198
+ [ -d "$d" ] && [ ! -L "$d" ] && [ "$(stat -c %u "$d")" = "0" ] \
199
+ || refuse "$d must be a directory that belongs to root (chown root:root, once, by hand)"
200
+ held "$d"
201
+ # It is itself the parent of what gets mounted or moved: only root may
202
+ # write it, or an entry could be swapped there.
203
+ held "$d/."
204
+ }
205
+ # A folder of ours: a real directory, directly under resources/, on a held chain.
206
+ folder_of() {
207
+ kept resources
208
+ [ "$SAY" = "yes" ] && return 0
209
+ [ -d "$MNT/resources/$1" ] && [ ! -L "$MNT/resources/$1" ] || refuse "no such folder: $1"
210
+ held "$MNT/resources/$1"
211
+ }
212
+
213
+ # How a finished read unit is said to the server: from what systemd recorded,
214
+ # never from what the code inside printed. See "git read".
215
+ unit_code() {
216
+ local result="$1" how="$2" status="$3"
217
+ case "$result" in
218
+ timeout) echo 80 ;;
219
+ oom-kill) echo 81 ;;
220
+ signal|core-dump) echo 82 ;;
221
+ exit-code)
222
+ # 1: the process exited (2 killed, 3 dumped: told by Result above).
223
+ if [ "$how" != "1" ] || ! [[ "$status" =~ ^[0-9]{1,3}$ ]]; then echo 79
224
+ elif [ "$status" = "128" ]; then echo 65
225
+ elif [ "$status" = "3" ]; then echo 67
226
+ # 200-243: systemd could not start the process. The wrapper turns
227
+ # every other code of git into 66, so these are systemd's own.
228
+ elif [ "$status" -ge 200 ] && [ "$status" -le 243 ]; then echo 79
229
+ else echo 66
230
+ fi ;;
231
+ *) echo 79 ;;
232
+ esac
233
+ }
234
+
235
+ [ $# -ge 2 ] || usage
236
+ AREA="$1"; VERB="$2"; shift 2
237
+
238
+ case "$AREA $VERB" in
239
+ "vm launch")
240
+ [ $# -eq 2 ] || usage; vm_of "$1"; memory_ok "$2"
241
+ run "$MULTIPASS" launch "$IMAGE" --name "$1" --memory "${2}M" --disk "$DISK" --cpus 1 --timeout "$LAUNCH_SECONDS" ;;
242
+ "vm clone")
243
+ # <shelf> <vm> <memory>: a conversation's machine, copied from the
244
+ # ready-made one (which must be stopped: Multipass refuses otherwise).
245
+ # A copy has the size of its source; its memory is set before its
246
+ # first start, while it is still stopped.
247
+ [ $# -eq 3 ] || usage; shelf_of "$1"; vm_of "$2"; memory_ok "$3"
248
+ if [ "$SAY" = "yes" ]; then
249
+ printf '%s\n' "$MULTIPASS clone $1 --name $2" "$MULTIPASS set local.$2.memory=${3}M"; exit 0
250
+ fi
251
+ "$MULTIPASS" clone "$1" --name "$2"
252
+ exec "$MULTIPASS" set "local.$2.memory=${3}M" ;;
253
+ "vm exec")
254
+ [ $# -ge 1 ] || usage; vm_of "$1"; [ "${2:-}" = "--" ] || refuse "exec takes: <vm> -- <command...>"
255
+ [ $# -ge 3 ] || usage
256
+ vm="$1"; shift 2
257
+ run "$MULTIPASS" exec "$vm" -- "$@" ;;
258
+ "vm info")
259
+ [ $# -eq 1 ] || usage; vm_of "$1"
260
+ run "$MULTIPASS" info "$1" --format json ;;
261
+ "vm start"|"vm stop"|"vm suspend")
262
+ [ $# -eq 1 ] || usage; vm_of "$1"
263
+ run "$MULTIPASS" "$VERB" "$1" ;;
264
+ "vm kill")
265
+ # A stop that does not wait for the system inside: asked only after a
266
+ # clean stop hung or failed (a restart the owner asked for).
267
+ [ $# -eq 1 ] || usage; vm_of "$1"
268
+ run "$MULTIPASS" stop --force "$1" ;;
269
+ "vm set")
270
+ # <vm> memory <megabytes> | <vm> disk <gigabytes>. The VM manager
271
+ # takes it only from a stopped machine, and a disk only ever grows:
272
+ # both are its own refusals, passed on as they are.
273
+ vm_of "${1:-}"; [ $# -eq 3 ] || usage
274
+ case "$2" in
275
+ memory) memory_ok "$3"; run "$MULTIPASS" set "local.$1.memory=${3}M" ;;
276
+ disk) [[ "$3" =~ ^[0-9]{1,4}$ ]] && [ "$3" -ge 5 ] && [ "$3" -le "$DISK_MAX_GB" ] || refuse "disk out of bounds: $3"
277
+ # Refused when the host has not the room for it, whatever
278
+ # the server said (its size unknown: counted from nothing).
279
+ if [ "$SAY" != "yes" ]; then
280
+ has="$(size_gb "$("$MULTIPASS" get "local.$1.disk" 2>/dev/null | head -1 | tr -d ' ')")"
281
+ grow_room "${has:-0}" "$3" "$(store_free)"
282
+ fi
283
+ run "$MULTIPASS" set "local.$1.disk=${3}G" ;;
284
+ *) refuse "only memory and disk can be set" ;;
285
+ esac ;;
286
+ "vm size")
287
+ # What the machine really has, as the VM manager says it: one line
288
+ # for its memory, one for its disk. Anything of an unexpected shape
289
+ # is left out rather than passed on.
290
+ [ $# -eq 1 ] || usage; vm_of "$1"
291
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "$MULTIPASS get local.$1.memory" "$MULTIPASS get local.$1.disk"; exit 0; fi
292
+ for key in memory disk; do
293
+ value="$("$MULTIPASS" get "local.$1.$key" 2>/dev/null | head -1 | tr -d ' ')" || value=""
294
+ if [[ "$value" =~ ^[0-9]{1,12}(\.[0-9]{1,3})?[A-Za-z]{0,3}$ ]]; then printf '%s %s\n' "$key" "$value"; fi
295
+ done
296
+ exit 0 ;;
297
+ "vm delete")
298
+ [ $# -eq 1 ] || usage; vm_of "$1"
299
+ run "$MULTIPASS" delete --purge "$1" ;;
300
+ "vm mount")
301
+ # <vm> <folder|view> <resource> <uid> <gid>
302
+ [ $# -eq 5 ] || usage; vm_of "$1"; is_name "$3" || refuse "not a folder name: $3"; number "$4"; number "$5"
303
+ target="$1:/resources/$3"
304
+ case "$2" in
305
+ folder) folder_of "$3"; source="$MNT/resources/$3" ;;
306
+ view) kept views; source="$MNT/views/$3"; held "$source"
307
+ # A read attachment must meet the VIEW, never a bare directory.
308
+ [ "$SAY" = "yes" ] || mountpoint -q "$source" || refuse "the read-only view of $3 is not in place" ;;
309
+ work) # The work folder of a conversation goes to ITS machine, and to no other.
310
+ [ "$1" = "clv-$3" ] || refuse "a work folder belongs to its own conversation"
311
+ kept work; source="$MNT/work/$3"; held "$source"
312
+ # Its DISK must be in place: a bare directory there would be the
313
+ # host's own disk, with no limit.
314
+ [ "$SAY" = "yes" ] || mountpoint -q "$source" || refuse "the work disk of $3 is not in place"
315
+ target="$1:/work" ;;
316
+ tools) # The tools every machine is given, through their read-only view only.
317
+ [ "$3" = "tools" ] || refuse "the tools are mounted by that name only"
318
+ kept toolbox; source="$MNT/toolbox/view"; held "$source"
319
+ [ "$SAY" = "yes" ] || mountpoint -q "$source" || refuse "the read-only view of the tools is not in place"
320
+ target="$1:/tools" ;;
321
+ *) refuse "a mount is of a folder, of a view, of a work folder or of the tools" ;;
322
+ esac
323
+ run "$MULTIPASS" mount -u "$4:$VM_ID" -g "$5:$VM_ID" "$source" "$target" ;;
324
+ "vm unwork")
325
+ # The work folder is taken out of its machine (its work moves to the
326
+ # machine's own disk, or a move is undone).
327
+ [ $# -eq 1 ] || usage; vm_of "$1"
328
+ run "$MULTIPASS" umount "$1:/work" ;;
329
+ "vm umount")
330
+ [ $# -eq 2 ] || usage; vm_of "$1"; is_name "$2" || refuse "not a folder name: $2"
331
+ run "$MULTIPASS" umount "$1:/resources/$2" ;;
332
+ "vm untools")
333
+ # The tools are taken out of a machine, to be mounted again (a mount
334
+ # that came loose: the VM manager may still believe it is there).
335
+ [ $# -eq 1 ] || usage; vm_of "$1"
336
+ run "$MULTIPASS" umount "$1:/tools" ;;
337
+ "shelf launch")
338
+ # A new ready-made machine, from the stock image. Refused here too when
339
+ # the disk is short, whatever the server said: a copy of it is made for
340
+ # every new conversation.
341
+ [ $# -eq 1 ] || usage; shelf_of "$1"
342
+ if [ "$SAY" != "yes" ]; then
343
+ free="$(store_free)"
344
+ [ "$free" -ge "$SHELF_MIN_FREE_MB" ] || refuse "only ${free} MB free for machines; a ready-made machine needs ${SHELF_MIN_FREE_MB} MB"
345
+ fi
346
+ run "$MULTIPASS" launch "$IMAGE" --name "$1" --memory "$SHELF_MEMORY" --disk "$DISK" --cpus 1 --timeout "$LAUNCH_SECONDS" ;;
347
+ "shelf exec")
348
+ # The preparation steps (the same argument vectors as a conversation's
349
+ # build), run in the ready-made machine only.
350
+ [ $# -ge 3 ] || usage; shelf_of "$1"; [ "$2" = "--" ] || refuse "exec takes: <shelf> -- <command...>"
351
+ vm="$1"; shift 2
352
+ run "$MULTIPASS" exec "$vm" -- "$@" ;;
353
+ "shelf seal")
354
+ # Prepared: what makes a machine ITSELF is wiped, so that every copy
355
+ # makes its own at its first start instead of being a twin of the
356
+ # others. `cloud-init clean` forgets this instance (its next start is
357
+ # a first start: new SSH host keys, network set for the copy's own
358
+ # address) and empties /etc/machine-id (a new one is made at start;
359
+ # two machines with the same one ask for the same address). Then it
360
+ # is stopped: only a stopped machine can be copied.
361
+ [ $# -eq 1 ] || usage; shelf_of "$1"
362
+ if [ "$SAY" = "yes" ]; then
363
+ printf '%s\n' "$MULTIPASS exec $1 -- sudo -n cloud-init clean --logs --machine-id" "$MULTIPASS stop $1"; exit 0
364
+ fi
365
+ "$MULTIPASS" exec "$1" -- sudo -n cloud-init clean --logs --machine-id
366
+ exec "$MULTIPASS" stop "$1" ;;
367
+ "shelf info")
368
+ [ $# -eq 1 ] || usage; shelf_of "$1"
369
+ run "$MULTIPASS" info "$1" --format json ;;
370
+ "shelf delete")
371
+ [ $# -eq 1 ] || usage; shelf_of "$1"
372
+ run "$MULTIPASS" delete --purge "$1" ;;
373
+ "shelf size")
374
+ # Megabytes it takes on the host (shown in Settings).
375
+ [ $# -eq 1 ] || usage; shelf_of "$1"
376
+ if [ "$SAY" = "yes" ]; then printf 'du -sm %s\n' "$SHELF_INSTANCES/$1"; exit 0; fi
377
+ [ -d "$SHELF_INSTANCES/$1" ] || refuse "no such ready-made machine: $1"
378
+ printf 'size %s\n' "$(du -sm "$SHELF_INSTANCES/$1" | cut -f1)"
379
+ exit 0 ;;
380
+ "shelf room")
381
+ [ $# -eq 0 ] || usage
382
+ if [ "$SAY" = "yes" ]; then printf 'df %s\n' "$SHELF_STORE"; exit 0; fi
383
+ printf 'free %s\n' "$(store_free)"
384
+ exit 0 ;;
385
+ "shelf list")
386
+ # The names of the ready-made machines that exist, and of no other
387
+ # machine: a rebuild cut short by a restart is found and removed.
388
+ [ $# -eq 0 ] || usage
389
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "$MULTIPASS list --format csv (ready-made machines only)"; exit 0; fi
390
+ "$MULTIPASS" list --format csv | cut -d, -f1 | grep -E '^clvshelf-[0-9]{8}-[0-9]{6}$' || true
391
+ exit 0 ;;
392
+ "server room")
393
+ # WHAT THE SERVER HAS LEFT, for Settings (This server) and for the
394
+ # reserve that creating, starting, waking or growing a machine must
395
+ # leave. Read-only, no argument, nothing asked of any machine (a
396
+ # machine asleep stays asleep). Numbers and our own names only:
397
+ # place <machines|data|folders> <device> <size MB> <free MB>
398
+ # memory <total MB> <available MB>
399
+ # machine <one of our VMs> <resident MB>
400
+ [ $# -eq 0 ] || usage
401
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "df $SHELF_STORE $DATA $MNT" "/proc/meminfo" "resident memory of the processes of our machines"; exit 0; fi
402
+ store="$SHELF_STORE"; [ -d "$store" ] || store="/var"
403
+ for place in "machines:$store" "data:$DATA" "folders:$MNT"; do
404
+ what="${place%%:*}"; at="${place#*:}"
405
+ [ -d "$at" ] || continue
406
+ sizes="$(df -Pm -- "$at" | awk 'NR==2 && $2 ~ /^[0-9]+$/ && $4 ~ /^[0-9]+$/ {print $2, $4}')" || sizes=""
407
+ [ -n "$sizes" ] && printf 'place %s %s %s\n' "$what" "$(stat -c %d -- "$at")" "$sizes"
408
+ done
409
+ awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} END{printf "memory %d %d\n", t/1024, a/1024}' /proc/meminfo
410
+ # A machine is a qemu process whose disk is in one of our instances.
411
+ for pid in $(pgrep qemu-system || true); do
412
+ vm="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null | grep -oE '/instances/clv(shelf)?-[a-z0-9-]+/' | head -1 | cut -d/ -f3)" || vm=""
413
+ [ -n "$vm" ] || continue
414
+ rss="$(awk '/^VmRSS:/{print int($2/1024)}' "/proc/$pid/status" 2>/dev/null)" || rss=""
415
+ [ -n "$rss" ] && printf 'machine %s %s\n' "$vm" "$rss"
416
+ done
417
+ exit 0 ;;
418
+ "server usage")
419
+ # WHAT TAKES THE ROOM, for Settings: this server's own data, the
420
+ # folders given to machines, their work disks, what was put aside,
421
+ # the tools; the disks of the machines, the ready-made machine and
422
+ # the images the VM manager keeps. Read-only, no argument; each
423
+ # measure bounded in time (slow: the server asks it now and then).
424
+ # used <data|folders|work|removed|tools|shelf|images|others> <MB>
425
+ # used machine <one of our VMs> <MB>
426
+ [ $# -eq 0 ] || usage
427
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "du $DATA $MNT/resources $MNT/work-disks $MNT/removed $MNT/toolbox/files" "du $SHELF_INSTANCES $SHELF_STORE/cache"; exit 0; fi
428
+ mb() { { timeout 60 du -smx "$@" 2>/dev/null || true; } | awk '$1 ~ /^[0-9]+$/ {s+=$1} END{print s+0}'; }
429
+ # The data directory may hold the shared folders (MNT the same, or
430
+ # under it): they are counted once, in their own lines.
431
+ printf 'used data %s\n' "$(mb --exclude="$MNT/resources" --exclude="$MNT/views" --exclude="$MNT/work" --exclude="$MNT/work-disks" --exclude="$MNT/removed" --exclude="$MNT/toolbox" -- "$DATA")"
432
+ for part in "folders:resources" "work:work-disks" "removed:removed" "tools:toolbox/files"; do
433
+ at="$MNT/${part#*:}"
434
+ if [ -d "$at" ] && [ ! -L "$at" ]; then printf 'used %s %s\n' "${part%%:*}" "$(mb -- "$at")"; fi
435
+ done
436
+ ours=0; shelf=0
437
+ if [ -d "$SHELF_INSTANCES" ]; then
438
+ for at in "$SHELF_INSTANCES"/clv-* "$SHELF_INSTANCES"/clvshelf-*; do
439
+ [ -d "$at" ] && [ ! -L "$at" ] || continue
440
+ vm="${at##*/}"; size="$(mb -- "$at")"
441
+ if [[ "$vm" =~ ^clvshelf-[0-9]{8}-[0-9]{6}$ ]]; then shelf=$((shelf + size)); ours=$((ours + size))
442
+ elif is_name "${vm#clv-}"; then printf 'used machine %s %s\n' "$vm" "$size"; ours=$((ours + size)); fi
443
+ done
444
+ all="$(mb -- "$SHELF_INSTANCES")"
445
+ printf 'used shelf %s\n' "$shelf"
446
+ printf 'used others %s\n' "$(( all > ours ? all - ours : 0 ))"
447
+ fi
448
+ [ -d "$SHELF_STORE/cache" ] && printf 'used images %s\n' "$(mb -- "$SHELF_STORE/cache")"
449
+ exit 0 ;;
450
+ "folder make")
451
+ # The folder is made here, and given to the account that owns the data
452
+ # directory: the server fills it, but cannot replace it.
453
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a folder name: $1"
454
+ kept resources; owner="OWNER:GROUP"
455
+ if [ "$SAY" != "yes" ]; then
456
+ [ ! -e "$MNT/resources/$1" ] && [ ! -L "$MNT/resources/$1" ] || refuse "this folder exists already: $1"
457
+ owner="$(stat -c %u:%g "$DATA")"
458
+ fi
459
+ run install -d -o "${owner%:*}" -g "${owner#*:}" -m 0755 "$MNT/resources/$1" ;;
460
+ "folder aside")
461
+ # Taken off the list: MOVED ASIDE, never erased.
462
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a folder name: $1"
463
+ folder_of "$1"; kept removed
464
+ if [ "$SAY" != "yes" ]; then
465
+ ! mountpoint -q "$MNT/views/$1" || refuse "the read-only view of $1 is still open"
466
+ fi
467
+ run mv -T -n "$MNT/resources/$1" "$MNT/removed/$1-$(date -u +%Y%m%dT%H%M%SZ)-$$" ;;
468
+ "work make")
469
+ # Where a conversation's tool works: kept by the server, so that what
470
+ # the tool makes is seen from the page and outlives its machine.
471
+ #
472
+ # IT IS A DISK OF ITS OWN, OF A FIXED SIZE: a file of this host,
473
+ # formatted and mounted. A shared folder has no limit by itself, and a
474
+ # tool that ran away would fill the host's disk and stop everything.
475
+ # Here it fills ITS disk, gets an error, and nobody else notices
476
+ # (tried on a real machine). The file takes room only as it is filled.
477
+ #
478
+ # Asked again at each start of the server and for a machine built
479
+ # again: what is already in place is left as it is.
480
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a conversation name: $1"
481
+ img="$MNT/work-disks/$1.img"; at="$MNT/work/$1"
482
+ if [ "$SAY" = "yes" ]; then printf 'disk %s %sM mounted at %s\n' "$img" "$WORK_MB" "$at"; exit 0; fi
483
+ kept work; kept work-disks
484
+ [ ! -L "$at" ] && [ ! -L "$img" ] || refuse "the work folder of $1 is not what it should be"
485
+ [ ! -e "$img" ] || [ -f "$img" ] || refuse "the work disk of $1 is not a plain file"
486
+ owner="$(stat -c %u:%g "$DATA")"
487
+ mkdir -p "$at"
488
+ held "$at"
489
+ if [ ! -e "$img" ]; then
490
+ truncate -s "${WORK_MB}M" "$img.new"
491
+ mkfs.ext4 -q -m 0 -E "root_owner=$owner" "$img.new"
492
+ # Files found in a plain directory (a backup just put back) go
493
+ # INTO the disk: mounting over them would hide them.
494
+ if ! mountpoint -q "$at" && [ -n "$(ls -A "$at")" ]; then
495
+ tmp="$(mktemp -d -p "$MNT/work-disks")"
496
+ mount -o loop,nosuid,nodev "$img.new" "$tmp"
497
+ cp -a "$at/." "$tmp/"; umount "$tmp"; rmdir "$tmp"
498
+ find "$at" -mindepth 1 -delete
499
+ fi
500
+ mv "$img.new" "$img"
501
+ fi
502
+ if ! mountpoint -q "$at"; then
503
+ mount -o loop,nosuid,nodev,discard "$img" "$at"
504
+ rmdir "$at/lost+found" 2>/dev/null || true
505
+ fi
506
+ exit 0 ;;
507
+ "work room")
508
+ # How much room is left, in megabytes: in the conversation's work
509
+ # disk, and on the host that holds it. Asked before a move, so that
510
+ # one is refused instead of filling either.
511
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a conversation name: $1"
512
+ at="$MNT/work/$1"
513
+ if [ "$SAY" = "yes" ]; then printf 'df %s %s\n' "$at" "$MNT/work-disks"; exit 0; fi
514
+ kept work; kept work-disks
515
+ mountpoint -q "$at" || refuse "the work disk of $1 is not in place"
516
+ # "host": the room left where the disks' files grow.
517
+ printf 'disk %s host %s\n' "$(df -m --output=avail "$at" | tail -1 | tr -d ' ')" "$(df -m --output=avail "$MNT/work-disks" | tail -1 | tr -d ' ')"
518
+ exit 0 ;;
519
+ "work aside")
520
+ # The conversation is archived: its work is PUT ASIDE, never erased --
521
+ # as its disk, which root can mount again to read it.
522
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a conversation name: $1"
523
+ img="$MNT/work-disks/$1.img"; at="$MNT/work/$1"
524
+ if [ "$SAY" = "yes" ]; then printf 'disk %s put aside in %s\n' "$img" "$MNT/removed"; exit 0; fi
525
+ kept work; kept work-disks; kept removed
526
+ if mountpoint -q "$at"; then umount "$at"; fi
527
+ if [ -d "$at" ] && [ ! -L "$at" ]; then rmdir "$at" 2>/dev/null || true; fi
528
+ if [ -f "$img" ] && [ ! -L "$img" ]; then mv -n "$img" "$MNT/removed/$1-work-$(date -u +%Y%m%dT%H%M%SZ)-$$.img"; fi
529
+ exit 0 ;;
530
+ "view make")
531
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a folder name: $1"
532
+ folder_of "$1"; kept views
533
+ if [ "$SAY" != "yes" ]; then
534
+ mkdir -p "$MNT/views/$1"
535
+ held "$MNT/views/$1"
536
+ mountpoint -q "$MNT/views/$1" && exit 0
537
+ fi
538
+ run bindfs -r -o allow_other "$MNT/resources/$1" "$MNT/views/$1" ;;
539
+ "tools make")
540
+ # THE TOOLS FOLDER: what every machine is given to install by itself
541
+ # (an archive and an index, put there by the server), seen in each
542
+ # machine at /tools through a READ-ONLY VIEW -- the one way a folder
543
+ # was proven to stay read-only against root in the machine (see
544
+ # "view make"). toolbox/files is the server's to fill; toolbox/view
545
+ # is root's. Asked at each start of the server: what is in place
546
+ # stays as it is.
547
+ [ $# -eq 0 ] || usage
548
+ files="$MNT/toolbox/files"; view="$MNT/toolbox/view"
549
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "install -d -o OWNER -g GROUP -m 0755 $files" "bindfs -r -o allow_other $files $view"; exit 0; fi
550
+ kept toolbox
551
+ [ ! -L "$files" ] && [ ! -L "$view" ] || refuse "the tools folder is not what it should be"
552
+ if [ ! -e "$files" ]; then owner="$(stat -c %u:%g "$DATA")"; install -d -o "${owner%:*}" -g "${owner#*:}" -m 0755 "$files"; fi
553
+ held "$files"
554
+ [ -e "$view" ] || install -d -o root -g root -m 0755 "$view"
555
+ held "$view"
556
+ mountpoint -q "$view" && exit 0
557
+ exec bindfs -r -o allow_other "$files" "$view" ;;
558
+ "view close")
559
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a folder name: $1"
560
+ kept views
561
+ if [ "$SAY" != "yes" ]; then
562
+ [ -e "$MNT/views/$1" ] || exit 0
563
+ held "$MNT/views/$1"
564
+ mountpoint -q "$MNT/views/$1" || exit 0
565
+ fi
566
+ run umount "$MNT/views/$1" ;;
567
+ "net taps")
568
+ [ $# -eq 0 ] || usage
569
+ run pgrep -a qemu-system ;;
570
+ "net bridge")
571
+ [ $# -eq 0 ] || usage
572
+ run ip -4 -o addr show "$BRIDGE" ;;
573
+ "net apply")
574
+ # <ruleset>: the whole table, as text. Each line must be one of the
575
+ # shapes the server writes; one unknown line refuses the whole.
576
+ [ $# -eq 1 ] || usage
577
+ tap='"tap-[0-9a-f]{1,12}"'; addr='[0-9]{1,3}(\.[0-9]{1,3}){3}'
578
+ while IFS= read -r line; do
579
+ [[ -z "$line"
580
+ || "$line" =~ ^(delete\ )?table\ bridge\ $TABLE(\ \{)?$
581
+ || "$line" =~ ^\ \ chain\ (between_vms|from_vm)\ \{$
582
+ || "$line" =~ ^\ \ \ \ type\ filter\ hook\ (forward|input)\ priority\ 0\;$
583
+ || "$line" =~ ^\ \ \ \ iifname\ $tap\ (drop|udp\ dport\ 67\ accept|ether\ type\ arp\ accept)$
584
+ || "$line" =~ ^\ \ \ \ iifname\ $tap\ ip\ daddr\ $addr\ (accept|meta\ l4proto\ \{\ tcp,\ udp\ \}\ th\ dport\ 53\ drop)$
585
+ || "$line" =~ ^(\ \ )?\}$ ]] || refuse "a network rule of an unknown shape: $line"
586
+ done <<< "$1"
587
+ run nft "$1" ;;
588
+ "git read")
589
+ # <name>: the branches of the agent's repository, as ONE FULL BUNDLE
590
+ # on stdout. The server never runs git on a folder the agent writes
591
+ # (its config, hooks, gitfile, links could make git do anything as the
592
+ # server); this verb runs ONE fixed read of it in a throw-away
593
+ # sandbox -- a dynamic user, no network, nothing writable, only that
594
+ # folder, read-only -- and the server treats what comes out as hostile.
595
+ #
596
+ # Exit codes, FIXED HERE (the code inside chooses only among 0, 65,
597
+ # 66, 67): 0 read; 65 git said 128 (with exactly the 17 bytes of an
598
+ # empty bundle: no branches); 66 git or the wrapper failed; 67 refs
599
+ # unreadable; 78 refused before anything ran; 79 the unit did not
600
+ # start or its state is unknown; 80 time limit; 81 memory limit;
601
+ # 82 killed by a signal.
602
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a folder name: $1"
603
+ case "$1" in *-origin) refuse "the reference of a repository is never read this way: $1" ;; esac
604
+ name="$1"
605
+ rand="RANDOM16"
606
+ [ "$SAY" = "yes" ] || rand="$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')"
607
+ unit="clvread-${name}_${rand}"
608
+ vector=(systemd-run --quiet --wait --pipe --service-type=exec "--unit=$unit"
609
+ -p DynamicUser=yes -p "RootDirectory=$RUNNER_ROOT" -p MountAPIVFS=no
610
+ -p "TemporaryFileSystem=/dev:ro /tmp:ro /var/tmp:ro /resources:ro" -p "BindPaths=/dev/null /dev/zero /dev/urandom"
611
+ -p DevicePolicy=closed -p "DeviceAllow=/dev/null rw" -p "DeviceAllow=/dev/zero r" -p "DeviceAllow=/dev/urandom r"
612
+ -p "BindReadOnlyPaths=/usr /usr/bin:/bin /usr/lib:/lib /usr/lib64:/lib64 $MNT/resources/$name:/resources/$name"
613
+ -p PrivateNetwork=yes -p RestrictAddressFamilies=AF_UNIX -p NoNewPrivileges=yes
614
+ -p CapabilityBoundingSet= -p AmbientCapabilities= -p RestrictNamespaces=yes -p RestrictSUIDSGID=yes
615
+ -p RestrictRealtime=yes -p LockPersonality=yes -p MemoryDenyWriteExecute=yes
616
+ -p SystemCallArchitectures=native -p SystemCallFilter=@system-service
617
+ -p "SystemCallFilter=~@aio @keyring @setuid @chown @memlock @privileged @resources userfaultfd kcmp process_vm_readv process_vm_writev msgget msgsnd msgrcv msgctl semget semop semctl shmget shmat shmdt shmctl mq_open mq_unlink"
618
+ -p SystemCallErrorNumber=EPERM
619
+ -p RuntimeMaxSec=300 -p MemoryMax=1G -p MemorySwapMax=0 -p TasksMax=8 -p CPUQuota=100%
620
+ -p LimitFSIZE=0 -p LimitCORE=0 -p LimitNOFILE=256 -p "WorkingDirectory=/resources/$name"
621
+ -E PATH=/usr/bin -E HOME=/nonexistent -E XDG_CONFIG_HOME=/nonexistent -E LC_ALL=C -E GIT_CONFIG_NOSYSTEM=1
622
+ -E GIT_CONFIG_GLOBAL=/dev/null -E GIT_CEILING_DIRECTORIES=/resources -E GIT_NO_LAZY_FETCH=1
623
+ -E GIT_TERMINAL_PROMPT=0 -E GIT_OPTIONAL_LOCKS=0 -E GIT_NO_REPLACE_OBJECTS=1
624
+ "$GIT_READ" "$name")
625
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "${vector[@]}"; exit 0; fi
626
+ folder_of "$name"
627
+ [ -d "$RUNNER_ROOT" ] && [ ! -L "$RUNNER_ROOT" ] && [ -x "$GIT_READ" ] || refuse "the git reader is not installed (run install.sh again)"
628
+ # Nothing to read from us; and what comes out must go to the server
629
+ # as it is: a regular file would be killed by LimitFSIZE=0 (tried).
630
+ # A pipe, or a socket (what Node.js gives a child for "pipe").
631
+ exec 0</dev/null
632
+ { [ -p /dev/stdout ] || [ -S /dev/stdout ]; } && { [ -p /dev/stderr ] || [ -S /dev/stderr ]; } || refuse "stdout and stderr must be pipes"
633
+ install -d -o root -g root -m 0700 "$LOCKS"
634
+ exec 9>"$LOCKS/read-$name.lock"
635
+ flock -n 9 || refuse "busy: $name is being read already"
636
+ # Stopped whatever happens to us (the server gave up, a signal, an
637
+ # error here): a read never outlives the one who asked for it. The
638
+ # unit runs as a child we WAIT for: bash runs a trap only between
639
+ # commands, and `wait` is a command a signal cuts short, where a
640
+ # plain systemd-run in the foreground would hold the trap back
641
+ # until the read ended by itself.
642
+ child=""
643
+ stop_unit() { systemctl stop "$unit.service" >/dev/null 2>&1 || true; systemctl reset-failed "$unit.service" >/dev/null 2>&1 || true; }
644
+ gone() { stop_unit; [ -z "$child" ] || wait "$child" 2>/dev/null; exit 82; }
645
+ trap gone TERM INT HUP PIPE
646
+ trap stop_unit EXIT
647
+ set +e
648
+ "${vector[@]}" &
649
+ child=$!
650
+ wait "$child"
651
+ rc=$?
652
+ set -e
653
+ code=0
654
+ if [ "$rc" -ne 0 ]; then
655
+ props="$(systemctl show -p Result -p ExecMainCode -p ExecMainStatus "$unit.service" 2>/dev/null || true)"
656
+ code="$(unit_code "$(sed -n 's/^Result=//p' <<< "$props")" "$(sed -n 's/^ExecMainCode=//p' <<< "$props")" "$(sed -n 's/^ExecMainStatus=//p' <<< "$props")")"
657
+ fi
658
+ trap - TERM INT HUP PIPE EXIT
659
+ stop_unit
660
+ exit "$code" ;;
661
+ "git stop")
662
+ # <name>: every read of that repository is stopped, and forgotten
663
+ # (asked after the server gave up on a read, and at its start).
664
+ [ $# -eq 1 ] || usage; is_name "$1" || refuse "not a folder name: $1"
665
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "systemctl stop clvread-$1_*.service" "systemctl reset-failed clvread-$1_*.service"; exit 0; fi
666
+ systemctl stop "clvread-$1_*.service" >/dev/null 2>&1 || true
667
+ systemctl reset-failed "clvread-$1_*.service" >/dev/null 2>&1 || true
668
+ exit 0 ;;
669
+ "update check")
670
+ # Asks the registry what is out on the channel, now, and prints what
671
+ # the updater knows (state.json). No argument: the registry, the name
672
+ # of the package and the channel are the updater's own, not asked.
673
+ [ $# -eq 0 ] || usage
674
+ run "$UPDATER_NODE" "$UPDATER" check ;;
675
+ "update set")
676
+ # <mode|channel|window> <value>: one setting of /etc/clavarium/update.conf.
677
+ [ $# -eq 2 ] || usage
678
+ case "$1" in
679
+ mode) [[ "$2" =~ ^(auto|asap|ask|off)$ ]] || refuse "not a mode: $2" ;;
680
+ channel) [[ "$2" =~ ^(latest|next)$ ]] || refuse "not a channel: $2" ;;
681
+ window) [[ "$2" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$ ]] && [ "${2%-*}" != "${2#*-}" ] || refuse "not a window: $2" ;;
682
+ *) refuse "not a setting: $1" ;;
683
+ esac
684
+ run "$UPDATER_NODE" "$UPDATER" set "$1" "$2" ;;
685
+ "update apply"|"update rollback")
686
+ # <version>: done by the updater's own service, in the background, so
687
+ # that restarting this server does not cut it. The updater checks it
688
+ # again: apply only to a newer version, from the registry, verified;
689
+ # rollback only to a release that is kept.
690
+ [ $# -eq 1 ] || usage
691
+ [[ "$1" =~ ^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,40})?$ ]] || { [ "$VERB" = "rollback" ] && [[ "$1" =~ ^local-[0-9]{8}-[0-9]{6}$ ]]; } || refuse "not a version: $1"
692
+ if [ "$SAY" = "yes" ]; then printf '%s\n' "echo $VERB $1 > $UPDATE_STATE/request" "systemctl start --no-block clavarium-update.service"; exit 0; fi
693
+ install -d -o root -g root -m 0755 "$UPDATE_STATE"
694
+ printf '%s %s\n' "$VERB" "$1" > "$UPDATE_STATE/request.new"
695
+ mv -f -- "$UPDATE_STATE/request.new" "$UPDATE_STATE/request"
696
+ exec systemctl start --no-block clavarium-update.service ;;
697
+ "path held")
698
+ # <dir>: the check `held` makes, run for real on that path, as the
699
+ # caller (root not needed to refuse). --say-only only: it exists so
700
+ # that the check can be tested without root; it changes nothing.
701
+ [ "$SAY" = "yes" ] || refuse "unknown verb: path held"
702
+ [ $# -eq 1 ] || usage
703
+ held_check "$1"; echo "held: $1"; exit 0 ;;
704
+ "disk room")
705
+ # <has GB> <wants GB> <free MB>: the check `vm set disk` makes, with
706
+ # these figures. --say-only only: it exists so that the check can be
707
+ # tested without a VM manager; it changes nothing.
708
+ [ "$SAY" = "yes" ] || refuse "unknown verb: disk room"
709
+ [ $# -eq 3 ] || usage; number "$1"; number "$2"; [[ "$3" =~ ^[0-9]{1,9}$ ]] || refuse "not a number: $3"
710
+ grow_room "$1" "$2" "$3"; echo "room: $2 GB"; exit 0 ;;
711
+ "git code")
712
+ # <Result> <ExecMainCode> <ExecMainStatus>: the exit code "git read"
713
+ # gives for a unit that ended so. --say-only only: it exists so that
714
+ # the mapping can be tested without root; it does nothing.
715
+ [ "$SAY" = "yes" ] || refuse "unknown verb: git code"
716
+ [ $# -eq 3 ] || usage
717
+ unit_code "$1" "$2" "$3"; exit 0 ;;
718
+ *)
719
+ refuse "unknown verb: $AREA $VERB" ;;
720
+ esac