server edition, first version complete

This commit is contained in:
mrkmntal 2025-12-18 21:07:59 -05:00
commit 7c8ba859fb
6 changed files with 848 additions and 551 deletions

403
README.md
View file

@ -1,142 +1,265 @@
# 🚀 MNMIVM-SE (Server Edition)
# 🚀 MNMIVM ### *A LAN-Native VM Cloud with a Minimal Control Plane*
### *Fire-and-Forget Virtual Machines*
![MNMIVM Hero](./assets/tuxrockets.jpg) ![MNMIVM Hero](./assets/tuxrockets.jpg)
**MNMIVM** is a minimal, single-binary VM launcher built on **QEMU + KVM + cloud-init**. **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**.
It is designed for *fast iteration*, *ephemeral usage*, and *zero ceremony*.
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. > Your LAN is the fabric.
> Get a random port. > The kernel is the scheduler.
> SSH in. > The CLI is the control plane.
> Done.
--- ---
## ✨ What MNMIVM Is ## ☁️ What MNMIVM-SE Is
* A **fire-and-forget VM launcher** * A **local VM cloud** built directly on your LAN
* A **CLI-first** virtualization tool * A **process-native control plane**
* A **thin orchestration layer**, not a platform * A **CLI-first infrastructure tool**
* A way to spin up *real Linux VMs* without running a control plane * 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 Routers, firewalls, and switches see MNMIVM-SE VMs as **real machines**, not NAT artifacts.
* Static network assumptions
* Cluster state
* Databases
* APIs
* Daemons
It launches a VM, hands you SSH + VNC, and gets out of the way.
--- ---
## 🧠 Design Philosophy ## 🧠 Control Plane Model
MNMIVM is built around a few hard rules: MNMIVM-SE **does have a control plane** — its just intentionally **minimal, local, and explicit**.
* **No background services** The control plane is implemented as:
* **No required config files** - A single CLI binary
* **No global state beyond `/var/lib/microvm`** - A file-backed state store
* **Every VM is self-contained** - Linux process lifecycle tracking
* **Cloud-init is the source of truth**
* **Root-only, explicit control**
This makes MNMIVM ideal for: There is:
- No always-on daemon
- No API server
- No database
- No reconciliation loop
- No scheduler service
* Homelabs Instead:
* CI runners
* Testing OS images - VM lifecycle = Linux process lifecycle
* Disposable dev environments - State = files under `/var/lib/microvm`
* Learning QEMU/KVM internals - Configuration changes = cloud-init regeneration
* “I just need a VM *right now* - 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 | MNMIVM-SE is conservative about host support and only documents what is tested.
| ---------------------- | ----------- | ------- | ------- | ------- | --------- |
| Real VMs | ✅ | ❌ | ⚠️ | ✅ | ✅ |
| Cloud-init | ✅ | ❌ | ⚠️ | ✅ | ✅ |
| Requires Daemons | ❌ | ✅ | ✅ | ✅ | ✅ |
| Random Ports | ✅ | ❌ | ❌ | ❌ | ❌ |
| Cluster-aware | ❌ | ❌ | ⚠️ | ✅ | ✅ |
| Stateful Control Plane | ❌ | ❌ | ⚠️ | ✅ | ✅ |
| Setup Time | **Seconds** | Minutes | Minutes | Hours | Days |
> 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-Fionly hosts
- WSL / nested hypervisors
- Desktop laptop setups expecting NAT
--- ---
## 🧱 Architecture Overview ## 🧱 Architecture Overview
* **QEMU + KVM** for virtualization * **QEMU + KVM**
* **User-mode networking** (no bridges required) * **Linux bridge (`br0`)**
* **Cloud-init seed ISO** (`cidata`) for provisioning * **TAP devices**
* **QCOW2 backing images** * **Cloud-init seed ISO**
* **Ephemeral SSH + VNC ports** * **Static IP networking**
* **State stored on disk only** * **VNC console for recovery**
``` ```
/var/lib/microvm/ /var/lib/microvm/
├── images/ # Base cloud images ├── images/
└── vms/ └── vms/
└── vm1/ └── vm1/
├── disk.qcow2 ├── disk.qcow2
├── seed.iso ├── seed.iso
├── pubkey.pub ├── pubkey.pub
├── os.name ├── os.name
├── ssh.port ├── vm.ip
├── vnc.port ├── vm.mac
└── vm.pid ├── vnc.port
``` └── vm.pid
No database. ````
No API.
No libvirt.
No XML.
No daemon. No daemon.
--- ---
## 📦 Supported Operating Systems ## 🌐 Host Networking Requirements (CRITICAL)
| OS | Version | Boot Mode | MNMIVM-SE requires a **proper Linux bridge**.
| ------ | ----------- | --------- |
| Ubuntu | 24.04 LTS | BIOS |
| Debian | 13 (Trixie) | BIOS |
| Fedora | 43 Cloud | BIOS |
| Alpine | 3.22 | BIOS |
> 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** Linux defaults can silently block bridged traffic.
* Keys are **fully replaced**, not appended
* Old keys are **revoked**
* `update-cloud` regenerates the seed ISO
* Changes apply on **next boot**
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) QEMU must be allowed to attach TAP devices.
* Uses **QCOW2 backing images**
* Root filesystem auto-expands on first boot
* Predictable, uniform VM sizing
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 2530):
```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 ### Create a VM
```bash ```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 ### Start a VM
```bash ```bash
sudo mnmivm start vm1 sudo mnmivm-se start vm1
```
### SSH in
```bash
ssh debian@192.168.86.53
``` ```
### Stop a VM ### Stop a VM
```bash ```bash
sudo mnmivm stop vm1 sudo mnmivm-se stop vm1
``` ```
### Update SSH key (cloud-init) ### Update cloud-init (SSH key / IP)
```bash ```bash
sudo mnmivm update-cloud vm1 --pubkey-path newkey.pub sudo mnmivm-se update-cloud vm1 \
``` --pubkey-path newkey.pub \
--ip 192.168.86.54
### List VMs
```bash
sudo mnmivm list
```
### Delete a VM
```bash
sudo mnmivm delete vm1
# Requires typing YES in all caps
``` ```
--- ---
## 📊 VM List Output ## 🔑 Security Model
MNMIVM renders a clean Unicode table showing: * SSH keyonly access
* No password authentication
* No root login
* Static IPs (no DHCP ambiguity)
* MAC addresses pinned via cloud-init
* VNC console for recovery only
* Name This follows **server-grade discipline**, not container ergonomics.
* Running state
* OS
* SSH endpoint
* VNC endpoint
* SSH public key path
This makes it usable *without* scripting.
--- ---
## ⚠️ What MNMIVM Is Not ## ⚠️ What MNMIVM-SE Is Not
* ❌ A cloud platform * ❌ A managed cloud service
* ❌ A hypervisor manager * ❌ A multi-tenant platform
* ❌ A replacement for Proxmox * ❌ A scheduler or orchestrator
* ❌ A Kubernetes node orchestrator * ❌ A UI-driven system
* ❌ A long-lived VM manager * ❌ 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 Because sometimes you dont want:
* ✅ Under ~600 lines of Go
* ✅ No external Go dependencies
* ⚠️ API not stabilized yet
* ⚠️ CLI flags may evolve
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 dont want: If you break networking with MNMIVM-SE, it isnt a bug.
* A cluster Its Linux doing exactly what you told it to do.
* A UI
* A dashboard
* A service mesh
You just want to: And thats the point.
> “Launch a VM and throw it into orbit.”
Thats MNMIVM.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Before After
Before After

View file

@ -1,2 +1,2 @@
#!/bin/sh #!/bin/sh
go build -o ./mnmivm go build -o ./mnmivm-se

4
go.mod
View file

@ -1,3 +1,3 @@
module tux-microvm module mnmivm
go 1.24.4 go 1.19

346
main.go
View file

@ -4,12 +4,12 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"time"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
const ( const (
@ -21,6 +21,13 @@ const (
cpus = "1" cpus = "1"
baseDiskSize = "12G" 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 { type OSImage struct {
@ -59,7 +66,7 @@ var osImages = map[string]OSImage{
func requireRoot() { func requireRoot() {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr, "mnmivm must be run as root. Try: `sudo mnmivm` or login as the root user") fmt.Fprintln(os.Stderr, "mnmivm-se must be run as root. Try: `sudo mnmivm-se` or login as the root user")
os.Exit(1) os.Exit(1)
} }
} }
@ -74,12 +81,12 @@ func ensureDirs() {
func usage() { func usage() {
fmt.Println("usage:") fmt.Println("usage:")
fmt.Println(" mnmivm create <name> --os <ubuntu|debian|fedora|alpine> --pubkey-path <path>") fmt.Println(" mnmivm-se create <name> --os <ubuntu|debian|fedora|alpine> --pubkey-path <path> --ip <192.168.86.X>")
fmt.Println(" mnmivm start <name>") fmt.Println(" mnmivm-se start <name>")
fmt.Println(" mnmivm stop <name>") fmt.Println(" mnmivm-se stop <name>")
fmt.Println(" mnmivm update-cloud <name> --pubkey-path <path>") fmt.Println(" mnmivm-se update-cloud <name> --pubkey-path <path> [--ip <192.168.86.X>]")
fmt.Println(" mnmivm delete <name>") fmt.Println(" mnmivm-se delete <name>")
fmt.Println(" mnmivm list") fmt.Println(" mnmivm-se list")
os.Exit(1) os.Exit(1)
} }
@ -88,6 +95,8 @@ 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 diskPath(name string) string { return filepath.Join(vmDir(name), "disk.qcow2") }
func seedISOPath(name string) string { return filepath.Join(vmDir(name), "seed.iso") } 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 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 { func imagePath(osName string) string {
return filepath.Join(imageDir, osImages[osName].Filename) return filepath.Join(imageDir, osImages[osName].Filename)
@ -102,6 +111,15 @@ func parseArg(args []string, key string) string {
panic("missing required argument: " + key) 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 { func findFreePort() int {
l, err := net.Listen("tcp", "127.0.0.1:0") l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@ -149,7 +167,89 @@ func readPublicKey(path string) string {
return key return key
} }
func updateCloudVM(name, pubKeyPath string) { 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) dir := vmDir(name)
if _, err := os.Stat(dir); err != nil { if _, err := os.Stat(dir); err != nil {
@ -170,22 +270,40 @@ func updateCloudVM(name, pubKeyPath string) {
if err != nil { if err != nil {
panic(err) panic(err)
} }
if err := os.WriteFile(vmPubKeyPath(name), keyData, 0644); err != nil { if err := os.WriteFile(vmPubKeyPath(name), keyData, 0644); err != nil {
panic(err) 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 // regenerate seed ISO
createCloudInitSeed(name, osName, vmPubKeyPath(name)) 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("Cloud-init updated for VM:", name)
fmt.Println("Changes will apply on next boot.") fmt.Println("Changes will apply on next boot.")
} }
func createCloudInitSeed(name, osName, pubKeyPath string) { func createCloudInitSeed(name, osName, pubKeyPath, staticIP, macAddr string) {
img := osImages[osName] img := osImages[osName]
sshKey := readPublicKey(pubKeyPath) sshKey := readPublicKey(pubKeyPath)
validateStaticIPv4(staticIP)
instanceID := fmt.Sprintf("%s-%d", name, time.Now().Unix()) instanceID := fmt.Sprintf("%s-%d", name, time.Now().Unix())
userData := fmt.Sprintf(`#cloud-config userData := fmt.Sprintf(`#cloud-config
@ -220,6 +338,30 @@ runcmd:
name, 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") tmpDir, err := os.MkdirTemp("", "cloudinit")
if err != nil { if err != nil {
panic(err) panic(err)
@ -232,6 +374,9 @@ runcmd:
if err := os.WriteFile(filepath.Join(tmpDir, "meta-data"), []byte(metaData), 0644); err != nil { if err := os.WriteFile(filepath.Join(tmpDir, "meta-data"), []byte(metaData), 0644); err != nil {
panic(err) panic(err)
} }
if err := os.WriteFile(filepath.Join(tmpDir, "network-config"), []byte(networkConfig), 0644); err != nil {
panic(err)
}
cmd := exec.Command( cmd := exec.Command(
"genisoimage", "genisoimage",
@ -241,6 +386,7 @@ runcmd:
"-rock", "-rock",
filepath.Join(tmpDir, "user-data"), filepath.Join(tmpDir, "user-data"),
filepath.Join(tmpDir, "meta-data"), filepath.Join(tmpDir, "meta-data"),
filepath.Join(tmpDir, "network-config"),
) )
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
@ -248,12 +394,9 @@ runcmd:
} }
} }
func renderBoxTable(headers []string, rows [][]string) { func renderBoxTable(headers []string, rows [][]string) {
colWidths := make([]int, len(headers)) colWidths := make([]int, len(headers))
// compute column widths
for i, h := range headers { for i, h := range headers {
colWidths[i] = len(h) colWidths[i] = len(h)
} }
@ -265,10 +408,7 @@ func renderBoxTable(headers []string, rows [][]string) {
} }
} }
// helpers repeat := func(s string, n int) string { return strings.Repeat(s, n) }
repeat := func(s string, n int) string {
return strings.Repeat(s, n)
}
drawRow := func(left, mid, right string) { drawRow := func(left, mid, right string) {
fmt.Print(left) fmt.Print(left)
@ -281,20 +421,16 @@ func renderBoxTable(headers []string, rows [][]string) {
fmt.Println(right) fmt.Println(right)
} }
// top border
drawRow("┌", "┬", "┐") drawRow("┌", "┬", "┐")
// header row
fmt.Print("│") fmt.Print("│")
for i, h := range headers { for i, h := range headers {
fmt.Printf(" %-*s │", colWidths[i], h) fmt.Printf(" %-*s │", colWidths[i], h)
} }
fmt.Println() fmt.Println()
// header separator
drawRow("├", "┼", "┤") drawRow("├", "┼", "┤")
// data rows
for _, row := range rows { for _, row := range rows {
fmt.Print("│") fmt.Print("│")
for i, cell := range row { for i, cell := range row {
@ -303,22 +439,43 @@ func renderBoxTable(headers []string, rows [][]string) {
fmt.Println() fmt.Println()
} }
// bottom border
drawRow("└", "┴", "┘") drawRow("└", "┴", "┘")
} }
func createVM(name, osName, pubKeyPath, staticIP string) {
func createVM(name, osName, pubKeyPath string) {
if _, ok := osImages[osName]; !ok { if _, ok := osImages[osName]; !ok {
panic("unsupported OS: " + osName) panic("unsupported OS: " + osName)
} }
ensureBridgeExists()
validateStaticIPv4(staticIP)
ensureBaseImage(osName) ensureBaseImage(osName)
os.MkdirAll(vmDir(name), 0755) if err := os.MkdirAll(vmDir(name), 0755); err != nil {
panic(err)
}
keyData, _ := os.ReadFile(pubKeyPath) // Store pubkey + OS
os.WriteFile(vmPubKeyPath(name), keyData, 0644) keyData, err := os.ReadFile(pubKeyPath)
os.WriteFile(filepath.Join(vmDir(name), "os.name"), []byte(osName), 0644) 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( cmd := exec.Command(
"qemu-img", "create", "qemu-img", "create",
@ -327,41 +484,52 @@ func createVM(name, osName, pubKeyPath string) {
"-b", imagePath(osName), "-b", imagePath(osName),
diskPath(name), diskPath(name),
) )
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
panic(string(out)) panic(string(out))
} }
resizeCmd := exec.Command( resizeCmd := exec.Command("qemu-img", "resize", diskPath(name), baseDiskSize)
"qemu-img", "resize",
diskPath(name),
baseDiskSize,
)
if out, err := resizeCmd.CombinedOutput(); err != nil { if out, err := resizeCmd.CombinedOutput(); err != nil {
panic("disk resize failed:\n" + string(out)) 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, "IP:", staticIP, "BRIDGE:", bridgeName)
fmt.Println("VM created:", name, "OS:", osName)
} }
func startVM(name string) { func startVM(name string) {
ensureBridgeExists()
if data, err := os.ReadFile(pidFile(name)); err == nil { if data, err := os.ReadFile(pidFile(name)); err == nil {
if _, err := os.Stat("/proc/" + strings.TrimSpace(string(data))); err == nil { if _, err := os.Stat("/proc/" + strings.TrimSpace(string(data))); err == nil {
panic("VM already running") panic("VM already running")
} }
os.Remove(pidFile(name)) _ = os.Remove(pidFile(name))
} }
sshPort := findFreePort() // VNC stays (console access)
vncPort := findFreePort() vncPort := findFreePort()
vncDisplay := vncPort - 5900 vncDisplay := vncPort - 5900
if vncDisplay < 0 { if vncDisplay < 0 {
panic("invalid VNC port") 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( cmd := exec.Command(
"qemu-system-x86_64", "qemu-system-x86_64",
"-enable-kvm", "-enable-kvm",
@ -371,10 +539,14 @@ func startVM(name string) {
"-smp", cpus, "-smp", cpus,
"-vga", "virtio", "-vga", "virtio",
"-display", fmt.Sprintf("vnc=0.0.0.0:%d", vncDisplay), "-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=qcow2", diskPath(name)),
"-drive", fmt.Sprintf("file=%s,if=virtio,format=raw", seedISOPath(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", // 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), "-pidfile", pidFile(name),
"-daemonize", "-daemonize",
) )
@ -383,18 +555,21 @@ func startVM(name string) {
panic(string(out)) panic(string(out))
} }
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)
os.WriteFile(filepath.Join(vmDir(name), "vnc.port"), []byte(fmt.Sprint(vncPort)), 0644)
fmt.Println("VM started:", name) 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("SSH: ssh %s@%s\n", osImages[osName].User, ip)
fmt.Printf("VNC: vnc://0.0.0.0:%d\n", vncPort) 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) { func stopVM(name string) {
data, _ := os.ReadFile(pidFile(name)) data, err := os.ReadFile(pidFile(name))
exec.Command("kill", strings.TrimSpace(string(data))).Run() if err != nil {
os.Remove(pidFile(name)) panic("VM not running (pidfile missing)")
}
_ = exec.Command("kill", strings.TrimSpace(string(data))).Run()
_ = os.Remove(pidFile(name))
fmt.Println("VM stopped:", name) fmt.Println("VM stopped:", name)
} }
@ -405,8 +580,8 @@ func deleteVM(name string) {
} }
if data, err := os.ReadFile(pidFile(name)); err == nil { if data, err := os.ReadFile(pidFile(name)); err == nil {
exec.Command("kill", strings.TrimSpace(string(data))).Run() _ = exec.Command("kill", strings.TrimSpace(string(data))).Run()
os.Remove(pidFile(name)) _ = os.Remove(pidFile(name))
} }
fmt.Printf("\nWARNING: Permanently delete VM \"%s\"?\nType YES to confirm: ", name) fmt.Printf("\nWARNING: Permanently delete VM \"%s\"?\nType YES to confirm: ", name)
@ -417,27 +592,10 @@ func deleteVM(name string) {
return return
} }
os.RemoveAll(dir) _ = os.RemoveAll(dir)
fmt.Println("VM deleted:", name) 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
}
func listVMs() { func listVMs() {
entries, _ := os.ReadDir(vmDirBase) entries, _ := os.ReadDir(vmDirBase)
@ -445,9 +603,9 @@ func listVMs() {
"NAME", "NAME",
"STATE", "STATE",
"OS", "OS",
"SSH", "IP",
"VNC", "VNC",
"PUBKEY", "BRIDGE",
} }
rows := [][]string{} rows := [][]string{}
@ -462,15 +620,9 @@ func listVMs() {
} }
osName := readFileTrim(filepath.Join(dir, "os.name")) osName := readFileTrim(filepath.Join(dir, "os.name"))
ip := readFileTrim(filepath.Join(dir, "vm.ip"))
sshPort := readFileTrim(filepath.Join(dir, "ssh.port"))
vncPort := readFileTrim(filepath.Join(dir, "vnc.port")) vncPort := readFileTrim(filepath.Join(dir, "vnc.port"))
ssh := "-"
if sshPort != "-" {
ssh = "localhost:" + sshPort
}
vnc := "-" vnc := "-"
if vncPort != "-" { if vncPort != "-" {
vnc = "0.0.0.0:" + vncPort vnc = "0.0.0.0:" + vncPort
@ -480,9 +632,9 @@ func listVMs() {
name, name,
state, state,
osName, osName,
ssh, ip,
vnc, vnc,
vmPubKeyPath(name), bridgeName,
}) })
} }
@ -494,7 +646,6 @@ func listVMs() {
renderBoxTable(headers, rows) renderBoxTable(headers, rows)
} }
func main() { func main() {
requireRoot() requireRoot()
if len(os.Args) < 2 { if len(os.Args) < 2 {
@ -505,21 +656,48 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "create": case "create":
createVM(os.Args[2], parseArg(os.Args, "--os"), parseArg(os.Args, "--pubkey-path")) if len(os.Args) < 3 {
updateCloudVM(os.Args[2], parseArg(os.Args, "--pubkey-path")) 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": case "start":
if len(os.Args) < 3 {
usage()
}
startVM(os.Args[2]) startVM(os.Args[2])
case "stop": case "stop":
if len(os.Args) < 3 {
usage()
}
stopVM(os.Args[2]) stopVM(os.Args[2])
case "delete": case "delete":
if len(os.Args) < 3 {
usage()
}
deleteVM(os.Args[2]) deleteVM(os.Args[2])
case "list": case "list":
listVMs() listVMs()
case "update-cloud": case "update-cloud":
if len(os.Args) < 3 { if len(os.Args) < 3 {
usage() 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: default:
usage() usage()

BIN
mnmivm-se Executable file

Binary file not shown.