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
+
+
+ %struetrueAdministrator1
+ %strue
+ truetruetruetruetrue3
+ 1CLICD Windows initialization%s
+
+
+
+`, 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,
`, 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(`
+
+ %d
+ `, bytesPerSecond)
+ }
+ bandwidth := ""
+ if networkBWMbps > 0 {
+ averageKiB := networkBWMbps * 128
+ bandwidth = fmt.Sprintf(`
+
+
+
+ `, averageKiB, averageKiB)
+ }
+ virtioWinISO := virtioWinISOPath()
+ unattendDisk := ""
+ if strings.TrimSpace(unattendISOPath) != "" {
+ unattendDisk = fmt.Sprintf(`
+
+
+
+
+
+ `, xmlEscape(unattendISOPath))
+ }
+ return fmt.Sprintf(`
+ %s
+ %s
+ %d
+ %d
+ %d
+ 2048
+
+ hvm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ destroy
+ restart
+ restart
+
+ /usr/bin/qemu-system-x86_64
+
+
+
+
+ %s
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s
+
+
+
+ %s
+
+
+
+
+
+
+
+
+`, 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
@@ -753,10 +794,18 @@ export default function ContainerDetail() {
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
- setShowSSH(true)}>
-
- WebSSH
-
+ {!isWindows && (
+ setShowSSH(true)}>
+
+ WebSSH
+
+ )}
+ {isKVM && (
+ setShowVNC(true)}>
+
+ WebVNC
+
+ )}
>
)}
{!isSubUser && (
@@ -793,30 +842,59 @@ export default function ContainerDetail() {
-
-
-
-
SSH 密码
-
-
setShowPassword(!showPassword)}
- title={showPassword ? '点击隐藏' : '点击显示'}
- >
- {showPassword ? (container.ssh_password || '-') : '••••••••'}
-
- {container.ssh_password && (
-
copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
-
+ {isWindows ? (
+ <>
+
+
+
+
管理员密码
+
+ setShowPassword(!showPassword)}
+ title={showPassword ? '点击隐藏' : '点击显示'}
+ >
+ {container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
+
+ {container.ssh_password && (
+ copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
+
+
+ )}
+
+
+ {container.vnc_port > 0 && (
+
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
SSH 密码
+
+ setShowPassword(!showPassword)}
+ title={showPassword ? '点击隐藏' : '点击显示'}
+ >
+ {container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
+
+ {container.ssh_password && (
+ copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
+
+
+ )}
+
+
+ {!isSubUser && (
+
+
+ 重置 SSH 密码
)}
-
-
- {!isSubUser && (
-
-
- 重置 SSH 密码
-
+ >
)}
@@ -871,7 +949,7 @@ export default function ContainerDetail() {
)}
+ {showVNC && (
+ setShowVNC(false)}
+ wide
+ flush
+ extra={
+
+ {vncFullscreen ? : }
+ {vncFullscreen ? '退出全屏' : '全屏'}
+
+ }
+ >
+
+
+ {canOpenVNC ? (
+
setShowVNC(false)} />
+ ) : (
+ VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面
+ )}
+
+
+
+ )}
+
{showSnapshots && (
{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 (
@@ -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 (
@@ -1632,7 +1742,7 @@ function Modal({ title, children, onClose, wide = false, extra }: { title: strin
- {children}
+ {children}
)
@@ -1817,5 +1927,6 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('nixos')) return
if (id.startsWith('kali')) return
if (id.startsWith('rockylinux')) return
+ if (id.startsWith('windows')) return
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
if (id.startsWith('fedora')) return
if (id.startsWith('rockylinux')) return
+ if (id.startsWith('windows')) return
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([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(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({
{img.name}
{img.description}
+
|
@@ -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
if (id.startsWith('fedora')) return
if (id.startsWith('rockylinux')) return
+ if (id.startsWith('windows')) return
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 {
@@ -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>('/ssh-ticket', { container_name: containerName })
+export const createVNCTicket = (containerName: string) =>
+ api.post>('/vnc-ticket', { container_name: containerName })
+
// Version
export const getVersion = () =>
api.get>('/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