diff --git a/.coveragerc b/.coveragerc index 35c47de41608c..063039fca1630 100644 --- a/.coveragerc +++ b/.coveragerc @@ -108,8 +108,10 @@ omit = homeassistant/components/canary/alarm_control_panel.py homeassistant/components/canary/camera.py homeassistant/components/cast/* - homeassistant/components/cert_expiry/sensor.py + homeassistant/components/cert_expiry/__init__.py + homeassistant/components/cert_expiry/errors.py homeassistant/components/cert_expiry/helper.py + homeassistant/components/cert_expiry/sensor.py homeassistant/components/channels/* homeassistant/components/cisco_ios/device_tracker.py homeassistant/components/cisco_mobility_express/device_tracker.py diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index f3bd2f07d6368..a60c830231c78 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -56,15 +56,31 @@ async def _test_connection(self, user_input=None): except socket.timeout: _LOGGER.error("Timed out connecting to %s", host) self._errors[CONF_HOST] = "connection_timeout" + except ConnectionRefusedError: + _LOGGER.error("Connection refused: %s", host) + self._errors[CONF_HOST] = "connection_refused" except ssl.CertificateError as err: - if "doesn't match" in err.args[0]: + if "Hostname mismatch" in err.verify_message: _LOGGER.error("Certificate does not match host: %s", host) self._errors[CONF_HOST] = "wrong_host" + elif "certificate has expired" in err.verify_message: + _LOGGER.error("Certificate has expired: %s", host) + self._errors[CONF_HOST] = "certificate_expired" + elif "self signed certificate in certificate chain" in err.verify_message: + _LOGGER.error("Bad root in certificate chain: %s", host) + self._errors[CONF_HOST] = "certificate_badroot" + elif "self signed certificate" in err.verify_message: + _LOGGER.error("Self-signed certificate: %s", host) + self._errors[CONF_HOST] = "certificate_selfsigned" else: - _LOGGER.error("Certificate could not be validated: %s", host) + _LOGGER.error( + "Certificate could not be validated: %s [%s]", + host, + err.verify_message, + ) self._errors[CONF_HOST] = "certificate_error" except ssl.SSLError: - _LOGGER.error("Certificate could not be validated: %s", host) + _LOGGER.exception("Certificate could not be validated: %s", host) self._errors[CONF_HOST] = "certificate_error" return False diff --git a/homeassistant/components/cert_expiry/errors.py b/homeassistant/components/cert_expiry/errors.py new file mode 100644 index 0000000000000..0d8622a22e1f7 --- /dev/null +++ b/homeassistant/components/cert_expiry/errors.py @@ -0,0 +1,14 @@ +"""Errors for the cert_expiry integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class CertExpiryException(HomeAssistantError): + """Base class for cert_expiry exceptions.""" + + +class TemporaryFailure(CertExpiryException): + """Temporary failure has occurred.""" + + +class PermanentFailure(CertExpiryException): + """Permanent failure has occurred.""" diff --git a/homeassistant/components/cert_expiry/manifest.json b/homeassistant/components/cert_expiry/manifest.json index dc26006d711d2..f2203876075b2 100644 --- a/homeassistant/components/cert_expiry/manifest.json +++ b/homeassistant/components/cert_expiry/manifest.json @@ -5,5 +5,6 @@ "requirements": [], "config_flow": true, "dependencies": [], + "after_dependencies": ["http"], "codeowners": ["@Cereal2nd", "@jjlawren"] } diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 3a76575dfddd3..e2920babd5e3e 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -17,8 +17,11 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .errors import PermanentFailure, TemporaryFailure from .helper import get_cert _LOGGER = logging.getLogger(__name__) @@ -71,12 +74,23 @@ def __init__(self, sensor_name, server_name, server_port): self._state = None self._available = False self._valid = False + self._retry_attempts = 0 @property def name(self): """Return the name of the sensor.""" return self._name + @property + def next_check(self): + """Return the timestamp of the next update retry attempt .""" + return dt_util.utcnow() + timedelta(seconds=self.retry_delay) + + @property + def retry_delay(self): + """Return the retry delay in seconds.""" + return int(min(2 ** (self._retry_attempts - 1) * 30, 3600)) + @property def unique_id(self): """Return a unique id for the sensor.""" @@ -116,30 +130,62 @@ def do_update(_): # Delay until HA is fully started in case we're checking our own cert. self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_update) - def update(self): + async def async_update(self): """Fetch the certificate information.""" try: - cert = get_cert(self.server_name, self.server_port) - except socket.gaierror: - _LOGGER.error("Cannot resolve hostname: %s", self.server_name) - self._available = False - self._valid = False - return - except socket.timeout: - _LOGGER.error("Connection timeout with server: %s", self.server_name) + try: + cert = await self.hass.async_add_executor_job( + get_cert, self.server_name, self.server_port + ) + except socket.gaierror: + raise TemporaryFailure("Cannot resolve hostname: %s, will retry in %ds") + except socket.timeout: + raise TemporaryFailure( + "Connection timeout with server: %s, will retry in %ds" + ) + except ConnectionRefusedError: + raise TemporaryFailure( + "Connection refused by server: %s, will retry in %ds" + ) + except ssl.CertificateError as err: + raise PermanentFailure( # pylint: disable=raising-format-tuple + "Certificate error with server: %s [%s]", err.verify_message + ) + except ssl.SSLError as err: + raise PermanentFailure( # pylint: disable=raising-format-tuple + "SSL error with server: %s [%s]", err.args[0] + ) + + except TemporaryFailure as err: + + def scheduled_update(_): + self.async_schedule_update_ha_state(True) + + _LOGGER.error(err, self.server_name, self.retry_delay) self._available = False self._valid = False + async_track_point_in_utc_time(self.hass, scheduled_update, self.next_check) + self._retry_attempts += 1 return - except (ssl.CertificateError, ssl.SSLError): + + except PermanentFailure as err: + _LOGGER.error(err.args[0], self.server_name, err.args[1]) self._available = True self._state = 0 self._valid = False return + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error checking server: %s", self.server_name) + self._available = False + self._valid = False + return + ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) timestamp = datetime.fromtimestamp(ts_seconds) expiry = timestamp - datetime.today() self._available = True + self._retry_attempts = 0 self._state = expiry.days self._valid = True diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json index e5e670d214fc4..536d859ec681f 100644 --- a/homeassistant/components/cert_expiry/strings.json +++ b/homeassistant/components/cert_expiry/strings.json @@ -14,8 +14,12 @@ "error": { "host_port_exists": "This host and port combination is already configured", "resolve_failed": "This host can not be resolved", + "connection_refused": "Connection refused when connecting to this host", "connection_timeout": "Timeout when connecting to this host", + "certificate_badroot": "Bad root in certificate chain", "certificate_error": "Certificate could not be validated", + "certificate_expired": "Certificate has expired", + "certificate_selfsigned": "Certificate is self-signed", "wrong_host": "Certificate does not match hostname" }, "abort": { diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 71005672fdb66..7b0521d90bfb6 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -17,6 +17,14 @@ HOST = "example.com" +class MockCertificateError(ssl.CertificateError): + """Mock a ssl.CertificateError exception to insert attributes.""" + + def __init__(self, msg): + """Init the mocked exception.""" + self.verify_message = msg + + @pytest.fixture(name="test_connect") def mock_controller(): """Mock a successful _prt_in_configuration_exists.""" @@ -43,9 +51,10 @@ async def test_user(hass, test_connect): assert result["step_id"] == "user" # tets with all provided - result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT} - ) + with patch("socket.create_connection"): + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == NAME assert result["data"][CONF_HOST] == HOST @@ -57,21 +66,24 @@ async def test_import(hass, test_connect): flow = init_config_flow(hass) # import with only host - result = await flow.async_step_import({CONF_HOST: HOST}) + with patch("socket.create_connection"): + result = await flow.async_step_import({CONF_HOST: HOST}) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_PORT] == DEFAULT_PORT # import with host and name - result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME}) + with patch("socket.create_connection"): + result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME}) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == NAME assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_PORT] == DEFAULT_PORT # improt with host and port - result = await flow.async_step_import({CONF_HOST: HOST, CONF_PORT: PORT}) + with patch("socket.create_connection"): + result = await flow.async_step_import({CONF_HOST: HOST, CONF_PORT: PORT}) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"][CONF_HOST] == HOST @@ -96,23 +108,26 @@ async def test_abort_if_already_setup(hass, test_connect): ).add_to_hass(hass) # Should fail, same HOST and PORT (default) - result = await flow.async_step_import( - {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT} - ) + with patch("socket.create_connection"): + result = await flow.async_step_import( + {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "host_port_exists" # Should be the same HOST and PORT (default) - result = await flow.async_step_user( - {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT} - ) + with patch("socket.create_connection"): + result = await flow.async_step_user( + {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_HOST: "host_port_exists"} # SHOULD pass, same Host diff PORT - result = await flow.async_step_import( - {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: 888} - ) + with patch("socket.create_connection"): + result = await flow.async_step_import( + {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: 888} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == NAME assert result["data"][CONF_HOST] == HOST @@ -133,16 +148,50 @@ async def test_abort_on_socket_failed(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_HOST: "connection_timeout"} + with patch("socket.create_connection", side_effect=ConnectionRefusedError): + result = await flow.async_step_user({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "connection_refused"} + with patch( "socket.create_connection", - side_effect=ssl.CertificateError(f"{HOST} doesn't match somethingelse.com"), + side_effect=MockCertificateError( + msg=f"Hostname mismatch, certificate is not valid for '{HOST}'" + ), ): result = await flow.async_step_user({CONF_HOST: HOST}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_HOST: "wrong_host"} with patch( - "socket.create_connection", side_effect=ssl.CertificateError("different error") + "socket.create_connection", + side_effect=MockCertificateError(msg="certificate has expired"), + ): + result = await flow.async_step_user({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "certificate_expired"} + + with patch( + "socket.create_connection", + side_effect=MockCertificateError( + msg="self signed certificate in certificate chain" + ), + ): + result = await flow.async_step_user({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "certificate_badroot"} + + with patch( + "socket.create_connection", + side_effect=MockCertificateError(msg="self signed certificate"), + ): + result = await flow.async_step_user({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "certificate_selfsigned"} + + with patch( + "socket.create_connection", + side_effect=MockCertificateError(msg="different error"), ): result = await flow.async_step_user({CONF_HOST: HOST}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM