Is your feature request related to a problem? Please describe.
We use ZeroTier as a management VPN — it connects our OpenWrt hotspots back to FreeRADIUS, the OpenWISP controller, and monitoring infrastructure. User traffic does not flow over ZeroTier.
The ZeroTier backend currently generates a single route from the VPN's IPAM subnet:
config["routes"] = [{"target": str(self.subnet), "via": ""}]
This is called by both create_network() and update_network() in zerotier_service.py. Every sync pushes one flat route (e.g., 10.0.0.0/8) to the ZeroTier controller, replacing any routes previously on the network.
With a large flat subnet and many members, ZeroTier's Multicast Recipient Limit (default 32) becomes a bottleneck. ZeroTier converts ARP to targeted multicast groups, but when the network exceeds the recipient limit, ARP responses don't reliably reach all members, causing intermittent connectivity.
Additionally, there's no observability into what routes OpenWISP pushes:
vpn_backends.py removes routes from the ZeroTier JSON schema (no UI exposure)
_get_repsonse() strips routes and ipAssignmentPools from ZT API responses
Describe the solution you'd like
1. Generate ZeroTier routes from IPAM child subnets (opt-in)
Add an option to the VPN model (e.g., a boolean zt_routes_from_children) that changes route generation behavior. When enabled and the VPN's IPAM subnet has child subnets, generate a route for each child instead of a single route from the parent.
Example IPAM structure:
10.0.0.0/8 (master subnet — VPN points here)
├── 10.1.1.0/24 (server group 1)
├── 10.1.2.0/24 (server group 2)
├── 10.1.3.0/24 (server group 3)
└── ...
When enabled, _add_routes_and_ip_assignment() would produce:
config["routes"] = [
{"target": "10.0.0.0/8", "via": ""}, # parent (cross-subnet reachability)
{"target": "10.1.1.0/24", "via": ""}, # child
{"target": "10.1.2.0/24", "via": ""}, # child
{"target": "10.1.3.0/24", "via": ""}, # child
...
]
Per-child routes reduce the L2 scope per subnet — each device's multicast groups are scoped to its /24 instead of the entire flat network.
Implementation considerations:
- Backward compatible: default behavior unchanged; feature is opt-in
- Uses existing IPAM data: leverages
master_subnet / child_subnet_set hierarchy in openwisp-ipam — no new models needed
- ZeroTier-specific: other backends (OpenVPN, WireGuard, VXLAN) generate on-device configs via templates, not controller-level routes. This change is scoped to
zerotier_service.py only
- Route limit: ZeroTier has a hard limit of 128 routes per network (
ZT_MAX_NETWORK_ROUTES in ZeroTierOne.h). The implementation should validate against this and raise a clear error if exceeded.
- Gateway support: for deployments needing true L2 isolation across subnets, the parent route should support a
via gateway address (e.g., "via": "10.0.0.1") instead of being directly attached. With via: "", devices still consider the entire parent subnet as L2-local and will ARP for cross-subnet IPs. A gateway route avoids this.
2. Per-device vpn_child_subnet template variable
The vpn_subnet_{pk} system variable (added in #642/#654) always returns the VPN's master subnet. There's no way for a VPN template to know which child subnet a device's IP belongs to.
This is because:
vpn_subnet_{pk} is set in vpn.py get_context() as str(self.subnet.subnet) — always the master
- VPN context is applied after group variables in
config.py's merge order (line 960 vs 958), so group-level overrides are overwritten
- A VPN object is tied to a single IPAM subnet with no mechanism to resolve the device's child subnet
A new system variable (e.g., vpn_child_subnet_{pk}) that resolves the device's assigned IP to its containing child subnet would close this gap.
3. Route observability (minor)
Re-enable routes in the VPN JSON schema (currently removed in vpn_backends.py) and keep routes in _get_repsonse() output. This would let administrators see what routes OpenWISP is pushing to the ZT controller.
Describe alternatives you've considered
Cron-based route re-addition: We initially ran a 5-minute cron job that re-added /24 routes directly to the ZeroTier controller API after every OpenWISP-triggered wipe. This works but creates a race condition window where routes are missing after each sync cycle.
Separate ZeroTier networks per subnet: This would provide true isolation but loses the convenience of a single network and makes IPAM management more complex.
Modifying zerotier_service.py directly (PoC): We applied a proof-of-concept patch to _add_routes_and_ip_assignment() that queries IPAM child subnets and generates routes from them. This has been running stably in production but reverts on any pip upgrade and isn't a maintainable solution.
Additional context
Is your feature request related to a problem? Please describe.
We use ZeroTier as a management VPN — it connects our OpenWrt hotspots back to FreeRADIUS, the OpenWISP controller, and monitoring infrastructure. User traffic does not flow over ZeroTier.
The ZeroTier backend currently generates a single route from the VPN's IPAM subnet:
This is called by both
create_network()andupdate_network()inzerotier_service.py. Every sync pushes one flat route (e.g.,10.0.0.0/8) to the ZeroTier controller, replacing any routes previously on the network.With a large flat subnet and many members, ZeroTier's Multicast Recipient Limit (default 32) becomes a bottleneck. ZeroTier converts ARP to targeted multicast groups, but when the network exceeds the recipient limit, ARP responses don't reliably reach all members, causing intermittent connectivity.
Additionally, there's no observability into what routes OpenWISP pushes:
vpn_backends.pyremovesroutesfrom the ZeroTier JSON schema (no UI exposure)_get_repsonse()stripsroutesandipAssignmentPoolsfrom ZT API responsesDescribe the solution you'd like
1. Generate ZeroTier routes from IPAM child subnets (opt-in)
Add an option to the VPN model (e.g., a boolean
zt_routes_from_children) that changes route generation behavior. When enabled and the VPN's IPAM subnet has child subnets, generate a route for each child instead of a single route from the parent.Example IPAM structure:
When enabled,
_add_routes_and_ip_assignment()would produce:Per-child routes reduce the L2 scope per subnet — each device's multicast groups are scoped to its
/24instead of the entire flat network.Implementation considerations:
master_subnet/child_subnet_sethierarchy in openwisp-ipam — no new models neededzerotier_service.pyonlyZT_MAX_NETWORK_ROUTESinZeroTierOne.h). The implementation should validate against this and raise a clear error if exceeded.viagateway address (e.g.,"via": "10.0.0.1") instead of being directly attached. Withvia: "", devices still consider the entire parent subnet as L2-local and will ARP for cross-subnet IPs. A gateway route avoids this.2. Per-device
vpn_child_subnettemplate variableThe
vpn_subnet_{pk}system variable (added in #642/#654) always returns the VPN's master subnet. There's no way for a VPN template to know which child subnet a device's IP belongs to.This is because:
vpn_subnet_{pk}is set invpn.pyget_context()asstr(self.subnet.subnet)— always the masterconfig.py's merge order (line 960 vs 958), so group-level overrides are overwrittenA new system variable (e.g.,
vpn_child_subnet_{pk}) that resolves the device's assigned IP to its containing child subnet would close this gap.3. Route observability (minor)
Re-enable
routesin the VPN JSON schema (currently removed invpn_backends.py) and keeproutesin_get_repsonse()output. This would let administrators see what routes OpenWISP is pushing to the ZT controller.Describe alternatives you've considered
Cron-based route re-addition: We initially ran a 5-minute cron job that re-added
/24routes directly to the ZeroTier controller API after every OpenWISP-triggered wipe. This works but creates a race condition window where routes are missing after each sync cycle.Separate ZeroTier networks per subnet: This would provide true isolation but loses the convenience of a single network and makes IPAM management more complex.
Modifying
zerotier_service.pydirectly (PoC): We applied a proof-of-concept patch to_add_routes_and_ip_assignment()that queries IPAM child subnets and generates routes from them. This has been running stably in production but reverts on any pip upgrade and isn't a maintainable solution.Additional context
vpn_subnetsystem variable was added in [feature] Add VPN subnet CIDR to device system defined variables #642/[change] Add VPN subnet CIDR to device system defined variables #642 #654 to expose the VPN CIDR to templates