From 7ec3dc60828fce7ae0478ce98353419305632869 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:17:23 +0000 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0:=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=20virsh,=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BC=D0=B0=D1=88=D0=B8=D0=BD,=20IP,=20IS?= =?UTF-8?q?O=20=D0=B2=20=D0=BF=D1=80=D0=B8=D0=B2=D0=BE=D0=B4=D0=B5,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - все команды запускаются с LC_ALL=C.UTF-8: русский перевод virsh ломал разбор состояния машин - имя машины не может быть числом или UUID (virsh принял бы его за ID другой машины) - IP без MAC машины не угадывается по чужой аренде DHCP; у выключенных машин нет устаревшего IP - вставленный ISO определяется по устройству CD-ROM, а не по «.iso» в пути - ISO, вставленный в привод машины, нельзя удалить — машина перестала бы запускаться - на ARM-платах нет ложного предупреждения про флаги vmx/svm - проверка машины в консоли не блокирует остальные запросы Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- scripts/virtuality-ctl | 1 + tests/test_audit_fixes.py | 57 +++++++++++++++++++++++++++++++++++++++ web/app.py | 35 ++++++++++++++---------- web/core.py | 9 ++++++- web/host_profile.py | 3 ++- web/network_core.py | 20 ++++++++++---- web/templates/iso.html | 2 +- 7 files changed, 105 insertions(+), 22 deletions(-) diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl index ec4d25a..395f905 100755 --- a/scripts/virtuality-ctl +++ b/scripts/virtuality-ctl @@ -140,6 +140,7 @@ LimitNOFILE=65536 User=root Group=root Environment=PYTHONUNBUFFERED=1 +Environment=LC_ALL=C.UTF-8 Environment=VIRTUALITY_AUTH_USER=${auth_user} Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR} Environment=VIRTUALITY_NODE_ENV=${NODE_ENV} diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py index 17ef862..d93d87e 100644 --- a/tests/test_audit_fixes.py +++ b/tests/test_audit_fixes.py @@ -144,3 +144,60 @@ def test_snapshot_names_with_spaces_are_listed(): rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n") assert [row["name"] for row in rows] == ["before update", "clean"] + + +# ---------------------------------------------------------------- second audit round +def test_vm_names_that_virsh_reads_as_id_or_uuid_are_rejected(): + import core + + for module in (app, core, network_core): + assert module.valid_vm_name("web01") and module.valid_vm_name("1c-server") + assert not module.valid_vm_name("10") and not module.valid_vm_name("6c1b3c1e-0000-0000-0000-000000000001") + + +def test_subprocesses_use_c_locale(): + import os + + import core + + assert core.STORAGE_DIR and os.environ["LC_ALL"] == "C.UTF-8" # set when core is imported + + +def test_lease_is_not_guessed_without_mac(data_dirs, monkeypatch): + recording(monkeypatch, network_core, [ + (["virsh", "domifaddr"], conftest.fake_result(ok=False, stderr="domain is not running")), + (["virsh", "domiflist"], conftest.fake_result(ok=False, stderr="failed to get domain")), + (["virsh", "net-dhcp-leases"], conftest.fake_result(" 2026-09-26 10:00:00 52:54:00:11:22:33 ipv4 192.168.100.77/24 other -")), + ]) + assert network_core.resolve_vm_ip("gone") is None + + +def test_stopped_machines_have_no_stale_ip(data_dirs, monkeypatch): + calls = recording(monkeypatch, app) + rows = {row["name"]: row for row in app.parse_virsh_list()} + assert rows["db01"]["ip"] == "—" and rows["web01"]["ip"] == "192.168.100.51" + assert not [cmd for cmd in calls if cmd[:2] == ["virsh", "domifaddr"] and cmd[-1] == "db01"] + + +def test_system_disk_named_like_iso_is_not_shown_as_iso(data_dirs, monkeypatch): + xml = conftest.DUMPXML.replace("", "") + recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml))]) + assert app.current_vm_iso("web01") == "" + + +def test_iso_in_use_is_not_deleted(logged_in, data_dirs, monkeypatch): + iso = data_dirs["iso"] / "old.iso" + iso.write_bytes(b"iso") + xml = CDROM_XML.replace("/var/lib/virtuality/iso/old.iso", str(iso)) + recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml)), (["virsh", "list", "--all", "--name"], conftest.fake_result("web01\n"))]) + response = logged_in.post("/iso/old.iso/delete", follow_redirects=False) + assert "error=" in response.headers["location"] and iso.exists() + + +def test_arm_board_is_ready_without_vmx_flags(monkeypatch): + import host_profile + + monkeypatch.setattr(host_profile.platform, "machine", lambda: "aarch64") + monkeypatch.setattr(host_profile, "cpu_virtualization_flags_count", lambda: 0) + profile = host_profile.detect_host_profile() + assert not [check for check in profile["checks"] if check["name"] == "Поддержка виртуализации процессором"] diff --git a/web/app.py b/web/app.py index 4b94a08..c2bba90 100644 --- a/web/app.py +++ b/web/app.py @@ -467,11 +467,13 @@ def parse_virsh_list() -> list[dict[str, str]]: manual_ips = manual_ip_map() - def resolve_ip(name: str) -> str: + def resolve_ip(name: str, state: str = "running") -> str: if not name: return "—" if manual_ips.get(name): return manual_ips[name] + if "running" not in state and "paus" not in state: + return "—" macs = vm_macs(name) for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)): try: @@ -499,7 +501,7 @@ def parse_virsh_list() -> list[dict[str, str]]: label = "enabled" if enabled else "disabled" if info["autostart_known"] else "unknown" rows.append({"id": vm_id, "name": name, "state": state, "autostart_enabled": enabled, "autostart_label": label, "autostart_css": "ok" if enabled else "warn", "vcpus": info["vcpus"], "memory_mb": info["memory_mb"]}) for row in rows: - row["ip"] = resolve_ip(row.get("name", "")) + row["ip"] = resolve_ip(row.get("name", ""), row.get("state", "")) row["manual_ip"] = manual_ips.get(row.get("name", ""), "") return rows @@ -859,7 +861,10 @@ def disk_upload_response(request: Request, payload: dict[str, Any]): def valid_vm_name(name: str) -> bool: - return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or "")) + """Latin letters, digits, dot, dash, underscore; never a bare number or a UUID — virsh would read those as an ID/UUID of another machine.""" + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""): + return False + return not name.isdigit() and not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", name) def vm_exists(name: str) -> bool: @@ -1096,16 +1101,14 @@ def vm_state(name: str) -> str: def current_vm_iso(name: str) -> str: - result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10) - if not result.get("ok"): - return "" - for line in (result.get("stdout") or "").splitlines(): - if ".iso" not in line.lower(): - continue - parts = line.split() - if parts: - return parts[-1] - return "" + return next((drive["source"] for drive in vm_cdrom_devices(name) if drive["source"]), "") + + +def vms_using_iso(path: Path) -> list[str]: + """Machines that have this ISO in their CD-ROM drive (they would not start without the file).""" + listed = run_cmd(["virsh", "list", "--all", "--name"], timeout=10) + target = str(path) + return [name for name in (listed.get("stdout") or "").split() if any(drive["source"] == target for drive in vm_cdrom_devices(name))] def vm_cdrom_devices(name: str) -> list[dict[str, str]]: @@ -1397,6 +1400,9 @@ def iso_delete(request: Request, name: str): return auth_redirect path = iso_path_by_name(name) if path and path.exists() and path.is_file(): + users = vms_using_iso(path) + if users: + return redirect_with_message("/iso", "error", f"Образ вставлен в привод машин: {', '.join(users)}. Сначала извлеките его на странице машины — иначе машина не запустится.") path.unlink() refresh_iso_pool() return RedirectResponse(url="/iso", status_code=303) @@ -1909,7 +1915,8 @@ async def console_websocket(websocket: WebSocket, token: str): except Exception: await websocket.close(code=1008) return - if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999: + # vm_exists runs virsh: in a thread, so one slow call does not freeze every console and page. + if not valid_vm_name(vm_name) or target_port < 5900 or target_port > 5999 or not await asyncio.to_thread(vm_exists, vm_name): await websocket.close(code=1008) return await websocket.accept() diff --git a/web/core.py b/web/core.py index c87124c..4393ec8 100644 --- a/web/core.py +++ b/web/core.py @@ -24,6 +24,10 @@ import update_core BASE_DIR = Path(__file__).resolve().parent APP_NAME = "Virtuality" ENV_FILE = BASE_DIR / ".env" +# virsh, ip and qemu-img print translated text under a Russian locale; the panel parses their output. +os.environ["LC_ALL"] = "C.UTF-8" +os.environ["LANG"] = "C.UTF-8" + STORAGE_DIR = Path("/var/lib/virtuality") ISO_DIR = Path("/var/lib/virtuality/iso") IMAGES_DIR = Path("/var/lib/virtuality/images") @@ -199,7 +203,10 @@ def tail_text(path: Path, max_lines: int = 220) -> str: def valid_vm_name(name: str) -> bool: - return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or "")) + """Latin letters, digits, dot, dash, underscore; never a bare number or a UUID — virsh would read those as an ID/UUID of another machine.""" + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""): + return False + return not name.isdigit() and not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", name) def valid_label(name: str) -> bool: diff --git a/web/host_profile.py b/web/host_profile.py index 6804bcb..f9f3ec9 100644 --- a/web/host_profile.py +++ b/web/host_profile.py @@ -153,7 +153,8 @@ def detect_host_profile() -> dict[str, Any]: } checks = [] checks.append({'name': 'Аппаратное ускорение (KVM)', 'ok': data['kvm_device'], 'level': 'ok' if data['kvm_device'] else 'warn', 'hint': 'KVM недоступен. На физическом сервере включи Intel VT-x / AMD-V в BIOS/UEFI; на VPS проверь nested virtualization. Пока доступен медленный QEMU fallback.'}) - checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'}) + if not is_arm: # ARM CPUs have no vmx/svm flags; /dev/kvm above is what matters there + checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'}) if is_arm: checks.append({'name': 'Эмулятор ARM64 (QEMU)', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'}) checks.append({'name': 'Загрузчик UEFI для ARM64', 'ok': data['uefi_aarch64_hint'], 'level': 'ok' if data['uefi_aarch64_hint'] else 'warn', 'hint': 'Пакет qemu-efi-aarch64 или AAVMF.'}) diff --git a/web/network_core.py b/web/network_core.py index 5ce94df..e806891 100644 --- a/web/network_core.py +++ b/web/network_core.py @@ -26,6 +26,13 @@ class NetworkError(Exception): pass +def valid_vm_name(name: str) -> bool: + """Same rule as core.valid_vm_name (kept here: this module does not import core).""" + if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', name or ''): + return False + return not name.isdigit() and not re.fullmatch(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', name) + + def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]: try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) @@ -206,7 +213,7 @@ def _net_update(action: str, host_xml: str) -> dict[str, Any]: def reserve_nat_address(vm_name: str, mac: str) -> str | None: """Pin the machine to one NAT address for its whole life; None when libvirt refused.""" - if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name or ''): + if not valid_vm_name(vm_name): return None reservations = nat_reservations() for item in reservations: @@ -360,13 +367,16 @@ def resolve_vm_ip(vm_name: str) -> str | None: if match: return match.group(1) + macs = set(vm_mac_addresses(vm_name)) + if not macs: + # Without the machine's MAC any lease would be a guess — possibly another machine's address. + return None leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8) if not leases['ok']: return None - macs = set(vm_mac_addresses(vm_name)) for line in leases['stdout'].splitlines(): low = line.lower() - if macs and not any(mac in low for mac in macs): + if not any(mac in low for mac in macs): continue match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', line) if match: @@ -383,7 +393,7 @@ def tcp_connect_check(host: str, port: int, timeout: float = 2.0) -> dict[str, A def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port: Any, protocol: str, note: str = '') -> dict[str, Any]: - if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name): + if not valid_vm_name(vm_name): raise NetworkError('Некорректное имя VM') if guest_ip == 'auto': resolved_ip = resolve_vm_ip(vm_name) @@ -611,7 +621,7 @@ def find_matching_forward(forwards: list[dict[str, Any]], vm_name: str, external def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, protocol: str = 'tcp') -> dict[str, Any]: - if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name): + if not valid_vm_name(vm_name): raise NetworkError('Некорректное имя VM') if not valid_port(external_port) or not valid_port(guest_port): raise NetworkError('Порт должен быть от 1 до 65535') diff --git a/web/templates/iso.html b/web/templates/iso.html index 835c3b5..1a44995 100644 --- a/web/templates/iso.html +++ b/web/templates/iso.html @@ -21,7 +21,7 @@ {{ icon('disc-3') }}
{{ iso.name }}
{{ iso.size|replace('MB', 'МБ') }} · загружен {{ iso.updated }}
Создать машину -
+