#!/usr/bin/env bash # Host setup only. Application dependencies are built in the backend container. set -Eeuo pipefail export LC_ALL=C SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) DRY_RUN=false ASSUME_YES=false CHECK_ONLY=false TARGET_USER= DISTRO= INIT_SYSTEM= PHASE=preflight ROOT=() LIBVIRT_SOCKET=/var/run/libvirt/libvirt-sock STORAGE_DIR=/var/lib/distribox OPENRC_DIR=/etc/init.d RUNIT_AVAILABLE=/etc/sv RUNIT_ENABLED=/var/service TUN_DEVICE=/dev/net/tun usage() { cat <<'EOF' Usage: bash setup.sh [--dry-run] [--yes] [--user USER] [--check] Prepare a Linux host for Distribox: install QEMU/libvirt, enable local services and the default NAT network, create shared storage, and add user groups. --dry-run Print the planned changes without sudo or host modifications. --yes, -y Accept package-manager prompts (including Gentoo builds). --user USER User receiving storage/libvirt access. Defaults to the invoking user, or SUDO_USER under sudo. Required for a direct root login. --check Diagnose an existing host without changing it. --help, -h Show this help. Supported: Debian/Ubuntu, Arch, Fedora/RHEL, openSUSE, Alpine, Gentoo, Void, Solus, and NixOS (declarative flake module). Install Docker Engine and Compose separately. On minimal Alpine, install Bash first. Package managers keep their native prompts; APK applies changes directly. Log out and back in after setup to apply all group memberships. EOF } info() { printf '\n==> %s\n' "$*"; } warn() { printf 'Warning: %s\n' "$*" >&2; } die() { printf 'Error: %s\n' "$*" >&2; exit 1; } on_error() { printf '\nError during %s (line %s, exit %s): %s\n' "$PHASE" "$2" "$1" "$3" >&2 printf 'Resolve the error above, then rerun setup. Use --check to inspect the host.\n' >&2 exit "$1" } run() { printf ' $' printf ' %q' "$@" printf '\n' if ! "$DRY_RUN"; then "$@"; fi } run_root() { run "${ROOT[@]}" "$@"; } query_root() { "${ROOT[@]}" "$@"; } virsh_query() { query_root virsh --connect qemu:///system "$@"; } virsh_run() { run_root virsh --connect qemu:///system "$@"; } require_command() { command -v "$1" >/dev/null || die "Required command '$1' is missing. $2"; } parse_args() { while (($#)); do case "$1" in --dry-run) DRY_RUN=true ;; --yes|-y) ASSUME_YES=true ;; --check) CHECK_ONLY=true ;; --user) [[ $# -ge 2 && -n $2 && $2 != -* ]] || die '--user requires a username.' TARGET_USER=$2 shift ;; --help|-h) usage; exit 0 ;; *) die "Unknown argument '$1'. Use --help for usage." ;; esac shift done if "$CHECK_ONLY" && "$DRY_RUN"; then die 'Use --check or --dry-run separately.'; fi } read_os_release() { local release_file=${1:-/etc/os-release} local ID='' ID_LIKE='' [[ -r $release_file ]] || release_file=/usr/lib/os-release [[ -r $release_file ]] || die 'Cannot read os-release; the Linux distribution is unknown.' # os-release is an administrator-owned shell assignment file. # shellcheck disable=SC1090 . "$release_file" local candidate for candidate in "${ID:-}" ${ID_LIKE:-}; do case "$candidate" in nixos) DISTRO=nixos; return ;; debian|ubuntu) DISTRO=debian; return ;; arch|archlinux) DISTRO=arch; return ;; fedora|rhel|centos|rocky|almalinux) DISTRO=fedora; return ;; opensuse*|suse|sles) DISTRO=opensuse; return ;; alpine|gentoo|void|solus) DISTRO=$candidate; return ;; esac done die "Unsupported distribution '${ID:-unknown}'. No changes made. Use --help for supported systems." } detect_platform() { [[ $(uname -s) == Linux ]] || die 'Distribox needs a Linux host: its containers use host libvirt and KVM. Use a Linux machine or VM.' [[ $(uname -m) == x86_64 ]] || die 'Distribox currently creates x86_64 VMs. Use an x86_64 Linux host; other host architectures need a separate emulator configuration.' read_os_release } detect_init() { if [[ -d /run/systemd/system ]]; then INIT_SYSTEM=systemd require_command systemctl 'Boot the host with systemd.' systemctl show --property=Version --value >/dev/null 2>&1 || die 'Cannot reach the running systemd manager. Run setup on the host, outside a container/chroot.' elif [[ -r /run/openrc/softlevel ]] && command -v rc-service >/dev/null && command -v rc-update >/dev/null; then INIT_SYSTEM=openrc elif [[ $DISTRO == void ]] && command -v sv >/dev/null && [[ -d /var/service ]]; then INIT_SYSTEM=runit else die 'No supported running init system found (systemd, OpenRC, or Void runit). Run this on the host, outside a container/chroot.' fi } select_user() { TARGET_USER=${TARGET_USER:-${SUDO_USER:-$(id -un)}} [[ $TARGET_USER != root ]] || die 'Choose a non-root operator with --user USER (or run setup as that user).' [[ $TARGET_USER =~ ^[a-zA-Z_][a-zA-Z0-9_.-]*\$?$ ]] || die "Invalid username '$TARGET_USER'." id "$TARGET_USER" >/dev/null 2>&1 || die "User '$TARGET_USER' does not exist. Create the operator account first." [[ $(id -u "$TARGET_USER") != 0 ]] || die 'The operator account must have a non-root UID.' } prepare_privileges() { if ((EUID != 0)); then require_command sudo 'Install sudo or run sudo bash setup.sh --user USER.' ROOT=(sudo) if ! "$DRY_RUN"; then sudo -v; fi fi } install_packages() { PHASE='package installation' info "Install host packages ($DISTRO)" local yes=() "$ASSUME_YES" && yes=(-y) case "$DISTRO" in debian) require_command apt-get 'This host must provide apt-get.' run_root apt-get update run_root apt-get install "${yes[@]}" qemu-system-x86 qemu-utils libvirt-daemon-system libvirt-clients dnsmasq-base ovmf ;; arch) require_command pacman 'This host must provide pacman.' yes=() "$ASSUME_YES" && yes=(--noconfirm) local packages=(libvirt qemu-desktop dnsmasq edk2-ovmf nftables) # Keep an existing iptables provider; do not force a conflicting replacement. if ! command -v iptables >/dev/null; then packages+=(iptables-nft); fi # No database-only refresh: Arch does not support partial upgrades. run_root pacman -S --needed "${yes[@]}" "${packages[@]}" ;; fedora) local manager=dnf command -v dnf >/dev/null || manager=yum require_command "$manager" 'Install dnf or yum first.' run_root "$manager" install "${yes[@]}" qemu-kvm qemu-img libvirt-daemon-kvm libvirt-client libvirt-daemon-config-network dnsmasq edk2-ovmf ;; opensuse) require_command zypper 'This host must provide zypper.' yes=() "$ASSUME_YES" && yes=(--non-interactive) run_root zypper "${yes[@]}" install qemu libvirt-daemon-qemu libvirt-client libvirt-daemon-config-network dnsmasq qemu-ovmf-x86_64 ;; alpine) require_command apk 'This host must provide apk.' run_root apk add libvirt-daemon libvirt-qemu libvirt-daemon-openrc qemu-system-x86_64 qemu-img qemu-modules ovmf dbus polkit ;; gentoo) require_command emerge 'This host must provide Portage.' yes=(--ask) "$ASSUME_YES" && yes=(--ask=n) info 'If Portage requests USE changes, enable the requested flags in package.use and rerun; setup leaves Portage configuration to you.' run_root emerge "${yes[@]}" --noreplace 'app-emulation/libvirt[libvirtd,qemu,virt-network,policykit]' 'app-emulation/qemu[vnc,qemu_softmmu_targets_x86_64]' sys-firmware/edk2 sys-apps/dbus ;; void) require_command xbps-install 'This host must provide XBPS.' run_root xbps-install -S "${yes[@]}" libvirt qemu dbus polkit ;; solus) require_command eopkg 'This host must provide eopkg.' run_root eopkg install "${yes[@]}" qemu libvirt dnsmasq ;; esac } prepare_tun() { PHASE='TUN kernel support' info 'Ensure guest TAP devices work now and after reboot' if [[ ! -c $TUN_DEVICE ]]; then require_command modprobe 'Install kmod or enable TUN in the host kernel.' run_root modprobe tun if ! "$DRY_RUN" && [[ ! -c $TUN_DEVICE ]]; then die "TUN is unavailable at $TUN_DEVICE after modprobe. Enable CONFIG_TUN in the host kernel; do not run setup inside a container." fi fi run_root install -d -m 755 /etc/modules-load.d if "$DRY_RUN"; then info 'Write tun to /etc/modules-load.d/60-distribox.conf for future boots' else printf 'tun\n' | query_root tee /etc/modules-load.d/60-distribox.conf >/dev/null fi } unit_exists() { local state state=$(systemctl show --property=LoadState --value "$1" 2>/dev/null) || return 1 [[ $state == loaded ]] } systemd_services() { local service run_root systemctl daemon-reload if systemctl is-active --quiet libvirtd.service libvirtd.socket; then run_root systemctl enable --now libvirtd.service elif systemctl is-active --quiet virtqemud.service virtqemud.socket virtproxyd.socket || unit_exists virtqemud.socket; then info 'Use modular libvirt with the compatibility socket required by Compose' for service in virtlogd virtlockd virtqemud virtnetworkd virtstoraged virtnodedevd virtnwfilterd virtsecretd virtproxyd; do if "$DRY_RUN" || unit_exists "$service.socket"; then run_root systemctl enable --now "$service.socket" elif [[ $service == virtqemud || $service == virtnetworkd || $service == virtproxyd ]]; then die "Missing $service.socket; install the QEMU, network, and proxy libvirt drivers." fi done elif "$DRY_RUN" || unit_exists libvirtd.service; then run_root systemctl enable --now libvirtd.service else die 'No libvirt service units were installed. Check the package-manager output.' fi } openrc_services() { local service for service in dbus virtlogd virtlockd libvirtd; do if "$DRY_RUN" || [[ -x $OPENRC_DIR/$service ]]; then run_root rc-update add "$service" default run_root rc-service "$service" start elif [[ $service == libvirtd || $service == virtlogd ]]; then die "Missing OpenRC service '$service'. Install the distribution's libvirt OpenRC package." fi done } runit_services() { local service local services=(dbus virtlogd virtlockd libvirtd) # Preserve a host already using modular daemons. if [[ -e $RUNIT_ENABLED/virtqemud || -L $RUNIT_ENABLED/virtqemud ]]; then services=(dbus virtlogd virtlockd virtqemud virtnetworkd virtstoraged virtnodedevd virtnwfilterd virtsecretd virtproxyd) fi for service in "${services[@]}"; do local service_path=$RUNIT_AVAILABLE/$service if [[ -e $RUNIT_ENABLED/$service || -L $RUNIT_ENABLED/$service ]]; then service_path=$RUNIT_ENABLED/$service; fi if ! "$DRY_RUN" && [[ ! -d $service_path ]]; then die "Missing runit service $service_path (check for a dangling link)."; fi if [[ -e $service_path/down ]]; then die "$service is disabled by $service_path/down. Review and remove that marker before enabling it at boot." fi if [[ ! -e $RUNIT_ENABLED/$service && ! -L $RUNIT_ENABLED/$service ]]; then run_root ln -s "$RUNIT_AVAILABLE/$service" "$RUNIT_ENABLED/$service" fi if ! "$DRY_RUN"; then # runsvdir scans new links asynchronously; sv's -w does not wait for it. local attempt for ((attempt=0; attempt<30; attempt++)); do if query_root test -p "$RUNIT_ENABLED/$service/supervise/ok"; then break; fi sleep 1 done ((attempt < 30)) || die "runit did not supervise $service within 30 seconds. Check runsvdir and the enabled-service link." fi run_root sv -w 30 up "$RUNIT_ENABLED/$service" done } enable_services() { PHASE='service startup' info "Enable local libvirt ($INIT_SYSTEM); existing guests are not restarted" case "$INIT_SYSTEM" in systemd) systemd_services ;; openrc) openrc_services ;; runit) runit_services ;; esac } wait_for_libvirt() { PHASE='libvirt readiness' info 'Wait for the local libvirt API (up to 60 seconds)' if "$DRY_RUN"; then return; fi require_command virsh 'Install the distribution libvirt client package.' require_command timeout 'Install coreutils or BusyBox timeout for bounded daemon checks.' local attempt output= for ((attempt=0; attempt<20; attempt++)); do if [[ -S $LIBVIRT_SOCKET ]] && output=$(query_root timeout 2 virsh --connect qemu:///system list --all 2>&1); then return; fi if ((attempt < 19)); then sleep 1; fi done # Keep the final daemon error visible, without an unbounded diagnostic query. if [[ -n $output ]]; then printf '%s\n' "$output" >&2; fi die "Libvirt readiness checks failed after the retry window (expected $LIBVIRT_SOCKET). Inspect the service logs before rerunning setup." } configure_network() { PHASE='default NAT network' info 'Ensure the default libvirt network exists, starts now, and starts at boot' if "$DRY_RUN"; then info 'If default is missing, define the bundled NAT network (virbr0, 192.168.122.0/24). Existing definitions are preserved.' virsh_run net-define "$SCRIPT_DIR/setup/default-network.xml" virsh_run net-autostart default virsh_run net-start default return fi # List first: a failed connection must never be mistaken for a missing network. local networks active networks=$(virsh_query net-list --all --name) if ! printf '%s\n' "$networks" | grep -Fx default >/dev/null; then virsh_run net-define "$SCRIPT_DIR/setup/default-network.xml" fi virsh_run net-autostart default active=$(virsh_query net-list --name) if ! printf '%s\n' "$active" | grep -Fx default >/dev/null; then virsh_run net-start default; fi } configure_groups() { PHASE='operator groups' info "Grant $TARGET_USER storage and libvirt administration access" local group groups=(distribox libvirt) if getent group kvm >/dev/null; then groups+=(kvm); fi for group in "${groups[@]}"; do if ! getent group "$group" >/dev/null; then if command -v groupadd >/dev/null; then run_root groupadd "$group" else run_root addgroup -S "$group"; fi fi if id -nG "$TARGET_USER" | tr ' ' '\n' | grep -Fxq "$group"; then continue; fi if command -v usermod >/dev/null; then run_root usermod -aG "$group" "$TARGET_USER" else run_root addgroup "$TARGET_USER" "$group"; fi done } configure_storage() { PHASE='shared storage' info 'Prepare shared directories; existing VM files keep their ownership and permissions' local directory for directory in "$STORAGE_DIR" "$STORAGE_DIR/images" "$STORAGE_DIR/vms"; do [[ ! -L $directory ]] || die "$directory is a symlink. Use a bind mount for custom storage; see the storage section in README.md." done run_root install -d -m 2775 -o root -g distribox "$STORAGE_DIR" "$STORAGE_DIR/images" "$STORAGE_DIR/vms" # QEMU identities differ by distro. Libvirt manages individual disk ownership. if command -v getenforce >/dev/null && [[ $(getenforce) != Disabled ]]; then warn 'SELinux is enabled. Configure image-storage labels and backend access to the libvirt socket before starting VMs; see README.md.' fi } configure_sysctl() { PHASE='remote desktop TCP tuning' info 'Keep TCP congestion windows across idle desktop sessions' run_root install -d -m 755 /etc/sysctl.d if "$DRY_RUN"; then info 'Write net.ipv4.tcp_slow_start_after_idle = 0 to /etc/sysctl.d/60-distribox.conf' else printf 'net.ipv4.tcp_slow_start_after_idle = 0\n' | query_root tee /etc/sysctl.d/60-distribox.conf >/dev/null fi run_root sysctl -q -p /etc/sysctl.d/60-distribox.conf } check_host() { PHASE='host verification' info 'Verify the host connection and default network' if "$DRY_RUN"; then virsh_run list --all return fi require_command virsh 'Install the libvirt client package.' [[ -S $LIBVIRT_SOCKET ]] || die "The compatibility socket $LIBVIRT_SOCKET is missing; Compose cannot reach libvirt." virsh_query list --all >/dev/null virsh_query domcapabilities --arch x86_64 --machine pc --virttype qemu >/dev/null [[ -c $TUN_DEVICE ]] || die "Missing TUN device $TUN_DEVICE. Enable the host's tun kernel module before starting guests." local active active=$(virsh_query net-list --name) printf '%s\n' "$active" | grep -Fx default >/dev/null || die "The libvirt 'default' network is inactive. Inspect its definition with virsh net-dumpxml default, host routes, firewall, and daemon logs." local directory for directory in "$STORAGE_DIR" "$STORAGE_DIR/images" "$STORAGE_DIR/vms"; do [[ -d $directory ]] || die "Missing storage directory $directory." done [[ -c /dev/kvm ]] || warn 'KVM is unavailable. Enable CPU virtualization/nested virtualization, or set VIRT_TYPE=qemu in .env for slower software emulation.' if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then warn 'Install Docker Engine and the Compose plugin separately before starting Distribox.' elif ! docker info >/dev/null 2>&1; then warn 'Docker is not reachable as the current user. Start Docker and arrange access before starting Compose.' fi } nixos_instructions() { cat <<'EOF' NixOS hosts are configured declaratively. No host changes were made. Add the Distribox flake input to your existing host flake: inputs.distribox.url = "github:PoCInnovation/Distribox"; Add to your nixosSystem modules (with distribox in your outputs arguments): distribox.nixosModules.default { services.distriboxHost.enable = true; services.distriboxHost.users = [ "YOUR_USER" ]; virtualisation.docker.enable = true; } Review and apply your host configuration with nixos-rebuild switch --flake. Log out and back in, then run: bash setup.sh --check For a local checkout, use inputs.distribox.url = "path:/absolute/path/to/Distribox". For a customized default network bridge, set services.distriboxHost.networkInterface to that bridge's name. Keep your existing QEMU runtime identity and Docker policy. EOF } main() { trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR parse_args "$@" detect_platform if [[ $DISTRO == nixos ]] && ! "$CHECK_ONLY"; then nixos_instructions; return; fi if "$CHECK_ONLY"; then prepare_privileges check_host info 'Host checks passed. Docker/KVM warnings above still need attention.' return fi select_user detect_init info "Distribox host setup: $DISTRO / $INIT_SYSTEM / operator $TARGET_USER" if "$DRY_RUN"; then info 'Dry run: commands below are a plan; no changes or privilege prompts.'; fi prepare_privileges install_packages prepare_tun enable_services wait_for_libvirt configure_network configure_groups configure_storage configure_sysctl check_host if "$DRY_RUN"; then info 'Dry run complete. Service and network choices are checked again after installation.' else info 'Host setup complete. Log out and back in to apply all user group changes.' printf 'Next: configure .env, then run docker compose --profile master up -d --build\n' printf 'For a slave node use the dashboard setup guide.\n' fi } if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi