diff --git a/README.md b/README.md index b63b747..d8130ca 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,25 @@ # KVM 虚拟化管理平台 -基于 FastAPI + Vue 3 + Element Plus 的 KVM 虚拟机管理平台,通过 libvirt API 管理虚拟机。 +基于 FastAPI + Vue 3 + Element Plus 的 KVM 虚拟机管理平台,通过 libvirt API 管理虚拟机,支持多主机纳管。 ## 技术栈 - **后端**: FastAPI + libvirt Python API -- **前端**: Vue 3 + Element Plus + Vite +- **前端**: Vue 3 + Element Plus + Vite + noVNC - **虚拟化**: QEMU/KVM + libvirt +- **认证**: JWT Token ## 功能 -- 🖥️ 虚拟机管理(创建/启动/停止/删除/快照) +- 🖥️ 虚拟机管理(创建/启动/停止/删除/克隆/迁移) - 📊 资源监控(CPU/内存/磁盘/网络) -- 💾 存储池管理 -- 🌐 网络管理 -- 📋 控制台访问(noVNC) +- 💾 存储池管理(支持多种存储类型) +- 🌐 网络管理(桥接/NAT/独立网络) +- 📋 控制台访问(WebSocket VNC) - 📸 快照管理 -- 🔐 用户认证 +- 🔐 用户认证(JWT) +- 🖧 多主机纳管(支持 SSH/TCP 连接远程 KVM) +- 📡 IP 地址自动获取(需要配置 QEMU Guest Agent) ## 项目结构 @@ -24,42 +27,142 @@ kvm-manager/ ├── backend/ │ ├── app/ -│ │ ├── main.py -│ │ ├── config.py -│ │ ├── database.py -│ │ ├── models.py -│ │ ├── auth.py -│ │ └── routers/ -│ │ ├── vm.py -│ │ ├── storage.py -│ │ ├── network.py -│ │ ├── snapshot.py -│ │ └── monitor.py +│ │ ├── main.py # FastAPI 应用入口 +│ │ ├── config.py # 配置 +│ │ ├── libvirt_conn.py # libvirt 连接池 +│ │ ├── hosts.py # 主机注册表 +│ │ ├── utils.py # 工具函数 +│ │ └── routers/ # API 路由 +│ │ ├── vm.py # 虚拟机管理 +│ │ ├── storage.py # 存储管理 +│ │ ├── network.py # 网络管理 +│ │ ├── snapshot.py # 快照管理 +│ │ ├── monitor.py # 资源监控 +│ │ └── host.py # 主机管理 │ ├── requirements.txt │ └── Dockerfile ├── frontend/ │ ├── src/ -│ │ ├── views/ -│ │ ├── components/ -│ │ ├── api/ -│ │ ├── router/ +│ │ ├── views/ # 页面组件 +│ │ ├── components/ # 通用组件 +│ │ ├── api/ # API 调用 +│ │ ├── router/ # 路由配置 │ │ └── App.vue │ ├── package.json │ └── vite.config.js +├── scripts/ # 工具脚本 +│ ├── start.sh # 服务启动脚本 +│ └── install-guest-agent.sh # Guest Agent 安装脚本 ├── docker-compose.yml └── README.md ``` ## 快速开始 +### 方式一:使用启动脚本(推荐) + +```bash +cd kvm-manager/scripts + +# 启动所有服务 +./start.sh + +# 查看服务状态 +./start.sh status + +# 停止服务 +./start.sh -s +``` + +### 方式二:手动启动 + ```bash # 后端 cd backend +python -m venv venv +source venv/bin/activate pip install -r requirements.txt uvicorn app.main:app --host 0.0.0.0 --port 8004 -# 前端 +# 前端(另开终端) cd frontend npm install npm run dev ``` + +### 方式三:Docker 部署 + +```bash +docker-compose up -d +``` + +## 访问地址 + +- 前端界面:http://localhost:8005 +- API 接口:http://localhost:8004 +- API 文档:http://localhost:8004/docs + +## 多主机纳管 + +在「主机管理」页面添加远程 KVM 主机,支持以下连接方式: + +- **本地连接**:`qemu:///system` +- **SSH 连接**:`qemu+ssh://user@host/system` +- **TCP 连接**:`qemu+tcp://host/system` + +## QEMU Guest Agent + +用于自动获取虚拟机 IP 地址。 + +### 安装脚本 + +```bash +# 单个虚拟机 +./scripts/install-guest-agent.sh VM_NAME + +# 所有虚拟机 +./scripts/install-guest-agent.sh --all +``` + +### 手动配置 + +1. 关机虚拟机 +2. 编辑配置:`virsh edit VM_NAME` +3. 在 `` 中添加: + +```xml + + + + + +``` + +4. 开机后安装 Agent: + +```bash +# CentOS/RHEL +yum install qemu-guest-agent +systemctl enable qemu-guest-agent + +# Ubuntu/Debian +apt install qemu-guest-agent +systemctl enable qemu-guest-agent +``` + +5. 验证:`virsh qemu-agent-command VM_NAME '{"execute":"guest-info"}'` + +## 配置说明 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `LIBVIRT_URI` | 本地 libvirt 连接 URI | `qemu:///system` | +| `API_PREFIX` | API 路径前缀 | `/api` | +| `SECRET_KEY` | JWT 密钥 | 自动生成 | +| `KVM_DATA_DIR` | 主机数据存储目录 | `/var/lib/kvm-manager` | + +## 注意事项 + +- 远程主机 VNC 需要监听 `0.0.0.0` 才能被代理访问 +- SSH 模式需要配置无密码 SSH 登录 +- Guest Agent 需要虚拟机内部安装并运行才能获取 IP diff --git a/backend/app/routers/vm.py b/backend/app/routers/vm.py index 0baeb3e..1a8cba3 100644 --- a/backend/app/routers/vm.py +++ b/backend/app/routers/vm.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from typing import Optional, List from lxml import etree +from app.hosts import list_hosts import os from app.libvirt_conn import conn_pool @@ -36,14 +37,14 @@ class VMClone(BaseModel): # ===== API ===== @router.get("/list") -async def list_vms(host_id: str = Query("local")): - """获取所有虚拟机列表""" +async def list_vms(host_id: str = Query("local"), include_ip: bool = False): + """获取指定主机所有虚拟机列表(轻量模式,默认不获取IP)""" conn = conn_pool.get_conn(host_id) domains = conn.listAllDomains(0) vms = [] for dom in domains: try: - vm_info = parse_vm_info(dom) + vm_info = parse_vm_info(dom, host_id, include_ip=include_ip) vms.append(vm_info) except Exception as e: vms.append({ @@ -52,7 +53,37 @@ async def list_vms(host_id: str = Query("local")): "state": "error", "error": str(e), }) - return {"vms": vms, "total": len(vms)} + return {"vms": vms, "total": len(vms), "host_id": host_id} + + +@router.get("/list-all") +async def list_all_vms(include_ip: bool = False): + """获取所有主机所有虚拟机列表(聚合模式)""" + hosts = list_hosts() + all_vms = [] + + for host in hosts: + try: + conn = conn_pool.get_conn(host.id) + domains = conn.listAllDomains(0) + for dom in domains: + try: + vm_info = parse_vm_info(dom, host.id, include_ip=include_ip) + vm_info["host_id"] = host.id + vm_info["host_name"] = host.name + all_vms.append(vm_info) + except Exception: + all_vms.append({ + "name": dom.name(), + "uuid": dom.UUIDString(), + "state": "error", + "host_id": host.id, + "host_name": host.name, + }) + except Exception: + pass # 跳过无法连接的主机 + + return {"vms": all_vms, "total": len(all_vms), "host_count": len(hosts)} @router.get("/detail/{name}") @@ -64,7 +95,7 @@ async def get_vm_detail(name: str, host_id: str = Query("local")): except libvirt.libvirtError: raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") - info = parse_vm_info(dom) + info = parse_vm_info(dom, host_id) # 运行中的虚拟机获取更多动态信息 if info["state"] == "running": @@ -115,6 +146,120 @@ async def get_vm_detail(name: str, host_id: str = Query("local")): return info +@router.get("/ip/{name}") +async def get_vm_ip(name: str, host_id: str = Query("local")): + """获取虚拟机 IP 地址""" + conn = conn_pool.get_conn(host_id) + try: + dom = conn.lookupByName(name) + except libvirt.libvirtError: + raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") + + if not dom.isActive(): + return {"name": name, "ips": [], "message": "虚拟机未运行"} + + xml_desc = dom.XMLDesc(0) + tree = etree.fromstring(xml_desc.encode()) + + interfaces = [] + for iface in tree.findall(".//interface"): + source = iface.find("source") + mac_elem = iface.find("mac") + if mac_elem is None: + continue + mac = mac_elem.get("address", "") + network = source.get("network", "") if source is not None else "" + + ips = [] + + # 方式1: QEMU Guest Agent + try: + ifaces = dom.interfaceAddresses( + libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0 + ) + if ifaces: + for ifname, ifdata in ifaces.items(): + if ifdata.get("hwaddr", "").lower() == mac.lower(): + addrs = ifdata.get("addrs", []) + for a in addrs: + addr = a.get("addr", "") + if "." in addr: + ips.append({"ip": addr, "type": "ipv4", "source": "guest-agent"}) + elif ":" in addr: + ips.append({"ip": addr, "type": "ipv6", "source": "guest-agent"}) + except Exception: + pass + + # 方式2: DHCP 租约 + if not ips: + try: + ifaces = dom.interfaceAddresses( + libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0 + ) + if ifaces: + for ifname, ifdata in ifaces.items(): + hwaddr = ifdata.get("hwaddr", "").lower() + if hwaddr == mac.lower(): + addrs = ifdata.get("addrs", []) + for a in addrs: + addr = a.get("addr", "") + if "." in addr: + ips.append({"ip": addr, "type": "ipv4", "source": "dhcp-lease"}) + except Exception: + pass + + # 方式3: ARP 表查找 + if not ips: + ip_list = _arp_lookup(mac, host_id) + for ip in ip_list: + ips.append({"ip": ip, "type": "ipv4", "source": "arp"}) + + interfaces.append({ + "mac": mac, + "network": network, + "ips": ips, + }) + + return {"name": name, "interfaces": interfaces} + + +def _arp_lookup(mac: str, host_id: str = "local") -> list: + """通过 ARP 表查找 MAC 对应的 IP""" + import subprocess + from app.hosts import get_host as get_host_info + + host_info = get_host_info(host_id) + is_remote = host_info and host_info.type != "local" + + try: + if is_remote and host_info.type == "ssh": + from urllib.parse import urlparse + parsed = urlparse(host_info.uri) + remote_host = parsed.hostname + ssh_args = ["ssh", "-o", "StrictHostKeyChecking=no"] + if host_info.ssh_key_path: + ssh_args.extend(["-i", host_info.ssh_key_path]) + ssh_args.extend([remote_host, "ip", "neigh", "show"]) + result = subprocess.run(ssh_args, capture_output=True, text=True, timeout=5) + else: + result = subprocess.run( + ["ip", "neigh", "show"], + capture_output=True, text=True, timeout=3, + ) + + if result.returncode == 0: + found = [] + for line in result.stdout.strip().split("\n"): + if mac.lower() in line.lower(): + parts = line.split() + if parts and "." in parts[0]: + found.append(parts[0]) + return found + except Exception: + pass + return [] + + @router.post("/create") async def create_vm(vm: VMCreate, host_id: str = Query("local")): """创建虚拟机""" diff --git a/backend/app/utils.py b/backend/app/utils.py index 1c717ed..e6d20d9 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -96,8 +96,14 @@ def generate_vm_xml( return xml_parts -def parse_vm_info(dom) -> dict: - """从 libvirt domain 对象提取虚拟机信息""" +def parse_vm_info(dom, host_id: str = "local", include_ip: bool = False) -> dict: + """从 libvirt domain 对象提取虚拟机信息 + + Args: + dom: libvirt domain 对象 + host_id: 主机ID + include_ip: 是否获取IP地址(开启会明显变慢,默认关闭) + """ from app.libvirt_conn import libvirt_conn # 基本信息 @@ -161,43 +167,37 @@ def parse_vm_info(dom) -> dict: for iface in tree.findall(".//interface"): source = iface.find("source") model = iface.find("model") + target = iface.find("target") iface_info = { "type": iface.get("type", ""), "network": source.get("network", "") if source is not None else "", + "bridge": source.get("bridge", "") if source is not None else "", "model": model.get("type", "") if model is not None else "", + "dev": target.get("dev", "") if target is not None else "", } - # 如果运行中,获取MAC和IP - if info["state"] == "running": - mac = iface.find("mac") - if mac is not None: - iface_info["mac"] = mac.get("address", "") - # 尝试获取IP地址 - try: - ifaces = dom.interfaceAddresses( - libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0 - ) - if ifaces: - for ifname, ifdata in ifaces.items(): - if mac is not None and ifdata.get("hwaddr", "") == iface_info.get("mac", ""): - addrs = ifdata.get("addrs", []) - if addrs: - iface_info["ip"] = addrs[0].get("addr", "") - except Exception: - try: - ifaces = dom.interfaceAddresses( - libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0 - ) - if ifaces: - for ifname, ifdata in ifaces.items(): - addrs = ifdata.get("addrs", []) - if addrs: - iface_info["ip"] = addrs[0].get("addr", "") - break - except Exception: - pass + mac_elem = iface.find("mac") + if mac_elem is not None: + iface_info["mac"] = mac_elem.get("address", "") interfaces.append(iface_info) info["interfaces"] = interfaces + # 获取 IP 地址(仅在需要时) + if include_ip and info["state"] == "running": + arp_cache = _get_arp_table(host_id) + for iface in interfaces: + if "mac" in iface: + # 先尝试 Guest Agent 和 DHCP 租约 + iface["ip"] = _get_vm_ip(dom, iface["mac"], iface.get("network", ""), host_id, None) + # 如果没找到再用 ARP 表 + if not iface.get("ip") and arp_cache: + mac_lower = iface["mac"].lower() + if mac_lower in arp_cache: + iface["ip"] = arp_cache[mac_lower] + else: + # 不获取 IP 时,初始化 ip 字段为空 + for iface in interfaces: + iface["ip"] = "" + # VNC graphics = tree.find(".//graphics[@type='vnc']") if graphics is not None: @@ -214,6 +214,93 @@ def parse_vm_info(dom) -> dict: return info +def _get_arp_table(host_id: str = "local") -> dict: + """获取 ARP 表(MAC -> IP 映射),返回 {mac_lower: ip}""" + import subprocess + from app.hosts import get_host as get_host_info + + host_info = get_host_info(host_id) + is_remote = host_info and host_info.type != "local" + + try: + if is_remote and host_info.type == "ssh": + from urllib.parse import urlparse + parsed = urlparse(host_info.uri) + remote_host = parsed.hostname + ssh_args = ["ssh", "-o", "StrictHostKeyChecking=no"] + if host_info.ssh_key_path: + ssh_args.extend(["-i", host_info.ssh_key_path]) + ssh_args.extend([remote_host, "ip", "neigh", "show"]) + result = subprocess.run(ssh_args, capture_output=True, text=True, timeout=5) + else: + result = subprocess.run( + ["ip", "neigh", "show"], + capture_output=True, text=True, timeout=3, + ) + + if result.returncode == 0: + arp = {} + for line in result.stdout.strip().split("\n"): + parts = line.split() + if len(parts) >= 5: + ip = parts[0] + # lladdr 行格式: IP dev IFACE lladdr MAC ... + for i, p in enumerate(parts): + if p == "lladdr" and i + 1 < len(parts) and "." in ip: + arp[parts[i + 1].lower()] = ip + return arp + except Exception: + pass + return {} + + +def _get_vm_ip(dom, mac: str, network: str = "", host_id: str = "local", arp_cache: dict = None) -> str: + """多种方式尝试获取虚拟机 IP 地址""" + # 方式1: QEMU Guest Agent(最准确) + try: + ifaces = dom.interfaceAddresses( + libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0 + ) + if ifaces: + for ifname, ifdata in ifaces.items(): + if ifdata.get("hwaddr", "").lower() == mac.lower(): + addrs = ifdata.get("addrs", []) + for a in addrs: + addr = a.get("addr", "") + # 优先返回 IPv4 + if "." in addr: + return addr + if addrs: + return addrs[0].get("addr", "") + except Exception: + pass + + # 方式2: DHCP 租约(通过 libvirt 网络) + try: + ifaces = dom.interfaceAddresses( + libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0 + ) + if ifaces: + for ifname, ifdata in ifaces.items(): + hwaddr = ifdata.get("hwaddr", "").lower() + if hwaddr == mac.lower(): + addrs = ifdata.get("addrs", []) + for a in addrs: + addr = a.get("addr", "") + if "." in addr: + return addr + if addrs: + return addrs[0].get("addr", "") + except Exception: + pass + + # 方式3: ARP 表查找(通过 MAC 地址查 IP) + if arp_cache is not None and mac.lower() in arp_cache: + return arp_cache[mac.lower()] + + return "" + + def _get_state(dom) -> str: """获取虚拟机运行状态""" raw = dom.info() diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 2cdde3d..2cab734 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -67,6 +67,16 @@ + + +