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.
- package/LICENSE +21 -0
- package/README.md +265 -6
- package/config/tools.json +172 -0
- package/deploy/appliance.sh +83 -0
- package/deploy/backup.sh +97 -0
- package/deploy/clavarium-host +720 -0
- package/deploy/clavarium.mjs +30 -0
- package/deploy/git-read +85 -0
- package/deploy/install.sh +659 -0
- package/deploy/move-to-mnt.sh +142 -0
- package/deploy/parley-archive.mjs +47 -0
- package/deploy/relay.sh +156 -0
- package/deploy/restore.sh +147 -0
- package/deploy/updater.mjs +600 -0
- package/npm-shrinkwrap.json +1951 -0
- package/package.json +53 -8
- package/server/alerts.mjs +83 -0
- package/server/answerer.mjs +326 -0
- package/server/api.mjs +458 -0
- package/server/bridge.mjs +271 -0
- package/server/capacity.mjs +220 -0
- package/server/conversations.mjs +895 -0
- package/server/dataformat.mjs +59 -0
- package/server/devices.mjs +257 -0
- package/server/door.mjs +235 -0
- package/server/files.mjs +455 -0
- package/server/gate.mjs +54 -0
- package/server/gitaccess.mjs +295 -0
- package/server/gitchecks.mjs +126 -0
- package/server/gitkeeper.mjs +719 -0
- package/server/gitpush.mjs +585 -0
- package/server/gitrunner.mjs +540 -0
- package/server/grants.mjs +110 -0
- package/server/guide.mjs +278 -0
- package/server/health.mjs +207 -0
- package/server/imap.mjs +75 -0
- package/server/mailbox.mjs +165 -0
- package/server/main.mjs +420 -0
- package/server/names.mjs +10 -0
- package/server/network.mjs +85 -0
- package/server/parley.mjs +62 -0
- package/server/resources.mjs +396 -0
- package/server/seats.mjs +24 -0
- package/server/secrets.mjs +118 -0
- package/server/setup.mjs +768 -0
- package/server/shelf.mjs +260 -0
- package/server/shortcuts.mjs +62 -0
- package/server/signin.mjs +185 -0
- package/server/smoke.mjs +26 -0
- package/server/store.mjs +48 -0
- package/server/talk.mjs +514 -0
- package/server/tmux.mjs +54 -0
- package/server/toolbox.mjs +168 -0
- package/server/tools.mjs +109 -0
- package/server/updates.mjs +116 -0
- package/server/vault.mjs +37 -0
- package/server/vm.mjs +139 -0
- package/server/voice.mjs +90 -0
- package/server/wakeups.mjs +320 -0
- package/web/app.js +4779 -0
- package/web/device.html +149 -0
- package/web/device.js +152 -0
- package/web/icon-192.png +0 -0
- package/web/icon-512.png +0 -0
- package/web/icon.svg +1 -0
- package/web/icons.js +9 -0
- package/web/index.html +784 -0
- package/web/links.js +995 -0
- package/web/manifest.webmanifest +30 -0
- package/web/prism-manual.js +3 -0
- package/web/style.css +1006 -0
- package/web/sw.js +26 -0
- package/web/voice-core.js +107 -0
- package/web/voice.js +56 -0
- 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
|