← Back to Blog

Field notes

The wifi module stopped shelling out to iw

A Ahmed Bouallegui · ·from internship work on the lili wifi module
Diagram: on the left the wifi module forks and execs iw and ip before reaching the kernel; on the right it sends nl80211 and rtnetlink messages over one socket
Before: the module asks two other programs to change the radio. After: it sends the messages itself.

lili is the device detection project I worked on during the research internship: several probes (wifi, bluetooth, lora, gps) listen for objects nearby and publish what they hear to an MQTT bus. I worked on the wifi module, and the change I got the most out of is a deletion. The module used to reconfigure a wireless interface by starting another program. Two commits in July 2026 removed that habit. The code sits in the university GitLab and is not public, so everything quoted below is quoted from it.

The history in that repository goes back to 2021, so the module I joined had a few years of decisions in it, and calling iw was a reasonable one. It is quick to write and easy to read. It is also a subprocess, a PATH lookup, a dependency on a program being installed on the probe, and an exit status as the only way to find out what went wrong.

What the module does

It creates a monitor interface (mon0 on phy0 by default), captures on it with pcap, and checks that the datalink is DLT_IEEE802_11_RADIO before trusting anything. Each frame goes through a radiotap iterator and then an 802.11 management frame parser, which keeps two linked lists: devices and access points, each holding a MAC and the last signal level seen. Three MQTT topics carry the result. wifi/raw gets the frame itself, wifi/dev/new, upd and del get the objects, and wifi/net/link gets a pair of ids that were seen talking.

The radio does not stay put. A detached thread walks a FIFO of 31 channels across 2.4 and 5 GHz, sleeping 4 seconds between switches, so a full sweep takes just over two minutes.

The hot path was a subprocess

That thread called set_channel every four seconds. This is what set_channel was:

int set_channel(const char *iface, unsigned char chan)
{
    pid_t child;
    int status = 0;
    char str_chan[8] = {0};
    if (chan < 1 || chan > 140) {
        fprintf(stderr,
            "Tentative d'utiliser le channel WiFi %hhu, "
            "non-existant.\n",
            chan);
        return -1;
    }
    snprintf(str_chan, sizeof str_chan, "%hhu", chan);

    if (veryverbose) {
        printf("Execution de `iw dev %s set channel %s`\n", iface,
               str_chan);
    }
    if ((child = fork()) == 0) {
        execlp("iw", "iw", "dev", iface, "set", "channel", str_chan,
               NULL);
    }
    waitpid(child, &status, 0);
    return WEXITSTATUS(status);
}

A fork, an exec of whatever iw is first on PATH, a waitpid, and a WEXITSTATUS. Fifteen times a minute, about 900 times an hour, to hand the kernel something it exposes as a single netlink message.

Setup did it three more times

Bringing the capture interface up was the same pattern, repeated:

if ((child = fork()) == 0) {
        PERR_CALL_L0(execlp("iw", "iw", "phy", physical_iface,
                    "interface", "add", monitor_iface, "type",
                    "monitor", NULL));
    }
    waitpid(child, NULL, 0); // TODO: verifier echec

    for (char **iface = wifi_ifaces; *iface != NULL; iface++) {
        if (verbose) {
            printf("Desactivation de l'interface %s\n", *iface);
            printf("`ip link set %s down`\n", *iface);
        }
        if ((child = fork()) == 0) {
            PERR_CALL_L0(execlp("ip", "ip", "link", "set", *iface,
                        "down", NULL));
        }
        waitpid(child, NULL, 0);
    }

    if (verbose) {
        printf("Activation de l'interface %s\n", monitor_iface);
        printf("`ip link set %s up`\n", monitor_iface);
    }
    if ((child = fork()) == 0) {
        PERR_CALL_L0(execlp("ip", "ip", "link", "set", monitor_iface,
                    "up", NULL));
    }
    waitpid(child, NULL, 0);

Five execlp sites in the file. The error handling was a comment: waitpid(child, NULL, 0); // TODO: verifier echec. The list of interfaces to bring down was also hardcoded as {"wlan0", NULL}, which stops being funny the first time someone runs a probe with a USB adapter, because those come up named after their MAC. The config in the repository points at one such wlx... interface.

First commit: ask the kernel which interfaces exist

NL80211_CMD_GET_INTERFACE sent with NLM_F_DUMP | NLM_F_REQUEST gives one netlink message back per wireless interface. libnl hands each one to a callback, and the callback reads NL80211_ATTR_IFNAME and strdups it into a NULL-terminated array capped at 5 entries. That is the whole trick, and it replaced a hardcoded name.

The commit is 160 lines, and a suspicious number of them are socket boilerplate: allocate, genl_connect, genl_ctrl_resolve, allocate a message, install the callback, send, receive, free. The second commit pulled all of that into one place.

Second commit: the state changes go through netlink as well

437 insertions and 262 deletions across four files. It starts with a session object, opened once in main_init and closed in main_cleanup:

struct nl80211_session {
    struct nl_sock *sock;
    int id;    // id de la famille nl80211
};

static int nl80211_session_init(struct nl80211_session *s)
{
    int ret;

    s->sock = nl_socket_alloc();
    if (!s->sock) {
        fprintf(stderr, "Impossible d'allouer un socket netlink\n");
        return -1;
    }

    ret = genl_connect(s->sock);
    if (ret < 0) {
        fprintf(stderr, "Impossible de connecter le socket netlink (code %d)\n", ret);
        nl_socket_free(s->sock);
        return ret;
    }

    ret = genl_ctrl_resolve(s->sock, "nl80211");
    if (ret < 0) {
        fprintf(stderr, "Famille nl80211 non trouvee (code %d)\n", ret);
        nl_socket_free(s->sock);
        return ret;
    }
    s->id = ret;

    return 0;
}

Five nl80211 commands cover everything the module needs:

  • GET_INTERFACE, dumped, to list what is on the machine.
  • GET_WIPHY, dumped, to turn a name like phy0 into the u32 index nl80211 wants.
  • NEW_INTERFACE with WIPHY, IFNAME and IFTYPE set to NL80211_IFTYPE_MONITOR.
  • DEL_INTERFACE with an ifindex, called at startup in case mon0 already exists and again in main_cleanup.
  • SET_WIPHY with WIPHY_FREQ and CHANNEL_WIDTH, which is the one the hopping thread runs every four seconds.

They all have the same shape:

static int nl80211_create_interface(struct nl80211_session *s, int wiphy_idx,
                                    const char *ifname, enum nl80211_iftype type)
{
    struct nl_msg *msg;
    int ret;

    msg = nlmsg_alloc();
    if (!msg) return -1;

    genlmsg_put(msg, NL_AUTO_PORT, NL_AUTO_SEQ, s->id, 0, 0,
                NL80211_CMD_NEW_INTERFACE, 0);

    NLA_PUT_U32(msg, NL80211_ATTR_WIPHY, wiphy_idx);
    NLA_PUT_STRING(msg, NL80211_ATTR_IFNAME, ifname);
    NLA_PUT_U32(msg, NL80211_ATTR_IFTYPE, type);

    ret = nl_send_auto(s->sock, msg);
    if (ret < 0) {
        fprintf(stderr, "nl80211_create_interface: send error %d\n", ret);
        nlmsg_free(msg);
        return ret;
    }

    ret = nl_recvmsgs_default(s->sock);
    if (ret < 0)
        fprintf(stderr, "nl80211_create_interface: recv error %d\n", ret);

    nlmsg_free(msg);
    return ret;

nla_put_failure:
    nlmsg_free(msg);
    return -NLE_NOMEM;
}

Two things in there are worth knowing before you write your own. NLA_PUT_U32 is a macro that jumps to a label called nla_put_failure when an attribute does not fit, which is why every helper ends with that label returning -NLE_NOMEM. And nl80211 addresses interfaces by index, so if_nametoindex sits at the top of the ones that take a name.

Bringing an interface up is a different family

nl80211 creates the interface. It does not set IFF_UP, because that belongs to rtnetlink, so the two ip link set calls became one helper over libnl-route:

// Recuperation du lien actuel
    struct rtnl_link *link;
    ret = rtnl_link_get_kernel(sk, 0, ifname, &link);
    if (ret < 0) {
        fprintf(stderr, "rtnl_link_set_up: cannot get link %s (err %d)\n", ifname, ret);
        nl_socket_free(sk);
        return ret;
    }

    // Creation d'une copie pour appliquer les modifications
    struct rtnl_link *new_link = rtnl_link_alloc();
    if (!new_link) {
        rtnl_link_put(link);
        nl_socket_free(sk);
        return -NLE_NOMEM;
    }

    // Copie des proprietes existantes
    rtnl_link_set_ifindex(new_link, rtnl_link_get_ifindex(link));
    unsigned int flags = rtnl_link_get_flags(link);
    if (up)
        flags |= IFF_UP;
    else
        flags &= ~IFF_UP;
    rtnl_link_set_flags(new_link, flags);

    // Passage du lien original (non modifie) et du nouveau lien modifie
    ret = rtnl_link_change(sk, link, new_link, 0);

libnl wants the original object and the modified one so it can work out the difference itself, which is why the code copies the ifindex and the flags into a fresh rtnl_link before flipping the bit.

Channels became frequencies

The set path in nl80211 has no notion of a channel number. It takes NL80211_ATTR_WIPHY_FREQ in MHz, so the FIFO of channels became a FIFO of frequencies and one small function appeared:

static uint32_t channel_to_freq(unsigned int channel)
{
    if (channel >= 1 && channel <= 14)
        return 2407 + channel * 5;   // bande 2.4 GHz
    else if (channel >= 36 && channel <= 165)
        return 5000 + channel * 5;   // bande 5 GHz
    else
        return 0; // invalide
}

The 5 GHz line looks wrong for a second. It is not: channel 36 is 5180 MHz, and 5000 plus 36 times 5 is exactly that.

The hopping thread kept its shape and lost the fork:

void *channel_handler_run(__attribute__((unused)) void *v)
{
    uint32_t current_freq = START_FREQ;
    while (!quit) {
        current_freq = get_next_freq(&fifo);
        usleep(channel_switch_delay);
        if (nl_session_ptr)
            nl80211_set_freq(nl_session_ptr, monitor_iface, current_freq,
                             NL80211_CHAN_WIDTH_20_NOHT);
    }
    return NULL;
}

What the rewrite is worth

I did not benchmark it, so there is no before and after number here. What changed is structural. The steady state no longer creates a process every four seconds. The module no longer needs iw and ip installed to run. A failure comes back as a libnl error code printed next to the call that produced it, instead of an exit status from a child process.

The geteuid() != 0 check that used to sit in monitor_setup went away with the exec calls. The netlink send fails on its own now and prints its own error. I think that is the better trade, but it is harder to read when it happens over SSH on a machine you have not configured.

Two things are still open in that file. main_cleanup ends with //TODO (OPTIONNEL) liberer les interfaces wifi aussi, and the doc comment on the interface listing still carries \todo Séparer la sortie par interface physique, which starts to matter as soon as a probe has more than one radio. The header is also still declaring set_freq and next_channel, and neither has a definition anywhere in the tree any more.

Building just this module

The radiotap library the parser links against is packaged in Kali and, as far as I could find, nowhere convenient, so the README tells you to clone it and run cmake yourself. wifi/flake.nix builds this module on its own: it fetches radiotap-library into a derivation, writes a lili.h holding the handful of macros the module expects, and compiles three translation units against libnl, mosquitto, libpcap and cjson. It is still untracked in the repository, so treat it as scratch work rather than something the project has adopted.

If you are making the same replacement

  • • Do the read-only commands first. A GET_INTERFACE dump gives you a socket, a family id and a callback in one commit, and nothing is damaged if you get it wrong.
  • • Keep one socket for the life of the process. The first version resolved the family inside the listing function, which is fine once and wasteful in a loop.
  • • Work out which netlink family owns the operation before searching for a command. Interface creation is nl80211, IFF_UP is rtnetlink, and looking for an nl80211 command for the second one costs an afternoon.
  • • Read include/uapi/linux/nl80211.h. The attribute names live there and it is the only place they are authoritative.

Further reading

Keep reading

The config those machines run is written up in NixOS, in pieces (eight parts).

Where this fits: Wireless detection over nl80211 in the portfolio.