Files
2026-09-15 15:24:03 -04:00

380 lines
20 KiB
Bash
Executable File

#!/bin/bash
# =====================================================================
# Installs the host side of the VM sandbox. Idempotent: run it again
# after editing anything in this directory.
#
# Everything here is versioned in the repo and copied into place, rather
# than edited live, so that this directory is the source of truth and
# `install.sh` is the only thing that has ever touched
# /etc/systemd/system. The archived sub-agent stack was bitten twice by
# the opposite arrangement, where a redeploy regenerated config that had
# been hand-edited on disk.
# =====================================================================
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
[ "$(id -u)" -eq 0 ] || { echo "install.sh must run as root" >&2; exit 1; }
# --- who operates this machine ----------------------------------------
# The operator gets the desktop shortcut and is the person polkit lets
# unfreeze a base image. It used to be the literal string `operator` in
# three files, which is fine on one host and wrong on every other.
#
# SUDO_USER is the right source: this script runs as root via sudo, so it
# names the human who asked for it. logname is the fallback for a real
# root login. configs/host.env overrides both, for the case where the
# person installing is not the person who will use it.
CONFIG_DIR="$(cd ../../configs 2>/dev/null && pwd || true)"
[ -n "$CONFIG_DIR" ] && [ -f "$CONFIG_DIR/host.env" ] && . "$CONFIG_DIR/host.env"
OPERATOR=${OWUIVM_OPERATOR:-${SUDO_USER:-$(logname 2>/dev/null || true)}}
if [ -z "$OPERATOR" ] || [ "$OPERATOR" = "root" ]; then
echo "FAILED: cannot tell who the operator is." >&2
echo " Run this with sudo from your own account, or set OWUIVM_OPERATOR" >&2
echo " in configs/host.env. Whoever it is gets the desktop shortcut and" >&2
echo " is the only account polkit will let unfreeze a base image." >&2
exit 1
fi
getent passwd "$OPERATOR" >/dev/null || {
echo "FAILED: there is no user '$OPERATOR' on this machine" >&2; exit 1; }
OPERATOR_HOME=$(getent passwd "$OPERATOR" | cut -d: -f6)
POOL_ROOT=$(printf '%s' "${OWUIVM_POOL_ROOT:-/Pool}" | sed 's:/*$::')
[ -n "$POOL_ROOT" ] || POOL_ROOT=/Pool
# --- how much of the machine one VM may take --------------------------
# Derived from what is actually here rather than from the machine this
# was written on, which had 28 threads and 58 GB. Ollama needs cores for
# sampling while a session runs, so taking every core makes both slower.
TOTAL_MB=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
CPUS_TOTAL=$(nproc)
VM_MEM_MB=${OWUIVM_VM_MEM_MB:-$(( TOTAL_MB/2 < 8192 ? TOTAL_MB/2 : 8192 ))}
VM_CPUS=${OWUIVM_VM_CPUS:-$(( CPUS_TOTAL-4 < 8 ? (CPUS_TOTAL-4 < 2 ? 2 : CPUS_TOTAL-4) : 8 ))}
# The cgroup ceilings sit above the guest's own figures, because qemu's
# overhead is outside the guest's RAM and would otherwise push the unit
# over its own limit and get it killed.
VM_MEMORY_MAX=${OWUIVM_MEMORY_MAX:-$(( VM_MEM_MB/1024 + 3 ))G}
VM_CPU_QUOTA=${OWUIVM_CPU_QUOTA:-$(( (VM_CPUS-1) * 100 ))%}
MAINT_CPU_QUOTA=${OWUIVM_MAINT_CPU_QUOTA:-$(( VM_CPUS * 100 ))%}
# Empty means no pinning, which is right on a machine that is not this
# one. Pinning matters when something else is using specific cores.
VM_ALLOWED_CPUS=${OWUIVM_ALLOWED_CPUS:-}
# --- what the daemon is allowed to reach ------------------------------
# vmd runs with IPAddressDeny=any and an allowlist. Gitea is not on it by
# default, because a fresh install has no Gitea: publishing is optional
# and the daemon should not be given reach it does not need.
#
# BOTH the LAN address and the container's own network are needed when
# you do add it - systemd's filter is a BPF program on the cgroup's
# packets, and Docker's DNAT has already rewritten the destination by the
# time it sees them. See docs/setup/gitea.md.
if [ -n "${OWUIVM_GITEA_ALLOW:-}" ]; then
GITEA_ALLOW=$(printf '%s\n' $OWUIVM_GITEA_ALLOW | sed 's/^/IPAddressAllow=/')
else
GITEA_ALLOW="# No Gitea configured. See docs/setup/gitea.md to add one."
fi
TOOLS_SUBNET=${OWUIVM_TOOLS_SUBNET:-172.22.0.0/24}
echo "==> operator: $OPERATOR pool: $POOL_ROOT"
echo " vm: ${VM_MEM_MB}MB / ${VM_CPUS} cpus ceilings: $VM_MEMORY_MAX $VM_CPU_QUOTA ${VM_ALLOWED_CPUS:+cpus $VM_ALLOWED_CPUS}"
# Renders the @PLACEHOLDER@ tokens in a versioned file and installs the
# result. Everything in vm/host that mentions a path, a size or an
# address this host chose goes through here, so the repo stays free of
# any one machine's numbers.
render() {
local src="$1" dst="$2" mode="${3:-0644}" tmp allow
tmp=$(mktemp); allow=$(mktemp)
printf '%s\n' "$GITEA_ALLOW" > "$allow"
# The multi-line one first, by file, because a sed replacement cannot
# carry newlines portably.
sed -e "/@GITEA_ALLOW@/{r $allow" -e "d;}" "$src" > "$tmp"
sed -i -e "s|@POOL_ROOT@|$POOL_ROOT|g" \
-e "s|@OPERATOR@|$OPERATOR|g" \
-e "s|@VM_MEMORY_MAX@|$VM_MEMORY_MAX|g" \
-e "s|@VM_CPU_QUOTA@|$VM_CPU_QUOTA|g" \
-e "s|@MAINT_CPU_QUOTA@|$MAINT_CPU_QUOTA|g" \
-e "s|@TOOLS_SUBNET@|$TOOLS_SUBNET|g" "$tmp"
# An empty AllowedCPUs= RESETS the property rather than leaving it
# alone, so with no pinning the line is removed rather than blanked.
if [ -z "$VM_ALLOWED_CPUS" ]; then
sed -i '/^AllowedCPUs=@VM_ALLOWED_CPUS@$/d' "$tmp"
else
sed -i "s|@VM_ALLOWED_CPUS@|$VM_ALLOWED_CPUS|g" "$tmp"
fi
# A placeholder that survived means a value was added to a template and
# not to this function. Failing here is cheap; finding out later,
# because a unit refused to start with a syntax error, is not.
if grep -q '@[A-Z_]\+@' "$tmp"; then
echo "FAILED: $src has an unrendered placeholder:" >&2
grep -o '@[A-Z_]\+@' "$tmp" | sort -u | sed 's/^/ /' >&2
rm -f "$tmp" "$allow"; exit 1
fi
install -o root -g root -m "$mode" "$tmp" "$dst"
rm -f "$tmp" "$allow"
}
echo "==> preflight"
# EVERY REQUIREMENT CHECKED HERE, BEFORE ANYTHING IS CREATED.
#
# This script makes users, datasets, units and firewall rules. Finding
# out at step nine that swtpm is missing leaves a half-installed host and
# a confusing error; the point of this block is that it either all works
# or nothing has happened yet.
missing=""
for cmd in qemu-system-x86_64 qemu-img swtpm socat nft setfacl visudo zfs python3; do
command -v "$cmd" >/dev/null 2>&1 || missing="$missing $cmd"
done
# virtiofsd moved between packages and distributions; both locations count.
[ -x /usr/libexec/virtiofsd ] || [ -x /usr/lib/qemu/virtiofsd ] \
|| command -v virtiofsd >/dev/null 2>&1 || missing="$missing virtiofsd"
if [ -n "$missing" ]; then
echo "FAILED: missing:$missing" >&2
echo " On Ubuntu: sudo apt install qemu-system-x86 qemu-utils virtiofsd ovmf \\" >&2
echo " swtpm-tools socat nftables acl zfsutils-linux" >&2
exit 1
fi
# The firmware. Windows needs the Secure Boot pair specifically, and a
# missing one of those fails much later, as a machine that will not
# install, with an error that looks like hardware.
for fw in /usr/share/OVMF/OVMF_CODE_4M.fd /usr/share/OVMF/OVMF_VARS_4M.fd; do
[ -f "$fw" ] || { echo "FAILED: $fw is missing - install the 'ovmf' package" >&2; exit 1; }
done
for fw in /usr/share/OVMF/OVMF_CODE_4M.secboot.fd /usr/share/OVMF/OVMF_VARS_4M.ms.fd; do
[ -f "$fw" ] || echo " note: $fw is missing; Windows guests will not install (Ubuntu is fine)"
done
[ -e /dev/kvm ] || { echo "FAILED: /dev/kvm is missing. This host cannot run VMs -" >&2
echo " check virtualisation is enabled in firmware, and that kvm modules load." >&2
exit 1; }
# The pool. Its quotas are what stop a runaway guest filling the root
# filesystem and taking Docker, Ollama and Open WebUI down with it, so a
# plain directory is not a substitute.
if ! zfs list -H -o name "${POOL_ROOT#/}" >/dev/null 2>&1; then
echo "FAILED: no ZFS pool named '${POOL_ROOT#/}' (looked because OWUIVM_POOL_ROOT=$POOL_ROOT)." >&2
echo " Run 'zfs list' to see what you have, then set OWUIVM_POOL_ROOT in" >&2
echo " configs/host.env to the mountpoint of the pool you want to use." >&2
exit 1
fi
for ds in vmprojects vmrun; do
zfs list -H -o name "${POOL_ROOT#/}/$ds" >/dev/null 2>&1 || {
echo " creating dataset ${POOL_ROOT#/}/$ds"
zfs create -o exec=off -o setuid=off -o devices=off "${POOL_ROOT#/}/$ds"
}
done
# The overlay dataset carries a quota so a runaway guest hits ENOSPC
# inside its own ceiling rather than filling the pool.
zfs set quota="${OWUIVM_OVERLAY_QUOTA:-40G}" "${POOL_ROOT#/}/vmrun"
echo "==> users and groups"
getent passwd owuivm >/dev/null || useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin --user-group owuivm
getent passwd owuivmd >/dev/null || useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin --user-group owuivmd
getent passwd owuivmadm >/dev/null || useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin --user-group owuivmadm
# gid 10001 is the vmagent container's uid:gid. No userns remapping is
# configured on this host, so container uid 10001 IS host uid 10001, and
# that is what makes a mode-0660 socket directory a real boundary.
getent group owuitools-agent >/dev/null || groupadd --system --gid 10001 owuitools-agent
usermod -aG kvm owuivm
usermod -aG owuivm,owuitools-agent owuivmd
# The maintenance unit runs as owuivmadm and has to create its own
# session directory under /run/owuivm, which is owned by vmd.
usermod -aG owuivm owuivmadm
echo "==> directories"
# Base images are readable by everyone and writable by owuivmadm alone.
# "Frozen" is literally the owner write bit: the maintenance unit runs
# `chmod u+w` to unfreeze and `chmod u-w` to refreeze, so the state is
# visible in `ls -l` and the agent's uid can never be the one to change
# it - owuivm is not the owner and is not in the group.
install -d -o root -g root -m 0755 /var/lib/owuivm
# 0775 and operator in the group: the operator installs and edits base
# images, so they need to create a disk and drop an install marker
# beside it. This does not weaken the freeze - the freeze exists to stop
# the AGENT, which runs as owuivm and is in neither the owner nor the
# group, and which has no verb that asks anything else to change a base.
# The operator already has passwordless sudo; pretending otherwise here
# would be theatre.
install -d -o owuivmadm -g owuivmadm -m 0775 /var/lib/owuivm/base
usermod -aG owuivmadm "$OPERATOR" 2>/dev/null || true
install -d -o owuivmadm -g owuivmadm -m 0755 /var/lib/owuivm/iso
# Where the maintenance app stages an installation disc before the
# privileged unit checks it. OWNED BY THE OPERATOR, group owuivmadm: the
# operator writes it as owner - so copying out of their own home needs no
# privilege and no group that would only take effect at their next login
# - and the unit reads it through the group. See owuivm-insert-media.
install -d -o "$OPERATOR" -g owuivmadm -m 0770 /var/lib/owuivm/incoming
install -d -o owuivmd -g owuivmd -m 0750 /var/lib/owuivm/state
# What each session actually did, kept after the machine is gone.
# See vm/vmd/sessionlog.py - the transcript used to die with the VM.
install -d -o owuivmd -g owuivmd -m 0750 /var/lib/owuivm/sessions
install -d -o root -g root -m 0755 /usr/local/libexec
# /Pool is 0770 root:docker - neither service user can traverse it, and
# adding them to `docker` would hand them the docker socket, which is
# root. A traverse-only ACL is the whole of what they actually need:
# they reach their own datasets and still cannot list the parent.
echo "==> $POOL_ROOT traversal"
# owuivmadm is on this list because a FROZEN maintenance boot writes its
# throwaway disk to /Pool/vmrun, the same dataset and the same 40 GB
# quota a session's overlay uses. Traverse only: the directories below
# carry their own permissions and none of them operator it anything here.
setfacl -m u:owuivmd:--x,u:owuivm:--x,u:owuivmadm:--x "$POOL_ROOT"
for u in owuivmd owuivm owuivmadm; do
getfacl -p "$POOL_ROOT" | grep -q "^user:$u:--x" || { echo "FAILED: $POOL_ROOT ACL did not apply for $u" >&2; exit 1; }
done
echo "==> scripts"
install -o root -g root -m 0755 owuivm-guest-run /usr/local/bin/owuivm-guest-run
render owuivm-launch /usr/local/libexec/owuivm-launch 0755
render owuivm-teardown /usr/local/libexec/owuivm-teardown 0755
render owuivm-maint-prepare /usr/local/libexec/owuivm-maint-prepare 0755
install -o root -g root -m 0755 owuivm-insert-media /usr/local/libexec/owuivm-insert-media
install -o root -g root -m 0755 owuivm-set-name /usr/local/libexec/owuivm-set-name
install -d -o root -g root -m 0755 /usr/local/sbin
render owuivm-ctl /usr/local/sbin/owuivm-ctl 0755
echo "==> sudoers"
# visudo -c first: a malformed sudoers file locks every sudo on the host,
# and installing it and finding out afterwards is not recoverable from a
# non-root shell.
visudo -cf owuivm.sudoers >/dev/null
install -o root -g root -m 0440 owuivm.sudoers /etc/sudoers.d/owuivm
echo "==> apparmor (swtpm)"
# Ubuntu's swtpm profile allows libvirt's paths only. Without this, the
# software TPM cannot create its socket and Windows 11 refuses to
# install - with an error that looks like a permission problem on the
# directory rather than a policy denial.
if [ -d /etc/apparmor.d/local ]; then
install -o root -g root -m 0644 apparmor-local-swtpm /etc/apparmor.d/local/usr.bin.swtpm
apparmor_parser -r /etc/apparmor.d/usr.bin.swtpm 2>/dev/null || true
fi
echo "==> nftables guard"
install -d -o root -g root -m 0755 /etc/nftables.d
install -o root -g root -m 0644 60-owuivm.nft /etc/nftables.d/60-owuivm.nft
nft -c -f /etc/nftables.d/60-owuivm.nft
echo "==> vmd"
# The daemon's code lives under /usr/local/lib, root-owned: vmd runs as
# owuivmd and must not be able to rewrite the program it is running, nor
# the one root runs through sudo.
install -d -o root -g root -m 0755 /usr/local/lib/owuivm
for f in ../vmd/*.py; do install -o root -g root -m 0644 "$f" /usr/local/lib/owuivm/; done
install -o root -g root -m 0755 ../vmd/vmctl /usr/local/bin/vmctl
install -d -o root -g owuivmd -m 0750 /etc/owuivm
# The daemon's settings come from configs/vmd.env, and are installed only
# if they are not already there: the live copy holds the Gitea token, and
# re-running this script must never wipe it. Edit /etc/owuivm/vmd.env to
# change the running daemon.
if [ ! -f /etc/owuivm/vmd.env ]; then
install -o root -g owuivmd -m 0640 ../../configs/vmd.env /etc/owuivm/vmd.env
echo " wrote /etc/owuivm/vmd.env - fill in OWUITOOLS_GITEA_TOKEN and"
echo " OWUITOOLS_AUTH_TOKEN, or the agent publishes nothing and has no"
echo " lookup tools. See docs/setup/gitea.md."
fi
# The pool has to reach the daemon too, and unlike the rest of vmd.env it
# is not something the operator types - it is whatever this script was
# told. Appended rather than written, so re-running never disturbs the
# token beside it.
if ! grep -q '^OWUIVM_POOL_ROOT=' /etc/owuivm/vmd.env; then
printf '\n# Where overlays and project datasets live. Set by install.sh.\nOWUIVM_POOL_ROOT=%s\n' \
"$POOL_ROOT" >> /etc/owuivm/vmd.env
echo " recorded OWUIVM_POOL_ROOT=$POOL_ROOT in /etc/owuivm/vmd.env"
fi
# The machine registry lives HERE and not in /etc/owuivm, which is 0750
# root:owuivmd - only the daemon can even traverse it. Four different
# accounts need to read this file (the operator's desktop app, the
# maintenance units as owuivmadm, vmd, and the container), and the
# maintenance units need to WRITE the renames beside it. Measured the
# hard way: with it in /etc/owuivm the app could not read its own
# machine list and the rename unit died on `mktemp: permission denied`.
#
# Owned by owuivmadm so the rename unit can write vm-names.toml here,
# world-readable because none of it is a secret.
install -d -o owuivmadm -g owuivmadm -m 0755 /var/lib/owuivm/registry
# Overwritten on every install, unlike vmd.env: it carries no secret, and
# the operator's renames live in vm-names.toml beside it, which is merged
# over the top and is NOT touched here.
install -o owuivmadm -g owuivmadm -m 0644 ../../configs/vms.toml \
/var/lib/owuivm/registry/vms.toml
# The tool switch, so vmd offers the sub-agent the same lookup tools the
# server is actually serving. Same reasoning: a tool switched off in one
# place and offered in the other is a 404 that reads as a broken tool.
install -o root -g root -m 0644 ../../configs/tools.toml /etc/owuivm/tools.toml
echo "==> the sandbox's resolver"
# Bind-mounted over /etc/resolv.conf inside the VM units, so SLIRP's
# built-in forwarder relays to a public resolver instead of the host's -
# which the sandbox denies, and which knows the LAN's internal names.
install -o root -g root -m 0644 vm-resolv.conf /etc/owuivm/resolv.conf
echo "==> systemd"
render owuivmd.service /etc/systemd/system/owuivmd.service
render owuivm-tmpfiles.conf /usr/lib/tmpfiles.d/owuivm.conf
install -o root -g root -m 0644 owuivm-nft-guard.service /etc/systemd/system/owuivm-nft-guard.service
render owuivm.service /etc/systemd/system/owuivm.service
render owuivm-maint@.service /etc/systemd/system/owuivm-maint@.service
install -o root -g root -m 0644 owuivm-detach@.service /etc/systemd/system/owuivm-detach@.service
install -o root -g root -m 0644 owuivm-thaw@.service /etc/systemd/system/owuivm-thaw@.service
install -o root -g root -m 0644 owuivm-freeze@.service /etc/systemd/system/owuivm-freeze@.service
install -o root -g root -m 0644 owuivm-insert@.service /etc/systemd/system/owuivm-insert@.service
install -o root -g root -m 0644 owuivm-rename@.service /etc/systemd/system/owuivm-rename@.service
echo "==> maintenance tool (desktop)"
# Authorises an ACTIVE LOCAL SESSION of the operator to start the
# maintenance unit - not a group, which a background process would
# inherit. This is the gate an unfreeze passes through.
install -d -o root -g root -m 0755 /etc/polkit-1/rules.d
# The operator's name is substituted in rather than committed: the rule
# is what decides who may unfreeze a base image, so it has to name a real
# account on THIS machine.
render 50-owuivm.rules /etc/polkit-1/rules.d/50-owuivm.rules
install -o root -g root -m 0755 ../gui/owuivm-maintenance.py /usr/local/bin/owuivm-maintenance
install -d -o root -g root -m 0755 /usr/local/share/applications
install -o root -g root -m 0644 ../gui/owuivm-maintenance.desktop \
/usr/local/share/applications/owuivm-maintenance.desktop
# The shortcut the operator actually asked for. Written as the desktop
# user, marked trusted, or GNOME shows it as an untrusted text file.
if [ -n "$OPERATOR_HOME" ] && [ -d "$OPERATOR_HOME/Desktop" ]; then
install -o "$OPERATOR" -g "$OPERATOR" -m 0755 ../gui/owuivm-maintenance.desktop \
"$OPERATOR_HOME/Desktop/owuivm-maintenance.desktop"
su - "$OPERATOR" -c \
"gio set '$OPERATOR_HOME/Desktop/owuivm-maintenance.desktop' metadata::trusted true" \
>/dev/null 2>&1 || true
fi
systemd-tmpfiles --create /usr/lib/tmpfiles.d/owuivm.conf
systemctl daemon-reload
systemctl enable --now owuivm-nft-guard.service
systemctl enable --now owuivmd.service
sleep 1
systemctl is-active --quiet owuivmd.service || {
echo "FAILED: owuivmd did not start" >&2
journalctl -u owuivmd.service -n 20 --no-pager >&2
exit 1
}
echo "==> verify the guard actually took"
# A firewall that silently failed to apply is worse than none, because
# everything downstream carries on as though it were enforced. Same
# assertion netguard/firewall.sh makes about its own rules.
nft list table inet owuivm_guard >/dev/null
nft list table inet owuivm_guard | grep -q 'meta skuid' \
|| { echo "FAILED: owuivm_guard loaded but has no skuid rule" >&2; exit 1; }
echo
echo "host side installed."
echo " base images /var/lib/owuivm/base (0444 = frozen, owner owuivmadm)"
echo " overlays $POOL_ROOT/vmrun"
echo " projects $POOL_ROOT/vmprojects"
echo " guard table inet owuivm_guard (second layer; primary is owuivm.service)"