Your website as an unikernel
A step forward about developping unikernels in OCaml
In this final part of our tutorial, we're going to try to create a unikernel that provides a web interface for chatting with others. I'd like to point out that, at this stage, it can already be very interesting to build unikernels that deal with lower-level protocols such as DNS or SSH, or simply to create your own protocol on top of TCP/IP, and that it isn't necessarily essential to build a webapp to understand the full potential of unikernels (in fact, the opposite is probably true).
However, this tutorial takes a brief look at how to build a unikernel by
integrating other elements, such as the output of js_of_ocaml,
or the ability to incorporate static elements into our unikernel.
Re-introduce our workflow
Before we begin, it is important to fully understand the workflow we have
outlined here. It is important to realise that with dune, we build our
unikernel twice:
- once as a simple executable capable of generating the
manifest.jsonfile required for compilation with Solo5 - once as a unikernel using the generated
manifest.json
In fact, dune has the capability to generate artefacts from software that it
has built itself. We therefore have two contexts (the default one and the Solo5
context), where the first can build and run software, whilst the second can
only build unikernels. dune then allows us to switch between these two
contexts.
For this example, we simply want to present the user with a login page so
that they can log in. This page will be pure HTML, and the question is: how do
I integrate this page statically into my unikernel? This is where we
introduce vifu, our OCaml web framework, and mcrunch, a tool for generating
an OCaml module from files that can be statically linked.
Vifu & mcrunch
So we’re going to create a simple index.html file:
<html>
<head><title>uniker.nl</title></head>
<body>
<form action="/login" method="post" enctype="multipart/form-data">
<label for="username">Enter your username: </label>
<input type="text" name="username" required />
<input type="submit" value="Login!" />
</form>
</body>
</html>
We will then build our unikernel to serve this file; in particular, we will
create a Documents module that exists and contains the contents of our
index.html file:
This function is a generic function that allows content to be transmitted, but
also calculates an identifier that will be set in the ETag field, enabling a
Not_modified response if the content has already been served.
In this function, we can see several aspects of Vifu:
- firstly, we can see that we can inspect a received request (and look for the
If-None-Matchfield) - we can also see the monad used to construct our response (adding the
ETagfield and responding withNot_modified) - we can also see the introduction of the
Fluxmodule, our stream library for Miou (we recommend the documentation)
We're starting to write some proper code in our unikernel.
let from_documents ~mime contents =
let hash =
let rec go ctx idx =
if idx >= Array.length contents
then Digestif.SHA1.(to_hex (get ctx))
else go (Digestif.SHA1.feed_string ctx contents.(idx)) (succ idx) in
go Digestif.SHA1.empty 0 in
fun req _server _ ->
let open Vifu.Response.Syntax in
let* () = Vifu.Response.add ~field:"content-type" mime in
let hdrs = Vifu.Request.headers req in
let if_none_match =
match Vifu.Headers.get hdrs "if-none-match" with
| Some hash' -> String.equal hash hash'
| None -> false in
if if_none_match then
let* () = Vifu.Response.empty in
Vifu.Response.respond `Not_modified
else
let from = Flux.Source.array contents in
let* () = Vifu.Response.add ~field:"etag" hash in
let* () = Vifu.Response.with_source req ~compression:`DEFLATE from in
Vifu.Response.respond `OK
Here, we create a specific Vifu handler for the index document.
let index = from_documents ~mime:"text/html" Documents.index
And we define a route to this handler. vifu provides a small DSL for
describing routes, ensuring that the DSL is typed. We will look at the power of
its routing system later.
let routes =
let open Vifu.Uri in
let open Vifu.Route in
[ get (rel /?? nil) --> index ]
module RNG = Mirage_crypto_rng.Fortuna
let rng () = Mirage_crypto_rng_mkernel.initialize (module RNG)
let rng = Mkernel.map rng [] |> Mkernel.finally Mirage_crypto_rng_mkernel.kill
Here we have the first entry point for our unikernel, where we initialise the HTTP server using our TCP/IP stack.
let run _quiet (cidr4, gateway4, ipv6, gateway6) port =
let stack = Mnet.stack ~name:"service" ?gateway:gateway4
~ipv6 ?ipv6_gateway:gateway6 cidr4 in
Mkernel.run [ rng; stack ] @@ fun _ (_, tcp, _) () ->
let cfg = Vifu.Config.v port in
Vifu.run ~cfg tcp routes ()
And finally, we set the few options needed to configure our unikernel (such as
its IP address or the port for our HTTP server). We do this using cmdliner.
mnet-cli also provides helpers so that we do not have to redefine the
arguments required by our unikernel every time.
open Cmdliner
let port =
let doc = "The HTTP port." in
let open Arg in
value & opt int 80 & info [ "p"; "port" ] ~doc ~docv:"PORT"
let term =
let open Term in
const run
$ Mnet_cli.setup_logs
$ Mnet_cli.setup
$ port
let cmd =
let info = Cmd.info "chat" in
Cmd.v info term
let () = Cmd.(exit @@ eval cmd)
Next, we're going to install a new tool: mcrunch. The purpose of this tool is
to generate an OCaml file that can contain the contents of one or more files.
Here's an example of how to use this tool:
$ opam install mcrunch
$ mcrunch --file index:index.html
let index =
[| "\x3c\x68\x74\x6d\x6c\x3e\x0a\x3c\x62\x6f\x64\x79\x3e\x0a\x3c\x66"
; "\x6f\x72\x6d\x20\x61\x63\x74\x69\x6f\x6e\x3d\x22\x2f\x6c\x6f\x67"
; "\x69\x6e\x22\x20\x6d\x65\x74\x68\x6f\x64\x3d\x22\x70\x6f\x73\x74"
; "\x22\x20\x65\x6e\x63\x74\x79\x70\x65\x3d\x22\x6d\x75\x6c\x74\x69"
; "\x70\x61\x72\x74\x2f\x66\x6f\x72\x6d\x2d\x64\x61\x74\x61\x22\x3e"
; "\x0a\x20\x20\x3c\x6c\x61\x62\x65\x6c\x20\x66\x6f\x72\x3d\x22\x75"
; "\x73\x65\x72\x6e\x61\x6d\x65\x22\x3e\x45\x6e\x74\x65\x72\x20\x79"
; "\x6f\x75\x72\x20\x75\x73\x65\x72\x6e\x61\x6d\x65\x3a\x20\x3c\x2f"
; "\x6c\x61\x62\x65\x6c\x3e\x0a\x20\x20\x3c\x69\x6e\x70\x75\x74\x20"
; "\x74\x79\x70\x65\x3d\x22\x74\x65\x78\x74\x22\x20\x6e\x61\x6d\x65"
; "\x3d\x22\x75\x73\x65\x72\x6e\x61\x6d\x65\x22\x20\x72\x65\x71\x75"
; "\x69\x72\x65\x64\x20\x2f\x3e\x0a\x20\x20\x3c\x69\x6e\x70\x75\x74"
; "\x20\x74\x79\x70\x65\x3d\x22\x73\x75\x62\x6d\x69\x74\x22\x20\x76"
; "\x61\x6c\x75\x65\x3d\x22\x4c\x6f\x67\x69\x6e\x21\x22\x20\x2f\x3e"
; "\x0a\x3c\x2f\x66\x6f\x72\x6d\x3e\x0a\x3c\x2f\x62\x6f\x64\x79\x3e"
; "\x0a\x3c\x2f\x68\x74\x6d\x6c\x3e\x0a" |]
We'll use this tool to generate the Documents module, so we'll describe a
build pipeline that's a little more complex than the one we've used before:
Let's recap how to describe how to build our unikernel (don't forget the
dune-workspace).
(executable
(name main)
(modules main documents)
(link_flags :standard -cclib "-z solo5-abi=hvt")
(libraries mkernel mirage-crypto-rng-mkernel vifu mnet-cli)
(foreign_stubs
(language c)
(names manifest)))
Let's use dune to generate the manifest.c file. We will then use our
executable ./main.exe (in our default context) to generate our
manifest.json file.
(rule
(targets manifest.c)
(deps manifest.json)
(enabled_if
(= %{context_name} "solo5"))
(action
(run solo5-elftool gen-manifest manifest.json manifest.c)))
(rule
(targets manifest.c)
(enabled_if
(= %{context_name} "default"))
(action
(write-file manifest.c "")))
And that's how you can describe how to build the Documents module using
dune and mcrunch.
(rule
(targets documents.ml)
(deps index.html)
(action
(with-stdout-to documents.ml (run mcrunch -f index:index.html))))
With all this, we'll be able to:
- work out the dependencies using
unic - download the necessary source code using
mfetch - generate our manifest.json using
dune - and finally build our unikernel!
$ unic infer -r . -x _build -x vendors --ignore Documents \
--prefer digestif.c --prefer checkseum.c -o _mfetch
$ mfetch --with-dune-file
$ dune exec ./main.exe -- --ipv4=0.0.0.0/0 > manifest.json
$ dune build
You can try out our unikernel right now:
$ sudo ip tuntap add name tap0 mode tap
$ sudo ip link set tap0 up
$ sudo ip link add name service type bridge
$ sudo ip link set service up
$ sudo ip link set tap0 master service
$ sudo ip addr add 10.0.0.1/24 dev service
$ solo5-hvt --net:service=tap0 -- _build/solo5/main.exe --ipv4=10.0.0.2/24 &
$ PID=$!
$ curl http://10.0.0.2/
<html>
<head><title>uniker.nl</title></head>
<body>
<form action="/login" method="post" enctype="multipart/form-data">
<label for="username">Enter your username: </label>
<input type="text" name="username" required />
<input type="submit" value="Login!" />
</form>
</body>
</html>
$ kill -9 $PID
We've built our first website using a unikernel! As you can see, it's not that
difficult, as vifu provides everything we need to map HTTP routes to
OCaml functions. With that in mind, we recommend you find out more about this
web framework.
We've also seen how to statically embed content into our unikernel from an
external file using mcrunch. This is very handy, but it's important to bear
in mind that it isn't the only way to embed files into our unikernel, as it has
a direct impact on your unikernel's memory usage. In this context, we mentioned
mfat, which uses a block device that is more memory-efficient. It all
depends on what you want to achieve. mcrunch has the advantage of being very
simple and requiring no dependencies.
This has enabled us to make our build pipeline a little more complex and to understand that we can generate artefacts that will be necessary for building our unikernel.
A simple login
Let's take our unikernel a step further. We're going to create a mini database
system where users can assign themselves a unique username and the server
returns a JWToken representing that username. We'll therefore be making more
extensive use of vifu and also using our jws library, which
implements JWTokens in OCaml:
let with_textf req fmt = Fmt.kstr (Vifu.Response.with_text req) fmt
In a web application, we often refer to the .env file, which contains
information relating to the web server, such as the database password. We're
applying a similar principle here with this type, the value of which will be
available across all our handlers.
type env = { secret : Jws.Pk.t }
vifu provides an object called device, which is global and can be accessed
from all handlers using the server value. This will allow us to have a small
global database (a simple Hashtbl) that we can access from all our handlers.
let usernames =
let finally = Fun.const () in
Vifu.Device.v ~name:"usernames" ~finally [] @@ fun _ ->
Hashtbl.create 0x7ff
Like any web framework, vifu also provides middleware. The aim here is to
retrieve any cookies and extract the username from the JWToken that can be
extracted. Any errors return None.
let jwt =
Vifu.Middlewares.v ~name:"jwt" @@ fun req _target server { secret } ->
match Vifu.Cookie.get server req ~name:"jwt" with
| Error _err -> None
| Ok token ->
let ( let* ) = Option.bind in
let public = Jws.Pk.public secret in
let token = Jwt.decode ~public token in
let* token = Result.to_option token in
Jwt.value token ~key:"username" Jsont.string
let login req server { secret } =
let open Vifu.Response.Syntax in
let usernames = Vifu.Server.device usernames server in
match Vifu.Request.of_multipart_form req with
| Ok username when Hashtbl.mem usernames username = false ->
let cl = Jwt.Claims.(add "username" Jsont.string username empty) in
let token = Jwt.encode secret cl in
let* () = Vifu.Cookie.set ~name:"jwt" server req token in
let* () = Vifu.Response.empty in
Vifu.Response.redirect_to req Vifu.Uri.(rel / "chat" /?? any)
| Ok username ->
let* () = with_textf req "%s is already taken!\n" username in
Vifu.Response.respond `Conflict
| Error _ ->
let* () = with_textf req "Invalid multipart/form-data\n" in
Vifu.Response.respond (`Code 422)
let logout req server _ =
let open Vifu.Response.Syntax in
let usernames = Vifu.Server.device usernames server in
let fn = Hashtbl.remove usernames in
Option.iter fn (Vifu.Request.get jwt req);
let* () = Vifu.Cookie.set ~name:"jwt" server req String.empty in
let* () = Vifu.Response.empty in
Vifu.Response.redirect_to req Vifu.Uri.(rel /?? any)
let username =
let open Vifu.Multipart_form in
record Fun.id
|+ field "username" string
|> sealr
let routes =
let open Vifu.Uri in
let open Vifu.Route in
let open Vifu.Type in
[ get (rel /?? nil) --> index
; post (m username) (rel / "login" /?? nil) --> login
; post any (rel / "logout" /?? nil) --> logout ]
-let run _quiet (cidr4, gateway4, ipv6, gateway6) port =
+let run _quiet (cidr4, gateway4, ipv6, gateway6) seed port =
let stack = Mnet.stack ~name:"service" ?gateway:gateway4
~ipv6 ?ipv6_gateway:gateway6 cidr4 in
Mkernel.run [ rng; stack ] @@ fun _ (_, tcp, _) () ->
let cfg = Vifu.Config.v port in
+ let secret = X509.Private_key.generate ?seed `ED25519 in
+ let secret = Jws.Pk.of_private_key_exn secret in
+ let devices = Vifu.Devices.[ usernames ] in
+ let middlewares = Vifu.Middlewares.[ jwt ] in
- Vifu.run ~cfg tcp routes ()
+ Vifu.run ~cfg ~devices ~middlewares tcp routes { secret }
$ unic infer -r . -x _build -x vendors --ignore Documents \
--prefer digestif.c --prefer checkseum.c -o _mfetch
$ mfetch
$ dune build