illumos has an IPC mechanism called doors. At a high level, doors allow a process to expose a procedure that another process can call. The client calls into the door, the kernel transfers control to a server thread in the server process, and the server returns a response back to the client. In practice, it feels like making a function call that magically goes across process boundaries.

I have known about doors for a while mostly because they show up all over the system. For example, name resolution and user lookups, among other things, may use doors to talk to nscd. I thought it would be fun to write a small example program to see how the API works and use dtrace to spy on our program a bit.

What are doors?

Doors are an IPC mechanism available on illumos. A server creates a door with door_create(3C) and gives the kernel a function pointer. A client then opens a door descriptor and calls it with door_call(3C). When the call reaches the server process, the server function runs and eventually returns data back to the client with door_return(3C).

One thing that makes doors interesting is that a door can be attached to a path on the filesystem with fattach(3C). This makes the door available by file name - similar to something like mkfifo(8) but with more features provided by the kernel. This means that the client doesn’t need to inherit a file descriptor or connect to a socket - it can simply open a path and make a call.

A simple door server

Here is a simple example to get us started - a server and client where the client can call the door in the server and the server will print that a request was received:

server.c

#include <door.h>
#include <fcntl.h>
#include <stdio.h>
#include <stropts.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

void door_func(void *cookie, char *args, size_t nargs,
    door_desc_t *desc, uint_t ndesc) {

    printf("hello from door_func\n");
    door_return(NULL, 0, NULL, 0);
}

int main() {
    char *path = "./my-file.door";

    // create the file
    unlink(path);
    int fd = open(path, O_RDWR | O_CREAT, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    close(fd);

    // create the door
    int door = door_create(&door_func, NULL, 0);
    if (door == -1) {
        perror("door_create");
        return 1;
    }

    // attach the door to the file
    if ((fattach(door, path)) == -1) {
        perror("fattach");
        return 1;
    }

    printf("door created: %s\n", path);
    printf("waiting for requests...\n");

    // run forever
    while (1) {
        pause();
    }

    // not reached
    return 1;
}

client.c

#include <door.h>
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main() {
    int door = open("./my-file.door", O_RDONLY);
    if (door == -1) {
        perror("open");
        return 1;
    }

    // call the door (this is like calling "door_func" in the server)
    printf("calling door\n");
    int result = door_call(door, NULL);
    if (result == -1) {
        perror("door_call");
        return 1;
    }
    printf("door called successfully\n");

    return 0;
}

Compile the examples:

cc -ldoor client.c -o client
cc -ldoor server.c -o server

Finally, test the code by running the server and client in separate terminals:

$ ./server
door created: ./my-file.door
waiting for requests...
... (after the client is run) ...
hello from door_func
$ ./client
calling door
door called successfully

It works! Notice that the output from door_func appears in the server. This is because the function runs inside the server and the client just receives whatever data is sent back. In our example, no data is sent back to the client… so let’s fix that:

Make the following changes to the door_func function in server.c and main in client.c respectively:

server.c

void door_func(void *cookie, char *args, size_t nargs,
    door_desc_t *desc, uint_t ndesc) {

    char *response = "hello from door_func";
    door_return(response, strlen(response) + 1, NULL, 0);
}

client.c

printf("calling door\n");

door_arg_t args = {0};
int result = door_call(door, &args);
if (result == -1) {
    perror("door_call");
    return 1;
}

printf("door called successfully\n");
printf("got data: %s\n", args.data_ptr);

Then, recompile and run them again like above:

$ ./server
door created: ./my-file.door
waiting for requests...
$ ./client
calling door
door called successfully
got data: hello from door_func

It works again! This time notice that the server did not print any output when the door was called - this is because we removed the printf call from the server and instead returned that character data over to the client.

The args variable we created is really neat - we passed it into the door_call function completely zeroed-out but got it back with the data populated by the server. We can also set that before we call door_call and our server can receive it.

Let’s make that change by updating both the server and client programs again:

server.c

void door_func(void *cookie, char *args, size_t nargs,
    door_desc_t *desc, uint_t ndesc) {

    printf("got data from client: %s\n", args);

    char *response = "hello from door_func";
    door_return(response, strlen(response) + 1, NULL, 0);
}

client.c

// call the door (this is like calling "door_func" in the server)
printf("calling door\n");

char *input = "hi from client";

door_arg_t args = {0};
args.data_ptr = input;
args.data_size = strlen(input) + 1;
int result = door_call(door, &args);
if (result == -1) {
    perror("door_call");
    return 1;
}

Recompile the code and run it to see:

$ ./server
door created: ./my-file.door
waiting for requests...
got data from client: hi from client
$ ./client
calling door
door called successfully
got data: hello from door_func

Perfect! The client can receive data from the server like before and the server can also receive data from the client.

In this example our data is null-terminated strings so we can easily format them, but you are also able to pass any bytes or structs.

For the next example however I want to show that the server process is able to “interrogate” the client - by which I mean gather information about the caller such as its PID, user info, and zone information.

Update just the server code this time:

void door_func(void *cookie, char *args, size_t nargs,
    door_desc_t *desc, uint_t ndesc) {

    // interrogate our client
    ucred_t *uc = NULL;
    if (door_ucred(&uc) == 0) {
        uid_t euid = ucred_geteuid(uc);
        pid_t pid = ucred_getpid(uc);
        zoneid_t zoneid = ucred_getzoneid(uc);

        printf("client request received from pid %d uid %d zone %d\n",
            pid, euid, zoneid);

        ucred_free(uc);
    }

    printf("got data from client: %s\n", args);

    char *response = "hello from door_func";
    door_return(response, strlen(response) + 1, NULL, 0);
}

Recompile the server and re-run the code:

$ ./server
door created: ./my-file.door
waiting for requests...
client request received from pid 25833 uid 1000 zone 13
got data from client: hi from client

And there we go! We can see that the client request came from zone 13, uid 1000, and pid 25833. In this example we were running both the server and client in the same zone but they don’t have to be - we could share the door file and have something like the server in the global zone while the client (or clients!) is in a non-global zone.

Security Concerns

I should briefly touch on the concerns when crossing the zone boundary (though this broadly applies to doors used and accessed on the same zone as well).

The first thing to think about is the filesystem path. If a door is attached to a path that gets mounted into a zone, then processes in that zone may be able to call it. Normal file permissions still matter because the client has to open the door, but the server should not treat the path alone as the security boundary.

The second thing to think about is credentials. As shown above, the door server can call door_ucred(3C) during a door invocation to get information about the immediate caller. From there you can inspect things like the effective uid, pid, privileges, and zone id using the ucred_get*() functions.

For example, if the server should only accept calls from the global zone we could do something:

ucred_t *uc = NULL;
char *s = NULL;

if (door_ucred(&uc) != 0) {
    s = "no credentials";
    door_return(s, strlen(s) + 1, NULL, 0);
}

if (ucred_getzoneid(uc) != GLOBAL_ZONEID) {
    s = "bad zone";
    door_return(s, strlen(s) + 1, NULL, 0);
}

Or, if the server is meant to be called from a specific zone, it could check for that zone id explicitly.

Finally, a door call can pass data and file descriptors to the server. If the door is reachable by a less trusted zone, then the server should treat everything in the call as untrusted input. In the small example above, the server prints the client-provided string directly, but a real server should validate the input size and make sure string data is '\0' terminated before using it (or ideally pass structured data rather than C strings).

Finding doors with DTrace

One of the reasons I wanted to play with doors is that illumos uses them all over the system. We can trace them all with (run from the global zone):

$ sudo dtrace -qn 'fbt::door_return:entry { printf("[%s] %s\n", zonename, execname); }'
[arbiter] svc.configd
[arbiter] svc.configd
... trimmed ...
[arbiter] svc.configd
[arbiter] svc.configd
[arbiter] server # <--- that's us!
[nagios] nscd
[nagios] nscd
[nagios] pfexecd
[vault] svc.configd
[tunnel] nscd
[tunnel] nscd
^C

I had to trim a lot of output - svc.configd is particularly noisy! For my example I am running from the arbiter zone (zone 13) and the name of our program is server. We can tighten up our dtrace invocation to just look for that program by name and zone:

$ sudo dtrace -qn 'fbt::door_return:entry
/zonename == "arbiter" && execname == "server"/
{ printf("[%s] %s\n", zonename, execname); }'

[arbiter] server
[arbiter] server
[arbiter] server
[arbiter] server

You should see one line for every time the client program is run. Because we know it’s our program, we can be liberal in how we handle processing the arguments. For example, we know the door_return function gets called like this:

door_return(response, strlen(response) + 1, NULL, 0);

We can pull out arg0 as a string since we know it will be character data that has been safely null-terminated:

$ sudo dtrace -qn 'fbt::door_return:entry
/zonename == "arbiter" && execname == "server"/
{ printf("got data: %s\n", copyinstr(arg0)); }'

got data: hello from door_func
got data: hello from door_func
got data: hello from door_func

Beautiful! We can basically spy on our doors with this. There are other probes we can look at as well, but I will leave that as an exercise for the reader - there are a lot!

$ sudo dtrace -l | grep doorfs | wc -l
     144

Using Rust

Switching gears a bit, a really neat thing I want to showcase about doors is they can allow for interoperability between languages. Using the Rust doors crate we can create a client that will talk to our existing C server and transfer data with it.

To create this Rust client I ran this in the current directory:

cargo new rust-door-client
cd rust-door-client
cargo add doors

Then we can use this code which has been slightly modified from the crate documentation.

src/main.rs

use doors::Client;

fn main() {
    let client = Client::open("../my-file.door").unwrap();

    let input = b"Hello From Rust!!\0";
    let response = client.call_with_data(input).unwrap();
    let data = response.data();

    println!("{:#?}", data);
}

Notice that we are able to pass data into the client.call_with_data() function - the b"..." notation allows us to store a string as bytes (as a slice of u8s to be exact aka &[u8]).

We can run the server and this new Rust client with:

$ ./server
door created: ./my-file.door
waiting for requests...
client request received from pid 4880 uid 1000 zone 13
got data from client: Hello From Rust!!
$ cargo run -q
[
    104,
    101,
    108,
    108,
    111,
    32,
    102,
    114,
    111,
    109,
    32,
    100,
    111,
    111,
    114,
    95,
    102,
    117,
    110,
    99,
    0,
]

Ok great! We are almost there - our client was able to receive bytes from the server. The nice thing here is that we know our data is a null-terminated string (notice the last element of the output is 0) so we can convert it to a string. Change the client code to:

let response = client.call_with_data(input).unwrap();
let data = response.data();

let s = String::from_utf8_lossy(data);
println!("got data: {}", s);

Run the client again and we see:

$ cargo run -q
got data: hello from door_func

And there we go! We’ve successfully moved strings between the Rust client and C server, through the kernel!

But wait… one last thing.

Let’s take a deeper look at the Rust output:

$ cargo run -q --
got data: hello from door_func
$ cargo run -q -- | xxd
00000000: 676f 7420 6461 7461 3a20 6865 6c6c 6f20  got data: hello
00000010: 6672 6f6d 2064 6f6f 725f 6675 6e63 000a  from door_func..

The last 2 bytes are 00 and 0a. The 0a makes sense since println!() emits a newline, but the 00 is caused by our string being null-terminated coming from C. This is nothing specific to doors but I think it’s a good thing to remember that we are just transferring bytes to and from the server and we just so happened to make those strings suitable for our C programs to print to the user. Now that we are in Rust we don’t need to null-terminate our strings.

We can easily fix this bug by removing the last byte from the slice, or stripping the suffix of &[0], but I think the best way will be to tell Rust exactly what type of data this is: a C string.

We can do this by using the std::ffi::CStr type which represents a borrowed C string. Since the data from the C server is null-terminated, CStr::from_bytes_with_nul() can validate it and give us a string view without the trailing NUL. Update our client code to look like this:

use doors::Client;
use std::ffi::CStr;

fn main() {
    let client = Client::open("../my-file.door").unwrap();

    let input = b"Hello From Rust!!\0";
    let response = client.call_with_data(input).unwrap();
    let data = response.data();

    let c_str = CStr::from_bytes_with_nul(data).unwrap();
    let s = c_str.to_string_lossy();

    println!("got data: {}", s);
}

Run the client again and verify:

$ cargo run -q --
got data: hello from door_func
$ cargo run -q -- | xxd
00000000: 676f 7420 6461 7461 3a20 6865 6c6c 6f20  got data: hello
00000010: 6672 6f6d 2064 6f6f 725f 6675 6e63 0a    from door_func.

And there we go! No stray \0 bytes in the printed output.

Conclusion

Doors are a pretty neat illumos feature. The API is small enough that a simple example can be written in a couple of short C programs, but it is powerful enough to be used by core parts of the system. Having all of the underlying logic such as thread creation, handling multiple requests, etc. abstracted for you means it’s very easy to hit the ground running with them.

I don’t know how often I’ll reach for doors in my own programs, but after writing this I understand them a lot better. And, like a lot of these operating system features, specifically on illumos, once you know what to look for you start seeing them show up in a lot of places throughout the system.