diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl index fff8f4d..ec4d25a 100755 --- a/scripts/virtuality-ctl +++ b/scripts/virtuality-ctl @@ -306,11 +306,24 @@ restart_panel() { fi } +# VMs created by older versions listened for VNC on every address; the panel proxies consoles over localhost. +harden_vnc() { + command -v virsh >/dev/null 2>&1 && command -v virt-xml >/dev/null 2>&1 || return 0 + local name + while read -r name; do + [[ -n "$name" ]] || continue + if virsh dumpxml "$name" 2>/dev/null | grep -q "listen='0.0.0.0'"; then + virt-xml "$name" --edit --graphics listen=127.0.0.1 >/dev/null 2>&1 && log "vnc of ${name} now listens on 127.0.0.1 (applies after restart)" || true + fi + done < <(virsh list --all --name 2>/dev/null) +} + cmd_reconfigure() { need_root reconfigure [[ -d "$APP_DIR" ]] || die "панель не установлена: ${APP_DIR} не найден" write_units apply_firewall + harden_vnc restart_panel log "reconfigured: $(panel_url)" echo "Панель перезапущена: $(panel_url)" diff --git a/tests/conftest.py b/tests/conftest.py index 7835f6a..f41a70b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -143,6 +143,7 @@ def data_dirs(tmp_path, monkeypatch): monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"]) monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"]) monkeypatch.setattr(network_core, "PORT_FORWARDS_FILE", dirs["network"] / "port_forwards.json") + monkeypatch.setattr(network_core, "UFW_STATE_FILE", dirs["network"] / "ufw_rules.json") monkeypatch.setattr(network_core, "NAT_XML_FILE", dirs["network"] / "virtuality-nat.xml") monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft") monkeypatch.setattr(network_core, "enable_ip_forward", lambda: None) diff --git a/tests/dev_server.py b/tests/dev_server.py index a814781..0c0fca2 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -76,6 +76,7 @@ def seed(root: Path) -> None: seed_backups(dirs["backups"]) network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"] network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json" + network_core.UFW_STATE_FILE = dirs["network"] / "ufw_rules.json" network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" network_core.NFT_FILE = dirs["nft"] / "virtuality.nft" network_core.enable_ip_forward = lambda: None diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py new file mode 100644 index 0000000..2353b9f --- /dev/null +++ b/tests/test_audit_fixes.py @@ -0,0 +1,124 @@ +"""Regressions for the defects found by the 0.11 audit.""" +import json +from pathlib import Path +from urllib.parse import quote + +import app +import conftest +import network_core + +CDROM_XML = conftest.DUMPXML.replace( + "", + "", +) + + +def recording(monkeypatch, module, overrides=()): + calls: list[list[str]] = [] + + def run(cmd, timeout=12, **kwargs): + calls.append(list(cmd)) + for prefix, result in overrides: + if cmd[: len(prefix)] == list(prefix): + return result + return conftest.fake_run_cmd(cmd, timeout, **kwargs) + + monkeypatch.setattr(module, "run_cmd", run) + return calls + + +def find(calls, *prefix): + return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)] + + +# ---------------------------------------------------------------- disk image format (app.py:620) +def test_disk_image_format_asks_qemu_img(data_dirs, monkeypatch): + # Ubuntu cloud images are qcow2 files named *.img: the suffix must not decide the format. + assert app.disk_image_format(Path("/tmp/noble-server-cloudimg-amd64.img")) == "qcow2" + recording(monkeypatch, app, [(["qemu-img", "info"], conftest.fake_result(ok=False, stderr="no such file"))]) + assert app.disk_image_format(Path("/tmp/disk.img")) == "raw" + assert app.disk_image_format(Path("/tmp/disk.qcow2")) == "qcow2" + + +# ---------------------------------------------------------------- VM actions (app.py:1977, 1981) +def test_vm_action_reports_virsh_error(logged_in, data_dirs, monkeypatch): + recording(monkeypatch, app, [(["virsh", "start"], conftest.fake_result(ok=False, stderr="error: Domain is already active"))]) + response = logged_in.post("/vm/web01/start", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/vm/web01?error=" + quote("Машина уже запущена") + assert "Машина уже запущена" in logged_in.get("/vm/web01?error=" + quote("Машина уже запущена")).text + + +def test_vm_action_success_redirects_without_error(logged_in, data_dirs, monkeypatch): + recording(monkeypatch, app) + assert logged_in.post("/vm/web01/shutdown", follow_redirects=False).headers["location"] == "/vm/web01" + + +def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch): + calls = recording(monkeypatch, app) + response = logged_in.post("/vm/web01/delete", follow_redirects=False) + assert response.headers["location"].startswith("/?message=") + assert find(calls, "virsh", "destroy", "web01") + undefine = find(calls, "virsh", "undefine", "web01")[0] + assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine + + +def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch): + recording(monkeypatch, app, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))]) + response = logged_in.post("/vm/web01/delete", follow_redirects=False) + assert response.headers["location"].startswith("/vm/web01?error=") + + +# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158) +def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch): + iso = data_dirs["iso"] / "debian.iso" + iso.write_bytes(b"iso") + calls = recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(CDROM_XML))]) + ok, message = app.mount_vm_iso("web01", str(iso)) + assert ok, message + assert find(calls, "virsh", "change-media", "web01", "sda", str(iso), "--update", "--config", "--live") + assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "attach-disk") + ok, message = app.detach_vm_iso("web01") + assert ok and find(calls, "virsh", "change-media", "web01", "sda", "--eject", "--config", "--live") + + +def test_iso_unmount_never_guesses_targets(data_dirs, monkeypatch): + # web01 has only a virtio data disk: nothing may be detached blindly. + calls = recording(monkeypatch, app) + ok, message = app.detach_vm_iso("web01") + assert not ok and message == "Подключенный ISO не найден." + assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "change-media") + + +def test_iso_mount_adds_drive_to_config_when_missing(data_dirs, monkeypatch): + iso = data_dirs["iso"] / "debian.iso" + iso.write_bytes(b"iso") + calls = recording(monkeypatch, app) + ok, message = app.mount_vm_iso("web01", str(iso)) + assert ok and "перезапуска" in message + attach = find(calls, "virsh", "attach-disk", "web01")[0] + assert "--config" in attach and "--live" not in attach + + +# ---------------------------------------------------------------- port forwards leave no stale firewall rules (network_core.py:459) +def test_iptables_fallback_removes_stale_rules(data_dirs, monkeypatch): + listing = "\n".join([ + "-A FORWARD -i eth0 -o virbr1 -p tcp -d 192.168.100.51 -m tcp --dport 22 -m comment --comment virtuality-forward -j ACCEPT", + "-A FORWARD -j SOMETHING_ELSE", + ]) + calls = recording(monkeypatch, network_core, [(["iptables", "-t", "filter", "-S", "FORWARD"], conftest.fake_result(listing))]) + network_core.apply_iptables_fallback([]) + deleted = find(calls, "iptables", "-t", "filter", "-D", "FORWARD") + assert deleted == [["iptables", "-t", "filter", "-D", "FORWARD", "-i", "eth0", "-o", "virbr1", "-p", "tcp", "-d", "192.168.100.51", "-m", "tcp", "--dport", "22", "-m", "comment", "--comment", "virtuality-forward", "-j", "ACCEPT"]] + checked = find(calls, "iptables", "-t", "nat", "-C", "POSTROUTING") + assert checked and "virtuality-forward" in checked[0] + + +def test_ufw_rules_follow_forwards(data_dirs, monkeypatch): + old_rule = ["allow", "2222/tcp"] + network_core.UFW_STATE_FILE.write_text(json.dumps([old_rule, ["route", "allow", "in", "on", "eth0", "out", "on", "virbr1", "to", "192.168.100.51", "port", "22", "proto", "tcp"]])) + calls = recording(monkeypatch, network_core, [(["ufw", "status"], conftest.fake_result("Status: active"))]) + network_core.apply_ufw_route_rules([]) + assert find(calls, "ufw", "delete", "allow", "2222/tcp") + assert find(calls, "ufw", "route", "delete", "allow", "in", "on", "eth0") + assert json.loads(network_core.UFW_STATE_FILE.read_text()) == [] diff --git a/web/app.py b/web/app.py index 894241d..54837c6 100644 --- a/web/app.py +++ b/web/app.py @@ -561,26 +561,10 @@ def refresh_iso_pool() -> None: run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20) -def vm_arch_options() -> list[dict[str, str]]: - return [ - {"value": "auto", "label": "Auto — по профилю хоста"}, - {"value": "x86_64", "label": "x86_64 / amd64"}, - {"value": "aarch64", "label": "ARM64 / aarch64"}, - {"value": "generic", "label": "Generic / no arch override"}, - ] - - -def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str: - value = (value or "auto").strip() - if value == "auto": - return str(profile.get("recommended_guest_arch") or "x86_64") - if value in ("x86_64", "amd64"): - return "x86_64" - if value in ("aarch64", "arm64"): - return "aarch64" - if value == "generic": - return "generic" - return str(profile.get("recommended_guest_arch") or "x86_64") +def guest_arch_for_host(profile: dict[str, Any]) -> str: + """Machines run natively: aarch64 on ARM boards, x86_64 everywhere else.""" + arch = str(profile.get("recommended_guest_arch") or profile.get("arch") or platform.machine() or "x86_64") + return "aarch64" if arch in ("aarch64", "arm64") else "x86_64" def list_disk_image_files() -> list[dict[str, str]]: @@ -619,10 +603,16 @@ def disk_image_path_by_name(name: str) -> Path | None: def disk_image_format(path: Path) -> str: - suffix = path.suffix.lower().lstrip('.') - if suffix == 'qcow2': - return 'qcow2' - return 'raw' + """Real image format: Ubuntu cloud images are qcow2 despite the .img name, so the suffix is only a fallback.""" + info = run_cmd(["qemu-img", "info", "--output=json", str(path)], timeout=30) + if info.get("ok"): + try: + fmt = str(json.loads(info.get("stdout") or "{}").get("format", "")).lower() + if fmt in ("qcow2", "raw", "vmdk", "vdi", "vpc", "vhdx", "qcow"): + return fmt + except (json.JSONDecodeError, AttributeError): + pass + return 'qcow2' if path.suffix.lower() == '.qcow2' else 'raw' def bridge_exists(name: str) -> bool: @@ -909,7 +899,6 @@ def boot_order_to_devs(value: str) -> list[str]: "disk": ["hd"], "cdrom_disk": ["cdrom", "hd"], "disk_cdrom": ["hd", "cdrom"], - "network_disk": ["network", "hd"], } return mapping.get(value, ["hd"]) @@ -920,8 +909,6 @@ def boot_devs_to_order(devs: list[str]) -> str: return "cdrom_disk" if clean[:2] == ["hd", "cdrom"]: return "disk_cdrom" - if clean[:2] == ["network", "hd"]: - return "network_disk" if clean[:1] == ["hd"]: return "disk" return "auto" @@ -949,7 +936,7 @@ def current_vm_boot_order(name: str) -> str: def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]: if not valid_vm_name(name) or not vm_exists(name): return False, "VM не найдена." - if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"): + if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom"): return False, "Некорректный порядок загрузки VM." selected = normalize_boot_order(boot_order, "disk_image") @@ -1042,18 +1029,16 @@ def vm_resource_settings(name: str) -> dict[str, Any]: } -def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]: +def apply_vm_resources(name: str, memory_mb: int, vcpus: int) -> tuple[bool, str]: if not valid_vm_name(name) or not vm_exists(name): return False, "VM не найдена." resources = vm_resource_settings(name) if not resources.get("is_shutoff"): - return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM." + return False, "Процессор и память можно менять только когда машина выключена." if memory_mb < 512 or memory_mb > 262144: return False, "RAM должна быть от 512 MB до 262144 MB." if vcpus < 1 or vcpus > 128: return False, "CPU должен быть от 1 до 128 vCPU." - if guest_arch not in ("keep", "x86_64", "aarch64"): - return False, "Некорректная архитектура VM." result = run_cmd(["virsh", "dumpxml", name], timeout=15) if not result.get("ok"): @@ -1077,22 +1062,6 @@ def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) - vcpu_node.text = str(int(vcpus)) vcpu_node.set("placement", "static") - arch_changed = False - if guest_arch != "keep": - os_type = root.find("os/type") - if os_type is None: - os_node = root.find("os") - if os_node is None: - os_node = ET.SubElement(root, "os") - os_type = ET.SubElement(os_node, "type") - os_type.text = "hvm" - old_arch = os_type.attrib.get("arch", "") - if old_arch != guest_arch: - os_type.set("arch", guest_arch) - if guest_arch == "aarch64": - os_type.set("machine", "virt") - arch_changed = True - xml_text = ET.tostring(root, encoding="unicode") with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle: handle.write(xml_text) @@ -1103,11 +1072,7 @@ def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) - Path(tmp_name).unlink(missing_ok=True) if not define.get("ok"): return False, define.get("stderr") or "virsh define завершился ошибкой." - - message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB" - if arch_changed: - message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик." - return True, message + return True, f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB" def vm_state(name: str) -> str: @@ -1128,45 +1093,47 @@ def current_vm_iso(name: str) -> str: return "" +def vm_cdrom_devices(name: str) -> list[dict[str, str]]: + """CD-ROM drives of the VM from its XML: [{"target": "sda", "source": "/path.iso" or ""}].""" + xml = run_cmd(["virsh", "dumpxml", name], timeout=12) + drives: list[dict[str, str]] = [] + if not xml.get("ok"): + return drives + try: + root = ET.fromstring(xml.get("stdout") or "") + except ET.ParseError: + return drives + devices = root.find("devices") + for disk in devices.findall("disk") if devices is not None else []: + if disk.attrib.get("device") != "cdrom": + continue + target = disk.find("target") + source = disk.find("source") + dev = target.attrib.get("dev", "") if target is not None else "" + if dev: + drives.append({"target": dev, "source": (source.attrib.get("file") or source.attrib.get("dev") or "") if source is not None else ""}) + return drives + + +def change_media_flags(name: str) -> list[str]: + # --live --config: the running VM and its saved configuration change together. + return ["--config", "--live"] if vm_state(name) == "running" else ["--config"] + + def detach_vm_iso(name: str) -> tuple[bool, str]: if not valid_vm_name(name) or not vm_exists(name): return False, "VM не найдена." - xml = run_cmd(["virsh", "dumpxml", name], timeout=12) - cdrom_targets: list[str] = [] - if xml.get("ok"): - try: - root = ET.fromstring(xml.get("stdout") or "") - devices = root.find("devices") - if devices is not None: - for disk in devices.findall("disk"): - if disk.attrib.get("device") != "cdrom": - continue - target = disk.find("target") - dev = target.attrib.get("dev") if target is not None else "" - if dev: - cdrom_targets.append(dev) - except Exception: - pass - if not cdrom_targets: - cdrom_targets = ["sda", "hda", "sdb", "hdc"] - - running = vm_state(name) == "running" + drives = [drive for drive in vm_cdrom_devices(name) if drive["source"]] + if not drives: + return False, "Подключенный ISO не найден." errors = [] - changed_any = False - for target in cdrom_targets: - commands = [] - if running: - commands.append(["virsh", "detach-disk", name, target, "--live"]) - commands.append(["virsh", "detach-disk", name, target, "--config"]) - for cmd in commands: - result = run_cmd(cmd, timeout=30) - if result.get("ok"): - changed_any = True - elif result.get("stderr"): - errors.append(result.get("stderr")) - if changed_any: - return True, "ISO был отмонтирован." - return False, errors[-1] if errors else "Подключенный ISO не найден." + for drive in drives: + result = run_cmd(["virsh", "change-media", name, drive["target"], "--eject", *change_media_flags(name)], timeout=30) + if not result.get("ok"): + errors.append(result.get("stderr") or "virsh change-media завершился с ошибкой") + if errors: + return False, errors[-1] + return True, "ISO был отмонтирован." def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]: @@ -1180,17 +1147,19 @@ def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]: if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists(): return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso." - detach_vm_iso(name) - running = vm_state(name) == "running" - base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"] - if running: - live = run_cmd(base + ["--live"], timeout=30) - if not live.get("ok"): - return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM." - config = run_cmd(base + ["--config"], timeout=30) - if not config.get("ok"): - return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM." - return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС." + drives = vm_cdrom_devices(name) + if drives: + result = run_cmd(["virsh", "change-media", name, drives[0]["target"], str(iso), "--update", *change_media_flags(name)], timeout=30) + if not result.get("ok"): + return False, result.get("stderr") or "Не удалось подключить ISO." + return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС." + # No CD-ROM drive at all: add one to the configuration; a SATA drive appears after the next start. + result = run_cmd(["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly", "--config"], timeout=30) + if not result.get("ok"): + return False, result.get("stderr") or "Не удалось добавить CD-ROM в конфигурацию VM." + if vm_state(name) == "running": + return True, "CD-ROM добавлен в конфигурацию. Он появится в машине после её перезапуска." + return True, "ISO был смонтирован в VM." def vm_details(name: str) -> dict[str, Any]: @@ -1244,7 +1213,6 @@ def vm_boot_order_options() -> list[dict[str, str]]: {"value": "disk", "label": "Сначала диск"}, {"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"}, {"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"}, - {"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"}, ] @@ -1252,7 +1220,7 @@ def normalize_boot_order(value: str, source_type: str) -> str: value = (value or "auto").strip() if value == "auto": return "cdrom_disk" if source_type == "iso" else "disk" - if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"): + if value in ("disk", "cdrom_disk", "disk_cdrom"): return value return "cdrom_disk" if source_type == "iso" else "disk" @@ -1262,7 +1230,6 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str: "disk": "hd", "cdrom_disk": "cdrom,hd", "disk_cdrom": "hd,cdrom", - "network_disk": "network,hd", } value = mapping.get(boot_order, "hd") if is_arm: @@ -1273,7 +1240,7 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str: def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200): profile = host_profile.load_host_profile() default_mode = profile.get("recommended_network", "nat") - return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code) + return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code) # Virtuality noVNC console patch @@ -1773,7 +1740,7 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""): if not iso and not image: return vm_form_context(request) profile = host_profile.load_host_profile() - form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE} + form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE} if iso: form["iso_path"] = str(ISO_DIR / Path(iso).name) if image: @@ -1782,11 +1749,11 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""): @app.post("/vm/create", response_class=HTMLResponse) -def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)): +def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge} + form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge} error = None if not valid_vm_name(name): error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа." @@ -1804,9 +1771,7 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form error = "Некорректное имя bridge." elif source_type not in ("iso", "disk_image"): error = "Некорректный источник VM." - elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"): - error = "Некорректная архитектура VM." - elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"): + elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom"): error = "Некорректный порядок загрузки VM." elif network_mode == "bridge" and not bridge_exists(bridge): error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}." @@ -1834,17 +1799,13 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400) profile = host_profile.load_host_profile() - selected_arch = normalize_guest_arch(guest_arch, profile) + selected_arch = guest_arch_for_host(profile) selected_boot_order = normalize_boot_order(boot_order, source_type) - host_arch = str(profile.get("arch") or platform.machine() or "") is_arm = selected_arch == "aarch64" - # ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation. - virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu") + virt_type = "kvm" if profile.get("kvm_device") else "qemu" network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio" cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type] - if selected_arch == "x86_64": - cmd += ["--arch", "x86_64"] - elif is_arm: + if is_arm: cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57"] cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)] @@ -1853,10 +1814,10 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form source_disk = Path(disk_image_path).resolve() source_format = disk_image_format(source_disk) convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}" - virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]) + virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=127.0.0.1", "--noautoconsole"]) cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"] else: - cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"] + cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=127.0.0.1", "--noautoconsole"] operation_id = str(uuid.uuid4()) operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "cmd": " ".join(cmd)} @@ -1924,11 +1885,11 @@ def vm_detail_page(request: Request, name: str): @app.post("/vm/{name}/resources") -def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep")): +def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...)): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch) + ok, message = apply_vm_resources(name, memory_mb, vcpus) if ok: return redirect_with_message(f"/vm/{name}", "resource_message", message) return redirect_with_message(f"/vm/{name}", "resource_error", message) @@ -1967,6 +1928,23 @@ def vm_iso_unmount_apply(request: Request, name: str): return redirect_with_message(f"/vm/{name}", "iso_error", message) +VIRSH_ERRORS = { + "Domain is already active": "Машина уже запущена", + "domain is not running": "Машина не запущена", + "Domain not found": "Машина не найдена", + "Requested operation is not valid: domain is not running": "Машина не запущена", +} + + +def virsh_error(result: dict[str, Any]) -> str: + text = (result.get("stderr") or result.get("stdout") or "virsh завершился с ошибкой").strip() + text = re.sub(r"^error: ", "", text, flags=re.M) + for needle, human in VIRSH_ERRORS.items(): + if needle.lower() in text.lower(): + return human + return text.splitlines()[0][:300] if text else "virsh завершился с ошибкой" + + @app.post("/vm/{name}/{action}") def vm_action(request: Request, name: str, action: str): auth_redirect = require_auth(request) @@ -1976,12 +1954,18 @@ def vm_action(request: Request, name: str, action: str): return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400) allowed = {"start": ["virsh", "start", name], "shutdown": ["virsh", "shutdown", name], "reboot": ["virsh", "reboot", name], "destroy": ["virsh", "destroy", name], "autostart": ["virsh", "autostart", name], "autostart-disable": ["virsh", "autostart", "--disable", name]} if action == "delete": - run_cmd(["virsh", "destroy", name], timeout=20) - run_cmd(["virsh", "undefine", name, "--remove-all-storage"], timeout=60) - return RedirectResponse(url="/", status_code=303) + if vm_state(name) == "running": + run_cmd(["virsh", "destroy", name], timeout=20) + # --nvram: UEFI machines keep firmware variables that block a plain undefine. + result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120) + if not result["ok"]: + return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}") + return redirect_with_message("/", "message", f"Машина {name} удалена") if action not in allowed: return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400) - run_cmd(allowed[action], timeout=30) + result = run_cmd(allowed[action], timeout=30) + if not result["ok"]: + return redirect_with_message(f"/vm/{name}", "error", virsh_error(result)) return RedirectResponse(url=f"/vm/{name}", status_code=303) diff --git a/web/network_core.py b/web/network_core.py index dade7a7..49b7f9c 100644 --- a/web/network_core.py +++ b/web/network_core.py @@ -1,5 +1,6 @@ import json import re +import shlex import socket import subprocess import uuid @@ -9,6 +10,7 @@ from typing import Any CONFIG_DIR = Path('/var/lib/virtuality/config') NETWORK_DIR = Path('/var/lib/virtuality/network') NFT_DIR = Path('/etc/virtuality/nftables') +UFW_STATE_FILE = NETWORK_DIR / 'ufw_rules.json' PORT_FORWARDS_FILE = NETWORK_DIR / 'port_forwards.json' NAT_XML_FILE = NETWORK_DIR / 'virtuality-nat.xml' NFT_FILE = NFT_DIR / 'virtuality.nft' @@ -415,6 +417,25 @@ def render_nft_rules() -> str: return '\n'.join(lines) + '\n' +def ufw_rules_for(items: list[dict[str, Any]], ext: str) -> list[list[str]]: + rules: list[list[str]] = [] + for raw_item in items: + item = normalize_forward(raw_item) + guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end']) + external_port = iptables_port_value(item['external_port_start'], item['external_port_end']) + rules.append(['route', 'allow', 'in', 'on', ext, 'out', 'on', forward_guest_interface(item), 'to', item['guest_ip'], 'port', guest_port, 'proto', item['protocol']]) + rules.append(['allow', f"{external_port}/{item['protocol']}"]) + return rules + + +def load_ufw_state() -> list[list[str]]: + try: + data = json.loads(UFW_STATE_FILE.read_text()) + return [list(map(str, rule)) for rule in data] if isinstance(data, list) else [] + except (OSError, ValueError): + return [] + + def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]: ext = external_interface() results: list[dict[str, Any]] = [] @@ -423,20 +444,16 @@ def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]: status = run_cmd(['ufw', 'status'], timeout=8) if 'Status: active' not in status['stdout']: return results - for raw_item in items: - item = normalize_forward(raw_item) - guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end']) - external_port = iptables_port_value(item['external_port_start'], item['external_port_end']) - cmd = [ - 'ufw', 'route', 'allow', - 'in', 'on', ext, - 'out', 'on', forward_guest_interface(item), - 'to', item['guest_ip'], - 'port', guest_port, - 'proto', item['protocol'], - ] - results.append(run_cmd(cmd, timeout=15)) - results.append(run_cmd(['ufw', 'allow', f"{external_port}/{item['protocol']}"], timeout=15)) + wanted = ufw_rules_for(items, ext) + # Rules of forwards that were removed (or moved to another port) are deleted; ufw only keeps what is still wanted. + for rule in load_ufw_state(): + if rule not in wanted: + delete = ['ufw', 'route', 'delete', *rule[1:]] if rule[0] == 'route' else ['ufw', 'delete', *rule] + results.append(run_cmd(delete, timeout=15)) + for rule in wanted: + results.append(run_cmd(['ufw', *rule], timeout=15)) + ensure_dirs() + UFW_STATE_FILE.write_text(json.dumps(wanted)) run_cmd(['ufw', 'reload'], timeout=20) return results @@ -456,11 +473,28 @@ def ensure_iptables_rule(cmd: list[str]) -> dict[str, Any]: return run_cmd(cmd, timeout=10) +IPTABLES_TAG = ['-m', 'comment', '--comment', 'virtuality-forward'] + + +def clear_iptables_fallback() -> None: + """Delete every rule this module added earlier (they carry the virtuality-forward comment).""" + for table, chains in (('filter', ['FORWARD']), ('nat', ['PREROUTING', 'POSTROUTING'])): + for chain in chains: + listing = run_cmd(['iptables', '-t', table, '-S', chain], timeout=8) + if not listing['ok']: + continue + for line in listing['stdout'].splitlines(): + if 'virtuality-forward' not in line or not line.startswith('-A '): + continue + run_cmd(['iptables', '-t', table, '-D', *shlex.split(line)[1:]], timeout=8) + + def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]]: ext = external_interface() results: list[dict[str, Any]] = [] if not run_cmd(['sh', '-lc', 'command -v iptables >/dev/null 2>&1'], timeout=5)['ok']: return results + clear_iptables_fallback() for raw_item in items: item = normalize_forward(raw_item) proto = item['protocol'] @@ -470,11 +504,11 @@ def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]] guest_to_port = iptables_dnat_port_value(item['guest_port_start'], item['guest_port_end']) guest_to = f"{guest_ip}:{guest_to_port}" guest_iface = forward_guest_interface(item) - results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT'])) - results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT'])) - results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to])) - results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE'])) - results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, '-j', 'MASQUERADE'])) + results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, *IPTABLES_TAG, '-j', 'ACCEPT'])) + results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', *IPTABLES_TAG, '-j', 'ACCEPT'])) + results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, *IPTABLES_TAG, '-j', 'DNAT', '--to-destination', guest_to])) + results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, *IPTABLES_TAG, '-j', 'MASQUERADE'])) + results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, *IPTABLES_TAG, '-j', 'MASQUERADE'])) return results diff --git a/web/static/panel.js b/web/static/panel.js index e67ae91..38598b7 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -67,6 +67,8 @@ const raw = sessionStorage.getItem('virtualityToast'); if (!raw) return; sessionStorage.removeItem('virtualityToast'); + // The toast was queued when the form was submitted; if the page came back with an error, the action failed. + if (document.querySelector('.alert.danger') || /(^|[?&_])error=/.test(location.search)) return; const data = JSON.parse(raw); toast(data.message, data.tone); } catch (_) {} diff --git a/web/templates/vm_create.html b/web/templates/vm_create.html index cb89a2c..421f3e4 100644 --- a/web/templates/vm_create.html +++ b/web/templates/vm_create.html @@ -126,19 +126,11 @@ {{ icon('wrench', 'icon-sm') }}Дополнительные настройки{{ icon('chevron-down', 'icon-sm') }} - - - Архитектура - - {% for arch in arch_options %}{{ arch.label }}{% endfor %} - - - - Порядок загрузки - - {% for opt in boot_options %}{{ opt.label }}{% endfor %} - - + + Порядок загрузки + + {% for opt in boot_options %}{{ opt.label }}{% endfor %} + Сетевой мост для режима «В локальной сети» diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index b0f5194..984f682 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -40,6 +40,8 @@ {% endblock %} {% block alerts %} + {% if request.query_params.get('error') %}{{ alert('danger', request.query_params.get('error')) }}{% endif %} + {% if request.query_params.get('message') %}{{ alert('success', request.query_params.get('message')) }}{% endif %} {% for key in ('resource', 'iso', 'boot') %} {% set msg = {'resource': resource_message, 'iso': iso_message, 'boot': boot_message}[key] %} {% set err = {'resource': resource_error, 'iso': iso_error, 'boot': boot_error}[key] %} @@ -139,17 +141,6 @@ 1024 МБ = 1 ГБ - - {{ icon('wrench', 'icon-sm') }}Архитектура процессора{{ icon('chevron-down', 'icon-sm') }} - - - Не менять — {{ rs.arch|default('неизвестно') }} - x86_64 — обычные ПК и серверы - ARM64 — образы для Raspberry Pi и ARM-серверов - - Меняйте, только если точно знаете, зачем: система внутри может перестать загружаться. - -