Reference · reference

Lab file: lab and segment

14 min read · 2026-09-02 · vmlab 0.9

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.

vmlab.wclwcl
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.

wcl
lab "<name>" {
  gui          = false
  agent_update = true
  segment "…" { … }
  vm "…" { … }
  container "…" { … }
  record { … }
  sinkhole { … }
  on "…" { … }
}
FieldTypeDefaultMeaning
nameutf8 (label)requiredLab name, a DNS label of at most 63 characters; the inline block label.
guiboolunsetDefault for all VMs: open a VNC viewer on up. A VM's own gui overrides it.
agent_updatebooltrueDefault 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 {}childrennoneVirtual L2 network segments in this lab.
vm {}childrennoneThe VMs in this lab.
container {}childrennoneOCI containers in this lab, each run in a micro-VM.
on {}childrennoneLifecycle event handlers. A handler failure is logged, never fatal.
record {}childrennoneLab-wide static DNS entries; wildcards allowed.
sinkhole {}childrennoneLab-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.

examples/mixed-lab/vmlab.wclwcl
# 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.

wcl
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 { … }
}
FieldTypeDefaultMeaning
nameutf8 (label)requiredSegment name, a DNS label, unique per lab; the inline block label.
subnetutf8autoIPv4 CIDR. Auto-allocated as a /24 from the host pool if omitted.
globalboolfalseOwned by the supervisor and shared across labs.
dhcpbooltrueEnable DHCP on this segment.
natboolfalseEnable NAT internet egress for this segment.
mtui649000 or 1500Link 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_tolist<utf8>noneLab-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 {}childnoneDNS service override: hand out another server, or opt out.
connect {}childnoneCross-host segment peer over TCP, authenticated by the PSK from host config.
route {}childrennoneGuest routes pushed via DHCP option 121.
record {}childrennoneStatic DNS entries for this segment; wildcards allowed.
forward {}childrennoneHost-to-guest port forwards.
block {}childrennoneL3 block rules at the switch.
redirect {}childrennoneL3 DNAT redirect rules.
sinkhole {}childrennoneDNS 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:

examples/ad-lab/vmlab.wclwcl
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.

wcl
dns {
  server  = "10.50.0.10"
  enabled = true
}
FieldTypeDefaultMeaning
serverutf8the daemonIPv4 address of the DNS server to hand out via DHCP instead of the daemon.
enabledbooltrueHand 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.

wcl
connect { host = "helios:13947" }
FieldTypeDefaultMeaning
hostutf8requiredRemote 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.

examples/peer-a/vmlab.wclwcl
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.

wcl
route { dest = "10.60.0.0/24" via = "10.50.0.254" }
FieldTypeDefaultMeaning
destutf8requiredDestination CIDR, for example 10.60.0.0/24.
viautf8requiredGateway 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.

wcl
record { name = "*.internal" ip = "10.50.0.10" }
FieldTypeDefaultMeaning
nameutf8requiredDNS name to resolve. Wildcards are allowed, for example *.internal.
iputf8requiredIPv4 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.

wcl
forward { host_port = 13389 to = "dc01:3389" proto = "tcp" }
FieldTypeDefaultMeaning
host_porti64requiredHost port to listen on, 1 to 65535. Unique across the lab.
toutf8requiredTarget as vm:port. The machine must be declared in this lab.
protoutf8tcpProtocol: 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.

examples/mixed-lab/vmlab.wclwcl
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.

wcl
block { cidr = "203.0.113.0/24" proto = "tcp" port = 443 }
FieldTypeDefaultMeaning
cidrutf8requiredIPv4 CIDR to drop traffic to and from.
protoutf8anyProtocol to scope the rule: tcp, udp or icmp.
porti64anyPort 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.

wcl
redirect { from = "10.50.0.10:443" to = "10.50.0.99:8443" proto = "tcp" }
FieldTypeDefaultMeaning
fromutf8requiredMatch destination as ip[:port].
toutf8requiredRewrite destination to ip[:port].
protoutf8anyProtocol 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.

wcl
sinkhole { pattern = "*.telemetry.example.com" mode = "nxdomain" }
FieldTypeDefaultMeaning
patternutf8requiredDNS name pattern to sink; wildcards allowed.
modeutf8nxdomainResponse: 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.