Reference · reference
Lab file: lab and segment
This chapter is the field-by-field reference for the top of a lab file: the lab {} block, its segment {} children, and the network rule blocks a segment or a lab carries. The lab file guide explains how the pieces fit together; the networking guide explains what the segment fields do on the wire. The machine blocks are in vm and its children, container and its children and template and source.
How the file is found
A lab is a directory containing a file named vmlab.wcl. Every lab-scoped verb walks up from the current directory, the way git finds its repository, and uses the first directory that holds one as the lab root. Relative paths in the file resolve against that root. If no ancestor holds a vmlab.wcl, the verb fails with an error naming the directory it started from.
The first line of the file must be import <vmlab.wcl>, which loads the schema every block below is checked against. A file without the import is rejected before anything else is read. A lab file declares exactly one lab {} block; a file with none, or with two, is rejected. It may also declare template {} blocks beside the lab, which template and source describes.
import <vmlab.wcl>
lab "demo" {
segment "lan" { nat = true }
vm "box" {
template = "x86_64/ubuntu-24.04"
nic { segment = "lan" }
}
}
Two kinds of rule apply to what you write. The schema rejects unknown fields, wrong types and missing required child blocks as the file is parsed. vmlab validate then runs the semantic rules listed under each entry below, reports every problem it finds in one pass, and every other verb runs the same checks before it touches a machine. See vmlab validate.
Value types
utf8 is a quoted string. bool is true or false. i64 is an integer. ByteSize is an integer with a unit, such as 8GiB or 512MiB. Duration is an integer with a unit, such as 10s. list<utf8> is a bracketed list of strings, such as ["dc01", "dc02"].
lab {}
The one lab a file declares. The inline label is the lab's name, which becomes part of every guest hostname and is unique on the host.
lab "<name>" {
gui = false
agent_update = true
segment "…" { … }
vm "…" { … }
container "…" { … }
record { … }
sinkhole { … }
on "…" { … }
}
| Field | Type | Default | Meaning |
|---|---|---|---|
| name | utf8 (label) | required | Lab name, a DNS label of at most 63 characters; the inline block label. |
| gui | bool | unset | Default for all VMs: open a VNC viewer on up. A VM's own gui overrides it. |
| agent_update | bool | true | Default for all VMs: refresh an out-of-date guest agent on up and mark the VM diverged. A VM's own agent_update overrides it. See vmlab up. |
| segment {} | children | none | Virtual L2 network segments in this lab. |
| vm {} | children | none | The VMs in this lab. |
| container {} | children | none | OCI containers in this lab, each run in a micro-VM. |
| on {} | children | none | Lifecycle event handlers. A handler failure is logged, never fatal. |
| record {} | children | none | Lab-wide static DNS entries; wildcards allowed. |
| sinkhole {} | children | none | Lab-wide DNS sinkholes. |
Validation checks the name is a DNS label: letters, digits and hyphens only, not starting or ending with a hyphen, at most 63 characters. VM and container names share one namespace inside the lab, so a vm and a container with the same name is an error. A lab with no machines is valid.
# A small mixed Windows/Linux lab built on the two example templates
# (examples/templates/windows-server-2025 and examples/templates/
# ubuntu-24.04 — build those first). Demonstrates a NAT'd segment, a
# static IP, boot ordering, an SMB share, a host port-forward, an OCI
# container on the same segment (§18), and a provision script driving
# both guests.
#
# vmlab up
# curl http://localhost:18080 # nginx on nix01, via the forward
# curl http://localhost:18081 # nginx in the "web" container
# vmlab down
import <vmlab.wcl>
lab "mixed-lab" {
gui = true # open a VNC viewer for each guest on `vmlab up`
segment "lan" {
subnet = "10.70.0.0/24"
nat = true # apt needs egress
forward {
host_port = 18080
to = "nix01:80"
} # host → nginx
}
vm "winsrv" {
template = "x86_64/windows-server-2025"
cpus = 4
memory = 8GiB
nic {
segment = "lan"
ip = "10.70.0.10"
} # DHCP reservation
share {
host = "./shared"
guest = "S:"
} # auto-mounted when ready
# Runs once winsrv is ready; nix01 depends on it, so it waits.
provision "scripts/setup.ws" { }
}
vm "nix01" {
template = "x86_64/ubuntu-24.04"
memory = 2GiB
depends_on = ["winsrv"]
nic {
segment = "lan"
} # dynamic lease
}
# An OCI container on the same segment: pulled like a docker image,
# run in a micro-VM, resolvable as web.mixed-lab.<suffix> from the VMs.
container "web" {
image = "nginx:1.27"
# The micro-VM's size comes from the profile; declare `cpus`/`memory`
# here to override it. One of the two must supply them.
profile = "container"
depends_on = ["nix01"]
nic {
segment = "lan"
}
port {
host = 18081
container = 80
} # host → container nginx
healthcheck {
command = ["curl", "-fsS", "http://localhost/"]
interval = 5s
}
}
on "vm.crashed" {
run = "scripts/on-crash.ws"
}
}
segment {}
One virtual L2 switch owned by the lab daemon, or by the supervisor when global = true. Machines attach to it with nic {} blocks. A segment with no fields gets an auto-allocated subnet, DHCP and DNS from the daemon, and no internet egress.
segment "<name>" {
subnet = "10.50.0.0/24"
global = false
dhcp = true
nat = false
mtu = 1500
routes_to = ["other"]
dns { … }
connect { … }
route { … }
record { … }
forward { … }
block { … }
redirect { … }
sinkhole { … }
}
| Field | Type | Default | Meaning |
|---|---|---|---|
| name | utf8 (label) | required | Segment name, a DNS label, unique per lab; the inline block label. |
| subnet | utf8 | auto | IPv4 CIDR. Auto-allocated as a /24 from the host pool if omitted. |
| global | bool | false | Owned by the supervisor and shared across labs. |
| dhcp | bool | true | Enable DHCP on this segment. |
| nat | bool | false | Enable NAT internet egress for this segment. |
| mtu | i64 | 9000 or 1500 | Link MTU, 576 to 65535. Default is jumbo (9000) on a nat segment, else 1500, including on a global segment, where the first lab to attach sets it for every lab sharing the segment. |
| routes_to | list<utf8> | none | Lab-local segments the daemon routes to and from. Opt-in per pair, never a default; a pair runs both ways whichever side declares it. Routed traffic keeps its source address, the leaving segment's rules apply, and each side offers the other's subnet in DHCP option 121. |
| dns {} | child | none | DNS service override: hand out another server, or opt out. |
| connect {} | child | none | Cross-host segment peer over TCP, authenticated by the PSK from host config. |
| route {} | children | none | Guest routes pushed via DHCP option 121. |
| record {} | children | none | Static DNS entries for this segment; wildcards allowed. |
| forward {} | children | none | Host-to-guest port forwards. |
| block {} | children | none | L3 block rules at the switch. |
| redirect {} | children | none | L3 DNAT redirect rules. |
| sinkhole {} | children | none | DNS sinkhole rules. |
The daemon claims the first usable address of the subnet as the segment gateway. DHCP, DNS, NAT and shared folders are all served there. The host pool the automatic /24 comes from is subnet_pool in the host configuration file.
Validation enforces these rules:
- The name is a DNS label and no other segment in the lab has it.
- subnet is a well-formed CIDR, and no two declared subnets overlap.
- mtu is between 576 and 65535.
- Every name in routes_to is a segment declared in this lab, other than the segment itself.
- Neither side of routes_to is a global segment: daemon routing is lab-local.
- A connect {} child requires global = true; on a lab-local segment it would be ignored, so it is refused.
- A segment with a machine gateway (a nic with gateway = true) cannot also set nat = true, and cannot be global.
segment "corp" {
subnet = "10.50.0.0/24"
// Hand out the DC as DNS instead of the daemon (AD owns DNS).
dns { server = "10.50.0.10" }
route { dest = "10.60.0.0/24" via = "10.50.0.254" }
}
dns {}
Overrides the DNS server a segment hands out over DHCP. Use it when a guest, such as a domain controller, owns name resolution for the segment. At most one per segment.
dns {
server = "10.50.0.10"
enabled = true
}
| Field | Type | Default | Meaning |
|---|---|---|---|
| server | utf8 | the daemon | IPv4 address of the DNS server to hand out via DHCP instead of the daemon. |
| enabled | bool | true | Hand out a DNS server at all. false suppresses the DHCP DNS option. |
server must parse as an IPv4 address. The daemon's own resolver keeps answering on the gateway address either way; the block only changes what guests are told to use.
connect {}
Bridges this segment to the same segment on another host. The two supervisors tunnel L2 frames over TCP, authenticated by the pre-shared key both hosts set in their host config. At most one per segment.
connect { host = "helios:13947" }
| Field | Type | Default | Meaning |
|---|---|---|---|
| host | utf8 | required | Remote supervisor as host[:port] to bridge this segment with. |
Validation requires the segment to be global = true and host to be non-empty. The port defaults to the remote's trunk_port, which is 13947 unless its host config changes it. Set psk in the host configuration file on both sides.
segment "wan" {
subnet = "10.99.0.0/24"
global = true
connect { host = "127.0.0.1:13948" } // side B's trunk_port
}
route {}
A static route pushed to every guest on the segment at lease time, as DHCP option 121. This is how a router VM becomes the path to another segment.
route { dest = "10.60.0.0/24" via = "10.50.0.254" }
| Field | Type | Default | Meaning |
|---|---|---|---|
| dest | utf8 | required | Destination CIDR, for example 10.60.0.0/24. |
| via | utf8 | required | Gateway IPv4 address the route points at. |
dest must parse as a CIDR and via as an IPv4 address. A guest with dhcp = false on its segment never receives the route.
record {}
A static DNS entry. Inside a segment {} it answers on that segment; inside lab {} it answers on every segment of the lab.
record { name = "*.internal" ip = "10.50.0.10" }
| Field | Type | Default | Meaning |
|---|---|---|---|
| name | utf8 | required | DNS name to resolve. Wildcards are allowed, for example *.internal. |
| ip | utf8 | required | IPv4 address the name resolves to. |
ip must parse as an IPv4 address. Records are only seen by guests using the segment's DNS; a segment whose dns {} hands out another server bypasses them.
forward {}
A host-to-guest port forward. The daemon listens on the host port and proxies into the segment, which is the path for RDP, SSH and web UIs from the host.
forward { host_port = 13389 to = "dc01:3389" proto = "tcp" }
| Field | Type | Default | Meaning |
|---|---|---|---|
| host_port | i64 | required | Host port to listen on, 1 to 65535. Unique across the lab. |
| to | utf8 | required | Target as vm:port. The machine must be declared in this lab. |
| proto | utf8 | tcp | Protocol: tcp, udp or both. |
Validation requires to to have the form name:port with a numeric port, the name to be a VM or container in this lab, and host_port to be unused by every other forward and every container port {} in the lab, since both compile into the same forward machinery.
A forward rides the segment's NAT engine, so the segment needs nat = true. Validation does not check this: on a segment without NAT the forward is not installed, and a forward.skipped event says port forwarding requires NAT/egress on the segment.
segment "lan" {
subnet = "10.70.0.0/24"
nat = true # apt needs egress
forward {
host_port = 18080
to = "nix01:80"
} # host → nginx
}
block {}
An L3 rule that drops traffic to or from a CIDR at the switch, answering with ICMP unreachable or TCP RST where it can so guests fail fast.
block { cidr = "203.0.113.0/24" proto = "tcp" port = 443 }
| Field | Type | Default | Meaning |
|---|---|---|---|
| cidr | utf8 | required | IPv4 CIDR to drop traffic to and from. |
| proto | utf8 | any | Protocol to scope the rule: tcp, udp or icmp. |
| port | i64 | any | Port to scope the rule, 1 to 65535. Requires proto. |
cidr must parse as a CIDR. Redirect rules are evaluated before block rules; within a layer the most specific match wins, and ties go to declaration order. Scripts can add and remove rules at runtime; see Guest automation with wscript.
redirect {}
An L3 DNAT rule. Traffic to one destination is rewritten to another, and the daemon keeps the connection state to rewrite the return path.
redirect { from = "10.50.0.10:443" to = "10.50.0.99:8443" proto = "tcp" }
| Field | Type | Default | Meaning |
|---|---|---|---|
| from | utf8 | required | Match destination as ip[:port]. |
| to | utf8 | required | Rewrite destination to ip[:port]. |
| proto | utf8 | any | Protocol to scope the rule: tcp or udp. |
Both addresses must parse as an IPv4 address with an optional numeric port. A rule without a port matches every port.
sinkhole {}
A DNS sinkhole. Names matching the pattern get NXDOMAIN, or resolve to 0.0.0.0 in zero mode. Inside a segment {} it applies there; inside lab {} it applies to every segment.
sinkhole { pattern = "*.telemetry.example.com" mode = "nxdomain" }
| Field | Type | Default | Meaning |
|---|---|---|---|
| pattern | utf8 | required | DNS name pattern to sink; wildcards allowed. |
| mode | utf8 | nxdomain | Response: nxdomain, or zero to resolve to 0.0.0.0. |
Validation rejects an empty pattern. Like records, a sinkhole is only effective for guests that use the segment's DNS.