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:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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<Set<string>>(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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Connection status indicator */}
|
||||
|
||||
Reference in New Issue
Block a user