Исправлены ошибки по итогам аудита: формат образов, CD-ROM, действия с машинами, firewall, VNC
- формат образа диска определяется через qemu-img info (облачные .img — это qcow2) - ISO подключается/извлекается через virsh change-media, без угадывания устройств - действия start/shutdown/delete сообщают об ошибке virsh; удаление UEFI-машин (--nvram) - правила iptables/ufw для удалённых пробросов портов теперь удаляются - VNC машин слушает только 127.0.0.1; virtuality-ctl reconfigure чинит старые машины - зелёное уведомление не показывается, если действие вернуло ошибку - убраны смена архитектуры машины, эмуляция ARM64 на x86 и загрузка по PXE - tests/test_audit_fixes.py Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -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)"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>",
|
||||
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><source file='/var/lib/virtuality/iso/old.iso'/><target dev='sda' bus='sata'/></disk>",
|
||||
)
|
||||
|
||||
|
||||
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()) == []
|
||||
+108
-124
@@ -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)
|
||||
|
||||
|
||||
|
||||
+53
-19
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 (_) {}
|
||||
|
||||
@@ -126,19 +126,11 @@
|
||||
<details class="advanced">
|
||||
<summary>{{ icon('wrench', 'icon-sm') }}Дополнительные настройки<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
|
||||
<div class="advanced-body form-grid">
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="guest_arch">Архитектура</label>
|
||||
<select class="select" id="guest_arch" name="guest_arch">
|
||||
{% for arch in arch_options %}<option value="{{ arch.value }}" {% if form.guest_arch == arch.value %}selected{% endif %}>{{ arch.label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="boot_order">Порядок загрузки</label>
|
||||
<select class="select" id="boot_order" name="boot_order">
|
||||
{% for opt in boot_options %}<option value="{{ opt.value }}" {% if form.boot_order == opt.value %}selected{% endif %}>{{ opt.label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="boot_order">Порядок загрузки</label>
|
||||
<select class="select" id="boot_order" name="boot_order">
|
||||
{% for opt in boot_options %}<option value="{{ opt.value }}" {% if form.boot_order == opt.value %}selected{% endif %}>{{ opt.label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="bridge">Сетевой мост для режима «В локальной сети»</label>
|
||||
|
||||
@@ -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 @@
|
||||
<span class="hint">1024 МБ = 1 ГБ</span>
|
||||
</div>
|
||||
</div>
|
||||
<details class="advanced">
|
||||
<summary>{{ icon('wrench', 'icon-sm') }}Архитектура процессора<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
|
||||
<div class="advanced-body field">
|
||||
<select class="select" name="guest_arch" {% if not rs.is_shutoff %}disabled{% endif %}>
|
||||
<option value="keep">Не менять — {{ rs.arch|default('неизвестно') }}</option>
|
||||
<option value="x86_64">x86_64 — обычные ПК и серверы</option>
|
||||
<option value="aarch64">ARM64 — образы для Raspberry Pi и ARM-серверов</option>
|
||||
</select>
|
||||
<span class="hint">Меняйте, только если точно знаете, зачем: система внутри может перестать загружаться.</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="card-footer"><button type="submit" class="btn btn-primary" {% if not rs.is_shutoff %}disabled{% endif %}>Сохранить</button></div>
|
||||
</form>
|
||||
|
||||
Ссылка в новой задаче
Block a user