diff --git a/README.md b/README.md index 67f57bb..1221f63 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,26 @@ Tool untuk membuat **template** di Proxmox menggunakan cloud image (Ubuntu, Debi - **Firewall configuration** (enable/disable + custom rules) - **Batch mode** untuk create multiple templates sekaligus +## Tools + +### 1. Linux Cloud Images (Main Tool) +Tool utama untuk Linux cloud images (Ubuntu, Debian, CentOS, Rocky, dll). + +📁 **Location**: Root directory +📖 **Docs**: [README.md](README.md) (this file) + +### 2. Windows Cloud Images +Tool terpisah untuk Windows cloud images (Windows Server, Windows 11). + +📁 **Location**: `windows-tools/` +📖 **Docs**: [windows-tools/README.md](windows-tools/README.md) + +**Key Differences:** +- Windows: UEFI + TPM 2.0 + Secure Boot +- Linux: BIOS/UEFI flexible +- Windows: Requires qcow2 image from [cloudbase/windows-imaging-tools](https://github.com/cloudbase/windows-imaging-tools) +- Linux: Download langsung dari official repos + ## Requirements - Go 1.19+ @@ -242,7 +262,10 @@ proxmox-cloud-image -batch batch.txt ## How It Works 1. Download cloud image dari URL yang diberikan -2. Customize image (resize, inject SSH key jika ada) +2. Customize image: + - Resize disk (jika di-specify) + - Inject SSH key (jika ada) + - **Install qemu-guest-agent package** (jika guest-agent enabled) 3. Upload image ke Proxmox host via SCP 4. Create VM menggunakan `qm` commands 5. Import disk dan configure VM @@ -260,11 +283,15 @@ QEMU Guest Agent adalah service yang berjalan di guest OS untuk: - File system freeze/thaw - Time synchronization +**Tool ini akan otomatis install qemu-guest-agent package** ke dalam image menggunakan `virt-customize` sebelum upload ke Proxmox. + Enable dengan flag `-guest-agent` atau di config file: ```yaml guest_agent: true ``` +**Note**: Guest agent di-enable by default. Package akan di-install otomatis saat customize image. + ## Proxmox Firewall Proxmox firewall bisa di-enable untuk template dengan flag `-firewall` atau di config file: diff --git a/configs/cloudlinux/panel/cloudlinux-7-panel.yaml b/configs/cloudlinux/panel/cloudlinux-7-panel.yaml index c3e1442..332c59f 100644 --- a/configs/cloudlinux/panel/cloudlinux-7-panel.yaml +++ b/configs/cloudlinux/panel/cloudlinux-7-panel.yaml @@ -4,7 +4,7 @@ vm_id: 100020 storage: "local" memory: 1024 cores: 1 -disk_size: "10G" +disk_size: "40G" bridge: "vmbr1" vlan_tag: 301 proxmox_host: "10.10.26.11" diff --git a/configs/cloudlinux/panel/cloudlinux-8-panel.yaml b/configs/cloudlinux/panel/cloudlinux-8-panel.yaml index 280e5cc..973c7a3 100644 --- a/configs/cloudlinux/panel/cloudlinux-8-panel.yaml +++ b/configs/cloudlinux/panel/cloudlinux-8-panel.yaml @@ -4,7 +4,7 @@ vm_id: 100021 storage: "local" memory: 1024 cores: 1 -disk_size: "10G" +disk_size: "40G" bridge: "vmbr1" vlan_tag: 301 proxmox_host: "10.10.26.11" diff --git a/configs/cloudlinux/panel/cloudlinux-9-panel.yaml b/configs/cloudlinux/panel/cloudlinux-9-panel.yaml index ea15b7b..be25b26 100644 --- a/configs/cloudlinux/panel/cloudlinux-9-panel.yaml +++ b/configs/cloudlinux/panel/cloudlinux-9-panel.yaml @@ -4,7 +4,7 @@ vm_id: 100022 storage: "local" memory: 1024 cores: 1 -disk_size: "10G" +disk_size: "40G" bridge: "vmbr1" vlan_tag: 301 proxmox_host: "10.10.26.11" diff --git a/configs/fedora/fedora-41.yaml b/configs/fedora/fedora-41.yaml index f2146e4..dec6e78 100644 --- a/configs/fedora/fedora-41.yaml +++ b/configs/fedora/fedora-41.yaml @@ -1,4 +1,4 @@ -image_url: "https://mirrors.bfsu.edu.cn/fedora/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2" +image_url: "https://dl.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2" vm_name: "cloudimg-fedora-41" vm_id: 100008 storage: "local" diff --git a/configs/fedora/fedora-42.yaml b/configs/fedora/fedora-42.yaml index c5d4deb..2b2ea7c 100644 --- a/configs/fedora/fedora-42.yaml +++ b/configs/fedora/fedora-42.yaml @@ -1,4 +1,4 @@ -image_url: "https://mirrors.bfsu.edu.cn/fedora/releases/42/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-42-1.1.x86_64.qcow2" +image_url: "https://dl.fedoraproject.org/pub/fedora/linux/releases/42/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-42-1.1.x86_64.qcow2" vm_name: "cloudimg-fedora-42" vm_id: 100009 storage: "local" diff --git a/configs/fedora/fedora-43.yaml b/configs/fedora/fedora-43.yaml index c24da46..5cff13c 100644 --- a/configs/fedora/fedora-43.yaml +++ b/configs/fedora/fedora-43.yaml @@ -1,4 +1,4 @@ -image_url: "https://mirrors.bfsu.edu.cn/fedora/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2" +image_url: "https://dl.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2" vm_name: "cloudimg-fedora-43" vm_id: 100010 storage: "local" diff --git a/configs/oracle/oracle-10.yaml b/configs/oracle/oracle-10.yaml index 75fa95e..0c8f3a9 100644 --- a/configs/oracle/oracle-10.yaml +++ b/configs/oracle/oracle-10.yaml @@ -4,7 +4,7 @@ vm_id: 100016 storage: "local" memory: 1024 cores: 1 -disk_size: "10G" +disk_size: "40G" bridge: "vmbr1" vlan_tag: 301 proxmox_host: "10.10.26.11" diff --git a/configs/oracle/oracle-8.yaml b/configs/oracle/oracle-8.yaml index 0ebacaf..8f6515b 100644 --- a/configs/oracle/oracle-8.yaml +++ b/configs/oracle/oracle-8.yaml @@ -4,7 +4,7 @@ vm_id: 100014 storage: "local" memory: 1024 cores: 1 -disk_size: "10G" +disk_size: "40G" bridge: "vmbr1" vlan_tag: 301 proxmox_host: "10.10.26.11" @@ -16,4 +16,4 @@ firewall_rules: - type: out action: drop dest: "10.0.0.0/8" - comment: "Bottom most rules - Block service network to internal network" \ No newline at end of file + comment: "Bottom most rules - Block service network to internal network" diff --git a/configs/oracle/oracle-9.yaml b/configs/oracle/oracle-9.yaml index f228f50..9517e4a 100644 --- a/configs/oracle/oracle-9.yaml +++ b/configs/oracle/oracle-9.yaml @@ -4,7 +4,7 @@ vm_id: 100015 storage: "local" memory: 1024 cores: 1 -disk_size: "10G" +disk_size: "40G" bridge: "vmbr1" vlan_tag: 301 proxmox_host: "10.10.26.11" diff --git a/customize.go b/customize.go index 9b5a652..0dc4f18 100644 --- a/customize.go +++ b/customize.go @@ -28,6 +28,12 @@ func customizeImage(config *Config) error { } } + if config.GuestAgent { + if err := installGuestAgent(imagePath); err != nil { + return err + } + } + fmt.Println("Verifying customized image...") if err := verifyImage(imagePath); err != nil { return fmt.Errorf("customized image verification failed: %w", err) @@ -93,3 +99,22 @@ func injectSSHKey(imagePath, sshKeyPath string) error { return nil } + +func installGuestAgent(imagePath string) error { + fmt.Println("Installing QEMU Guest Agent...") + + cmd := exec.Command("virt-customize", + "-a", imagePath, + "--install", "qemu-guest-agent", + "--run-command", "systemctl enable qemu-guest-agent", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install qemu-guest-agent: %w", err) + } + + fmt.Println("QEMU Guest Agent installed and enabled!") + return nil +} diff --git a/windows-tools/README.md b/windows-tools/README.md new file mode 100644 index 0000000..983fd1f --- /dev/null +++ b/windows-tools/README.md @@ -0,0 +1,256 @@ +# Proxmox Windows Cloud Image Tool + +Tool untuk membuat Proxmox VM template dari Windows cloud images (qcow2 format). + +## Features + +- ✅ Upload Windows cloud image (qcow2) ke Proxmox +- ✅ Create VM dengan konfigurasi Windows (UEFI, TPM 2.0, Secure Boot) +- ✅ Convert VM ke template +- ✅ Support QEMU Guest Agent +- ✅ Support firewall configuration +- ✅ Support VLAN tagging +- ✅ Auto-find available VM ID +- ✅ Batch processing multiple configs +- ✅ List available storage + +## Requirements + +- Go 1.19+ +- SSH access ke Proxmox host +- Windows cloud image dalam format qcow2 + +## Cara Buat Windows Cloud Image + +Gunakan [cloudbase/windows-imaging-tools](https://github.com/cloudbase/windows-imaging-tools): + +```powershell +# Clone repo +git clone https://github.com/cloudbase/windows-imaging-tools.git +cd windows-imaging-tools + +# Import module +Import-Module .\WinImageBuilder.psm1 + +# Download Windows ISO (contoh: Windows Server 2022) +# Buat cloud image +$ConfigFilePath = ".\Examples\config.ini" +New-WindowsCloudImage -ConfigFilePath $ConfigFilePath + +# Output: windows-server-2022.qcow2 +``` + +## Installation + +### Linux: + +```bash +cd windows-tools +go build -o proxmox-windows +sudo cp proxmox-windows /usr/local/bin/ +``` + +### Windows (PowerShell): + +```powershell +cd windows-tools +go build -o proxmox-windows.exe +# Add to PATH atau jalankan langsung +``` + +## Usage + +### CLI Flags: + +```bash +proxmox-windows \ + -image-path /path/to/windows-server-2022.qcow2 \ + -vm-name windows-server-2022-template \ + -vm-id 0 \ + -storage local-lvm \ + -memory 4096 \ + -cores 2 \ + -bridge vmbr0 \ + -proxmox-host 192.168.1.100 \ + -proxmox-user root@pam \ + -guest-agent \ + -firewall +``` + +### Config File (Recommended): + +```bash +proxmox-windows -config config.yaml +``` + +**config.yaml:** +```yaml +image_path: "/path/to/windows-server-2022.qcow2" +vm_name: "windows-server-2022-template" +vm_id: 0 # 0 = auto-find +storage: "local-lvm" +memory: 4096 +cores: 2 +sockets: 1 +bridge: "vmbr0" +vlan_tag: 0 +proxmox_host: "192.168.1.100" +proxmox_user: "root@pam" +guest_agent: true +firewall: true + +firewall_rules: + - type: out + action: drop + dest: "10.0.0.0/8" + comment: "Block internal network" + - type: in + action: accept + protocol: tcp + dport: "3389" + comment: "Allow RDP" + - type: in + action: accept + protocol: tcp + dport: "5985-5986" + comment: "Allow WinRM" +``` + +### Batch Processing: + +```bash +proxmox-windows -batch batch.txt +``` + +**batch.txt:** +``` +configs/windows-server-2022.yaml +configs/windows-11.yaml +configs/windows-server-2019.yaml +``` + +### List Available Storage: + +```bash +proxmox-windows -list-storage -proxmox-host 192.168.1.100 +# atau +proxmox-windows -ls -proxmox-host 192.168.1.100 +``` + +## VM Configuration + +Tool ini akan membuat VM dengan konfigurasi: + +- **BIOS**: OVMF (UEFI) +- **Machine Type**: q35 +- **EFI Disk**: 1GB dengan pre-enrolled keys (Secure Boot) +- **TPM**: v2.0 (required untuk Windows 11) +- **SCSI Controller**: VirtIO SCSI +- **Network**: VirtIO +- **Boot Order**: scsi0 +- **Guest Agent**: Enabled (optional) + +## Firewall Rules + +Format firewall rules sama dengan Linux tools: + +```yaml +firewall_rules: + - type: in|out + action: accept|drop|reject + protocol: tcp|udp|icmp + dport: "port atau port-range" + sport: "port atau port-range" + source: "IP/CIDR" + dest: "IP/CIDR" + comment: "Description" +``` + +**Contoh:** +```yaml +firewall_rules: + # Allow RDP + - type: in + action: accept + protocol: tcp + dport: "3389" + comment: "Allow RDP" + + # Allow WinRM + - type: in + action: accept + protocol: tcp + dport: "5985-5986" + comment: "Allow WinRM" + + # Block outgoing to internal network + - type: out + action: drop + dest: "10.0.0.0/8" + comment: "Block internal network" + + # Allow DNS + - type: out + action: accept + protocol: udp + dport: "53" + comment: "Allow DNS" +``` + +## Clone Template + +Setelah template dibuat: + +```bash +# Clone template +qm clone 10000 101 --name windows-server-01 + +# Start VM +qm start 101 + +# Access via RDP +# IP akan di-assign via DHCP (jika cloud-init configured) +``` + +## Troubleshooting + +### 1. SSH Connection Failed +```bash +# Test SSH connection +ssh root@192.168.1.100 + +# Check SSH key +ssh-keygen -R 192.168.1.100 +``` + +### 2. Storage Not Found +```bash +# List available storage +proxmox-windows -ls -proxmox-host 192.168.1.100 + +# Atau manual +ssh root@192.168.1.100 'pvesm status' +``` + +### 3. Upload Failed +- Check network connection +- Check disk space di Proxmox host +- Tool akan retry 3x otomatis + +### 4. VM Creation Failed +```bash +# Check Proxmox logs +ssh root@192.168.1.100 'tail -f /var/log/pve/tasks/active' +``` + +## Notes + +- Windows cloud images harus dalam format **qcow2** +- VM akan dibuat dengan **UEFI + TPM 2.0** (required untuk Windows 11) +- Guest Agent harus di-install di Windows image untuk full functionality +- Firewall rules di-apply di Proxmox level, bukan di Windows +- Template bisa di-clone unlimited times + +## License + +MIT diff --git a/windows-tools/batch.txt b/windows-tools/batch.txt new file mode 100644 index 0000000..ef9b73c --- /dev/null +++ b/windows-tools/batch.txt @@ -0,0 +1,3 @@ +configs/windows-server-2022.yaml +configs/windows-11.yaml +configs/windows-server-2019.yaml diff --git a/windows-tools/config.go b/windows-tools/config.go new file mode 100644 index 0000000..72d0677 --- /dev/null +++ b/windows-tools/config.go @@ -0,0 +1,130 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +func loadConfig(filename string, config *WindowsConfig) error { + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, config); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + return nil +} + +func processBatchFile(batchFile string) error { + file, err := os.Open(batchFile) + if err != nil { + return fmt.Errorf("failed to open batch file: %w", err) + } + defer file.Close() + + var configFiles []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + configFiles = append(configFiles, line) + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read batch file: %w", err) + } + + if len(configFiles) == 0 { + return fmt.Errorf("no config files found in batch file") + } + + fmt.Printf("Processing %d config files...\n\n", len(configFiles)) + + maxConcurrent := 3 + semaphore := make(chan struct{}, maxConcurrent) + var wg sync.WaitGroup + var mu sync.Mutex + results := make(map[string]error) + + for _, configFile := range configFiles { + wg.Add(1) + go func(cf string) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + fmt.Printf("Processing: %s\n", cf) + + config := &WindowsConfig{} + if err := loadConfig(cf, config); err != nil { + mu.Lock() + results[cf] = err + mu.Unlock() + fmt.Printf("Error loading %s: %v\n", cf, err) + return + } + + if config.VMID == 0 { + vmid, err := findAvailableVMID(config) + if err != nil { + mu.Lock() + results[cf] = err + mu.Unlock() + fmt.Printf("Error finding VM ID for %s: %v\n", cf, err) + return + } + config.VMID = vmid + } + + if err := createProxmoxWindowsVM(config); err != nil { + mu.Lock() + results[cf] = err + mu.Unlock() + fmt.Printf("Error creating template from %s: %v\n", cf, err) + return + } + + mu.Lock() + results[cf] = nil + mu.Unlock() + fmt.Printf("Successfully created template from %s\n", cf) + }(configFile) + } + + wg.Wait() + + fmt.Println("\n========================================") + fmt.Println("Batch Processing Summary") + fmt.Println("========================================") + + successCount := 0 + failCount := 0 + + for configFile, err := range results { + if err == nil { + fmt.Printf("✓ %s - SUCCESS\n", configFile) + successCount++ + } else { + fmt.Printf("✗ %s - FAILED: %v\n", configFile, err) + failCount++ + } + } + + fmt.Printf("\nTotal: %d | Success: %d | Failed: %d\n", len(configFiles), successCount, failCount) + + if failCount > 0 { + return fmt.Errorf("%d config(s) failed to process", failCount) + } + + return nil +} diff --git a/windows-tools/config.yaml b/windows-tools/config.yaml new file mode 100644 index 0000000..1fbd781 --- /dev/null +++ b/windows-tools/config.yaml @@ -0,0 +1,31 @@ +# Windows Cloud Image Configuration +image_path: "/path/to/windows-server-2022.qcow2" +vm_name: "windows-server-2022-template" +vm_id: 0 # 0 = auto-find from 10000+ +storage: "local-lvm" +memory: 4096 +cores: 2 +sockets: 1 +bridge: "vmbr0" +vlan_tag: 0 # 0 = no VLAN +proxmox_host: "192.168.1.100" +proxmox_user: "root@pam" +guest_agent: true +firewall: true + +# Firewall Rules (optional) +firewall_rules: + - type: out + action: drop + dest: "10.0.0.0/8" + comment: "Block service network to internal network" + - type: in + action: accept + protocol: tcp + dport: "3389" + comment: "Allow RDP" + - type: in + action: accept + protocol: tcp + dport: "5985-5986" + comment: "Allow WinRM" diff --git a/windows-tools/configs/windows-11.yaml b/windows-tools/configs/windows-11.yaml new file mode 100644 index 0000000..81c0d77 --- /dev/null +++ b/windows-tools/configs/windows-11.yaml @@ -0,0 +1,30 @@ +image_path: "/path/to/windows-11.qcow2" +vm_name: "windows-11-template" +vm_id: 0 +storage: "local-lvm" +memory: 8192 +cores: 4 +sockets: 1 +bridge: "vmbr0" +vlan_tag: 0 +proxmox_host: "192.168.1.100" +proxmox_user: "root@pam" +guest_agent: true +firewall: true + +firewall_rules: + - type: in + action: accept + protocol: tcp + dport: "3389" + comment: "Allow RDP" + - type: out + action: accept + protocol: tcp + dport: "80,443" + comment: "Allow HTTP/HTTPS" + - type: out + action: accept + protocol: udp + dport: "53" + comment: "Allow DNS" diff --git a/windows-tools/configs/windows-server-2019.yaml b/windows-tools/configs/windows-server-2019.yaml new file mode 100644 index 0000000..051e2b0 --- /dev/null +++ b/windows-tools/configs/windows-server-2019.yaml @@ -0,0 +1,13 @@ +image_path: "/path/to/windows-server-2019.qcow2" +vm_name: "windows-server-2019-template" +vm_id: 0 +storage: "local-lvm" +memory: 4096 +cores: 2 +sockets: 1 +bridge: "vmbr0" +vlan_tag: 0 +proxmox_host: "192.168.1.100" +proxmox_user: "root@pam" +guest_agent: true +firewall: false diff --git a/windows-tools/configs/windows-server-2022.yaml b/windows-tools/configs/windows-server-2022.yaml new file mode 100644 index 0000000..d49716f --- /dev/null +++ b/windows-tools/configs/windows-server-2022.yaml @@ -0,0 +1,29 @@ +image_path: "/path/to/windows-server-2022.qcow2" +vm_name: "windows-server-2022-template" +vm_id: 0 +storage: "local-lvm" +memory: 4096 +cores: 2 +sockets: 1 +bridge: "vmbr0" +vlan_tag: 0 +proxmox_host: "192.168.1.100" +proxmox_user: "root@pam" +guest_agent: true +firewall: true + +firewall_rules: + - type: in + action: accept + protocol: tcp + dport: "3389" + comment: "Allow RDP" + - type: in + action: accept + protocol: tcp + dport: "5985-5986" + comment: "Allow WinRM" + - type: out + action: drop + dest: "10.0.0.0/8" + comment: "Block internal network" diff --git a/windows-tools/go.mod b/windows-tools/go.mod new file mode 100644 index 0000000..d36f95a --- /dev/null +++ b/windows-tools/go.mod @@ -0,0 +1,5 @@ +module github.com/yourusername/proxmox-windows-tools + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/windows-tools/go.sum b/windows-tools/go.sum new file mode 100644 index 0000000..d542811 --- /dev/null +++ b/windows-tools/go.sum @@ -0,0 +1,2 @@ +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/windows-tools/install.sh b/windows-tools/install.sh new file mode 100755 index 0000000..b033f97 --- /dev/null +++ b/windows-tools/install.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -e + +INSTALL_DIR="/usr/local/bin" +BINARY_NAME="proxmox-windows" + +echo "==========================================" +echo "Proxmox Windows Tool Installer" +echo "==========================================" +echo "" + +if [ "$EUID" -ne 0 ]; then + echo "Error: This script must be run as root (use sudo)" + exit 1 +fi + +if ! command -v go &> /dev/null; then + echo "Error: Go is not installed. Please install Go 1.19+ first." + echo "Visit: https://golang.org/doc/install" + exit 1 +fi + +echo "Building binary..." +go build -o $BINARY_NAME + +echo "Installing to $INSTALL_DIR..." +cp $BINARY_NAME $INSTALL_DIR/ +chmod +x $INSTALL_DIR/$BINARY_NAME + +echo "" +echo "==========================================" +echo "Installation completed successfully!" +echo "==========================================" +echo "" +echo "You can now use: $BINARY_NAME" +echo "" +echo "Examples:" +echo " $BINARY_NAME -h" +echo " $BINARY_NAME -config config.yaml" +echo " $BINARY_NAME -list-storage -proxmox-host 192.168.1.100" +echo "" diff --git a/windows-tools/main.go b/windows-tools/main.go new file mode 100644 index 0000000..b5b7d17 --- /dev/null +++ b/windows-tools/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +type FirewallRule struct { + Type string `yaml:"type"` + Action string `yaml:"action"` + Protocol string `yaml:"protocol"` + Dport string `yaml:"dport"` + Sport string `yaml:"sport"` + Source string `yaml:"source"` + Dest string `yaml:"dest"` + Comment string `yaml:"comment"` +} + +type WindowsConfig struct { + ImagePath string `yaml:"image_path"` + VMName string `yaml:"vm_name"` + VMID int `yaml:"vm_id"` + Storage string `yaml:"storage"` + Memory int `yaml:"memory"` + Cores int `yaml:"cores"` + Sockets int `yaml:"sockets"` + Bridge string `yaml:"bridge"` + VlanTag int `yaml:"vlan_tag"` + ProxmoxHost string `yaml:"proxmox_host"` + ProxmoxUser string `yaml:"proxmox_user"` + ProxmoxPass string `yaml:"proxmox_pass"` + GuestAgent bool `yaml:"guest_agent"` + Firewall bool `yaml:"firewall"` + FirewallRules []FirewallRule `yaml:"firewall_rules"` +} + +func main() { + config := &WindowsConfig{} + var configFile string + var batchFile string + var listStorage bool + + flag.StringVar(&configFile, "config", "", "Config file path (YAML)") + flag.StringVar(&batchFile, "batch", "", "Batch file with multiple config paths (one per line)") + flag.BoolVar(&listStorage, "list-storage", false, "List available storage on Proxmox host") + flag.BoolVar(&listStorage, "ls", false, "List available storage on Proxmox host (shorthand)") + flag.StringVar(&config.ImagePath, "image-path", "", "Windows cloud image path (local qcow2 file)") + flag.StringVar(&config.VMName, "vm-name", "windows-vm", "VM name") + flag.IntVar(&config.VMID, "vm-id", 0, "VM ID (0 = auto-find from 10000+)") + flag.StringVar(&config.Storage, "storage", "", "Proxmox storage name") + flag.IntVar(&config.Memory, "memory", 4096, "Memory in MB") + flag.IntVar(&config.Cores, "cores", 2, "CPU cores") + flag.IntVar(&config.Sockets, "sockets", 1, "CPU sockets") + flag.StringVar(&config.Bridge, "bridge", "vmbr0", "Network bridge") + flag.IntVar(&config.VlanTag, "vlan-tag", 0, "VLAN tag (optional, 0 = no VLAN)") + flag.StringVar(&config.ProxmoxHost, "proxmox-host", "", "Proxmox host (e.g., 192.168.1.100)") + flag.StringVar(&config.ProxmoxUser, "proxmox-user", "root@pam", "Proxmox user") + flag.StringVar(&config.ProxmoxPass, "proxmox-pass", "", "Proxmox password") + flag.BoolVar(&config.GuestAgent, "guest-agent", true, "Enable QEMU guest agent") + flag.BoolVar(&config.Firewall, "firewall", false, "Enable firewall") + + flag.Parse() + + if listStorage || flag.Lookup("ls").Value.(flag.Getter).Get().(bool) { + if config.ProxmoxHost == "" { + fmt.Println("Error: -proxmox-host is required for storage listing") + os.Exit(1) + } + if err := listAvailableStorage(config); err != nil { + fmt.Printf("Error listing storage: %v\n", err) + os.Exit(1) + } + return + } + + if batchFile != "" { + if err := processBatchFile(batchFile); err != nil { + fmt.Printf("Error processing batch file: %v\n", err) + os.Exit(1) + } + return + } + + if configFile != "" { + if err := loadConfig(configFile, config); err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + } + + if config.ImagePath == "" { + fmt.Println("Error: -image-path or config file is required") + flag.Usage() + os.Exit(1) + } + + if config.ProxmoxHost == "" { + fmt.Println("Error: -proxmox-host is required") + flag.Usage() + os.Exit(1) + } + + if config.Storage == "" { + fmt.Println("Error: -storage is required") + flag.Usage() + os.Exit(1) + } + + if config.VMID == 0 { + fmt.Println("Auto-finding available VM ID starting from 10000...") + vmid, err := findAvailableVMID(config) + if err != nil { + fmt.Printf("Error finding available VM ID: %v\n", err) + os.Exit(1) + } + config.VMID = vmid + fmt.Printf("Found available VM ID: %d\n", config.VMID) + } + + fmt.Printf("Creating template %s (ID: %d) on Proxmox host %s\n", config.VMName, config.VMID, config.ProxmoxHost) + + if err := createProxmoxWindowsVM(config); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +} diff --git a/windows-tools/proxmox-windows.go b/windows-tools/proxmox-windows.go new file mode 100644 index 0000000..cad676c --- /dev/null +++ b/windows-tools/proxmox-windows.go @@ -0,0 +1,350 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +func findAvailableVMID(config *WindowsConfig) (int, error) { + sshCmd := func(args ...string) *exec.Cmd { + fullArgs := []string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + fmt.Sprintf("%s@%s", strings.Split(config.ProxmoxUser, "@")[0], config.ProxmoxHost), + } + fullArgs = append(fullArgs, args...) + return exec.Command("ssh", fullArgs...) + } + + cmd := sshCmd("qm", "list") + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return 0, fmt.Errorf("failed to list VMs: %w", err) + } + + usedIDs := make(map[int]bool) + lines := strings.Split(stdout.String(), "\n") + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) > 0 { + if vmid, err := strconv.Atoi(fields[0]); err == nil { + usedIDs[vmid] = true + } + } + } + + for vmid := 10000; vmid < 20000; vmid++ { + if !usedIDs[vmid] { + return vmid, nil + } + } + + return 0, fmt.Errorf("no available VM ID found in range 10000-19999") +} + +func buildNetworkConfig(config *WindowsConfig) string { + netConfig := fmt.Sprintf("virtio,bridge=%s", config.Bridge) + if config.VlanTag > 0 { + netConfig += fmt.Sprintf(",tag=%d", config.VlanTag) + } + if config.Firewall { + netConfig += ",firewall=1" + } + return netConfig +} + +func createProxmoxWindowsVM(config *WindowsConfig) error { + fmt.Println("Creating Proxmox Windows template...") + + sshCmd := func(args ...string) *exec.Cmd { + fullArgs := []string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + fmt.Sprintf("%s@%s", strings.Split(config.ProxmoxUser, "@")[0], config.ProxmoxHost), + } + fullArgs = append(fullArgs, args...) + return exec.Command("ssh", fullArgs...) + } + + scpCmd := func(src, dst string) *exec.Cmd { + return exec.Command("scp", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + src, + fmt.Sprintf("%s@%s:%s", strings.Split(config.ProxmoxUser, "@")[0], config.ProxmoxHost, dst), + ) + } + + imageName := fmt.Sprintf("vm-%d-disk-0.qcow2", config.VMID) + remotePath := fmt.Sprintf("/tmp/%s", imageName) + + if _, err := os.Stat(config.ImagePath); os.IsNotExist(err) { + return fmt.Errorf("Windows image not found at %s", config.ImagePath) + } + + fmt.Println("Uploading Windows image to Proxmox host...") + maxRetries := 3 + var uploadErr error + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + fmt.Printf("Retry upload attempt %d/%d...\n", attempt, maxRetries) + } + + cmd := scpCmd(config.ImagePath, remotePath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + uploadErr = cmd.Run() + + if uploadErr == nil { + fmt.Println("Upload completed!") + break + } + + if attempt == maxRetries { + return fmt.Errorf("failed to upload image after %d attempts: %w", maxRetries, uploadErr) + } + fmt.Printf("Upload failed: %v\n", uploadErr) + } + + fmt.Println("Creating Windows VM and converting to template...") + + createCmd := []string{"qm", "create", fmt.Sprintf("%d", config.VMID), + "--name", config.VMName, + "--memory", fmt.Sprintf("%d", config.Memory), + "--cores", fmt.Sprintf("%d", config.Cores), + "--sockets", fmt.Sprintf("%d", config.Sockets), + "--cpu", "host", + "--ostype", "win11", + "--net0", buildNetworkConfig(config), + } + + fmt.Printf("Running: %s\n", strings.Join(createCmd, " ")) + cmd := sshCmd(createCmd...) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create VM: %w", err) + } + + importCmd := []string{"qm", "importdisk", fmt.Sprintf("%d", config.VMID), remotePath, config.Storage} + fmt.Printf("Running: %s\n", strings.Join(importCmd, " ")) + + cmd = sshCmd(importCmd...) + var importOut bytes.Buffer + cmd.Stdout = &importOut + cmd.Stderr = &importOut + + if err := cmd.Run(); err != nil { + fmt.Println(importOut.String()) + return fmt.Errorf("failed to import disk: %w", err) + } + + output := importOut.String() + fmt.Println(output) + + var diskPath string + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "successfully imported disk") { + start := strings.Index(line, "'") + end := strings.LastIndex(line, "'") + if start != -1 && end != -1 && end > start { + diskPath = line[start+1 : end] + break + } + } + } + + if diskPath == "" { + return fmt.Errorf("failed to parse disk path from importdisk output") + } + + fmt.Printf("Imported disk path: %s\n", diskPath) + + commands := [][]string{ + {"qm", "set", fmt.Sprintf("%d", config.VMID), + "--scsihw", "virtio-scsi-pci", + "--scsi0", diskPath, + }, + {"qm", "set", fmt.Sprintf("%d", config.VMID), + "--boot", "order=scsi0", + }, + {"qm", "set", fmt.Sprintf("%d", config.VMID), + "--machine", "q35", + }, + {"qm", "set", fmt.Sprintf("%d", config.VMID), + "--bios", "ovmf", + }, + {"qm", "set", fmt.Sprintf("%d", config.VMID), + "--efidisk0", fmt.Sprintf("%s:1,efitype=4m,pre-enrolled-keys=1", config.Storage), + }, + {"qm", "set", fmt.Sprintf("%d", config.VMID), + "--tpmstate0", fmt.Sprintf("%s:1,version=v2.0", config.Storage), + }, + } + + if config.GuestAgent { + commands = append(commands, []string{"qm", "set", fmt.Sprintf("%d", config.VMID), "--agent", "enabled=1"}) + } + + commands = append(commands, [][]string{ + {"qm", "template", fmt.Sprintf("%d", config.VMID)}, + {"rm", "-f", remotePath}, + }...) + + for _, cmdArgs := range commands { + fmt.Printf("Running: %s\n", strings.Join(cmdArgs, " ")) + + var cmdErr error + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + fmt.Printf("Retry command attempt %d/%d...\n", attempt, maxRetries) + } + + cmd := sshCmd(cmdArgs...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + cmdErr = cmd.Run() + + if cmdErr == nil { + if stdout.Len() > 0 { + fmt.Println(stdout.String()) + } + break + } + + fmt.Println(stdout.String()) + fmt.Println(stderr.String()) + + if attempt == maxRetries { + return fmt.Errorf("failed to execute command '%s' after %d attempts: %w", strings.Join(cmdArgs, " "), maxRetries, cmdErr) + } + + fmt.Printf("Command failed: %v\n", cmdErr) + } + } + + if config.Firewall && len(config.FirewallRules) > 0 { + if err := configureFirewallRules(config, sshCmd); err != nil { + return fmt.Errorf("failed to configure firewall rules: %w", err) + } + } + + fmt.Printf("\nWindows Template %s (ID: %d) created successfully!\n", config.VMName, config.VMID) + fmt.Printf("You can clone it with: qm clone %d --name \n", config.VMID) + + return nil +} + +func configureFirewallRules(config *WindowsConfig, sshCmd func(args ...string) *exec.Cmd) error { + fmt.Println("Configuring firewall rules...") + + firewallConfig := "[OPTIONS]\nenable: 1\n\n[RULES]\n" + + for _, rule := range config.FirewallRules { + ruleLine := fmt.Sprintf("%s %s", strings.ToUpper(rule.Type), strings.ToUpper(rule.Action)) + + if rule.Protocol != "" { + ruleLine += fmt.Sprintf(" -p %s", rule.Protocol) + } + if rule.Dport != "" { + ruleLine += fmt.Sprintf(" -dport %s", rule.Dport) + } + if rule.Sport != "" { + ruleLine += fmt.Sprintf(" -sport %s", rule.Sport) + } + if rule.Source != "" { + ruleLine += fmt.Sprintf(" -source %s", rule.Source) + } + if rule.Dest != "" { + ruleLine += fmt.Sprintf(" -dest %s", rule.Dest) + } + if rule.Comment != "" { + ruleLine += fmt.Sprintf(" -log nolog # %s", rule.Comment) + } + + firewallConfig += ruleLine + "\n" + } + + firewallPath := fmt.Sprintf("/etc/pve/firewall/%d.fw", config.VMID) + + createCmd := sshCmd("bash", "-c", fmt.Sprintf("cat > %s << 'EOF'\n%sEOF", firewallPath, firewallConfig)) + var stdout, stderr bytes.Buffer + createCmd.Stdout = &stdout + createCmd.Stderr = &stderr + + if err := createCmd.Run(); err != nil { + fmt.Println(stdout.String()) + fmt.Println(stderr.String()) + return fmt.Errorf("failed to create firewall config: %w", err) + } + + fmt.Printf("Firewall rules configured: %s\n", firewallPath) + return nil +} + +func listAvailableStorage(config *WindowsConfig) error { + fmt.Printf("Detecting available storage on %s...\n", config.ProxmoxHost) + + sshCmd := func(args ...string) *exec.Cmd { + fullArgs := []string{ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + fmt.Sprintf("%s@%s", strings.Split(config.ProxmoxUser, "@")[0], config.ProxmoxHost), + } + fullArgs = append(fullArgs, args...) + return exec.Command("ssh", fullArgs...) + } + + cmd := sshCmd("pvesm", "status", "--output-format", "json") + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to get storage status: %w", err) + } + + var storageList []map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &storageList); err != nil { + return fmt.Errorf("failed to parse storage status: %w", err) + } + + fmt.Println("\nAvailable storage:") + fmt.Println("================") + + found := false + for _, storage := range storageList { + nameVal, ok := storage["storage"] + if !ok { + continue + } + name, _ := nameVal.(string) + + typeVal, _ := storage["type"] + typeStr, _ := typeVal.(string) + + activeVal, _ := storage["active"] + activeFloat, _ := activeVal.(float64) + + if activeFloat == 1 { + fmt.Printf("- %s (%s) - ACTIVE\n", name, typeStr) + found = true + } + } + + if !found { + fmt.Println("No active storage found!") + fmt.Println("\nManual check:") + fmt.Println(" ssh root@your-proxmox 'pvesm status'") + } + + return nil +} diff --git a/windows-tools/uninstall.sh b/windows-tools/uninstall.sh new file mode 100755 index 0000000..c042a66 --- /dev/null +++ b/windows-tools/uninstall.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +INSTALL_DIR="/usr/local/bin" +BINARY_NAME="proxmox-windows" + +echo "==========================================" +echo "Proxmox Windows Tool Uninstaller" +echo "==========================================" +echo "" + +if [ "$EUID" -ne 0 ]; then + echo "Error: This script must be run as root (use sudo)" + exit 1 +fi + +if [ -f "$INSTALL_DIR/$BINARY_NAME" ]; then + echo "Removing $INSTALL_DIR/$BINARY_NAME..." + rm -f "$INSTALL_DIR/$BINARY_NAME" + echo "Uninstallation completed successfully!" +else + echo "Binary not found at $INSTALL_DIR/$BINARY_NAME" + echo "Nothing to uninstall." +fi + +echo ""