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)
This commit is contained in:
Pouzor
2026-05-06 16:30:23 +02:00
parent 517130a9ef
commit d49610163f
7 changed files with 243 additions and 4 deletions
+4
View File
@@ -38,6 +38,8 @@ async def import_zigbee_network(
base_topic=payload.base_topic, base_topic=payload.base_topic,
username=payload.mqtt_username, username=payload.mqtt_username,
password=payload.mqtt_password, password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
) )
except ImportError as exc: except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from 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, mqtt_port=payload.mqtt_port,
username=payload.mqtt_username, username=payload.mqtt_username,
password=payload.mqtt_password, password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
) )
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful") return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
except ImportError as exc: except ImportError as exc:
+19 -1
View File
@@ -1,6 +1,6 @@
"""Pydantic v2 schemas for Zigbee2MQTT import.""" """Pydantic v2 schemas for Zigbee2MQTT import."""
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class ZigbeeImportRequest(BaseModel): class ZigbeeImportRequest(BaseModel):
@@ -9,6 +9,16 @@ class ZigbeeImportRequest(BaseModel):
mqtt_username: str | None = Field(None, description="MQTT username (optional)") mqtt_username: str | None = Field(None, description="MQTT username (optional)")
mqtt_password: str | None = Field(None, description="MQTT password (optional)") mqtt_password: str | None = Field(None, description="MQTT password (optional)")
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic") 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): class ZigbeeTestConnectionRequest(BaseModel):
@@ -16,6 +26,14 @@ class ZigbeeTestConnectionRequest(BaseModel):
mqtt_port: int = Field(1883, ge=1, le=65535) mqtt_port: int = Field(1883, ge=1, le=65535)
mqtt_username: str | None = None mqtt_username: str | None = None
mqtt_password: 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): class ZigbeeDeviceData(BaseModel):
+24
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import ssl
from typing import Any from typing import Any
logger = logging.getLogger(__name__) 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 _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: def _z2m_type_to_homelable(device_type: str) -> str:
"""Map a Z2M device type string to a homelable node type.""" """Map a Z2M device type string to a homelable node type."""
mapping = { mapping = {
@@ -159,6 +173,8 @@ async def fetch_networkmap(
base_topic: str, base_topic: str,
username: str | None = None, username: str | None = None,
password: str | None = None, password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges). """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() result_event: asyncio.Event = asyncio.Event()
response_payload: dict[str, Any] = {} response_payload: dict[str, Any] = {}
tls_context = _build_tls_context(tls_insecure) if tls else None
try: try:
async with aiomqtt.Client( async with aiomqtt.Client(
hostname=mqtt_host, hostname=mqtt_host,
@@ -186,6 +204,7 @@ async def fetch_networkmap(
username=username, username=username,
password=password, password=password,
timeout=_CONNECTION_TIMEOUT, timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
) as client: ) as client:
await client.subscribe(response_topic) await client.subscribe(response_topic)
await client.publish( await client.publish(
@@ -229,6 +248,8 @@ async def test_mqtt_connection(
mqtt_port: int, mqtt_port: int,
username: str | None = None, username: str | None = None,
password: str | None = None, password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> bool: ) -> bool:
"""Attempt a quick MQTT connection to verify broker reachability. """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 if aiomqtt is None: # pragma: no cover
raise ImportError("aiomqtt is required") raise ImportError("aiomqtt is required")
tls_context = _build_tls_context(tls_insecure) if tls else None
try: try:
async with aiomqtt.Client( async with aiomqtt.Client(
hostname=mqtt_host, hostname=mqtt_host,
@@ -244,6 +267,7 @@ async def test_mqtt_connection(
username=username, username=username,
password=password, password=password,
timeout=_CONNECTION_TIMEOUT, timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
): ):
return True return True
except aiomqtt.MqttError as exc: except aiomqtt.MqttError as exc:
+56
View File
@@ -152,6 +152,8 @@ async def test_import_with_credentials(client: AsyncClient, headers: dict) -> No
base_topic="z2m", base_topic="z2m",
username="admin", username="admin",
password="secret", 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, headers=headers,
) )
assert res.status_code == 422 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
+76
View File
@@ -361,3 +361,79 @@ async def test_test_mqtt_connection_failure() -> None:
with pytest.raises(ConnectionError): with pytest.raises(ConnectionError):
await _test_mqtt_connection("bad-host", 1883) 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
+4
View File
@@ -79,6 +79,8 @@ export const zigbeeApi = {
mqtt_port: number mqtt_port: number
mqtt_username?: string mqtt_username?: string
mqtt_password?: string mqtt_password?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) => }) =>
api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data), api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data),
@@ -88,6 +90,8 @@ export const zigbeeApi = {
mqtt_username?: string mqtt_username?: string
mqtt_password?: string mqtt_password?: string
base_topic?: string base_topic?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) => }) =>
api.post<{ api.post<{
nodes: import('@/components/zigbee/types').ZigbeeNode[] nodes: import('@/components/zigbee/types').ZigbeeNode[]
@@ -20,6 +20,9 @@ interface ConnectionForm {
mqtt_username: string mqtt_username: string
mqtt_password: string mqtt_password: string
base_topic: string base_topic: string
mqtt_tls: boolean
mqtt_tls_insecure: boolean
port_user_edited: boolean
} }
const DEFAULT_FORM: ConnectionForm = { const DEFAULT_FORM: ConnectionForm = {
@@ -28,6 +31,9 @@ const DEFAULT_FORM: ConnectionForm = {
mqtt_username: '', mqtt_username: '',
mqtt_password: '', mqtt_password: '',
base_topic: 'zigbee2mqtt', base_topic: 'zigbee2mqtt',
mqtt_tls: false,
mqtt_tls_insecure: false,
port_user_edited: false,
} }
const DEVICE_TYPE_ICON = { const DEVICE_TYPE_ICON = {
@@ -58,14 +64,35 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
const [checked, setChecked] = useState<Set<string>>(new Set()) const [checked, setChecked] = useState<Set<string>>(new Set())
const updateField = (field: keyof ConnectionForm, value: string) => 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 = () => ({ const buildPayload = () => ({
mqtt_host: form.mqtt_host.trim(), 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_username: form.mqtt_username.trim() || undefined,
mqtt_password: form.mqtt_password || undefined, mqtt_password: form.mqtt_password || undefined,
base_topic: form.base_topic.trim() || 'zigbee2mqtt', base_topic: form.base_topic.trim() || 'zigbee2mqtt',
mqtt_tls: form.mqtt_tls,
mqtt_tls_insecure: form.mqtt_tls_insecure,
}) })
const handleTestConnection = async () => { const handleTestConnection = async () => {
@@ -74,9 +101,11 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
try { try {
const res = await zigbeeApi.testConnection({ const res = await zigbeeApi.testConnection({
mqtt_host: form.mqtt_host.trim(), 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_username: form.mqtt_username.trim() || undefined,
mqtt_password: form.mqtt_password || undefined, mqtt_password: form.mqtt_password || undefined,
mqtt_tls: form.mqtt_tls,
mqtt_tls_insecure: form.mqtt_tls_insecure,
}) })
if (res.data.connected) { if (res.data.connected) {
setConnectionStatus('ok') setConnectionStatus('ok')
@@ -207,9 +236,37 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
onChange={(e) => updateField('mqtt_password', e.target.value)} onChange={(e) => updateField('mqtt_password', e.target.value)}
placeholder="••••••••" placeholder="••••••••"
type="password" type="password"
autoComplete="new-password"
className="text-sm bg-[#0d1117] border-border" className="text-sm bg-[#0d1117] border-border"
/> />
</div> </div>
<div className="col-span-2 flex items-center gap-4 pt-1">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={form.mqtt_tls}
onChange={(e) => toggleTls(e.target.checked)}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
/>
Use TLS (port 8883)
</label>
<label
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
}`}
>
<input
type="checkbox"
checked={form.mqtt_tls_insecure}
disabled={!form.mqtt_tls}
onChange={(e) =>
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
}
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
/>
Skip cert verify (self-signed only)
</label>
</div>
</div> </div>
{/* Connection status indicator */} {/* Connection status indicator */}