Everything Is a File Descriptor

Unix has one of the cleanest abstractions in all of computing: everything is a file descriptor. Sockets, pipes, devices, actual files, /proc entries — they all speak the same language of read() and write(). In theory, this means you can compose things. A network socket can go where a pipe goes. An encrypted stream looks the same as a plain one, from the outside.

In practice, the C API for file descriptors is tedious enough that people stop caring and just hardcode integers everywhere. So I spent some time writing a set of C++ experiments to make that composability real, and to learn how a bunch of Unix features actually work under the hood.

This post covers the most interesting pieces: a smart FD wrapper, composable process pipelines, AES encryption as a transparent FD transform, UDP sockets that look like regular FDs, automatic UPnP port forwarding, and a mini background job scheduler.


The FD Problem

The raw C API looks like this:

int fd = open("data.bin", O_RDONLY);
int a, b, c;
char name[64];

int n;
n = read(fd, &a, sizeof(a)); if (n != sizeof(a)) break;
n = read(fd, &b, sizeof(b)); if (n != sizeof(b)) break;
n = read(fd, &c, sizeof(c)); if (n != sizeof(c)) break;
n = read(fd, name, sizeof(name)); if (n != sizeof(name)) break;

close(fd);

Three problems here. First, you have to remember to call close() — and if something throws or returns early, you probably won't. Second, every read() needs a manual size check. Third, fd is just an int, so nothing stops you from accidentally passing the wrong one somewhere.


The FD Class: RAII + Stream Operators

The fix is a thin C++ wrapper around the raw int:

class FD {
    struct FDHandler {
        int fd = -1;
        ~FDHandler() { close(fd); }  // automatic close
    };
    std::shared_ptr<FDHandler> fd;
    bool errored = false, eof = false;

public:
    FD(int _fd) { fd = std::make_shared<FDHandler>(_fd); }

    // Stream-style read: calls read() syscall directly
    template <typename T>
    FD& operator>>(T& value) {
        int status = read(fd->fd, &value, sizeof(T));
        errored |= status < 0;
        eof    |= status == 0;
        return *this;
    }

    // Stream-style write: calls write() syscall directly
    template <typename T>
    FD& operator<<(const T& value) {
        int status = write(fd->fd, &value, sizeof(T));
        errored |= status < 0;
        return *this;
    }

    operator bool() const { return !errored && !eof; }
};

The shared_ptr means multiple FD objects can reference the same underlying descriptor, and close() fires when the last one is destroyed. No manual cleanup.

The stream operators don't serialize to text like std::iostream does — they call read() and write() directly on the raw binary representation. So this reads three packed integers and a name from a binary file:

FD file = open("data.bin", O_RDONLY);
int a, b, c;
char name[64];

while (file >> a >> b >> c >> name) {
    std::cout << name << ": " << (a + b + c) << "\n";
}
// close() happens automatically here

And copying one FD to another (think: piping) is a one-liner when you specialize the is_fd trait for FD:

FD input  = 0;   // stdin
FD output = 1;   // stdout

input >> output; // splice all of stdin to stdout

Under the hood that uses splice() instead of a read/write loop, which is a Linux syscall that moves data between file descriptors in kernel space without copying it to userland first.


Building Unix Pipelines Without system()

When you run cat /proc/cpuinfo | grep MHz in your shell, it creates two processes connected by a pipe. Shells do this with fork(), pipe(), and dup2(). Most programs that need to run other programs just call system("..."), which is convenient but has some annoying properties — it goes through /bin/sh, it blocks, and you can't easily get a readable FD from it.

Here's a cleaner C++ approach:

inline int execute_pipe(int input_fd, const Command& args) {
    int pipe_fd[2];
    pipe(pipe_fd);

    if (fork() == 0) {
        if (input_fd != 0) { dup2(input_fd, 0); close(input_fd); }
        dup2(pipe_fd[1], 1);
        close(pipe_fd[0]); close(pipe_fd[1]);
        execvp(args[0], ...); // mutate child into the target program
    }

    close(pipe_fd[1]);
    return pipe_fd[0]; // caller reads the output from here
}

The child calls dup2 to rewire its stdin and stdout to the pipe ends, then calls execvp to replace itself with the target program. The parent closes the write end of the pipe and returns the read end. The caller can chain calls:

// Build: cat /proc/cpuinfo | grep MHz | grep 2000
int fd = execute_pipe_line(0, {
    {"cat", "/proc/cpuinfo"},
    {"grep", "MHz"},
    {"grep", "2000"}
});
cpy(fd, 1); // copy result to stdout

Each execute_pipe call spawns one child process and returns a read-end FD. They chain together without blocking, giving you something equivalent to a shell pipeline but entirely in C++.

Data flowing through a Unix pipeline (each box is a separate process)


Encrypting a Stream In-Flight

Here is the idea: what if you could wrap any file descriptor in an AES encryption layer, and the result was just another file descriptor? The caller wouldn't need to know or care.

The trick is virtual_pipe:

template <typename Transform>
int virtual_pipe(int input_fd, Transform&& transform) {
    int pipe_fd[2];
    pipe(pipe_fd);

    if (fork() == 0) {
        close(pipe_fd[0]);
        transform(input_fd, pipe_fd[1]); // e.g. encrypt
        exit(0);
    }

    close(input_fd);
    close(pipe_fd[1]);
    return pipe_fd[0]; // caller reads the transformed data
}

Fork a child, let it do the transform (reading from input_fd, writing encrypted bytes to the pipe), and return the readable end to the parent. The parent sees a normal file descriptor that happens to produce encrypted data.

Using OpenSSL's EVP API for AES-256-CBC:

// input_fd → [AES encrypt] → returned fd
int encrypted = virtual_pipe(input_fd, [&](int in, int out) {
    encrypt(in, out, key);
});

// encrypted → [AES decrypt] → returned fd
int decrypted = virtual_pipe(encrypted, [&](int in, int out) {
    decrypt(in, out, key);
});

cpy(decrypted, 1); // should match original

The AES encrypt/decrypt functions themselves process fixed-size blocks (4096 bytes at a time), prepending each block with a small header that says how many encrypted bytes follow. The CBC mode uses OpenSSL's EVP_EncryptUpdate and EVP_EncryptFinal_ex so padding is handled correctly.

I also ported a small standalone AES-128 implementation (by kokke, public domain) that doesn't depend on OpenSSL at all, for embedded or minimal environments. It supports ECB, CBC, and CTR modes and is verified against NIST test vectors.

A fun extension from the README: you can combine virtual_pipe with a UDP socket. Write to the encrypted FD and the data goes through AES encryption before being sent over UDP:

// Conceptually:
FD enc = aes_crypt(UDP_FD("192.168.1.24", 9776), key, encrypt);
// Writing to enc → encrypts → sends over UDP

UDP as Just Another File Descriptor

TCP gives you a stream: you connect, then read/write like a file. UDP doesn't — you use sendto and recvfrom with explicit socket addresses on every call. That breaks composability.

The fix is a UDP_FD class that overloads the read and write functions to call sendto/recvfrom internally:

class UDP_FD {
    struct sockaddr_in client_info, client_other;
    FD socket_fd;
public:
    UDP_FD(int port);                          // server: bind to port
    UDP_FD(const string& host, int port);      // client: target address
};

inline int write(UDP_FD& fd, const void* buf, size_t len) {
    return sendto(fd.get_fd(), buf, len, 0,
                  (struct sockaddr*)fd.get_client_info(), fd.client_size());
}
inline int read(UDP_FD& fd, void* buf, size_t len) {
    socklen_t sz = fd.client_size();
    return recvfrom(fd.get_fd(), buf, len, 0,
                    (struct sockaddr*)fd.get_other_info(), &sz);
}

Now UDP_FD acts like any other FD in templates that use read/write. The cpy(src, dst) helper works without knowing whether src is a TCP socket, a pipe, a file, or a UDP socket.

There's also a create_udp_server helper that demuxes incoming UDP packets by source address, forking a child process for each new client (since UDP is connection-less, this is the way to handle multiple clients if you want per-client state).


Making Your Router Open Ports Automatically

UPnP (Universal Plug and Play) is a protocol that lets software ask the local router to open external ports and forward them to a specific LAN host. It's how applications like BitTorrent clients, video game consoles, and VoIP apps punch through NAT without asking you to log in to your router admin panel.

The library miniupnpc handles the SSDP discovery and SOAP calls. The wrapper makes it RAII:

class UPnPDevice {
    int upnp_controller;
    string localport, externalport;
public:
    UPnPDevice(int controller, const string& name,
               const string& localport, const string& externalport,
               bool remote, int timeout);
    ~UPnPDevice(); // removes the port mapping on destruction
};

Here's how you use it:

int ctrl = create_upnp_controller(); // discover the router via SSDP
if (ctrl == -1) { /* no UPnP-capable router found */ }

// Ask the router to forward external port 7653 → local port 8080
UPnPDevice device(ctrl, "MyApp", "8080", "7653", false, 86400);

cout << "WAN address: " << get_wan_address(ctrl) << "\n";
debug_dump_upnp_controller_list(ctrl); // print all current port mappings

// When 'device' goes out of scope, the port mapping is automatically deleted

It maps both TCP and UDP for each port (because many protocols need both). The destructor calls UPNP_DeletePortMapping so you don't leave stale router rules behind.

The actual flow: upnpDiscover broadcasts an SSDP (Simple Service Discovery Protocol) packet over UDP to 239.255.255.250:1900. Any UPnP-capable device on the LAN responds. The library then talks SOAP over HTTP to the IGD (Internet Gateway Device) control URL to add or remove port mappings.


A Tiny Process Scheduler

The scheduler is a small background daemon that manages long-running jobs. You send it commands over a named pipe (a FIFO file) and it forks child processes, tracks them, and sends desktop notifications when jobs start or finish.

The IPC mechanism is a named FIFO:

void process(const char* pipe_path) {
    mkfifo(pipe_path, 0700); // create the FIFO file
    close(0);
    int fd;
    while ((fd = open(pipe_path, O_RDONLY)) != -1) {
        // read a command, e.g. "A /path/to/dir jobname ./build.sh"
        string data = readAll(fd);
        handle_command(data);
        close(fd);
    }
}

A client sends a command by just writing to the FIFO path. Each open() blocks until a writer connects, and each read() gets one complete message (a FIFO write is atomic for small sizes). This is the simplest possible IPC pattern — no sockets, no shared memory, just a special file.

For tracking child processes, the scheduler uses SIGCHLD:

void handle_signal(int s) {
    if (s == SIGCHLD) {
        int pid, exit_code;
        while ((pid = waitpid(-1, &exit_code, 0)) != -1) {
            bool killed  = !WIFEXITED(exit_code);
            int  status  = killed ? WTERMSIG(exit_code) : WEXITSTATUS(exit_code);
            scheduler.finish(pid, status, killed);
        }
    }
    else if (s == SIGINT) {
        // propagate shutdown to all children
        for (auto& [pid, _] : scheduler.jobsTable)
            kill(pid, SIGTERM);
        exit(0);
    }
}

SIGCHLD fires whenever any child process exits (or is killed). waitpid(-1, ...) with the loop handles the case where multiple children exit simultaneously (you could get only one signal for several exits, so you drain the queue). WIFEXITED / WEXITSTATUS / WTERMSIG decode the status word the kernel gives you.

The SA_RESTART flag on the signal handler is important: it tells the kernel to restart interrupted system calls (like read) automatically, instead of returning EINTR and making you handle that yourself.

When a job starts or finishes, the scheduler calls send_notification, which (on Linux) can use notify-send or similar to pop a desktop notification. The tracked state is minimal:

struct Scheduler {
    map<int, JobDesc> jobsTable;   // pid → {name, command}
    int completed_jobs = 0;
    int initiated_jobs = 0;
};

Other Things in the Box

A few other experiments worth a quick mention:

mmap file reading (io.cc): instead of reading a file in chunks with read(), mmap maps it directly into the process's virtual address space. The kernel handles paging on demand, and you get a char* to the whole file without copying it through a userspace buffer:

std::string readFile(const char* filename) {
    int fd = open(filename, O_RDONLY);
    struct stat s; fstat(fd, &s);
    char* data = (char*)mmap(0, s.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);
    std::string result(data, s.st_size);
    munmap(data, s.st_size);
    return result;
}

For large files this is noticeably faster than a read() loop. For small files it doesn't matter much.

STUN binding request (stun.h): STUN (Session Traversal Utilities for NAT) is the protocol that tells you your public IP and port from behind a NAT. The binding request is exactly 20 bytes in a specific format. Parsing the response gives you your external address, which is how WebRTC and similar things figure out where they live on the internet.

RSA key storage (keystorer/): a small key store that reads serialized RSA public keys (from OpenSSL), compares them by value rather than pointer, and tracks which ones need to be synced to disk.


What I Got Out of This

The most useful thing isn't any single project — it's the mental model. Once you think of everything as a file descriptor and fork+exec+pipe as your composition primitive, a lot of things click into place. Shells are just programs that combine these three syscalls in a loop. Daemon processes are just programs that detach from their terminal and manage their own SIGCHLD. Network proxies are just programs that splice between two sockets.

The C++ layer on top (the FD class, UDP_FD, virtual_pipe) makes the composability literal: you can take any source of bytes and any sink of bytes and connect them, whether they're files, sockets, pipes, or AES encryption processes running in the background.

Annoyingly fun to write. Worth doing if you've only ever used fopen and boost::asio and wondered what happens underneath.