Skip to content

Commit 7e56b29

Browse files
authored
Add support for network objects and profiles in UniFi integration (#97)
- Introduced new API endpoints and paths for managing network objects, port profiles, WLAN rate profiles, RADIUS profiles, and WAN SLA profiles. - Added a service to sync remote curated groups from specified URLs, enhancing firewall group management. - Updated the manifest version to 3.3.0 and required aiounifi version to 84.0.0. - Implemented mixins for handling various profile types and added utility functions for fetching and parsing curated lists.
1 parent 79dd9c1 commit 7e56b29

10 files changed

Lines changed: 650 additions & 3 deletions

File tree

custom_components/unifi_network_rules/const.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@
137137
API_ENDPOINT_QOS_RULES_BATCH = "/proxy/network/v2/api/site/{site}/qos-rules/batch"
138138
API_ENDPOINT_NETWORK_CONF = "/proxy/network/api/s/{site}/rest/networkconf"
139139
API_ENDPOINT_NETWORK_CONF_DETAIL = "/proxy/network/api/s/{site}/rest/networkconf/{network_id}"
140+
# Objects and Profiles
141+
API_ENDPOINT_OBJECTS = "/proxy/network/v2/api/site/{site}/objects"
142+
API_ENDPOINT_OBJECT_DETAIL = "/proxy/network/v2/api/site/{site}/objects/{object_id}"
143+
API_ENDPOINT_PORT_PROFILES = "/proxy/network/api/s/{site}/rest/portconf"
144+
API_ENDPOINT_PORT_PROFILE_DETAIL = "/proxy/network/api/s/{site}/rest/portconf/{profile_id}"
145+
API_ENDPOINT_WLAN_RATE_PROFILES = "/proxy/network/v2/api/site/{site}/profiles/wlanrate"
146+
API_ENDPOINT_WLAN_RATE_PROFILE_DETAIL = "/proxy/network/v2/api/site/{site}/profiles/wlanrate/{profile_id}"
147+
API_ENDPOINT_RADIUS_PROFILES = "/proxy/network/api/s/{site}/rest/radiusprofile"
148+
API_ENDPOINT_RADIUS_PROFILE_DETAIL = "/proxy/network/api/s/{site}/rest/radiusprofile/{profile_id}"
149+
API_ENDPOINT_WAN_SLA_PROFILES = "/proxy/network/v2/api/site/{site}/profiles/wansla"
150+
API_ENDPOINT_WAN_SLA_PROFILE_DETAIL = "/proxy/network/v2/api/site/{site}/profiles/wansla/{profile_id}"
140151

141152
# API Paths used for aiounifi API Requests
142153
API_PATH_FIREWALL_POLICIES = "/firewall-policies"
@@ -161,6 +172,17 @@
161172
API_PATH_QOS_RULES_BATCH_DELETE = "/qos-rules/batch-delete"
162173
API_PATH_NETWORK_CONF = "/rest/networkconf"
163174
API_PATH_NETWORK_CONF_DETAIL = "/rest/networkconf/{network_id}"
175+
# Objects and Profiles (aiounifi path fragments)
176+
API_PATH_PORT_PROFILES = "/rest/portconf"
177+
API_PATH_PORT_PROFILE_DETAIL = "/rest/portconf/{profile_id}"
178+
API_PATH_WLAN_RATE_PROFILES = "/profiles/wlanrate"
179+
API_PATH_WLAN_RATE_PROFILE_DETAIL = "/profiles/wlanrate/{profile_id}"
180+
API_PATH_RADIUS_PROFILES = "/rest/radiusprofile"
181+
API_PATH_RADIUS_PROFILE_DETAIL = "/rest/radiusprofile/{profile_id}"
182+
API_PATH_WAN_SLA_PROFILES = "/profiles/wansla"
183+
API_PATH_WAN_SLA_PROFILE_DETAIL = "/profiles/wansla/{profile_id}"
184+
API_PATH_FIREWALL_GROUPS = "/rest/firewallgroup"
185+
API_PATH_FIREWALL_GROUP_DETAIL = "/rest/firewallgroup/{group_id}"
164186

165187
# Detection endpoints
166188
API_ENDPOINT_SDN_STATUS = "/proxy/network/api/s/{site}/stat/sdn"

custom_components/unifi_network_rules/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
"quality_scale": "custom",
1313
"requirements": [
1414
"aiohttp",
15-
"aiounifi>=82.0.0",
15+
"aiounifi>=84.0.0",
1616
"orjson>=3.8.0"
1717
],
18-
"version": "3.2.0"
18+
"version": "3.3.0"
1919
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Network Object models for UniFi Network Rules integration.
2+
3+
Represents v2 Objects (addresses, address-groups, ports, etc.).
4+
"""
5+
from __future__ import annotations
6+
7+
from dataclasses import dataclass
8+
from typing import Any, Literal, Optional, TypedDict
9+
10+
from aiounifi.models.api import ApiItem
11+
12+
13+
class TypedObjectMember(TypedDict):
14+
type: Literal[
15+
"ipv4-address",
16+
"ipv6-address",
17+
"ipv4-subnet",
18+
"ipv6-subnet",
19+
"port",
20+
]
21+
value: str
22+
23+
24+
class TypedNetworkObject(TypedDict, total=False):
25+
_id: str
26+
name: str
27+
description: str
28+
type: Literal[
29+
"address",
30+
"address-group",
31+
"ipv6-address-group",
32+
"port-group",
33+
]
34+
members: list[TypedObjectMember]
35+
site_id: str
36+
37+
38+
"""Typed representation of an object-like firewall group."""
39+
40+
41+
class NetworkObject(ApiItem):
42+
raw: TypedNetworkObject
43+
44+
@property
45+
def id(self) -> str:
46+
return self.raw.get("_id", "")
47+
48+
@property
49+
def name(self) -> str:
50+
return self.raw.get("name", "")
51+
52+
@property
53+
def type(self) -> str:
54+
return self.raw.get("type", "")
55+
56+
@property
57+
def members(self) -> list[TypedObjectMember]:
58+
return self.raw.get("members", [])
59+
60+
def to_dict(self) -> dict[str, Any]:
61+
return dict(self.raw)
62+
63+

custom_components/unifi_network_rules/services.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,25 @@ force_remove_stale:
241241
websocket_diagnostics:
242242
name: WebSocket Diagnostics
243243
description: Run diagnostics on the WebSocket connection and attempt to repair if needed
244+
245+
sync_remote_curated:
246+
name: Sync Remote Curated Groups
247+
description: >-
248+
Fetch curated list file(s) from public raw URLs and create/update firewall group(s).
249+
Each file should follow the UNR Lists format (see docs at https://github.com/sirkirby/unr-lists).
250+
Header lines start with '# key: value' (name, type, description), then one entry per line.
251+
fields:
252+
entry_id:
253+
name: Entry ID
254+
description: Optional specific configuration entry ID to target
255+
required: false
256+
selector:
257+
text:
258+
urls:
259+
name: Curated list URLs
260+
description: >-
261+
One or more raw text URLs. Provide a list (YAML) or paste multiple lines here,
262+
one URL per line. Example: https://raw.githubusercontent.com/sirkirby/unr-lists/main/dns-ipv4.txt
263+
required: true
264+
selector:
265+
object:

custom_components/unifi_network_rules/services/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
SERVICE_WEBSOCKET_DIAGNOSTICS = "websocket_diagnostics"
1414
SERVICE_TOGGLE_RULE = "toggle_rule"
1515
SERVICE_REFRESH_DATA = "refresh_data"
16+
SERVICE_SYNC_REMOTE_CURATED = "sync_remote_curated"
1617

1718
# Schema fields
1819
CONF_FILENAME = "filename"
@@ -25,6 +26,12 @@
2526
CONF_STATE = "state"
2627
CONF_RULE_ID = "rule_id"
2728
CONF_RULE_TYPE = "rule_type"
29+
CONF_OBJECT_SETS = "object_sets"
30+
CONF_GITHUB_CLONE_URL = "github_clone_url"
31+
CONF_GITHUB_REF = "ref"
32+
CONF_REMOTE_FILE = "file"
33+
CONF_REMOTE_FILES = "files"
34+
CONF_REMOTE_URLS = "urls"
2835

2936
# Signal for entity cleanup
3037
SIGNAL_ENTITIES_CLEANUP = "unifi_network_rules_cleanup"

custom_components/unifi_network_rules/services/system_services.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@
55
from typing import Any, Dict, List, Optional
66

77
import voluptuous as vol
8+
import re
89

910
from homeassistant.core import HomeAssistant, ServiceCall
1011
from homeassistant.helpers import config_validation as cv
1112
from homeassistant.exceptions import HomeAssistantError
13+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1214

1315
from ..const import DOMAIN, LOGGER
1416
from .constants import (
1517
SERVICE_REFRESH,
1618
SERVICE_REFRESH_DATA,
1719
SERVICE_WEBSOCKET_DIAGNOSTICS,
20+
SERVICE_SYNC_REMOTE_CURATED,
1821
)
22+
from ..utils.remote_lists import parse_curated_text
1923

2024
# Schema for refresh service
2125
REFRESH_DATA_SCHEMA = vol.Schema(
@@ -211,4 +215,76 @@ async def handle_websocket_diagnostics(call: ServiceCall) -> None:
211215

212216
hass.services.async_register(
213217
DOMAIN, SERVICE_WEBSOCKET_DIAGNOSTICS, handle_websocket_diagnostics, schema=vol.Schema({})
214-
)
218+
)
219+
220+
# Remote curated file sync
221+
async def handle_sync_remote_curated(call: ServiceCall) -> None:
222+
entry_id = call.data.get("entry_id")
223+
urls_input = call.data.get("urls")
224+
url_list: list[str] = []
225+
if isinstance(urls_input, list):
226+
# Flatten any strings that might contain multiple URLs
227+
for item in urls_input:
228+
if isinstance(item, str):
229+
found = re.findall(r"https?://\S+", item)
230+
url_list.extend(found if found else [item])
231+
elif isinstance(urls_input, str):
232+
# Extract all http(s) URLs from the string (handles spaces, newlines)
233+
url_list = re.findall(r"https?://\S+", urls_input)
234+
if not url_list:
235+
raise HomeAssistantError("'urls' must contain at least one valid http(s) URL")
236+
237+
targets = (
238+
{entry_id: coordinators.get(entry_id)} if entry_id and entry_id in coordinators else coordinators
239+
)
240+
for _entry, coord in targets.items():
241+
if not coord:
242+
continue
243+
api = getattr(coord, "api", None) or getattr(coord, "_api", None)
244+
if not api:
245+
continue
246+
for raw_url in url_list:
247+
try:
248+
session = async_get_clientsession(hass)
249+
async with session.get(raw_url) as resp:
250+
if resp.status != 200:
251+
raise HomeAssistantError(f"Failed to fetch remote list '{raw_url}': {resp.status} {await resp.text()}")
252+
content = await resp.text()
253+
254+
payload = parse_curated_text(content)
255+
# Enforce type-specific members
256+
obj_type = payload.get("type", "address-group")
257+
if obj_type == "port-group":
258+
filtered = [m for m in payload.get("members", []) if m.get("type") == "port"]
259+
elif obj_type == "ipv6-address-group":
260+
filtered = [m for m in payload.get("members", []) if m.get("type", "").startswith("ipv6")]
261+
else:
262+
filtered = [m for m in payload.get("members", []) if m.get("type", "").startswith("ipv4")]
263+
payload = {**payload, "members": filtered}
264+
265+
existing = await api.get_objects()
266+
existing_by_name = {o.name: o for o in existing}
267+
name = payload.get("name")
268+
if name in existing_by_name:
269+
obj = existing_by_name[name]
270+
to_update = obj.to_dict()
271+
to_update.update({
272+
"description": payload.get("description", to_update.get("description")),
273+
"type": obj_type,
274+
"members": payload.get("members", to_update.get("members", [])),
275+
})
276+
await api.update_object(to_update)
277+
else:
278+
await api.add_object(payload)
279+
except Exception as err:
280+
LOGGER.error("Remote curated sync failed for '%s': %s", raw_url, err)
281+
282+
hass.services.async_register(
283+
DOMAIN,
284+
SERVICE_SYNC_REMOTE_CURATED,
285+
handle_sync_remote_curated,
286+
schema=vol.Schema({
287+
vol.Optional("entry_id"): cv.string,
288+
vol.Required("urls"): vol.Any(cv.ensure_list(cv.string), cv.string),
289+
}),
290+
)

custom_components/unifi_network_rules/udm/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@
1616
from .network import NetworkMixin
1717
from .qos import QoSMixin
1818
from .vpn import VPNMixin
19+
from .objects import ObjectsMixin
20+
from .profiles import PortProfilesMixin, WlanRateProfilesMixin, RadiusProfilesMixin, WanSlaProfilesMixin
1921

2022
from ..const import LOGGER
2123
from ..queue import ApiOperationQueue
2224

2325
class UDMAPI(
26+
ObjectsMixin,
27+
PortProfilesMixin,
28+
WlanRateProfilesMixin,
29+
RadiusProfilesMixin,
30+
WanSlaProfilesMixin,
2431
NetworkMixin,
2532
RoutesMixin,
2633
PortForwardMixin,

0 commit comments

Comments
 (0)