Configuration
netboot loads a YAML config (via anycfg,
so !include and deep-merge are available). By default it reads pixie.yaml from
./config; override with --config FILE or --baseconfig DIR.
The top-level keys map to the Pixie engine's collections:
# Values shared into every render context.
globals:
domain: example.com
# Machines to provision, keyed by an id (hostname / MAC / IP).
targets:
web01:
hostname: web01
ip: 10.0.0.10
mac: "52:54:00:12:34:56" # quoted: an all-digit MAC is a YAML number
image: debian
"aa:bb:cc:dd:ee:ff": # a MAC-keyed target
image: debian
dhcpzone: lan # no ip to match a zone by containment, so name it
# What a target boots. template_path is searched for that image's templates.
images:
debian:
template_path: [debian] # relative: searched inside the template root
globals:
kernel: vmlinuz
# The network a target lives on, plus its DHCP backend(s).
dhcpzones:
lan:
network: 10.0.0.0/24
gateway: 10.0.0.1
nameservers: [10.0.0.53]
search: [example.com]
dhcpservers:
# The scheme selects the backend; the query says how to reach it.
# See "DHCP servers and options" below.
- dnsmasq:///?hostsfile=/etc/dnsmasq.d/netboot/hosts/&optsfile=/etc/dnsmasq.d/netboot/opts/
# Where boot artifacts are fetched / served from.
repos:
mirror:
address: mirror.example.com
services:
http: http://mirror.example.com/debian
local: /srv/mirror/debian
DHCP servers and options
Each entry under a zone's dhcpservers is a URI, or a mapping of uri: and
that server's secrets. Its scheme picks the backend, and its query
string says only how to reach the server: that backend's connection settings,
nothing else. What the server tells a client
comes from the zone, the image and the target. The backends are
dhcpctl's, and each netboot extra below
installs the matching dhcpctl one.
| Scheme | Server | How it applies a reservation | Needs |
|---|---|---|---|
dnsmasq:// |
dnsmasq | writes --dhcp-hostsfile/--dhcp-optsfile entries, local or over sftp:// |
nothing locally; netboot[ssh] for remote paths |
kea://, keas:// |
ISC Kea | reservation-add / reservation-del via the control agent |
netboot[kea] |
dhcpd:// |
ISC dhcpd | an OMAPI host object | netboot[dhcpd] |
windhcp:// |
Windows DHCP | its PowerShell cmdlets or netsh, locally, over ssh or WinRM |
netboot[ssh] over ssh, netboot[winrm] over WinRM |
Credentials
A server's credentials are its own, given in its entry: Windows DHCP and Kea
take user and password, dhcpd keyname and omapi_key (a keyfile= in
the URI outranks omapi_key). A value in the entry overrides a user or
password written in the URI, so one URI can serve several logins. A value is written in the config, or produced when the file is loaded:
{{ env.NAME }} reads an environment variable, and anything else the config
loader offers works the same way. netboot reads no credential from the
environment by itself.
dhcpservers:
- uri: windhcp://dhcp01/?transport=winrm
user: svc-netboot
password: "{{ env.DHCP01_PASSWORD }}"
- uri: dhcpd://dhcp02/?keyname=omapi_key
omapi_key: "{{ env.DHCP02_OMAPI_KEY }}"
- kea://kea01:8000/ # no credentials
An unknown key is a configuration error, and it never shows a value.
Each has one thing that is easy to get wrong:
- dnsmasq re-reads
--dhcp-hostsfile/--dhcp-optsfileonly on SIGHUP, and never re-reads its main config.--dhcp-hostsdir/--dhcp-optsdirread a new file by themselves but only ever add, so a directory arms with noreload=while completing a target SIGHUPs dnsmasq (pidfile=, default/run/dnsmasq/dnsmasq.pid, orreload=for a remote host); a plain file needsreload=every time. A target served options needsoptsfile=too. - Kea needs the
host_cmdshook loaded and a writable hosts backend, or?target=memoryon the URI to keep reservations in Kea's running configuration (Kea 2.7.4 or later; they are gone when Kea restarts); a Kea with neither loads the hook and still refuses. - dhcpd writes a host added over OMAPI to its leases file, which is what keeps it across a restart: a dhcpd started with a fresh leases file has lost it.
- Windows validates some option values as it stores them, whichever method
you use: a name server that does not answer is refused rather than stored. An
arm that fails part-way leaves the reservation with the options applied so far
— the next successful arm fixes it, and
pixie completeremoves it. - Windows needs an account with DHCP-administrator rights, and — for the
default
method=powershell— theDhcpServermodule on the host the shell runs on. Where that module is missing (Server Core without the RSAT feature, or an older release)method=netshdrivesnetsh dhcp server ...instead. The two choices are independent:transport=is how netboot gets a shell (local,sshorwinrm),method=is what it runs there.transport=localruns PowerShell on the machine netboot is running on, which withserver=is the ordinary RSAT shape — run it here, act on that server — and needs no sshd, no WinRM and no credential. An empty host means local, because there is no host to reach:windhcp:///?server=dhcp01. A named host always uses a transport, even if the name happens to be this machine.
netsh is still invoked from PowerShell, with each argument an element of a
JSON array — so no value is ever spliced into a command line, which is the same
guarantee the cmdlet path gives. Extra commands (a policy, say) go through
a backend subclass's extras(); see
Extra commands and conditions,
which is also where the iPXE chainload recipe lives.
netsh cannot be trusted to report failure. Given an option value it dislikes
— a name server that does not answer — it keeps the rest, prints "not a valid DNS
Server", prints "Command completed successfully" and exits 0 (measured on
Windows Server 2025). So netboot reads the state back afterwards with
netsh ... dump, the one netsh output that is command syntax rather than
localised prose, and fails naming any option that did not land or lost values.
The cmdlet method needs no such check: Windows raises there by itself. A few behaviours are
netsh's own rather than netboot's: an option's data type has to be declared
(netboot maps the modelled options, and sends anything else as STRING), a
multi-valued option is passed as one argument per value, and the reservation is
created as BOTH (DHCP and BOOTP) to match what the cmdlets do by default.
When provisioning finishes (dhcp_complete:)
A target that is still handed a boot file after its install finishes boots
the installer again. pixie complete removes the reservation, which stops that
— and also throws away the fixed address the rest of the estate may depend on.
dhcp_complete: chooses what the entry becomes instead:
images:
debian:
dhcp_options: {boot-file-name: undionly.kpxe}
dhcp_complete:
keep: true # default false = remove it
options: {boot-file-name: sanboot.ipxe} # omit for "no boot options"
| Config | Result |
|---|---|
| nothing (the default) | the reservation is removed, exactly as before |
keep: true |
the address and its options stay; the boot options go, so the client's firmware falls through to local disk |
keep: true with options |
those options are served instead — an iPXE script ending in sanboot hands control to the disk, which works on firmware with no local-disk fallback of its own |
Only the boot file (boot-file-name), next-server, the server name, and
options 66 and 67 (tftp-server-name, bootfile-name) are replaced or
removed. router, domain-name-servers, subnet-mask and the rest are what make
a kept reservation worth keeping, and none of them re-kicks a machine. options
replaces the boot options rather than merging with them — a merge would leave
the installer's in place, which is the whole problem.
It can be declared on a zone, an image or a target, and the most specific wins.
A kept entry also loses its dhcp_when membership, or the condition would go
on serving the installer to a finished machine.
Every shipped backend can keep an entry: windhcp:// sets and removes option
values on the reservation, dhcpd:// supersedes the host in one call, kea://
upserts the reservation (so a failure leaves the old one rather than none), and
dnsmasq:// rewrites that target's region in the files it already owns. A plugin
backend that cannot says so by not implementing keep_target, and the failure is
logged per server like any other.
Conditional options (dhcp_when:)
Some boot decisions depend on who is asking. The standard case is chainloading iPXE: a PXE ROM must be handed the iPXE binary, and iPXE itself must then be handed a script — serve the binary to both and iPXE loads iPXE forever. No per-target option can express that, because both requests come from the same target; the server has to answer differently depending on the client.
dhcp_when: is a mapping keyed by name, and the name is what each DHCP server
calls its own construct — a Windows policy, a dhcpd group, a Kea client class:
images:
debian:
dhcp_options:
boot-file-name: undionly.kpxe # what a PXE ROM gets
dhcp_when:
ipxe: # -> policy / group / class "ipxe"
match: {user-class: iPXE} # what iPXE sends
options: {boot-file-name: boot.ipxe}
It can be declared on a zone, an image or a target, and layers by name, so a
target redeclaring ipxe replaces the image's instead of adding a second
condition that also matches. Keying by name is also what makes two targets share
one policy rather than create two.
match accepts user-class (option 77) and vendor-class (option 60) — the tests
every backend can express. options takes the same option names as
dhcp_options.
| Backend | Construct | netboot creates it | If it cannot |
|---|---|---|---|
windhcp:// |
a scope-level policy per target (plus the user/vendor class it references), and a base policy for the same target | yes, through the cmdlets — netsh has no policy support at all, so this part ignores method= |
prints the Add-DhcpServerv4Class / Add-DhcpServerv4Policy lines to run once as an admin |
dhcpd:// |
an inline if in the host's statements (default), or a named group with conditions=group |
yes — statements always, groups over OMAPI | prints the group { } block for dhcpd.conf |
kea:// |
a client class of the target's own (<name>-<mac>), plus one serving what the condition overrides when it does not match |
only with the class_cmds hook (class-add) |
prints the client-classes JSON for kea-dhcp4.conf |
dnsmasq:// |
— | no | refuses, and prints the dhcp-match/dhcp-option pair for dnsmasq.conf |
Windows: why a condition means two policies
A Windows DHCP reservation's options outrank every policy — the order is reservation, scope policy, server policy, scope, server, applied per option. A client that takes option 67 from its reservation ignores option 67 from any policy. So netboot keeps an option a condition overrides off the reservation and serves it from policies instead: one matching the condition's test, and a base one carrying what the reservation would have said. Options no condition touches stay on the reservation, where that precedence is a feature.
The two policies are matched on the target's MAC as well, because a policy is scope-level: without the MAC it would answer for every client in the scope. Processing order is set explicitly, conditional before base, or the base policy would answer an iPXE client and the chainload would never happen. Completing the target removes both, whether the reservation is removed or kept.
conditions=shared is the other shape: one policy per condition name, matched on
the test alone, for every client in the scope. netboot still keeps the option off
the reservation, but the base value is then not netboot's to serve — a
scope-wide base policy would answer for every client, so it comes from a scope
option or a policy of your own. A shared policy that exists is used as it is, and
is never removed. Use it where netboot cannot create policies at all (no
PowerShell module, so method=netsh): an administrator creates one policy per
condition once, rather than one per machine forever, and netboot trusts it to
be there.
windhcp://dhcp01/?conditions=shared # an admin pre-creates the policies
windhcp://dhcp01/ # netboot manages a pair per target
Creating a reservation already needs DHCP-administrator rights, so in the default
target mode netboot creating the policies costs no privilege it did not already
have.
A backend that cannot apply a condition fails rather than arming without it. A target that silently misses its chainload boots the installer again, which is worse than an error. That is safe because arming is best effort per server: the zone's other servers are still armed, the failure is logged, and the run fails only if no server could be armed. A zone whose only server refuses therefore fails, which is the correct reading of "this config cannot be delivered here".
When netboot cannot create the construct — no class_cmds hook, no DHCP-admin
rights, a Windows host without the PowerShell module — the error carries the exact
commands or config to apply. Once an administrator has run them, netboot finds the
construct by name and needs no privileges of its own.
pixie dhcp-config prints that configuration for every server a config names,
without contacting anything, so it can be read, reviewed and pasted into a change
ticket. --apply runs it, and --as <user> prompts for that account's password
— the only place netboot prompts, because initiate runs unattended from
hooks and would otherwise hang a pipeline. See the CLI reference.
Options
Options are set in dhcp_options on an image or a target (and the zone
supplies its own, below), named as pydhcp
names them (router, dns,
domain-name, domain-search, ntp-servers, subnet-mask, bootfile-name,
... in any case, with - or _), by the older names netboot always took
(domain-name-servers, boot-file-name, tftp-server-name, host-name,
lease-time), by a name the config declares (below), or as option-<n>. Each
value is checked against the option's type before any server is contacted —
router: gateway is a configuration error, not a reservation Kea refuses. Give
a list option a YAML list, and an option with no type of its own
(option-224, unless declared) its octets as hex text (0a0b, 0a:0b) or
{hex: 0a0b}. Backend-native text comes from a backend subclass (see the
extending guide).
next-server and boot-file-name are not options but the BOOTP header
fields siaddr and file, as file, sname and siaddr are. They are
still written in dhcp_options, and each backend sets them as its server
spells the fields (Kea's boot-file-name, dhcpd's filename, dnsmasq's
dhcp-boot; Windows, which has no such fields per reservation, gets the boot
file as option 67). bootfile-name is option 67 itself, sent as an option:
some ROMs read that, some the header field; set both if unsure.
Declaring option names (dhcp.options)
The top-level dhcp.options section names options a config uses that pydhcp
has no name or type for, and aliases of its own:
dhcp:
options:
site-tag: {code: 224, type: String} # an option of your own: its code, and the pydhcp type of its value
gw: router # an alias of a name...
pxe-file: 67 # ...or of a code
tag: site-tag # ...or of another declared name
type is a pydhcp codec name (String, U8, U16, U32,
IPv4AddressOption, Boolean, ...) or List[...] of one; without a type,
pydhcp's own is used, and an option pydhcp has no type for takes only bytes.
Every place that names an option reads these: dhcp_options, dhcp_when
and dhcp_complete.
Options are merged in this order, later winning:
- what the zone already knows (
gateway→router,nameservers,domain…); images.<id>.dhcp_options;targets.<id>.dhcp_options;- any hook on
PixieEvent.BuildDhcpOptions(ahooks:entry), which gets the options, the target and the server, and returns the options to serve.
subnet_id (Kea) and scope (Windows) belong to the zone.
dhcpzones:
lan:
network: 10.0.0.0/24
subnet_id: 44 # kea; windhcp uses `scope`
dhcpservers:
- kea://10.0.0.1:8000/
images:
debian:
dhcp_options: {boot-file-name: pxelinux.0, next-server: 10.0.0.2}
Where templates are looked for
templates is the list of template roots. A relative root is resolved
against the config file's directory, the same base !include uses, and with
no templates: at all the root is templates beside the config file
(./config/templates for the default ./config/pixie.yaml). An absolute path or
a URI is used as given. The working directory is never a root, so a config means
the same thing whichever directory pixie runs from:
templates: [../templates, /srv/netboot/shared] # beside ./config, then absolute
An image's or target's template_path extends the search path; it does not
replace the roots, and the roots are always searched last:
- a relative entry is resolved inside each root, so
template_path: [debian]means<root>/debian; - an absolute path or a URI (
/srv/tftp/tpl,http://boot/tpl) is used as given.
A repository's src/path on an image address its artifacts (kernel,
initrd), never its templates.
Undefined template variables
templates_undefined is a top-level config key choosing what happens when a
template names a variable that has no value. It means the same thing in both
engines — before it existed, the shell engine raised while Jinja quietly
rendered an empty string, so the behaviour depended on a file's suffix.
| Value | Behaviour |
|---|---|
strict (default) |
Raise, naming the variable. A typo fails the run instead of shipping a boot artifact with a blank where a kernel path belongs. |
lenient |
Render an empty string. The shell and mako engines also log a warning naming the variable; Jinja, Liquid, Handlebars and Mustache do not. |
debug |
Leave the placeholder ({{ name }} / %{NAME}) in the output, so the gap is visible in the rendered file. |
templates_undefined: lenient # keep the pre-0.2 Jinja behaviour
Interpolation, includes and trust
Config values may use {{ ... }} interpolation, evaluated while the config
loads: greeting: "{{ globals.site }}-boot" resolves against the merged
document. It renders in tplx's sandbox, and the loader runs with anycfg's
untrusted policy (no command sources, no template files), so a config document
cannot execute a command or reach out of the template language -- netboot never documented those capabilities and a config is
often assembled by !include from inventory exports rather than written by
hand.
!include can still read any file the process can read. To confine it, set
ANYCFG_CONFINE_TO to the directories includes may come from.
An empty or comment-only config loads as an empty mapping. A top level that is not a mapping is an error naming the file.
How values resolve
- Targets normalise
ip/mac/hostnameat load time. If a field is missing, netboot fills it in where it can (a MAC-shaped id becomes themac; an IP-shaped id becomes theip; a hostname is resolved to an IP via DNS). - Zones derive
networkfrom a CIDRgateway, and coercenameservers/searchto lists.dhcpserversURIs are constructed into backends by scheme. - globals are layered: engine globals, then per-image / per-zone / per-target
globals, are merged into the render context (later wins).
Templates
templates is a list of search paths (local or URI). For each render netboot looks
for a file named by the target's MAC (aa-bb-cc-...), hostname, or IP — falling
back to the bare template name.
An include inside a template ({% include %}, <%include/>, {{> partial}}, ...)
is found the same way, per target, and rendered by the engine its own suffix
names: a Jinja page can include a .shtpl fragment, which the shell engine
renders with the same context.
The suffix picks the engine, and the stem names the artifact. A .j2 /
.jinja / .jinja2 file is rendered with Jinja2; a .shtpl file with the
%-delimited shell engine, whose %{UPPER_SNAKE} placeholders come from the
flattened context. Seven more engines ship for template trees that already exist
in another language. They are tplx's, which
netboot renders through:
| Engine | Suffixes | Needs | templates_undefined |
|---|---|---|---|
tplx.JinjaTemplate |
.j2 .jinja .jinja2 |
— | all three |
tplx.MakoTemplate |
.mako |
netboot[mako] |
strict, lenient; debug behaves as lenient |
tplx.LiquidTemplate |
.liquid |
netboot[liquid] |
all three (debug names the variable in the output) |
tplx.HandlebarsTemplate |
.hbs .handlebars |
netboot[handlebars] |
always lenient — the library cannot fail |
tplx.MustacheTemplate |
.mustache |
netboot[mustache] |
always lenient (logs under strict) |
tplx.ERBTemplate |
.erb |
ruby on PATH (or TPLX_RUBY) |
n/a — missing data is Ruby's nil |
tplx.EPPTemplate |
.epp |
puppet on PATH (or TPLX_PUPPET) |
n/a — missing data is Puppet's undef |
tplx.ShellTemplate |
.shtpl |
— | all three |
tplx.CopyTemplate |
anything else | — | n/a — nothing is substituted |
Every optional engine is registered whether or not its library is installed,
so a .liquid file fails naming the extra to install (tplx[liquid], which
netboot[liquid] installs) instead of being quietly copied. None of them is imported until a file claims it, so an install
that uses none pays nothing.
Jinja and mako evaluate Python, so a template reaches into ctx directly. The
data languages (liquid, handlebars, mustache) and the external ones (ERB, EPP)
get a mapping view of the same context instead: {{ ctx.target.hostname }}
and {{ target.hostname }} both resolve, and an address, a MAC or a path arrives
as the string a template would have printed. ERB and EPP receive JSON in which
_-prefixed names are left out, except _id. ERB additionally sets each top-level
name as an instance variable (@target), and EPP as a parameter ($target).
ERB and EPP run the real ruby and puppet as a subprocess, with the context
marshalled to JSON — which is the point: an existing .erb renders the way its
author tested it, rather than the way a reimplementation guesses. Set TPLX_RUBY
or TPLX_PUPPET if the program is installed but not on PATH. Because a template is also found by its stem, the file behind
an artifact called boot.cfg is boot.cfg.shtpl and ctx.render("boot.cfg")
still finds it.
Anything else is copied as is — a static grub.cfg, an EFI binary or a
license file is a template that needs no engine. The copy engine works in
bytes: the loader never decodes the file, so the result is byte-exact
whatever it holds (CRLF line endings, latin-1 text, something that is not text at
all) and ctx.render() returns bytes for it rather than str.
Before 0.3.0 the shell engine claimed every suffix, so a file that does carry
%{NAME} placeholders now ships them unrendered: rename it to *.shtpl once.
That is the one mistake a copy can hide, so a copy that still finds placeholders
logs a warning naming the rename.
Only the braced form is substituted, so a bare %word is left alone — a kickstart
file keeps its %packages, %pre, %post and %end sections, and a script keeps
date +%Y. Write %% for a literal % next to a brace, %{NAME} to substitute.
An unknown %{NAME} follows templates_undefined (above).
%{NAME:-fallback} renders fallback when NAME is unset or empty, and
%{NAME-fallback} only when it is unset — the two POSIX forms, so a template can
carry its own default instead of requiring the variable. One layer of '/"
quotes is stripped (%{NAME:-'a default'}), the fallback is literal text (no
nested placeholders, and no } inside it), and a placeholder that has a default
never fails whatever templates_undefined says. The other POSIX forms (:=,
:?, :+) are not implemented and stay literal text.
The engine is configured by subclassing, not by config, for a tree that uses another convention:
from tplx import ShellTemplate
class DollarTemplate(ShellTemplate):
EXT = (".tpl", ".cfg") # a single string is fine; `None` claims any suffix
DELIMITER = "$"
PATTERN = "all" # 'braced' (default), 'unbraced', or 'all'
Register the class with tplx.register_engine (see
Extending). Engines are consulted in
PRIORITY order, highest first, so a registered engine is asked before the
catch-all CopyTemplate whatever order they were registered in. A file that is
not valid UTF-8 raises tplx.NoEngineError when the engine that claims it needs
text; set BINARY = True on an engine to be handed the raw bytes instead, as
CopyTemplate does.