Status: in progress, but a lot further along than the title suggests. Ethernet, Wi-Fi, VLAN-aware switching and LuCI are all genuinely working on this hardware now — just not yet all stitched together under a single unattended OpenWrt boot. I’m publishing this as a running account, written as it happens, warts and all, because the honest version of this story is more useful than a tidy one.
The target
The Heights HT-8766BE is a Wi-Fi 7 gateway supplied by UK ISPs — the kind of box that turns up, pre-provisioned, bolted to the wall, and never thought about again. This one is brand new out of the box. There is essentially zero public information about it: nothing in the OpenWrt Table of Hardware, nothing on the OpenWrt forum, no FCC filing I could find, no teardown photos. The closest public relatives are the Heights HT-6766BE (same silicon family, different cosmetic SKU) and its ISP-branded variants sold by BeFibre and Zzoomm in the UK.
Heights states its product line is built on Broadcom chipsets and SDK. That turns out to be exactly right, and it shapes everything that follows.
The one constraint that defines this whole project: the PCB sits under a heatsink/shield I’m not opening. The only access point is a UART header. Everything here — the recon, the backup, the porting, the debugging — happens over a 115200-baud serial link, nothing else, until very late in the piece when a wired Ethernet port came alive enough to test with a direct cable.
Tools
- An ESP32 “Bus Pirate” board used purely as a UART-to-USB bridge (115200 8N1, 3.3 V).
- A small Python daemon (
uartd.py) that logs everything to a timestamped file and exposes a FIFO for sending commands — built because the browser-based serial terminal I started with missed the first ~21 seconds of boot, and because the router’s 1-second autoboot window needs something hammering the keyboard faster than a human can. - Standard Linux tools on the laptop side:
objdump(ARM cross),readelf, Python for ad-hoc binary parsing (FDT walking, LZO/LZOP decompression viactypesagainstliblzo2, FIT image parsing).
First boot: what’s actually in this box
With the UART logger running from power-on, the full boot chain is visible:
BTRM
V4.0
...
U-Boot SPL 2019.07 (Apr 29 2026 - 13:55:51 +0800)
RevID: 67660A0
Board is non secure
“Board is non secure” is the single most important line in the whole log. No secure boot, no signature verification on the boot chain. That’s the thing that makes the rest of this project possible at all.
The hardware, pieced together from the boot log and later confirmed live:
- SoC: Broadcom BCM96766 — 4× ARM Cortex (32-bit, armv7l) at 2 GHz, 1 GiB DDR4 at 1600 MHz.
- Wi-Fi: two SoC-integrated radios,
wl0(BCM6039, 4×4, 2.4/5/6 GHz — Wi-Fi 7 capable) andwl1(BCM6038, 2×2), both using Broadcom’s proprietarywldriver (version 17.10.369.39076). - Switch/Ethernet: a Broadcom-internal “SF2” switch fabric, plus a separate external AN8855 switch chip handling three of the Gigabit copper ports, plus two SERDES-capable ports (labelled “2.5G WAN/LAN” on the rear panel) driven directly by the SF2.
- Voice: an Intel/MaxLinear SLIC for FXS/analogue phone lines.
- Storage: eMMC, 3.64 GiB, with the usual
boot0/boot1hardware boot partitions (4 MiB each) plus an RPMB region. - Boot chain: boot ROM → U-Boot SPL → U-Boot TPL → full U-Boot 2019.07 → a FIT image containing the Linux kernel and device tree.
- Software: Broadcom’s BDK/SDK 5.04L.04p3, Linux 4.19.294 (armv7, BusyBox 1.36.1 userland, built with Buildroot 2021.02.4 / GCC 10.3), TR-069 (
usp_md) and OpenPlat for ISP remote management.
The firmware identifies itself as 2.0.0.14-HT-6766BE-EM, customer config HT_6766BE_EM_BeFiber_default.conf — the BeFibre rebrand of the same hardware family, consistent with the sparse public information available.
U-Boot gives a one-second window to interrupt autoboot (Hit any key to stop autoboot: 1 0), with no authentication at all. The boot environment itself prints several things in cleartext during boot that I am deliberately not reproducing here, including an admin password, a default Wi-Fi key, the device serial number, and a set of TR-069 ACS credentials for acs-heights.fullfibre.systems. None of that belongs in a public writeup, regardless of how it was obtained.
The eMMC layout is a plain GPT table with a dual-bank design — two complete copies of bootfs/rootfs (100 MiB / 200 MiB squashfs each), so the device can fail an update and fall back. This dual-bank structure turned out to be the single most useful thing about the whole device, for reasons that become obvious below.
Getting a root shell — without any credentials at all
U-Boot’s bootcmd is printenv;run once;sdk boot_img — a Broadcom-specific helper (sdk boot_img) that loads the active bank’s FIT image and boots it. Critically, U-Boot lets you append arbitrary extra kernel command-line arguments before that boot, and nothing validates them:
setenv bootargs_append 'cma=72M init=/bin/sh'
sdk boot_img
That’s it. The kernel boots its normal squashfs root filesystem, but instead of running the vendor’s real init system, it drops straight to a BusyBox ash shell as PID 1 — fully root, no login, no password, nothing persisted (since we never saveenv, this reverts to the normal boot on the next power cycle). This became the base working environment for essentially the entire project.
Two gotchas showed up immediately:
- The hardware watchdog resets the board after about a minute, because
init=/bin/shnever feeds it (the real init system normally has a dedicated watchdog-feeding service,wdtd.sh, running very early). Fix, run as the very first thing after getting a shell: �CODE2�Vis the magic-close character that disarms the Broadcom watchdog driver cleanly. Follow it with a tiny background loop that writes to it every 10 seconds, just in case something else re-arms it.
- USB isn’t live in this minimal boot. The USB PHY power-up module (
/lib/modules/4.19.294/extra/bcm_bca_usb.ko) andusb-storage/xhci-plat-hcdall need loading by hand — none of this is inmodules.depfor the vendor’s out-of-tree modules at all; load order is only encoded in the vendor’s own (never-run, in this boot path) init scripts.
Backing up the whole eMMC, over USB
Before touching anything, I wanted a full backup. Over raw UART that’s impractically slow for 3.6 GiB, so the plan was: get USB mass storage working in the minimal shell, then dd everything onto a USB stick plugged straight into the router.
A few things bit along the way:
- The kernel has no exFAT support — the first stick I tried, pre-formatted exFAT, just sat there unmountable. Switched to a blank ext4 stick instead.
- BusyBox’s
mount -o remount,rwsilently fails on this kernel’s ext4 driver (it passes arelatimemount option the kernel rejects) —umountand a freshmount -o rwworks around it. - No
od,wc,cat -v,base64, orblkidin this BusyBox build;dd,gzip,sha256sum,xxdandhexdump(from a fuller toolset found elsewhere on the real rootfs),ifconfig/ip,wget,curl,tftp,nc,fdiskare all present.
With USB sorted, the backup was three dd passes — mmcblk0boot0, mmcblk0boot1 (4 MiB each), and the full 3.64 GiB user area — each verified by hashing the source device and the resulting image file separately and comparing. (Lesson: reading 3.6 GB back off a USB stick to verify it takes noticeably longer than writing it did; budget for that.) RPMB wasn’t dumped; it needs an authentication key I don’t have, and nothing in this project needed it.
Two copies of the backup now exist, on a USB stick and on the laptop, both checksum-verified against the live device. Everything that follows is reversible because of this.
Getting OpenWrt to boot
The realistic goal, decided early: mainline OpenWrt has no target for the BCM96766 at all – I checked the current release tree directly, nothing. Writing one from scratch would mean reimplementing the DDR controller bring-up, the PMC, the eMMC controller, the Archer packet-offload engine, and the AN8855 switch glue, entirely from binary reverse-engineering, with zero vendor source available (no GPL source-code offer exists anywhere in this firmware as of the time of writing). For scale: Linux’s one genuinely open driver for a comparable chip in this family, the AN8855 switch, took over a year and more than twenty patch revisions from an experienced upstream kernel developer, working with a different vendor who at least cooperated. Writing a whole SoC BSP blind is not a realistic solo undertaking.
So: keep the vendor’s already-working, already-signed-off kernel and device tree. Replace only the userland. The vendor kernel already has everything procd (OpenWrt’s init) needs – CONFIG_NAMESPACES, cgroups, overlayfs, devtmpfs, ext4, loop devices, all present, confirmed by pulling the live .config via gunzip -c /proc/config.gz. A quick ABI check (readelf -A on a vendor binary) confirmed soft-float EABI, matching OpenWrt’s armsr/armv7 target build exactly.
OpenWrt ships a prebuilt image for exactly this scenario – openwrt-25.12.6-armsr-armv7-generic-ext4-rootfs.img, a 104 MiB plain ext4 filesystem meant to be written straight to a block device and booted via root=, with no squashfs-plus-overlay switch_root dance needed. Its /etc/inittab already has an entry for ttyAMA0 – the exact UART this board’s console uses – so it needed zero editing.
Using the spare bank (bank 1) as the target, leaving the running stock bank (2) completely untouched:
dd if=/dev/mmcblk0p5 of=/dev/mmcblk0p3 bs=1M # copy the known-good kernel/FDT onto the spare slot
dd if=<usb-stick>/openwrt-....img of=/dev/mmcblk0p4 bs=1M # write OpenWrt's rootfs onto the spare slot
then, from U-Boot:
setenv bootargs_append 'root=/dev/mmcblk0p4 rootfstype=ext4 rootwait rw'
sdk boot_img 1
sdk boot_img 1 boots bank 1 specifically, without touching the metadata/commit state — so a plain power cycle, with no further action, always lands back on the stock firmware. This is a one-off test boot, not a bank switch.
It worked first try:
OpenWrt 25.12.6, r33311-981c978eff Dave's Guitar
root@OpenWrt:~#
A real procd/ubus OpenWrt userland, running on this SoC’s own vendor kernel, over the serial console. No Ethernet yet – deliberately not attempted in this first test, to isolate variables.
Ethernet: the kernel panic, and the long way round
Loading the vendor’s Ethernet module chain under OpenWrt (same .ko files, copied in from the read-only-mounted stock rootfs – same kernel build, so the module ABI matches exactly) goes fine, right up until the actual driver:
insmod bcm_enet.ko
→ a hard kernel panic. Full ARM backtrace, tainted kernel, forced reboot in three seconds. The crash backtrace pointed at two symbols: _phy_drv_init and phy_drv_serdes_146class_init_lock. Checking bcm_enet.ko‘s own symbol table showed neither of them actually exist in that module — they’re kernel-builtin symbols, confirmed by cross-checking the live kernel’s /proc/kallsyms (c0922504 t _phy_drv_init, address in the 0xc0... vmlinux range, not the 0xbf1... module range). So this wasn’t a missing-module problem at all: bcm_enet was calling into a SERDES PHY-class framework compiled directly into the kernel, and some piece of that framework’s internal state wasn’t set up the way the driver’s probe path expected.
The device tree detour
With the router’s entire eMMC backed up, I could work entirely offline against the backup image. The FIT image inside the boot partition is itself a device-tree-structured container (U-Boot’s “Flattened Image Tree” format), parseable with the same FDT-walking logic used for the main device tree – so I wrote a small parser and found the real port topology:
port_gphy reg=0 phy-mode=gmii status=okay
port_sgmii0 reg=5 phy-mode=serdes status=okay
port_sgmii1 reg=6 phy-mode=serdes status=okay
port_imp reg=8 phy-mode=gmii status=okay (management/CPU port)
Both SERDES ports – the two “2.5G WAN/LAN” ports on the rear panel — share the PHY-class code path that was crashing. The devicetree status property is a standard way to disable a node; "okay" and "disabled" are both five bytes including the terminator, and "fail" is a value the kernel treats as equally unavailable and is also exactly five bytes – meaning a two-port fix could, in principle, be a trivial in-place byte patch with no FDT restructuring at all.
It wasn’t quite that simple: FIT images store a SHA-256 hash of each sub-image, and U-Boot checks it. Patch the device tree, and the stored hash no longer matches – Bad Data Hash, followed by a hang that needs a hard power-cycle. The actual fix needed two patches: the five bytes of the status string, and the stored SHA-256 of the whole device tree blob, recomputed and written to its own location in the FIT header. Both applied only to the spare bank’s boot partition – the running stock bank was never touched.
With one SERDES port disabled this way, bcm_enet loaded clean. A genuinely new milestone: the first physical Ethernet link lights of the whole project, auto-negotiated at 1000 Mb/s full duplex, confirmed on the actual hardware.
The correction
Later the same day, while working on Wi-Fi in parallel (see below), I loaded the Wi-Fi driver prerequisite chain – hnd.ko and friends – before bcm_enet, out of simple necessity (it turns out bcm_enet and the Wi-Fi driver share dependencies; more on that below). With that load order, bcm_enet loaded cleanly with the device tree fully reverted to its original, unpatched state – both SERDES ports enabled, original hash restored, no hack in place at all.
That was a real “oh” moment: the device-tree patch was never the actual fix. Something in the Wi-Fi prerequisite chain – almost certainly hnd.ko, Broadcom’s shared hardware-abstraction module – populates the same kernel-builtin PHY-class state that bcm_enet‘s probe path needs, as a side effect of its own initialisation. Loading it first was the real fix, and it’s much cleaner: no FIT-image patching needed at all, and all three wired ports are available simultaneously rather than having to sacrifice one.
I’m leaving the device-tree-patch story in this writeup rather than editing it out, because it’s honestly representative of how this kind of work actually goes: a plausible, working-seeming fix that turns out, on broader testing, to have been coincidental.
The part that was still broken — and then wasn’t
With real PHY-level link established – auto-negotiation correct, LEDs on, speed and duplex both correctly negotiated – actual traffic still didn’t pass. Sending a ping got no reply, in either direction. The question was whether this was a signal-integrity/calibration problem or something else.
A previously-undocumented debug interface answered that cleanly:
echo 'stats 2' > /proc/driver/enet/cmd # argument is a MAC index, not an interface name
dmesg | tail -n 40 # output goes to the kernel log, not back to the proc file
This gives the full hardware MIB counters per port. The result was unambiguous: rx_dropped equalled rx_packet exactly, across every traffic class and every frame-size bucket, with zero FCS, alignment, fragment, jabber, or overflow errors. The switch hardware was receiving perfectly clean, valid frames and discarding every single one before they reached Linux’s networking stack.
I spent a long time tracing this in disassembly, following a hooks-table early-return inside bcm_enet.ko‘s enetxapi_rx_pkt, convinced the bug lived somewhere in Broadcom’s proprietary Archer/Crossbow packet-acceleration engine. It didn’t. The actual fix was absurdly small, and I only found it by going back to the vendor’s own debug CLI (ethswctl) and working through its command list methodically rather than guessing inside a disassembler:
ethswctl -c hw-switching -o enable
That’s a global switch-fabric enable, independent of any per-port setting, VLAN membership, or STP state – and it was simply off. Our manual boot path never calls it, because it’s normally asserted by a proprietary userspace management daemon during a full stock boot, which we skip entirely by booting straight to a shell. One command. Immediate effect: the next hardware MIB read showed rx_dropped: 0, and the first real ping (10.99.99.2 ↔ 10.99.99.1) came back clean – 20/20, 0% loss, ~0.7 ms RTT.
(There’s a sharp gotcha hiding in the same debug tool worth flagging: ethswctl -c portctrl -i <if> has no pure query mode. Omitting its -v flag doesn’t read the port’s state – it silently defaults to -v 0 and disables the port. I “checked” a port’s status this way more than once and immediately un-did my own fix by doing so.)
With that one flag in place, all five physical Ethernet ports work: the two SERDES-driven “2.5G WAN/LAN” ports on the rear panel (driven by the SF2 fabric directly), plus the three plain Gigabit LAN ports (driven by a separate AN8855 switch chip, confirmed via the MediaTek/Airoha mt753x lineage hinted at by its own CLI tool, /bin/switch) — tested with sustained TCP throughput, not just ping.
Full per-port VLAN isolation, for free
The AN8855 turned out to have a complete, working control path that I’d initially assumed didn’t exist. /bin/switch talks to a Generic Netlink family (an8855_nl_family) compiled straight into bcm_enet.ko – confirmed by grepping /proc/kallsyms for an8855_* symbols (an8855_reg_read, an8855_hw_reset, an8855_gsw_nl_init, and so on). It’s a genuinely capable tool: register-level read/write, full clause-22/45 PHY access, MAC/ARL table management, port mirroring, a complete ACL engine, and the important part – full 802.1Q VLAN control (vlan dump/set, per-port PVID, egress-tag modes).
Better still, I found /etc/switch-airoha.sh – the vendor’s own AN8855 setup script, sitting unused on the stock rootfs because our manual boot path skips vendor userspace init entirely. Running it directly configures exactly what you’d want from a router like this: one AN8855 port is a tagged uplink trunk to the SoC, and the other three are each an untagged, isolated member of their own VLAN. Confirmed by moving one cable between all three physical LAN jacks in turn, creating a standard Linux 802.1Q sub-interface (eth0.1/eth0.2/eth0.3) for each VLAN, and getting a clean, independent ping on each one. No DSA kernel driver involved at all – this is standard 802.1Q tagging over a single physical uplink, handled entirely by the switch chip itself.
One command found by deep-diving this same tool turned out to be the single most dangerous thing in this whole project: switch ... reset visibly reset the real chip (confirmed by a port’s link light dropping instantly) – but the very next call into any AN8855-related code afterward hung the kernel completely, uninterruptibly, immune even to Ctrl-C. Recoverable only with a full power cycle. Everything else about this tool, including register writes, has been completely safe; reset specifically never gets called again.
Wi-Fi: solved, and a real client connected
(The next several sections were the previous day’s “equally unresolved” story – kept below because the dead end and the eventual fix are both worth reading. Skip ahead to “The actual fix” if you just want the resolution.)
The dead end
The Wi-Fi driver has the same “nothing’s in modules.dep” problem as Ethernet – the real module load order only exists encoded in a vendor init script (bcm-wlan-drivers.sh) that never runs in the minimal boot path. Recovered from reading that script directly:
wlshared → firmware_class → cfg80211 → hnd → bcmmcast → emf → igs → bcm_pcie_hcd → wl
with real parameters (intf_name=wl%d instance_base=<n> for wl) also recovered from the same script.
One genuinely surprising discovery: Wi-Fi and Ethernet are not independent subsystems on this SoC. wl.ko needs bcm_pcie_config_bar_addr/bcm_pcie_map_bar_addr from bcm_pcie_hcd.ko – and bcm_pcie_hcd.ko itself needs enet_init_done, an export from bcm_enet.ko. Trying to test Wi-Fi in true isolation, without any Ethernet modules loaded, hits a hard dependency wall immediately. They share the same underlying packet-acceleration fabric.
With the correct module chain and the Ethernet prerequisite loaded first, wl.ko loads with zero missing symbols – real, measurable progress. The actual hardware attach still fails, though:
si_doattach: Embedded WLAN core (devid:0x6039) pseudo-PCI ... <- succeeds
M2MDMA / BME / M2M ring alloc ... <- all succeed
M2MDMA: core 0/1 deinit <- rolled back
wl driver 17.10.369.39076 (...) failed with code 15
Disassembling wl.ko traced this to wl_pci_probe calling wlc_attach(..., &err); on failure it prints exactly this message, with err (15) read back through an output parameter. wlc_attach is the actual radio/firmware/calibration attach routine – and it is, and apparently always has been, completely closed. I confirmed this isn’t specific to our build by checking the equivalent open-source glue code from the long-running broadcom-sta Linux driver project: the exact same call pattern exists there too (wlc_attach(..., &err) → printf("... driver failed with code %d", err)), with wlc_attach itself still a sealed blob even in that project, across every chip generation it’s supported. There has never been a public table mapping these error codes to causes.
I tried restoring the device’s real NVRAM/calibration data properly, via the actual supported mechanism rather than guessing – /bin/nvram kernelset /etc/wlan/nvram/HT6766BDM.nvm (the nvram binary has an alarming ~30 shared-library dependencies, but they’re all just .so files already present on the vendor’s own rootfs; nothing needs to be running, so it executes fine standalone, prints restore done!, and is a clean, safe way to apply calibration data, unlike the ad-hoc route described below). It made no difference – same error 15 before and after, so whatever’s missing isn’t (just) calibration.
At the time, this looked like the Ethernet story before its resolution: genuine progress, blocked on an opaque, closed function with no public reference anywhere. I even tried the obvious alternative driver, dhd.ko (Broadcom ships two WLAN drivers historically) – it loaded cleanly but explicitly rejected this chip’s PCI device ID (dhdpcie_pci_probe: chipmatch failed!! Unsupported vendor 14e4 device 6039), conclusively ruling it out: dhd.ko is built for discrete PCIe WiFi dongles, not this SoC-integrated radio, so wl.ko genuinely is the correct driver, and the error 15 was a real problem inside it.
The actual fix
The breakthrough came from asking the obvious question I’d somehow skipped: why can’t we just look at the vendor’s own boot process for how it brings Wi-Fi up, given it demonstrably works in stock firmware? I’d read the Wi-Fi driver load order script already, but never the NVRAM population script, /etc/init.d/hndnvram.sh.
It calls a function, nvram_mfg_conf_mlo(), that runs a real binary (wifi_init_mlomode, deep in the vendor’s TR-069/CMS management framework, too heavy to replicate standalone) to get a numeric “MLO mode” value, then uses it to write a wl_mlo_config=... line into the NVRAM settings file – before the calibration restore we’d already been doing correctly. Checked the raw calibration blob directly: it contains no wl_mlo_config entry at all. For context, MLO (Multi-Link Operation) is a genuinely core Wi-Fi 7 feature (using multiple bands simultaneously for one client connection), and this chip is Wi-Fi 7 hardware. A missing, uninitialised MLO link configuration is an extremely plausible reason for a hard attach failure on exactly this kind of radio.
Read the small helper script (mlo_nvram_setting.sh) that consumes that mode value rather than replicating the whole binary chain: mode 1 sets wl_mlo_config="0 1 -1 -1"; anything else leaves it at "-1 -1 -1 -1". Tried the 1 case directly — which meant first discovering that the raw .nvm calibration file isn’t the newline-separated text format I’d assumed; it’s NUL-separated (confirmed with hexdump: ... $Rev: 83751 $\0boardnum=1780\0 ...), while the plain-text file the vendor’s own scripts edit with sed is a different, converted representation. tr '\0' '\n' converts it cleanly. Appended the one missing line, ran the real restore mechanism (nvram kernelset), and loaded wl.ko.
Both radios attached immediately, with zero errors. wl0 (BCM6039, 4×4) and wl1 (BCM6038, 2×2) both registered real network interfaces, both explicitly logging MLO_BA64/MLO_BA256 buffer pool configuration on attach, confirming the MLO subsystem itself was now correctly active, not just skipped. ip link set wl0 up brought the radio fully up: SMAC/CM7 firmware downloaded, PHY DSP firmware downloaded, MAC clock configured clean, with no crash. One missing NVRAM variable was the entire root cause of the WiFi blocker, from the very start of this project.
A real phone, a real connection
Getting an access point running from here hit two more real bugs, each worth its own paragraph:
Wrong band. My first hostapd config asked for 2.4 GHz (hw_mode=g) and got “Could not determine operating frequency” – because wl0 was already sitting in AP mode on channel 36, 5180 MHz, by default. Matched the config to the hardware instead of the other way round, and the AP started cleanly, genuinely visible over the air from a laptop’s own Wi-Fi scan (correct BSSID, correct channel, WPA2).
Stuck handshake. A real phone associated and got through message 2 of the WPA 4-way handshake cleanly (so the passphrase was right), but message 4 never arrived back, hostapd timed out and disconnected, every single retry. Root-caused to forcing pure legacy 802.11a mode on a chip that’s natively Wi-Fi 7/EHT: the driver’s key-install path evidently isn’t reliable in that mode. Adding ieee80211n=1 (ordinary HT mode, nothing exotic) fixed it on the very next attempt – EAPOL-4WAY-HS-COMPLETED, genuine two-way traffic counters climbing.
The only thing left after that was DHCP – the phone reported “timeout, could not connect” for a few minutes even though it was fully authenticated and passing real traffic at Layer 2, simply because nothing was serving leases on the Wi-Fi interface yet. A scoped dnsmasq instance later: DHCPACK in the log, a real lease handed out, and confirmation, in person, on the phone: it works.
Porting it all onto OpenWrt itself
Everything above was proven on the vendor rootfs, driven by hand over UART. The real goal is OpenWrt with all of it working unattended, so the next step was converting the whole manual sequence into real OpenWrt /etc/init.d services.
OpenWrt runs on the exact same vendor-built kernel as the stock firmware (only the bootloader’s root= argument differs), so the proprietary .ko files are 100% kernel-ABI compatible and insmod under OpenWrt with zero changes. The vendor’s own CLI tools (ethswctl, switch, nvram, hostapd) are a different story – they’re glibc-linked, and OpenWrt itself is musl. Copying the glibc runtime pieces (ld-linux.so.3, libc.so.6, and around forty other shared libraries) straight into OpenWrt’s real /lib, alongside musl’s own differently-named equivalents, turned out to just work for most of them. One genuinely nasty exception: nvram‘s dependency chain pulls in libgcc_s.so.1 — a filename musl also ships, at the same path, with an incompatible ABI, producing a cryptic internal error from the dynamic linker. Fixed by shadowing just that one library in an isolated override directory and replacing the real binary with a tiny wrapper that explicitly points the dynamic linker at it.
A live, unattended cold-boot test caught one more real bug the hard way: hostapd failing with libatomic.so.1: cannot open shared object file – a transitive dependency of libcrypto.so.3 I’d missed entirely by hand-tracing the dependency list. Copied the one missing file (mounting the vendor’s read-only rootfs directly from inside the running OpenWrt session, no reboot needed) and re-ran the exact same cold boot: both Wi-Fi radios came up and both successfully joined the LAN bridge automatically, confirmed in the system log, zero manual intervention. Wi-Fi is now a genuinely automated, reboot-proof OpenWrt feature.
The one thing still not reliable: wired LAN under OpenWrt specifically
The wired side of this same automation is written, and is proven correct on the vendor rootfs, the exact same module chain and VLAN sub-interfaces work every time. Under OpenWrt specifically, there’s a genuine, reproducible bug: netifd (OpenWrt’s own network management daemon) can never successfully create the LAN bridge itself. Isolated this properly rather than guessing: netifd’s own debug log shows precisely which internal call fails:
system_bridge_addbr(1573): Error adding bridge 'br-lan': -10
– and manually performing the exact same operation (ip link add br-lan type bridge, or the older brctl addbr, under any name, including br-lan itself) works instantly, every time, at the kernel level. So it’s not a kernel limitation and not a driver problem – it’s something specific to netifd’s own bridge-creation call path on this particular kernel/config combination, returning an error code that doesn’t obviously correspond to anything that should go wrong here. Diagnosing further would mean strace-ing netifd’s actual syscalls, which is a reasonable next step but a step too far for a serial console in one sitting. The wired side works completely reliably by hand; it just isn’t automated yet.
A mistake worth documenting honestly
Partway through the Wi-Fi investigation, trying to supply calibration data the wrong way, I wrote roughly 2,500 lines of real board NVRAM data into /proc/nvram/set – a kernel interface that turned out to be capped at 16 KB, far smaller than the ~76 KB calibration file. This didn’t just fail cleanly: it left malformed entries in the persistent flash-backed NVRAM store (not RAM-only, as I’d assumed), which then caused a flood of kernel warnings on every subsequent boot, severe enough in one case to blow the boot-time watchdog before a shell was even reachable.
The fix was a straightforward image restore – the corrupted region lives entirely inside the small mmcblk0boot0/mmcblk0boot1 hardware partitions, both of which were in the very first backup taken, before any of this. dd-ing the pristine 4 MiB images back (after clearing the kernel’s force_ro write-protect flag on those block devices, then re-setting it afterward) restored normal boot behaviour completely, checksum-verified against the original backup. Nothing was lost; the stock bank’s reliability as a fallback was never actually compromised, just the thing I’d been testing with recklessly. The lesson – verify the actual capacity of a kernel interface before bulk-writing to it, and never assume a knob called /proc/something is RAM-only without checking – earned its place in this writeup.
Where this stands
- Rooted: completely, trivially, via an unauthenticated U-Boot bootarg injection. No exploit needed.
- OpenWrt: genuinely boots, on the real hardware, on a spare bank that leaves stock firmware completely intact and recoverable with a plain power cycle.
- Ethernet: all five physical ports working with real, sustained traffic – the two SERDES “2.5G” ports and the three AN8855-driven LAN ports, the latter with full per-port 802.1Q VLAN isolation. Proven solid on the vendor rootfs; the same automation under OpenWrt itself hits a reproducible
netifdbridge-creation bug, not yet root-caused. - Wi-Fi: fully working. Both radios attach cleanly (the real fix was a single missing NVRAM variable,
wl_mlo_config, that the vendor’s own boot process sets and ours never had). A real phone associated, completed a WPA2 handshake, and got a DHCP lease – confirmed in person. Automated as a proper OpenWrt/etc/init.dservice, reboot-proof, confirmed working unattended. - LuCI: reachable and rendering correctly over the router’s own Wi-Fi/Ethernet, the moment Ethernet started passing traffic – nothing extra needed installing.
- Full switch/VLAN control: a complete, working control path for the external AN8855 switch chip (register access, ACLs, full VLAN management) – not a kernel DSA driver, but genuinely capable and confirmed live.
- Full eMMC backup, taken and verified before any of this, in two separate locations – meaning everything above is undoable, and the default boot bank is still deliberately left as stock firmware until the wired-LAN automation under OpenWrt is sorted.
This has gone from “rooted, with a working OpenWrt shell and nothing else” to “every subsystem the original goal named is individually proven working on real hardware,” in one extended session. What’s left is less “can this work at all” and more “stitching it into one unattended boot.” I’ll keep this updated as it moves.







































































































































