#!/usr/bin/env bash set -euo pipefail # Virtuality Web Panel Installer # Auto-detects x86_64 / Raspberry Pi ARM64 / Orange Pi 5 ARM64 / generic ARM64 profile. REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WEB_DIR="${REPO_DIR}/web" APP_DIR="/opt/virtuality/web" VENV_DIR="/opt/virtuality/venv" SERVICE_FILE="/etc/systemd/system/virtuality-web.service" AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service" AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer" PORT="${VIRTUALITY_WEB_PORT:-8088}" AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}" LOG_DIR="/var/log/virtuality" LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log" PROFILE_DIR="/var/lib/virtuality/config" PROFILE_FILE="${PROFILE_DIR}/host_profile.json" SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret" UPLOAD_TMP_DIR="/var/lib/virtuality/tmp" TOTAL_STEPS=13 CURRENT_STEP=0 ESC="\033" RESET="${ESC}[0m" BOLD="${ESC}[1m" DIM="${ESC}[2m" GREEN="${ESC}[32m" YELLOW="${ESC}[33m" RED="${ESC}[31m" CYAN="${ESC}[36m" BLUE="${ESC}[34m" GRAY="${ESC}[90m" mkdir -p "$LOG_DIR" "$PROFILE_DIR" "$UPLOAD_TMP_DIR" chmod 1777 "$UPLOAD_TMP_DIR" timestamp() { date '+%Y-%m-%d %H:%M:%S'; } line() { printf '%*s\n' 72 '' | tr ' ' '─'; } log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; } print_header() { clear 2>/dev/null || true echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Web Panel Installer${RESET} ${CYAN}${BOLD}│${RESET}" echo -e "${CYAN}${BOLD}│${RESET} Auto profile: x86 / VPS / Raspberry / Orange Pi 5 ${CYAN}${BOLD}│${RESET}" echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}" echo -e "${GRAY}Порт панели:${RESET} ${PORT}" echo -e "${GRAY}Linux auth user:${RESET} ${AUTH_USER}" echo } step() { CURRENT_STEP=$((CURRENT_STEP + 1)) echo echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}" log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*" } ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; } warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; } fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; exit 1; } run_logged() { local description="$1" shift log "RUN: $*" if "$@" >> "$LOG_FILE" 2>&1; then ok "$description" else fail "$description" fi } service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; } require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; } json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; } print_header step "Проверяем права и структуру проекта" require_root ok "Права root подтверждены" [[ -d "$WEB_DIR" ]] || fail "Не найдена директория web: $WEB_DIR" ok "Исходники web найдены: $WEB_DIR" [[ -f "$WEB_DIR/app.py" ]] || fail "Не найден web/app.py" ok "FastAPI entrypoint найден: web/app.py" [[ -f "$WEB_DIR/requirements.txt" ]] || fail "Не найден web/requirements.txt" ok "requirements.txt найден" step "Автоматически определяем профиль сборки" if [[ -x "${REPO_DIR}/scripts/detect_host_profile.sh" ]]; then PROFILE="$(${REPO_DIR}/scripts/detect_host_profile.sh 2>>"$LOG_FILE" | tail -n1)" else bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true PROFILE="$(json_value profile)" fi [[ -n "$PROFILE" ]] || PROFILE="x86_64" ARCH="$(json_value arch)" MODEL="$(json_value model)" LABEL="$(json_value label)" PACKAGE_PROFILE="$(json_value package_profile)" ok "Профиль: ${LABEL:-$PROFILE}" ok "Архитектура: ${ARCH:-unknown}" ok "Модель: ${MODEL:-unknown}" ok "Конфиг профиля: $PROFILE_FILE" step "Проверяем Linux-пользователя для входа" if id "$AUTH_USER" >/dev/null 2>&1; then ok "Пользователь найден: $AUTH_USER" else fail "Пользователь $AUTH_USER не найден. Создай его или передай VIRTUALITY_AUTH_USER=username" fi if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then warn "У пользователя $AUTH_USER может быть заблокирован пароль. Установи пароль: sudo passwd $AUTH_USER" else ok "У пользователя $AUTH_USER есть пароль для Linux-auth" fi step "Проверяем базовые команды" for cmd in apt systemctl rsync python3 hostname openssl; do if command -v "$cmd" >/dev/null 2>&1; then ok "$cmd найден: $(command -v "$cmd")" else fail "$cmd не найден" fi done step "Обновляем apt cache" run_logged "apt update выполнен" apt update step "Устанавливаем системные зависимости под выбранную сборку" COMMON_PACKAGES=(python3 python3-venv python3-pip rsync openssl curl wget unzip nftables novnc python3-websockify) X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils) ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils) PACKAGES=("${COMMON_PACKAGES[@]}") case "$PACKAGE_PROFILE" in raspberry|orangepi5|arm64) PACKAGES+=("${ARM_PACKAGES[@]}") ;; *) PACKAGES+=("${X86_PACKAGES[@]}") ;; esac run_logged "Системные пакеты установлены для профиля ${PACKAGE_PROFILE:-x86}" apt install -y "${PACKAGES[@]}" if [[ "${PACKAGE_PROFILE:-x86}" != "raspberry" && "${PACKAGE_PROFILE:-x86}" != "orangepi5" && "${PACKAGE_PROFILE:-x86}" != "arm64" ]]; then if command -v qemu-system-aarch64 >/dev/null 2>&1; then ok "ARM64 QEMU эмулятор найден: $(command -v qemu-system-aarch64)" else fail "Не найден qemu-system-aarch64. Установи пакет: sudo apt install -y qemu-system-arm qemu-efi-aarch64" fi fi step "Копируем web-панель в /opt/virtuality" run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/" if [[ -f "${REPO_DIR}/scripts/patch_web_console.py" ]]; then run_logged "noVNC web-console patch применён" python3 "${REPO_DIR}/scripts/patch_web_console.py" "${APP_DIR}/app.py" else warn "patch_web_console.py не найден, noVNC console patch пропущен" fi if [[ -f "${REPO_DIR}/scripts/patch_upload_compat.py" ]]; then run_logged "upload compatibility patch применён" python3 "${REPO_DIR}/scripts/patch_upload_compat.py" "${APP_DIR}/app.py" else warn "patch_upload_compat.py не найден, совместимость загрузки файлов пропущена" fi if [[ -f "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" ]]; then run_logged "upload navigation guard patch применён" python3 "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" "${APP_DIR}/app.py" else warn "patch_upload_navigation_guard.py не найден, защита загрузок от переходов пропущена" fi if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then run_logged "disk images patch применён" python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py" else warn "patch_disk_images.py не найден, менеджер дисковых образов пропущен" fi if [[ -f "${REPO_DIR}/scripts/patch_vm_boot_order.py" ]]; then run_logged "VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_vm_boot_order.py" "${APP_DIR}/app.py" else warn "patch_vm_boot_order.py не найден, порядок загрузки VM пропущен" fi if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" ]]; then run_logged "existing VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" "${APP_DIR}/app.py" else warn "patch_existing_vm_boot_order.py не найден, порядок загрузки существующих VM пропущен" fi if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_resources.py" ]]; then run_logged "existing VM resources patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_resources.py" "${APP_DIR}/app.py" else warn "patch_existing_vm_resources.py не найден, ресурсы существующих VM пропущены" fi if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" ]]; then run_logged "existing VM ISO mount patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" "${APP_DIR}/app.py" else warn "patch_existing_vm_iso_mount.py не найден, монтирование ISO в VM пропущено" fi if [[ -f "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" ]]; then run_logged "VM detail resource layout patch применён" python3 "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" "${APP_DIR}/app.py" else warn "patch_vm_detail_resource_layout.py не найден, раскладка ресурсов VM пропущена" fi if [[ -f "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" ]]; then run_logged "legacy boot order cleanup patch применён" python3 "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" "${APP_DIR}/app.py" else warn "patch_remove_legacy_boot_order_card.py не найден, удаление старого блока порядка загрузки пропущено" fi if [[ -f "${REPO_DIR}/scripts/patch_disk_archives.py" ]]; then run_logged "disk archive import patch применён" python3 "${REPO_DIR}/scripts/patch_disk_archives.py" "${APP_DIR}/app.py" else warn "patch_disk_archives.py не найден, импорт архивов дисков пропущен" fi if [[ -f "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" ]]; then run_logged "DHCP leases empty-state patch применён" python3 "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" "${APP_DIR}/app.py" else warn "patch_dhcp_leases_empty.py не найден, диагностика DHCP leases пропущена" fi if [[ -f "${REPO_DIR}/scripts/patch_network_diagnostics.py" ]]; then run_logged "network diagnostics patch применён" python3 "${REPO_DIR}/scripts/patch_network_diagnostics.py" "${APP_DIR}/app.py" else warn "patch_network_diagnostics.py не найден, диагностика сети пропущена" fi if [[ -f "${REPO_DIR}/scripts/patch_network_ranges.py" ]]; then run_logged "network port ranges patch применён" python3 "${REPO_DIR}/scripts/patch_network_ranges.py" "${APP_DIR}/app.py" else warn "patch_network_ranges.py не найден, поддержка диапазонов портов пропущена" fi if [[ -f "${REPO_DIR}/scripts/patch_network_nat_errors.py" ]]; then run_logged "network NAT error patch применён" python3 "${REPO_DIR}/scripts/patch_network_nat_errors.py" "${APP_DIR}/app.py" else warn "patch_network_nat_errors.py не найден, безопасные ошибки NAT пропущены" fi if [[ -f "${REPO_DIR}/scripts/patch_logs_center.py" ]]; then run_logged "logs center patch применён" python3 "${REPO_DIR}/scripts/patch_logs_center.py" "${APP_DIR}/app.py" else warn "patch_logs_center.py не найден, центр журналов пропущен" fi if [[ -f "${REPO_DIR}/scripts/patch_vm_network_guard.py" ]]; then run_logged "VM network guard patch применён" python3 "${REPO_DIR}/scripts/patch_vm_network_guard.py" "${APP_DIR}/app.py" else warn "patch_vm_network_guard.py не найден, защита от отсутствующего bridge пропущена" fi if [[ -f "${REPO_DIR}/scripts/patch_update_center.py" ]]; then run_logged "update center patch применён" python3 "${REPO_DIR}/scripts/patch_update_center.py" "${APP_DIR}/app.py" else warn "patch_update_center.py не найден, центр обновлений пропущен" fi run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR" if [[ -f "$PROFILE_FILE" ]]; then ok "Профиль уже сохранён: $PROFILE_FILE" else warn "Профиль не найден после детекта, будет создан web-панелью при старте" fi step "Создаём конфиг авторизации" if [[ -s "$SESSION_SECRET_FILE" ]]; then SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")" ok "Используем существующий session secret: $SESSION_SECRET_FILE" else SESSION_SECRET="$(openssl rand -hex 32)" printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE" chmod 600 "$SESSION_SECRET_FILE" ok "Создан постоянный session secret: $SESSION_SECRET_FILE" fi cat > "${APP_DIR}/.env" < "$SERVICE_FILE" < "$AUTO_UPDATE_SERVICE_FILE" < "$AUTO_UPDATE_TIMER_FILE" <> "$LOG_FILE" 2>&1 || true fi step "Настраиваем firewall и проверяем порт" if command -v ufw >/dev/null 2>&1; then ufw allow "${PORT}/tcp" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить UFW правило для ${PORT}/tcp" ok "UFW правило добавлено/проверено: ${PORT}/tcp" else warn "ufw не установлен, firewall-правило пропущено" fi if command -v ss >/dev/null 2>&1 && ss -tulpn | grep -q ":${PORT}"; then ok "Порт ${PORT} слушается" else warn "Порт ${PORT} пока не найден в ss; проверь service logs" fi SERVER_IP="$(hostname -I | awk '{print $1}')" echo echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}" echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo echo -e "${BOLD}URL:${RESET} http://${SERVER_IP}:${PORT}" echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-пользователя" echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}" echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}" echo -e "${BOLD}Service:${RESET} virtuality-web.service" echo -e "${BOLD}Auto update:${RESET} virtuality-auto-update.timer / ежедневно в 00:00 по Москве" echo -e "${BOLD}Upload tmp:${RESET} ${UPLOAD_TMP_DIR}" echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}" echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager" echo -e "${BOLD}Logs:${RESET} journalctl -u virtuality-web -f" echo -e "${BOLD}Install log:${RESET} ${LOG_FILE}" echo line echo -e "${DIM}Следующий шаг: /host, /network, /logs, /update, /vm/create, /vm/NAME/console.${RESET}" line