Homelab Rebuild Part 9: The Devices That Can't Do 802.1X

What this is about Link to heading

Part 7 ends with a piece of advice: for devices that can’t do 802.1X, plan a second SSID with a classic shared key, hardwired to an untrusted VLAN.

I didn’t take my own advice.

The reason is the same one I used in part 1 to argue against one SSID per VLAN: once the assignment is glued to the SSID, every additional trust level needs another SSID. My AV receiver and my games console both lack 802.1X, but they are explicitly not supposed to share a network. A hardwired SSID would have given me exactly the catch-all VLAN I set out to get rid of.

The alternative is MAC Authentication Bypass, or MAB. The access point asks the RADIUS server before any login takes place, with the MAC address as the only credential. The server answers the way it does for 802.1X: with a VLAN assignment. One SSID, and the decision stays in one place.

I had actually announced the firewall rules for this part. They’re coming in the next one. The devices need to be in their networks first, otherwise I’d be describing rules for VLANs that nobody is in.

What MAB is, and what it isn’t Link to heading

One thing should be clear up front: MAC addresses are not a security property. They travel unencrypted in every frame, and any operating system can change one in a single line. Anyone who can see my SSID can read my receiver’s MAC and impersonate it.

That’s well known and it doesn’t diminish the usefulness. MAB isn’t authentication, it’s sorting. The question is not “may this device join the network” but “which network does this device belong in”. Protection against strangers still comes from the SSID’s WPA2 key.

From that follows the design decision that shapes the whole setup: an unknown MAC is not rejected, it is pushed into GUEST. In, but isolated, rather than reject. Anyone holding my key ends up on the guest network with internet access and no way inside. That is the trust level a shared password actually justifies.

Flow diagram: a device associates with the SSID oli, the access point asks FreeRADIUS using the MAC address. If an entry exists the device is placed in its own VLAN 46 or 47, otherwise in VLAN 50 GUEST.

The MAC is the only credential โ€” and a device without one isn’t turned away, it’s filed.

So I have two SSIDs instead of one, and that’s the trade-off:

SSID Encryption Mechanism Devices
oli-ent WPA2-Enterprise 802.1X, PEAP phones, printer, TV, lighting
oli WPA2-PSK MAB via RADIUS receiver, console, guests

Two is fine under the rule of thumb from part 1. And here’s the decisive difference from the advice I gave back then: on the second SSID as well, the RADIUS server decides, not the access point’s configuration. The access point stays dumb, device management lives in one place.

What went wrong Link to heading

Five problems. In two of them, the literature simply says something other than what my system does.

The marker that doesn’t exist Link to heading

Every guide to MAB with FreeRADIUS opens the same way: you recognise MAC auth requests by the access point sending Service-Type = Call-Check. That’s how you separate them from real 802.1X sessions running on the same infrastructure.

My condition produced this:

ERROR: Failed retrieving values required to evaluate condition

The attribute isn’t in the request. My hostapd doesn’t send it, so the MAB branch was never entered.

What is in there only becomes visible once you run the server in the foreground and look at a real request:

User-Name          = "bc9ebb24a843"
User-Password      = "bc9ebb24a843"
Called-Station-Id  = "7A-DA-11-69-12-17:oli"
Calling-Station-Id = "BC-9E-BB-24-A8-43"
NAS-Port-Type      = Wireless-802.11
Connect-Info       = "CONNECT 11Mbps 802.11b"

Two usable markers: there is no EAP-Message, and the Called-Station-Id ends with the SSID name. Together they make a reliable substitute:

if (!&EAP-Message && &Called-Station-Id =~ /:oli$/) {
    update control {
        &Auth-Type := Accept
    }
}

The $ is mandatory. Without the anchor, :oli-ent matches too, and the MAB branch then reaches into live enterprise sessions. My two SSIDs differ by a suffix โ€” an unfortunate naming choice I had never thought about before.

The lesson: before testing for an attribute, check whether your own hardware sends it. One radiusd -X and a single real request would have saved me an hour.

Two MAC formats in the same request Link to heading

The packet above contains two spellings of the same address:

User-Name          = "bc9ebb24a843"       <- lowercase, no separators
Calling-Station-Id = "BC-9E-BB-24-A8-43"  <- uppercase, with hyphens

I had written my device entries in the hyphenated form, because that’s the form you see in guides. None of them would have matched. Entries are checked against User-Name, and that one is lowercase without separators:

000678c7a0a7                     # receiver
    Tunnel-Type = VLAN,
    Tunnel-Medium-Type = IEEE-802,
    Tunnel-Private-Group-Id = 47

bc9ebb24a843                     # console
    Tunnel-Type = VLAN,
    Tunnel-Medium-Type = IEEE-802,
    Tunnel-Private-Group-Id = 46

The file, by the way, is mods-config/files/authorize, not users โ€” the mistake from part 7 that held me up longest at the time.

Which format your own access point sends isn’t standardised. Other implementations use colons, or uppercase. Don’t guess it, read it off the debug log once.

The road to macaddr_acl=2 Link to heading

On the OpenWrt side there’s a switch to flip: hostapd should ask the RADIUS server about MAC authorisation. The option is macaddr_acl=2. Before it landed in the right file, I tried two approaches that both fail.

option macfilter 'radius' sounds like the obvious solution and appears in several guides. In my build the relevant case block only knows allow and deny. Everything else falls silently into the catch-all branch โ€” no error, no effect.

list hostapd_options 'macaddr_acl=2' is the usual escape hatch when an option is missing from UCI. It doesn’t work here either, and the reason is more interesting: netifd appends hostapd_options to the global part of the configuration file. But macaddr_acl is a per-BSS option. It lands in the wrong place and is ignored. On top of that, my two SSIDs share one file per radio โ€” so the option couldn’t have been targeted at either of them anyway.

The approach that does work is unspectacular. In the branch for PSK encryption, it is enough that a RADIUS server is configured at all. netifd then writes macaddr_acl=2 into the correct BSS by itself. So the regular options suffice:

uci set wireless.default_radio0.auth_server='10.0.1.138'
uci set wireless.default_radio0.auth_port='1812'
uci set wireless.default_radio0.auth_secret='<shared secret>'
uci set wireless.default_radio0.dynamic_vlan='1'

No image rebuild, no special configuration.

The result can be verified in the generated file, and that is exactly where you should look:

grep -E "macaddr_acl|auth_server_addr|dynamic_vlan" /var/run/hostapd-phy0.*.conf

The lesson: in OpenWrt, the UCI configuration is only the input. What hostapd actually reads lives in /var/run/. Check only the input and you don’t see what comes out the other end โ€” a sentence that matters again towards the end of this post.

The fallback that caught too much Link to heading

At the end of the device file sits the catch-all entry for unknown addresses:

DEFAULT Auth-Type == Accept
    Tunnel-Type = VLAN,
    Tunnel-Medium-Type = IEEE-802,
    Tunnel-Private-Group-Id = 50

The condition Auth-Type == Accept is meant to scope it strictly to the MAB branch. That value is only set there, so it can’t apply in enterprise sessions โ€” or so I assumed.

The debug log says otherwise. The entry also matches during live oli-ent sessions and writes VLAN 50 into the intermediate reply. The reason I never noticed is that attributes from the inner PEAP tunnel overwrite the outer ones in the end. The result is correct, the path to it isn’t.

The mistake would only have become visible the moment an enterprise session delivers no inner VLAN. A trusted device would then land silently on the guest network. Nothing crashes, nothing reports in, everything just works slightly differently than intended.

Fixed with a second condition that holds regardless of tunnel ordering:

DEFAULT Auth-Type == Accept, Called-Station-Id =~ ":oli$"

The lesson: the right result coming out at the end doesn’t mean the logic is sound. Two mistakes that cancel each other out are still two mistakes.

One SSID, three radios, one forgotten Link to heading

And then the mistake that cost me the most, even though the fix was a single line.

My access point has three radios. In OpenWrt, one SSID across three radios means three separate configuration sections, and every option has to appear in all three.

On oli-ent, one section was missing the VLAN options. The generated configuration for that radio therefore contained not a single VLAN line. My phone got its VLAN on one radio and nothing on the other โ€” and then landed on the native network. In among my servers, in other words.

In the log the difference is unambiguous. On the working radio:

RADIUS: VLAN ID 41
AP-STA-CONNECTED ... vlanid=41

On the other one, both lines are missing.

What makes this nasty: the device picks its own radio, and it switches during normal operation. So the fault appears sporadically and disappears again when you go looking. A device that is sometimes sorted correctly and sometimes not looks like a device problem.

It didn’t help that my sections aren’t named consistently. One SSID uses the default names, the other has three freely chosen ones. Type the names into a loop from memory and you’ll miss exactly one. Hence:

uci show wireless | grep -E "\.device=|dynamic_vlan"

The list comes from the command, not from recollection.

The trap a working fallback creates Link to heading

Before moving on, a point that matters more than all five mistakes put together.

As long as unknown devices get rejected, a configuration error is loud: the device doesn’t join the WLAN and you notice immediately. With a working fallback, that signal disappears.

Rule of thumb: with a working fallback there is no hard failure left. A mistyped MAC lands silently on the guest network instead of being rejected โ€” the device appears to work and is on the wrong network. When testing, don’t check that it “works”, check which address it was actually given.

That is the price of “in, but isolated”. I think it’s worth paying, but it demands a different testing discipline. Every check of mine now ends in the firewall’s lease table, not at the device’s connection icon.

Six weeks later: the configuration that did nothing Link to heading

The setup had been running for weeks. While writing this post I tripped over a contradiction in my own notes and looked, for the first time, at how the VLAN assignment is actually wired up.

Two options are responsible:

vlan_bridge=br-lan.
vlan_tagged_interface=br-lan

The first tells hostapd to create a bridge named br-lan.<id> per VLAN. The second tells it to attach a tagged uplink โ€” which is derived from the same naming convention and therefore has the same name.

Diagram: the options vlan_bridge and vlan_tagged_interface both produce the name br-lan.47. The attempt fails with File exists and Symbolic link loop. The actual wiring is done by the hotplug script, which attaches the interface directly to br-lan.

Two options, one name โ€” and a script that has been doing the real work for weeks.

In the log it looks like this, on every single assignment:

VLAN: vlan_add: ADD_VLAN_CMD failed for br-lan: File exists
VLAN: br_addif: ioctl[...] failed for br_name=br-lan.47 if_name=br-lan.47: Symbolic link loop

The bridge already exists, the name is taken, so the uplink device fails. And then hostapd tries to attach br-lan.47 to br-lan.47. The two options wipe each other out over a name.

What remained were empty bridges without a single port. Traffic had been going the other way the whole time: through the small hotplug script from part 7, which attaches every newly created VLAN interface to the VLAN-aware bridge and enters the matching assignment. Visible in one line:

ip link show dev phy0.0-ap0.47 | grep -o 'master [^ ]*'
# master br-lan

master br-lan, not master br-lan.47. The script had been winning for six weeks.

The resolution had been sitting in netifd’s source the whole time, a few lines below the place that writes those two options: there is a third switch, vlan_no_bridge, which stops hostapd from bridging altogether. And it is set automatically โ€” but only if vlan_bridge is empty. Anyone who never touched those two options got the clean mode for free.

The correction was three lines per section:

uci delete wireless.default_radio0.vlan_bridge
uci delete wireless.default_radio0.vlan_tagged_interface
uci set wireless.default_radio0.vlan_no_bridge='1'

Six sections, one reload, and afterwards: no more error messages, no empty bridges, assignment as correct as before. It now works for the same reason it did before, just without the failed attempt sitting next to it.

The lesson: a configuration that looks like it’s working, on a system that runs anyway โ€” that is the most uncomfortable combination there is. Nothing forces you to look. I only found this because, while writing, I laid two of my own notes side by side and they contradicted each other.

Addendum to part 7 Link to heading

Two corrections follow from this.

The hotplug script from part 7 is not obsolete. It is the load-bearing mechanism of the entire WLAN segmentation. It doesn’t live in any configuration file and it won’t come along by itself if the access point gets rebuilt. Without it, no WLAN device gets a VLAN any more, and everything lands on the native network.

And the fault that looks like a client problem showed up a second time that same afternoon:

IEEE 802.11: authenticated
IEEE 802.11: Could not set STA to kernel driver

After several restarts of the wireless service, the WLAN driver gets stuck. RADIUS answers correctly, hostapd authenticates, and then the driver refuses the station. In part 7 this cost me a dismantled media player, because I looked for the fault in the device. This time I checked the access point’s log first and rebooted straight away.

Practical consequence: collect changes to the WLAN configuration and load them once. Every extra reload is a risk, and the symptom points the wrong way.

Why this is no good for phones Link to heading

One point where the original plan failed: I wanted to sort every device via MAB and save myself 802.1X for the rest.

That doesn’t work, because modern phones use private WLAN addresses. The MAC is randomly generated, recognisable by a bit set in the first byte, and it rotates. An entry holds until the next rotation.

There’s something else that causes confusion while debugging: there is a separate address per SSID. My phone appears twice in the lease table, with completely different MACs and on two different networks โ€” on the enterprise SSID in the phone VLAN, on the PSK SSID in the guest network, because the address is unknown there. At first glance you take it for two devices.

Both of those are intentional here, and at the same time they are the running proof that the fallback path does what it should.

Phones belong on 802.1X. There the MAC is irrelevant and the assignment runs off the username inside the encrypted part of the login.

What became of the plan Link to heading

Part 1 said: a 40-series of VLANs, one VLAN per trust situation, illustrated by the light controller and the media player sharing a VLAN because of the ambient lighting.

I built the opposite, and that’s the more interesting finding of this section: the two now sit in separate VLANs, with a single rule for exactly the one data stream they need.

Comparison: the plan was a shared VLAN 41 for the media player and the light controller. What got built are two separate VLANs, 42 and 43, with a single rule for UDP 4048 between them.

The founding example for “one VLAN per trust situation” was the first thing to fail it.

The pattern has generalised. Instead of grouping device pairs, every device now gets its own VLAN, and whatever needs to talk gets a targeted rule. The number is cheap and so is the interface. What you save yourself is the question “is this device actually allowed to talk to that one”, which can’t even be asked inside a shared VLAN โ€” there the traffic goes over the switch and never passes the firewall at all.

The second planning error concerned the catch-all network. The plan was a quarantine with a high number, completely isolated. What it became is the guest network, with internet and no access inside. The reason is practical: a completely isolated network produces, for every device that accidentally lands in it, a fault that looks like a network outage. A guest network produces a working device in the wrong place. You want to notice both โ€” but the second one I can see in the lease table, while the first one phones me.

Where I stand now Link to heading

Two SSIDs, two mechanisms, one place where the decision is made. The receiver sits in its VLAN, the console in its own, the phone in two depending on the SSID, and everything I don’t recognise ends up on the guest network.

The access point knows none of this. It asks, gets a number back and attaches the device accordingly. If I want to move a device, I change one line on the RADIUS server.

What’s still open: the new VLANs are networks, but not yet boundaries. Traffic between them is restricted and I wrote the rules as I went โ€” but they haven’t been thought through or documented properly. That’s the next part, and this time for real.

Next step Link to heading

The firewall rules. A recurring pattern of four rules per device VLAN, why the order decides everything, why I deliberately set no port filter for consoles and guests โ€” and why a rule restricted to 80 and 443 makes an Android device voluntarily switch back to a different network.


Part of a series about rebuilding my home network. Status: September 2026. Questions, corrections and your own experiences are welcome in the comments.