diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index ce8e6bb9274863..926f805a2566f7 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -13,6 +13,7 @@ CONF_PASSWORD, CONF_USERNAME, CONF_VARIABLES, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -31,9 +32,9 @@ CONF_IGNORE_STRING, CONF_NETWORK, CONF_SENSOR_STRING, - CONF_TLS_VER, DEFAULT_IGNORE_STRING, DEFAULT_SENSOR_STRING, + DEFAULT_VERIFY_SSL, DOMAIN, ISY_CONF_FIRMWARE, ISY_CONF_MODEL, @@ -62,6 +63,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: + """Migrate old config entries.""" + if entry.version == 1 and entry.minor_version == 1: + new_data = {key: value for key, value in entry.data.items() if key != "tls"} + new_data.setdefault(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Set up the ISY 994 integration.""" isy_config = entry.data @@ -71,9 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: user = isy_config[CONF_USERNAME] password = isy_config[CONF_PASSWORD] host = urlparse(isy_config[CONF_HOST]) - - # Optional - tls_version = isy_config.get(CONF_TLS_VER) + verify_ssl = isy_config[CONF_VERIFY_SSL] ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING) sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) @@ -86,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: elif host.scheme == SCHEME_HTTPS: https = True port = host.port or 443 - session = aiohttp_client.async_get_clientsession(hass) + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl) else: _LOGGER.error("The ISY/IoX host value in configuration is invalid") return False @@ -98,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: username=user, password=password, use_https=https, - tls_ver=tls_version, + verify_ssl=verify_ssl, webroot=host.path, websession=session, use_websocket=True, diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 4306b05b832264..976c4c76ef60a8 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -6,6 +6,7 @@ from typing import Any from urllib.parse import urlparse, urlunparse +import aiohttp from aiohttp import CookieJar from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy.configuration import Configuration @@ -18,7 +19,13 @@ ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError @@ -34,13 +41,12 @@ CONF_IGNORE_STRING, CONF_RESTORE_LIGHT_STATE, CONF_SENSOR_STRING, - CONF_TLS_VER, CONF_VAR_SENSOR_STRING, DEFAULT_IGNORE_STRING, DEFAULT_RESTORE_LIGHT_STATE, DEFAULT_SENSOR_STRING, - DEFAULT_TLS_VERSION, DEFAULT_VAR_SENSOR_STRING, + DEFAULT_VERIFY_SSL, DOMAIN, HTTP_PORT, HTTPS_PORT, @@ -63,7 +69,7 @@ def _data_schema(schema_input: dict[str, str]) -> vol.Schema: vol.Required(CONF_HOST, default=schema_input.get(CONF_HOST, "")): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_TLS_VER, default=DEFAULT_TLS_VERSION): vol.In([1.1, 1.2]), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, }, extra=vol.ALLOW_EXTRA, ) @@ -77,7 +83,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, user = data[CONF_USERNAME] password = data[CONF_PASSWORD] host = urlparse(data[CONF_HOST]) - tls_version = data.get(CONF_TLS_VER) + verify_ssl = data[CONF_VERIFY_SSL] if host.scheme == SCHEME_HTTP: https = False @@ -88,7 +94,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, elif host.scheme == SCHEME_HTTPS: https = True port = host.port or HTTPS_PORT - session = aiohttp_client.async_get_clientsession(hass) + session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl) else: _LOGGER.error("The ISY/IoX host value in configuration is invalid") raise InvalidHost @@ -100,7 +106,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, user, password, use_https=https, - tls_ver=tls_version, + verify_ssl=verify_ssl, webroot=host.path, websession=session, ) @@ -111,6 +117,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except ISYInvalidAuthError as error: raise InvalidAuth from error except ISYConnectionError as error: + # pyisy chains the underlying aiohttp error via __cause__; ClientSSLError + # covers both protocol mismatch and certificate verification failures. + if isinstance(error.__cause__, aiohttp.ClientSSLError): + raise SslError from error raise CannotConnect from error try: @@ -131,6 +141,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Universal Devices ISY/IoX.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the ISY/IoX config flow.""" @@ -156,6 +167,8 @@ async def async_step_user( info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" + except SslError: + errors["base"] = "ssl_error" except InvalidHost: errors["base"] = "invalid_host" except InvalidAuth: @@ -291,6 +304,8 @@ async def async_step_reauth_confirm( await validate_input(self.hass, new_data) except CannotConnect: errors["base"] = "cannot_connect" + except SslError: + errors["base"] = "ssl_error" except InvalidAuth: errors[CONF_PASSWORD] = "invalid_auth" else: @@ -368,5 +383,9 @@ class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" +class SslError(HomeAssistantError): + """Error to indicate a TLS/SSL handshake failure.""" + + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 9a0acf73601857..69ca68aac71f63 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -69,13 +69,12 @@ CONF_IGNORE_STRING = "ignore_string" CONF_SENSOR_STRING = "sensor_string" CONF_VAR_SENSOR_STRING = "variable_sensor_string" -CONF_TLS_VER = "tls" CONF_RESTORE_LIGHT_STATE = "restore_light_state" DEFAULT_IGNORE_STRING = "{IGNORE ME}" DEFAULT_SENSOR_STRING = "sensor" DEFAULT_RESTORE_LIGHT_STATE = False -DEFAULT_TLS_VERSION = 1.1 +DEFAULT_VERIFY_SSL = False DEFAULT_PROGRAM_STRING = "HA." DEFAULT_VAR_SENSOR_STRING = "HA." diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index d34afac96f5ab6..02aa667602ae4d 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.5.1"], + "requirements": ["pyisy==3.6.1"], "ssdp": [ { "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index f7c8aa228386b5..ef516bfb64f8ee 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -9,6 +9,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_host": "The host entry was not in full URL format, e.g., {sample_ip}", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "ssl_error": "TLS handshake failed. The controller may require a newer TLS version, or SSL verification may be failing due to a self-signed certificate.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name} ({host})", @@ -25,8 +26,11 @@ "data": { "host": "[%key:common::config_flow::data::url%]", "password": "[%key:common::config_flow::data::password%]", - "tls": "The TLS version of the ISY controller.", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "verify_ssl": "Verify the controller's TLS certificate. Leave disabled for ISY-994/eisy/Polisy controllers using their default self-signed certificate." }, "description": "The host entry must be in full URL format, e.g., {sample_ip}", "title": "Connect to your ISY" diff --git a/requirements_all.txt b/requirements_all.txt index 5d9b6962335fbe..70f2d99c343e70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2240,7 +2240,7 @@ pyiskra==0.1.27 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.5.1 +pyisy==3.6.1 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a00f2d80d41a3..d8fef5899d16e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,7 +1929,7 @@ pyiskra==0.1.27 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.5.1 +pyisy==3.6.1 # homeassistant.components.ituran pyituran==0.1.5 diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index f9cbfec90c1f7a..ae226c423e927c 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -1,19 +1,21 @@ """Test the Universal Devices ISY/IoX config flow.""" import re -from unittest.mock import patch +import ssl +from unittest.mock import AsyncMock, patch +import aiohttp from pyisy import ISYConnectionError, ISYInvalidAuthError +import pytest from homeassistant import config_entries from homeassistant.components.isy994.const import ( - CONF_TLS_VER, DOMAIN, ISY_URL_POSTFIX, UDN_UUID_PREFIX, ) from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IGNORE, SOURCE_SSDP -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -30,7 +32,7 @@ MOCK_PASSWORD = "test-password" # Don't use the integration defaults here to make sure they're being set correctly. -MOCK_TLS_VERSION = 1.2 +MOCK_VERIFY_SSL = True MOCK_IGNORE_STRING = "{IGNOREME}" MOCK_RESTORE_LIGHT_STATE = True MOCK_SENSOR_STRING = "IMASENSOR" @@ -40,13 +42,13 @@ CONF_HOST: f"http://{MOCK_HOSTNAME}", CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, - CONF_TLS_VER: MOCK_TLS_VERSION, + CONF_VERIFY_SSL: MOCK_VERIFY_SSL, } MOCK_IOX_USER_INPUT = { CONF_HOST: f"http://{MOCK_HOSTNAME}:8080", CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, - CONF_TLS_VER: MOCK_TLS_VERSION, + CONF_VERIFY_SSL: MOCK_VERIFY_SSL, } MOCK_DEVICE_NAME = "Name of the device" @@ -124,10 +126,10 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": MOCK_HOSTNAME, # Test with missing protocol (http://) - "username": MOCK_USERNAME, - "password": MOCK_PASSWORD, - "tls": MOCK_TLS_VERSION, + CONF_HOST: MOCK_HOSTNAME, # Test with missing protocol (http://) + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_VERIFY_SSL: MOCK_VERIFY_SSL, }, ) @@ -189,6 +191,66 @@ async def test_form_isy_connection_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_isy_ssl_error(hass: HomeAssistant) -> None: + """Test we surface ssl_error when pyisy chains an aiohttp.ClientSSLError. + + Uses an HTTPS URL so the HTTPS session branch (which honors verify_ssl) + is also exercised. + """ + ssl_cause = aiohttp.ClientSSLError( + connection_key=None, os_error=ssl.SSLError("handshake failed") + ) + isy_error = ISYConnectionError("ssl handshake failed") + isy_error.__cause__ = ssl_cause + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch(PATCH_CONNECTION, side_effect=isy_error): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**MOCK_USER_INPUT, CONF_HOST: f"https://{MOCK_HOSTNAME}"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "ssl_error"} + + +@pytest.mark.parametrize("verify_ssl", [True, False]) +async def test_form_forwards_verify_ssl_to_connection( + hass: HomeAssistant, verify_ssl: bool +) -> None: + """Test verify_ssl is forwarded to the pyisy Connection used during validation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + f"{INTEGRATION}.config_flow.Connection", + ) as mock_connection_cls, + patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ), + ): + mock_connection_cls.return_value.test_connection = AsyncMock( + return_value=MOCK_CONFIG_RESPONSE + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **MOCK_USER_INPUT, + CONF_HOST: f"https://{MOCK_HOSTNAME}", + CONF_VERIFY_SSL: verify_ssl, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert mock_connection_cls.call_args.kwargs["verify_ssl"] is verify_ssl + + async def test_form_isy_parse_response_error(hass: HomeAssistant) -> None: """Test we handle poorly formatted XML response from ISY.""" result = await hass.config_entries.flow.async_init( @@ -641,6 +703,7 @@ async def test_reauth(hass: HomeAssistant) -> None: data={ CONF_USERNAME: "bob", CONF_HOST: f"http://{MOCK_HOSTNAME}:1443{ISY_URL_POSTFIX}", + CONF_VERIFY_SSL: False, }, unique_id=MOCK_UUID, ) @@ -681,6 +744,22 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "cannot_connect"} + ssl_error = ISYConnectionError("ssl handshake failed") + ssl_error.__cause__ = aiohttp.ClientSSLError( + connection_key=None, os_error=ssl.SSLError("handshake failed") + ) + with patch(PATCH_CONNECTION, side_effect=ssl_error): + result_ssl = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result_ssl["type"] is FlowResultType.FORM + assert result_ssl["errors"] == {"base": "ssl_error"} + with ( patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( @@ -689,7 +768,7 @@ async def test_reauth(hass: HomeAssistant) -> None: ) as mock_setup_entry, ): result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result_ssl["flow_id"], { CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", diff --git a/tests/components/isy994/test_init.py b/tests/components/isy994/test_init.py new file mode 100644 index 00000000000000..2cd003ce5d835b --- /dev/null +++ b/tests/components/isy994/test_init.py @@ -0,0 +1,73 @@ +"""Test the Universal Devices ISY/IoX integration init.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.isy994.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_UUID = "ce:fb:72:31:b7:b9" + + +async def test_migrate_minor_version_drops_tls( + hass: HomeAssistant, +) -> None: + """Test minor migration drops legacy "tls" and seeds verify_ssl.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=1, + data={ + CONF_HOST: "http://1.1.1.1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + "tls": 1.1, + }, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert "tls" not in entry.data + assert entry.data[CONF_VERIFY_SSL] is False + + +@pytest.mark.parametrize("verify_ssl", [True, False]) +async def test_setup_forwards_verify_ssl_to_pyisy( + hass: HomeAssistant, + mock_isy: MagicMock, + verify_ssl: bool, +) -> None: + """Test the verify_ssl entry option is forwarded to the pyisy ISY constructor.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=2, + data={ + CONF_HOST: "https://1.1.1.1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: verify_ssl, + }, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.isy994.ISY", return_value=mock_isy + ) as isy_constructor: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert isy_constructor.call_args.kwargs["verify_ssl"] is verify_ssl