#!/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)"