From 7e5da67de4662c3307f5811137eb9bfbe2f1d82a Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:57:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=BC=E5=AE=B9KVM=E5=A4=A7=E5=A4=9A?= =?UTF-8?q?=E6=95=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/handlers.go | 4 + backend/internal/api/images.go | 10 +- backend/internal/api/vnc.go | 228 ++++ backend/internal/config/config.go | 7 + backend/internal/kvm/kvm.go | 1070 ++++++++++++++++- backend/internal/kvm/templates.go | 23 +- backend/internal/server/server.go | 3 +- backend/internal/server/web/.gitkeep | 1 - backend/main.go | 1 + frontend/package-lock.json | 7 + frontend/package.json | 1 + .../src/components/CreateContainerModal.tsx | 51 +- frontend/src/components/WebVNCViewer.tsx | 130 ++ frontend/src/novnc.d.ts | 14 + frontend/src/pages/ContainerDetail.tsx | 191 ++- frontend/src/pages/Containers.tsx | 16 +- frontend/src/pages/ImageManagement.tsx | 18 +- frontend/src/services/api.ts | 13 + frontend/vite.config.ts | 1 + install.sh | 10 +- 20 files changed, 1676 insertions(+), 123 deletions(-) create mode 100644 backend/internal/api/vnc.go delete mode 100644 backend/internal/server/web/.gitkeep create mode 100644 frontend/src/components/WebVNCViewer.tsx create mode 100644 frontend/src/novnc.d.ts diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index cae48a9..ea2ed9c 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -177,6 +177,10 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } + if c.IsKVM() && c.Status == "running" { + _, _ = kvmManager.RefreshVNCPort(c.ID) + _, _ = kvmManager.RefreshNetwork(c.ID) + } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c}) } diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 05f53a4..fa8d4d4 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -27,6 +27,7 @@ type ImageInfo struct { Enabled bool `json:"enabled"` Downloading bool `json:"downloading"` SizeBytes int64 `json:"size_bytes"` + ManualPath string `json:"manual_path,omitempty"` } var imageDownloadsMu sync.Mutex @@ -122,6 +123,10 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { for _, t := range kvm.GetImages() { _, downloading := imageDownloads[t.ID] downloaded, size := kvm.ImageDownloadedInfo(t.ID) + manualPath := "" + if t.Distro == "windows" { + manualPath = kvm.ImagePath(t.ID) + } images = append(images, ImageInfo{ ID: t.ID, Name: t.Name, @@ -134,6 +139,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { Enabled: enabledSet[t.ID], Downloading: downloading, SizeBytes: size, + ManualPath: manualPath, }) } @@ -182,7 +188,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { }() ensureImageEnabled(image.ID) if err := kvm.DownloadImage(*image); err != nil { - jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Download failed: " + err.Error()}) + message := "Download failed: " + err.Error() + + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message}) return } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) diff --git a/backend/internal/api/vnc.go b/backend/internal/api/vnc.go new file mode 100644 index 0000000..e6e68a0 --- /dev/null +++ b/backend/internal/api/vnc.go @@ -0,0 +1,228 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "sync" + "time" + + "clicd/internal/config" + + "github.com/gorilla/websocket" +) + +type webVNCTicket struct { + ContainerName string + ContainerUUID string + ExpiresAt time.Time +} + +var webVNCTickets = struct { + sync.Mutex + items map[string]webVNCTicket +}{items: map[string]webVNCTicket{}} + +func HandleVNCTicket(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + var req struct { + ContainerName string `json:"container_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"}) + return + } + if !isContainerAllowedForRequest(r, req.ContainerName) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } + c := config.FindContainerByName(req.ContainerName) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + if !c.IsKVM() { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"}) + return + } + + ticket := randomHex(32) + webVNCTickets.Lock() + cleanupExpiredWebVNCTicketsLocked(time.Now()) + webVNCTickets.items[ticket] = webVNCTicket{ + ContainerName: c.Name, + ContainerUUID: c.UUID, + ExpiresAt: time.Now().Add(60 * time.Second), + } + webVNCTickets.Unlock() + + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Data: map[string]string{"ticket": ticket}, + }) +} + +// HandleVNCProxy proxies a KVM VM's local libvirt VNC socket to the browser. +func HandleVNCProxy(w http.ResponseWriter, r *http.Request) { + ticket := webVNCTicketFromRequest(r) + if ticket == "" { + http.Error(w, "ticket required", http.StatusUnauthorized) + return + } + + containerName := r.URL.Query().Get("container") + if containerName == "" { + http.Error(w, "container name required", http.StatusBadRequest) + return + } + + item, ok := consumeWebVNCTicket(ticket, containerName) + if !ok { + http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) + return + } + + c := config.FindContainerByName(containerName) + if c == nil || c.UUID != item.ContainerUUID { + http.Error(w, "container not found", http.StatusNotFound) + return + } + if !c.IsKVM() { + http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest) + return + } + if c.Status != "running" { + http.Error(w, "container is not running", http.StatusBadRequest) + return + } + + vncPort, err := kvmManager.RefreshVNCPort(c.ID) + if err != nil { + http.Error(w, fmt.Sprintf("VNC display is not available: %v", err), http.StatusBadRequest) + return + } + + vncConn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", vncPort)), 5*time.Second) + if err != nil { + http.Error(w, fmt.Sprintf("VNC connection failed: %v", err), http.StatusBadRequest) + return + } + defer vncConn.Close() + + responseHeader := http.Header{} + if protocol := webVNCResponseProtocol(r); protocol != "" { + responseHeader.Set("Sec-WebSocket-Protocol", protocol) + } + ws, err := upgrader.Upgrade(w, r, responseHeader) + if err != nil { + log.Printf("WebVNC upgrade failed: %v", err) + return + } + defer ws.Close() + + log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort) + + done := make(chan string, 2) + var writeMu sync.Mutex + go streamVNCToWebSocket(ws, &writeMu, vncConn, done) + go streamWebSocketToVNC(ws, vncConn, done) + + reason := <-done + _ = vncConn.Close() + _ = ws.Close() + log.Printf("WebVNC disconnected for container %s: %s", containerName, reason) +} + +func webVNCTicketFromRequest(r *http.Request) string { + for _, protocol := range websocket.Subprotocols(r) { + const prefix = "clicd-vnc-ticket." + if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix { + return protocol[len(prefix):] + } + } + return r.URL.Query().Get("ticket") +} + +func webVNCResponseProtocol(r *http.Request) string { + for _, protocol := range websocket.Subprotocols(r) { + if protocol == "binary" { + return protocol + } + } + for _, protocol := range websocket.Subprotocols(r) { + const prefix = "clicd-vnc-ticket." + if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix { + return protocol + } + } + return "" +} + +func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) { + now := time.Now() + webVNCTickets.Lock() + defer webVNCTickets.Unlock() + cleanupExpiredWebVNCTicketsLocked(now) + item, ok := webVNCTickets.items[ticket] + if !ok { + return webVNCTicket{}, false + } + delete(webVNCTickets.items, ticket) + return item, item.ContainerName == containerName && now.Before(item.ExpiresAt) +} + +func cleanupExpiredWebVNCTicketsLocked(now time.Time) { + for ticket, item := range webVNCTickets.items { + if !now.Before(item.ExpiresAt) { + delete(webVNCTickets.items, ticket) + } + } +} + +func streamVNCToWebSocket(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- string) { + buf := make([]byte, 32*1024) + for { + n, err := src.Read(buf) + if n > 0 { + writeMu.Lock() + writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]) + writeMu.Unlock() + if writeErr != nil { + done <- fmt.Sprintf("browser websocket write failed: %v", writeErr) + return + } + } + if err != nil { + if err == io.EOF { + done <- "VNC server closed connection" + } else { + done <- fmt.Sprintf("VNC server read failed: %v", err) + } + return + } + } +} + +func streamWebSocketToVNC(ws *websocket.Conn, dst net.Conn, done chan<- string) { + for { + messageType, msg, err := ws.ReadMessage() + if err != nil { + done <- fmt.Sprintf("browser websocket read failed: %v", err) + return + } + if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage { + continue + } + if _, err := dst.Write(msg); err != nil { + done <- fmt.Sprintf("VNC server write failed: %v", err) + return + } + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bba8541..57bc082 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -58,6 +58,13 @@ type AuditLog struct { Error string `json:"error,omitempty"` } +type VMReadinessCheck struct { + Key string `json:"key"` + Label string `json:"label"` + OK bool `json:"ok"` + Detail string `json:"detail,omitempty"` +} + // Container represents an LXC container configuration type Container struct { ID int `json:"id"` diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index ef8cef5..17c1769 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -54,6 +54,20 @@ type rateSnapshot struct { UpdatedAt time.Time } +type windowsGuestMetrics struct { + MemoryUsageBytes int64 `json:"memory_usage_bytes"` + MemoryTotalBytes int64 `json:"memory_total_bytes"` + CPULoadPct float64 `json:"cpu_load_pct"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` +} + +type windowsGuestMetricsSnapshot struct { + Metrics windowsGuestMetrics + UpdatedAt time.Time +} + type trafficSample struct { RXBytes uint64 TXBytes uint64 @@ -67,6 +81,12 @@ var ( lastTrafficSnapshot = map[string]trafficSample{} kvmSnapshotMu sync.Mutex kvmSSHEnsureLocks sync.Map + portMapApplyMu sync.Mutex + lastPortMapApply = map[int]time.Time{} + windowsMetricsMu sync.Mutex + windowsMetricsCache = map[string]windowsGuestMetricsSnapshot{} + ipv6WarnMu sync.Mutex + lastIPv6GuestWarn = map[int]time.Time{} ) func BaseDir() string { @@ -102,15 +122,41 @@ func DownloadImage(image Image) error { if ok, _ := ImageDownloadedInfo(image.ID); ok { return nil } + // For images with no download URL (e.g. Windows ISO), the user must + // manually place the file at the expected path. + if image.URL == "" { + if _, err := os.Stat(target); err == nil { + _ = os.Chmod(target, 0644) + return nil + } + return fmt.Errorf("this image has no download URL. Please manually upload the ISO to: %s", target) + } tmp := target + ".tmp" _ = os.Remove(tmp) - if err := downloadFile(image.URL, tmp); err != nil { + if image.Distro == "windows" { + if err := downloadFileWithValidator(image.URL, tmp, validateWindowsISOResponse(target)); err != nil { + _ = os.Remove(tmp) + return err + } + } else if err := downloadFile(image.URL, tmp); err != nil { _ = os.Remove(tmp) return err } - if err := normalizeQCOW2(tmp, target); err != nil { - _ = os.Remove(tmp) - return err + if image.Distro == "windows" { + if err := validateWindowsISO(tmp, target); err != nil { + _ = os.Remove(tmp) + return err + } + // Keep Windows ISO as-is, don't convert to qcow2 + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return err + } + } else { + if err := normalizeQCOW2(tmp, target); err != nil { + _ = os.Remove(tmp) + return err + } } _ = os.Chmod(target, 0644) return nil @@ -120,9 +166,33 @@ func DeleteImage(id string) error { return os.RemoveAll(ImagePath(id)) } +type downloadResponseValidator func(*http.Response) error + func downloadFile(url, target string) error { - client := http.Client{Timeout: 30 * time.Minute} - resp, err := client.Get(url) + return downloadFileWithValidator(url, target, nil) +} + +func downloadFileWithValidator(url, target string, validate downloadResponseValidator) error { + client := http.Client{ + Timeout: 30 * time.Minute, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + // Copy User-Agent on redirect + if ua := via[0].Header.Get("User-Agent"); ua != "" { + req.Header.Set("User-Agent", ua) + } + return nil + }, + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + // Windows UA needed for Microsoft download servers + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + resp, err := client.Do(req) if err != nil { return err } @@ -130,6 +200,11 @@ func downloadFile(url, target string) error { if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("download failed: %s", resp.Status) } + if validate != nil { + if err := validate(resp); err != nil { + return err + } + } out, err := os.Create(target) if err != nil { return err @@ -141,6 +216,56 @@ func downloadFile(url, target string) error { return out.Sync() } +func validateWindowsISOResponse(target string) downloadResponseValidator { + return func(resp *http.Response) error { + contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "text/plain") { + return fmt.Errorf("downloaded Windows image response was %q instead of an ISO. Microsoft download links may be region/time limited; manually upload the ISO to: %s", contentType, target) + } + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + path := "" + if resp.Request != nil && resp.Request.URL != nil { + path = strings.ToLower(resp.Request.URL.Path) + } + looksLikeISO := strings.HasSuffix(path, ".iso") || + strings.Contains(contentType, "iso") || + strings.Contains(contentType, "octet-stream") || + contentType == "" + if !looksLikeISO { + return fmt.Errorf("Microsoft redirect did not appear to return an ISO (final URL: %s, Content-Type: %s). Please manually upload the ISO to: %s", finalURL, contentType, target) + } + return nil + } +} + +func validateWindowsISO(path, target string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.Size() < 1024*1024*1024 { + return fmt.Errorf("downloaded Windows ISO is unexpectedly small (%d bytes). Microsoft download links may be region/time limited; try again or manually upload the ISO to: %s", info.Size(), target) + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + header := make([]byte, 512) + n, err := io.ReadFull(f, header) + if err != nil && err != io.ErrUnexpectedEOF { + return err + } + prefix := strings.ToLower(strings.TrimSpace(string(header[:n]))) + if strings.HasPrefix(prefix, " 1 { @@ -294,7 +451,12 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig IPv6Interface: ipv6Interface, Status: "stopped", SSHPort: sshPort, - SSHPassword: sshPassword, + SSHPassword: func() string { + if winAdminPassword != "" { + return winAdminPassword + } + return sshPassword + }(), PortMappings: portMappings, PortMappingLimit: cfg.PortMappingCount, SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), @@ -308,7 +470,7 @@ func (m *Manager) StartContainer(id int) error { if c == nil { return fmt.Errorf("container not found: %d", id) } - if err := m.validateHost(); err != nil { + if err := m.validateHost(IsWindowsImage(c.Template)); err != nil { return err } name := c.VirshName() @@ -325,21 +487,41 @@ func (m *Manager) StartContainer(id int) error { } } config.UpdateContainerStatus(id, "running") + // Detect VNC port + if _, err := m.RefreshVNCPort(id); err != nil { + fmt.Printf("Warning: failed to refresh VNC port for %s: %v\n", name, err) + } _ = exec.Command("virsh", "dommemstat", name, "--period", "10", "--live").Run() _ = exec.Command("virsh", "dommemstat", name, "--period", "10", "--config").Run() - for i := 0; i < 90; i++ { - if ip, err := m.GetContainerIP(name); err == nil && ip != "" { - c.IP = ip - config.SaveConfig() - break + // Windows VMs need manual install via VNC — don't require IP on first boot + isWindows := IsWindowsImage(c.Template) + if isWindows { + for i := 0; i < 15; i++ { + if ip, err := m.GetContainerIP(name); err == nil && ip != "" { + c.IP = ip + config.SaveConfig() + break + } + time.Sleep(2 * time.Second) + } + } else { + for i := 0; i < 90; i++ { + if ip, err := m.GetContainerIP(name); err == nil && ip != "" { + c.IP = ip + config.SaveConfig() + break + } + time.Sleep(2 * time.Second) + } + if c.IP == "" { + return fmt.Errorf("KVM VM %s started but no IPv4 address was detected", c.Name) } - time.Sleep(2 * time.Second) } - if c.IP == "" { - return fmt.Errorf("KVM VM %s started but no IPv4 address was detected", c.Name) - } - if err := lxc.NewManager().ApplyPortMappings(id); err != nil { - return err + // Apply port mappings if IP is available (Linux: always; Windows: after installation) + if c.IP != "" { + if err := lxc.NewManager().ApplyPortMappings(id); err != nil { + return err + } } if c.IPv6 != "" { if err := m.applyIPv6Runtime(c); err != nil { @@ -447,8 +629,13 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { c.SSHPassword = next.SSHPassword c.SSHHostKey = "" c.IP = "" + c.VNCPort = 0 + normalizeKVMManagementPortMapping(c) c.Status = "stopped" config.SaveConfig() + if IsWindowsImage(templateID) { + return nil + } return m.StartContainer(id) } @@ -457,6 +644,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) { if c == nil { return "", fmt.Errorf("container not found: %d", id) } + if IsWindowsImage(c.Template) { + return "", fmt.Errorf("Windows KVM administrator password cannot be reset from CLICD yet; change it inside Windows or reinstall to generate a new password") + } if c.Status != "running" { return "", fmt.Errorf("KVM VM must be running before password reset") } @@ -509,8 +699,15 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error { if c.DiskImage == "" || c.MACAddress == "" { return nil } - seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") - xml := domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + var xml string + if IsWindowsImage(c.Template) { + winISO := ImagePath(c.Template) + unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName())) + xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + } else { + seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") + xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + } xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { return err @@ -526,9 +723,16 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error { if c == nil || !c.IsKVM() || c.DiskImage == "" || c.MACAddress == "" { return nil } - seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") - xml := domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + var xml string xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") + if IsWindowsImage(c.Template) { + winISO := ImagePath(c.Template) + unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName())) + xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + } else { + seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") + xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + } if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { return err } @@ -891,6 +1095,7 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) { cpuUsec, rxBytes, txBytes, readBytes, writeBytes := m.getUsageCounters(c) usage := map[string]interface{}{ "memory_usage_bytes": int64(0), + "memory_total_bytes": int64(0), "cpu_usage_usec": cpuUsec, "cpu_usage_pct": 0.0, "disk_usage_bytes": int64(0), @@ -905,6 +1110,7 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) { "load1": 0.0, "load5": 0.0, "load15": 0.0, + "guest_metrics": false, } if c.DiskImage != "" { if info, err := os.Stat(c.DiskImage); err == nil { @@ -926,9 +1132,105 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) { usage["disk_read_bps"] = rate.ReadBps usage["disk_write_bps"] = rate.WriteBps } + if c.Status == "running" && IsWindowsImage(c.Template) { + if metrics, err := m.windowsGuestResourceMetrics(c); err == nil { + vcpu := c.VCPU + if vcpu < 1 { + vcpu = 1 + } + if metrics.MemoryUsageBytes > 0 { + usage["memory_usage_bytes"] = metrics.MemoryUsageBytes + } + if metrics.MemoryTotalBytes > 0 { + usage["memory_total_bytes"] = metrics.MemoryTotalBytes + } + usage["cpu_usage_pct"] = metrics.CPULoadPct * vcpu + usage["load1"] = metrics.Load1 + usage["load5"] = metrics.Load5 + usage["load15"] = metrics.Load15 + usage["guest_metrics"] = true + } + } return usage, nil } +func (m *Manager) windowsGuestResourceMetrics(c *config.Container) (windowsGuestMetrics, error) { + if c == nil { + return windowsGuestMetrics{}, fmt.Errorf("container is nil") + } + name := c.VirshName() + windowsMetricsMu.Lock() + if cached, ok := windowsMetricsCache[name]; ok && time.Since(cached.UpdatedAt) < 10*time.Second { + windowsMetricsMu.Unlock() + return cached.Metrics, nil + } + windowsMetricsMu.Unlock() + if err := qemuGuestPing(name); err != nil { + return windowsGuestMetrics{}, err + } + script := windowsGuestMetricsPowerShell() + stdout, stderr, err := qemuGuestExecCommandOutput(name, "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 20*time.Second) + if err != nil { + if strings.TrimSpace(stderr) != "" { + return windowsGuestMetrics{}, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr)) + } + return windowsGuestMetrics{}, err + } + metrics, err := parseWindowsGuestMetrics(stdout) + if err != nil { + return windowsGuestMetrics{}, err + } + vcpu := c.VCPU + if vcpu < 1 { + vcpu = 1 + } + loadEquivalent := (metrics.CPULoadPct / 100.0) * vcpu + metrics.Load1 = loadEquivalent + metrics.Load5 = loadEquivalent + metrics.Load15 = loadEquivalent + + windowsMetricsMu.Lock() + windowsMetricsCache[name] = windowsGuestMetricsSnapshot{Metrics: metrics, UpdatedAt: time.Now()} + windowsMetricsMu.Unlock() + return metrics, nil +} + +func windowsGuestMetricsPowerShell() string { + return `$ErrorActionPreference = 'Stop' +$os = Get-CimInstance Win32_OperatingSystem +$cpu = Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average +$total = [int64]$os.TotalVisibleMemorySize * 1024 +$free = [int64]$os.FreePhysicalMemory * 1024 +$used = [Math]::Max([int64]0, $total - $free) +$load = [double]0 +if ($null -ne $cpu.Average) { $load = [double]$cpu.Average } +[pscustomobject]@{ + memory_usage_bytes = $used + memory_total_bytes = $total + cpu_load_pct = $load +} | ConvertTo-Json -Compress` +} + +func parseWindowsGuestMetrics(stdout string) (windowsGuestMetrics, error) { + text := strings.TrimSpace(stdout) + start := strings.LastIndex(text, "{") + end := strings.LastIndex(text, "}") + if start < 0 || end <= start { + return windowsGuestMetrics{}, fmt.Errorf("Windows guest metrics returned no JSON: %s", text) + } + var metrics windowsGuestMetrics + if err := json.Unmarshal([]byte(text[start:end+1]), &metrics); err != nil { + return windowsGuestMetrics{}, fmt.Errorf("invalid Windows guest metrics JSON: %w", err) + } + if metrics.CPULoadPct < 0 { + metrics.CPULoadPct = 0 + } + if metrics.CPULoadPct > 100 { + metrics.CPULoadPct = 100 + } + return metrics, nil +} + func (m *Manager) ListContainers(containers []config.Container) []config.Container { for i := range containers { if !containers[i].IsKVM() { @@ -939,7 +1241,12 @@ func (m *Manager) ListContainers(containers []config.Container) []config.Contain containers[i].Status = status } if status == "running" { - if ip, err := m.GetContainerIP(containers[i].VirshName()); err == nil && ip != "" { + if _, err := m.RefreshVNCPort(containers[i].ID); err == nil { + if refreshed := config.FindContainer(containers[i].ID); refreshed != nil { + containers[i].VNCPort = refreshed.VNCPort + } + } + if ip, err := m.RefreshNetwork(containers[i].ID); err == nil && ip != "" { containers[i].IP = ip } } @@ -947,6 +1254,44 @@ func (m *Manager) ListContainers(containers []config.Container) []config.Contain return containers } +func (m *Manager) RefreshNetwork(id int) (string, error) { + c := config.FindContainer(id) + if c == nil { + return "", fmt.Errorf("container not found: %d", id) + } + if !c.IsKVM() { + return "", fmt.Errorf("container is not a KVM VM: %d", id) + } + ip, err := m.GetContainerIP(c.VirshName()) + if err != nil || ip == "" { + return "", err + } + changed := c.IP != ip + c.IP = ip + if changed { + config.SaveConfig() + } + if c.Status == "running" && len(c.PortMappings) > 0 && shouldApplyPortMappings(id, changed) { + if err := lxc.NewManager().ApplyPortMappings(id); err != nil { + return ip, err + } + } + return ip, nil +} + +func shouldApplyPortMappings(id int, force bool) bool { + portMapApplyMu.Lock() + defer portMapApplyMu.Unlock() + now := time.Now() + if !force { + if last, ok := lastPortMapApply[id]; ok && now.Sub(last) < time.Minute { + return false + } + } + lastPortMapApply[id] = now + return true +} + func (m *Manager) GetContainerStatus(name string) (string, error) { cmd := exec.Command("virsh", "domstate", name) out, err := cmd.Output() @@ -982,12 +1327,21 @@ func (m *Manager) GetContainerIP(name string) (string, error) { return "", fmt.Errorf("no IPv4 address found for %s", name) } -func (m *Manager) validateHost() error { - for _, name := range []string{"virsh", "qemu-img", "cloud-localds"} { +func (m *Manager) validateHost(skipCloudInit bool) error { + for _, name := range []string{"virsh", "qemu-img"} { if err := requireCommand(name); err != nil { return err } } + if skipCloudInit { + if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil { + return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err) + } + } else { + if err := requireCommand("cloud-localds"); err != nil { + return err + } + } if _, err := os.Stat("/dev/kvm"); err != nil { return fmt.Errorf("KVM is not available: /dev/kvm not found") } @@ -1004,16 +1358,55 @@ func requireCommand(name string) error { return nil } -func ensureDefaultNetwork() error { - if exec.Command("virsh", "net-info", "default").Run() != nil { - return fmt.Errorf("libvirt default network is required for KVM support") - } - if exec.Command("virsh", "net-info", "default").Run() == nil { - out, _ := exec.Command("virsh", "net-info", "default").Output() - if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") { - _ = exec.Command("virsh", "net-start", "default").Run() +func requireAnyCommand(names ...string) error { + for _, name := range names { + if _, err := exec.LookPath(name); err == nil { + return nil } - _ = exec.Command("virsh", "net-autostart", "default").Run() + } + return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", ")) +} + +func ensureDefaultNetwork() error { + // Ensure libvirtd is running + if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil { + // Non-systemd systems may use a different init, try virsh connect + if exec.Command("virsh", "connect").Run() != nil { + return fmt.Errorf("libvirtd is not running and could not be started") + } + } + // Ensure default network is defined + if exec.Command("virsh", "net-info", "default").Run() != nil { + // Default network may not be defined; try to define it + netXML := ` + default + + + + + + + +` + tmpFile := filepath.Join(os.TempDir(), "clicd-default-net.xml") + if err := os.WriteFile(tmpFile, []byte(netXML), 0644); err != nil { + return fmt.Errorf("failed to write default network XML: %v", err) + } + defer os.Remove(tmpFile) + if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil { + return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out)) + } + } + // Start and autostart the default network + if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil { + if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") { + if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil { + return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut)) + } + } + } + if out, err := exec.Command("virsh", "net-autostart", "default").CombinedOutput(); err != nil { + return fmt.Errorf("failed to set autostart for libvirt default network: %v, output: %s", err, string(out)) } return nil } @@ -1034,6 +1427,247 @@ func createOverlayDisk(base, target string, diskGB int) error { return nil } +func ensureVirtioWinISO() error { + virtioPath := virtioWinISOPath() + if _, err := os.Stat(virtioPath); err == nil { + return nil + } + if err := os.MkdirAll(CacheDir(), 0755); err != nil { + return err + } + virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" + tmp := virtioPath + ".tmp" + _ = os.Remove(tmp) + if err := downloadFile(virtioURL, tmp); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("failed to download virtio-win.iso: %v", err) + } + if err := os.Rename(tmp, virtioPath); err != nil { + _ = os.Remove(tmp) + return err + } + _ = os.Chmod(virtioPath, 0644) + return nil +} + +func createEmptyDisk(target string, diskGB int) error { + if diskGB < 1 { + diskGB = 5 + } + cmd := exec.Command("qemu-img", "create", "-f", "qcow2", target, fmt.Sprintf("%dG", diskGB)) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img create empty disk failed: %v, output: %s", err, string(output)) + } + _ = os.Chmod(target, 0644) + return nil +} + +func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) error { + tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso") + if tool == "" { + return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup") + } + dir := filepath.Join(filepath.Dir(target), "unattend") + _ = os.RemoveAll(dir) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + defer os.RemoveAll(dir) + + answerPath := filepath.Join(dir, "Autounattend.xml") + setupScriptsDir := filepath.Join(dir, "$OEM$", "$$", "Setup", "Scripts") + clicdDir := filepath.Join(dir, "$OEM$", "$1", "CLICD") + for _, path := range []string{setupScriptsDir, clicdDir} { + if err := os.MkdirAll(path, 0700); err != nil { + return err + } + } + if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword)), 0600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil { + return err + } + _ = os.Remove(target) + var cmd *exec.Cmd + if tool == "xorriso" { + cmd = exec.Command(tool, "-as", "mkisofs", "-quiet", "-J", "-r", "-V", "CIDUNATTEND", "-o", target, dir) + } else { + cmd = exec.Command(tool, "-quiet", "-J", "-r", "-V", "CIDUNATTEND", "-o", target, dir) + } + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s failed: %v, output: %s", tool, err, string(output)) + } + _ = os.Chmod(target, 0644) + return nil +} + +func firstAvailableCommand(names ...string) string { + for _, name := range names { + if _, err := exec.LookPath(name); err == nil { + return name + } + } + return "" +} + +func windowsAutounattendXML(hostname, adminPassword string) string { + if strings.TrimSpace(hostname) == "" { + hostname = "clicd-win" + } + hostname = sanitizeWindowsComputerName(hostname) + setupCommand := `cmd.exe /c if exist C:\CLICD\FirstLogon.ps1 (powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\CLICD\FirstLogon.ps1) else (for %%d in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do @if exist %%d:\FirstLogon.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %%d:\FirstLogon.ps1)` + return fmt.Sprintf(` + + + + en-US + en-USen-USen-USen-US + + + + 0true1Primary3502Primarytrue11NTFStrue22CNTFS + OnError + + + + + + /IMAGE/INDEX + 1 + + + 02 + OnError + + + + true + CLICD + CLICD + + + + + + %s + UTC + + + + + en-USen-USen-USen-US + + + %strue</PlainText></Password><Enabled>true</Enabled><Username>Administrator</Username><LogonCount>1</LogonCount></AutoLogon> + <UserAccounts><AdministratorPassword><Value>%s</Value><PlainText>true</PlainText></AdministratorPassword></UserAccounts> + <OOBE><HideEULAPage>true</HideEULAPage><HideLocalAccountScreen>true</HideLocalAccountScreen><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE> + <FirstLogonCommands><SynchronousCommand wcm:action="add"><Order>1</Order><Description>CLICD Windows initialization</Description><CommandLine>%s</CommandLine></SynchronousCommand></FirstLogonCommands> + </component> + </settings> +</unattend> +`, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand)) +} + +func sanitizeWindowsComputerName(name string) string { + name = strings.TrimSpace(name) + var b strings.Builder + for _, r := range name { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' { + b.WriteRune(r) + } + } + result := strings.Trim(b.String(), "-") + if result == "" { + return "clicd-win" + } + if len(result) > 15 { + result = result[:15] + } + return result +} + +func windowsSetupCompleteCMD() string { + return `@echo off +if not exist C:\CLICD mkdir C:\CLICD +if exist C:\CLICD\FirstLogon.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\CLICD\FirstLogon.ps1 +exit /b 0 +` +} + +func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string { + commands := []string{ + "$ErrorActionPreference='Continue'", + "$ProgressPreference='SilentlyContinue'", + "New-Item -ItemType Directory -Force -Path 'C:\\CLICD' | Out-Null", + "Start-Transcript -Path 'C:\\CLICD\\init.log' -Append | Out-Null", + "try {", + "net user Administrator " + shellQuoteWindows(adminPassword) + " /active:yes", + "Set-LocalUser -Name 'Administrator' -PasswordNeverExpires $true -ErrorAction SilentlyContinue", + "Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force", + "$iface=$null", + "for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }", + "$iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1", + "if ($iface) { Set-NetIPInterface -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -Dhcp Enabled -ErrorAction SilentlyContinue }", + "if ($iface) { Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ResetServerAddresses -ErrorAction SilentlyContinue }", + "Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue", + "Set-ItemProperty -Path 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server' -Name fDenyTSConnections -Value 0", + "Set-ItemProperty -Path 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp' -Name UserAuthentication -Value 1", + "Set-Service -Name TermService -StartupType Automatic", + "Start-Service -Name TermService", + "Enable-NetFirewallRule -Name 'RemoteDesktop*' -ErrorAction SilentlyContinue", + "Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue", + "netsh advfirewall firewall set rule group=\"remote desktop\" new enable=Yes | Out-Null", + "New-NetFirewallRule -DisplayName 'CLICD RDP TCP 3389' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3389 -Profile Any -ErrorAction SilentlyContinue | Out-Null", + "New-NetFirewallRule -DisplayName 'CLICD RDP UDP 3389' -Direction Inbound -Action Allow -Protocol UDP -LocalPort 3389 -Profile Any -ErrorAction SilentlyContinue | Out-Null", + "$virtio=Get-Volume | Where-Object DriveType -eq 'CD-ROM' | ForEach-Object { $d=$_.DriveLetter; if ($d) { Get-ChildItem ($d+':\\') -Recurse -Filter 'qemu-ga-*.msi' -ErrorAction SilentlyContinue | Select-Object -First 1 } } | Select-Object -First 1", + "if ($virtio) { Start-Process msiexec.exe -ArgumentList '/i', $virtio.FullName, '/qn' -Wait }", + "Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic", + "Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue", + } + if strings.TrimSpace(ipv6) != "" { + commands = append(commands, + windowsIPv6PowerShell(strings.TrimSpace(ipv6)), + ) + } + commands = append(commands, + "New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null", + "} finally { Stop-Transcript | Out-Null }", + ) + return strings.Join(commands, "\r\n") + "\r\n" +} + +func windowsIPv6PowerShell(ipv6 string) string { + ipv6 = strings.TrimSpace(ipv6) + if ipv6 == "" { + return "" + } + return strings.Join([]string{ + "$iface=$null", + "for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }", + "if ($iface) {", + " Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq '" + ipv6 + "' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue", + " New-NetIPAddress -IPAddress '" + ipv6 + "' -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null", + " Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue", + " New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null", + " Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue", + "}", + }, "\r\n") +} + +func shellQuoteWindows(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` +} + func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error { guestSetup := kvmSSHSetupScript(password) if strings.TrimSpace(ipv6) != "" { @@ -1182,10 +1816,119 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, </domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth) } +func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string { + if vcpu < 1 { + vcpu = 1 + } + if ramMB < 2048 { + ramMB = 2048 + } + iotune := "" + if ioSpeedMBps > 0 { + bytesPerSecond := int64(ioSpeedMBps) * 1024 * 1024 + iotune = fmt.Sprintf(` + <iotune> + <total_bytes_sec>%d</total_bytes_sec> + </iotune>`, bytesPerSecond) + } + bandwidth := "" + if networkBWMbps > 0 { + averageKiB := networkBWMbps * 128 + bandwidth = fmt.Sprintf(` + <bandwidth> + <inbound average='%d'/> + <outbound average='%d'/> + </bandwidth>`, averageKiB, averageKiB) + } + virtioWinISO := virtioWinISOPath() + unattendDisk := "" + if strings.TrimSpace(unattendISOPath) != "" { + unattendDisk = fmt.Sprintf(` + <disk type='file' device='cdrom'> + <driver name='qemu' type='raw'/> + <source file='%s'/> + <target dev='hdd' bus='ide'/> + <readonly/> + </disk>`, xmlEscape(unattendISOPath)) + } + return fmt.Sprintf(`<domain type='kvm'> + <name>%s</name> + %s + <memory unit='MiB'>%d</memory> + <currentMemory unit='MiB'>%d</currentMemory> + <vcpu placement='static' current='%d'>%d</vcpu> + <cputune><shares>2048</shares></cputune> + <os> + <type arch='x86_64' machine='pc'>hvm</type> + </os> + <features> + <acpi/> + <apic/> + <hyperv mode='custom'> + <relaxed state='on'/> + <vapic state='on'/> + <spinlocks state='on' retries='8191'/> + </hyperv> + </features> + <cpu mode='host-passthrough' check='none'> + <topology sockets='1' cores='%d' threads='1'/> + </cpu> + <clock offset='localtime'> + <timer name='hypervclock' present='yes'/> + </clock> + <on_poweroff>destroy</on_poweroff> + <on_reboot>restart</on_reboot> + <on_crash>restart</on_crash> + <devices> + <emulator>/usr/bin/qemu-system-x86_64</emulator> + <disk type='file' device='disk'> + <driver name='qemu' type='qcow2' cache='none'/> + <source file='%s'/> + <target dev='sda' bus='sata'/> + <boot order='2'/>%s + </disk> + <disk type='file' device='cdrom'> + <driver name='qemu' type='raw'/> + <source file='%s'/> + <target dev='hdb' bus='ide'/> + <readonly/> + <boot order='1'/> + </disk> + <disk type='file' device='cdrom'> + <driver name='qemu' type='raw'/> + <source file='%s'/> + <target dev='hdc' bus='ide'/> + <readonly/> + </disk>%s + <interface type='network'> + <mac address='%s'/> + <source network='default'/> + <model type='e1000e'/>%s + </interface> + <channel type='unix'> + <target type='virtio' name='org.qemu.guest_agent.0'/> + </channel> + <input type='tablet' bus='usb'/> + <graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/> + <video><model type='qxl'/></video> + </devices> +</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, vcpu, + xmlEscape(diskPath), iotune, + xmlEscape(winISOPath), xmlEscape(virtioWinISO), unattendDisk, xmlEscape(mac), bandwidth) +} + func xmlEscape(value string) string { return html.EscapeString(value) } +func existingWindowsUnattendISO(instanceDir string) string { + path := filepath.Join(instanceDir, "unattend.iso") + if _, err := os.Stat(path); err == nil { + return path + } + return "" +} + func domainUUIDXML(name string) string { out, err := exec.Command("virsh", "domuuid", name).Output() if err != nil { @@ -1211,6 +1954,81 @@ func undefineDomain(name string) error { return exec.Command("virsh", "undefine", name).Run() } +func (m *Manager) RefreshVNCPort(id int) (int, error) { + c := config.FindContainer(id) + if c == nil { + return 0, fmt.Errorf("container not found: %d", id) + } + if !c.IsKVM() { + return 0, fmt.Errorf("container is not a KVM VM: %d", id) + } + port := getVNCPort(c.VirshName()) + if port <= 0 { + return 0, fmt.Errorf("VNC display is not available for %s", c.VirshName()) + } + if c.VNCPort != port { + c.VNCPort = port + config.SaveConfig() + } + return port, nil +} + +func normalizeKVMManagementPortMapping(c *config.Container) { + if c == nil || !c.IsKVM() { + return + } + hostPort := c.SSHPort + if hostPort <= 0 { + hostPort = config.AllocateSSHPort() + c.SSHPort = hostPort + } + desiredPort := 22 + description := "SSH" + if IsWindowsImage(c.Template) { + desiredPort = 3389 + description = "RDP" + } + mapping := config.PortMapping{ + ContainerPort: desiredPort, + HostPort: hostPort, + Protocol: "tcp", + Description: description, + } + for i, pm := range c.PortMappings { + if strings.EqualFold(pm.Description, "SSH") || strings.EqualFold(pm.Description, "RDP") || pm.ContainerPort == 22 || pm.ContainerPort == 3389 || pm.HostPort == hostPort { + if pm.HostPort > 0 { + mapping.HostPort = pm.HostPort + c.SSHPort = pm.HostPort + } + c.PortMappings[i] = mapping + return + } + } + c.PortMappings = append([]config.PortMapping{mapping}, c.PortMappings...) +} + +func getVNCPort(name string) int { + out, err := exec.Command("virsh", "domdisplay", name).Output() + if err != nil { + return 0 + } + display := strings.TrimSpace(string(out)) + // virsh domdisplay returns "vnc://127.0.0.1:0" or "vnc://127.0.0.1:5901" + if idx := strings.LastIndex(display, ":"); idx >= 0 { + portStr := display[idx+1:] + port, err := strconv.Atoi(portStr) + if err != nil { + return 0 + } + // Port 0 means VNC display 0 → actual port 5900 + if port < 5900 { + port += 5900 + } + return port + } + return 0 +} + func firstIPv4(output string) string { re := regexp.MustCompile(`\b((?:\d{1,3}\.){3}\d{1,3})(?:/\d+)?\b`) for _, match := range re.FindAllStringSubmatch(output, -1) { @@ -1270,6 +2088,10 @@ func (m *Manager) EnsureSSH(id int) error { if !c.IsKVM() { return fmt.Errorf("container is not a KVM VM: %d", id) } + // Windows VMs are managed via VNC, not SSH + if IsWindowsImage(c.Template) { + return nil + } status, _ := m.GetContainerStatus(c.VirshName()) if status != "running" { return fmt.Errorf("KVM VM %s is not running; cannot configure SSH", c.Name) @@ -1418,16 +2240,26 @@ fi ssh-keygen -A >/dev/null 2>&1 || true if command -v systemctl >/dev/null 2>&1; then systemctl enable --now qemu-guest-agent >/dev/null 2>&1 || true + systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true + systemctl restart getty@tty1.service >/dev/null 2>&1 || true systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || systemctl enable --now ssh >/dev/null 2>&1 || systemctl enable --now sshd >/dev/null 2>&1 || true fi if command -v rc-update >/dev/null 2>&1; then rc-update add sshd default >/dev/null 2>&1 || true rc-update add qemu-guest-agent default >/dev/null 2>&1 || rc-update add qemu-ga default >/dev/null 2>&1 || true + rc-update add agetty.tty1 default >/dev/null 2>&1 || true rc-service qemu-guest-agent start >/dev/null 2>&1 || rc-service qemu-ga start >/dev/null 2>&1 || true + rc-service agetty.tty1 restart >/dev/null 2>&1 || true rc-service sshd restart >/dev/null 2>&1 || /etc/init.d/sshd restart >/dev/null 2>&1 || true fi service qemu-guest-agent start >/dev/null 2>&1 || service qemu-ga start >/dev/null 2>&1 || true service ssh restart >/dev/null 2>&1 || service sshd restart >/dev/null 2>&1 || true +if command -v chvt >/dev/null 2>&1; then + chvt 1 >/dev/null 2>&1 || true +fi +if [ -w /dev/tty1 ]; then + printf '\nCLICD VNC console is ready. Press Enter for login prompt.\n' >/dev/tty1 || true +fi ` } @@ -1444,21 +2276,30 @@ func qemuGuestPing(name string) error { } func qemuGuestExec(name string, script string, timeout time.Duration) error { + return qemuGuestExecCommand(name, "/bin/sh", []string{"-lc", script}, timeout) +} + +func qemuGuestExecCommand(name string, path string, args []string, timeout time.Duration) error { + _, _, err := qemuGuestExecCommandOutput(name, path, args, timeout) + return err +} + +func qemuGuestExecCommandOutput(name string, path string, args []string, timeout time.Duration) (string, string, error) { req := map[string]interface{}{ "execute": "guest-exec", "arguments": map[string]interface{}{ - "path": "/bin/sh", - "arg": []string{"-lc", script}, + "path": path, + "arg": args, "capture-output": true, }, } payload, err := json.Marshal(req) if err != nil { - return err + return "", "", err } out, err := exec.Command("virsh", "qemu-agent-command", name, string(payload)).CombinedOutput() if err != nil { - return fmt.Errorf("guest-exec failed: %v, output: %s", err, string(out)) + return "", "", fmt.Errorf("guest-exec failed: %v, output: %s", err, string(out)) } var started struct { Return struct { @@ -1466,14 +2307,14 @@ func qemuGuestExec(name string, script string, timeout time.Duration) error { } `json:"return"` } if err := json.Unmarshal(out, &started); err != nil || started.Return.PID <= 0 { - return fmt.Errorf("guest-exec returned invalid response: %s", string(out)) + return "", "", fmt.Errorf("guest-exec returned invalid response: %s", string(out)) } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { statusReq := fmt.Sprintf(`{"execute":"guest-exec-status","arguments":{"pid":%d}}`, started.Return.PID) statusOut, err := exec.Command("virsh", "qemu-agent-command", name, statusReq).CombinedOutput() if err != nil { - return fmt.Errorf("guest-exec-status failed: %v, output: %s", err, string(statusOut)) + return "", "", fmt.Errorf("guest-exec-status failed: %v, output: %s", err, string(statusOut)) } var status struct { Return struct { @@ -1484,20 +2325,22 @@ func qemuGuestExec(name string, script string, timeout time.Duration) error { } `json:"return"` } if err := json.Unmarshal(statusOut, &status); err != nil { - return fmt.Errorf("guest-exec-status returned invalid response: %s", string(statusOut)) + return "", "", fmt.Errorf("guest-exec-status returned invalid response: %s", string(statusOut)) } if !status.Return.Exited { time.Sleep(3 * time.Second) continue } + stdoutBytes, _ := base64.StdEncoding.DecodeString(status.Return.OutData) + stderrBytes, _ := base64.StdEncoding.DecodeString(status.Return.ErrData) + stdout := string(stdoutBytes) + stderr := string(stderrBytes) if status.Return.Exitcode == 0 { - return nil + return stdout, stderr, nil } - stdout, _ := base64.StdEncoding.DecodeString(status.Return.OutData) - stderr, _ := base64.StdEncoding.DecodeString(status.Return.ErrData) - return fmt.Errorf("guest SSH setup exited with %d, stdout: %s, stderr: %s", status.Return.Exitcode, string(stdout), string(stderr)) + return stdout, stderr, fmt.Errorf("guest command exited with %d, stdout: %s, stderr: %s", status.Return.Exitcode, stdout, stderr) } - return fmt.Errorf("timed out waiting for guest SSH setup after %s", timeout) + return "", "", fmt.Errorf("timed out waiting for guest command after %s", timeout) } func (m *Manager) getUsageCounters(c *config.Container) (uint64, uint64, uint64, uint64, uint64) { @@ -1530,6 +2373,47 @@ func (m *Manager) StartIPv6Guard() { }() } +func (m *Manager) StartNetworkSyncMonitor() { + go func() { + m.syncRunningNetworks() + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for range ticker.C { + m.syncRunningNetworks() + } + }() +} + +func (m *Manager) syncRunningNetworks() { + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if !c.IsKVM() { + continue + } + status, err := m.GetContainerStatus(c.VirshName()) + if err == nil && status != "" && c.Status != status { + c.Status = status + config.SaveConfig() + } + if status != "running" && c.Status != "running" { + continue + } + if _, err := m.RefreshVNCPort(c.ID); err != nil { + fmt.Printf("Warning: failed to sync VNC port for %s: %v\n", c.Name, err) + } + if ip, err := m.RefreshNetwork(c.ID); err == nil && ip != "" { + c.IP = ip + } else if err != nil { + fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err) + } + if c.IPv6 != "" { + if err := m.applyIPv6Runtime(c); err != nil { + fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err) + } + } + } +} + func (m *Manager) applyIPv6Guards() { for i := range config.AppConfig.Containers { c := &config.AppConfig.Containers[i] @@ -1906,13 +2790,26 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error { } if c.Status == "running" { if err := m.applyGuestIPv6Runtime(c); err != nil { - fmt.Printf("Warning: failed to apply KVM guest IPv6 for %s: %v\n", c.Name, err) + if shouldLogIPv6GuestWarning(c.ID) { + fmt.Printf("Warning: failed to apply KVM guest IPv6 for %s: %v\n", c.Name, err) + } } } ensureKVMIPv6NAT66(c.IPv6, c.IPv6Interface) return nil } +func shouldLogIPv6GuestWarning(id int) bool { + ipv6WarnMu.Lock() + defer ipv6WarnMu.Unlock() + now := time.Now() + if last, ok := lastIPv6GuestWarn[id]; ok && now.Sub(last) < 5*time.Minute { + return false + } + lastIPv6GuestWarn[id] = now + return true +} + func (m *Manager) applyIPv6HostRuntime(c *config.Container) error { if c == nil || c.IPv6 == "" { return nil @@ -1931,7 +2828,10 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error { runQuiet("sysctl", "-w", "net.ipv6.conf."+c.IPv6Interface+".proxy_ndp=1") bridge := "virbr0" runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".disable_ipv6=0") - runQuiet("ip", "-6", "addr", "add", ipv6GatewayLinkLocal+"/64", "dev", bridge) + runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".forwarding=1") + runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1") + runQuiet("ip", "link", "set", bridge, "up") + runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge) if out, err := exec.Command("ip", "-6", "route", "replace", c.IPv6+"/128", "dev", bridge).CombinedOutput(); err != nil { return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out)) } @@ -2081,6 +2981,9 @@ func (m *Manager) applyGuestIPv6(c *config.Container) error { if c == nil || c.IPv6 == "" { return nil } + if IsWindowsImage(c.Template) { + return m.applyWindowsGuestIPv6(c) + } script := kvmIPv6SetupScript(c.IPv6) if err := qemuGuestPing(c.VirshName()); err != nil { return err @@ -2088,6 +2991,17 @@ func (m *Manager) applyGuestIPv6(c *config.Container) error { return qemuGuestExec(c.VirshName(), script, 60*time.Second) } +func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error { + if c == nil || c.IPv6 == "" { + return nil + } + if err := qemuGuestPing(c.VirshName()); err != nil { + return err + } + script := windowsIPv6PowerShell(c.IPv6) + return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second) +} + func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error { if c == nil || c.IPv6 == "" { return nil @@ -2107,6 +3021,9 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error { if c == nil || c.IPv6 == "" { return nil } + if IsWindowsImage(c.Template) { + return fmt.Errorf("SSH IPv6 fallback is not supported for Windows guests") + } if c.IP == "" || c.SSHPassword == "" { return fmt.Errorf("missing guest IPv4 or SSH password") } @@ -2259,6 +3176,43 @@ func generateRandomString(length int) string { return hex.EncodeToString(b)[:length] } +func generateWindowsPassword() string { + upper := "ABCDEFGHJKLMNPQRSTUVWXYZ" + lower := "abcdefghijkmnopqrstuvwxyz" + digits := "23456789" + symbols := "!@#$%*-_+=" + all := upper + lower + digits + symbols + chars := []byte{ + randomChar(upper), + randomChar(lower), + randomChar(digits), + randomChar(symbols), + } + for len(chars) < 20 { + chars = append(chars, randomChar(all)) + } + for i := range chars { + j := secureRandomInt(len(chars)) + chars[i], chars[j] = chars[j], chars[i] + } + return string(chars) +} + +func randomChar(chars string) byte { + return chars[secureRandomInt(len(chars))] +} + +func secureRandomInt(max int) int { + if max <= 1 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) + if err != nil { + return int(time.Now().UnixNano() % int64(max)) + } + return int(n.Int64()) +} + func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go index 97e895f..bd4cebc 100644 --- a/backend/internal/kvm/templates.go +++ b/backend/internal/kvm/templates.go @@ -70,6 +70,12 @@ func GetImages() []Image { Description: "Rocky Linux 9 GenericCloud image for KVM", URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2", }, + { + ID: "kvm-windows-10", Name: "Windows 10 KVM", + Distro: "windows", Release: "10", Arch: "amd64", + Description: "Windows ISO for KVM (automatic unattended install from image index 1, network, Administrator password, RDP, firewall, and QEMU Guest Agent initialization)", + URL: "https://go.microsoft.com/fwlink/?LinkID=2195404", + }, } } @@ -87,5 +93,20 @@ func CacheDir() string { } func ImagePath(id string) string { - return filepath.Join(CacheDir(), id+".qcow2") + img := FindImage(id) + ext := ".qcow2" + if img != nil && img.Distro == "windows" { + ext = ".iso" + } + return filepath.Join(CacheDir(), id+ext) +} + +// IsWindowsImage returns true if the image distro is "windows". +func IsWindowsImage(id string) bool { + img := FindImage(id) + return img != nil && img.Distro == "windows" +} + +func virtioWinISOPath() string { + return filepath.Join(CacheDir(), "virtio-win.iso") } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 5360faf..34c8766 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -104,6 +104,8 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary))) mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket))) mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket + mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket))) + mux.HandleFunc("/api/vnc", api.HandleVNCProxy) // WebSocket // API Key management mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) @@ -161,4 +163,3 @@ func Run() error { return server.ListenAndServe() } - diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep deleted file mode 100644 index 30259b2..0000000 --- a/backend/internal/server/web/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/main.go b/backend/main.go index 542fced..dcef40f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -64,6 +64,7 @@ func main() { // Start usage monitors (computes CPU/network/disk rates every 5s) manager.StartUsageMonitor() kvmManager.StartUsageMonitor() + kvmManager.StartNetworkSyncMonitor() kvmManager.StartIPv6Guard() // Start scheduled snapshot scanners. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d87a5b..5249a75 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "clicd-frontend", "version": "1.0.0", "dependencies": { + "@novnc/novnc": "1.6.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "axios": "^1.7.7", @@ -801,6 +802,12 @@ "node": ">= 8" } }, + "node_modules/@novnc/novnc": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.6.0.tgz", + "integrity": "sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==", + "license": "MPL-2.0" + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1f6650e..f5566f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@novnc/novnc": "1.6.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "axios": "^1.7.7", diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 1531d69..4c8283c 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -48,7 +48,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist .then((res) => { const data = res.data.data || [] setTemplates(data) - setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') })) + setForm((prev) => { + const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') + return applyTemplateDefaults({ ...prev, template_id: templateID }) + }) }) .catch(console.error) @@ -190,14 +193,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist <div className="grid grid-cols-2 gap-2"> <button type="button" - onClick={() => setForm((prev) => ({ ...prev, virtualization: 'lxc', template_id: '' }))} + onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))} className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`} > LXC 容器 </button> <button type="button" - onClick={() => setForm((prev) => ({ ...prev, virtualization: 'kvm', template_id: '' }))} + onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))} className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`} > KVM 虚拟机 @@ -213,7 +216,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist ) : ( <select value={form.template_id} - onChange={(event) => setForm({ ...form, template_id: event.target.value })} + onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))} className={inputClass} > {templates.map((template) => ( @@ -223,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist ))} </select> )} + </Field> <label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}> @@ -324,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist /> <div className="mt-2 flex flex-wrap gap-1.5"> <span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono"> - SSH: {sshPortPreview} -&gt; 22 + {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22} </span> {autoPorts.map((port) => ( <span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono"> @@ -435,7 +439,10 @@ function NumberInput({ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) { const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {} - const minVCPU = form.virtualization === 'kvm' ? 1 : 0.25 + const windows = isWindowsTemplate(form.template_id) + const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25) + const minRAMMB = windows ? 2048 : 128 + const minDiskGB = windows ? 30 : 1 if (!Number.isFinite(form.vcpu)) { errors.vcpu = '请输入 vCPU' @@ -449,16 +456,16 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m if (!Number.isFinite(form.ram_mb)) { errors.ram_mb = '请输入内存' - } else if (form.ram_mb < 128) { - errors.ram_mb = '不能小于 128 MB' + } else if (form.ram_mb < minRAMMB) { + errors.ram_mb = `不能小于 ${minRAMMB} MB` } else if (maxRAMMB && form.ram_mb > maxRAMMB) { errors.ram_mb = `不能大于 ${maxRAMMB} MB` } if (!Number.isFinite(form.disk_gb)) { errors.disk_gb = '请输入磁盘' - } else if (form.disk_gb < 1) { - errors.disk_gb = '不能小于 1 GB' + } else if (form.disk_gb < minDiskGB) { + errors.disk_gb = `不能小于 ${minDiskGB} GB` } else if (maxDiskGB && form.disk_gb > maxDiskGB) { errors.disk_gb = `不能大于 ${maxDiskGB} GB` } @@ -467,15 +474,31 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m } function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest { + const normalized = applyTemplateDefaults(form) + return { + ...normalized, + vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu), + ram_mb: Math.round(normalized.ram_mb), + disk_gb: Math.round(normalized.disk_gb), + snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3), + } +} + +function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest { + if (!isWindowsTemplate(form.template_id)) return form return { ...form, - vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu), - ram_mb: Math.round(form.ram_mb), - disk_gb: Math.round(form.disk_gb), - snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3), + virtualization: 'kvm', + vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)), + ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)), + disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)), } } +function isWindowsTemplate(templateID: string) { + return templateID.toLowerCase().includes('windows') +} + function normalizeLXCvCPU(value: number) { const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4 return Number(rounded.toFixed(2)) diff --git a/frontend/src/components/WebVNCViewer.tsx b/frontend/src/components/WebVNCViewer.tsx new file mode 100644 index 0000000..3fd8690 --- /dev/null +++ b/frontend/src/components/WebVNCViewer.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' +import { Monitor, RefreshCw, Send, X } from 'lucide-react' +import RFB from '@novnc/novnc' +import { createVNCTicket, getWebVNCUrl } from '../services/api' + +interface WebVNCViewerProps { + containerName: string + onClose: () => void +} + +export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) { + const screenRef = useRef<HTMLDivElement>(null) + const rfbRef = useRef<RFB | null>(null) + const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting') + const [errorMsg, setErrorMsg] = useState('') + + const cleanup = () => { + if (rfbRef.current) { + rfbRef.current.disconnect() + rfbRef.current = null + } + } + + const connect = async () => { + const target = screenRef.current + if (!target) return + + cleanup() + target.innerHTML = '' + setStatus('connecting') + setErrorMsg('') + + let ticket = '' + try { + const response = await createVNCTicket(containerName) + ticket = response.data.data?.ticket || '' + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } } + setStatus('error') + setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试') + return + } + if (!ticket) { + setStatus('error') + setErrorMsg('WebVNC ticket 为空,请重新登录后再试') + return + } + + try { + const rfb = new RFB(target, getWebVNCUrl(containerName, ticket)) + rfb.scaleViewport = true + rfb.resizeSession = false + rfb.focusOnClick = true + rfb.qualityLevel = 6 + rfb.compressionLevel = 2 + rfb.background = '#050505' + rfb.addEventListener('connect', () => { + setStatus('connected') + }) + rfb.addEventListener('disconnect', (event) => { + const detail = (event as CustomEvent<{ clean?: boolean }>).detail + setStatus((current) => current === 'error' ? current : 'disconnected') + if (detail && detail.clean === false) { + setErrorMsg('WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用') + } + }) + rfb.addEventListener('securityfailure', () => { + setStatus('error') + setErrorMsg('VNC 安全协商失败') + }) + rfb.addEventListener('credentialsrequired', () => { + setStatus('error') + setErrorMsg('当前 VNC 控制台要求密码,暂不支持自动输入') + }) + rfbRef.current = rfb + } catch (err) { + console.error(err) + setStatus('error') + setErrorMsg('WebVNC 初始化失败') + } + } + + useEffect(() => { + const timer = window.setTimeout(connect, 100) + return () => { + window.clearTimeout(timer) + cleanup() + } + }, [containerName]) + + return ( + <div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col"> + <div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0"> + <div className="flex items-center gap-2"> + <Monitor className="w-4 h-4 text-gray-600" /> + <span className="text-sm font-medium text-black">WebVNC - {containerName}</span> + {status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>} + {status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>} + {status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>} + {status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>} + </div> + <div className="flex items-center gap-1"> + <button + onClick={() => rfbRef.current?.sendCtrlAltDel()} + className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" + title="发送 Ctrl+Alt+Del" + > + <Send className="w-3.5 h-3.5" /> + Ctrl+Alt+Del + </button> + <button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接"> + <RefreshCw className="w-3.5 h-3.5" /> + </button> + <button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭"> + <X className="w-4 h-4" /> + </button> + </div> + </div> + + <div className="relative flex-1 min-h-0 bg-black overflow-hidden"> + <div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" /> + {(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && ( + <div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}> + {status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')} + </div> + )} + </div> + </div> + ) +} diff --git a/frontend/src/novnc.d.ts b/frontend/src/novnc.d.ts new file mode 100644 index 0000000..89e0793 --- /dev/null +++ b/frontend/src/novnc.d.ts @@ -0,0 +1,14 @@ +declare module '@novnc/novnc' { + export default class RFB extends EventTarget { + constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }) + scaleViewport: boolean + resizeSession: boolean + focusOnClick: boolean + viewOnly: boolean + qualityLevel: number + compressionLevel: number + background: string + disconnect(): void + sendCtrlAltDel(): void + } +} diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index 9e94ada..add997a 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, type ReactNode } from 'react' +import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { ArrowLeft, @@ -8,7 +8,10 @@ import { Cpu, HardDrive, Key, + Maximize2, MemoryStick, + Minimize2, + Monitor, Network, Pencil, Play, @@ -65,6 +68,7 @@ import { import { useDialog } from '../components/Dialog' import { useAuth } from '../contexts/AuthContext' import WebSSHViewer from '../components/WebSSHViewer' +import WebVNCViewer from '../components/WebVNCViewer' import { RingStat } from '../components/RingStats' import { copyToClipboard } from '../utils/clipboard' import ResourceStatsPanel, { @@ -115,6 +119,9 @@ export default function ContainerDetail() { const [actionLoading, setActionLoading] = useState<string | null>(null) const [taskStatus, setTaskStatus] = useState('') // current task type for this container const [showSSH, setShowSSH] = useState(false) + const [showVNC, setShowVNC] = useState(false) + const vncFullscreenRef = useRef<HTMLDivElement>(null) + const [vncFullscreen, setVncFullscreen] = useState(false) const [showNat, setShowNat] = useState(false) const [showNatAdd, setShowNatAdd] = useState(false) const [showExpiryEdit, setShowExpiryEdit] = useState(false) @@ -180,15 +187,18 @@ export default function ContainerDetail() { const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => { if (!containerIdentifier || !currentContainer) return - const memoryPct = currentContainer.ram_mb > 0 - ? (nextUsage.memory_usage_bytes / (currentContainer.ram_mb * 1024 * 1024)) * 100 + const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0 + ? nextUsage.memory_total_bytes + : currentContainer.ram_mb * 1024 * 1024 + const memoryPct = memoryTotalBytes > 0 + ? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100 : 0 const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0) const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0) const point: MetricPoint = { ts: Date.now(), - cpu: clamp(nextUsage.cpu_usage_pct || 0), + cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)), memory: clamp(memoryPct), network: networkBps, diskIO: diskIOBps, @@ -227,6 +237,28 @@ export default function ContainerDetail() { return () => window.clearInterval(timer) }, [fetchContainer]) + useEffect(() => { + const handleFullscreenChange = () => { + setVncFullscreen(document.fullscreenElement === vncFullscreenRef.current) + } + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) + }, []) + + const toggleVNCFullscreen = async () => { + const target = vncFullscreenRef.current + if (!target) return + try { + if (document.fullscreenElement === target) { + await document.exitFullscreen() + } else { + await target.requestFullscreen() + } + } catch { + setVncFullscreen((value) => !value) + } + } + useEffect(() => { fetchUsage() const timer = window.setInterval(fetchUsage, 5000) @@ -278,6 +310,7 @@ export default function ContainerDetail() { case 'stop': await stopContainer(containerIdentifier) setShowSSH(false) + setShowVNC(false) break case 'restart': await restartContainer(containerIdentifier) @@ -390,6 +423,7 @@ export default function ContainerDetail() { await reinstallContainer(containerIdentifier, selectedTemplate) setShowReinstall(false) setShowSSH(false) + setShowVNC(false) await fetchContainer() } catch (err) { console.error('Reinstall failed:', err) @@ -643,17 +677,24 @@ export default function ContainerDetail() { } const isRunning = container.status === 'running' + const isKVM = (container.virtualization || 'lxc') === 'kvm' + const isWindows = container.template?.includes('windows') + const canOpenVNC = isKVM && isRunning const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}` const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && ( - container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 + container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 || + container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389 ) const filtered = filterHistory(history, range) - const cpuPct = clamp(usage?.cpu_usage_pct || 0) - const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0 + const cpuPct = clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))) + const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0 + ? usage.memory_total_bytes + : container.ram_mb * 1024 * 1024 + const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0 const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0 const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0) @@ -670,7 +711,7 @@ export default function ContainerDetail() { { title: 'CPU 使用率', icon: <Cpu className="w-5 h-5" />, - current: usage?.cpu_usage_pct || 0, + current: clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))), points: toChartPoints(filtered, 'cpu'), max: 100, formatValue: formatPercent, @@ -683,7 +724,7 @@ export default function ContainerDetail() { points: toChartPoints(filtered, 'memory'), max: 100, formatValue: formatPercent, - detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${container.ram_mb} MB`, + detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${formatBytes(ramTotalBytes)}`, }, { title: '网络流量', @@ -732,7 +773,7 @@ export default function ContainerDetail() { <InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag> <InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag> <InfoTag color="amber">NAT {mappingCount} 条</InfoTag> - <InfoTag color="violet">{publicHost}:{container.ssh_port}</InfoTag> + <InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag> </div> </div> </div> @@ -753,10 +794,18 @@ export default function ContainerDetail() { <RefreshCw className="w-3.5 h-3.5" /> {isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} </ActionButton> - <ActionButton dark onClick={() => setShowSSH(true)}> - <TerminalSquare className="w-3.5 h-3.5" /> - WebSSH - </ActionButton> + {!isWindows && ( + <ActionButton dark onClick={() => setShowSSH(true)}> + <TerminalSquare className="w-3.5 h-3.5" /> + WebSSH + </ActionButton> + )} + {isKVM && ( + <ActionButton dark disabled={!canOpenVNC} onClick={() => setShowVNC(true)}> + <Monitor className="w-3.5 h-3.5" /> + WebVNC + </ActionButton> + )} </> )} {!isSubUser && ( @@ -793,30 +842,59 @@ export default function ContainerDetail() { <div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <Panel title="连接信息"> - <PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} /> - <PlainRow label="用户名" value="root" mono /> - <div className="flex items-center justify-between gap-3"> - <span className="text-gray-500">SSH 密码</span> - <div className="flex items-center gap-1 min-w-0"> - <span - className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`} - onClick={() => setShowPassword(!showPassword)} - title={showPassword ? '点击隐藏' : '点击显示'} - > - {showPassword ? (container.ssh_password || '-') : '••••••••'} - </span> - {container.ssh_password && ( - <button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制"> - <Copy className="w-3 h-3" /> + {isWindows ? ( + <> + <PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono /> + <PlainRow label="用户名" value="Administrator" mono /> + <div className="flex items-center justify-between gap-3"> + <span className="text-gray-500">管理员密码</span> + <div className="flex items-center gap-1 min-w-0"> + <span + className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`} + onClick={() => setShowPassword(!showPassword)} + title={showPassword ? '点击隐藏' : '点击显示'} + > + {container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'} + </span> + {container.ssh_password && ( + <button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制"> + <Copy className="w-3 h-3" /> + </button> + )} + </div> + </div> + {container.vnc_port > 0 && ( + <PlainRow label="VNC 端口" value={`127.0.0.1:${container.vnc_port}`} mono /> + )} + </> + ) : ( + <> + <PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} /> + <PlainRow label="用户名" value="root" mono /> + <div className="flex items-center justify-between gap-3"> + <span className="text-gray-500">SSH 密码</span> + <div className="flex items-center gap-1 min-w-0"> + <span + className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`} + onClick={() => setShowPassword(!showPassword)} + title={showPassword ? '点击隐藏' : '点击显示'} + > + {container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'} + </span> + {container.ssh_password && ( + <button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制"> + <Copy className="w-3 h-3" /> + </button> + )} + </div> + </div> + {!isSubUser && ( + <button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black"> + <Key className="w-3 h-3" /> + 重置 SSH 密码 </button> )} - </div> - </div> - {!isSubUser && ( - <button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black"> - <Key className="w-3 h-3" /> - 重置 SSH 密码 - </button> + </> )} </Panel> @@ -871,7 +949,7 @@ export default function ContainerDetail() { <RingStat value={ramPct} label="内存" - subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(container.ram_mb * 1024 * 1024)}`} + subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(ramTotalBytes)}`} /> <RingStat value={loadPct} @@ -980,6 +1058,38 @@ export default function ContainerDetail() { </Modal> )} + {showVNC && ( + <Modal + title={`WebVNC - ${container.name}`} + onClose={() => setShowVNC(false)} + wide + flush + extra={ + <button + onClick={toggleVNCFullscreen} + className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-gray-600 hover:text-black hover:bg-gray-100 rounded" + title={vncFullscreen ? '退出全屏' : '全屏显示'} + > + {vncFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />} + {vncFullscreen ? '退出全屏' : '全屏'} + </button> + } + > + <div + ref={vncFullscreenRef} + className={`bg-white ${vncFullscreen ? 'fixed inset-0 z-[70] p-3' : 'h-[calc(92vh-112px)] min-h-[420px] p-5'}`} + > + <div className="h-full"> + {canOpenVNC ? ( + <WebVNCViewer containerName={container.name} onClose={() => setShowVNC(false)} /> + ) : ( + <div className="h-full flex items-center justify-center bg-gray-950 text-gray-400 rounded-md">VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面</div> + )} + </div> + </div> + </Modal> + )} + {showSnapshots && ( <Modal title="快照" @@ -1573,7 +1683,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, </thead> <tbody className="divide-y divide-gray-100"> {mappings.map((pm, index) => { - const isSSH = pm.description === 'SSH' || pm.container_port === 22 + const isSSH = pm.description === 'SSH' || pm.container_port === 22 || pm.description === 'RDP' || pm.container_port === 3389 return ( <tr key={`${pm.host_port}-${pm.container_port}-${index}`}> <td className="px-3 py-2 text-sm"> @@ -1619,7 +1729,7 @@ function Field({ label, children }: { label: string; children: ReactNode }) { ) } -function Modal({ title, children, onClose, wide = false, extra }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode }) { +function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) { return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}> @@ -1632,7 +1742,7 @@ function Modal({ title, children, onClose, wide = false, extra }: { title: strin </button> </div> </div> - <div className="p-5 overflow-y-auto">{children}</div> + <div className={flush ? "overflow-hidden" : "p-5 overflow-y-auto"}>{children}</div> </div> </div> ) @@ -1817,5 +1927,6 @@ function getTemplateIcon(id: string): ReactNode { if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg> if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> + if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg> return null } diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 354227d..f39bda4 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -393,10 +393,16 @@ export default function Containers() { const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask const isPlaceholder = !!container.isPlaceholder const usage = usageByName[container.name] + const isKVM = (container.virtualization || 'lxc') === 'kvm' - const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0 - const ramPct = isRunning && container.ram_mb > 0 - ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) + const cpuPct = isRunning + ? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1)) + : 0 + const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0 + ? usage.memory_total_bytes + : container.ram_mb * 1024 * 1024 + const ramPct = isRunning && ramTotalBytes > 0 + ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) @@ -803,6 +809,7 @@ function getSystemFilterValue(template: string) { if (normalized.startsWith('archlinux')) return 'archlinux' if (normalized.startsWith('fedora')) return 'fedora' if (normalized.startsWith('rockylinux')) return 'rockylinux' + if (normalized.startsWith('windows')) return 'windows' return normalized || 'unknown' } @@ -815,6 +822,7 @@ function getSystemFilterLabel(system: string) { archlinux: 'Arch Linux', fedora: 'Fedora', rockylinux: 'Rocky Linux', + windows: 'Windows', unknown: '未知系统', } return labels[system] || system @@ -944,6 +952,7 @@ function getTemplateName(id: string) { 'kvm-debian-bookworm': 'Debian 12', 'kvm-debian-bullseye': 'Debian 11', 'kvm-rockylinux-9': 'Rocky 9', + 'kvm-windows-10': 'Windows 10', } return map[id] || id } @@ -958,6 +967,7 @@ function getTemplateIcon(id: string): ReactNode { if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg> if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> + if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg> return null } diff --git a/frontend/src/pages/ImageManagement.tsx b/frontend/src/pages/ImageManagement.tsx index 80170f1..fc882e1 100644 --- a/frontend/src/pages/ImageManagement.tsx +++ b/frontend/src/pages/ImageManagement.tsx @@ -11,8 +11,10 @@ import { AlertCircle, } from 'lucide-react' import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api' +import { useDialog } from '../components/Dialog' export default function ImageManagement() { + const dialog = useDialog() const [images, setImages] = useState<ImageInfo[]>([]) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState<string | null>(null) @@ -43,15 +45,14 @@ export default function ImageManagement() { await downloadImage(templateId) await fetchImages() } catch (err: unknown) { - const msg = err instanceof Error ? err.message : '下载失败' - setError(msg) + setError(apiErrorMessage(err, '下载失败')) } finally { setActionLoading(null) } } const handleDelete = async (templateId: string) => { - if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return + if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return setActionLoading(templateId) setError('') try { @@ -207,6 +208,7 @@ function ImageTable({ <div> <span className="font-medium text-gray-900 text-sm">{img.name}</span> <p className="text-[11px] text-gray-400">{img.description}</p> + </div> </div> </td> @@ -316,6 +318,10 @@ function StatusBadge({ img }: { img: ImageInfo }) { ) } +function isWindowsImage(img: ImageInfo) { + return img.distro === 'windows' || img.id.toLowerCase().includes('windows') +} + function getTemplateIcon(id: string): ReactNode { const size = 'w-5 h-5' id = id.startsWith('kvm-') ? id.slice(4) : id @@ -326,9 +332,15 @@ function getTemplateIcon(id: string): ReactNode { if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg> if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> + if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg> return null } +function apiErrorMessage(err: unknown, fallback: string) { + const error = err as { response?: { data?: { message?: string } }; message?: string } + return error.response?.data?.message || error.message || fallback +} + function formatSize(bytes: number): string { if (bytes <= 0) return '-' if (bytes < 1024) return `${bytes} B` diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d522e86..c06c954 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -159,6 +159,7 @@ export interface HostInfo { export interface ContainerUsage { memory_usage_bytes: number + memory_total_bytes?: number cpu_usage_usec: number cpu_usage_pct: number disk_usage_bytes: number @@ -173,6 +174,7 @@ export interface ContainerUsage { load1: number load5: number load15: number + guest_metrics?: boolean } export interface APIResponse<T = unknown> { @@ -353,6 +355,7 @@ export interface ImageInfo { enabled: boolean downloading: boolean size_bytes: number + manual_path?: string } export const getImages = () => @@ -440,6 +443,13 @@ export const getWebSSHUrl = (containerName: string) => { return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` } +export const getWebVNCUrl = (containerName: string, ticket?: string) => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const params = new URLSearchParams({ container: containerName }) + if (ticket) params.set('ticket', ticket) + return `${protocol}//${window.location.host}/api/vnc?${params.toString()}` +} + // Task Queue export interface Task { id: string @@ -529,6 +539,9 @@ export const getSecuritySummary = () => export const createWebSSHTicket = (containerName: string) => api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName }) +export const createVNCTicket = (containerName: string) => + api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName }) + // Version export const getVersion = () => api.get<APIResponse<{ version: string }>>('/version') diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9f1c879..0d93746 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ }, build: { outDir: 'dist', + target: 'es2022', } }) diff --git a/install.sh b/install.sh index e0ec7da..654455f 100644 --- a/install.sh +++ b/install.sh @@ -249,7 +249,12 @@ install_apt() { quota \ e2fsprogs \ xfsprogs \ - dnsmasq-base + dnsmasq-base \ + qemu-kvm \ + libvirt-daemon-system \ + libvirt-clients \ + virtinst \ + virt-manager } enable_el_repos() { @@ -372,6 +377,7 @@ setup_lxc_services() { systemctl enable --now lxcfs >/dev/null 2>&1 || true systemctl enable --now lxc-net >/dev/null 2>&1 || true systemctl enable --now lxc >/dev/null 2>&1 || true + systemctl enable --now libvirtd >/dev/null 2>&1 || true return fi @@ -382,6 +388,8 @@ setup_lxc_services() { rc-service lxc start >/dev/null 2>&1 || true rc-update add lxcfs default >/dev/null 2>&1 || true rc-service lxcfs start >/dev/null 2>&1 || true + rc-update add libvirtd default >/dev/null 2>&1 || true + rc-service libvirtd start >/dev/null 2>&1 || true return fi