If you run OpenWrt as a dumb access point, you’ve probably noticed that LuCI’s Network > Wireless > Associated Stations page shows ? for every client hostname. This is because the AP doesn’t run a DHCP server - another device (your router) handles that - so /tmp/dhcp.leases is empty and LuCI has nothing to look up.

The hostnames are right there, though. DHCP packets from clients flow through the AP’s bridge, and they contain the hostname in Option 12. We just need to capture them.

The Problem

LuCI resolves hostnames for wireless clients via rpcd-mod-luci, which reads /tmp/dhcp.leases. On a full router running dnsmasq as the DHCP server, this file is populated automatically. On a dumb AP with DHCP disabled, it’s always empty.

The clients are sending their hostnames - every DHCP Discover and Request includes Option 12 (Hostname). The packets transit the AP’s bridge (br-lan) on their way to the router. We can sniff them with tcpdump and write the results to the lease file ourselves.

Verifying the Data is There

Before writing any scripts, confirm you can see hostnames in the DHCP traffic:

1
tcpdump -l -i br-lan -n -e -vv 'udp port 67'

You should see output like:

BOOTP/DHCP, Request from aa:bb:cc:dd:ee:f1, length 291
      Client-IP 192.168.1.42
      Client-Ethernet-Address aa:bb:cc:dd:ee:f1
      ...
      Hostname (12), length 9: "my-laptop"

If Hostname (12) appears, you’re good.

The Approach

The DHCP exchange between a client and server looks like this:

  1. Client Request - contains the hostname (Option 12), MAC address, and sometimes a requested IP
  2. Server ACK - contains the granted IP (Your-IP), actual lease time (Lease-Time), and the client’s MAC

Both packets pass through br-lan. The script correlates them: it stores the hostname from the Request, then when the ACK arrives for the same MAC, it grabs the actual lease time and IP, and writes a complete lease entry.

The script also learns the DHCP server’s MAC automatically from the first Reply packet it sees, so it can filter out the server’s own DHCP traffic (my upstream router was spamming Discovers every few seconds and polluting the lease file).

The Script

Install tcpdump-mini:

1
apk add tcpdump-mini

Create /usr/sbin/dhcp-hostname-sniff:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
#!/bin/sh
LEASE_FILE=/tmp/dhcp.leases
IFACE=${1:-br-lan}
SELF_MAC=$(cat /sys/class/net/br-lan/address)
FIFO=/tmp/dhcp-sniff.fifo
SERVER_MAC=""

rm -f "$FIFO"
mkfifo "$FIFO"

cleanup() {
    kill "$TCPDUMP_PID" 2>/dev/null
    rm -f "$FIFO"
    exit 0
}
trap cleanup TERM INT

tcpdump -l -i "$IFACE" -n -e -vv 'udp port 67' > "$FIFO" 2>/dev/null &
TCPDUMP_PID=$!

ether_src=""
is_reply=0
mac=""
hostname=""
ip=""
lease_time=""

while IFS= read -r line; do
    case "$line" in
        [0-9][0-9]:*)
            ether_src="${line#* }"
            ether_src="${ether_src%% *}"
            ;;
        *"BOOTP/DHCP, Reply"*)
            is_reply=1
            mac=""
            ip=""
            lease_time=""
            if [ -z "$SERVER_MAC" ]; then
                SERVER_MAC="$ether_src"
            fi
            ;;
        *BOOTP/DHCP*)
            is_reply=0
            mac=""
            hostname=""
            ip=""
            if [ -n "$SERVER_MAC" ] && [ "$ether_src" = "$SERVER_MAC" ]; then
                is_reply=2
            fi
            ;;
        *"Client-Ethernet-Address"*)
            mac="${line##* }"
            ;;
        *"Client-IP"*)
            ip="${line##* }"
            ;;
        *"Requested-IP"*)
            ip="${line##* }"
            ;;
        *"Your-IP"*)
            ip="${line##* }"
            ;;
        *"Hostname (12), length"*)
            _h="${line#*: \"}"
            hostname="${_h%%\"*}"
            ;;
        *"Lease-Time (51)"*)
            _lt="${line##* }"
            lease_time="$_lt"
            ;;
    esac

    # Client request: store hostname by MAC
    if [ "$is_reply" = 0 ] && [ -n "$mac" ] && [ -n "$hostname" ]; then
        eval "host_$(echo "$mac" | tr ':' '_')=\"$hostname\""
        eval "ip_$(echo "$mac" | tr ':' '_')=\"$ip\""
        mac=""
        hostname=""
    fi

    # Server reply: write lease
    if [ "$is_reply" = 1 ] && [ -n "$mac" ] && [ -n "$lease_time" ]; then
        if [ "$mac" = "$SELF_MAC" ]; then
            mac=""
            lease_time=""
            continue
        fi
        _key=$(echo "$mac" | tr ':' '_')
        eval "stored_host=\$host_$_key"
        if [ -n "$stored_host" ]; then
            if [ -z "$ip" ]; then
                eval "ip=\$ip_$_key"
            fi
            if [ -z "$ip" ]; then
                ip="0.0.0.0"
            fi
            expiry=$(($(date +%s) + lease_time))
            now=$(date +%s)
            tmp=$(mktemp /tmp/dhcp.leases.XXXXXX)
            awk -v m="$mac" -v n="$now" '$2 != m && ($1 > n || $1 == 0)' \
                "$LEASE_FILE" > "$tmp" 2>/dev/null
            echo "$expiry $mac $ip $stored_host *" >> "$tmp"
            mv "$tmp" "$LEASE_FILE"
        fi
        mac=""
        lease_time=""
        ip=""
    fi
done < "$FIFO"

A few things worth noting:

  • FIFO instead of a pipe - the while read loop must run in the main shell process, not a subshell, so that the trap handler works and procd can actually stop the service. A pipe (tcpdump | while read) creates a subshell that never receives SIGTERM.
  • Shell parameter expansion - ${line##* } and ${line%%\"*} instead of spawning awk or sed for each line. On a resource-constrained AP, avoiding subprocesses in the hot loop matters.
  • eval for hostname storage - busybox ash has no associative arrays. We store hostnames keyed by sanitized MAC (aa:bb:cc:dd:ee:ff becomes aa_bb_cc_dd_ee_ff) using eval. It’s ugly but it works.
  • Atomic writes - mktemp + mv prevents partial reads if LuCI queries the lease file mid-write.
  • Expired entry cleanup - the awk command that removes the old entry for the current MAC also drops any entries past their expiry timestamp.

The Lease File Format

The script writes entries in the format rpcd-mod-luci expects:

<expiry_epoch> <MAC> <IPv4> <hostname> <client-id>

For example:

1773120600 aa:bb:cc:dd:ee:f1 192.168.1.42 my-laptop *

LuCI’s getDHCPLeases function reads every entry, computes remaining time as expiry - now, and displays it. It does not filter out expired entries - they show as “expired” but remain visible until removed.

Init Script

Create /etc/init.d/dhcp-hostname-sniff:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=95
STOP=10

start_service() {
    procd_open_instance
    procd_set_param command /usr/sbin/dhcp-hostname-sniff br-lan
    procd_set_param respawn 3600 5 0
    procd_set_param stderr 1
    procd_close_instance
}

Enable and start:

1
2
3
chmod +x /usr/sbin/dhcp-hostname-sniff /etc/init.d/dhcp-hostname-sniff
service dhcp-hostname-sniff enable
service dhcp-hostname-sniff start

Hostnames will populate as clients renew their DHCP leases. With a typical 600-second lease time, all devices should appear within 10 minutes.

Surviving Firmware Upgrades

Three things need to persist across sysupgrade:

1. The script itself - /usr/sbin/ is not preserved by default:

1
echo /usr/sbin/dhcp-hostname-sniff >> /etc/sysupgrade.conf

2. The init script - /etc/init.d/ is under /etc/, so it’s preserved automatically.

3. Re-enabling the service and reinstalling tcpdump - sysupgrade recreates /etc/rc.d/ from installed packages, so the symlink that enables our service gets lost. A uci-defaults script seems like the right fix - it runs once on first boot after upgrade - but it races with package initialization. The package manager rebuilds /etc/rc.d/ after uci-defaults has already run and deleted itself, wiping our symlink for good.

/etc/rc.local runs after everything else and on every boot, so it won’t get stomped. The operations are all idempotent:

1
2
3
4
5
6
7
8
# Add to /etc/rc.local, before "exit 0":
if [ -x /etc/init.d/dhcp-hostname-sniff ]; then
    /etc/init.d/dhcp-hostname-sniff enable 2>/dev/null
    if ! command -v tcpdump >/dev/null 2>&1; then
        apk add tcpdump-mini
    fi
    /etc/init.d/dhcp-hostname-sniff start 2>/dev/null
fi

/etc/rc.local is under /etc/, so it’s preserved across sysupgrade.

Gotchas

  • Devices that don’t send hostnames - some devices with privacy features (e.g., Apple’s Private Wi-Fi Address) may not include Option 12 at all. There’s no fix for this short of static leases on the router.
  • The AP’s own MAC - SELF_MAC filters out the AP itself if it appears in DHCP traffic. The server’s MAC is learned dynamically from the first Reply packet.
  • Multiple APs - each AP runs its own sniffer and only sees traffic from clients connected to it. This is fine - each AP populates its own lease file for its own LuCI instance.