ui-cli 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ui_cli/__init__.py +31 -0
- ui_cli/client.py +269 -0
- ui_cli/commands/__init__.py +1 -0
- ui_cli/commands/devices.py +187 -0
- ui_cli/commands/groups.py +503 -0
- ui_cli/commands/hosts.py +114 -0
- ui_cli/commands/isp.py +100 -0
- ui_cli/commands/local/__init__.py +63 -0
- ui_cli/commands/local/apgroups.py +445 -0
- ui_cli/commands/local/clients.py +1537 -0
- ui_cli/commands/local/config.py +758 -0
- ui_cli/commands/local/devices.py +570 -0
- ui_cli/commands/local/dpi.py +369 -0
- ui_cli/commands/local/events.py +289 -0
- ui_cli/commands/local/firewall.py +285 -0
- ui_cli/commands/local/health.py +195 -0
- ui_cli/commands/local/networks.py +426 -0
- ui_cli/commands/local/portfwd.py +153 -0
- ui_cli/commands/local/stats.py +234 -0
- ui_cli/commands/local/utils.py +85 -0
- ui_cli/commands/local/vouchers.py +410 -0
- ui_cli/commands/local/wan.py +302 -0
- ui_cli/commands/local/wlans.py +257 -0
- ui_cli/commands/mcp.py +416 -0
- ui_cli/commands/sdwan.py +168 -0
- ui_cli/commands/sites.py +65 -0
- ui_cli/commands/speedtest.py +192 -0
- ui_cli/commands/status.py +410 -0
- ui_cli/commands/version.py +13 -0
- ui_cli/config.py +106 -0
- ui_cli/groups.py +567 -0
- ui_cli/local_client.py +897 -0
- ui_cli/main.py +61 -0
- ui_cli/models.py +188 -0
- ui_cli/output.py +251 -0
- ui_cli-1.2.1.dist-info/METADATA +1315 -0
- ui_cli-1.2.1.dist-info/RECORD +46 -0
- ui_cli-1.2.1.dist-info/WHEEL +4 -0
- ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
- ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
- ui_mcp/ARCHITECTURE.md +243 -0
- ui_mcp/README.md +235 -0
- ui_mcp/__init__.py +7 -0
- ui_mcp/__main__.py +10 -0
- ui_mcp/cli_runner.py +112 -0
- ui_mcp/server.py +468 -0
ui_cli/groups.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""Client groups management for ui-cli.
|
|
2
|
+
|
|
3
|
+
Groups allow users to organize client devices and perform bulk actions.
|
|
4
|
+
Supports both static (manual membership) and auto (rule-based) groups.
|
|
5
|
+
|
|
6
|
+
Storage: ~/.config/ui-cli/groups.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Literal
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import fnmatch
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GroupMember(BaseModel):
|
|
19
|
+
"""A member of a static group."""
|
|
20
|
+
|
|
21
|
+
mac: str
|
|
22
|
+
alias: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AutoGroupRules(BaseModel):
|
|
26
|
+
"""Rules for auto group membership evaluation."""
|
|
27
|
+
|
|
28
|
+
vendor: list[str] | None = None # OUI/manufacturer patterns
|
|
29
|
+
name: list[str] | None = None # Client name patterns
|
|
30
|
+
hostname: list[str] | None = None # Hostname patterns
|
|
31
|
+
network: list[str] | None = None # Network/SSID patterns
|
|
32
|
+
ip: list[str] | None = None # IP address patterns/ranges
|
|
33
|
+
mac: list[str] | None = None # MAC prefix patterns
|
|
34
|
+
conn_type: list[str] | None = None # "wired" or "wireless"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Group(BaseModel):
|
|
38
|
+
"""A client group definition."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
description: str | None = None
|
|
42
|
+
type: Literal["static", "auto"] = "static"
|
|
43
|
+
members: list[GroupMember] | None = None # For static groups
|
|
44
|
+
rules: AutoGroupRules | None = None # For auto groups
|
|
45
|
+
created_at: datetime
|
|
46
|
+
updated_at: datetime
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GroupsFile(BaseModel):
|
|
50
|
+
"""Root structure for groups.json file."""
|
|
51
|
+
|
|
52
|
+
version: int = 1
|
|
53
|
+
groups: dict[str, Group] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GroupManager:
|
|
57
|
+
"""Manages client groups stored in ~/.config/ui-cli/groups.json"""
|
|
58
|
+
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self._path = Path.home() / ".config" / "ui-cli" / "groups.json"
|
|
61
|
+
self._data: GroupsFile | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def data(self) -> GroupsFile:
|
|
65
|
+
"""Lazy-load groups data."""
|
|
66
|
+
if self._data is None:
|
|
67
|
+
self._load()
|
|
68
|
+
return self._data
|
|
69
|
+
|
|
70
|
+
def _load(self) -> None:
|
|
71
|
+
"""Load groups from disk."""
|
|
72
|
+
if self._path.exists():
|
|
73
|
+
try:
|
|
74
|
+
raw = json.loads(self._path.read_text())
|
|
75
|
+
self._data = GroupsFile(**raw)
|
|
76
|
+
except (json.JSONDecodeError, ValueError):
|
|
77
|
+
# Corrupted file, start fresh
|
|
78
|
+
self._data = GroupsFile()
|
|
79
|
+
else:
|
|
80
|
+
self._data = GroupsFile()
|
|
81
|
+
|
|
82
|
+
def _save(self) -> None:
|
|
83
|
+
"""Save groups to disk."""
|
|
84
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
self._path.write_text(
|
|
86
|
+
json.dumps(self.data.model_dump(), indent=2, default=str)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def slugify(name: str) -> str:
|
|
91
|
+
"""Convert display name to slug.
|
|
92
|
+
|
|
93
|
+
Example: 'Kids Devices' -> 'kids-devices'
|
|
94
|
+
"""
|
|
95
|
+
slug = name.lower().strip()
|
|
96
|
+
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
|
97
|
+
return slug.strip("-")
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def normalize_mac(mac: str) -> str:
|
|
101
|
+
"""Normalize MAC address to uppercase with colons.
|
|
102
|
+
|
|
103
|
+
Handles formats:
|
|
104
|
+
- AA:BB:CC:DD:EE:FF
|
|
105
|
+
- AA-BB-CC-DD-EE-FF
|
|
106
|
+
- AABBCCDDEEFF
|
|
107
|
+
- aa:bb:cc:dd:ee:ff
|
|
108
|
+
"""
|
|
109
|
+
mac = mac.upper().replace("-", ":").replace(".", ":")
|
|
110
|
+
# Handle formats like AABBCCDDEEFF
|
|
111
|
+
if ":" not in mac and len(mac) == 12:
|
|
112
|
+
mac = ":".join(mac[i : i + 2] for i in range(0, 12, 2))
|
|
113
|
+
return mac
|
|
114
|
+
|
|
115
|
+
def _resolve_group(self, name_or_slug: str) -> str | None:
|
|
116
|
+
"""Resolve name or slug to slug, return None if not found."""
|
|
117
|
+
slug = self.slugify(name_or_slug)
|
|
118
|
+
if slug in self.data.groups:
|
|
119
|
+
return slug
|
|
120
|
+
# Try exact name match
|
|
121
|
+
for s, g in self.data.groups.items():
|
|
122
|
+
if g.name.lower() == name_or_slug.lower():
|
|
123
|
+
return s
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
# -------------------------------------------------------------------------
|
|
127
|
+
# Group CRUD
|
|
128
|
+
# -------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def list_groups(self) -> list[tuple[str, Group]]:
|
|
131
|
+
"""List all groups as (slug, Group) tuples."""
|
|
132
|
+
return list(self.data.groups.items())
|
|
133
|
+
|
|
134
|
+
def get_group(self, name_or_slug: str) -> tuple[str, Group] | None:
|
|
135
|
+
"""Get group by name or slug.
|
|
136
|
+
|
|
137
|
+
Returns (slug, Group) or None if not found.
|
|
138
|
+
"""
|
|
139
|
+
slug = self._resolve_group(name_or_slug)
|
|
140
|
+
if slug:
|
|
141
|
+
return (slug, self.data.groups[slug])
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def create_group(
|
|
145
|
+
self,
|
|
146
|
+
name: str,
|
|
147
|
+
description: str | None = None,
|
|
148
|
+
group_type: Literal["static", "auto"] = "static",
|
|
149
|
+
rules: AutoGroupRules | None = None,
|
|
150
|
+
) -> tuple[str, Group]:
|
|
151
|
+
"""Create a new group.
|
|
152
|
+
|
|
153
|
+
Returns (slug, Group).
|
|
154
|
+
Raises ValueError if group already exists.
|
|
155
|
+
"""
|
|
156
|
+
slug = self.slugify(name)
|
|
157
|
+
if slug in self.data.groups:
|
|
158
|
+
raise ValueError(f"Group '{name}' already exists")
|
|
159
|
+
|
|
160
|
+
now = datetime.now(timezone.utc)
|
|
161
|
+
group = Group(
|
|
162
|
+
name=name,
|
|
163
|
+
description=description,
|
|
164
|
+
type=group_type,
|
|
165
|
+
members=[] if group_type == "static" else None,
|
|
166
|
+
rules=rules if group_type == "auto" else None,
|
|
167
|
+
created_at=now,
|
|
168
|
+
updated_at=now,
|
|
169
|
+
)
|
|
170
|
+
self.data.groups[slug] = group
|
|
171
|
+
self._save()
|
|
172
|
+
return (slug, group)
|
|
173
|
+
|
|
174
|
+
def delete_group(self, name_or_slug: str) -> bool:
|
|
175
|
+
"""Delete a group.
|
|
176
|
+
|
|
177
|
+
Returns True if deleted, False if not found.
|
|
178
|
+
"""
|
|
179
|
+
slug = self._resolve_group(name_or_slug)
|
|
180
|
+
if not slug:
|
|
181
|
+
return False
|
|
182
|
+
del self.data.groups[slug]
|
|
183
|
+
self._save()
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def update_group(
|
|
187
|
+
self,
|
|
188
|
+
name_or_slug: str,
|
|
189
|
+
new_name: str | None = None,
|
|
190
|
+
description: str | None = ..., # type: ignore # Use ... to distinguish from None
|
|
191
|
+
) -> tuple[str, Group]:
|
|
192
|
+
"""Update group name and/or description.
|
|
193
|
+
|
|
194
|
+
Returns (slug, Group).
|
|
195
|
+
Raises ValueError if group not found.
|
|
196
|
+
"""
|
|
197
|
+
slug = self._resolve_group(name_or_slug)
|
|
198
|
+
if not slug:
|
|
199
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
200
|
+
|
|
201
|
+
group = self.data.groups[slug]
|
|
202
|
+
|
|
203
|
+
# Handle rename (may change slug)
|
|
204
|
+
if new_name:
|
|
205
|
+
group.name = new_name
|
|
206
|
+
new_slug = self.slugify(new_name)
|
|
207
|
+
if new_slug != slug:
|
|
208
|
+
self.data.groups[new_slug] = group
|
|
209
|
+
del self.data.groups[slug]
|
|
210
|
+
slug = new_slug
|
|
211
|
+
|
|
212
|
+
# Handle description update (... means not provided)
|
|
213
|
+
if description is not ...:
|
|
214
|
+
group.description = description
|
|
215
|
+
|
|
216
|
+
group.updated_at = datetime.now(timezone.utc)
|
|
217
|
+
self._save()
|
|
218
|
+
return (slug, group)
|
|
219
|
+
|
|
220
|
+
# -------------------------------------------------------------------------
|
|
221
|
+
# Member CRUD Operations (Static Groups)
|
|
222
|
+
# -------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def add_member(
|
|
225
|
+
self,
|
|
226
|
+
name_or_slug: str,
|
|
227
|
+
mac: str,
|
|
228
|
+
alias: str | None = None,
|
|
229
|
+
) -> Group:
|
|
230
|
+
"""Add a member to a static group.
|
|
231
|
+
|
|
232
|
+
If member already exists, updates the alias if provided.
|
|
233
|
+
Raises ValueError if group not found or is auto group.
|
|
234
|
+
"""
|
|
235
|
+
slug = self._resolve_group(name_or_slug)
|
|
236
|
+
if not slug:
|
|
237
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
238
|
+
|
|
239
|
+
group = self.data.groups[slug]
|
|
240
|
+
if group.type != "static":
|
|
241
|
+
raise ValueError("Cannot add members to auto groups")
|
|
242
|
+
|
|
243
|
+
mac = self.normalize_mac(mac)
|
|
244
|
+
|
|
245
|
+
# Check if already exists - update alias if so
|
|
246
|
+
if group.members:
|
|
247
|
+
for member in group.members:
|
|
248
|
+
if member.mac == mac:
|
|
249
|
+
if alias:
|
|
250
|
+
member.alias = alias
|
|
251
|
+
group.updated_at = datetime.now(timezone.utc)
|
|
252
|
+
self._save()
|
|
253
|
+
return group
|
|
254
|
+
|
|
255
|
+
# Add new member
|
|
256
|
+
if group.members is None:
|
|
257
|
+
group.members = []
|
|
258
|
+
group.members.append(GroupMember(mac=mac, alias=alias))
|
|
259
|
+
group.updated_at = datetime.now(timezone.utc)
|
|
260
|
+
self._save()
|
|
261
|
+
return group
|
|
262
|
+
|
|
263
|
+
def get_member(self, name_or_slug: str, identifier: str) -> GroupMember | None:
|
|
264
|
+
"""Get a member by MAC or alias.
|
|
265
|
+
|
|
266
|
+
Returns GroupMember or None if not found.
|
|
267
|
+
"""
|
|
268
|
+
slug = self._resolve_group(name_or_slug)
|
|
269
|
+
if not slug:
|
|
270
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
271
|
+
|
|
272
|
+
group = self.data.groups[slug]
|
|
273
|
+
if group.type != "static" or not group.members:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
identifier_mac = self.normalize_mac(identifier)
|
|
277
|
+
for member in group.members:
|
|
278
|
+
if member.mac == identifier_mac or member.alias == identifier:
|
|
279
|
+
return member
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def update_member(
|
|
283
|
+
self,
|
|
284
|
+
name_or_slug: str,
|
|
285
|
+
identifier: str,
|
|
286
|
+
alias: str | None = ..., # type: ignore
|
|
287
|
+
) -> bool:
|
|
288
|
+
"""Update a member's alias.
|
|
289
|
+
|
|
290
|
+
Pass alias=None to clear the alias.
|
|
291
|
+
Returns True if updated, False if member not found.
|
|
292
|
+
"""
|
|
293
|
+
slug = self._resolve_group(name_or_slug)
|
|
294
|
+
if not slug:
|
|
295
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
296
|
+
|
|
297
|
+
group = self.data.groups[slug]
|
|
298
|
+
if group.type != "static" or not group.members:
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
identifier_mac = self.normalize_mac(identifier)
|
|
302
|
+
for member in group.members:
|
|
303
|
+
if member.mac == identifier_mac or member.alias == identifier:
|
|
304
|
+
if alias is not ...:
|
|
305
|
+
member.alias = alias
|
|
306
|
+
group.updated_at = datetime.now(timezone.utc)
|
|
307
|
+
self._save()
|
|
308
|
+
return True
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
def remove_member(self, name_or_slug: str, identifier: str) -> bool:
|
|
312
|
+
"""Remove a member by MAC or alias.
|
|
313
|
+
|
|
314
|
+
Returns True if removed, False if not found.
|
|
315
|
+
"""
|
|
316
|
+
slug = self._resolve_group(name_or_slug)
|
|
317
|
+
if not slug:
|
|
318
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
319
|
+
|
|
320
|
+
group = self.data.groups[slug]
|
|
321
|
+
if group.type != "static" or not group.members:
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
identifier_mac = self.normalize_mac(identifier)
|
|
325
|
+
for i, member in enumerate(group.members):
|
|
326
|
+
if member.mac == identifier_mac or member.alias == identifier:
|
|
327
|
+
group.members.pop(i)
|
|
328
|
+
group.updated_at = datetime.now(timezone.utc)
|
|
329
|
+
self._save()
|
|
330
|
+
return True
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
def list_members(self, name_or_slug: str) -> list[GroupMember]:
|
|
334
|
+
"""List all members in a static group.
|
|
335
|
+
|
|
336
|
+
Raises ValueError if group not found or is auto group.
|
|
337
|
+
"""
|
|
338
|
+
slug = self._resolve_group(name_or_slug)
|
|
339
|
+
if not slug:
|
|
340
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
341
|
+
|
|
342
|
+
group = self.data.groups[slug]
|
|
343
|
+
if group.type != "static":
|
|
344
|
+
raise ValueError("Auto groups have dynamic membership")
|
|
345
|
+
return group.members or []
|
|
346
|
+
|
|
347
|
+
def clear_members(self, name_or_slug: str) -> bool:
|
|
348
|
+
"""Clear all members from a static group.
|
|
349
|
+
|
|
350
|
+
Raises ValueError if group not found or is auto group.
|
|
351
|
+
"""
|
|
352
|
+
slug = self._resolve_group(name_or_slug)
|
|
353
|
+
if not slug:
|
|
354
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
355
|
+
|
|
356
|
+
group = self.data.groups[slug]
|
|
357
|
+
if group.type != "static":
|
|
358
|
+
raise ValueError("Cannot clear members from auto groups")
|
|
359
|
+
|
|
360
|
+
group.members = []
|
|
361
|
+
group.updated_at = datetime.now(timezone.utc)
|
|
362
|
+
self._save()
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
def get_member_macs(self, name_or_slug: str) -> list[str]:
|
|
366
|
+
"""Get list of MAC addresses in a static group."""
|
|
367
|
+
result = self.get_group(name_or_slug)
|
|
368
|
+
if not result:
|
|
369
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
370
|
+
_, group = result
|
|
371
|
+
if group.type != "static" or not group.members:
|
|
372
|
+
return []
|
|
373
|
+
return [m.mac for m in group.members]
|
|
374
|
+
|
|
375
|
+
# -------------------------------------------------------------------------
|
|
376
|
+
# Auto Group Operations
|
|
377
|
+
# -------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def set_rules(self, name_or_slug: str, rules: AutoGroupRules) -> Group:
|
|
380
|
+
"""Set rules for an auto group.
|
|
381
|
+
|
|
382
|
+
Raises ValueError if group not found or is static group.
|
|
383
|
+
"""
|
|
384
|
+
slug = self._resolve_group(name_or_slug)
|
|
385
|
+
if not slug:
|
|
386
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
387
|
+
|
|
388
|
+
group = self.data.groups[slug]
|
|
389
|
+
if group.type != "auto":
|
|
390
|
+
raise ValueError("Cannot set rules on static groups")
|
|
391
|
+
|
|
392
|
+
group.rules = rules
|
|
393
|
+
group.updated_at = datetime.now(timezone.utc)
|
|
394
|
+
self._save()
|
|
395
|
+
return group
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def pattern_matches(pattern: str, value: str | None) -> bool:
|
|
399
|
+
"""Check if value matches pattern.
|
|
400
|
+
|
|
401
|
+
Pattern syntax:
|
|
402
|
+
- Exact: "Apple" - case-insensitive exact match
|
|
403
|
+
- Wildcard: "*phone*" - uses fnmatch
|
|
404
|
+
- Regex: "~^iPhone-[0-9]+" - prefix with ~ for regex
|
|
405
|
+
- Multiple: "Apple,Samsung" - comma-separated, OR logic
|
|
406
|
+
"""
|
|
407
|
+
if not pattern or not value:
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
# Handle multiple patterns (OR logic)
|
|
411
|
+
if "," in pattern and not pattern.startswith("~"):
|
|
412
|
+
patterns = [p.strip() for p in pattern.split(",")]
|
|
413
|
+
return any(GroupManager.pattern_matches(p, value) for p in patterns)
|
|
414
|
+
|
|
415
|
+
# Regex pattern (prefix with ~)
|
|
416
|
+
if pattern.startswith("~"):
|
|
417
|
+
try:
|
|
418
|
+
return bool(re.search(pattern[1:], value, re.IGNORECASE))
|
|
419
|
+
except re.error:
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
# Wildcard pattern
|
|
423
|
+
if "*" in pattern or "?" in pattern:
|
|
424
|
+
return fnmatch.fnmatch(value.lower(), pattern.lower())
|
|
425
|
+
|
|
426
|
+
# Exact match (case-insensitive)
|
|
427
|
+
return value.lower() == pattern.lower()
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def ip_matches(pattern: str, ip: str | None) -> bool:
|
|
431
|
+
"""Check if IP matches pattern.
|
|
432
|
+
|
|
433
|
+
Supports:
|
|
434
|
+
- CIDR: 192.168.1.0/24
|
|
435
|
+
- Range: 192.168.1.100-200 or 192.168.1.100-192.168.1.200
|
|
436
|
+
- Wildcard: 192.168.1.*
|
|
437
|
+
"""
|
|
438
|
+
if not pattern or not ip:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
import ipaddress
|
|
442
|
+
|
|
443
|
+
# CIDR notation: 192.168.1.0/24
|
|
444
|
+
if "/" in pattern:
|
|
445
|
+
try:
|
|
446
|
+
network = ipaddress.ip_network(pattern, strict=False)
|
|
447
|
+
return ipaddress.ip_address(ip) in network
|
|
448
|
+
except ValueError:
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
# Range: 192.168.1.100-200
|
|
452
|
+
if "-" in pattern and not pattern.startswith("-"):
|
|
453
|
+
try:
|
|
454
|
+
base, end = pattern.rsplit("-", 1)
|
|
455
|
+
if "." in end:
|
|
456
|
+
# Full IP range: 192.168.1.100-192.168.1.200
|
|
457
|
+
start_ip = ipaddress.ip_address(base)
|
|
458
|
+
end_ip = ipaddress.ip_address(end)
|
|
459
|
+
else:
|
|
460
|
+
# Partial range: 192.168.1.100-200
|
|
461
|
+
start_ip = ipaddress.ip_address(base)
|
|
462
|
+
base_parts = base.rsplit(".", 1)
|
|
463
|
+
end_ip = ipaddress.ip_address(f"{base_parts[0]}.{end}")
|
|
464
|
+
target = ipaddress.ip_address(ip)
|
|
465
|
+
return start_ip <= target <= end_ip
|
|
466
|
+
except ValueError:
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
# Wildcard: 192.168.1.*
|
|
470
|
+
return GroupManager.pattern_matches(pattern, ip)
|
|
471
|
+
|
|
472
|
+
def evaluate_auto_group(
|
|
473
|
+
self,
|
|
474
|
+
name_or_slug: str,
|
|
475
|
+
clients: list[dict],
|
|
476
|
+
) -> list[dict]:
|
|
477
|
+
"""Evaluate auto group rules against client list.
|
|
478
|
+
|
|
479
|
+
Returns list of matching clients.
|
|
480
|
+
Raises ValueError if group not found.
|
|
481
|
+
"""
|
|
482
|
+
result = self.get_group(name_or_slug)
|
|
483
|
+
if not result:
|
|
484
|
+
raise ValueError(f"Group '{name_or_slug}' not found")
|
|
485
|
+
|
|
486
|
+
_, group = result
|
|
487
|
+
if group.type != "auto" or not group.rules:
|
|
488
|
+
return []
|
|
489
|
+
|
|
490
|
+
matching = []
|
|
491
|
+
for client in clients:
|
|
492
|
+
if self._client_matches_rules(client, group.rules):
|
|
493
|
+
matching.append(client)
|
|
494
|
+
return matching
|
|
495
|
+
|
|
496
|
+
def _client_matches_rules(self, client: dict, rules: AutoGroupRules) -> bool:
|
|
497
|
+
"""Check if client matches all rules (AND logic between rule types)."""
|
|
498
|
+
# Vendor (OUI)
|
|
499
|
+
if rules.vendor:
|
|
500
|
+
oui = client.get("oui", "")
|
|
501
|
+
if not any(self.pattern_matches(p, oui) for p in rules.vendor):
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
# Client name
|
|
505
|
+
if rules.name:
|
|
506
|
+
name = client.get("name") or client.get("hostname") or ""
|
|
507
|
+
if not any(self.pattern_matches(p, name) for p in rules.name):
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
# Hostname
|
|
511
|
+
if rules.hostname:
|
|
512
|
+
hostname = client.get("hostname", "")
|
|
513
|
+
if not any(self.pattern_matches(p, hostname) for p in rules.hostname):
|
|
514
|
+
return False
|
|
515
|
+
|
|
516
|
+
# Network/SSID
|
|
517
|
+
if rules.network:
|
|
518
|
+
network = client.get("essid") or client.get("network", "")
|
|
519
|
+
if not any(self.pattern_matches(p, network) for p in rules.network):
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
# IP address
|
|
523
|
+
if rules.ip:
|
|
524
|
+
ip = client.get("ip", "")
|
|
525
|
+
if not any(self.ip_matches(p, ip) for p in rules.ip):
|
|
526
|
+
return False
|
|
527
|
+
|
|
528
|
+
# MAC prefix
|
|
529
|
+
if rules.mac:
|
|
530
|
+
mac = client.get("mac", "")
|
|
531
|
+
if not any(mac.upper().startswith(p.upper().replace("-", ":")) for p in rules.mac):
|
|
532
|
+
return False
|
|
533
|
+
|
|
534
|
+
# Connection type
|
|
535
|
+
if rules.conn_type:
|
|
536
|
+
is_wired = client.get("is_wired", False)
|
|
537
|
+
client_type = "wired" if is_wired else "wireless"
|
|
538
|
+
if client_type not in [t.lower() for t in rules.conn_type]:
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
# -------------------------------------------------------------------------
|
|
544
|
+
# Import/Export
|
|
545
|
+
# -------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
def export_groups(self) -> dict:
|
|
548
|
+
"""Export all groups as dict."""
|
|
549
|
+
return self.data.model_dump()
|
|
550
|
+
|
|
551
|
+
def import_groups(self, data: dict, replace: bool = False) -> int:
|
|
552
|
+
"""Import groups from dict.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
data: Groups data to import
|
|
556
|
+
replace: If True, replace all existing groups. If False, merge.
|
|
557
|
+
|
|
558
|
+
Returns count of imported groups.
|
|
559
|
+
"""
|
|
560
|
+
imported = GroupsFile(**data)
|
|
561
|
+
if replace:
|
|
562
|
+
self._data = imported
|
|
563
|
+
else:
|
|
564
|
+
for slug, group in imported.groups.items():
|
|
565
|
+
self.data.groups[slug] = group
|
|
566
|
+
self._save()
|
|
567
|
+
return len(imported.groups)
|