Multi-host cybersecurity benchmark environment system. MHBench builds VM images, then provisions and configures multi-host network topologies from a single JSON spec. It supports two cloud backends: OpenStack (default) and Google Cloud.
- Python ≥ 3.12
- One cloud backend:
- OpenStack — credentials in a
clouds.yaml(default~/.config/openstack/clouds.yaml) and an SSH keypair registered with the cloud (seeopenstack.keypair_name/openstack.ssh_key_path). - Google Cloud — a GCP project, credentials (a service-account key or Application Default Credentials), and a local SSH keypair whose public key is injected into VM metadata. Install the extra with
uv sync --extra gcp.
- OpenStack — credentials in a
Ansible and all Python dependencies are installed automatically by uv sync.
Dependencies are managed with uv. If you don't already have uv:
pip install uvClone the repo and sync the environment:
git clone https://github.com/cylabcyberautonomy/MHBench.git v3_MHBench
cd v3_MHBench
uv sync
source .venv/bin/activateAfter this, the mhbench command is available inside the venv — every CLI example below uses it. If you'd rather not activate the venv, substitute uv run mhbench (or python cli.py) for mhbench in any command.
Copy the example config:
cp config/example_config.yaml config/config.yamlChoose a backend with the top-level backend: key (openstack or gcp); it
defaults to openstack, so existing configs keep working unchanged.
OpenStack — fill in the four openstack.* fields:
| Field | What to set it to |
|---|---|
openstack.clouds_yaml |
Path to your OpenStack clouds.yaml |
openstack.cloud |
Name of the entry in clouds.yaml to use |
openstack.keypair_name |
SSH keypair registered on the cloud |
openstack.ssh_key_path |
Local path to the matching private key |
Google Cloud — set backend: gcp and fill in the gcp.* fields:
| Field | What to set it to |
|---|---|
gcp.project |
GCP project that owns the VMs, network and images |
gcp.region / gcp.zone |
Where subnetworks/NAT and VMs are created |
gcp.credentials_file |
Service-account key JSON (omit to use Application Default Credentials) |
gcp.ssh_key_path |
Local private key Ansible connects with |
gcp.ssh_public_key_path |
Public key injected into VM metadata (defaults to <ssh_key_path>.pub) |
gcp.image_bucket |
Cloud Storage bucket used to stage images during upload |
gcp.machine_type_map |
Maps OpenStack flavor names (used in specs) to GCP machine types |
See How the GCP backend maps a topology below.
Every other section ships with working defaults — see the inline comments in config/example_config.yaml. You'd typically only revisit them when:
management— the default management subnet (10.0.1.0/24) collides with something on your cloud, or you want a larger flavor / different base image for the jumpbox.compilation.images_dir— you want compiled.qcow2images written somewhere outside the repo (e.g. a larger disk).registry.registry_dir/playbooks.playbooks_dir— you're maintaining a fork with a custom registry or playbook layout.
- Offline registry (src/registry/offline_registry.yaml) — VM images built ahead of time. Each entry declares a parent image and a list of playbooks layered on top. Plays here are network-independent: they install and configure software but never start anything that needs awareness of the live host (IP, user, peers).
compilewalks this graph to produce.qcow2files. - Online registry (src/registry/online_registry.yaml) — runtime VM types that reference an offline image plus playbooks that run after boot, once the VM exists in its deployed topology. This is where services are started and host-specific state (credentials, shells, IP-aware config) is applied. Topology specs refer to these via
vm_type. - Playbook registry (src/registry/playbook_registry.yaml) — named Ansible plays available to both registries.
- Environment spec — a JSON file under environments/ describing networks, subnets, hosts, and the VM type for each host. Examples live in environments/non-generated/ and environments/generated/.
All commands accept --config PATH (default config/config.yaml) and -v/--verbose. Run mhbench --help or mhbench <command> --help to see every option.
Builds offline-registry images in dependency order.
mhbench compile ubuntu_base webserver # specific images (with ancestors)
mhbench compile --all # everything in the registry
mhbench compile --all --force # force rebuildmhbench deploy environments/non-generated/dumbbell.json \
--project-name my-experiment \
--c2c-url http://10.0.0.1:8888Validate a spec without touching the cloud:
mhbench deploy environments/non-generated/dumbbell.json --validate-onlyFlags:
--validate-only— parse and validate the spec, then exit--project-name— prefix applied to every OpenStack resource name (use a unique value per experiment)--c2c-url— override the C2C server URL from config
mhbench provision environments/non-generated/dumbbell.json \
--project-name my-experiment \
--output-file mgmt.json--output-file writes {"mgmt_ip": "..."} with the management host's floating IP.
mhbench configure environments/non-generated/dumbbell.json \
--project-name my-experiment \
--mgmt-ip 1.2.3.4mhbench teardown environments/non-generated/dumbbell.json \
--project-name my-experiment --yesThe --project-name must match the value used at deploy time, or resources will not be matched for deletion.
# 1. Build images once
mhbench compile --all
# 2. Deploy an environment
mhbench deploy environments/non-generated/chain.json --project-name exp1
# 3. Run experiments against it...
# 4. Tear it down
mhbench teardown environments/non-generated/chain.json --project-name exp1 --yesv3_MHBench/
├── cli.py # Click entry point (mhbench)
├── config/config.yaml # cloud + path configuration
├── environments/ # topology JSON specs
└── src/
├── compilation/ # offline image compiler
├── deployment/ # cloud orchestrator
│ └── backends/ # provider backends (openstack, gcp) behind one interface
├── playbooks/ # Ansible plays
├── registry/ # offline/online/playbook YAML registries
└── abstractions/ # shared data models
MHBench provisions through a single CloudBackend interface, chosen by the
backend: config key. The OpenStack path is unchanged from earlier versions;
the Google Cloud path implements the same interface with GCP primitives. The
CLI, Ansible configuration, topology specs, and registries are identical across
backends — only config.yaml changes.
| MHBench / OpenStack concept | Google Cloud resource |
|---|---|
| router + all networks | one custom-mode VPC network (<project>-vpc) |
| subnet | a regional subnetwork with the subnet's CIDR |
| security group + rules | VPC firewall rules targeted by network tag |
| internet egress for private VMs | a Cloud Router + Cloud NAT |
| management host floating IP | the management instance's external IP |
OpenStack flavor (e.g. m1.small) |
GCP machine type (via gcp.machine_type_map) |
compiled image (e.g. ubuntu_base) |
GCP image ubuntu-base (RFC1035-sanitized; override with gcp.image_map) |
Every resource is prefixed with --project-name exactly as on OpenStack, so
concurrent experiments never collide and teardown matches purely by name.
Quick start. Run the one-time project setup with
scripts/gcp_bootstrap.sh, which creates the service
account, roles, key, and staging bucket idempotently and prints the gcp:
config block to paste:
PROJECT_ID=my-mhbench-proj BILLING_ACCOUNT=XXXXXX-XXXXXX-XXXXXX \
CREATE_PROJECT=1 ./scripts/gcp_bootstrap.shThen compile/upload the ubuntu_base image and deploy the tiny
Ubuntu-only smoke topology
environments/non-generated/gcp_smoke.json:
uv sync --extra gcp
mhbench compile ubuntu_base && mhbench upload ubuntu_base
mhbench deploy environments/non-generated/gcp_smoke.json --project-name gcpsmoke -v
mhbench teardown environments/non-generated/gcp_smoke.json --project-name gcpsmoke --yesNotes and current limitations:
- Images. Hosts boot from the compiled images, so upload them first with
mhbench upload. On GCPuploadconverts the qcow2 to a raw disk, stages it ingcp.image_bucket, and registers it as a GCP image (equivalent togcloud compute images importwithout the Cloud Build step). This needsqemu-imglocally and thegoogle-cloud-storagepackage (included in thegcpextra). Alternatively import images out of band and point at them withgcp.image_map. - SSH keys. GCP delivers keys via instance metadata, but the compiled images
carry no google-guest-agent, and cloud-init's GCE datasource only keys the
default
ubuntuuser while MHBench connects asroot. The backend therefore also passes a cloud-inituser-datathat writes the public key into root's (and ubuntu's)authorized_keys. Images only need cloud-init, which they already have; no guest agent is required. - DNS. Per-subnet
dns_serversin a spec are ignored on GCP; instances use GCP's internal resolver. - Guest hostname. Set to
<host>.mhbench.internal, keepinghostname -sequal to the spec host name (as on OpenStack).
Config file not found: config/config.yaml— copyconfig/example_config.yamltoconfig/config.yaml(see Configuration).- Authentication / endpoint errors from OpenStack — confirm
openstack.cloudmatches an entry in yourclouds.yamland that you can runopenstack server listagainst the same cloud. google-cloud-computeimport error — install the GCP extra:uv sync --extra gcp.- GCP auth / permission errors — confirm
gcloud auth application-default login(orgcp.credentials_file) works and the account has Compute Admin ongcp.project. - Teardown leaves resources behind —
--project-namemust exactly match the value used at deploy time; resources are matched by that prefix. - Ansible failures during
deploy/configure— re-run with-vfor debug logs, and verify the management host's floating IP is reachable on TCP/22 from your workstation.
Please open an issue on the project's issue tracker for bug reports and feature requests. Include the command you ran, the relevant log output (with -v), and the topology spec when applicable.