Logo artwork by @greeddj — thank you!
Static DHCPv4 provisioning driven by MAC-address mappings.
The name combines MAC and DHCP ACK. macdack provides deterministic IP,
routing, DNS, NTP, MTU, domain, and PXE/iPXE boot configuration for known
clients, with first-class support for DHCP relay deployments.
It is implemented in Rust and includes classless routes, periodically refreshed CSV client data, health checks, and Prometheus metrics. Detailed requirements are in PLAN.md.
You need the Rust version specified in rust-toolchain.toml (rustup installs it
automatically) and just. All builds use
Cargo.lock.
Release validation and just check also require Python 3.11 or newer.
just build-release
target/release/macdack check-config --config examples/server.toml
target/release/macdack --config examples/server.tomlWithout arguments, macdack reads /etc/macdack/config.toml. Use --config
only for a different path. On Unix, SIGHUP validates the same TOML path and
replaces the current process in place, preserving its PID and reloading logging,
listeners, polling settings, saved state, and the remote CSV. Invalid TOML is
logged and the running process is retained.
Before starting the server, replace the example addresses, CSV URL, and state
path with your own values. On Linux, UDP/67 requires appropriate privileges. Use
the examples/macdack.service unit, which grants
CAP_NET_BIND_SERVICE and creates /var/lib/macdack. The binary is installed at
/usr/local/bin/macdack, and the configuration at /etc/macdack/config.toml.
For Podman, examples/macdack.container is an
equivalent Quadlet example. Place the file in /etc/containers/systemd/, then
run systemctl daemon-reload and
systemctl start macdack.service. Quadlet-generated services cannot be enabled
directly; the [Install] section makes the generator add the boot dependency.
Enable periodic registry checks with
systemctl enable --now podman-auto-update.timer. The Quadlet uses
AutoUpdate=registry, so Podman pulls a changed stable image and restarts the
generated service. Pull=newer also checks for a newer image whenever the
service starts. Registry authentication, when required, must be configured for
the root account running this system Quadlet.
The container uses host networking and a read-only root filesystem, drops every
capability, and restores only CAP_NET_BIND_SERVICE, which the non-root image
needs to bind DHCP port 67. The /etc/macdack host directory is mounted
read-only so atomic config-file replacement remains visible on SIGHUP, while a
bind-mounted host directory stores the cached CSV in /var/lib/macdack; create
that directory before starting the service. The U mount option makes it
writable by the image's non-root user and therefore changes its host ownership.
systemctl reload macdack.service sends SIGHUP to macdack.
The server accepts requests from any relay and looks clients up by the MAC in
chaddr. With a wildcard bind on Linux, the Server Identifier is derived from
the packet's local destination address. Other operating systems require an
explicit dhcp.server_identifier when using a wildcard bind. Always configure it
explicitly behind NAT: it must be an address reachable by clients, including for
direct lease renewal. The application is not itself a relay and does not serve
files over TFTP or HTTP.
Logging is configured in the [logging] section:
[logging]
level = "info"
format = "json" # json or text
disable_timestamp = false
color = true # applies to text format
dhcp_packet_debug = falselevel accepts standard tracing_subscriber::EnvFilter expressions, such as
debug or macdack=debug,tower_http=warn. Use format = "text" for readable
console output; json is generally more convenient for systemd and log
aggregators. Setting disable_timestamp = true omits the date and time. When
dhcp_packet_debug = true, received and sent packets are logged at DEBUG with
decoded fields and a tcpdump-like BOOTP/DHCP dump. The flag automatically
enables DEBUG events for the macdack::dhcp target even when the global level
is info or stricter. This is verbose and should normally be enabled only while
diagnosing DHCP traffic. XIDs use the tcpdump-compatible form 0x27e9542c, and
the architecture field is named arch.
The packet_dump field includes BOOTP addresses and flags, MAC, boot fields,
XID, DHCP options, parameter request names, relay suboptions, and classless
routes. JSON logging escapes its embedded newlines; text logging renders it as a
readable multiline value. Since macdack uses a UDP socket rather than a raw
packet socket, IP ID, fragmentation flags, and other IP-header fields are not
available. Packet dumps require no additional Linux capability.
The CSV has nine fields and does not require a header (a header is also
supported): location,hostname,domain,boot_type,mtu,mac,ip,prefix_length,gateway.
Blank lines and lines beginning with # are ignored.
# Datacenter clients
dc1,host.example,example.internal,uefi,1500,AA:BB:CC:DD:EE:FF,10.20.0.10,24,10.20.0.1
dc1,host.example,example.internal,bios,9000,AA:BB:CC:DD:EE:AA,10.20.0.11,24,10.20.0.1Duplicate MAC or IP values reject the entire update; duplicate hostnames are
allowed. location is included in logs and client metric labels so each
host's boot location is visible. Ethernet prefixes /1 through /30 use normal
subnet semantics; /31 is supported for point-to-point links with distinct client
and gateway addresses, while /32 is rejected. No lease database is maintained.
When reassigning addresses, the operator must account for leases that may still be
active for previous clients.
Domain and interface MTU are returned from each client row as DHCP options 15
and 26. MTU must be in the range 68..65535. [client_options].timezone defaults
to Etc/UTC and is returned as DHCP option 101 when requested by the client.
BIOS and UEFI clients receive a default gateway. iPXE and OS clients that request
option 121 receive only the specific routes through the GW in their CSV record,
without a default route. DNS, NTP, and boot resources must be reachable through
those routes. Boot file profiles are configured separately for x86_64 and arm64
and are independent of the server's architecture.
An initial BIOS request is ignored when its CSV row requires uefi, and an
initial UEFI request is ignored when the row requires bios. The mismatch is
logged without sending OFFER, ACK, or NAK. Later iPXE and OS requests remain
eligible for service.
/health returns 200 when the DHCP socket is ready and a valid client snapshot is
available; otherwise it returns 503. /metrics returns Prometheus text format;
hostname and location metric labels come from the CSV, and every metric name has
the macdack_ prefix. Both endpoints include Server: macdack/<version> and
X-App-Version: <version> headers. The metrics payload also exposes
macdack_build_info{version="..."}. A valid saved CSV is used indefinitely while
the source is unavailable. The main TOML is reread after a valid Unix SIGHUP.
Only HTTP 200 replaces the CSV; a cached HTTP 304 keeps the current snapshot.
Other statuses, including 204 and 206, retain the previous data and report an
error. An empty or header-only CSV delivered with HTTP 200 intentionally clears
all assignments. A valid empty snapshot still satisfies /health.
Polling requests use User-Agent: macdack/<version>.
BIOS/UEFI mismatches increment macdack_boot_mode_mismatches_total.
Client metric series expire after 24 hours without requests (cleanup runs at most once per minute during requests or scrapes). A returning series starts at zero again; global request/response counters are not reset. Metric text is formatted after releasing the client-series lock.
If relay option 82 alone would exceed the client's response-size limit, the
server omits it, logs a warning with client context, and increments
macdack_errors_total. Required network settings are not silently truncated.
Podman is the default container engine for the generic recipes. Automated GitHub
Actions builds and publishes the linux/amd64 image only. The application itself
still supports ARM64 boot profiles.
Use just podman-build-amd64 and
just container-smoke macdack:dev-amd64 for explicit Podman commands. The
generic just container-build recipe also uses Podman by default; set
CONTAINER_ENGINE=docker to select Docker instead. On macOS, Podman requires a
running machine, and building amd64 on ARM may require emulation support in that
VM. The smoke test performs a real UDP relay exchange and checks Linux wildcard
binding, UEFI/iPXE/OS routing, configuration reload, and saved-state recovery in
an isolated container network.
just docker-build-amd64
sudo install -d -m 0755 /etc/macdack
sudo install -m 0644 examples/server.toml /etc/macdack/config.toml
sudo install -d -m 0700 -o 65532 -g 65532 /var/lib/macdack
docker run -d --name macdack \
--cap-drop ALL --sysctl net.ipv4.ip_unprivileged_port_start=0 \
-p 67:67/udp -p 127.0.0.1:8080:8080 \
--mount type=bind,src=/etc/macdack,dst=/etc/macdack,readonly \
--mount type=bind,src=/var/lib/macdack,dst=/var/lib/macdack \
macdack:dev-amd64For containers, set listen_ip = "0.0.0.0", HTTP
listen = "0.0.0.0:8080", a client-reachable server_identifier, and the state
path /var/lib/macdack/clients.csv. The example uses a separate container network
namespace and permits binding low ports through sysctl. With host networking,
grant appropriate privileges for port 67 instead. Verify routing to the relay:
responses are sent to giaddr:67.
The runtime image is based on gcr.io/distroless/cc-debian13:nonroot. It has no
shell or package manager, but includes glibc and CA certificates needed by the
dynamically linked Rust binary and HTTPS CSV polling. The process runs as the
standard distroless UID/GID 65532:65532. When bind-mounting the state directory,
make it writable by this UID. Linux binaries target the Debian Trixie glibc
environment or a compatible one.
just build-amd64 # cargo-dist artifact for x86_64-unknown-linux-gnu
just dist-plan # show planned cargo-dist artifacts
just dist-build # build the amd64 cargo-dist artifact
just container-build # build an amd64 image with Podman by default
just container-smoke macdack:dev-amd64
just docker-push registry.example/dhcp 0.2.0build-amd64 runs cargo-dist in a temporary Linux container and places the
release archive in target/distrib. The image name, tag, and OCI artifact
directory are controlled by IMAGE, TAG, and ARTIFACTS.
Select Docker for generic recipes with CONTAINER_ENGINE=docker. Only
docker-push publishes an image and requires an explicit name and tag.
The repository includes three workflows:
CIruns directly for pushes outsidemainand for every pull request. It performs checks, unit tests, integration tests, a release build, and an amd64 container smoke test against the real DHCP listener.Publish main containerbuilds and publishes thelinux/amd64ghcr.io/<owner>/macdack:latestimage after every push tomain. It invokes the reusable CI workflow first, so the same checks are not started separately byci.ymlfor that push. Its reusable CI call skips the temporary CI image; the publish job builds and smoke-tests the exact image it will push.ReleaseacceptsX.Y.Ztags that point to a commit reachable frommain, publisheslatest,stable, andX.Y.Zimage tags, and attaches the amd64 cargo-dist archive plus its SHA-256 file to the GitHub Release.
Publishing workflows require the reusable CI checks to pass. Release tags must
exactly match the application version in both Cargo.toml and Cargo.lock;
prerelease tags are rejected by this stable-release workflow. Update both files
before creating a version tag. RELEASE_TAG=X.Y.Z just dist-build validates the
version and passes the explicit tag to cargo-dist.
Release archives and the runtime image are built and the image is smoke-tested before publication. The same locally tested image is pushed to GHCR without a second build. Publish workflows skip the redundant temporary CI container build because they perform this exact-image check themselves. GHCR publication and GitHub Release creation are separate external operations; a network failure during publication may still require a rerun.
The workflows use the repository's default GITHUB_TOKEN; publishing jobs grant
it package and release write permissions, so no additional registry secret is
required for GHCR.
just fmt
just check # fmt-check, Clippy, Rust and release-validation tests
just test-integration
just docker-smoke macdack:dev-amd64The smoke test requires Docker or Podman and curl. It starts a temporary CSV HTTP
source in nginx:mainline-alpine, verifies health, metrics, and CSV persistence,
then restarts the server while the source is unavailable. UDP/67 is not published
to the host. Created containers and the network are removed; temporary files
remain at the printed path for diagnostics.
Testing the complete client -> relay -> server path and BIOS/UEFI/iPXE requires
an isolated Linux network and test bootloaders. Creating network namespaces
requires CAP_NET_ADMIN or root; never point these tests at a production DHCP
network. Building for another architecture is not a substitute for running and
testing on that architecture.
macdack is licensed under the MIT License. See LICENSE. Third-party
dependencies remain under their respective licenses and are not relicensed by
this project.
