mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-08 06:24:44 +08:00
Add custom image handling and access policy management
- Implement tests for custom KVM and LXC image creation, ensuring invalid sources and architecture mismatches are rejected. - Introduce access policy management in CLI, allowing configuration of allowed sources and trusted proxies. - Add NAT network configuration with validation for RFC1918 compliance and subnet parsing. - Create panel access policy management, including normalization and evaluation of access decisions based on client IPs and forwarded headers. - Develop middleware for enforcing access policies in the server, returning appropriate responses for allowed and denied requests. - Enhance custom image downloading and validation, ensuring integrity and security of downloaded root filesystem archives. - Include comprehensive tests for all new functionalities to ensure reliability and correctness.
This commit is contained in:
+61
-17
@@ -166,7 +166,7 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
return err
|
||||
}
|
||||
ext := ".qcow2"
|
||||
if image.Distro == "windows" {
|
||||
if image.IsWindows() {
|
||||
ext = ".iso"
|
||||
}
|
||||
target := filepath.Join(cacheDir, image.ID+ext)
|
||||
@@ -184,7 +184,7 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
_ = os.Remove(tmp)
|
||||
if image.Distro == "windows" {
|
||||
if image.IsWindows() {
|
||||
if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -197,7 +197,13 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if image.Distro == "windows" {
|
||||
if image.SHA256 != "" {
|
||||
if err := verifyFileSHA256(tmp, image.SHA256); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if image.IsWindows() {
|
||||
if err := validateWindowsISO(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -221,6 +227,23 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyFileSHA256(path, expected string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
|
||||
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteImage(id string) error {
|
||||
return os.RemoveAll(ImagePath(id))
|
||||
}
|
||||
@@ -500,11 +523,15 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
var xml string
|
||||
winAdminPassword := ""
|
||||
if IsWindowsImage(image.ID) {
|
||||
if cfg.RAMMB < 2048 {
|
||||
cfg.RAMMB = 2048
|
||||
minVCPU, minRAMMB, minDiskGB := windowsMinimumResources(image.ID)
|
||||
if cfg.VCPU < minVCPU {
|
||||
cfg.VCPU = minVCPU
|
||||
}
|
||||
if cfg.DiskGB < 30 {
|
||||
cfg.DiskGB = 30
|
||||
if cfg.RAMMB < minRAMMB {
|
||||
cfg.RAMMB = minRAMMB
|
||||
}
|
||||
if cfg.DiskGB < minDiskGB {
|
||||
cfg.DiskGB = minDiskGB
|
||||
}
|
||||
cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘")
|
||||
if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil {
|
||||
@@ -516,7 +543,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
winAdminPassword = generateWindowsPassword()
|
||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
||||
cfg.ReportProgress("cloud_init", "生成 Windows 自动应答配置")
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List, IsWindows11Image(image.ID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||
@@ -1724,16 +1751,17 @@ func ensureDefaultNetwork() error {
|
||||
// Ensure default network is defined
|
||||
if virshCLocaleCommand("net-info", "default").Run() != nil {
|
||||
// Default network may not be defined; try to define it
|
||||
netXML := `<network>
|
||||
network := config.KVMNATNetwork()
|
||||
netXML := fmt.Sprintf(`<network>
|
||||
<name>default</name>
|
||||
<bridge name='virbr0'/>
|
||||
<forward mode='nat'/>
|
||||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||||
<ip address='%s' netmask='%s'>
|
||||
<dhcp>
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
<range start='%s' end='%s'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>`
|
||||
</network>`, network.Gateway, network.Netmask, network.DHCPStart, network.DHCPEnd)
|
||||
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)
|
||||
@@ -1832,7 +1860,7 @@ func createEmptyDisk(target string, diskGB int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error {
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string, windows11 bool) error {
|
||||
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
||||
if tool == "" {
|
||||
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
||||
@@ -1852,7 +1880,7 @@ func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword)), 0600); err != nil {
|
||||
if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword, windows11)), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||
@@ -1890,12 +1918,21 @@ func firstAvailableCommand(names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
func windowsAutounattendXML(hostname, adminPassword string, windows11 bool) 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)`
|
||||
compatibilityCommands := ""
|
||||
if windows11 {
|
||||
compatibilityCommands = `
|
||||
<RunSynchronous>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>1</Order><Description>Allow virtual TPM compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>2</Order><Description>Allow virtual Secure Boot compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>3</Order><Description>Allow virtual CPU compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
</RunSynchronous>`
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
<settings pass="windowsPE">
|
||||
@@ -1924,7 +1961,7 @@ func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
<AcceptEula>true</AcceptEula>
|
||||
<FullName>CLICD</FullName>
|
||||
<Organization>CLICD</Organization>
|
||||
</UserData>
|
||||
</UserData>%s
|
||||
</component>
|
||||
</settings>
|
||||
<settings pass="specialize">
|
||||
@@ -1945,7 +1982,14 @@ func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
`, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand))
|
||||
`, compatibilityCommands, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand))
|
||||
}
|
||||
|
||||
func windowsMinimumResources(imageID string) (float64, int, int) {
|
||||
if IsWindows11Image(imageID) {
|
||||
return 2, 4096, 64
|
||||
}
|
||||
return 1, 2048, 30
|
||||
}
|
||||
|
||||
func sanitizeWindowsComputerName(name string) string {
|
||||
|
||||
@@ -3,8 +3,14 @@ package kvm
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
@@ -24,6 +30,51 @@ func TestImagePathUsesAllowlistedImageID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindows11ImageDefinition(t *testing.T) {
|
||||
image := FindImage("kvm-windows-11")
|
||||
if image == nil {
|
||||
t.Fatal("Windows 11 image is missing from the amd64 image list")
|
||||
}
|
||||
if image.Distro != "windows" || image.Release != "11" || image.Arch != "amd64" {
|
||||
t.Fatalf("Windows 11 image metadata = %+v", image)
|
||||
}
|
||||
if !strings.Contains(image.URL, "microsoft.com/fwlink/") {
|
||||
t.Fatalf("Windows 11 image does not use an official Microsoft URL: %s", image.URL)
|
||||
}
|
||||
if got := filepath.Base(ImagePath(image.ID)); got != "kvm-windows-11.iso" {
|
||||
t.Fatalf("Windows 11 image basename = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindows11UnattendAddsCompatibilityChecksOnlyForWindows11(t *testing.T) {
|
||||
windows11 := windowsAutounattendXML("win11-test", "Password123!", true)
|
||||
windows10 := windowsAutounattendXML("win10-test", "Password123!", false)
|
||||
|
||||
for _, key := range []string{"BypassTPMCheck", "BypassSecureBootCheck", "BypassCPUCheck"} {
|
||||
if !strings.Contains(windows11, key) {
|
||||
t.Fatalf("Windows 11 unattend is missing %s", key)
|
||||
}
|
||||
if strings.Contains(windows10, key) {
|
||||
t.Fatalf("Windows 10 unattend unexpectedly contains %s", key)
|
||||
}
|
||||
}
|
||||
var document struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(windows11), &document); err != nil {
|
||||
t.Fatalf("Windows 11 unattend XML is invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsMinimumResources(t *testing.T) {
|
||||
if cpu, ram, disk := windowsMinimumResources("kvm-windows-11"); cpu != 2 || ram != 4096 || disk != 64 {
|
||||
t.Fatalf("Windows 11 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||
}
|
||||
if cpu, ram, disk := windowsMinimumResources("kvm-windows-10"); cpu != 1 || ram != 2048 || disk != 30 {
|
||||
t.Fatalf("Windows 10 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -115,6 +166,77 @@ func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImagesIncludesHostArchitectureCustomImage(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
CustomKVMImages: []config.CustomKVMImage{
|
||||
{
|
||||
ID: "custom-kvm-linux",
|
||||
Name: "Custom Linux",
|
||||
Distro: "ubuntu",
|
||||
Release: "noble",
|
||||
Arch: runtime.GOARCH,
|
||||
URL: "https://example.test/linux.qcow2",
|
||||
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||
},
|
||||
{
|
||||
ID: "custom-kvm-other-arch",
|
||||
Name: "Other Architecture",
|
||||
Distro: "ubuntu",
|
||||
Release: "noble",
|
||||
Arch: "not-" + runtime.GOARCH,
|
||||
URL: "https://example.test/other.qcow2",
|
||||
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
image := FindImage("custom-kvm-linux")
|
||||
if image == nil || !image.Custom || image.Provisioner != config.KVMProvisionerLinuxCloudInit {
|
||||
t.Fatalf("custom image was not exposed correctly: %+v", image)
|
||||
}
|
||||
if FindImage("custom-kvm-other-arch") != nil {
|
||||
t.Fatal("custom image for another architecture was exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomWindowsProvisionerControlsImageType(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{CustomKVMImages: []config.CustomKVMImage{{
|
||||
ID: "custom-kvm-windows",
|
||||
Name: "Custom Windows",
|
||||
Distro: "windows",
|
||||
Release: "11",
|
||||
Arch: runtime.GOARCH,
|
||||
URL: "https://example.test/windows.iso",
|
||||
Provisioner: config.KVMProvisionerWindows11,
|
||||
}}}
|
||||
|
||||
if !IsWindowsImage("custom-kvm-windows") || !IsWindows11Image("custom-kvm-windows") {
|
||||
t.Fatal("custom Windows 11 provisioner was not recognized")
|
||||
}
|
||||
if ext := filepath.Ext(ImagePath("custom-kvm-windows")); ext != ".iso" {
|
||||
t.Fatalf("custom Windows image extension = %q, want .iso", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileSHA256(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "image")
|
||||
content := []byte("clicd custom image")
|
||||
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil {
|
||||
t.Fatalf("valid checksum failed: %v", err)
|
||||
}
|
||||
if err := verifyFileSHA256(path, strings.Repeat("0", 64)); err == nil {
|
||||
t.Fatal("invalid checksum unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
|
||||
t.Helper()
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
|
||||
@@ -17,15 +17,37 @@ type Image struct {
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
Provisioner string `json:"provisioner,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
func GetImages() []Image {
|
||||
var images []Image
|
||||
switch runtime.GOARCH {
|
||||
case "arm64":
|
||||
return arm64Images()
|
||||
images = arm64Images()
|
||||
default:
|
||||
return amd64Images()
|
||||
images = amd64Images()
|
||||
}
|
||||
for _, custom := range config.ListCustomKVMImages() {
|
||||
if custom.Arch != runtime.GOARCH {
|
||||
continue
|
||||
}
|
||||
images = append(images, Image{
|
||||
ID: custom.ID,
|
||||
Name: custom.Name,
|
||||
Distro: custom.Distro,
|
||||
Release: custom.Release,
|
||||
Arch: custom.Arch,
|
||||
Description: custom.Description,
|
||||
URL: custom.URL,
|
||||
Provisioner: custom.Provisioner,
|
||||
SHA256: custom.SHA256,
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func amd64Images() []Image {
|
||||
@@ -111,6 +133,12 @@ func amd64Images() []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-11", Name: "Windows 11 KVM",
|
||||
Distro: "windows", Release: "11", Arch: "amd64",
|
||||
Description: "Windows 11 Enterprise LTSC 2024 Evaluation",
|
||||
URL: "https://go.microsoft.com/fwlink/?clcid=0x409&country=us&culture=en-us&linkid=2289029",
|
||||
},
|
||||
{
|
||||
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
||||
Distro: "windows", Release: "10", Arch: "amd64",
|
||||
@@ -196,7 +224,7 @@ func ImagePath(id string) string {
|
||||
if img != nil {
|
||||
safeID = img.ID
|
||||
}
|
||||
if img != nil && img.Distro == "windows" {
|
||||
if img != nil && img.IsWindows() {
|
||||
ext = ".iso"
|
||||
}
|
||||
fileName := safeID + ext
|
||||
@@ -213,10 +241,26 @@ func ImagePath(id string) string {
|
||||
return filepath.Join(CacheDir(), fileName)
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image distro is "windows".
|
||||
func (image Image) IsWindows() bool {
|
||||
return image.Provisioner == config.KVMProvisionerWindows10 ||
|
||||
image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||
(image.Provisioner == "" && image.Distro == "windows")
|
||||
}
|
||||
|
||||
func (image Image) IsWindows11() bool {
|
||||
return image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||
(image.Provisioner == "" && image.Distro == "windows" && image.Release == "11")
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image uses Windows unattended installation.
|
||||
func IsWindowsImage(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.Distro == "windows"
|
||||
return img != nil && img.IsWindows()
|
||||
}
|
||||
|
||||
func IsWindows11Image(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.IsWindows11()
|
||||
}
|
||||
|
||||
func virtioWinISOPath() string {
|
||||
|
||||
Reference in New Issue
Block a user