Rust superpowered DHCP cli with rhai scripts

I’ve been working on cli tool for a little while called dhcpm (“m” for “mock” - link). It started as a cli tool for constructing & sending arbitrary DHCP messages. I had been looking for a tool that could build various dhcp (mostly v4) messages with different parameters easily, then simply print the responses back so I could inspect its contents.

I discovered that you can use nmap scripts for this, and there are a 2 pre-written dhcp scripts in a typical nmap install. For example, to send a discover message:

sudo nmap -sU -p 67 --script=dhcp-discover <target>

(U for UDP, 67 is the default dhcpv4 port, the script we want to run and the IP of the dhcp server)

See the docs for arguments. While this works for basic tests, it didn’t have all the features that I wanted out of the box… and I suck at writting lua… and with dhcproto all I really had to do was generate a cli parser from a struct and write some bytes to a UDP socket. So, while nmap is super flexible, I do still feel there is room for a tool focused just on DHCP.

CLI

Hacking something workable came together pretty quickly, dhcpm currently looks like this:

> dhcpm --help

Usage: dhcpm <target> [-b <bind>] [-p <port>] [-t <timeout>] [--output <output>] [--script <script>] [--no-retry <no-retry>] [<command>] [<args>]

dhcpm is a cli tool for sending dhcpv4/v6 messages

ex  dhcpv4:
        dhcpm 0.0.0.0 -p 9901 discover  (unicast discover to 0.0.0.0:9901)
        dhcpm 255.255.255.255 discover (broadcast discover to default dhcp port)
        dhcpm 192.168.0.1 dora (unicast DORA to 192.168.0.1)
        dhcpm 192.168.0.1 dora -o 118,C0A80001 (unicast DORA, incl opt 118:192.168.0.1)
    dhcpv6:
        dhcpm ::0 -p 9901 solicit       (unicast solicit to [::0]:9901)
        dhcpm ff02::1:2 solicit         (multicast solicit to default port)

Positional Arguments:
  target            ip address to send to

Options:
  -b, --bind        address to bind to [default: INADDR_ANY:0]
  -p, --port        which port use. [default: 67 (v4) or 546 (v6)]
  -t, --timeout     query timeout in seconds [default: 5]
  --output          select the log output format (json|pretty|debug) [default: pretty]
  --script          pass in a path to a rhai script
                    (https://github.com/rhaiscript/rhai) NOTE: must compile
                    dhcpm with `script` feature
  --no-retry        setting to "true" will prevent re-sending if we don't get a
                    response [default: false]
  --help            display usage information

Commands:
  discover          Send a DISCOVER msg
  request           Send a REQUEST msg
  release           Send a RELEASE msg
  inform            Send an INFORM msg
  dora              Sends Discover then Request
  solicit           Send a SOLICIT msg (dhcpv6)

There are some base parameters that tell the tool which ports to bind to, the target, etc, there are even output options to tell tracing to log structured JSON or more readable logs. The meat of it are the subcommands for each of the dhcpv4 message types, with DHCPv6 is unfinished at the moment. Subcommands each have their own parameters:

> dhcpm 0.0.0.0 discover --help

Send a DISCOVER msg

Options:
  -c, --chaddr      supply a mac address for DHCPv4 [default: first avail mac]
  --ciaddr          address of client [default: None]
  -r, --req-addr    request specific ip [default: None]
  -g, --giaddr      giaddr [default: 0.0.0.0]
  --subnet-select   subnet selection opt 118 [default: None]
  --relay-link      relay link select opt 82 subopt 5 [default: None]
  -o, --opt         add opts to the message [ex: these are equivalent-
                    "118,hex,C0A80001" or "118,ip,192.168.0.1"]
  --params          params to include: [default: 1,3,6,15 (Subnet, Router,
                    DnsServer, DomainName]
  --help            display usage information

With this arbitrary DHCP options can be set with hex or an ip string (ex. --opt 118,ip,192.168.0.1), we can change some IPs in the header like giaddr/ciaddr, the hardware address (chaddr), etc. This was great for quick testing with different parameters. But what about nmap’s scripting engine? While you can use dhcpm inside a shell script, sometimes that can be a bit unwieldy when we’re talking about poking into a text formatted DHCP message to pull out fields to pass to subsequent runs. It would be cool to have a mini scripting engine to edit scripts on the fly, change a couple parameters on what might be a chain of several messages and re-run.

Scripting

At this point I stumbled on rhai, an embedded scripting environment for Rust. What attracted me to it was after looking at the code examples, it seemed like I already “knew” rhai. It looks much less foreign to me than lua. It’s basically Rust with dynamic types, and it has a well documented book with some good examples of how to integrate with a Rust application. Let’s take a look at the integration with dhcpm.

In rhai you can create a new Engine that can do things like call engine.eval::<T>("1 + 2"). What’s passed will be executed by rhai and have its result returned. On other hand, you can pass a path to the engine and execute an entire script. This is what dhcpm does with the path provided through the cli.

Rhai also provides a robust set of macros for providing Rust code to the running script

#[export_module]
pub mod discover_mod {
    use tracing::trace;
    #[rhai_fn()]
    pub fn args_default() -> DiscoverArgs {
        DiscoverArgs::default()
    }
    #[rhai_fn(global, name = "to_string", name = "to_debug", pure)]
    pub fn to_string(args: &mut DiscoverArgs) -> String {
        format!("{:?}", args)
    }
    // ciaddr
    #[rhai_fn(global, get = "ciaddr", pure)]
    pub fn get_ciaddr(args: &mut DiscoverArgs) -> String {
        args.ciaddr.to_string()
    }
    #[rhai_fn(global, set = "ciaddr")]
    pub fn set_ciaddr(args: &mut DiscoverArgs, ciaddr: &str) {
        trace!(?ciaddr, "setting ciaddr");
        args.ciaddr = ciaddr.parse::<Ipv4Addr>().expect("failed to parse ciaddr");
    }
    ...
}

I think of this like an FFI, we’re generating bindings for rhai to use. Some things to note, rhai can have any valid Rust type in functions exposed to it, although it looks to me like anything that is a custom type needs to have any variants/methods/etc explicitly exposed for it to be useful in rhai. Notice also that rhai’s first paramater takes by &mut Thing. All methods can mutate; this is a scripting language after all. In any case, this can be registered with the Engine

engine
    .register_type_with_name::<DiscoverArgs>("DiscoverArgs")
    .register_static_module(
        "discover",
        exported_module!(crate::discover::discover_mod).into(),
    );

And using it in rhai:

let args = discover::args_default();
args.ciaddr = "1.2.3.4";
print(args)

This made it all fairly mechanical to expose the different configuration structs to rhai so that they could be created and modified inside the script. The next issue was, how will I get rhai to actually send the dhcp message that can be built from these arguments?

dhcpm is a simple tool, at startup it creates one Arc<UdpSocket> and two threads, one to recv and one send. These are complemented by a pair of channels so provide some scallfolding for the message runner.

// messages put on `send_tx` will go out on the socket
let (send_tx, send_rx) = crossbeam_channel::bounded(1);
// messages coming from `recv_rx` were received from the socket
let (recv_tx, recv_rx) = crossbeam_channel::bounded(1);

runner::sender_thread(send_rx, soc.clone());
runner::recv_thread(recv_tx, soc);

send_tx will put any dhcp messages sent to it on the socket. recv_rx will be forwarded any any messages we read from the socket, however there can only be one reading at a time, otherwise we won’t know which message is meant for which receiver. These channels passed into TimeoutRunner when it is initialized which I won’t post the code for, but suffice to say it sends a messages and exits with an error if there is no reply within a certain time period or if we get a SIGINT.

For creating a rhai binding to this, initially I didn’t understand how that would work. What’s needed is a function that will take the DiscoverArgs and an already initialized value (of type TimeoutRunner), but one provided from the Rust environment, not the script. However, with function macros I showed before, all parameters were provided by Rhai, there is no way to pass in an already initialized variable.

I initially thought of using something like Lazy or OnceCell and putting TimeoutRunner there, but it didn’t feel right. Eventually, I stumbled over closure bindings for engine and that appeared to solve this problem.

let run = runner.clone();
...
engine.register_fn("send", {
    move |args: &mut DiscoverArgs| {
        let mut new_runner = run.clone();
        // replace runner args so it knows which message type to run
        new_runner.args.msg = Some(MsgType::Discover(args.clone()));
        new_runner.send().expect("runner failed").unwrap_v4()
    }
})

This will attach the method send on DiscoverArgs allowing the script to use it like this:

let args = discover::args_default();
args.ciaddr = "1.2.3.4";
print(args);
let msg = args.send();

Currently in dhcpm there are bindings for discover, request, inform & release, and getters/setters for each, allowing one to script the full DORA negotiation for an IP with custom fields for each.

Conclusion

While what I’ve described in this post might be banal to some folks, there was something about it that I found exciting and made me want to share. I think there are so many possibilities for this type of a setup where you can embed a scripting language with superpowers provided by Rust into your application.