兼容KVM大多数功能

This commit is contained in:
MengMengCode
2026-06-07 18:57:55 +08:00
parent c99f3f6d55
commit 7e5da67de4
20 changed files with 1676 additions and 123 deletions
+4
View File
@@ -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})
}
+9 -1
View File
@@ -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"})
+228
View File
@@ -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
}
}
}
+7
View File
@@ -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"`
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -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")
}
+2 -1
View File
@@ -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()
}
-1
View File
@@ -1 +0,0 @@
+1
View File
@@ -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.