#!/usr/bin/env python3 from pathlib import Path import re import sys app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') changed: list[str] = [] warnings: list[str] = [] if not app_path.exists(): print(f'WARN: app.py not found: {app_path}') raise SystemExit(0) templates_dir = app_path.parent / 'templates' dashboard_template_path = templates_dir / 'dashboard.html' network_template_path = templates_dir / 'network.html' if not dashboard_template_path.exists(): print(f'WARN: dashboard.html not found: {dashboard_template_path}') raise SystemExit(0) text = app_path.read_text(encoding='utf-8') text, removed_helpers = re.subn( r"\n\ndef parse_dhcp_leases_output\(output: str\).*?\n\ndef parse_virsh_list\(\) -> list\[dict\[str, str\]\]:", "\n\ndef parse_virsh_list() -> list[dict[str, str]]:", text, count=1, flags=re.S, ) if removed_helpers: changed.append('old VM IP helper block was removed') new_parse_virsh_list = '''def parse_virsh_list() -> list[dict[str, str]]: def clean_ip(ip: str) -> str: ip = (ip or "").split("/")[0].strip() if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0": return "" return ip def manual_ip_map() -> dict[str, str]: path = Path("/var/lib/virtuality/network/vm_ips.json") if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))} except Exception: pass return {} def vm_macs(name: str) -> list[str]: try: result = run_cmd(["virsh", "domiflist", name], timeout=8) if not result.get("ok"): return [] return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))] except Exception: return [] def ip_from_domifaddr(name: str) -> str: try: result = run_cmd(["virsh", "domifaddr", name], timeout=8) if result.get("ok"): for ip in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})/\\d+", result.get("stdout", "")): ip = clean_ip(ip) if ip: return ip except Exception: pass return "" def ip_from_network_core(name: str) -> str: try: resolved = network_core.resolve_vm_ip(name) if resolved: return clean_ip(str(resolved)) except Exception: pass return "" def ip_from_dnsmasq_leases(macs: list[str]) -> str: if not macs: return "" try: for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"): for line in lease_file.read_text(errors="ignore").splitlines(): low = line.lower() if not any(mac in low for mac in macs): continue parts = line.split() if len(parts) >= 3: ip = clean_ip(parts[2]) if ip: return ip except Exception: pass return "" def ip_from_neighbor_tables(macs: list[str]) -> str: if not macs: return "" commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]] for cmd in commands: try: result = run_cmd(cmd, timeout=8) if not result.get("ok"): continue for line in result.get("stdout", "").splitlines(): low = line.lower() if not any(mac in low for mac in macs): continue for value in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})\\b", line): ip = clean_ip(value) if ip: return ip except Exception: pass return "" manual_ips = manual_ip_map() def resolve_ip(name: str) -> str: if not name: return "—" if manual_ips.get(name): return manual_ips[name] 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: ip = resolver() if ip: return ip except Exception: pass return "—" result = run_cmd(["virsh", "list", "--all"]) rows = [] if not result["ok"]: return rows for line in result["stdout"].splitlines()[2:]: parts = line.strip().split(None, 2) if len(parts) == 3: rows.append({"id": parts[0], "name": parts[1], "state": parts[2]}) elif len(parts) == 2: rows.append({"id": "-", "name": parts[0], "state": parts[1]}) for row in rows: row["ip"] = resolve_ip(row.get("name", "")) row["manual_ip"] = manual_ips.get(row.get("name", ""), "") return rows ''' pattern = r"def parse_virsh_list\(\) -> list\[dict\[str, str\]\]:.*?\n\ndef parse_pool_list\(\) -> list\[dict\[str, str\]\]:" replacement = new_parse_virsh_list + "\n\ndef parse_pool_list() -> list[dict[str, str]]:" text, replaced = re.subn(pattern, lambda _match: replacement, text, count=1, flags=re.S) if replaced: changed.append('parse_virsh_list was replaced with manual-first VM IP resolver') else: warnings.append('parse_virsh_list block not found, app.py IP injection skipped') manual_route = ''' @app.post("/network/vm-ip/save") def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect if not valid_vm_name(vm_name): return RedirectResponse(url="/network", status_code=303) manual_ip = (manual_ip or "").strip() if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}", manual_ip): return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400) path = Path("/var/lib/virtuality/network/vm_ips.json") path.parent.mkdir(parents=True, exist_ok=True) try: data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} if not isinstance(data, dict): data = {} except Exception: data = {} if manual_ip: data[vm_name] = manual_ip else: data.pop(vm_name, None) tmp = path.with_suffix(".json.tmp") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(path) return RedirectResponse(url="/network", status_code=303) ''' if '/network/vm-ip/save' not in text: marker = '\n\n@app.post("/network/nat/setup")' if marker in text: text = text.replace(marker, manual_route + marker, 1) changed.append('manual VM IP route was added') else: warnings.append('network NAT route marker not found, manual IP route skipped') else: changed.append('manual VM IP route was already present') app_path.write_text(text, encoding='utf-8') dashboard_html = dashboard_template_path.read_text(encoding='utf-8') header_old = 'IDNameStateActions' header_new = 'IDNameIPStateActions' name_cell = '''{{ vm.name }} {{ vm.name }} {{ vm.ip|default("—") }} IP' not in dashboard_html: dashboard_html = dashboard_html.replace(header_old, header_new).replace(name_cell, name_ip_cell).replace('colspan="4" class="muted">Виртуальных машин пока нет', 'colspan="5" class="muted">Виртуальных машин пока нет') changed.append('Dashboard VM IP column was added') elif 'vm.ip' not in dashboard_html: dashboard_html = dashboard_html.replace(name_cell, name_ip_cell) changed.append('Dashboard VM IP cell was added') else: changed.append('Dashboard VM IP column was already present') dashboard_template_path.write_text(dashboard_html, encoding='utf-8') if network_template_path.exists(): network_html = network_template_path.read_text(encoding='utf-8') manual_card = '''

Ручные IP VM

override
Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.
{% for vm in vms %} {% else %} {% endfor %}
VMТекущий IPРучной IPДействие
{{ vm.name }} {{ vm.ip|default("—") }}
VM пока нет
''' if 'Ручные IP VM' not in network_html: marker = '
\n
\n

Диагностика публичного доступа

' if marker in network_html: network_html = network_html.replace(marker, manual_card + '\n' + marker, 1) changed.append('manual VM IP card was added to network page') else: warnings.append('network diagnostics card marker not found, manual IP card skipped') else: changed.append('manual VM IP card was already present') network_template_path.write_text(network_html, encoding='utf-8') else: warnings.append('network.html not found, manual IP card skipped') print('DHCP leases empty-state patch completed:') for item in changed: print(f'- {item}') for item in warnings: print(f'WARN: {item}') raise SystemExit(0)