diff --git a/README.md b/README.md index 43ebb81..a6eb20e 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,265 @@ +# πŸš€ MNMIVM-SE (Server Edition) -# πŸš€ MNMIVM - -### *Fire-and-Forget Virtual Machines* +### *A LAN-Native VM Cloud with a Minimal Control Plane* ![MNMIVM Hero](./assets/tuxrockets.jpg) -**MNMIVM** is a minimal, single-binary VM launcher built on **QEMU + KVM + cloud-init**. -It is designed for *fast iteration*, *ephemeral usage*, and *zero ceremony*. +**MNMIVM-SE** is the **server-focused edition** of MNMIVM β€” a minimal, single-binary VM launcher built on **QEMU + KVM + cloud-init** that turns your **LAN into a local VM cloud**. -If Docker feels too constrained, and OpenStack / Proxmox feel like overkill β€” MNMIVM sits comfortably in the middle. +Unlike traditional platforms, MNMIVM-SE exposes the raw infrastructure primitives directly: +bridges, TAP devices, MAC addresses, static IPs, and Linux processes. -> Spin it up. -> Get a random port. -> SSH in. -> Done. +> Your LAN is the fabric. +> The kernel is the scheduler. +> The CLI is the control plane. --- -## ✨ What MNMIVM Is +## ☁️ What MNMIVM-SE Is -* A **fire-and-forget VM launcher** -* A **CLI-first** virtualization tool -* A **thin orchestration layer**, not a platform -* A way to spin up *real Linux VMs* without running a control plane +* A **local VM cloud** built directly on your LAN +* A **process-native control plane** +* A **CLI-first infrastructure tool** +* A Proxmox-style networking model **without Proxmox** -MNMIVM intentionally avoids: +Each VM: +- Has a persistent MAC address +- Has a static IP on your LAN +- Appears as a first-class network device +- Can host real infrastructure services (DNS, CI, storage, routing, etc.) -* Long-lived port bindings -* Static network assumptions -* Cluster state -* Databases -* APIs -* Daemons - -It launches a VM, hands you SSH + VNC, and gets out of the way. +Routers, firewalls, and switches see MNMIVM-SE VMs as **real machines**, not NAT artifacts. --- -## 🧠 Design Philosophy +## 🧠 Control Plane Model -MNMIVM is built around a few hard rules: +MNMIVM-SE **does have a control plane** β€” it’s just intentionally **minimal, local, and explicit**. -* **No background services** -* **No required config files** -* **No global state beyond `/var/lib/microvm`** -* **Every VM is self-contained** -* **Cloud-init is the source of truth** -* **Root-only, explicit control** +The control plane is implemented as: +- A single CLI binary +- A file-backed state store +- Linux process lifecycle tracking -This makes MNMIVM ideal for: +There is: +- No always-on daemon +- No API server +- No database +- No reconciliation loop +- No scheduler service -* Homelabs -* CI runners -* Testing OS images -* Disposable dev environments -* Learning QEMU/KVM internals -* β€œI just need a VM *right now*” +Instead: + +- VM lifecycle = Linux process lifecycle +- State = files under `/var/lib/microvm` +- Configuration changes = cloud-init regeneration +- Access = SSH + VNC + +> The filesystem is the state store. +> `/proc` is the source of truth. +> Each CLI command is a deliberate control action. + +This makes MNMIVM-SE closer to **early private IaaS** and **bare-metal virtualization** than modern hyperscaler platforms. --- -## πŸ†š How It Compares (At a Glance) +## 🧱 Supported Host Operating Systems -| Feature | MNMIVM | Docker | LXC/LXD | Proxmox | OpenStack | -| ---------------------- | ----------- | ------- | ------- | ------- | --------- | -| Real VMs | βœ… | ❌ | ⚠️ | βœ… | βœ… | -| Cloud-init | βœ… | ❌ | ⚠️ | βœ… | βœ… | -| Requires Daemons | ❌ | βœ… | βœ… | βœ… | βœ… | -| Random Ports | βœ… | ❌ | ❌ | ❌ | ❌ | -| Cluster-aware | ❌ | ❌ | ⚠️ | βœ… | βœ… | -| Stateful Control Plane | ❌ | ❌ | ⚠️ | βœ… | βœ… | -| Setup Time | **Seconds** | Minutes | Minutes | Hours | Days | +MNMIVM-SE is conservative about host support and only documents what is tested. -> MNMIVM behaves more like **`docker run` for VMs**, not like a cloud. +### βœ… Supported + +| Host OS | Version | +|------|---------| +| **Debian** | 12+ | +| **Alpine Linux** | 3.22+ | + +### πŸ•’ Coming Soon + +| Host OS | Notes | +|------|------| +| Ubuntu | Netplan-based host networking support planned | + +> Ubuntu is not currently documented due to netplan-specific bridge handling. +> Support will be added, but is not a top priority. + +### ❌ Not Supported + +- Wi-Fi–only hosts +- WSL / nested hypervisors +- Desktop laptop setups expecting NAT --- ## 🧱 Architecture Overview -* **QEMU + KVM** for virtualization -* **User-mode networking** (no bridges required) -* **Cloud-init seed ISO** (`cidata`) for provisioning -* **QCOW2 backing images** -* **Ephemeral SSH + VNC ports** -* **State stored on disk only** +* **QEMU + KVM** +* **Linux bridge (`br0`)** +* **TAP devices** +* **Cloud-init seed ISO** +* **Static IP networking** +* **VNC console for recovery** ``` + /var/lib/microvm/ -β”œβ”€β”€ images/ # Base cloud images +β”œβ”€β”€ images/ └── vms/ - └── vm1/ - β”œβ”€β”€ disk.qcow2 - β”œβ”€β”€ seed.iso - β”œβ”€β”€ pubkey.pub - β”œβ”€β”€ os.name - β”œβ”€β”€ ssh.port - β”œβ”€β”€ vnc.port - └── vm.pid -``` +└── vm1/ +β”œβ”€β”€ disk.qcow2 +β”œβ”€β”€ seed.iso +β”œβ”€β”€ pubkey.pub +β”œβ”€β”€ os.name +β”œβ”€β”€ vm.ip +β”œβ”€β”€ vm.mac +β”œβ”€β”€ vnc.port +└── vm.pid -No database. -No API. +```` + +No libvirt. +No XML. No daemon. --- -## πŸ“¦ Supported Operating Systems +## 🌐 Host Networking Requirements (CRITICAL) -| OS | Version | Boot Mode | -| ------ | ----------- | --------- | -| Ubuntu | 24.04 LTS | BIOS | -| Debian | 13 (Trixie) | BIOS | -| Fedora | 43 Cloud | BIOS | -| Alpine | 3.22 | BIOS | +MNMIVM-SE requires a **proper Linux bridge**. -> UEFI images are intentionally avoided for simplicity and reliability. +### Example: `/etc/network/interfaces` (Debian) + +```ini +auto lo +iface lo inet loopback + +auto ens18 +iface ens18 inet manual + +auto br0 +iface br0 inet static + address 192.168.86.10 + netmask 255.255.255.0 + gateway 192.168.86.1 + dns-nameservers 1.1.1.1 8.8.8.8 + bridge_ports ens18 + bridge_stp off + bridge_fd 0 +```` + +**Rules that must be followed:** + +* The host IP must live on `br0` +* The physical NIC must have no IP +* Wi-Fi cannot be bridged +* VMs attach via TAP devices --- -## πŸ”‘ SSH & Identity Management +## πŸ”₯ Kernel Bridge Filtering (THIS WILL BREAK VMs) -* SSH keys are injected via **cloud-init** -* Keys are **fully replaced**, not appended -* Old keys are **revoked** -* `update-cloud` regenerates the seed ISO -* Changes apply on **next boot** +Linux defaults can silently block bridged traffic. -This gives you **real identity revocation**, not just key sprawl. +This **must** be disabled: + +```bash +cat /proc/sys/net/bridge/bridge-nf-call-iptables +# must be 0 +``` + +If set to `1`, VMs will: + +* Boot successfully +* Have valid IPs +* Be completely unreachable + +### Fix (runtime) + +```bash +sudo sysctl -w net.bridge.bridge-nf-call-iptables=0 +sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=0 +sudo sysctl -w net.bridge.bridge-nf-call-arptables=0 +``` + +### Persistent fix + +`/etc/sysctl.d/99-bridge.conf`: + +```ini +net.bridge.bridge-nf-call-iptables = 0 +net.bridge.bridge-nf-call-ip6tables = 0 +net.bridge.bridge-nf-call-arptables = 0 +``` --- -## πŸ“€ Disk Behavior +## πŸ” QEMU Bridge Permissions -* Base disk size is **12GB** (configurable constant) -* Uses **QCOW2 backing images** -* Root filesystem auto-expands on first boot -* Predictable, uniform VM sizing +QEMU must be allowed to attach TAP devices. -This keeps resource usage balanced and intentional. +### `/etc/qemu/bridge.conf` + +```ini +allow br0 +``` + +Verify helper: + +```bash +ls -l /usr/lib/qemu/qemu-bridge-helper +``` + +--- + +## 🐧 Alpine Linux Host Notes (3.22+) + +Alpine does not ship a hypervisor stack by default. + +Install required packages: + +```bash +apk add \ + qemu-system-x86_64 \ + qemu-img \ + qemu-hw-display-virtio-vga \ + bridge-utils \ + cdrkit \ + go +``` + +Notes: + +* `cdrkit` provides `genisoimage` +* `bridge-utils` provides `brctl` +* `qemu-hw-display-virtio-vga` is required for VNC +* No libvirt or services are used +* OpenRC is sufficient + +Alpine works well as a **minimal KVM host** once assembled. + +--- + +## βš™οΈ Server Edition Configuration (Code-Level) + +Networking and sizing are configured **in code**, not via runtime flags. + +Edit these constants in `main.go` (around lines 25–30): + +```go +// Networking +bridgeName = "br0" +lanCIDR = "192.168.86.0/24" +lanGW = "192.168.86.1" +lanDNS1 = "192.168.86.1" +lanDNS2 = "8.8.8.8" + +// VM sizing +baseDiskSize = "12G" +memMB = "1024" +cpus = "1" +``` + +This keeps runtime behavior explicit and predictable. --- @@ -145,95 +268,91 @@ This keeps resource usage balanced and intentional. ### Create a VM ```bash -sudo mnmivm create vm1 --os debian --pubkey-path ~/.ssh/id_ed25519.pub +sudo mnmivm-se create vm1 \ + --os debian \ + --pubkey-path ~/.ssh/id_ed25519.pub \ + --ip 192.168.86.53 ``` ### Start a VM ```bash -sudo mnmivm start vm1 +sudo mnmivm-se start vm1 +``` + +### SSH in + +```bash +ssh debian@192.168.86.53 ``` ### Stop a VM ```bash -sudo mnmivm stop vm1 +sudo mnmivm-se stop vm1 ``` -### Update SSH key (cloud-init) +### Update cloud-init (SSH key / IP) ```bash -sudo mnmivm update-cloud vm1 --pubkey-path newkey.pub -``` - -### List VMs - -```bash -sudo mnmivm list -``` - -### Delete a VM - -```bash -sudo mnmivm delete vm1 -# Requires typing YES in all caps +sudo mnmivm-se update-cloud vm1 \ + --pubkey-path newkey.pub \ + --ip 192.168.86.54 ``` --- -## πŸ“Š VM List Output +## πŸ”‘ Security Model -MNMIVM renders a clean Unicode table showing: +* SSH key–only access +* No password authentication +* No root login +* Static IPs (no DHCP ambiguity) +* MAC addresses pinned via cloud-init +* VNC console for recovery only -* Name -* Running state -* OS -* SSH endpoint -* VNC endpoint -* SSH public key path - -This makes it usable *without* scripting. +This follows **server-grade discipline**, not container ergonomics. --- -## ⚠️ What MNMIVM Is Not +## ⚠️ What MNMIVM-SE Is Not -* ❌ A cloud platform -* ❌ A hypervisor manager -* ❌ A replacement for Proxmox -* ❌ A Kubernetes node orchestrator -* ❌ A long-lived VM manager +* ❌ A managed cloud service +* ❌ A multi-tenant platform +* ❌ A scheduler or orchestrator +* ❌ A UI-driven system +* ❌ A laptop-friendly NAT tool -If you want **policy, HA, scheduling, quotas, tenants** β€” use Proxmox or OpenStack. +If you want **policy, HA, quotas, tenants**, use Proxmox or OpenStack. -If you want **a VM right now**, MNMIVM wins. +If you want **direct control over real infrastructure**, MNMIVM-SE is the tool. --- -## πŸ§ͺ Project Status +## 🐧 Why MNMIVM-SE Exists -* βœ… Actively working -* βœ… Under ~600 lines of Go -* βœ… No external Go dependencies -* ⚠️ API not stabilized yet -* ⚠️ CLI flags may evolve +Because sometimes you don’t want: -This project is intentionally **hackable** and **readable**. +* libvirt +* XML +* dashboards +* APIs +* orchestration layers + +You want: + +> β€œPut a VM on my LAN, give it an IP, and let me build infrastructure.” + +MNMIVM-SE does exactly that β€” and nothing more. --- -## 🐧 Why MNMIVM Exists +### ⚠️ Final Note -Sometimes you don’t want: +If you break networking with MNMIVM-SE, it isn’t a bug. -* A cluster -* A UI -* A dashboard -* A service mesh +It’s Linux doing exactly what you told it to do. -You just want to: +And that’s the point. -> β€œLaunch a VM and throw it into orbit.” - -That’s MNMIVM. diff --git a/assets/tuxrockets.jpg b/assets/tuxrockets.jpg index 2461528..e480745 100644 Binary files a/assets/tuxrockets.jpg and b/assets/tuxrockets.jpg differ diff --git a/build.sh b/build.sh index 41abf34..848ce56 100755 --- a/build.sh +++ b/build.sh @@ -1,2 +1,2 @@ #!/bin/sh -go build -o ./mnmivm +go build -o ./mnmivm-se diff --git a/go.mod b/go.mod index 01f39d8..1756695 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module tux-microvm +module mnmivm -go 1.24.4 +go 1.19 diff --git a/main.go b/main.go index 212c6fb..68ba416 100644 --- a/main.go +++ b/main.go @@ -1,86 +1,93 @@ package main import ( - "fmt" - "io" - "net" - "time" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" ) const ( - rootDir = "/var/lib/microvm" - imageDir = rootDir + "/images" - vmDirBase = rootDir + "/vms" + rootDir = "/var/lib/microvm" + imageDir = rootDir + "/images" + vmDirBase = rootDir + "/vms" - memMB = "1024" - cpus = "1" + 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 + 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", - }, - "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", - }, + "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", + }, + "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 must be run as root. Try: `sudo mnmivm` or login as the root user") - os.Exit(1) - } + 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) - } - } + for _, d := range []string{imageDir, vmDirBase} { + if err := os.MkdirAll(d, 0755); err != nil { + panic(err) + } + } } func usage() { - fmt.Println("usage:") - fmt.Println(" mnmivm create --os --pubkey-path ") - fmt.Println(" mnmivm start ") - fmt.Println(" mnmivm stop ") - fmt.Println(" mnmivm update-cloud --pubkey-path ") - fmt.Println(" mnmivm delete ") - fmt.Println(" mnmivm list") - os.Exit(1) + 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) } @@ -88,107 +95,218 @@ func pidFile(name string) string { return filepath.Join(vmDir(name), "vm.pi 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) + 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) + 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 + 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) + img := osImages[osName] + path := imagePath(osName) - if _, err := os.Stat(path); err == nil { - return - } + 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() + 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() + out, err := os.Create(path) + if err != nil { + panic(err) + } + defer out.Close() - if _, err := io.Copy(out, resp.Body); err != nil { - panic(err) - } + 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 + 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 updateCloudVM(name, pubKeyPath 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) - } - - // regenerate seed ISO - createCloudInitSeed(name, osName, vmPubKeyPath(name)) - - fmt.Println("Cloud-init updated for VM:", name) - fmt.Println("Changes will apply on next boot.") +func readFileTrim(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "-" + } + return strings.TrimSpace(string(data)) } -func createCloudInitSeed(name, osName, pubKeyPath string) { - img := osImages[osName] - sshKey := readPublicKey(pubKeyPath) +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 +} - instanceID := fmt.Sprintf("%s-%d", name, time.Now().Unix()) +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 + ")") + } +} - userData := fmt.Sprintf(`#cloud-config +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 @@ -206,323 +324,383 @@ runcmd: - 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) + 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, - ) - - 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) - } - - cmd := exec.Command( - "genisoimage", - "-output", seedISOPath(name), - "-volid", "cidata", - "-joliet", - "-rock", - filepath.Join(tmpDir, "user-data"), - filepath.Join(tmpDir, "meta-data"), - ) - - 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)) - - // compute column widths - 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) - } - } - } - - // helpers - 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) - } - - // top border - drawRow("β”Œ", "┬", "┐") - - // header row - fmt.Print("β”‚") - for i, h := range headers { - fmt.Printf(" %-*s β”‚", colWidths[i], h) - } - fmt.Println() - - // header separator - drawRow("β”œ", "β”Ό", "─") - - // data rows - for _, row := range rows { - fmt.Print("β”‚") - for i, cell := range row { - fmt.Printf(" %-*s β”‚", colWidths[i], cell) - } - fmt.Println() - } - - // bottom border - drawRow("β””", "β”΄", "β”˜") -} - - -func createVM(name, osName, pubKeyPath string) { - if _, ok := osImages[osName]; !ok { - panic("unsupported OS: " + osName) - } - - ensureBaseImage(osName) - os.MkdirAll(vmDir(name), 0755) - - keyData, _ := os.ReadFile(pubKeyPath) - os.WriteFile(vmPubKeyPath(name), keyData, 0644) - os.WriteFile(filepath.Join(vmDir(name), "os.name"), []byte(osName), 0644) - - 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, + 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) - createCloudInitSeed(name, osName, vmPubKeyPath(name)) - fmt.Println("VM created:", name, "OS:", osName) + fmt.Println("VM created:", name, "OS:", osName, "IP:", staticIP, "BRIDGE:", bridgeName) } func startVM(name string) { - 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)) - } + ensureBridgeExists() - sshPort := findFreePort() - vncPort := findFreePort() - vncDisplay := vncPort - 5900 - if vncDisplay < 0 { - panic("invalid VNC port") - } + 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)) + } - 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)), - "-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp::%d-:22", sshPort), - "-device", "virtio-net-pci,netdev=net0", - "-pidfile", pidFile(name), - "-daemonize", - ) + // VNC stays (console access) + vncPort := findFreePort() + vncDisplay := vncPort - 5900 + if vncDisplay < 0 { + panic("invalid VNC port") + } - if out, err := cmd.CombinedOutput(); err != nil { - panic(string(out)) - } + 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)") + } - os.WriteFile(filepath.Join(vmDir(name), "ssh.port"), []byte(fmt.Sprint(sshPort)), 0644) - os.WriteFile(filepath.Join(vmDir(name), "vnc.port"), []byte(fmt.Sprint(vncPort)), 0644) + // 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), - fmt.Println("VM started:", name) - fmt.Printf("SSH: ssh %s@localhost -p %d\n", osImages[readFileTrim(filepath.Join(vmDir(name), "os.name"))].User, sshPort) - fmt.Printf("VNC: vnc://0.0.0.0:%d\n", vncPort) + "-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, _ := os.ReadFile(pidFile(name)) - exec.Command("kill", strings.TrimSpace(string(data))).Run() - os.Remove(pidFile(name)) - fmt.Println("VM stopped:", name) + 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") - } + 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)) - } + 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 - } + 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 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 + _ = os.RemoveAll(dir) + fmt.Println("VM deleted:", name) } func listVMs() { - entries, _ := os.ReadDir(vmDirBase) + entries, _ := os.ReadDir(vmDirBase) - headers := []string{ - "NAME", - "STATE", - "OS", - "SSH", - "VNC", - "PUBKEY", - } + headers := []string{ + "NAME", + "STATE", + "OS", + "IP", + "VNC", + "BRIDGE", + } - rows := [][]string{} + rows := [][]string{} - for _, e := range entries { - name := e.Name() - dir := vmDir(name) + for _, e := range entries { + name := e.Name() + dir := vmDir(name) - state := "stopped" - if vmRunning(name) { - state = "running" - } + state := "stopped" + if vmRunning(name) { + state = "running" + } - osName := readFileTrim(filepath.Join(dir, "os.name")) + osName := readFileTrim(filepath.Join(dir, "os.name")) + ip := readFileTrim(filepath.Join(dir, "vm.ip")) + vncPort := readFileTrim(filepath.Join(dir, "vnc.port")) - sshPort := readFileTrim(filepath.Join(dir, "ssh.port")) - vncPort := readFileTrim(filepath.Join(dir, "vnc.port")) + vnc := "-" + if vncPort != "-" { + vnc = "0.0.0.0:" + vncPort + } - ssh := "-" - if sshPort != "-" { - ssh = "localhost:" + sshPort - } + rows = append(rows, []string{ + name, + state, + osName, + ip, + vnc, + bridgeName, + }) + } - vnc := "-" - if vncPort != "-" { - vnc = "0.0.0.0:" + vncPort - } + if len(rows) == 0 { + fmt.Println("No VMs found.") + return + } - rows = append(rows, []string{ - name, - state, - osName, - ssh, - vnc, - vmPubKeyPath(name), - }) - } - - if len(rows) == 0 { - fmt.Println("No VMs found.") - return - } - - renderBoxTable(headers, rows) + renderBoxTable(headers, rows) } - func main() { requireRoot() - if len(os.Args) < 2 { - usage() - } + if len(os.Args) < 2 { + usage() + } - ensureDirs() + 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() - switch os.Args[1] { - case "create": - createVM(os.Args[2], parseArg(os.Args, "--os"), parseArg(os.Args, "--pubkey-path")) - updateCloudVM(os.Args[2], parseArg(os.Args, "--pubkey-path")) - case "start": - startVM(os.Args[2]) - case "stop": - stopVM(os.Args[2]) - case "delete": - deleteVM(os.Args[2]) - case "list": - listVMs() case "update-cloud": if len(os.Args) < 3 { usage() } - updateCloudVM(os.Args[2], parseArg(os.Args, "--pubkey-path")) + name := os.Args[2] + pub := parseArg(os.Args, "--pubkey-path") + ip := parseArgOptional(os.Args, "--ip") + updateCloudVM(name, pub, ip) - default: - usage() - } + default: + usage() + } } diff --git a/mnmivm-se b/mnmivm-se new file mode 100755 index 0000000..c144abc Binary files /dev/null and b/mnmivm-se differ