Добавлена поддержка NAT-сети и port forwarding в web-панель

Этот коммит содержится в:
Виктор
2026-05-06 21:39:15 +09:00
родитель 08d0317358
Коммит b02956fff2
+110 -78
Просмотреть файл
@@ -8,7 +8,6 @@ import shutil
import spwd import spwd
import subprocess import subprocess
import threading import threading
import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -20,6 +19,9 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, URLSafeSerializer from itsdangerous import BadSignature, URLSafeSerializer
import network_core
from network_core import NetworkError
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
APP_NAME = "Virtuality" APP_NAME = "Virtuality"
ENV_FILE = BASE_DIR / ".env" ENV_FILE = BASE_DIR / ".env"
@@ -30,11 +32,9 @@ DEFAULT_BRIDGE = "br0"
app = FastAPI(title="Virtuality Panel") app = FastAPI(title="Virtuality Panel")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
static_dir = BASE_DIR / "static" static_dir = BASE_DIR / "static"
static_dir.mkdir(exist_ok=True) static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
OP_LOCK = threading.Lock() OP_LOCK = threading.Lock()
@@ -43,8 +43,7 @@ def load_env() -> dict[str, str]:
if ENV_FILE.exists(): if ENV_FILE.exists():
for raw_line in ENV_FILE.read_text().splitlines(): for raw_line in ENV_FILE.read_text().splitlines():
line = raw_line.strip() line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line: if line and not line.startswith("#") and "=" in line:
continue
key, value = line.split("=", 1) key, value = line.split("=", 1)
data[key.strip()] = value.strip().strip('"').strip("'") data[key.strip()] = value.strip().strip('"').strip("'")
return data return data
@@ -61,15 +60,11 @@ def is_configured() -> bool:
def verify_linux_password(username: str, password: str) -> bool: def verify_linux_password(username: str, password: str) -> bool:
if not username or not password: if not username or not password or username != AUTH_USER:
return False
if username != AUTH_USER:
return False return False
try: try:
shadow = spwd.getspnam(username) shadow = spwd.getspnam(username)
except PermissionError: except (PermissionError, KeyError):
return False
except KeyError:
return False return False
stored_hash = shadow.sp_pwdp stored_hash = shadow.sp_pwdp
if stored_hash in ("!", "*", "!!", ""): if stored_hash in ("!", "*", "!!", ""):
@@ -85,14 +80,11 @@ def get_current_user(request: Request) -> str | None:
data = serializer.loads(token) data = serializer.loads(token)
except BadSignature: except BadSignature:
return None return None
if data.get("user") == AUTH_USER: return AUTH_USER if data.get("user") == AUTH_USER else None
return AUTH_USER
return None
def require_auth(request: Request): def require_auth(request: Request):
user = get_current_user(request) if not get_current_user(request):
if not user:
return RedirectResponse(url="/login", status_code=303) return RedirectResponse(url="/login", status_code=303)
return None return None
@@ -125,10 +117,9 @@ def tail_text(path: Path, max_lines: int = 220) -> str:
if not path.exists(): if not path.exists():
return "" return ""
try: try:
lines = path.read_text(errors="replace").splitlines() return "\n".join(path.read_text(errors="replace").splitlines()[-max_lines:])
except OSError: except OSError:
return "" return ""
return "\n".join(lines[-max_lines:])
def write_operation(operation: dict[str, Any]) -> None: def write_operation(operation: dict[str, Any]) -> None:
@@ -171,19 +162,12 @@ def list_operations(limit: int = 25) -> list[dict[str, Any]]:
def append_operation_log(operation_id: str, message: str) -> None: def append_operation_log(operation_id: str, message: str) -> None:
ensure_operations_dir() ensure_operations_dir()
line = message.rstrip("\n")
with operation_log_path(operation_id).open("a", encoding="utf-8") as handle: with operation_log_path(operation_id).open("a", encoding="utf-8") as handle:
handle.write(f"[{utc_now()}] {line}\n") handle.write(f"[{utc_now()}] {message.rstrip()}\n")
def operation_css(status: str) -> str: def operation_css(status: str) -> str:
if status == "success": return "ok" if status == "success" else "err" if status == "error" else "warn"
return "ok"
if status == "error":
return "err"
if status in ("queued", "running"):
return "warn"
return "warn"
def update_operation(operation: dict[str, Any], **changes: Any) -> None: def update_operation(operation: dict[str, Any], **changes: Any) -> None:
@@ -212,7 +196,6 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
update_operation(operation, status="running", progress=10, message="virt-install запущен", started_at=utc_now()) update_operation(operation, status="running", progress=10, message="virt-install запущен", started_at=utc_now())
append_operation_log(operation_id, "Запуск команды:") append_operation_log(operation_id, "Запуск команды:")
append_operation_log(operation_id, " ".join(cmd)) append_operation_log(operation_id, " ".join(cmd))
try: try:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
if process.stdout: if process.stdout:
@@ -240,20 +223,16 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
def start_background_operation(operation: dict[str, Any], cmd: list[str]) -> None: def start_background_operation(operation: dict[str, Any], cmd: list[str]) -> None:
write_operation(operation) write_operation(operation)
append_operation_log(operation["id"], "Операция поставлена в очередь.") append_operation_log(operation["id"], "Операция поставлена в очередь.")
thread = threading.Thread(target=run_operation_worker, args=(operation["id"], cmd), daemon=True) threading.Thread(target=run_operation_worker, args=(operation["id"], cmd), daemon=True).start()
thread.start()
def parse_virsh_list() -> list[dict[str, str]]: def parse_virsh_list() -> list[dict[str, str]]:
result = run_cmd(["virsh", "list", "--all"]) result = run_cmd(["virsh", "list", "--all"])
if not result["ok"]:
return []
rows = [] rows = []
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]: for line in result["stdout"].splitlines()[2:]:
line = line.strip() parts = line.strip().split(None, 2)
if not line:
continue
parts = line.split(None, 2)
if len(parts) == 3: if len(parts) == 3:
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]}) rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
elif len(parts) == 2: elif len(parts) == 2:
@@ -263,14 +242,11 @@ def parse_virsh_list() -> list[dict[str, str]]:
def parse_pool_list() -> list[dict[str, str]]: def parse_pool_list() -> list[dict[str, str]]:
result = run_cmd(["virsh", "pool-list", "--all"]) result = run_cmd(["virsh", "pool-list", "--all"])
if not result["ok"]:
return []
rows = [] rows = []
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]: for line in result["stdout"].splitlines()[2:]:
line = line.strip() parts = line.strip().split()
if not line:
continue
parts = line.split()
if len(parts) >= 3: if len(parts) >= 3:
rows.append({"name": parts[0], "state": parts[1], "autostart": parts[2]}) rows.append({"name": parts[0], "state": parts[1], "autostart": parts[2]})
return rows return rows
@@ -306,8 +282,7 @@ def iso_path_by_name(name: str) -> Path | None:
if not safe_name: if not safe_name:
return None return None
path = (ISO_DIR / safe_name).resolve() path = (ISO_DIR / safe_name).resolve()
iso_root = ISO_DIR.resolve() if ISO_DIR.resolve() not in path.parents:
if iso_root not in path.parents:
return None return None
return path return path
@@ -324,11 +299,20 @@ def vm_exists(name: str) -> bool:
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"] return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
def vm_ip(name: str) -> str:
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
if not result["ok"]:
return "not available"
match = re.search(r"(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/", result["stdout"])
return match.group(1) if match else "not available"
def vm_details(name: str) -> dict[str, Any]: def vm_details(name: str) -> dict[str, Any]:
return { return {
"name": name, "name": name,
"dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"], "dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available", "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",
"ip": vm_ip(name),
"disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"], "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],
"interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"], "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],
"autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"], "autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
@@ -354,6 +338,10 @@ def network_summary() -> dict[str, str]:
return {"interfaces": run_cmd(["ip", "-br", "a"])["stdout"], "routes": run_cmd(["ip", "route"])["stdout"]} return {"interfaces": run_cmd(["ip", "-br", "a"])["stdout"], "routes": run_cmd(["ip", "route"])["stdout"]}
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": "nat", "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
@app.get("/login", response_class=HTMLResponse) @app.get("/login", response_class=HTMLResponse)
def login_page(request: Request): def login_page(request: Request):
if get_current_user(request): if get_current_user(request):
@@ -385,10 +373,8 @@ def dashboard(request: Request):
auth_redirect = require_auth(request) auth_redirect = require_auth(request)
if auth_redirect: if auth_redirect:
return auth_redirect return auth_redirect
vms = parse_virsh_list()
pools = parse_pool_list()
services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")} services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "vms": vms, "pools": pools, "network": network_summary(), "user": AUTH_USER, "operations": list_operations(5), "operation_css": operation_css}) return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "operations": list_operations(5), "operation_css": operation_css})
@app.get("/iso", response_class=HTMLResponse) @app.get("/iso", response_class=HTMLResponse)
@@ -444,6 +430,63 @@ def iso_refresh(request: Request):
return RedirectResponse(url="/iso", status_code=303) return RedirectResponse(url="/iso", status_code=303)
@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": error})
@app.post("/network/nat/setup")
def network_nat_setup(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/forward/add")
def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), note: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.add_port_forward(vm_name, guest_ip, external_port, guest_port, protocol, note)
except NetworkError as exc:
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=400)
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/forward/{forward_id}/delete")
def network_forward_delete(request: Request, forward_id: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.delete_port_forward(forward_id)
except NetworkError:
pass
return RedirectResponse(url="/network", status_code=303)
@app.post("/network/apply")
def network_apply(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.apply_port_forwards()
except NetworkError as exc:
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
return RedirectResponse(url="/network", status_code=303)
@app.get("/operations", response_class=HTMLResponse) @app.get("/operations", response_class=HTMLResponse)
def operations_page(request: Request): def operations_page(request: Request):
auth_redirect = require_auth(request) auth_redirect = require_auth(request)
@@ -480,22 +523,20 @@ def api_operation(request: Request, operation_id: str):
return {"ok": True, "operation": operation} return {"ok": True, "operation": operation}
# Важно: статический маршрут /vm/create должен быть выше динамического /vm/{name}.
# Иначе FastAPI воспринимает слово "create" как имя виртуальной машины.
@app.get("/vm/create", response_class=HTMLResponse) @app.get("/vm/create", response_class=HTMLResponse)
def vm_create_page(request: Request): def vm_create_page(request: Request):
auth_redirect = require_auth(request) auth_redirect = require_auth(request)
if auth_redirect: if auth_redirect:
return auth_redirect return auth_redirect
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": None, "form": {"memory": 2048, "vcpus": 2, "disk_size": 20, "bridge": DEFAULT_BRIDGE}}) return vm_form_context(request)
@app.post("/vm/create", response_class=HTMLResponse) @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(...), iso_path: str = Form(...), bridge: str = Form(DEFAULT_BRIDGE), autostart_install: str | None = Form(None)): def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):
auth_redirect = require_auth(request) auth_redirect = require_auth(request)
if auth_redirect: if auth_redirect:
return auth_redirect return auth_redirect
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "bridge": bridge} form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}
error = None error = None
if not valid_vm_name(name): if not valid_vm_name(name):
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа." error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
@@ -507,42 +548,33 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
error = "CPU должен быть от 1 до 128 vCPU." error = "CPU должен быть от 1 до 128 vCPU."
elif disk_size < 4 or disk_size > 4096: elif disk_size < 4 or disk_size > 4096:
error = "Диск должен быть от 4 GB до 4096 GB." error = "Диск должен быть от 4 GB до 4096 GB."
elif not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge): elif network_mode not in ("nat", "bridge"):
error = "Некорректный режим сети."
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge." error = "Некорректное имя bridge."
else: else:
iso = Path(iso_path).resolve() iso = Path(iso_path).resolve()
iso_root = ISO_DIR.resolve() if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso." error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
if error: if error:
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error, "form": form}, status_code=400) return vm_form_context(request, error=error, form=form, status_code=400)
if network_mode == "nat":
try:
network_core.create_nat_network()
except NetworkError as exc:
return vm_form_context(request, error=f"NAT-сеть не готова: {exc}", form=form, status_code=500)
IMAGES_DIR.mkdir(parents=True, exist_ok=True) IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2" disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists(): if disk_path.exists():
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Диск уже существует: {disk_path}", "form": form}, status_code=400) return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", f"bridge={bridge},model=virtio", "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"] 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), "--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"]
operation_id = str(uuid.uuid4()) operation_id = str(uuid.uuid4())
operation = { 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, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "cmd": " ".join(cmd)}
"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,
"bridge": bridge,
"memory": memory,
"vcpus": vcpus,
"disk_size": disk_size,
"cmd": " ".join(cmd),
}
start_background_operation(operation, cmd) start_background_operation(operation, cmd)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303) return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@@ -577,4 +609,4 @@ def vm_action(request: Request, name: str, action: str):
def api_health(request: Request): def api_health(request: Request):
if not get_current_user(request): if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"system": system_summary(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary()} return {"system": system_summary(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()}