diff --git a/.coveragerc b/.coveragerc index 1d861d69c1dfe1083b4831636c35c583f7422276..02d59b55f5f2a1f35ebf0584557baf80cd32ed40 100644 --- a/.coveragerc +++ b/.coveragerc @@ -93,6 +93,7 @@ omit = homeassistant/components/canary/camera.py homeassistant/components/cast/* homeassistant/components/cert_expiry/sensor.py + homeassistant/components/cert_expiry/helper.py homeassistant/components/channels/media_player.py homeassistant/components/cisco_ios/device_tracker.py homeassistant/components/cisco_mobility_express/device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index 3ede39518c18742181e1ced6132b4599ffde0c7f..d51031486ef7cc866adcb0e848315616fd9371a2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -46,6 +46,7 @@ homeassistant/components/broadlink/* @danielhiversen homeassistant/components/brunt/* @eavanvalkenburg homeassistant/components/bt_smarthub/* @jxwolstenholme homeassistant/components/buienradar/* @mjj4791 @ties +homeassistant/components/cert_expiry/* @cereal2nd homeassistant/components/cisco_ios/* @fbradyirl homeassistant/components/cisco_mobility_express/* @fbradyirl homeassistant/components/cisco_webex_teams/* @fbradyirl diff --git a/homeassistant/components/cert_expiry/.translations/en.json b/homeassistant/components/cert_expiry/.translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..b6aa1cefb02aedb827ed169a74a55bd9a9cc6b36 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "This host and port combination is already configured" + }, + "error": { + "certificate_fetch_failed": "Can not fetch certificate from this host and port combination", + "connection_timeout": "Timeout whemn connecting to this host", + "host_port_exists": "This host and port combination is already configured", + "resolve_failed": "This host can not be resolved" + }, + "step": { + "user": { + "data": { + "host": "The hostname of the certificate", + "name": "The name of the certificate", + "port": "The port of the certificate" + }, + "title": "Define the certificate to test" + } + }, + "title": "Certificate Expiry" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 78ceb60dd404b72d8d42a25be4d5a34ccad95519..ab68d5ba08bc4387336f6a07ba5c91d6b96afd4c 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1 +1,25 @@ """The cert_expiry component.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType + + +async def async_setup(hass, config): + """Platform setup, do nothing.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Load the saved entities.""" + + @callback + def async_start(_): + """Load the entry after the start event.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_start) + + return True diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..dd3463fff95c4ee6830c2f07f512e13015680e76 --- /dev/null +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for the Cert Expiry platform.""" +import socket +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PORT, CONF_NAME, CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .const import DOMAIN, DEFAULT_PORT, DEFAULT_NAME +from .helper import get_cert + + +@callback +def certexpiry_entries(hass: HomeAssistant): + """Return the host,port tuples for the domain.""" + return set( + (entry.data[CONF_HOST], entry.data[CONF_PORT]) + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors = {} + + def _prt_in_configuration_exists(self, user_input) -> bool: + """Return True if host, port combination exists in configuration.""" + host = user_input[CONF_HOST] + port = user_input.get(CONF_PORT, DEFAULT_PORT) + if (host, port) in certexpiry_entries(self.hass): + return True + return False + + def _test_connection(self, user_input=None): + """Test connection to the server and try to get the certtificate.""" + try: + get_cert(user_input[CONF_HOST], user_input.get(CONF_PORT, DEFAULT_PORT)) + return True + except socket.gaierror: + self._errors[CONF_HOST] = "resolve_failed" + except socket.timeout: + self._errors[CONF_HOST] = "connection_timeout" + except OSError: + self._errors[CONF_HOST] = "certificate_fetch_failed" + return False + + async def async_step_user(self, user_input=None): + """Step when user intializes a integration.""" + self._errors = {} + if user_input is not None: + # set some defaults in case we need to return to the form + if self._prt_in_configuration_exists(user_input): + self._errors[CONF_HOST] = "host_port_exists" + else: + if self._test_connection(user_input): + host = user_input[CONF_HOST] + name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) + prt = user_input.get(CONF_PORT, DEFAULT_PORT) + return self.async_create_entry( + title=name, data={CONF_HOST: host, CONF_PORT: prt} + ) + else: + user_input = {} + user_input[CONF_NAME] = DEFAULT_NAME + user_input[CONF_HOST] = "" + user_input[CONF_PORT] = DEFAULT_PORT + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, + vol.Required( + CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT) + ): int, + } + ), + errors=self._errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry. + + Only host was required in the yaml file all other fields are optional + """ + if self._prt_in_configuration_exists(user_input): + return self.async_abort(reason="host_port_exists") + return await self.async_step_user(user_input) diff --git a/homeassistant/components/cert_expiry/const.py b/homeassistant/components/cert_expiry/const.py new file mode 100644 index 0000000000000000000000000000000000000000..4129781f2a0fb2718e8f8599d8cf49995249c2b5 --- /dev/null +++ b/homeassistant/components/cert_expiry/const.py @@ -0,0 +1,6 @@ +"""Const for Cert Expiry.""" + +DOMAIN = "cert_expiry" +DEFAULT_NAME = "SSL Certificate Expiry" +DEFAULT_PORT = 443 +TIMEOUT = 10.0 diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py new file mode 100644 index 0000000000000000000000000000000000000000..9c10887293adbd5e8325b121c53a74aaf8b8ac4c --- /dev/null +++ b/homeassistant/components/cert_expiry/helper.py @@ -0,0 +1,15 @@ +"""Helper functions for the Cert Expiry platform.""" +import socket +import ssl + +from .const import TIMEOUT + + +def get_cert(host, port): + """Get the ssl certificate for the host and port combination.""" + ctx = ssl.create_default_context() + address = (host, port) + with socket.create_connection(address, timeout=TIMEOUT) as sock: + with ctx.wrap_socket(sock, server_hostname=address[0]) as ssock: + cert = ssock.getpeercert() + return cert diff --git a/homeassistant/components/cert_expiry/manifest.json b/homeassistant/components/cert_expiry/manifest.json index 7ef2e0b7d105d6827afd5d9619c5efc5d6992fe6..781f27afb5f115f74a8a775fdcbe3e2892c2c4d9 100644 --- a/homeassistant/components/cert_expiry/manifest.json +++ b/homeassistant/components/cert_expiry/manifest.json @@ -1,8 +1,9 @@ { - "domain": "cert_expiry", - "name": "Cert expiry", - "documentation": "https://www.home-assistant.io/components/cert_expiry", - "requirements": [], - "dependencies": [], - "codeowners": [] + "domain": "cert_expiry", + "name": "Cert expiry", + "documentation": "https://www.home-assistant.io/components/cert_expiry", + "requirements": [], + "config_flow": true, + "dependencies": [], + "codeowners": ["@cereal2nd"] } diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index b1e0d819358e0d9fb81c53c6f9b62c67b6138a7e..fccfb295c0fff2c6160d0c13e78b1efef35ab374 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -7,24 +7,18 @@ from datetime import datetime, timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_NAME, - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_START, -) +from homeassistant.const import CONF_NAME, CONF_HOST, CONF_PORT from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, DEFAULT_NAME, DEFAULT_PORT +from .helper import get_cert -DEFAULT_NAME = "SSL Certificate Expiry" -DEFAULT_PORT = 443 +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(hours=12) -TIMEOUT = 10.0 - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -34,22 +28,22 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up certificate expiry sensor.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config) + ) + ) - def run_setup(event): - """Wait until Home Assistant is fully initialized before creating. - Delay the setup until Home Assistant is fully initialized. - """ - server_name = config.get(CONF_HOST) - server_port = config.get(CONF_PORT) - sensor_name = config.get(CONF_NAME) - - add_entities([SSLCertificate(sensor_name, server_name, server_port)], True) - - # To allow checking of the HA certificate we must first be running. - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) +async def async_setup_entry(hass, entry, async_add_entities): + """Add cert-expiry entry.""" + async_add_entities( + [SSLCertificate(entry.title, entry.data[CONF_HOST], entry.data[CONF_PORT])], + True, + ) + return True class SSLCertificate(Entity): @@ -90,13 +84,8 @@ class SSLCertificate(Entity): def update(self): """Fetch the certificate information.""" - ctx = ssl.create_default_context() try: - address = (self.server_name, self.server_port) - with socket.create_connection(address, timeout=TIMEOUT) as sock: - with ctx.wrap_socket(sock, server_hostname=address[0]) as ssock: - cert = ssock.getpeercert() - + cert = get_cert(self.server_name, self.server_port) except socket.gaierror: _LOGGER.error("Cannot resolve hostname: %s", self.server_name) self._available = False diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..8943643e8b392e2f22519ea7ed80b4abcaf08d6d --- /dev/null +++ b/homeassistant/components/cert_expiry/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "title": "Certificate Expiry", + "step": { + "user": { + "title": "Define the certificate to test", + "data": { + "name": "The name of the certificate", + "host": "The hostname of the certificate", + "port": "The port of the certificate" + } + } + }, + "error": { + "host_port_exists": "This host and port combination is already configured", + "resolve_failed": "This host can not be resolved", + "connection_timeout": "Timeout whemn connecting to this host", + "certificate_fetch_failed": "Can not fetch certificate from this host and port combination" + }, + "abort": { + "host_port_exists": "This host and port combination is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index de665ecf5a6e44d9b771d8b2493ff8c6373ca2c4..082e0f853f87ceec38b417bd4ef5b0ee75919d59 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -10,6 +10,7 @@ FLOWS = [ "ambient_station", "axis", "cast", + "cert_expiry", "daikin", "deconz", "dialogflow", diff --git a/tests/components/cert_expiry/__init__.py b/tests/components/cert_expiry/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5ef5adee2e22911c8aa888e9dd67c746c2d8f5ce --- /dev/null +++ b/tests/components/cert_expiry/__init__.py @@ -0,0 +1 @@ +"""Tests for the Cert Expiry component.""" diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..f8c99496a563eb55e1e010c31501394282bd9fc1 --- /dev/null +++ b/tests/components/cert_expiry/test_config_flow.py @@ -0,0 +1,137 @@ +"""Tests for the Cert Expiry config flow.""" +import pytest +import socket +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.cert_expiry import config_flow +from homeassistant.components.cert_expiry.const import DEFAULT_PORT +from homeassistant.const import CONF_PORT, CONF_NAME, CONF_HOST + +from tests.common import MockConfigEntry + +NAME = "Cert Expiry test 1 2 3" +PORT = 443 +HOST = "example.com" + + +@pytest.fixture(name="test_connect") +def mock_controller(): + """Mock a successfull _prt_in_configuration_exists.""" + with patch( + "homeassistant.components.cert_expiry.config_flow.CertexpiryConfigFlow._test_connection", + return_value=True, + ): + yield + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.CertexpiryConfigFlow() + flow.hass = hass + return flow + + +async def test_user(hass, test_connect): + """Test user config.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # tets with all provided + 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"] == "cert_expiry_test_1_2_3" + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + +async def test_import(hass, test_connect): + """Test import step.""" + flow = init_config_flow(hass) + + # import with only host + result = await flow.async_step_import({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "ssl_certificate_expiry" + 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}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "cert_expiry_test_1_2_3" + 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}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "ssl_certificate_expiry" + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + # import with all + result = await flow.async_step_import( + {CONF_HOST: HOST, CONF_PORT: PORT, CONF_NAME: NAME} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "cert_expiry_test_1_2_3" + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + +async def test_abort_if_already_setup(hass, test_connect): + """Test we abort if the cert is already setup.""" + flow = init_config_flow(hass) + MockConfigEntry( + domain="cert_expiry", + data={CONF_PORT: DEFAULT_PORT, CONF_NAME: NAME, CONF_HOST: HOST}, + ).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} + ) + 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} + ) + 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} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "cert_expiry_test_1_2_3" + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == 888 + + +async def test_abort_on_socket_failed(hass): + """Test we abort of we have errors during socket creation.""" + flow = init_config_flow(hass) + + with patch("socket.create_connection", side_effect=socket.gaierror()): + result = await flow.async_step_user({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "resolve_failed"} + + with patch("socket.create_connection", side_effect=socket.timeout()): + result = await flow.async_step_user({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "connection_timeout"} + + with patch("socket.create_connection", side_effect=OSError()): + result = await flow.async_step_user({CONF_HOST: HOST}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "certificate_fetch_failed"}