The Linux kernel exposes network interface (“link”) information through the NETLINK_ROUTE family of the netlink socket API. Unlike getifaddrs() or /proc/net/dev, rtnetlink gives you the same structured data that ip link uses: interface type, flags, MTU, hardware address, queueing discipline, link kind (bridge/veth/wireguard/…), slave relationships, and 64-bit statistics.
This post walks through a complete C++ program that sends an RTM_GETLINK dump request and prints every link the kernel reports, decoding the most useful IFLA_* attributes.
How it works
The program follows the standard rtnetlink dump pattern:
- Open a
NETLINK_ROUTEsocket withsocket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE). - Bind it to your own PID (optional for a one-shot dump, but good practice).
- Send an
RTM_GETLINKrequest withNLM_F_DUMP— this asks the kernel for all links. The request payload is just annlmsghdrfollowed by anifinfomsgwithifi_family = AF_UNSPEC(all address families). recv()replies in a loop until you hit anNLMSG_DONEmessage. The kernel may split the dump across several datagrams, so keep reading until the done marker.- For each
RTM_NEWLINKmessage, theifinfomsgholds the fixed fields (index, type, flags) and is followed by a sequence ofrtattrs carrying the variable-length details. Walk them withRTA_OK/RTA_NEXTand dispatch onrta_type.
See How to iterate rtnetlink rtattr attributes for a focused look at the attribute-walking idiom.
The full program
#include <iostream>
#include <cstdint>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#include <net/if.h>
// Format a hardware address (MAC or other L2 address) of any length.
// Ethernet addresses are 6 bytes; other link layers may differ, so we
// branch on RTA_PAYLOAD rather than assuming a fixed size.
void print_hwaddr(const char* label, struct rtattr* rta) {
int len = RTA_PAYLOAD(rta);
auto* mac = reinterpret_cast<unsigned char*>(RTA_DATA(rta));
if (len == 6) {
char mac_str[18];
snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
std::cout << " " << label << ": " << mac_str << "\n";
} else if (len > 0) {
std::cout << " " << label << ": ";
for (int i = 0; i < len; ++i) {
std::cout << (i ? ":" : "") << std::hex << static_cast<int>(mac[i]) << std::dec;
}
std::cout << "\n";
}
}
// Parse the nested IFLA_LINKINFO attribute.
// IFLA_LINKINFO is a container: its payload is itself a list of rtattr's,
// the most useful being IFLA_INFO_KIND (e.g. "bridge", "veth", "wireguard")
// and IFLA_INFO_SLAVE_KIND (the master driver when the link is enslaved,
// e.g. "bridge" for a veth port attached to a bridge).
void print_linkinfo(struct rtattr* rta) {
int attr_len = RTA_PAYLOAD(rta);
auto* nested = reinterpret_cast<struct rtattr*>(RTA_DATA(rta));
for (; RTA_OK(nested, attr_len); nested = RTA_NEXT(nested, attr_len)) {
switch (nested->rta_type) {
case IFLA_INFO_KIND:
std::cout << " Kind: " << reinterpret_cast<char*>(RTA_DATA(nested)) << "\n";
break;
case IFLA_INFO_SLAVE_KIND:
std::cout << " Slave Kind: " << reinterpret_cast<char*>(RTA_DATA(nested)) << "\n";
break;
}
}
}
// Decode the ifi_flags bitmask into human-readable IFF_* names.
// The raw numeric value is also printed for completeness.
void print_flags(unsigned int flags) {
std::cout << " Flags: ";
if (flags & IFF_UP) std::cout << "UP ";
if (flags & IFF_BROADCAST) std::cout << "BROADCAST ";
if (flags & IFF_DEBUG) std::cout << "DEBUG ";
if (flags & IFF_LOOPBACK) std::cout << "LOOPBACK ";
if (flags & IFF_POINTOPOINT) std::cout << "POINTOPOINT ";
if (flags & IFF_RUNNING) std::cout << "RUNNING ";
if (flags & IFF_NOARP) std::cout << "NOARP ";
if (flags & IFF_PROMISC) std::cout << "PROMISC ";
if (flags & IFF_MULTICAST) std::cout << "MULTICAST ";
std::cout << "(" << flags << ")\n";
}
int main() {
// 1. Create a Netlink socket for NETLINK_ROUTE.
// SOCK_RAW gives access to the raw netlink protocol headers.
int sock_fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (sock_fd < 0) {
perror("socket");
return 1;
}
// 2. Bind the socket to our own PID.
// nl_groups = 0 means we only want replies to our own requests,
// not multicast notifications (those require RTMGRP_LINK).
struct sockaddr_nl sa{};
sa.nl_family = AF_NETLINK;
sa.nl_pid = getpid();
sa.nl_groups = 0;
if (bind(sock_fd, reinterpret_cast<struct sockaddr*>(&sa), sizeof(sa)) < 0) {
perror("bind");
close(sock_fd);
return 1;
}
// 3. Prepare the RTM_GETLINK dump request.
// The message is an nlmsghdr followed by an ifinfomsg.
// NLM_F_DUMP tells the kernel to return *all* links in one or
// more RTM_NEWLINK replies, terminated by NLMSG_DONE.
struct {
struct nlmsghdr nlh;
struct ifinfomsg ifm;
} req{};
req.nlh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP;
req.nlh.nlmsg_type = RTM_GETLINK;
req.ifm.ifi_family = AF_UNSPEC; // all address families
// 4. Send the request to the kernel (pid 0 = kernel).
struct sockaddr_nl dest{};
dest.nl_family = AF_NETLINK;
struct iovec iov = { &req, req.nlh.nlmsg_len };
struct msghdr msg = { &dest, sizeof(dest), &iov, 1, nullptr, 0, 0 };
if (sendmsg(sock_fd, &msg, 0) < 0) {
perror("sendmsg");
close(sock_fd);
return 1;
}
// 5. Read replies until NLMSG_DONE.
// A large dump may span multiple datagrams, so we loop on recv().
// Each datagram can contain several netlink messages back-to-back,
// which we walk with NLMSG_OK / NLMSG_NEXT.
char buf[16384];
bool running = true;
while (running) {
ssize_t len = recv(sock_fd, buf, sizeof(buf), 0);
if (len < 0) {
perror("recv");
break;
}
auto* nlh = reinterpret_cast<struct nlmsghdr*>(buf);
for (; NLMSG_OK(nlh, len); nlh = NLMSG_NEXT(nlh, len)) {
// NLMSG_DONE marks the end of the dump.
if (nlh->nlmsg_type == NLMSG_DONE) {
running = false;
break;
}
// NLMSG_ERROR carries an error code (negative errno) for the
// request itself; err->error is 0 on success.
if (nlh->nlmsg_type == NLMSG_ERROR) {
auto* err = reinterpret_cast<struct nlmsgerr*>(NLMSG_DATA(nlh));
std::cerr << "Netlink error: " << strerror(-err->error) << "\n";
running = false;
break;
}
// RTM_NEWLINK is one link in the dump.
if (nlh->nlmsg_type == RTM_NEWLINK) {
auto* ifi = reinterpret_cast<struct ifinfomsg*>(NLMSG_DATA(nlh));
int attr_len = IFLA_PAYLOAD(nlh);
struct rtattr* rta = IFLA_RTA(ifi);
std::cout << "----------------------------------------\n";
std::cout << "Interface Index: " << ifi->ifi_index << "\n";
std::cout << "Type (Family): " << ifi->ifi_type << "\n";
std::cout << "Flags (Kernel): ";
print_flags(ifi->ifi_flags);
// Walk the rtattr list following the ifinfomsg.
// attr_len is mutated in place by RTA_NEXT, so it must
// be a real variable, not a temporary.
for (; RTA_OK(rta, attr_len); rta = RTA_NEXT(rta, attr_len)) {
switch (rta->rta_type) {
case IFLA_IFNAME:
std::cout << " Name: " << reinterpret_cast<char*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_MTU:
std::cout << " MTU: * " << *reinterpret_cast<uint32_t*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_ADDRESS:
print_hwaddr("MAC", rta);
break;
case IFLA_BROADCAST:
print_hwaddr("Broadcast", rta);
break;
case IFLA_LINK:
// IfIndex of the parent/underlying device
// (e.g. the bridge a veth is enslaved to,
// or the physical device a VLAN sits on).
std::cout << " Parent IfIndex: " << *reinterpret_cast<uint32_t*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_LINKINFO:
// Nested: link kind and slave kind.
print_linkinfo(rta);
break;
case IFLA_QDISC:
std::cout << " Qdisc: " << reinterpret_cast<char*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_TXQLEN:
std::cout << " TX Queue Len: " << *reinterpret_cast<uint32_t*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_LINKMODE: {
// 0 = default, 1 = dormant (used by e.g. wifi
// drivers while associating).
uint8_t mode = *reinterpret_cast<uint8_t*>(RTA_DATA(rta));
const char* m = (mode == 0) ? "default" : "dormant";
std::cout << " Link Mode: " << m << " (" << static_cast<int>(mode) << ")\n";
break;
}
case IFLA_GROUP:
std::cout << " Group: " << *reinterpret_cast<uint32_t*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_PROMISCUITY:
std::cout << " Promiscuity: " << *reinterpret_cast<uint32_t*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_NUM_TX_QUEUES:
std::cout << " Num TX Queues: " << *reinterpret_cast<uint32_t*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_NUM_RX_QUEUES:
std::cout << " Num RX Queues: " << *reinterpret_cast<uint32_t*>(RTA_DATA(rta)) << "\n";
break;
case IFLA_CARRIER:
// 1 = carrier present (link up at L1), 0 = no carrier.
std::cout << " Carrier: " << (*reinterpret_cast<uint8_t*>(RTA_DATA(rta)) ? "yes" : "no") << "\n";
break;
case IFLA_STATS64: {
// Full 64-bit counters; IFLA_STATS is the
// 32-bit legacy variant and may wrap on 32-bit.
auto* s = reinterpret_cast<struct rtnl_link_stats64*>(RTA_DATA(rta));
std::cout << " Stats64:\n";
std::cout << " rx_packets: " << s->rx_packets
<< " tx_packets: " << s->tx_packets << "\n";
std::cout << " rx_bytes: " << s->rx_bytes
<< " tx_bytes: " << s->tx_bytes << "\n";
std::cout << " rx_errors: " << s->rx_errors
<< " tx_errors: " << s->tx_errors << "\n";
std::cout << " rx_dropped: " << s->rx_dropped
<< " tx_dropped: " << s->tx_dropped << "\n";
std::cout << " multicast: " << s->multicast
<< " collisions: " << s->collisions << "\n";
break;
}
case IFLA_OPERSTATE: {
// RFC 2863 operational state. This is the
// *administrative* view the driver reports,
// which may lag behind IFF_RUNNING.
uint8_t state = *reinterpret_cast<uint8_t*>(RTA_DATA(rta));
const char* state_str = "UNKNOWN";
if (state == 1) state_str = "NOTPRESENT";
else if (state == 2) state_str = "DOWN";
else if (state == 3) state_str = "LOWERLAYERDOWN";
else if (state == 4) state_str = "TESTING";
else if (state == 5) state_str = "DORMANT";
else if (state == 6) state_str = "UP";
std::cout << " State: " << state_str << "\n";
break;
}
}
}
}
}
}
close(sock_fd);
return 0;
}Building and running
Compile with any recent GCC or Clang — no external libraries are needed, only the kernel UAPI headers:
g++ -o list_links list_links.cpp
./list_linksThe program needs no special privileges: reading the link dump via rtnetlink is allowed for unprivileged users (the same way ip link show works without root).
Example output
The output below is an excerpt (counts trimmed) showing several link types the kernel reports on a typical Docker + WireGuard + Tailscale host: a loopback, a Wi-Fi interface, two bridges, a veth pair port enslaved to a bridge, a WireGuard interface, and a TUN interface.
----------------------------------------
Interface Index: 1
Type (Family): 772
Flags (Kernel): Flags: UP LOOPBACK RUNNING (65609)
Name: lo
TX Queue Len: 1000
State: UNKNOWN
Link Mode: default (0)
MTU: * 65536
Group: 0
Promiscuity: 0
Num TX Queues: 1
Num RX Queues: 1
Carrier: yes
MAC: 00:00:00:00:00:00
Broadcast: 00:00:00:00:00:00
Stats64:
rx_packets: 4136700 tx_packets: 4136700
rx_bytes: 5111788519 tx_bytes: 5111788519
rx_errors: 0 tx_errors: 0
rx_dropped: 0 tx_dropped: 0
multicast: 0 collisions: 0
Qdisc: noqueue
----------------------------------------
Interface Index: 3
Type (Family): 1
Flags (Kernel): Flags: UP BROADCAST RUNNING MULTICAST (69699)
Name: wlo1
TX Queue Len: 1000
State: UP
Link Mode: dormant (1)
MTU: * 1500
Group: 0
Promiscuity: 0
Num TX Queues: 1
Num RX Queues: 1
Carrier: yes
MAC: 3a:0c:8c:7d:72:47
Broadcast: ff:ff:ff:ff:ff:ff
Stats64:
rx_packets: 544381319 tx_packets: 440923730
rx_bytes: 543087539697 tx_bytes: 474255927621
rx_errors: 0 tx_errors: 0
rx_dropped: 515514 tx_dropped: 832
multicast: 0 collisions: 0
Qdisc: noqueue
----------------------------------------
Interface Index: 8
Type (Family): 1
Flags (Kernel): Flags: UP BROADCAST RUNNING MULTICAST (69699)
Name: docker0
TX Queue Len: 0
State: UP
Link Mode: default (0)
MTU: * 1500
Group: 0
Promiscuity: 0
Num TX Queues: 1
Num RX Queues: 1
Carrier: yes
MAC: 36:2c:d8:10:0f:2f
Broadcast: ff:ff:ff:ff:ff:ff
Stats64:
rx_packets: 46998 tx_packets: 605301
rx_bytes: 3447273 tx_bytes: 157253308
rx_errors: 0 tx_errors: 0
rx_dropped: 0 tx_dropped: 1246
multicast: 0 collisions: 0
Kind: bridge
Qdisc: noqueue
----------------------------------------
Interface Index: 10
Type (Family): 1
Flags (Kernel): Flags: UP BROADCAST RUNNING MULTICAST (69699)
Name: veth822ece7
TX Queue Len: 0
State: UP
Link Mode: default (0)
MTU: * 1500
Group: 0
Promiscuity: 1
Num TX Queues: 16
Num RX Queues: 16
Carrier: yes
MAC: 6e:77:0d:65:d6:70
Broadcast: ff:ff:ff:ff:ff:ff
Stats64:
rx_packets: 770 tx_packets: 541840
rx_bytes: 10133319 tx_bytes: 25808929
rx_errors: 0 tx_errors: 0
rx_dropped: 0 tx_dropped: 0
multicast: 0 collisions: 0
Kind: veth
Slave Kind: bridge
Parent IfIndex: 2
Qdisc: noqueue
----------------------------------------
Interface Index: 211
Type (Family): 65534
Flags (Kernel): Flags: UP POINTOPOINT RUNNING NOARP (65745)
Name: MyWireguard
TX Queue Len: 1000
State: UNKNOWN
Link Mode: default (0)
MTU: * 1420
Group: 0
Promiscuity: 0
Num TX Queues: 1
Num RX Queues: 1
Carrier: yes
Stats64:
rx_packets: 570585 tx_packets: 470542
rx_bytes: 524515448 tx_bytes: 48155876
rx_errors: 0 tx_errors: 7371
rx_dropped: 0 tx_dropped: 2
multicast: 0 collisions: 0
Kind: wireguard
Qdisc: noqueue
----------------------------------------
Interface Index: 290
Type (Family): 65534
Flags (Kernel): Flags: UP POINTOPOINT RUNNING NOARP MULTICAST (69841)
Name: tailscale0
TX Queue Len: 500
State: UNKNOWN
Link Mode: default (0)
MTU: * 1280
Group: 0
Promiscuity: 0
Num TX Queues: 1
Num RX Queues: 1
Carrier: yes
Stats64:
rx_packets: 14398 tx_packets: 63541
rx_bytes: 2415236 tx_bytes: 5489048
rx_errors: 0 tx_errors: 0
rx_dropped: 0 tx_dropped: 0
multicast: 0 collisions: 0
Kind: tun
Qdisc: fq_codelA few things worth noting in the output:
Type (Family)is the ARP hardware type (ifi_type), not an address family.1is Ethernet (ARPHRD_ETHER),772is loopback (ARPHRD_LOOPBACK), and65534is the catch-allARPHRD_NONEused by tunnel interfaces such as WireGuard and TUN.Kind:comes fromIFLA_LINKINFO→IFLA_INFO_KINDand identifies the link driver:bridge,veth,wireguard,tun, etc. Loopback and physical interfaces usually omit it.Slave Kind:is present when the link is the port of a master device. Theveth822ece7entry showsKind: veth(what it is) andSlave Kind: bridge(what it’s attached to), plusParent IfIndex: 2pointing at the bridge.Promiscuity: 1on the veth is normal: bridges put their ports into promiscuous mode so they receive every frame, not just unicast destined for their MAC.Link Mode: dormant (1)on the Wi-Fi interface means the driver uses the dormant state while the link is not yet fully operational (e.g. during association); once carrier comes up the operational state moves toUP.- Tunnel interfaces (
wireguard,tun) reportPOINTOPOINTandNOARPflags and have noMAC/Broadcastattributes — they are L3-only, so there is no L2 address to report.
Also see How to iterate rtnetlink rtattr attributes for the attribute-walking idiom used throughout the program, How to filter physical ethernet links using rtnetlink for filtering the dump down to physical wired NICs, and How to filter VLAN interfaces using rtnetlink for filtering the dump down to VLAN interfaces.