mnmivm-se/main.go
2025-12-18 21:33:14 -05:00

712 lines
16 KiB
Go

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 <name> --os <ubuntu|debian|debian-forky|fedora|alpine> --pubkey-path <path> --ip <192.168.86.X>")
fmt.Println(" mnmivm-se start <name>")
fmt.Println(" mnmivm-se stop <name>")
fmt.Println(" mnmivm-se update-cloud <name> --pubkey-path <path> [--ip <192.168.86.X>]")
fmt.Println(" mnmivm-se delete <name>")
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()
}
}