server edition, first version complete
This commit is contained in:
parent
38a66427ce
commit
7c8ba859fb
6 changed files with 848 additions and 551 deletions
403
README.md
403
README.md
|
|
@ -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** 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** — it’s 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-Fi–only 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 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
|
### 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 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
|
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 don’t 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 don’t want:
|
If you break networking with MNMIVM-SE, it isn’t a bug.
|
||||||
|
|
||||||
* A cluster
|
It’s Linux doing exactly what you told it to do.
|
||||||
* A UI
|
|
||||||
* A dashboard
|
|
||||||
* A service mesh
|
|
||||||
|
|
||||||
You just want to:
|
And that’s the point.
|
||||||
|
|
||||||
> “Launch a VM and throw it into orbit.”
|
|
||||||
|
|
||||||
That’s MNMIVM.
|
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 427 KiB |
2
build.sh
2
build.sh
|
|
@ -1,2 +1,2 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
go build -o ./mnmivm
|
go build -o ./mnmivm-se
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -1,3 +1,3 @@
|
||||||
module tux-microvm
|
module mnmivm
|
||||||
|
|
||||||
go 1.24.4
|
go 1.19
|
||||||
|
|
|
||||||
346
main.go
346
main.go
|
|
@ -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
BIN
mnmivm-se
Executable file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue