Skip to content

feat(wrt): identify unnamed devices by OS/vendor and retry silent mDNS lookups - #3623

Open
Dominion5254 wants to merge 5 commits into
masterfrom
wrt/device-ident
Open

feat(wrt): identify unnamed devices by OS/vendor and retry silent mDNS lookups#3623
Dominion5254 wants to merge 5 commits into
masterfrom
wrt/device-ident

Conversation

@Dominion5254

Copy link
Copy Markdown
Collaborator

Some clients never advertise a hostname through any source devices.list
consults: ChromeOS withholds DHCP option 12 as a privacy decision, stripped-down
IoT clients can't send one, and phones with randomized Wi-Fi MACs defeat any
vendor lookup. Those devices showed up as an opaque device-3af2b1. This PR
gives them descriptive labels derived from what a device can't help
revealing, and fixes the mDNS name recovery that gave every device exactly one
chance to answer. Bumps 1.0.2 → 1.1.0.

The resolved name chain becomes: UCI static name → live DHCP hostname → live
mDNS name → cached hostname → DHCP-fingerprint OS label (Windows device (3af2b1)) → MAC-OUI vendor label (Apple device (3af2b1)) → placeholder.
Labels are derived, not learned — recomputed every poll, never cached as names —
so any real hostname outranks them the moment it appears, and user-assigned
names always win.

  • feat(wrt): label unnamed devices by MAC vendor instead of a placeholder.
    New device_ident module with an embedded IEEE registry snapshot
    (MA-L/MA-M/MA-S, longest-prefix match, ~490 KB gzipped TSV) regenerated by
    build/gen-oui-table.sh. Unassigned sub-blocks and anonymized registrations
    are excluded at generation time so they fall through rather than mislabel.
    Locally-administered (randomized) and multicast MACs return no vendor by
    construction.

  • feat(wrt): recognize unnamed devices' OS via DHCP fingerprinting. The
    option-55 parameter-request order and option-60 vendor class are
    characteristic of the OS's DHCP client and survive MAC randomization.
    Captured by a dnsmasq dhcp-script hook that runs sourced inside the
    dnsmasq ujail
    (no exit, builtins only, /var/run/dnsmasq/ is the only
    writable path — verified against the shipped dnsmasq.init and wrapper).
    The daemon provisions the script and the UCI dhcpscript option on every
    dnsmasq section at boot (idempotent, reload only on change), so OTA-updated
    routers converge without a fresh flash; profile-created sections get it via
    ProfileDnsmasq. The fingerprint rung outranks OUI because it identifies
    the machine, not the NIC — a USB Ethernet dongle's OUI says "TP-Link" even
    when the laptop behind it is a Mac. Fingerprints persist per-MAC in the
    device_names cache (the capture file is tmpfs), so labels survive reboots;
    hostname in the cache becomes optional, and legacy pre-fingerprint JSON
    loads unchanged. The daemon-side parser is the validation layer for the raw
    hook output — a hostile vendor class can at worst forge a display label.

  • fix(wrt): retry silent mDNS name lookups on a backoff schedule. The
    reverse-mDNS lookup fired at most once per device per daemon run, usually at
    the worst moment (the instant a device first appeared, or in the
    reassociation rush after a router reboot), so a missed answer meant a
    generic label until restart. Silent devices are now retried on an RFC 6762
    §5.2-shaped schedule (1 m → 10 m → 1 h → 6 h → 24 h, 6 attempts total, then
    left alone). Instant-based, since routers boot with a wrong clock and
    NTP-jump minutes later. devices.forget clears the attempt history so a
    forgotten device restarts a fresh schedule.

  • feat(wrt): recognize NetworkManager's DHCP fingerprint as Linux. Table
    rule pinned to a bench capture from an Ubuntu client (NetworkManager's
    internal DHCP client, the Ubuntu/Fedora desktop default).

Fingerprint table rules are deliberately conservative (a miss falls through to
the OUI rung, which is harmless; a wrong match mislabels a device). Labels are
English-only, matching the existing "VPN Device" convention.

Some clients never advertise a hostname through any source devices.list
consults — ChromeOS deliberately sends no DHCP option 12 and answers no
reverse-mDNS query, and many stripped-down IoT DHCP clients behave the
same — so they surfaced as an opaque `device-3af2b1` placeholder.

Add a vendor-label rung to the name resolution chain, just above the
placeholder: look up the MAC's OUI in an embedded snapshot of the IEEE
registries (MA-L/MA-M/MA-S, longest-prefix match) and render e.g.
"Apple device (3af2b1)", keeping the stable MAC suffix so identical
unnamed devices stay distinguishable. Labels are derived, not learned:
recomputed each poll, never written to the name cache, so any real
hostname that later appears — or a user-assigned name — outranks them.

Randomized (locally-administered) MACs are skipped — they have no
registry entry by construction; on Ethernet, where MACs are burned in,
the lookup essentially always resolves. "IEEE Registration Authority"
parent rows and "Private" registrations are excluded at generation time
so an unassigned sub-block falls through to the placeholder rather than
yielding a meaningless label.

The snapshot (52,889 entries, ~490 KB gzipped) is generated by
build/gen-oui-table.sh; the registry is append-mostly, so the asset only
ever develops misses on brand-new vendors — refresh it opportunistically
by re-running the script. Version bumped to 1.1.0 (feature tier).
Layer an OS-recognition rung above the OUI vendor label: the order of a
client's DHCP option-55 parameter-request list and its option-60 vendor
class are characteristic of the OS's DHCP stack, ride the request
itself (so they survive MAC randomization, where OUI lookup is blind),
and cannot be withheld the way a hostname can. An unnamed Windows
laptop now shows "Windows device (3af2b1)" rather than falling to the
NIC vendor or the placeholder.

Capture: a hook script sourced by dnsmasq's dhcp-script wrapper appends
"MAC|options|vendor class" lines on every DHCP add/old event. The
wrapper sources the script inside the dnsmasq ujail, which dictates the
design: no `exit` (it would abort the wrapper's ubus hotplug notify),
shell builtins only (the jail mounts no coreutils), and the output
lands in /var/run/dnsmasq/ — the jail's only writable directory (/tmp
is silently unwritable there). The daemon parses the file defensively
(it is the validation layer for client-controlled bytes) and compacts
it when it outgrows 64 KiB.

Wiring: the daemon provisions the hook at every boot — writes the
script, points every `config dnsmasq` section's `dhcpscript` at it via
a minimal partial TypedSection (preserving all other options), and
reloads dnsmasq only on change — so OTA-updated routers converge
without a reflash; profile creation sets `dhcpscript` on new instances
directly.

Persistence: fingerprints are cached per-MAC in device_names.json
(hostname becomes optional there — a Chromebook has a fingerprint but
no name; legacy files load unchanged), so OS labels survive reboots
even though the capture file is tmpfs. Labels themselves stay derived:
recomputed each poll from fingerprint-else-OUI, never cached as names.

The rule table is seeded from widely-documented signatures (MSFT/
android-dhcp/dhcpcd/udhcp vendor classes; Apple and Windows option-55
sequences) and is deliberately conservative — a miss falls through to
the OUI rung. Live bench validation, documented in device_ident.rs:
capture /var/run/dnsmasq/dhcp.fingerprints for Windows, macOS, iOS,
Android, Debian, and above all ChromeOS (the client this feature exists
for), whose vendor-class behavior is undocumented and decides whether
it earns a dedicated rule or lands on "Linux"/the OUI rung.
Reverse-mDNS name recovery attempted each device exactly once per
daemon run: a silent device went into MDNS_ATTEMPTED and was never
queried again until restart — potentially months. That single attempt
is a bad sampler: it usually fires the moment the device first appears
(before its mDNS responder is up) or in the reassociation chaos right
after a router reboot, and sleeping phones/laptops don't answer at all,
so devices that do have Bonjour names got stuck on the generic label.

Replace the attempted-set with a per-MAC attempt history and retry
still-unnamed devices on a bounded backoff schedule: 6 attempts total,
gated at 1 min -> 10 min -> 1 h -> 6 h -> 24 h after the previous try,
then done until restart (the old behavior becomes the end state instead
of the first). The shape follows RFC 6762 §5.2, mDNS's own doubling
retry for unanswered queries; unlike a standing query we cap attempts,
since many devices genuinely have no responder and each attempt spawns
avahi-resolve. The early rungs catch responder-startup lag and boot
chaos (avahi-daemon also caches answers that arrive after our 1.5 s
timeout kills the CLI, so a quick retry often hits the cache); the late
rungs catch sleepers. A permanently silent device now costs 6 spawns
per daemon run instead of 1 — still nothing.

Backoff runs on Instant, not wall time: routers boot with a wrong clock
and NTP-jump minutes later, which would garble a wall-clock schedule.
Retries ride the existing devices.list polls, so nothing fires while no
client is viewing the device list, and success still persists to the
name cache, which gates any further attempts. devices.forget now also
drops the attempt history, so a forgotten device that reconnects starts
a fresh schedule (matching the "appears as a new entry" docs contract).
Bench capture 2026-08-03 (Ubuntu, NetworkManager internal client): option-55
list 1,2,6,12,15,26,28,121,3,33,40,41,42,119,249,252,17 with no vendor class —
the default DHCP client on Ubuntu/Fedora desktops. Exact-match rule in the
table's conservative style; a drifted list in a future NM version just falls
through to the OUI rung as before.
@Dominion5254
Dominion5254 requested a review from dr-bonez August 4, 2026 02:28
@Dominion5254 Dominion5254 self-assigned this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant