I recently ran into one of those networking problems where every setting looks reasonable, the controller insists everything is configured correctly, and the packets quietly disagree.

My setup is fairly typical for a UniFi home network: a UniFi Dream Router 7 as the gateway, two external UniFi access points, and separate networks for normal clients, IoT devices, guests, and a few other things. The IoT SSID had its own VLAN, subnet, and firewall policy. In the UniFi interface, it was assigned to exactly the Network it was supposed to use.

The strange part was that the exact same IoT Wi-Fi worked through one access point and failed through another.

A device connecting through one of my external U7 Pro XGs received an address from the IoT subnet and behaved normally. Put a client on the UDR7's built-in Wi-Fi, however, and it could end up on a completely different network.

What initially looked like a DHCP problem eventually turned into a tour through Linux bridges, bridge fdb, Hostapd, virtual AP interfaces, UniFi's generated configuration, and finally the UniFi Network API. Along the way, the problem even appeared to fix itself briefly during provisioning, only to come back again.

That last part made it feel random for a while.

It wasn't.

Environment when I verified the fix: UniFi Dream Router 7, UniFi OS 5.1.31 RC, UniFi Network 10.6.97 RC, plus two U7 Pro XGs.

I am not claiming those exact releases introduced the problem. Similar UDR7 behavior had already been reported on other releases.

I have changed or omitted SSID names, private IP addresses, MAC addresses, UUIDs, and other site-specific values in this post. I kept interface names such as wifi0ap1 and br3 where they are useful to understanding what happened.

The diagnostic, backup, and zone-remapping helpers I ended up using are included directly in this post. They are intentionally interactive so you do not have to hunt down every ID before you can start.

It looked like DHCP at first

When a Wi-Fi device gets an address from the wrong subnet, DHCP is an obvious suspect. Maybe another DHCP server is answering. Maybe a VLAN is missing from a trunk. Maybe a switch port has the wrong native network. Maybe some client-side setting is stale.

There are plenty of ordinary ways to create this symptom.

What made this case different was that the IoT network itself clearly worked. A client joining the IoT SSID through an external AP got the correct lease. The same logical SSID on the UDR7's own radio did not behave consistently.

That gave me a surprisingly useful A/B test. The DHCP server had not changed. The VLAN had not changed. The firewall had not changed. The SSID configuration in UniFi had not changed. Even the client could stay the same.

The major variable was the access point.

At that point, checking the same VLAN dropdown in the UniFi interface for the tenth time was not going to tell me anything new. The controller already believed the configuration was correct.

So I stopped asking where the client should be and started asking where it actually was.

Follow the MAC address, not the UI

One of the most useful commands in the entire investigation was almost embarrassingly simple:

CLIENT_MAC="aa:bb:cc:dd:ee:ff"

bridge fdb show | grep -i "$CLIENT_MAC"

The bridge forwarding database tells you where Linux has actually learned a MAC address. If a client is supposed to be in your IoT VLAN but its MAC appears on a different bridge, the problem is already happening below DHCP.

The neighbor table gives another useful view:

ip neigh show nud all | grep -i "$CLIENT_MAC"

And DHCP itself is easy to watch directly:

tcpdump -ni <iot-bridge> -e -nn \
  'port 67 or port 68'

If nothing appears there while the client reconnects, repeat the capture on the other bridges and see where the broadcast actually lands.

On my UDR7, the networks were represented by Linux bridges. The exact names are installation- and version-dependent, but conceptually mine looked like this:

Management / native network  -> br0
Main client network          -> br2
IoT network                  -> br3
Another isolated network     -> br4
Guest network                -> br99

That distinction mattered. The client was not simply sitting in the correct IoT VLAN and somehow receiving a weird lease.

Its traffic was appearing on the wrong Layer 2 bridge.

That is a very different problem.

There is a more recent UDR7 report on the Ubiquiti Community describing wireless clients being associated with the wrong Linux bridge. In that case, a wired client on the VLAN used the correct VLAN subinterface and bridge while a wireless client on the VLAN-assigned SSID appeared on the untagged interface instead. I would not assume every report has the same root cause, but it is a good example of why looking at the actual kernel bridge can reveal something the controller UI does not.

The AP that worked became the clue

My external U7 Pro XGs became the control group.

On those APs, the IoT SSID behaved exactly as I expected. The SSID had a virtual AP interface, the relevant VLAN subinterface existed, and client traffic went over the tagged uplink toward the IoT network. A real client connected there, produced DHCP traffic on the IoT bridge, and received the correct address.

Conceptually, the working path looked like this:

IoT SSID
   |
   v
virtual AP
   |
   v
IoT VLAN
   |
   v
tagged AP uplink
   |
   v
IoT bridge on gateway
   |
   v
IoT DHCP server

The UDR7 behaved differently even though UniFi displayed the same logical SSID-to-Network relationship.

That comparison ruled out a lot. If DHCP itself were fundamentally broken, the external APs should fail too. If the VLAN was missing from the network path, the external APs should fail too. If the client were simply confused, moving between APs should not so neatly change the result.

The increasingly likely explanation was that the UDR7 was translating a correct controller configuration into an incorrect local runtime configuration.

So I went one layer lower.

What the UDR7 was actually generating

The UDR7 creates several virtual wireless interfaces with names such as:

wifi0ap0
wifi0ap1
wifi0ap2

Those VAPs are tied to SSIDs, VLANs, AAA state, and Linux bridges. On my build, /tmp/system.cfg was particularly useful for seeing how UniFi had actually rendered that state.

For example:

grep -E 'aaa\.|wireless\.|vlan\.' /tmp/system.cfg

What I expected was straightforward: the IoT SSID, virtual AP, VLAN, and bridge should all agree with one another.

Instead, I found the equivalent of this:

SSID:       Home-IoT
VAP:        wifi0ap1
VLAN:       3

Expected bridge:
br3

Generated bridge association:
br4

That was the first real smoking gun.

The controller's logical configuration said one thing. The networking state generated from it said another.

At that point the question had changed from:

Why is DHCP giving this client the wrong address?

into:

Why did UniFi generate a different network topology from the one configured in UniFi?

Hostapd gave me another view of the same plumbing. Depending on the firmware and hardware, generated files under /etc/hostapd/ can show the VLAN mapping for a VAP:

cat /etc/hostapd/wifi0ap1.vlan

And bridge membership can be inspected directly:

bridge link show

These are implementation details, not a supported configuration interface. They can change between releases, and I would not try to fix this by manually editing them.

Even if a hand-edited file worked for five minutes, UniFi could regenerate it during provisioning, after another settings change, or at reboot. More importantly, editing the generated output would not repair whatever state caused UniFi to generate the wrong mapping in the first place.

I wanted UniFi to produce the correct configuration on its own.

Provisioning made it look intermittent

This was one of the more confusing parts of the troubleshooting.

At a few points, after reprovisioning or changing the Wi-Fi configuration, the IoT SSID appeared to behave correctly for a moment. Then the problem returned. That made it tempting to think there was some race condition, timing issue, or random wireless problem.

I did not want to build a diagnosis around something I could not reproduce reliably, though. The useful breakthrough came when I created an additional temporary SSID and UniFi reshuffled the virtual AP and AAA slots.

Then the broken behavior moved.

The original IoT SSID changed position and started behaving correctly. The temporary SSID inherited the problematic behavior instead.

That was the moment the whole thing stopped feeling random.

The failure was not following the client. It was not following DHCP. It was not even strictly following the SSID.

It was following the generated virtual AP/backend slot.

There are not many troubleshooting results more useful than making a supposedly intermittent bug move on command.

Once that happened, changing random firewall rules or DHCP settings would have been fixing the wrong layer.

Then I found someone with almost exactly the same UDR7 problem

Once I understood the pattern, I finally knew what to search for.

That led me to this Ubiquiti Community thread, and the similarities were difficult to ignore. In that case, clients connected through an external AP behaved correctly while clients using the UDR7's built-in Wi-Fi ended up on the wrong VLAN.

According to the poster, the case went through Ubiquiti's Wireless Escalation team, then Routing & Switching Escalation, and finally the general Escalation team. The eventual conclusion in that case was broken backend state: the virtual interfaces for the SSIDs and VLANs were not being bridged correctly.

The recommended fix was surprisingly simple on paper:

  1. Delete the affected Wi-Fi SSID or SSIDs.
  2. Delete the corresponding affected Network/VLAN.
  3. Recreate the Network first.
  4. Recreate the Wi-Fi SSID and assign it to the new Network.

The important part was why. Recreating those objects forces UniFi to rebuild the backend bridge and VLAN relationships instead of continuing to reuse the bad state.

The user reported that this solved the issue.

That matched my observations extremely well, including the part where moving the SSID between generated virtual slots changed the behavior.

So, finally, I had a plausible repair.

There was just one slightly inconvenient detail.

The Network Ubiquiti wanted rebuilt was also the Network I least wanted to delete.

"Just recreate the VLAN" is easy until other things depend on it

A Network object is rarely isolated once a setup has been in use for a while.

It can be referenced by Wi-Fi configurations, firewall zones, policies, fixed-IP assignments, and other objects. UniFi also identifies these objects internally by IDs. Recreating a Network with exactly the same name, VLAN ID, and subnet does not make it the same object again. The new Network gets a new internal ID.

Interestingly, the user in the Ubiquiti thread had avoided deleting the problematic VLAN during earlier troubleshooting for essentially the same reason: rebuilding fixed IPs and firewall configuration would be a pain.

I had the same concern.

I wanted to follow Ubiquiti's fix and genuinely recreate the affected objects, but I did not want a Wi-Fi repair to turn into an evening of rebuilding firewall rules from screenshots.

This is where the official UniFi Network API became useful.

Ubiquiti documents the API and its authentication in Getting Started with the Official UniFi API. More importantly for this problem, the Network API includes a Get Network References operation:

GET /v1/sites/{siteId}/networks/{networkId}/references

That endpoint exists for exactly the question I cared about before deleting anything:

What currently references this Network?

The current Network API also exposes Networks, WiFi Broadcasts, firewall zones, and — depending on the Network release — firewall policies. Ubiquiti provides version-specific API documentation for the version you are actually running, so I would always check that before making write requests.

Backup first. Delete later.

My first API action was therefore not DELETE.

It was inventory.

I saved the affected Network object, UniFi's list of references to it, Wi-Fi objects, firewall zones, and firewall policies. That gave me a snapshot I could inspect before changing anything.

For readers who run into the same problem, I turned that approach into a small interactive preflight helper. It performs GET requests only, lets you choose the site and affected Network rather than copying UUIDs by hand, and writes the result to a timestamped directory.

Save the script below as unifi-network-preflight.py, then run:

python3 unifi-network-preflight.py

It will ask for the console address and API key without echoing the key back to the terminal.

#!/usr/bin/env python3

"""Read-only preflight helper for rebuilding a UniFi Network object.

The script asks for a console address and API key, lets the user choose a
site and Network, then saves the affected Network, its references, Wi-Fi
broadcasts, firewall zones and (where supported) firewall policies.

It performs GET requests only.
"""

from __future__ import annotations

import getpass
import json
import os
import ssl
import sys
from datetime import datetime
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen


def prompt_yes_no(prompt: str, default: bool = False) -> bool:
    suffix = " [Y/n]: " if default else " [y/N]: "
    value = input(prompt + suffix).strip().lower()
    if not value:
        return default
    return value in {"y", "yes"}


def page_items(obj):
    if isinstance(obj, dict):
        data = obj.get("data")
        if isinstance(data, list):
            return data
    if isinstance(obj, list):
        return obj
    return []


def label(item, fallback="unnamed"):
    if not isinstance(item, dict):
        return fallback
    return str(item.get("name") or item.get("ssid") or item.get("id") or fallback)


def choose(items, title):
    if not items:
        raise RuntimeError(f"No {title.lower()} returned by the API.")

    print(f"\n{title}:")
    for index, item in enumerate(items, start=1):
        extra = []
        if isinstance(item, dict):
            if item.get("vlanId") is not None:
                extra.append(f"VLAN {item['vlanId']}")
            if item.get("enabled") is not None:
                extra.append("enabled" if item["enabled"] else "disabled")
        suffix = f" ({', '.join(extra)})" if extra else ""
        print(f"  {index:>2}. {label(item)}{suffix}")

    while True:
        raw = input("Select number: ").strip()
        try:
            number = int(raw)
            if 1 <= number <= len(items):
                return items[number - 1]
        except ValueError:
            pass
        print("Please enter one of the numbers shown above.")


def contains_value(value, needle):
    if isinstance(value, dict):
        return any(contains_value(v, needle) for v in value.values())
    if isinstance(value, list):
        return any(contains_value(v, needle) for v in value)
    return value == needle


def main():
    print("\nUniFi Network rebuild preflight")
    print("--------------------------------")
    print("READ-ONLY: this helper performs GET requests only.\n")

    gateway = input("UniFi console hostname/IP [127.0.0.1]: ").strip() or "127.0.0.1"
    base = f"https://{gateway}/proxy/network/integration/v1"
    verify_tls = prompt_yes_no("Verify the console TLS certificate?", default=False)
    api_key = getpass.getpass("UniFi API key: ").strip()

    if not api_key:
        print("No API key supplied.", file=sys.stderr)
        return 2

    context = ssl.create_default_context()
    if not verify_tls:
        context = ssl._create_unverified_context()  # local/self-signed console certs

    def api_get(path, query=None, optional=False):
        url = base + path
        if query:
            url += "?" + urlencode(query)
        request = Request(
            url,
            headers={
                "Accept": "application/json",
                "X-API-Key": api_key,
            },
            method="GET",
        )
        try:
            with urlopen(request, context=context, timeout=20) as response:
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as exc:
            if optional and exc.code in {404, 405}:
                return {"_skipped": True, "http_status": exc.code}
            body = exc.read().decode("utf-8", "replace")
            raise RuntimeError(f"GET {url} failed: HTTP {exc.code}: {body}") from exc
        except URLError as exc:
            raise RuntimeError(f"GET {url} failed: {exc}") from exc

    sites = page_items(api_get("/sites", {"offset": 0, "limit": 200}))
    site = choose(sites, "Sites")
    site_id = site["id"]

    networks_obj = api_get(f"/sites/{site_id}/networks", {"offset": 0, "limit": 200})
    networks = page_items(networks_obj)
    network = choose(networks, "Networks")
    network_id = network["id"]

    print("\nSelected:")
    print(f"  Site:    {label(site)} ({site_id})")
    print(f"  Network: {label(network)} ({network_id})")

    if not prompt_yes_no("Create a read-only snapshot for this Network?", default=True):
        print("Cancelled. No changes were made.")
        return 0

    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    out = Path(f"unifi-rebuild-preflight-{stamp}")
    out.mkdir(mode=0o700)

    resources = {
        "network.json": api_get(f"/sites/{site_id}/networks/{network_id}"),
        "network-references.json": api_get(
            f"/sites/{site_id}/networks/{network_id}/references"
        ),
        "wifi-broadcasts.json": api_get(
            f"/sites/{site_id}/wifi/broadcasts", {"offset": 0, "limit": 200}
        ),
        "firewall-zones.json": api_get(
            f"/sites/{site_id}/firewall/zones", {"offset": 0, "limit": 200}
        ),
        "firewall-policies.json": api_get(
            f"/sites/{site_id}/firewall/policies",
            {"offset": 0, "limit": 200},
            optional=True,
        ),
    }

    for filename, data in resources.items():
        (out / filename).write_text(
            json.dumps(data, indent=2, sort_keys=True) + "\n",
            encoding="utf-8",
        )

    refs = resources["network-references.json"].get("referenceResources", [])
    zones = [
        z for z in page_items(resources["firewall-zones.json"])
        if contains_value(z, network_id)
    ]
    wifi = [
        w for w in page_items(resources["wifi-broadcasts.json"])
        if contains_value(w, network_id)
    ]
    policies = []
    if not resources["firewall-policies.json"].get("_skipped"):
        policies = [
            p for p in page_items(resources["firewall-policies.json"])
            if contains_value(p, network_id)
        ]

    summary_lines = [
        "UniFi Network rebuild preflight",
        "================================",
        f"Created:  {datetime.now().isoformat(timespec='seconds')}",
        f"Site:     {label(site)} ({site_id})",
        f"Network:  {label(network)} ({network_id})",
    ]

    if network.get("vlanId") is not None:
        summary_lines.append(f"VLAN ID:  {network['vlanId']}")

    summary_lines += ["", "API-reported Network references:"]
    if refs:
        for ref in refs:
            summary_lines.append(
                f"  - {ref.get('resourceType', 'UNKNOWN')}: "
                f"{ref.get('referenceCount', len(ref.get('references', [])))}"
            )
    else:
        summary_lines.append("  - none reported")

    summary_lines += [
        "",
        f"Wi-Fi objects containing Network ID: {len(wifi)}",
        f"Firewall zones containing Network ID: {len(zones)}",
        f"Firewall policies directly containing Network ID: {len(policies)}",
    ]

    if wifi:
        summary_lines.append("\nMatching Wi-Fi objects:")
        for item in wifi:
            summary_lines.append(f"  - {label(item)} ({item.get('id', '?')})")

    if zones:
        summary_lines.append("\nMatching firewall zones:")
        for item in zones:
            summary_lines.append(f"  - {label(item)} ({item.get('id', '?')})")

    if policies:
        summary_lines.append("\nDirectly matching firewall policies:")
        for item in policies:
            summary_lines.append(f"  - {label(item)} ({item.get('id', '?')})")

    if resources["firewall-policies.json"].get("_skipped"):
        summary_lines.append(
            "\nFirewall policy listing was not exposed by this API version; "
            "that file records the skipped endpoint."
        )

    summary_lines += [
        "",
        "NO CHANGES WERE MADE.",
        f"Snapshot directory: {out.resolve()}",
        "Review network-references.json before deleting or recreating anything.",
    ]

    summary = "\n".join(summary_lines) + "\n"
    (out / "summary.txt").write_text(summary, encoding="utf-8")
    print("\n" + summary)
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        print("\nCancelled. No changes were made.", file=sys.stderr)
        raise SystemExit(130)
    except Exception as exc:
        print(f"\nERROR: {exc}", file=sys.stderr)
        raise SystemExit(1)

The version I actually use is a little more defensive and also attempts to snapshot firewall policies where the installed API exposes them, but the principle is the same: know the dependency graph before removing one of its nodes.

Also, do not paste your API key directly into a command line. There is no reason for a management key to end up in shell history, screenshots, or a public Git repository.

Preserving the firewall was easier than I expected

The reference inventory revealed an important detail in my configuration.

My relevant firewall rule did not directly point to the IoT Network. It pointed to an IoT firewall zone, and the zone contained the Network.

Conceptually, the existing relationship was:

Firewall policy
      |
      v
IoT firewall zone
      |
      v
OLD IoT Network

That was good news.

I did not need to recreate the policy. I could keep the same firewall policy and the same zone and, after creating the replacement Network, change the zone membership from the old Network ID to the new one:

Firewall policy
      |
      v
same IoT firewall zone
      |
      v
NEW IoT Network

The policy stays where it is. The zone stays where it is. Only the Network membership changes.

The UniFi API represents a firewall zone's Networks through networkIds. I used that relationship to remap the old Network ID to the new one rather than rebuilding the firewall by hand.

If you automate that part, I strongly recommend making it a dry run first. The safe pattern is:

  1. GET the current firewall zone.
  2. Confirm the old Network ID is actually present.
  3. Produce a payload with the new ID substituted.
  4. Print the payload and inspect it.
  5. Require an explicit confirmation before sending a PUT.
  6. GET the zone again afterward and verify the old ID is gone and the new one is present.

Do not blindly run a generic write script against your own controller. Your Network may have different references, and API schemas can change between releases. The point is to make the official "delete and recreate" fix controlled, not to turn it into a more dangerous one-liner.

For my own migration, I turned that last step into a second helper. It retrieves the current firewall zone, shows the existing and proposed networkIds, and does not write anything unless you type APPLY exactly. It then reads the zone back and verifies that the replacement actually happened.

Save it as unifi-zone-remap.py and run:

python3 unifi-zone-remap.py
#!/usr/bin/env python3

"""Safely replace one Network ID with another in a UniFi firewall zone.

The script shows the current zone, creates a minimal update payload containing
only name + networkIds, and performs no write unless the user types APPLY.
"""

from __future__ import annotations

import getpass
import json
import ssl
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


def ask(prompt, required=True):
    while True:
        value = input(prompt).strip()
        if value or not required:
            return value
        print("A value is required.")


def main():
    print("\nUniFi firewall-zone Network remap")
    print("----------------------------------")
    print("This helper performs one PUT only after an explicit APPLY confirmation.\n")

    gateway = ask("UniFi console hostname/IP [127.0.0.1]: ", required=False) or "127.0.0.1"
    site_id = ask("Site ID: ")
    zone_id = ask("Firewall zone ID: ")
    old_id = ask("OLD Network ID: ")
    new_id = ask("NEW Network ID: ")
    api_key = getpass.getpass("UniFi API key: ").strip()
    if not api_key:
        print("No API key supplied.", file=sys.stderr)
        return 2

    base = f"https://{gateway}/proxy/network/integration/v1"
    context = ssl._create_unverified_context()

    def request_json(method, path, payload=None):
        data = None
        headers = {
            "Accept": "application/json",
            "X-API-Key": api_key,
        }
        if payload is not None:
            data = json.dumps(payload).encode("utf-8")
            headers["Content-Type"] = "application/json"
        req = Request(base + path, data=data, headers=headers, method=method)
        try:
            with urlopen(req, context=context, timeout=20) as response:
                raw = response.read()
                return json.loads(raw.decode("utf-8")) if raw else {}
        except HTTPError as exc:
            body = exc.read().decode("utf-8", "replace")
            raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
        except URLError as exc:
            raise RuntimeError(str(exc)) from exc

    path = f"/sites/{site_id}/firewall/zones/{zone_id}"
    zone = request_json("GET", path)

    name = zone.get("name")
    network_ids = zone.get("networkIds")
    if not name or not isinstance(network_ids, list):
        raise RuntimeError("Zone response does not contain the expected name/networkIds fields.")

    if old_id not in network_ids:
        raise RuntimeError("OLD Network ID is not currently a member of this zone.")
    if new_id in network_ids:
        raise RuntimeError("NEW Network ID is already a member of this zone.")

    new_network_ids = [new_id if item == old_id else item for item in network_ids]
    payload = {"name": name, "networkIds": new_network_ids}

    print("\nCurrent zone:")
    print(json.dumps({"name": name, "networkIds": network_ids}, indent=2))
    print("\nProposed update:")
    print(json.dumps(payload, indent=2))

    print("\nNo write has happened yet.")
    confirmation = input("Type APPLY to perform this exact zone update: ").strip()
    if confirmation != "APPLY":
        print("Cancelled. No changes were made.")
        return 0

    result = request_json("PUT", path, payload)
    print("\nUpdate accepted. API response:")
    print(json.dumps(result, indent=2))

    verify = request_json("GET", path)
    ids_after = verify.get("networkIds", [])
    if new_id in ids_after and old_id not in ids_after:
        print("\nVerification OK: new Network ID is present and old Network ID is gone.")
        return 0

    print("\nWARNING: post-write verification did not match the expected state.", file=sys.stderr)
    return 3


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        print("\nCancelled.", file=sys.stderr)
        raise SystemExit(130)
    except Exception as exc:
        print(f"\nERROR: {exc}", file=sys.stderr)
        raise SystemExit(1)

This helper is intentionally narrower than the preflight tool. It does one thing: replace one known Network ID with another inside one known firewall zone. I would only use it after reviewing the preflight snapshot and the API documentation for the Network version actually installed on the console.

The Network still had to be genuinely recreated

This part is important: the API was not a workaround for Ubiquiti's recommendation.

The affected Network and Wi-Fi objects still needed to be rebuilt so that UniFi generated fresh backend state and fresh internal IDs. The API simply made the surrounding migration manageable.

My workflow was essentially:

  1. Snapshot the affected Network, its references, Wi-Fi objects, firewall zones, and policies.
  2. Identify every relevant dependency on the old Network ID.
  3. Remove the affected Wi-Fi object.
  4. Remove and recreate the affected Network with the required VLAN/subnet settings.
  5. Record the new Network ID.
  6. Remap the surrounding references from the old ID to the new one.
  7. Recreate the production IoT SSID against the new Network.
  8. Confirm that the old Network ID is no longer referenced where it should not be.
  9. Inspect the generated runtime configuration.
  10. Test with a real client.

I deliberately would not publish a huge universal POST /networks payload here. Network objects have enough options that a payload suitable for my installation could be subtly wrong for somebody else's, and the API evolves. Use the create schema documented by the Network version you are actually running.

Also, do not simply POST the complete JSON returned by a GET request. API responses can contain IDs, metadata, and read-only fields that are not valid create parameters.

The goal is to recreate the object, not clone its old internal identity.

After the rebuild, the pieces finally agreed

Once the affected Network and IoT SSID had been recreated, I checked the same generated state that had exposed the problem.

This time, the relevant mapping looked like this:

aaa.2.br.devname=br3
aaa.2.devname=wifi0ap1
aaa.2.ssid=Home-IoT

wireless.2.devname=wifi0ap1
wireless.2.ssid=Home-IoT

vlan.10.devname=wifi0ap1
vlan.10.id=3

And Hostapd agreed:

* wifi0ap1.#
1 wifi0ap1.1 br0
3 wifi0ap1.3 br3

The SSID, virtual AP, VLAN, and bridge were now telling the same story.

That was encouraging, but I still did not consider the issue fixed.

After all, the controller had looked correct before.

The final test had to be a packet.

Trust the DHCPACK, not the green check mark

I connected a real IoT client and watched the expected bridge:

IOT_BRIDGE="br3"

tcpdump -ni "$IOT_BRIDGE" -e -nn \
  'port 67 or port 68'

What I wanted to see was completely boring:

DHCPDISCOVER
DHCPOFFER
DHCPREQUEST
DHCPACK

Boring is excellent when you have spent hours debugging VLANs.

The client received an address from the correct IoT subnet. Its MAC was learned through the expected path:

bridge fdb show | grep -i "$CLIENT_MAC"

and the neighbor state matched:

ip neigh show nud all | grep -i "$CLIENT_MAC"

Only then did I consider the problem fixed.

Not when the Network dropdown looked correct. It had looked correct before.

Not when I recreated the objects.

Not even when /tmp/system.cfg finally looked sane.

It was fixed when a real client's packets went where the controller claimed they would go.

A diagnostic helper I wish I had at the start

After doing the same checks repeatedly, I put the useful ones into a small interactive script. It is intentionally read-only: it asks for a client MAC, optional SSID and expected bridge, checks the FDB and neighbor tables, looks for a direct Wi-Fi association, inspects Hostapd and /tmp/system.cfg, and finally listens on all brX bridges at the same time while you reconnect or wake the client.

This is much closer to how I would start if I encountered a "UniFi SSID gets an IP from the wrong VLAN" problem again.

Save it as unifi-vlan-diagnose.sh and run it as root on the UniFi gateway:

chmod +x unifi-vlan-diagnose.sh
./unifi-vlan-diagnose.sh
#!/bin/sh

set -u

printf '\nUniFi UDR7 Wi-Fi / VLAN diagnostic\n'
printf '%s\n\n' '---------------------------------'

printf 'Client MAC address: '
IFS= read -r CLIENT_MAC

if ! printf '%s\n' "$CLIENT_MAC" | grep -Eiq '^([0-9a-f]{2}:){5}[0-9a-f]{2}$'; then
    echo 'Invalid MAC address.'
    exit 1
fi

printf 'Expected SSID (optional): '
IFS= read -r EXPECTED_SSID

echo
echo 'Available bridge interfaces:'
ip -br addr 2>/dev/null | awk '$1 ~ /^br[0-9]+$/ {print "  " $0}'

echo
printf 'Expected bridge (optional, for example br3): '
IFS= read -r EXPECTED_BRIDGE

printf 'Live capture duration in seconds [15]: '
IFS= read -r CAPTURE_SECONDS
CAPTURE_SECONDS=${CAPTURE_SECONDS:-15}

case "$CAPTURE_SECONDS" in
    ''|*[!0-9]*)
        echo 'Capture duration must be a positive integer.'
        exit 1
        ;;
esac

LOG="/tmp/unifi-vlan-diagnostic-$(date +%Y%m%d-%H%M%S).log"
TMPDIR_CAPTURE="$(mktemp -d /tmp/unifi-vlan-capture.XXXXXX)" || exit 1

cleanup()
{
    rm -rf "$TMPDIR_CAPTURE"
}
trap cleanup EXIT INT TERM

{
    echo
    echo '============================================================'
    echo ' SYSTEM'
    echo '============================================================'
    date -Is 2>/dev/null || date
    uname -a

    echo
    echo '============================================================'
    echo ' CLIENT LOCATION - BRIDGE FDB'
    echo '============================================================'
    bridge fdb show 2>/dev/null | grep -i "$CLIENT_MAC" || \
        echo 'Client MAC is not currently in the bridge FDB.'

    echo
    echo '============================================================'
    echo ' CLIENT LOCATION - NEIGHBOR TABLE'
    echo '============================================================'
    ip neigh show nud all 2>/dev/null | grep -i "$CLIENT_MAC" || \
        echo 'No IPv4 neighbor entry.'
    ip -6 neigh show nud all 2>/dev/null | grep -i "$CLIENT_MAC" || \
        echo 'No IPv6 neighbor entry.'

    echo
    echo '============================================================'
    echo ' DIRECT WI-FI ASSOCIATION'
    echo '============================================================'

    FOUND=0
    if command -v iw >/dev/null 2>&1; then
        for IFACE in $(iw dev 2>/dev/null | awk '$1=="Interface" {print $2}'); do
            STA="$(iw dev "$IFACE" station get "$CLIENT_MAC" 2>/dev/null || true)"
            if [ -n "$STA" ]; then
                FOUND=1
                echo
                echo "Client is associated on: $IFACE"
                iw dev "$IFACE" info 2>/dev/null | \
                    grep -E 'Interface|addr |ssid |channel' || true
                echo
                printf '%s\n' "$STA"
                echo
                echo 'Bridge membership:'
                bridge link show 2>/dev/null | \
                    grep -E "$IFACE|${IFACE}\." || true
            fi
        done
    else
        echo 'iw is not available on this system.'
    fi

    [ "$FOUND" -eq 1 ] || \
        echo 'Client is not directly associated with a radio on this device.'

    if [ -n "$EXPECTED_SSID" ]; then
        echo
        echo '============================================================'
        echo " HOSTAPD CONFIG FOR SSID: $EXPECTED_SSID"
        echo '============================================================'

        SSID_FOUND=0
        for CFG in /etc/hostapd/*.cfg; do
            [ -f "$CFG" ] || continue
            if grep -Fxq "ssid=$EXPECTED_SSID" "$CFG" 2>/dev/null; then
                SSID_FOUND=1
                echo
                echo "--- $CFG ---"
                grep -E '^(interface|ssid|bridge|vlan_file|dynamic_vlan)=' \
                    "$CFG" 2>/dev/null || true

                VAP="$(sed -n 's/^interface=//p' "$CFG" | head -1)"
                VLAN_FILE="$(sed -n 's/^vlan_file=//p' "$CFG" | head -1)"

                if [ -n "$VLAN_FILE" ] && [ -f "$VLAN_FILE" ]; then
                    echo
                    echo "--- $VLAN_FILE ---"
                    cat "$VLAN_FILE"
                fi

                if [ -n "$VAP" ]; then
                    echo
                    echo "Bridge interfaces related to $VAP:"
                    bridge link show 2>/dev/null | \
                        grep -E "$VAP|${VAP}\." || true
                fi
            fi
        done

        [ "$SSID_FOUND" -eq 1 ] || \
            echo 'No matching Hostapd configuration found.'
    fi

    echo
    echo '============================================================'
    echo ' GENERATED UNIFI STATE'
    echo '============================================================'

    if [ -f /tmp/system.cfg ]; then
        if [ -n "$EXPECTED_SSID" ]; then
            echo
            echo 'Entries containing the expected SSID:'
            grep -n -F "$EXPECTED_SSID" /tmp/system.cfg || true
        fi

        echo
        echo 'Wireless / AAA / VLAN mappings:'
        grep -E '^(aaa|wireless|vlan)\.' /tmp/system.cfg 2>/dev/null | \
            grep -E 'devname|br\.devname|ssid|\.id=' || true
    else
        echo '/tmp/system.cfg not found.'
    fi

    if [ -n "$EXPECTED_BRIDGE" ]; then
        echo
        echo '============================================================'
        echo " EXPECTED BRIDGE: $EXPECTED_BRIDGE"
        echo '============================================================'
        ip -br addr show "$EXPECTED_BRIDGE" 2>/dev/null || \
            echo 'Expected bridge does not exist.'
        echo
        bridge link show 2>/dev/null | \
            grep "master $EXPECTED_BRIDGE" || true
    fi

    echo
    echo '============================================================'
    echo ' LIVE PACKET WATCH'
    echo '============================================================'
    echo
    echo "For the next $CAPTURE_SECONDS seconds, reconnect or wake the client."
    echo 'The script will listen on every brX bridge at the same time.'
    echo

    PIDS=''
    BRIDGES="$(ip -o link show 2>/dev/null | \
        awk -F': ' '$2 ~ /^br[0-9]+$/ {print $2}')"

    if [ -z "$BRIDGES" ]; then
        echo 'No brX bridge interfaces found.'
    elif ! command -v tcpdump >/dev/null 2>&1; then
        echo 'tcpdump is not available; skipping live capture.'
    elif ! command -v timeout >/dev/null 2>&1; then
        echo 'timeout is not available; skipping live capture.'
    else
        for BR in $BRIDGES; do
            timeout "$CAPTURE_SECONDS" \
                tcpdump -lni "$BR" -e -nn \
                "ether host $CLIENT_MAC" \
                >"$TMPDIR_CAPTURE/$BR.txt" 2>&1 &
            PIDS="$PIDS $!"
        done

        for PID in $PIDS; do
            wait "$PID" 2>/dev/null || true
        done

        HIT=0
        for FILE in "$TMPDIR_CAPTURE"/*.txt; do
            [ -f "$FILE" ] || continue
            if grep -qi "$CLIENT_MAC" "$FILE"; then
                HIT=1
                echo
                echo "--- $(basename "$FILE" .txt) ---"
                cat "$FILE"
            fi
        done

        [ "$HIT" -eq 1 ] || \
            echo "No frames from $CLIENT_MAC were observed during the capture."
    fi

    echo
    echo '============================================================'
    echo ' FDB AFTER CAPTURE'
    echo '============================================================'
    bridge fdb show 2>/dev/null | grep -i "$CLIENT_MAC" || \
        echo 'Client MAC is still not present in the bridge FDB.'

    echo
    echo '============================================================'
    echo ' DONE'
    echo '============================================================'
    echo "Report: $LOG"
} 2>&1 | tee "$LOG"

A few caveats: interface names can change between UniFi releases, not every client will be associated directly with the gateway radio, and an external AP client may only show up through the wired/VLAN side of the FDB. The script is meant to collect evidence, not pronounce a universal diagnosis.

That is exactly what I wish I had done earlier: gather the Layer 2 facts first, then decide which theory deserves attention.

What I would do first next time

If a UniFi Wi-Fi client suddenly gets an address from the wrong VLAN, I would go below the controller much earlier.

Start with a known client MAC and find out where it is actually learned. Watch DHCP on the bridge where it is supposed to be. If you have multiple APs, use them as an A/B test. The same client and SSID working through one AP but failing through another is an extremely valuable clue.

Then inspect the generated VAP, VLAN, Hostapd, and bridge state. If those disagree with the controller, stop randomly changing DHCP and firewall settings.

And if adding another SSID makes the broken behavior move to another virtual AP slot, that is not noise. That is evidence.

If you eventually decide the affected Network really does need to be recreated, find its dependencies before deleting it. The references endpoint exists for a reason.

The part I found most interesting

This would have been a boring problem if I had simply selected the wrong VLAN in a dropdown.

Instead, the logical configuration was correct while the generated network underneath it was not. The external APs provided a control group. bridge fdb showed where the client actually existed. tcpdump showed where DHCP really happened. Hostapd and /tmp/system.cfg exposed the disagreement, and creating an extra SSID made the faulty behavior move.

Only after that did the right search terms lead me to someone whose UDR7 had behaved almost identically — and to Ubiquiti's recommendation to rebuild the affected Network and Wi-Fi objects.

Following that advice fixed the bad state. Using the API to inventory and remap the surrounding dependencies meant I did not have to rebuild my firewall by hand.

And the final confirmation was not a green icon in UniFi.

It was a DHCPACK on the correct bridge.

Sometimes the fastest way through a networking problem is to stop asking where a packet should go and simply follow where it actually went.

You’ve successfully subscribed to Maxim's Blog
Welcome back! You’ve successfully signed in.
Great! You’ve successfully signed up.
Success! Your email is updated.
Your link has expired
Success! Check your email for magic link to sign-in.