By Christiano Haesbaert - 2015-12-29
Almost every network needs to support DHCP (Dynamic Host Configuration Protocol), that is, a way for clients to request network parameters from the environment. Common parameters are an IP address, a network mask, a default gateway and so on.
DHCP can be seen as a critical security component, since it deals usually with unauthenticated/unknown peers, therefore it is of special interest to run a server as a self-contained MirageOS unikernel.
Charrua is a DHCP implementation written in OCaml and it started off as an excuse to learn more about the language. While in development it got picked up on the MirageOS mailing lists and became one of the Pioneer Projects.
The name Charrua
is a reference to the, now extinct, semi-nomadic people of
southern South America — nowadays it is also used to refer to Uruguayan
nationals. The logic is that DHCP handles dynamic (hence nomadic) clients.
The library is platform agnostic and works outside of MirageOS as well. It provides two main modules: Dhcp_wire and Dhcp_server.
Dhcp_wire provides basic functions for dealing with the protocol, essentially marshalling/unmarshalling and helpers for dealing with the various DHCP options.
The central record type of Dhcp_wire is a pkt, which represents a full DHCP packet, including layer 2 and layer 3 data as well as the many possible DHCP options. The most important functions are:
val pkt_of_buf : Cstruct.t -> int -> [> `Error of string | `Ok of pkt ]
val buf_of_pkt : pkt -> Cstruct.t
pkt_of_buf takes
a Cstruct.t buffer and a length and it
then attempts to build a DHCP packet. Unknown DHCP options are ignored, invalid
options or malformed data are not accepted and you get an `Error of string
.
buf_of_pkt is the mirror function, but it never fails. It could for instance fail in case of two duplicate DHCP options, but that would imply too much policy in a marshalling function.
The DHCP options from RFC2132 are implemented in dhcp_option. There are more, but the most common ones look like this:
type dhcp_option =
| Subnet_mask of Ipaddr.V4.t
| Time_offset of int32
| Routers of Ipaddr.V4.t list
| Time_servers of Ipaddr.V4.t list
| Name_servers of Ipaddr.V4.t list
| Dns_servers of Ipaddr.V4.t list
| Log_servers of Ipaddr.V4.t list
Dhcp_server Provides a library for building a DHCP server and is divided into two sub-modules: Config, which handles the building of a suitable DHCP server configuration record and Input, which handles the input of DHCP packets.
The logic is modelled in a pure functional style and Dhcp_server does not perform any IO of its own. It works by taking an input packet, a configuration and returns a possible reply to be sent by the caller, or an error/warning:
type result =
| Silence (* Input packet didn't belong to us, normal nop event. *)
| Reply of Dhcp_wire.pkt (* A reply packet to be sent on the same subnet. *)
| Warning of string (* An odd event, could be logged. *)
| Error of string (* Input packet is invalid, or some other error ocurred. *)
val input_pkt : Dhcp_server.Config.t -> Dhcp_server.Config.subnet ->
Dhcp_wire.pkt -> float -> result
(** input_pkt config subnet pkt time Inputs packet pkt, the resulting action
should be performed by the caller, normally a Reply packet is returned and
must be sent on the same subnet. time is a float representing time as in
Unix.time or MirageOS's Clock.time. **)
A typical main server loop would work by:
A mainloop example can be found in mirage-skeleton:
let input_dhcp c net config subnet buf =
let open Dhcp_server.Input in
match (Dhcp_wire.pkt_of_buf buf (Cstruct.len buf)) with
| `Error e -> Lwt.return (log c (red "Can't parse packet: %s" e))
| `Ok pkt ->
match (input_pkt config subnet pkt (Clock.time ())) with
| Silence -> Lwt.return_unit
| Warning w -> Lwt.return (log c (yellow "%s" w))
| Error e -> Lwt.return (log c (red "%s" e))
| Reply reply ->
log c (blue "Received packet %s" (Dhcp_wire.pkt_to_string pkt));
N.write net (Dhcp_wire.buf_of_pkt reply)
>>= fun () ->
log c (blue "Sent reply packet %s" (Dhcp_wire.pkt_to_string reply));
Lwt.return_unit
As stated, Dhcp_server.Input.input_pkt does not perform any IO of its own, it only deals with the logic of analyzing a DHCP packet and building a possible answer, which should then be sent by the caller. This allows a design where all the side effects are controlled in one small chunk, which makes it easier to understand the state transitions since they are made explicit.
At the time of this writing, Dhcp_server.Input.input_pkt is not side effect free, as it manipulates a database of leases, this will be changed in the next version to be pure as well.
Storing leases in permanent storage is also unsupported at this time and should be available soon, with Irmin and other backends. The main idea is to always return a new lease database for each input, or maybe just the updates to be applied, and in this scenario, the caller would be able to store the database in permanent storage as he sees fit.
This project started independently of MirageOS and at that time, the best
configuration I could think of was the well known ISC
dhcpd.conf
. Therefore,
the configuration uses the same format but it does not support the myriad of
options of the original one.
type t = {
addresses : (Ipaddr.V4.t * Macaddr.t) list;
subnets : subnet list;
options : Dhcp_wire.dhcp_option list;
hostname : string;
default_lease_time : int32;
max_lease_time : int32;
}
val parse : string -> (Ipaddr.V4.Prefix.addr * Macaddr.t) list -> t
(** [parse cf l] Creates a server configuration by parsing [cf] as an ISC
dhcpd.conf file, currently only the options at [sample/dhcpd.conf] are
supported. [l] is a list of network addresses, each pair is the output
address to be used for building replies and each must match a [network
section] of [cf]. A normal usage would be a list of all interfaces
configured in the system *)
Although it is a great format, it doesn't exactly play nice with MirageOS and OCaml, since the unikernel needs to parse a string at runtime to build the configuration, this requires a file IO backend and other complications. The next version should provide OCaml helpers for building the configuration, which would drop the requirements of a file IO backend and facilitate writing tests.
The easiest way is to follow the mirage-skeleton DHCP README.
The next steps would be:
Map
, adding
also support/examples for Irmin.unikernel.ml
and config.ml
, with
Functoria we would be able to have it
much nicer and only touch config.ml
.This is my first real project in OCaml and I'm more or less a newcomer to functional programming as well, my background is mostly kernel hacking as an ex-OpenBSD developer. I'd love to hear how people are actually using it and any problems they're finding, so please do let me know via the issue tracker!
Prior to this project I had no contact with any of the MirageOS folks, but I'm amazed about how easy the interaction and communication with the community has been, everyone has been incredibly friendly and supportive. I'd say MirageOS is a gold project for anyone wanting to work with smart people and hack OCaml.
My many thanks to Anil, Richard, Hannes, Amir, Scott, Gabriel and others. Thanks also to Thomas and Christophe for comments on this post. I also would like to thank my employer for letting me work on this project in our hackathons.