package main import ( "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) const ( rootDir = "/var/lib/microvm" imageDir = rootDir + "/images" vmDirBase = rootDir + "/vms" memMB = "1024" cpus = "1" baseDiskSize = "12G" // Server Edition networking bridgeName = "br0" lanCIDR = "192.168.86.0/24" lanGW = "192.168.86.1" lanDNS1 = "192.168.86.1" lanDNS2 = "8.8.8.8" ) type OSImage struct { Name string URL string Filename string User string } var osImages = map[string]OSImage{ "ubuntu": { Name: "ubuntu", URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", Filename: "ubuntu-24.04.qcow2", User: "ubuntu", }, "debian": { Name: "debian", URL: "https://cloud.debian.org/images/cloud/trixie/20251117-2299/debian-13-generic-amd64-20251117-2299.qcow2", Filename: "debian-13.qcow2", User: "debian", }, "debian-forky": { Name: "debian-forky", URL: "https://cloud.debian.org/images/cloud/forky/daily/20251218-2330/debian-14-generic-amd64-daily-20251218-2330.qcow2", Filename: "debian-14.qcow2", User: "debian", }, "fedora": { Name: "fedora", URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2", Filename: "fedora-43.qcow2", User: "fedora", }, "alpine": { Name: "alpine", URL: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/generic_alpine-3.22.2-x86_64-bios-cloudinit-r0.qcow2", Filename: "alpine-3.22.qcow2", User: "alpine", }, } func requireRoot() { if os.Geteuid() != 0 { fmt.Fprintln(os.Stderr, "mnmivm-se must be run as root. Try: `sudo mnmivm-se` or login as the root user") os.Exit(1) } } func ensureDirs() { for _, d := range []string{imageDir, vmDirBase} { if err := os.MkdirAll(d, 0755); err != nil { panic(err) } } } func usage() { fmt.Println("usage:") fmt.Println(" mnmivm-se create --os --pubkey-path --ip <192.168.86.X>") fmt.Println(" mnmivm-se start ") fmt.Println(" mnmivm-se stop ") fmt.Println(" mnmivm-se update-cloud --pubkey-path [--ip <192.168.86.X>]") fmt.Println(" mnmivm-se delete ") fmt.Println(" mnmivm-se list") os.Exit(1) } func vmDir(name string) string { return filepath.Join(vmDirBase, name) } func pidFile(name string) string { return filepath.Join(vmDir(name), "vm.pid") } func diskPath(name string) string { return filepath.Join(vmDir(name), "disk.qcow2") } func seedISOPath(name string) string { return filepath.Join(vmDir(name), "seed.iso") } func vmPubKeyPath(name string) string { return filepath.Join(vmDir(name), "pubkey.pub") } func vmIPPath(name string) string { return filepath.Join(vmDir(name), "vm.ip") } func vmMACPath(name string) string { return filepath.Join(vmDir(name), "vm.mac") } func imagePath(osName string) string { return filepath.Join(imageDir, osImages[osName].Filename) } func parseArg(args []string, key string) string { for i := 0; i < len(args)-1; i++ { if args[i] == key { return args[i+1] } } panic("missing required argument: " + key) } func parseArgOptional(args []string, key string) string { for i := 0; i < len(args)-1; i++ { if args[i] == key { return args[i+1] } } return "" } func findFreePort() int { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { panic(err) } defer l.Close() return l.Addr().(*net.TCPAddr).Port } func ensureBaseImage(osName string) { img := osImages[osName] path := imagePath(osName) if _, err := os.Stat(path); err == nil { return } fmt.Println("Downloading", img.Name, "cloud image...") resp, err := http.Get(img.URL) if err != nil { panic(err) } defer resp.Body.Close() out, err := os.Create(path) if err != nil { panic(err) } defer out.Close() if _, err := io.Copy(out, resp.Body); err != nil { panic(err) } } func readPublicKey(path string) string { data, err := os.ReadFile(path) if err != nil { panic(err) } key := strings.TrimSpace(string(data)) if !strings.HasPrefix(key, "ssh-") { panic("invalid SSH public key") } return key } func readFileTrim(path string) string { data, err := os.ReadFile(path) if err != nil { return "-" } return strings.TrimSpace(string(data)) } func vmRunning(name string) bool { data, err := os.ReadFile(pidFile(name)) if err != nil { return false } _, err = os.Stat("/proc/" + strings.TrimSpace(string(data))) return err == nil } func ensureBridgeExists() { if _, err := os.Stat(filepath.Join("/sys/class/net", bridgeName)); err != nil { panic("bridge not found: " + bridgeName + " (expected at /sys/class/net/" + bridgeName + ")") } } func validateStaticIPv4(ipStr string) { ip := net.ParseIP(strings.TrimSpace(ipStr)) if ip == nil { panic("invalid IP address: " + ipStr) } ip4 := ip.To4() if ip4 == nil { panic("invalid IPv4 address: " + ipStr) } _, cidrNet, err := net.ParseCIDR(lanCIDR) if err != nil { panic("invalid lanCIDR constant: " + lanCIDR) } if !cidrNet.Contains(ip4) { panic("IP must be within " + lanCIDR + ": " + ipStr) } // Prevent network + broadcast addresses (simple /24-safe guard) if ip4[len(ip4)-1] == 0 || ip4[len(ip4)-1] == 255 { panic("IP cannot be network or broadcast address: " + ipStr) } } func generateLocallyAdministeredMAC() string { // Locally administered, unicast MAC: set bit1 of first octet, clear multicast bit. b := make([]byte, 6) _, err := io.ReadFull(strings.NewReader(randomHexBytes(12)), b) // placeholder; replaced below if err == nil { // not used, fallthrough } // Real randomness from crypto/rand via /dev/urandom without importing crypto/rand: f, err := os.Open("/dev/urandom") if err != nil { panic(err) } defer f.Close() if _, err := io.ReadFull(f, b); err != nil { panic(err) } b[0] = (b[0] | 0x02) & 0xFE return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]) } // Minimal helper to avoid unused import tricks; returns hex chars length n*2 (not cryptographically used). func randomHexBytes(n int) string { const hexd = "0123456789abcdef" out := make([]byte, n) for i := range out { out[i] = hexd[(i*7+3)%len(hexd)] } return string(out) } func updateCloudVM(name, pubKeyPath string, ipOptional string) { dir := vmDir(name) if _, err := os.Stat(dir); err != nil { panic("VM does not exist: " + name) } if vmRunning(name) { panic("VM is currently running. Stop it before updating cloud-init.") } osName := readFileTrim(filepath.Join(dir, "os.name")) if osName == "-" { panic("cannot determine OS for VM") } // overwrite stored pubkey keyData, err := os.ReadFile(pubKeyPath) if err != nil { panic(err) } if err := os.WriteFile(vmPubKeyPath(name), keyData, 0644); err != nil { panic(err) } // optionally overwrite stored IP if ipOptional != "" { validateStaticIPv4(ipOptional) if err := os.WriteFile(vmIPPath(name), []byte(strings.TrimSpace(ipOptional)), 0644); err != nil { panic(err) } } // regenerate seed ISO ip := readFileTrim(vmIPPath(name)) mac := readFileTrim(vmMACPath(name)) if ip == "-" { panic("missing VM IP; update-cloud requires --ip or a previously stored vm.ip") } if mac == "-" { panic("missing VM MAC; VM seems corrupted (vm.mac not found)") } createCloudInitSeed(name, osName, vmPubKeyPath(name), ip, mac) fmt.Println("Cloud-init updated for VM:", name) fmt.Println("Changes will apply on next boot.") } func createCloudInitSeed(name, osName, pubKeyPath, staticIP, macAddr string) { img := osImages[osName] sshKey := readPublicKey(pubKeyPath) validateStaticIPv4(staticIP) instanceID := fmt.Sprintf("%s-%d", name, time.Now().Unix()) userData := fmt.Sprintf(`#cloud-config users: - name: %s sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/sh lock_passwd: true create_home: true ssh_pwauth: false disable_root: true runcmd: - mkdir -p /home/%s/.ssh - echo '%s' > /home/%s/.ssh/authorized_keys - chown -R %s:%s /home/%s/.ssh - chmod 700 /home/%s/.ssh - chmod 600 /home/%s/.ssh/authorized_keys `, img.User, img.User, sshKey, img.User, img.User, img.User, img.User, img.User, img.User) metaData := fmt.Sprintf( "instance-id: %s\nlocal-hostname: %s\n", instanceID, name, ) // Cloud-init network config v2 (netplan-style) with MAC match networkConfig := fmt.Sprintf(`version: 2 ethernets: eth0: match: macaddress: %s set-name: eth0 dhcp4: false addresses: - %s/24 routes: - to: 0.0.0.0/0 via: %s nameservers: addresses: - %s - %s `, strings.ToLower(strings.TrimSpace(macAddr)), strings.TrimSpace(staticIP), lanGW, lanDNS1, lanDNS2, ) tmpDir, err := os.MkdirTemp("", "cloudinit") if err != nil { panic(err) } defer os.RemoveAll(tmpDir) if err := os.WriteFile(filepath.Join(tmpDir, "user-data"), []byte(userData), 0644); err != nil { panic(err) } if err := os.WriteFile(filepath.Join(tmpDir, "meta-data"), []byte(metaData), 0644); err != nil { panic(err) } if err := os.WriteFile(filepath.Join(tmpDir, "network-config"), []byte(networkConfig), 0644); err != nil { panic(err) } cmd := exec.Command( "genisoimage", "-output", seedISOPath(name), "-volid", "cidata", "-joliet", "-rock", filepath.Join(tmpDir, "user-data"), filepath.Join(tmpDir, "meta-data"), filepath.Join(tmpDir, "network-config"), ) if out, err := cmd.CombinedOutput(); err != nil { panic("cloud-init ISO creation failed:\n" + string(out)) } } func renderBoxTable(headers []string, rows [][]string) { colWidths := make([]int, len(headers)) for i, h := range headers { colWidths[i] = len(h) } for _, row := range rows { for i, cell := range row { if len(cell) > colWidths[i] { colWidths[i] = len(cell) } } } repeat := func(s string, n int) string { return strings.Repeat(s, n) } drawRow := func(left, mid, right string) { fmt.Print(left) for i, w := range colWidths { fmt.Print(repeat("─", w+2)) if i < len(colWidths)-1 { fmt.Print(mid) } } fmt.Println(right) } drawRow("┌", "┬", "┐") fmt.Print("│") for i, h := range headers { fmt.Printf(" %-*s │", colWidths[i], h) } fmt.Println() drawRow("├", "┼", "┤") for _, row := range rows { fmt.Print("│") for i, cell := range row { fmt.Printf(" %-*s │", colWidths[i], cell) } fmt.Println() } drawRow("└", "┴", "┘") } func createVM(name, osName, pubKeyPath, staticIP string) { if _, ok := osImages[osName]; !ok { panic("unsupported OS: " + osName) } ensureBridgeExists() validateStaticIPv4(staticIP) ensureBaseImage(osName) if err := os.MkdirAll(vmDir(name), 0755); err != nil { panic(err) } // Store pubkey + OS keyData, err := os.ReadFile(pubKeyPath) if err != nil { panic(err) } if err := os.WriteFile(vmPubKeyPath(name), keyData, 0644); err != nil { panic(err) } if err := os.WriteFile(filepath.Join(vmDir(name), "os.name"), []byte(osName), 0644); err != nil { panic(err) } // Store static IP (server edition) if err := os.WriteFile(vmIPPath(name), []byte(strings.TrimSpace(staticIP)), 0644); err != nil { panic(err) } // Create and store a stable MAC for matching in cloud-init mac := generateLocallyAdministeredMAC() if err := os.WriteFile(vmMACPath(name), []byte(mac), 0644); err != nil { panic(err) } cmd := exec.Command( "qemu-img", "create", "-f", "qcow2", "-F", "qcow2", "-b", imagePath(osName), diskPath(name), ) if out, err := cmd.CombinedOutput(); err != nil { panic(string(out)) } resizeCmd := exec.Command("qemu-img", "resize", diskPath(name), baseDiskSize) if out, err := resizeCmd.CombinedOutput(); err != nil { panic("disk resize failed:\n" + string(out)) } // Create cloud-init seed with static network config createCloudInitSeed(name, osName, vmPubKeyPath(name), staticIP, mac) fmt.Println("VM created:", name, "OS:", osName, "IP:", staticIP, "BRIDGE:", bridgeName) } func startVM(name string) { ensureBridgeExists() if data, err := os.ReadFile(pidFile(name)); err == nil { if _, err := os.Stat("/proc/" + strings.TrimSpace(string(data))); err == nil { panic("VM already running") } _ = os.Remove(pidFile(name)) } // VNC stays (console access) vncPort := findFreePort() vncDisplay := vncPort - 5900 if vncDisplay < 0 { panic("invalid VNC port") } osName := readFileTrim(filepath.Join(vmDir(name), "os.name")) if osName == "-" { panic("cannot determine OS for VM") } ip := readFileTrim(vmIPPath(name)) if ip == "-" { panic("missing vm.ip (static IP not set)") } mac := readFileTrim(vmMACPath(name)) if mac == "-" { panic("missing vm.mac (MAC not set)") } // Bridge networking (no user-mode, no hostfwd) cmd := exec.Command( "qemu-system-x86_64", "-enable-kvm", "-machine", "q35", "-cpu", "host", "-m", memMB, "-smp", cpus, "-vga", "virtio", "-display", fmt.Sprintf("vnc=0.0.0.0:%d", vncDisplay), "-drive", fmt.Sprintf("file=%s,if=virtio,format=qcow2", diskPath(name)), "-drive", fmt.Sprintf("file=%s,if=virtio,format=raw", seedISOPath(name)), // Bridge-backed NIC on br0, with stable MAC to match cloud-init "-netdev", fmt.Sprintf("bridge,id=net0,br=%s", bridgeName), "-device", fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s", strings.TrimSpace(mac)), "-pidfile", pidFile(name), "-daemonize", ) if out, err := cmd.CombinedOutput(); err != nil { panic(string(out)) } _ = os.WriteFile(filepath.Join(vmDir(name), "vnc.port"), []byte(fmt.Sprint(vncPort)), 0644) fmt.Println("VM started:", name) fmt.Printf("SSH: ssh %s@%s\n", osImages[osName].User, ip) fmt.Printf("VNC: vnc://0.0.0.0:%d\n", vncPort) fmt.Printf("NET: bridge=%s mac=%s ip=%s\n", bridgeName, strings.TrimSpace(mac), ip) } func stopVM(name string) { data, err := os.ReadFile(pidFile(name)) if err != nil { panic("VM not running (pidfile missing)") } _ = exec.Command("kill", strings.TrimSpace(string(data))).Run() _ = os.Remove(pidFile(name)) fmt.Println("VM stopped:", name) } func deleteVM(name string) { dir := vmDir(name) if _, err := os.Stat(dir); err != nil { panic("VM does not exist") } if data, err := os.ReadFile(pidFile(name)); err == nil { _ = exec.Command("kill", strings.TrimSpace(string(data))).Run() _ = os.Remove(pidFile(name)) } fmt.Printf("\nWARNING: Permanently delete VM \"%s\"?\nType YES to confirm: ", name) var confirm string fmt.Scanln(&confirm) if confirm != "YES" { fmt.Println("Aborted.") return } _ = os.RemoveAll(dir) fmt.Println("VM deleted:", name) } func listVMs() { entries, _ := os.ReadDir(vmDirBase) headers := []string{ "NAME", "STATE", "OS", "IP", "VNC", "BRIDGE", } rows := [][]string{} for _, e := range entries { name := e.Name() dir := vmDir(name) state := "stopped" if vmRunning(name) { state = "running" } osName := readFileTrim(filepath.Join(dir, "os.name")) ip := readFileTrim(filepath.Join(dir, "vm.ip")) vncPort := readFileTrim(filepath.Join(dir, "vnc.port")) vnc := "-" if vncPort != "-" { vnc = "0.0.0.0:" + vncPort } rows = append(rows, []string{ name, state, osName, ip, vnc, bridgeName, }) } if len(rows) == 0 { fmt.Println("No VMs found.") return } renderBoxTable(headers, rows) } func main() { requireRoot() if len(os.Args) < 2 { usage() } ensureDirs() switch os.Args[1] { case "create": if len(os.Args) < 3 { usage() } name := os.Args[2] osName := parseArg(os.Args, "--os") pub := parseArg(os.Args, "--pubkey-path") ip := parseArg(os.Args, "--ip") createVM(name, osName, pub, ip) // Keep behavior: after create, regenerate seed (ensures consistent ISO) updateCloudVM(name, pub, ip) case "start": if len(os.Args) < 3 { usage() } startVM(os.Args[2]) case "stop": if len(os.Args) < 3 { usage() } stopVM(os.Args[2]) case "delete": if len(os.Args) < 3 { usage() } deleteVM(os.Args[2]) case "list": listVMs() case "update-cloud": if len(os.Args) < 3 { usage() } name := os.Args[2] pub := parseArg(os.Args, "--pubkey-path") ip := parseArgOptional(os.Args, "--ip") updateCloudVM(name, pub, ip) default: usage() } }