From d49610163fb5941e1d9c75aa0a3052cdfdaca135 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 6 May 2026 16:30:23 +0200 Subject: [PATCH] feat(zigbee): add MQTT TLS support with optional cert verify skip - Schema: mqtt_tls + mqtt_tls_insecure flags on import + test-connection requests; model_validator enforces insecure requires tls - Service: _build_tls_context() using ssl.create_default_context(); logger.warning when verification disabled; tls_context plumbed into aiomqtt.Client for both fetch_networkmap and test_mqtt_connection - Route: passes tls flags through to service - Frontend: TLS checkbox auto-toggles port 1883<->8883 unless user edited; insecure checkbox disabled until TLS on, red-tinted; password field marked autocomplete=new-password - Tests: 7 new backend tests (TLS context build, client kwargs assertion, router happy path, insecure-without-tls 422) --- backend/app/api/routes/zigbee.py | 4 + backend/app/schemas/zigbee.py | 20 ++++- backend/app/services/zigbee_service.py | 24 ++++++ backend/tests/test_zigbee_router.py | 56 ++++++++++++++ backend/tests/test_zigbee_service.py | 76 +++++++++++++++++++ frontend/src/api/client.ts | 4 + .../components/zigbee/ZigbeeImportModal.tsx | 63 ++++++++++++++- 7 files changed, 243 insertions(+), 4 deletions(-) diff --git a/backend/app/api/routes/zigbee.py b/backend/app/api/routes/zigbee.py index 77051e0..c52488d 100644 --- a/backend/app/api/routes/zigbee.py +++ b/backend/app/api/routes/zigbee.py @@ -38,6 +38,8 @@ async def import_zigbee_network( base_topic=payload.base_topic, username=payload.mqtt_username, password=payload.mqtt_password, + tls=payload.mqtt_tls, + tls_insecure=payload.mqtt_tls_insecure, ) except ImportError as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @@ -68,6 +70,8 @@ async def test_zigbee_connection( mqtt_port=payload.mqtt_port, username=payload.mqtt_username, password=payload.mqtt_password, + tls=payload.mqtt_tls, + tls_insecure=payload.mqtt_tls_insecure, ) return ZigbeeTestConnectionResponse(connected=True, message="Connection successful") except ImportError as exc: diff --git a/backend/app/schemas/zigbee.py b/backend/app/schemas/zigbee.py index 975d85d..3831ee6 100644 --- a/backend/app/schemas/zigbee.py +++ b/backend/app/schemas/zigbee.py @@ -1,6 +1,6 @@ """Pydantic v2 schemas for Zigbee2MQTT import.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class ZigbeeImportRequest(BaseModel): @@ -9,6 +9,16 @@ class ZigbeeImportRequest(BaseModel): mqtt_username: str | None = Field(None, description="MQTT username (optional)") mqtt_password: str | None = Field(None, description="MQTT password (optional)") base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic") + mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)") + mqtt_tls_insecure: bool = Field( + False, description="Skip TLS certificate verification (self-signed only)" + ) + + @model_validator(mode="after") + def _insecure_requires_tls(self) -> "ZigbeeImportRequest": + if self.mqtt_tls_insecure and not self.mqtt_tls: + raise ValueError("mqtt_tls_insecure requires mqtt_tls=true") + return self class ZigbeeTestConnectionRequest(BaseModel): @@ -16,6 +26,14 @@ class ZigbeeTestConnectionRequest(BaseModel): mqtt_port: int = Field(1883, ge=1, le=65535) mqtt_username: str | None = None mqtt_password: str | None = None + mqtt_tls: bool = False + mqtt_tls_insecure: bool = False + + @model_validator(mode="after") + def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest": + if self.mqtt_tls_insecure and not self.mqtt_tls: + raise ValueError("mqtt_tls_insecure requires mqtt_tls=true") + return self class ZigbeeDeviceData(BaseModel): diff --git a/backend/app/services/zigbee_service.py b/backend/app/services/zigbee_service.py index 81313e6..b4234f4 100644 --- a/backend/app/services/zigbee_service.py +++ b/backend/app/services/zigbee_service.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import logging +import ssl from typing import Any logger = logging.getLogger(__name__) @@ -20,6 +21,19 @@ _CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability _NETWORKMAP_TIMEOUT = 10.0 # seconds to wait for the networkmap response +def _build_tls_context(insecure: bool) -> ssl.SSLContext: + """Build an SSL context for MQTT TLS. If insecure, skip verification.""" + ctx = ssl.create_default_context() + if insecure: + logger.warning( + "MQTT TLS certificate verification is DISABLED — " + "use only with self-signed brokers on trusted networks." + ) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + def _z2m_type_to_homelable(device_type: str) -> str: """Map a Z2M device type string to a homelable node type.""" mapping = { @@ -159,6 +173,8 @@ async def fetch_networkmap( base_topic: str, username: str | None = None, password: str | None = None, + tls: bool = False, + tls_insecure: bool = False, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges). @@ -179,6 +195,8 @@ async def fetch_networkmap( result_event: asyncio.Event = asyncio.Event() response_payload: dict[str, Any] = {} + tls_context = _build_tls_context(tls_insecure) if tls else None + try: async with aiomqtt.Client( hostname=mqtt_host, @@ -186,6 +204,7 @@ async def fetch_networkmap( username=username, password=password, timeout=_CONNECTION_TIMEOUT, + tls_context=tls_context, ) as client: await client.subscribe(response_topic) await client.publish( @@ -229,6 +248,8 @@ async def test_mqtt_connection( mqtt_port: int, username: str | None = None, password: str | None = None, + tls: bool = False, + tls_insecure: bool = False, ) -> bool: """Attempt a quick MQTT connection to verify broker reachability. @@ -237,6 +258,8 @@ async def test_mqtt_connection( if aiomqtt is None: # pragma: no cover raise ImportError("aiomqtt is required") + tls_context = _build_tls_context(tls_insecure) if tls else None + try: async with aiomqtt.Client( hostname=mqtt_host, @@ -244,6 +267,7 @@ async def test_mqtt_connection( username=username, password=password, timeout=_CONNECTION_TIMEOUT, + tls_context=tls_context, ): return True except aiomqtt.MqttError as exc: diff --git a/backend/tests/test_zigbee_router.py b/backend/tests/test_zigbee_router.py index 2aab038..bc3d48d 100644 --- a/backend/tests/test_zigbee_router.py +++ b/backend/tests/test_zigbee_router.py @@ -152,6 +152,8 @@ async def test_import_with_credentials(client: AsyncClient, headers: dict) -> No base_topic="z2m", username="admin", password="secret", + tls=False, + tls_insecure=False, ) @@ -226,3 +228,57 @@ async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> N headers=headers, ) assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None: + with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch: + mock_fetch.return_value = ([], []) + res = await client.post( + "/api/v1/zigbee/import", + json={ + "mqtt_host": "broker.example.com", + "mqtt_port": 8883, + "mqtt_tls": True, + }, + headers=headers, + ) + assert res.status_code == 200 + kwargs = mock_fetch.call_args.kwargs + assert kwargs["tls"] is True + assert kwargs["tls_insecure"] is False + + +@pytest.mark.asyncio +async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None: + res = await client.post( + "/api/v1/zigbee/import", + json={ + "mqtt_host": "broker.example.com", + "mqtt_port": 1883, + "mqtt_tls": False, + "mqtt_tls_insecure": True, + }, + headers=headers, + ) + assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None: + with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn: + mock_conn.return_value = True + res = await client.post( + "/api/v1/zigbee/test-connection", + json={ + "mqtt_host": "broker.example.com", + "mqtt_port": 8883, + "mqtt_tls": True, + "mqtt_tls_insecure": True, + }, + headers=headers, + ) + assert res.status_code == 200 + kwargs = mock_conn.call_args.kwargs + assert kwargs["tls"] is True + assert kwargs["tls_insecure"] is True diff --git a/backend/tests/test_zigbee_service.py b/backend/tests/test_zigbee_service.py index 4b9f6b7..3bf4e5c 100644 --- a/backend/tests/test_zigbee_service.py +++ b/backend/tests/test_zigbee_service.py @@ -361,3 +361,79 @@ async def test_test_mqtt_connection_failure() -> None: with pytest.raises(ConnectionError): await _test_mqtt_connection("bad-host", 1883) + + +# --------------------------------------------------------------------------- +# TLS context +# --------------------------------------------------------------------------- + +import ssl # noqa: E402 + +from app.services.zigbee_service import _build_tls_context # noqa: E402 + + +def test_build_tls_context_secure_verifies_cert() -> None: + ctx = _build_tls_context(insecure=False) + assert ctx.check_hostname is True + assert ctx.verify_mode == ssl.CERT_REQUIRED + + +def test_build_tls_context_insecure_disables_verification() -> None: + ctx = _build_tls_context(insecure=True) + assert ctx.check_hostname is False + assert ctx.verify_mode == ssl.CERT_NONE + + +@pytest.mark.asyncio +async def test_test_mqtt_connection_passes_tls_context() -> None: + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: + mock_aiomqtt.Client.return_value = _FakeClient() + mock_aiomqtt.MqttError = Exception + + await _test_mqtt_connection("host", 8883, tls=True) + kwargs = mock_aiomqtt.Client.call_args.kwargs + assert kwargs["tls_context"] is not None + assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED + + +@pytest.mark.asyncio +async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None: + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: + mock_aiomqtt.Client.return_value = _FakeClient() + mock_aiomqtt.MqttError = Exception + + await _test_mqtt_connection("host", 1883, tls=False) + assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None + + +@pytest.mark.asyncio +async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None: + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt: + mock_aiomqtt.Client.return_value = _FakeClient() + mock_aiomqtt.MqttError = Exception + + await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True) + ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"] + assert ctx.verify_mode == ssl.CERT_NONE + assert ctx.check_hostname is False diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 622ca09..83e28a0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -79,6 +79,8 @@ export const zigbeeApi = { mqtt_port: number mqtt_username?: string mqtt_password?: string + mqtt_tls?: boolean + mqtt_tls_insecure?: boolean }) => api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data), @@ -88,6 +90,8 @@ export const zigbeeApi = { mqtt_username?: string mqtt_password?: string base_topic?: string + mqtt_tls?: boolean + mqtt_tls_insecure?: boolean }) => api.post<{ nodes: import('@/components/zigbee/types').ZigbeeNode[] diff --git a/frontend/src/components/zigbee/ZigbeeImportModal.tsx b/frontend/src/components/zigbee/ZigbeeImportModal.tsx index ab22aac..fbc002a 100644 --- a/frontend/src/components/zigbee/ZigbeeImportModal.tsx +++ b/frontend/src/components/zigbee/ZigbeeImportModal.tsx @@ -20,6 +20,9 @@ interface ConnectionForm { mqtt_username: string mqtt_password: string base_topic: string + mqtt_tls: boolean + mqtt_tls_insecure: boolean + port_user_edited: boolean } const DEFAULT_FORM: ConnectionForm = { @@ -28,6 +31,9 @@ const DEFAULT_FORM: ConnectionForm = { mqtt_username: '', mqtt_password: '', base_topic: 'zigbee2mqtt', + mqtt_tls: false, + mqtt_tls_insecure: false, + port_user_edited: false, } const DEVICE_TYPE_ICON = { @@ -58,14 +64,35 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport const [checked, setChecked] = useState>(new Set()) const updateField = (field: keyof ConnectionForm, value: string) => - setForm((f) => ({ ...f, [field]: value })) + setForm((f) => ({ + ...f, + [field]: value, + ...(field === 'mqtt_port' ? { port_user_edited: true } : {}), + })) + + const toggleTls = (next: boolean) => + setForm((f) => { + const port = f.port_user_edited + ? f.mqtt_port + : next + ? '8883' + : '1883' + return { + ...f, + mqtt_tls: next, + mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false, + mqtt_port: port, + } + }) const buildPayload = () => ({ mqtt_host: form.mqtt_host.trim(), - mqtt_port: Number(form.mqtt_port) || 1883, + mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883), mqtt_username: form.mqtt_username.trim() || undefined, mqtt_password: form.mqtt_password || undefined, base_topic: form.base_topic.trim() || 'zigbee2mqtt', + mqtt_tls: form.mqtt_tls, + mqtt_tls_insecure: form.mqtt_tls_insecure, }) const handleTestConnection = async () => { @@ -74,9 +101,11 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport try { const res = await zigbeeApi.testConnection({ mqtt_host: form.mqtt_host.trim(), - mqtt_port: Number(form.mqtt_port) || 1883, + mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883), mqtt_username: form.mqtt_username.trim() || undefined, mqtt_password: form.mqtt_password || undefined, + mqtt_tls: form.mqtt_tls, + mqtt_tls_insecure: form.mqtt_tls_insecure, }) if (res.data.connected) { setConnectionStatus('ok') @@ -207,9 +236,37 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport onChange={(e) => updateField('mqtt_password', e.target.value)} placeholder="••••••••" type="password" + autoComplete="new-password" className="text-sm bg-[#0d1117] border-border" /> +
+ + +
{/* Connection status indicator */}