iflow-mcp_enuno-unifi-mcp-server 0.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.
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
- src/__init__.py +3 -0
- src/__main__.py +6 -0
- src/api/__init__.py +5 -0
- src/api/client.py +727 -0
- src/api/site_manager_client.py +176 -0
- src/cache.py +483 -0
- src/config/__init__.py +5 -0
- src/config/config.py +321 -0
- src/main.py +2234 -0
- src/models/__init__.py +126 -0
- src/models/acl.py +41 -0
- src/models/backup.py +272 -0
- src/models/client.py +74 -0
- src/models/device.py +53 -0
- src/models/dpi.py +50 -0
- src/models/firewall_policy.py +123 -0
- src/models/firewall_zone.py +28 -0
- src/models/network.py +62 -0
- src/models/qos_profile.py +458 -0
- src/models/radius.py +141 -0
- src/models/reference_data.py +34 -0
- src/models/site.py +59 -0
- src/models/site_manager.py +120 -0
- src/models/topology.py +138 -0
- src/models/traffic_flow.py +137 -0
- src/models/traffic_matching_list.py +56 -0
- src/models/voucher.py +42 -0
- src/models/vpn.py +73 -0
- src/models/wan.py +48 -0
- src/models/zbf_matrix.py +49 -0
- src/resources/__init__.py +8 -0
- src/resources/clients.py +111 -0
- src/resources/devices.py +102 -0
- src/resources/networks.py +93 -0
- src/resources/site_manager.py +64 -0
- src/resources/sites.py +86 -0
- src/tools/__init__.py +25 -0
- src/tools/acls.py +328 -0
- src/tools/application.py +42 -0
- src/tools/backups.py +1173 -0
- src/tools/client_management.py +505 -0
- src/tools/clients.py +203 -0
- src/tools/device_control.py +325 -0
- src/tools/devices.py +354 -0
- src/tools/dpi.py +241 -0
- src/tools/dpi_tools.py +89 -0
- src/tools/firewall.py +417 -0
- src/tools/firewall_policies.py +430 -0
- src/tools/firewall_zones.py +515 -0
- src/tools/network_config.py +388 -0
- src/tools/networks.py +190 -0
- src/tools/port_forwarding.py +263 -0
- src/tools/qos.py +1070 -0
- src/tools/radius.py +763 -0
- src/tools/reference_data.py +107 -0
- src/tools/site_manager.py +466 -0
- src/tools/site_vpn.py +95 -0
- src/tools/sites.py +187 -0
- src/tools/topology.py +406 -0
- src/tools/traffic_flows.py +1062 -0
- src/tools/traffic_matching_lists.py +371 -0
- src/tools/vouchers.py +249 -0
- src/tools/vpn.py +76 -0
- src/tools/wans.py +30 -0
- src/tools/wifi.py +498 -0
- src/tools/zbf_matrix.py +326 -0
- src/utils/__init__.py +88 -0
- src/utils/audit.py +213 -0
- src/utils/exceptions.py +114 -0
- src/utils/helpers.py +159 -0
- src/utils/logger.py +105 -0
- src/utils/sanitize.py +244 -0
- src/utils/validators.py +160 -0
- src/webhooks/__init__.py +6 -0
- src/webhooks/handlers.py +196 -0
- src/webhooks/receiver.py +290 -0
src/utils/validators.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Input validation functions for UniFi MCP Server."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .exceptions import ValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_mac_address(mac: str) -> str:
|
|
9
|
+
"""Validate and normalize MAC address.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
mac: MAC address string
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Normalized MAC address (lowercase, colon-separated)
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
ValidationError: If MAC address is invalid
|
|
19
|
+
"""
|
|
20
|
+
# Remove common separators
|
|
21
|
+
cleaned = re.sub(r"[:\-\.]", "", mac.lower())
|
|
22
|
+
|
|
23
|
+
# Check if valid hex and correct length
|
|
24
|
+
if not re.match(r"^[0-9a-f]{12}$", cleaned):
|
|
25
|
+
raise ValidationError(f"Invalid MAC address format: {mac}")
|
|
26
|
+
|
|
27
|
+
# Format as colon-separated
|
|
28
|
+
return ":".join([cleaned[i : i + 2] for i in range(0, 12, 2)])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_ip_address(ip: str) -> str:
|
|
32
|
+
"""Validate IPv4 address.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
ip: IP address string
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Validated IP address
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValidationError: If IP address is invalid
|
|
42
|
+
"""
|
|
43
|
+
parts = ip.split(".")
|
|
44
|
+
if len(parts) != 4:
|
|
45
|
+
raise ValidationError(f"Invalid IP address format: {ip}")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
for part in parts:
|
|
49
|
+
num = int(part)
|
|
50
|
+
if num < 0 or num > 255:
|
|
51
|
+
raise ValidationError(f"Invalid IP address octet: {part}")
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
raise ValidationError(f"Invalid IP address format: {ip}") from e
|
|
54
|
+
|
|
55
|
+
return ip
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_port(port: int) -> int:
|
|
59
|
+
"""Validate port number.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
port: Port number
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Validated port number
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValidationError: If port is invalid
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(port, int) or port < 1 or port > 65535:
|
|
71
|
+
raise ValidationError(f"Invalid port number: {port}")
|
|
72
|
+
|
|
73
|
+
return port
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_site_id(site_id: str) -> str:
|
|
77
|
+
"""Validate site ID format.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
site_id: Site identifier
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Validated site ID
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValidationError: If site ID is invalid
|
|
87
|
+
"""
|
|
88
|
+
if not site_id or not isinstance(site_id, str):
|
|
89
|
+
raise ValidationError("Site ID cannot be empty")
|
|
90
|
+
|
|
91
|
+
# Site IDs should be alphanumeric with hyphens/underscores
|
|
92
|
+
if not re.match(r"^[a-zA-Z0-9_\-]+$", site_id):
|
|
93
|
+
raise ValidationError(f"Invalid site ID format: {site_id}")
|
|
94
|
+
|
|
95
|
+
return site_id
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_device_id(device_id: str) -> str:
|
|
99
|
+
"""Validate device ID format.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
device_id: Device identifier
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Validated device ID
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ValidationError: If device ID is invalid
|
|
109
|
+
"""
|
|
110
|
+
if not device_id or not isinstance(device_id, str):
|
|
111
|
+
raise ValidationError("Device ID cannot be empty")
|
|
112
|
+
|
|
113
|
+
# Device IDs are typically 24-character hex strings (MongoDB ObjectId)
|
|
114
|
+
if not re.match(r"^[a-f0-9]{24}$", device_id.lower()):
|
|
115
|
+
raise ValidationError(f"Invalid device ID format: {device_id}")
|
|
116
|
+
|
|
117
|
+
return device_id.lower()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_confirmation(confirm: bool | None, operation: str) -> None:
|
|
121
|
+
"""Validate that confirmation is provided for mutating operations.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
confirm: Confirmation flag
|
|
125
|
+
operation: Operation name
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValidationError: If confirmation is not provided
|
|
129
|
+
"""
|
|
130
|
+
if not confirm:
|
|
131
|
+
raise ValidationError(
|
|
132
|
+
f"Operation '{operation}' requires confirmation. Set confirm=true to proceed."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def validate_limit_offset(limit: int | None = None, offset: int | None = None) -> tuple[int, int]:
|
|
137
|
+
"""Validate and normalize pagination parameters.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
limit: Maximum number of items to return
|
|
141
|
+
offset: Number of items to skip
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Tuple of (limit, offset) with defaults applied
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValidationError: If parameters are invalid
|
|
148
|
+
"""
|
|
149
|
+
# Set defaults
|
|
150
|
+
final_limit = limit if limit is not None else 100
|
|
151
|
+
final_offset = offset if offset is not None else 0
|
|
152
|
+
|
|
153
|
+
# Validate
|
|
154
|
+
if final_limit < 1 or final_limit > 1000:
|
|
155
|
+
raise ValidationError(f"Limit must be between 1 and 1000: {final_limit}")
|
|
156
|
+
|
|
157
|
+
if final_offset < 0:
|
|
158
|
+
raise ValidationError(f"Offset must be non-negative: {final_offset}")
|
|
159
|
+
|
|
160
|
+
return final_limit, final_offset
|
src/webhooks/__init__.py
ADDED
src/webhooks/handlers.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Webhook event handlers for UniFi events.
|
|
2
|
+
|
|
3
|
+
This module provides example event handlers and a handler management class.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ..config import Settings
|
|
10
|
+
from ..utils import get_logger
|
|
11
|
+
from .receiver import WebhookEvent
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .receiver import WebhookReceiver
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebhookEventHandler:
|
|
18
|
+
"""Manages webhook event handlers and provides common handlers."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, settings: Settings):
|
|
21
|
+
"""Initialize event handler.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
settings: Application settings
|
|
25
|
+
"""
|
|
26
|
+
self.settings = settings
|
|
27
|
+
self.logger = get_logger(__name__, settings.log_level)
|
|
28
|
+
|
|
29
|
+
async def handle_device_online(self, event: WebhookEvent) -> None:
|
|
30
|
+
"""Handle device online event.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
event: Webhook event
|
|
34
|
+
"""
|
|
35
|
+
device_mac = event.data.get("mac")
|
|
36
|
+
device_name = event.data.get("name", "Unknown")
|
|
37
|
+
|
|
38
|
+
self.logger.info(
|
|
39
|
+
f"Device came online: {device_name} ({device_mac}) in site {event.site_id}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Example: Invalidate device cache
|
|
43
|
+
from ..cache import invalidate_cache
|
|
44
|
+
|
|
45
|
+
await invalidate_cache(
|
|
46
|
+
self.settings,
|
|
47
|
+
resource_type="devices",
|
|
48
|
+
site_id=event.site_id,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def handle_device_offline(self, event: WebhookEvent) -> None:
|
|
52
|
+
"""Handle device offline event.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
event: Webhook event
|
|
56
|
+
"""
|
|
57
|
+
device_mac = event.data.get("mac")
|
|
58
|
+
device_name = event.data.get("name", "Unknown")
|
|
59
|
+
|
|
60
|
+
self.logger.warning(
|
|
61
|
+
f"Device went offline: {device_name} ({device_mac}) in site {event.site_id}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Example: Invalidate device cache
|
|
65
|
+
from ..cache import invalidate_cache
|
|
66
|
+
|
|
67
|
+
await invalidate_cache(
|
|
68
|
+
self.settings,
|
|
69
|
+
resource_type="devices",
|
|
70
|
+
site_id=event.site_id,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def handle_client_connected(self, event: WebhookEvent) -> None:
|
|
74
|
+
"""Handle client connected event.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
event: Webhook event
|
|
78
|
+
"""
|
|
79
|
+
client_mac = event.data.get("mac")
|
|
80
|
+
client_name = event.data.get("hostname", "Unknown")
|
|
81
|
+
ssid = event.data.get("essid", "N/A")
|
|
82
|
+
|
|
83
|
+
self.logger.info(
|
|
84
|
+
f"Client connected: {client_name} ({client_mac}) to {ssid} " f"in site {event.site_id}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Example: Invalidate clients cache
|
|
88
|
+
from ..cache import invalidate_cache
|
|
89
|
+
|
|
90
|
+
await invalidate_cache(
|
|
91
|
+
self.settings,
|
|
92
|
+
resource_type="clients",
|
|
93
|
+
site_id=event.site_id,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def handle_client_disconnected(self, event: WebhookEvent) -> None:
|
|
97
|
+
"""Handle client disconnected event.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
event: Webhook event
|
|
101
|
+
"""
|
|
102
|
+
client_mac = event.data.get("mac")
|
|
103
|
+
client_name = event.data.get("hostname", "Unknown")
|
|
104
|
+
|
|
105
|
+
self.logger.info(
|
|
106
|
+
f"Client disconnected: {client_name} ({client_mac}) from site {event.site_id}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Example: Invalidate clients cache
|
|
110
|
+
from ..cache import invalidate_cache
|
|
111
|
+
|
|
112
|
+
await invalidate_cache(
|
|
113
|
+
self.settings,
|
|
114
|
+
resource_type="clients",
|
|
115
|
+
site_id=event.site_id,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def handle_alert_raised(self, event: WebhookEvent) -> None:
|
|
119
|
+
"""Handle alert raised event.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
event: Webhook event
|
|
123
|
+
"""
|
|
124
|
+
alert_type = event.data.get("type", "Unknown")
|
|
125
|
+
alert_message = event.data.get("message", "")
|
|
126
|
+
severity = event.data.get("severity", "info")
|
|
127
|
+
|
|
128
|
+
self.logger.warning(
|
|
129
|
+
f"Alert raised in site {event.site_id}: [{severity}] " f"{alert_type} - {alert_message}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Example: Could trigger notifications, update monitoring systems, etc.
|
|
133
|
+
|
|
134
|
+
async def handle_event_occurred(self, event: WebhookEvent) -> None:
|
|
135
|
+
"""Handle generic event.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
event: Webhook event
|
|
139
|
+
"""
|
|
140
|
+
event_key = event.data.get("key", "unknown")
|
|
141
|
+
event_msg = event.data.get("msg", "")
|
|
142
|
+
|
|
143
|
+
self.logger.info(f"Event occurred in site {event.site_id}: {event_key} - {event_msg}")
|
|
144
|
+
|
|
145
|
+
async def handle_wildcard(self, event: WebhookEvent) -> None:
|
|
146
|
+
"""Handle any event (wildcard handler).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
event: Webhook event
|
|
150
|
+
"""
|
|
151
|
+
self.logger.debug(
|
|
152
|
+
f"Wildcard handler received event: {event.event_type} from site {event.site_id}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def get_default_handlers(self) -> dict[str, Callable]:
|
|
156
|
+
"""Get default event handlers mapping.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Dictionary mapping event types to handler functions
|
|
160
|
+
"""
|
|
161
|
+
return {
|
|
162
|
+
"device.online": self.handle_device_online,
|
|
163
|
+
"device.offline": self.handle_device_offline,
|
|
164
|
+
"client.connected": self.handle_client_connected,
|
|
165
|
+
"client.disconnected": self.handle_client_disconnected,
|
|
166
|
+
"alert.raised": self.handle_alert_raised,
|
|
167
|
+
"event.occurred": self.handle_event_occurred,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def register_default_handlers(self, receiver: "WebhookReceiver") -> None:
|
|
171
|
+
"""Register all default handlers with a webhook receiver.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
receiver: WebhookReceiver instance
|
|
175
|
+
"""
|
|
176
|
+
handlers = self.get_default_handlers()
|
|
177
|
+
|
|
178
|
+
for event_type, handler in handlers.items():
|
|
179
|
+
receiver.register_handler(event_type, handler)
|
|
180
|
+
|
|
181
|
+
self.logger.info(f"Registered {len(handlers)} default webhook handlers")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Example custom handler
|
|
185
|
+
async def custom_handler_example(event: WebhookEvent) -> None:
|
|
186
|
+
"""Example custom webhook handler.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
event: Webhook event
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
receiver = WebhookReceiver(settings)
|
|
193
|
+
receiver.register_handler("device.adopted", custom_handler_example)
|
|
194
|
+
"""
|
|
195
|
+
print(f"Custom handler received event: {event.event_type}")
|
|
196
|
+
print(f"Event data: {event.data}")
|
src/webhooks/receiver.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Webhook receiver for UniFi events.
|
|
2
|
+
|
|
3
|
+
This module provides a webhook receiver that listens for UniFi events
|
|
4
|
+
and processes them asynchronously. It includes signature verification,
|
|
5
|
+
event validation, and rate limiting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, Header, HTTPException, Request, status
|
|
16
|
+
from pydantic import BaseModel, Field, validator
|
|
17
|
+
|
|
18
|
+
from ..config import Settings
|
|
19
|
+
from ..utils import get_logger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WebhookEvent(BaseModel):
|
|
23
|
+
"""UniFi webhook event model."""
|
|
24
|
+
|
|
25
|
+
event_type: str = Field(..., description="Event type (e.g., device.online)")
|
|
26
|
+
timestamp: datetime = Field(..., description="Event timestamp")
|
|
27
|
+
site_id: str = Field(..., description="Site identifier")
|
|
28
|
+
data: dict[str, Any] = Field(..., description="Event data")
|
|
29
|
+
event_id: str | None = Field(None, description="Unique event identifier")
|
|
30
|
+
|
|
31
|
+
@validator("event_type")
|
|
32
|
+
def validate_event_type(cls, v: str) -> str:
|
|
33
|
+
"""Validate event type format."""
|
|
34
|
+
if not v or "." not in v:
|
|
35
|
+
raise ValueError("Event type must be in format 'category.action'")
|
|
36
|
+
return v.lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WebhookReceiver:
|
|
40
|
+
"""Webhook receiver for UniFi events."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
settings: Settings,
|
|
45
|
+
app: FastAPI | None = None,
|
|
46
|
+
path: str = "/webhooks/unifi",
|
|
47
|
+
):
|
|
48
|
+
"""Initialize webhook receiver.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
settings: Application settings
|
|
52
|
+
app: Optional FastAPI app instance
|
|
53
|
+
path: Webhook endpoint path
|
|
54
|
+
"""
|
|
55
|
+
self.settings = settings
|
|
56
|
+
self.path = path
|
|
57
|
+
self.logger = get_logger(__name__, settings.log_level)
|
|
58
|
+
self.handlers: dict[str, list[Callable]] = {}
|
|
59
|
+
self._event_cache: dict[str, datetime] = {}
|
|
60
|
+
self._rate_limit_cache: dict[str, list[datetime]] = {}
|
|
61
|
+
|
|
62
|
+
# Get webhook secret from settings
|
|
63
|
+
self.webhook_secret = getattr(settings, "webhook_secret", None)
|
|
64
|
+
if not self.webhook_secret:
|
|
65
|
+
self.logger.warning(
|
|
66
|
+
"WEBHOOK_SECRET not configured. Signature verification disabled. "
|
|
67
|
+
"Set WEBHOOK_SECRET environment variable for production use."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if app:
|
|
71
|
+
self.register_routes(app)
|
|
72
|
+
|
|
73
|
+
def register_routes(self, app: FastAPI) -> None:
|
|
74
|
+
"""Register webhook routes with FastAPI app.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
app: FastAPI application instance
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@app.post(self.path)
|
|
81
|
+
async def receive_webhook(
|
|
82
|
+
request: Request,
|
|
83
|
+
x_unifi_signature: str | None = Header(None),
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""Receive and process UniFi webhook."""
|
|
86
|
+
try:
|
|
87
|
+
# Get request body
|
|
88
|
+
body = await request.body()
|
|
89
|
+
body_str = body.decode("utf-8")
|
|
90
|
+
|
|
91
|
+
# Verify signature if secret is configured
|
|
92
|
+
if self.webhook_secret and x_unifi_signature:
|
|
93
|
+
if not self._verify_signature(body_str, x_unifi_signature):
|
|
94
|
+
self.logger.warning("Invalid webhook signature")
|
|
95
|
+
raise HTTPException(
|
|
96
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
97
|
+
detail="Invalid signature",
|
|
98
|
+
)
|
|
99
|
+
elif self.webhook_secret and not x_unifi_signature:
|
|
100
|
+
self.logger.warning("Missing webhook signature")
|
|
101
|
+
raise HTTPException(
|
|
102
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
103
|
+
detail="Missing signature",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Parse event
|
|
107
|
+
event_data = json.loads(body_str)
|
|
108
|
+
event = WebhookEvent(**event_data)
|
|
109
|
+
|
|
110
|
+
# Check for duplicate events
|
|
111
|
+
if self._is_duplicate(event):
|
|
112
|
+
self.logger.debug(f"Ignoring duplicate event: {event.event_id}")
|
|
113
|
+
return {"status": "duplicate", "event_id": event.event_id}
|
|
114
|
+
|
|
115
|
+
# Rate limiting check
|
|
116
|
+
if not self._check_rate_limit(event.site_id):
|
|
117
|
+
self.logger.warning(f"Rate limit exceeded for site: {event.site_id}")
|
|
118
|
+
raise HTTPException(
|
|
119
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
120
|
+
detail="Rate limit exceeded",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Process event
|
|
124
|
+
await self._process_event(event)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"status": "success",
|
|
128
|
+
"event_id": event.event_id,
|
|
129
|
+
"event_type": event.event_type,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
except json.JSONDecodeError as e:
|
|
133
|
+
self.logger.error(f"Invalid JSON in webhook payload: {e}")
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
136
|
+
detail="Invalid JSON payload",
|
|
137
|
+
) from e
|
|
138
|
+
except ValueError as e:
|
|
139
|
+
self.logger.error(f"Invalid webhook event: {e}")
|
|
140
|
+
raise HTTPException(
|
|
141
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
142
|
+
detail=str(e),
|
|
143
|
+
) from e
|
|
144
|
+
except Exception as e:
|
|
145
|
+
self.logger.error(f"Error processing webhook: {e}")
|
|
146
|
+
raise HTTPException(
|
|
147
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
148
|
+
detail="Internal server error",
|
|
149
|
+
) from e
|
|
150
|
+
|
|
151
|
+
self.logger.info(f"Webhook receiver registered at {self.path}")
|
|
152
|
+
|
|
153
|
+
def register_handler(self, event_type: str, handler: Callable) -> None:
|
|
154
|
+
"""Register an event handler.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
event_type: Event type to handle (e.g., "device.online")
|
|
158
|
+
handler: Async handler function
|
|
159
|
+
"""
|
|
160
|
+
if event_type not in self.handlers:
|
|
161
|
+
self.handlers[event_type] = []
|
|
162
|
+
|
|
163
|
+
self.handlers[event_type].append(handler)
|
|
164
|
+
self.logger.info(f"Registered handler for event type: {event_type}")
|
|
165
|
+
|
|
166
|
+
def unregister_handler(self, event_type: str, handler: Callable) -> None:
|
|
167
|
+
"""Unregister an event handler.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
event_type: Event type
|
|
171
|
+
handler: Handler function to remove
|
|
172
|
+
"""
|
|
173
|
+
if event_type in self.handlers:
|
|
174
|
+
self.handlers[event_type].remove(handler)
|
|
175
|
+
self.logger.info(f"Unregistered handler for event type: {event_type}")
|
|
176
|
+
|
|
177
|
+
async def _process_event(self, event: WebhookEvent) -> None:
|
|
178
|
+
"""Process a webhook event.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
event: Webhook event to process
|
|
182
|
+
"""
|
|
183
|
+
self.logger.info(
|
|
184
|
+
f"Processing webhook event: {event.event_type} "
|
|
185
|
+
f"(site: {event.site_id}, id: {event.event_id})"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Get handlers for this event type
|
|
189
|
+
handlers = self.handlers.get(event.event_type, [])
|
|
190
|
+
|
|
191
|
+
# Also get wildcard handlers (e.g., "device.*")
|
|
192
|
+
event_category = event.event_type.split(".")[0]
|
|
193
|
+
wildcard_handlers = self.handlers.get(f"{event_category}.*", [])
|
|
194
|
+
|
|
195
|
+
all_handlers = handlers + wildcard_handlers
|
|
196
|
+
|
|
197
|
+
if not all_handlers:
|
|
198
|
+
self.logger.debug(f"No handlers registered for event type: {event.event_type}")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Execute handlers
|
|
202
|
+
for handler in all_handlers:
|
|
203
|
+
try:
|
|
204
|
+
await handler(event)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
self.logger.error(
|
|
207
|
+
f"Error in handler for {event.event_type}: {e}",
|
|
208
|
+
exc_info=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _verify_signature(self, payload: str, signature: str) -> bool:
|
|
212
|
+
"""Verify webhook signature.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
payload: Request payload
|
|
216
|
+
signature: Signature from X-UniFi-Signature header
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True if signature is valid, False otherwise
|
|
220
|
+
"""
|
|
221
|
+
if not self.webhook_secret:
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
expected_signature = hmac.new(
|
|
225
|
+
self.webhook_secret.encode("utf-8"),
|
|
226
|
+
payload.encode("utf-8"),
|
|
227
|
+
hashlib.sha256,
|
|
228
|
+
).hexdigest()
|
|
229
|
+
|
|
230
|
+
return hmac.compare_digest(signature, expected_signature)
|
|
231
|
+
|
|
232
|
+
def _is_duplicate(self, event: WebhookEvent) -> bool:
|
|
233
|
+
"""Check if event is a duplicate.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
event: Webhook event
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
True if duplicate, False otherwise
|
|
240
|
+
"""
|
|
241
|
+
if not event.event_id:
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
# Clean old cache entries (older than 5 minutes)
|
|
245
|
+
cutoff = datetime.now() - timedelta(minutes=5)
|
|
246
|
+
self._event_cache = {eid: ts for eid, ts in self._event_cache.items() if ts > cutoff}
|
|
247
|
+
|
|
248
|
+
# Check if event ID exists
|
|
249
|
+
if event.event_id in self._event_cache:
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
# Add to cache
|
|
253
|
+
self._event_cache[event.event_id] = datetime.now()
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
def _check_rate_limit(
|
|
257
|
+
self,
|
|
258
|
+
site_id: str,
|
|
259
|
+
max_requests: int = 100,
|
|
260
|
+
window_seconds: int = 60,
|
|
261
|
+
) -> bool:
|
|
262
|
+
"""Check rate limit for a site.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
site_id: Site identifier
|
|
266
|
+
max_requests: Maximum requests per window
|
|
267
|
+
window_seconds: Time window in seconds
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
True if within rate limit, False otherwise
|
|
271
|
+
"""
|
|
272
|
+
now = datetime.now()
|
|
273
|
+
cutoff = now - timedelta(seconds=window_seconds)
|
|
274
|
+
|
|
275
|
+
# Initialize or clean rate limit cache for this site
|
|
276
|
+
if site_id not in self._rate_limit_cache:
|
|
277
|
+
self._rate_limit_cache[site_id] = []
|
|
278
|
+
|
|
279
|
+
# Remove old requests
|
|
280
|
+
self._rate_limit_cache[site_id] = [
|
|
281
|
+
ts for ts in self._rate_limit_cache[site_id] if ts > cutoff
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
# Check limit
|
|
285
|
+
if len(self._rate_limit_cache[site_id]) >= max_requests:
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
# Add current request
|
|
289
|
+
self._rate_limit_cache[site_id].append(now)
|
|
290
|
+
return True
|