Skip to content

Send only _id/name/network_ids on firewall zone PUT - #24

Open
estenrye wants to merge 12 commits into
ubiquiti-community:mainfrom
estenrye:fix-firewall-zone-minimal-payload
Open

estenrye wants to merge 12 commits into
ubiquiti-community:mainfrom
estenrye:fix-firewall-zone-minimal-payload

Conversation

@estenrye

Copy link
Copy Markdown

Summary

Follow-up to #23 -- stripping attr_no_edit alone wasn't enough. Confirmed live by manually replicating the PUT against a real UDM-SE: the zone object also carries cloud_template, default_zone, external_id, and zone_key, and /firewall/zone/{id} rejects every one of them as an "unrecognized field" on write. Only _id, name, and network_ids are accepted. Builds a minimal update object instead of filtering the GET response field-by-field.

Test plan

  • python3 -m py_compile unifi_ml2_driver/unifi_mech.py
  • Verified against the real UDM-SE: a minimal {_id, name, network_ids} PUT succeeds and the zone's network_ids reflects the change.

🤖 Generated with Claude Code

estenrye and others added 12 commits September 14, 2026 01:21
_get_controller() checked `CONF.unifi.controller not in self._controllers`
before every single controller-API call, but config.py never registers
a `controller` option in the [unifi] group -- only host, port, apikey,
username, password, site, verify_ssl, cafile, and the feature-flag
options. Every real call raised `oslo_config.cfg.NoSuchOptError`
unconditionally.

initialize()'s own call to _get_controller() (line 92) is wrapped in a
broad try/except that only logs and continues, which is why this never
surfaced at driver startup. create_network_postcommit and friends have
no such guard, so a real `openstack network create` fails outright and
Neutron's ML2 manager rolls back by deleting the network it just
created.

The fix is the option name actually used as the dict key two lines
later (`self._controllers[CONF.unifi.host]`), not a new option --
`self.controller = None` in __init__ (dead, unused elsewhere) suggests
`self.controller` was likely the original intent and got typo'd into
`CONF.unifi.controller` at some point.

Added a regression test that exercises real oslo_config registration
directly (TestUnifiMechDriver's existing setUp replaces cfg.CONF with a
MagicMock entirely, so register_opts/set_override there never validate
anything real -- which is exactly how this shipped unnoticed). Verified
against a real Python 3.10.12 + neutron/neutron-lib/oslo_config
environment (not just unit-test mocks): the new test passes, and the
existing suite's 10 pre-existing failures in TestUnifiMechDriver are
unchanged before/after this fix -- confirmed by running both against
the same environment, so they're pre-existing (stale mocks for an
older aiounifi API shape), not something this change caused.
Renames the PyPI distribution (not the import name or entry point --
still unifi_ml2_driver / unifi, so this stays a drop-in replacement)
since publishing under upstream's own unifi-ml2-driver name isn't
possible without owning that PyPI project.

requires-python drops from upstream's >=3.12.0 to >=3.10.12, matching
what the code actually needs rather than upstream's policy bump.
Verified live: the fixed driver plus its aiohttp-unifi>=88 dependency
both import and run correctly under real Python 3.10.12 (the actual
target platform this fork exists for), given a small sitecustomize.py
that back-ports the handful of Python 3.11+-only typing/enum symbols
aiohttp-unifi>=86 uses (Self, NotRequired, Required, StrEnum) from
typing_extensions/backports.strenum -- neither package's own code
needs 3.12 syntax, confirmed by a direct py_compile pass plus a real
import/pytest run inside a Python 3.10.12 environment matching the
target Neutron image exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Poetry's own dependency solver enforces overlapping Python-version
ranges strictly and refuses to lock or install a graph where this
fork's own requires-python (>=3.10.12) doesn't fully contain a
dependency's declared range (aiohttp-unifi's >=3.12.0) -- confirmed:
`poetry lock` fails outright with "version solving failed" on this
exact combination. pip's --ignore-requires-python (what
neutron-ml2-guardian's own install/download commands use) has no
poetry equivalent.

`poetry build` needs neither a lock file nor an installed environment,
only pyproject.toml, so removing the poetry.lock this repo can no
longer validly maintain and dropping `poetry install` from the publish
workflow doesn't affect building/publishing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It derived a nonsensical "1.0.9.dev3" even from a perfectly clean tree
with the release tag exactly at HEAD (git describe agreed: "v1.0.10",
no distance suffix) -- and worse, it turns out to hook poetry's build
process directly, silently overriding a manual `poetry version` call
regardless of what value is set. Simpler and more reliable to just
derive the version from the git ref directly and set it with a plain
`poetry version`, matching the pattern neutron-ml2-guardian's own CI
already uses for its Helm chart version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Failed outright: "No file ... matched to [**/poetry.lock]". Same root
cause as the previous two commits -- this repo can't maintain a valid
poetry.lock given its relaxed requires-python, by design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found live via a real openstack network create: every HTTPS request
to a real UniFi controller failed outright with "Cannot create a
client socket with a PROTOCOL_TLS_SERVER context" -- ssl.Purpose.
CLIENT_AUTH configures a context for a server verifying incoming
client certificates, the opposite of what's needed to make an
outgoing request as a client verifying the remote server's cert
(ssl.Purpose.SERVER_AUTH). This context was never usable for its
actual purpose at all with verify_ssl left at its default (true).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found live via a real openstack network create against a real UDM-SE:
the request succeeded in reaching the controller (both prior fixes --
_get_controller's NoSuchOptError and the SSL Purpose bug -- confirmed
working), but the server rejected it with api.err.VlanUsed reporting
vlan: 1, "Default Management Network" -- not the vlan we actually
requested (1999).

Root cause: aiounifi's Network.vlan property only returns the raw
"vlan" value when vlan_enabled is also true (defaults false):

    def vlan(self) -> Optional[int]:
        if self.vlan_enabled:
            return self.raw.get("vlan", None)

vlan_data never set vlan_enabled at all, so the server treated the
submission as untagged/VLAN-disabled -- which collides with the
Default Management Network's own untagged (effectively "vlan 1")
configuration, hence the conflict reported against vlan 1 rather than
whatever we asked for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The five subnet hooks (create/update/delete _precommit/_postcommit)
were literal no-op stubs, both upstream and in this fork. Networks
got VLAN-level sync via create_network_postcommit, but subnet-level
info (CIDR, gateway, DHCP pool, DNS servers) never reached the
matching UniFi networkconf's ip_subnet/dhcpd_*/dhcpdv6_* fields.

Postcommit hooks locate the UniFi network by VLAN ID and push the
Neutron subnet's cidr/gateway_ip/enable_dhcp/allocation_pools/
dns_nameservers onto it via NetworkUpdateRequest, following the same
pattern already proven for network create/update. IPv4 and IPv6
(including DHCPv6-stateful) are both handled, since this environment
already has an IPv6 self-service subnet. Delete disables DHCP/gateway
on the UniFi side rather than clearing ip_subnet/ipv6_subnet outright,
to avoid leaving the network config in an invalid state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Firewall zone membership in UniFi's Zone-Based Firewall lives on the
zone object's own network_ids list, not on the network's networkconf
-- there's no per-network "zone" field to set directly. Adds
CONF.unifi.default_firewall_zone (unset by default, matching every
other optional feature in this driver) and two helpers,
_assign_network_to_default_zone/_unassign_network_from_default_zone,
that look up the configured zone by name via the Network Integration
API's /firewall/zone endpoints and add/remove the network's ID from
it. Wired into create_network_postcommit, update_network_postcommit
(after the VLAN is (re)created), and delete_network_postcommit
(before the network itself is deleted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
create_network_postcommit/update_network_postcommit's zone-assignment
calls passed the Neutron network UUID to
_assign_network_to_default_zone, but UniFi assigns its own _id (a
Mongo ObjectId) on create and ignores the requested "_id" -- confirmed
live against a real UDM-SE: a test network's real _id
(6aa7f6d7742467ea9dfa2f6d) had nothing to do with its Neutron network
UUID. Zone membership (network_ids) needs that real id. Both
postcommit hooks now look the network up by VLAN via
_unifi_network_for_vlan (same helper the subnet hooks already use)
and pass its .id instead. delete_network_postcommit already did this
correctly via target_network.id -- only the create/update paths had
the bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two bugs found live against a real UDM-SE while verifying ubiquiti-community#22:

1. Round-tripping the full zone.raw dict (as returned by GET) into the
   PUT body includes "attr_no_edit", which the UniFi API rejects
   outright: "JSON parse error: Unrecognized field \"attr_no_edit\" ...
   not marked as ignorable". Strip it before sending.

2. That rejection was fatal: create_network_postcommit's exception
   handler re-raises, which Neutron treats as a MechanismDriverError
   and reacts to by deleting the network it just created -- so a
   firewall-zone hiccup was taking down the core VLAN/subnet sync that
   had already succeeded. Both zone helpers now catch their own
   exceptions and log-and-continue instead of propagating, matching
   the non-raising convention delete_network_postcommit already uses
   for the same reason (a secondary feature must never block or
   unwind the primary one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Stripping attr_no_edit alone (from ubiquiti-community#23) wasn't enough. Confirmed live
by manually replicating the PUT: the real UDM-SE zone object also
carries cloud_template, default_zone, external_id, and zone_key, and
the /firewall/zone/{id} endpoint rejects every one of them as an
"unrecognized field" on write -- only _id, name, and network_ids are
accepted. Round-tripping GET's full payload into PUT was never going
to work here; build a minimal update object instead of filtering the
GET response field-by-field.

Verified against the real UDM-SE: a minimal {_id, name, network_ids}
PUT succeeds and the zone's network_ids reflects the change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant