End-to-end now drives a real KVM guest through the full XMRig-shaped
phase schedule with the workload running INSIDE the guest. Telemetry is
host-side /proc/<qemu_pid>; the load is busybox `yes` (sustained CPU
saturation) and `dd if=/dev/urandom` (disk burst on infecting), driven
over the serial console at every phase transition. The plotted envelope
shows clean idle → armed → infecting (disk spike) → infected_running
(100% CPU plateau) → dormant → re-entry → final clean.
Components:
vm/launch_demo.sh now boots Alpine 3.21 nocloud-cloudinit
(Cirros 0.6.x's cirros-init blocks on the
EC2 metadata service for ~17 min before
falling through to NoCloud — abandoned).
Mounts a cidata ISO as a second drive.
tools/build_cidata.py pure-Python NoCloud ISO builder (pycdlib).
Sets root password and ssh_pwauth via
runcmd so we don't depend on a specific
cloud-init version's plain_text_passwd
handling.
tools/vm_serial.py serial-console client (stdlib socket).
Idempotent login (detects already-in-shell
state), sentinel-bracketed run() that
distinguishes shell output from the TTY
echo of input by requiring a leading
\r\n boundary on the marker.
tools/vm_load_controller.py in-guest load controller. set_phase()
dispatches the per-phase shell command
over the serial connection.
tools/run_real_vm_demo.py ties it all together: boot VM, wait for
cloud-init runcmd, log in, run the
EpisodeRunner with on_phase=controller,
shut down VM.
Deps: paramiko, pycdlib added.
docs/sources.md updated with Alpine cloud image (sha512 pinned), and
the new Python deps.
README leads with the tier-2 plot now (real VM, real workload). The
previous synthetic plot is moved below with explicit "host-side mimic,
not a VM" labelling. Tier-2 status flipped to ✅ in the tier table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
2.9 KiB
Python
112 lines
2.9 KiB
Python
"""Build a NoCloud cidata ISO for cloud-init.
|
|
|
|
Cirros 0.6.x — and most cloud images — look for a NoCloud datasource at
|
|
boot: an ISO9660 volume labeled ``cidata`` containing two files,
|
|
``user-data`` and ``meta-data``. We attach it as a second drive so
|
|
cloud-init proceeds without spending ~17 minutes timing out trying to
|
|
reach a non-existent metadata service.
|
|
|
|
This script is intentionally self-contained and uses only pycdlib (pure
|
|
Python) — no system mkisofs/xorriso/cloud-localds dependency.
|
|
|
|
Usage:
|
|
|
|
uv run python tools/build_cidata.py vm/images/cidata.iso
|
|
|
|
The defaults bake in the ``cirros`` user with the documented Cirros
|
|
password, enable SSH password auth (so future Metasploit-class images
|
|
work without changes), and set a hostname. Override via flags if needed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import io
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pycdlib
|
|
|
|
|
|
DEFAULT_USER_DATA = """\
|
|
#cloud-config
|
|
hostname: cis490
|
|
manage_etc_hosts: true
|
|
users:
|
|
- name: cis490
|
|
plain_text_passwd: cis490
|
|
lock_passwd: false
|
|
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
shell: /bin/sh
|
|
ssh_pwauth: true
|
|
disable_root: false
|
|
chpasswd:
|
|
expire: false
|
|
list: |
|
|
root:cis490
|
|
cis490:cis490
|
|
runcmd:
|
|
- [ sh, -c, "echo CIS490_BOOT_OK > /tmp/.cis490-boot" ]
|
|
"""
|
|
|
|
DEFAULT_META_DATA = """\
|
|
instance-id: cis490-vm-001
|
|
local-hostname: cis490
|
|
"""
|
|
|
|
|
|
def build_cidata(out_path: Path, user_data: bytes, meta_data: bytes) -> None:
|
|
iso = pycdlib.PyCdlib()
|
|
# Joliet=3 + Rock Ridge so cloud-init reads filenames correctly on Linux.
|
|
iso.new(joliet=3, vol_ident="cidata", interchange_level=3, rock_ridge="1.09")
|
|
|
|
iso.add_fp(
|
|
io.BytesIO(user_data),
|
|
len(user_data),
|
|
iso_path="/USERDATA.;1",
|
|
rr_name="user-data",
|
|
joliet_path="/user-data",
|
|
)
|
|
iso.add_fp(
|
|
io.BytesIO(meta_data),
|
|
len(meta_data),
|
|
iso_path="/METADATA.;1",
|
|
rr_name="meta-data",
|
|
joliet_path="/meta-data",
|
|
)
|
|
iso.write(str(out_path))
|
|
iso.close()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(prog="build_cidata")
|
|
parser.add_argument("out_path", type=Path)
|
|
parser.add_argument(
|
|
"--user-data",
|
|
type=Path,
|
|
default=None,
|
|
help="path to a custom cloud-config user-data file",
|
|
)
|
|
parser.add_argument(
|
|
"--meta-data",
|
|
type=Path,
|
|
default=None,
|
|
help="path to a custom meta-data file",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
user_data = (
|
|
args.user_data.read_bytes() if args.user_data else DEFAULT_USER_DATA.encode()
|
|
)
|
|
meta_data = (
|
|
args.meta_data.read_bytes() if args.meta_data else DEFAULT_META_DATA.encode()
|
|
)
|
|
|
|
args.out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
build_cidata(args.out_path, user_data, meta_data)
|
|
print(f"wrote {args.out_path} ({args.out_path.stat().st_size} bytes)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|