From 312af53935a1bffd58b3b35e82e31292a6ec22ad Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 28 Mar 2020 23:02:29 -0500 Subject: [PATCH] Handle Plex certificate updates (#33230) * Handle Plex certificate updates * Use exception in place * Add test --- homeassistant/components/plex/__init__.py | 14 ++++++- homeassistant/components/plex/errors.py | 4 ++ homeassistant/components/plex/server.py | 38 +++++++++++++++++-- tests/components/plex/test_init.py | 46 ++++++++++++++++++++++- 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 9d74ed8cb75..a73111793a7 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -46,6 +46,7 @@ from .const import ( SERVERS, WEBSOCKETS, ) +from .errors import ShouldUpdateConfigEntry from .server import PlexServer MEDIA_PLAYER_SCHEMA = vol.All( @@ -129,9 +130,20 @@ async def async_setup_entry(hass, entry): ) hass.config_entries.async_update_entry(entry, options=options) - plex_server = PlexServer(hass, server_config, entry.options) + plex_server = PlexServer( + hass, server_config, entry.data[CONF_SERVER_IDENTIFIER], entry.options + ) try: await hass.async_add_executor_job(plex_server.connect) + except ShouldUpdateConfigEntry: + new_server_data = { + **entry.data[PLEX_SERVER_CONFIG], + CONF_URL: plex_server.url_in_use, + CONF_SERVER: plex_server.friendly_name, + } + hass.config_entries.async_update_entry( + entry, data={**entry.data, PLEX_SERVER_CONFIG: new_server_data} + ) except requests.exceptions.ConnectionError as error: _LOGGER.error( "Plex server (%s) could not be reached: [%s]", diff --git a/homeassistant/components/plex/errors.py b/homeassistant/components/plex/errors.py index 11c15404f45..534c553d45e 100644 --- a/homeassistant/components/plex/errors.py +++ b/homeassistant/components/plex/errors.py @@ -12,3 +12,7 @@ class NoServersFound(PlexException): class ServerNotSpecified(PlexException): """Multiple servers linked to account without choice provided.""" + + +class ShouldUpdateConfigEntry(PlexException): + """Config entry data is out of date and should be updated.""" diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 788c96e15d2..196968cc097 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -1,5 +1,7 @@ """Shared class to maintain Plex server instances.""" import logging +import ssl +from urllib.parse import urlparse import plexapi.myplex import plexapi.playqueue @@ -26,7 +28,7 @@ from .const import ( X_PLEX_PRODUCT, X_PLEX_VERSION, ) -from .errors import NoServersFound, ServerNotSpecified +from .errors import NoServersFound, ServerNotSpecified, ShouldUpdateConfigEntry _LOGGER = logging.getLogger(__name__) @@ -40,7 +42,7 @@ plexapi.X_PLEX_VERSION = X_PLEX_VERSION class PlexServer: """Manages a single Plex server connection.""" - def __init__(self, hass, server_config, options=None): + def __init__(self, hass, server_config, known_server_id=None, options=None): """Initialize a Plex server instance.""" self._hass = hass self._plex_server = None @@ -50,6 +52,7 @@ class PlexServer: self._token = server_config.get(CONF_TOKEN) self._server_name = server_config.get(CONF_SERVER) self._verify_ssl = server_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + self._server_id = known_server_id self.options = options self.server_choice = None self._accounts = [] @@ -64,6 +67,7 @@ class PlexServer: def connect(self): """Connect to a Plex server directly, obtaining direct URL if necessary.""" + config_entry_update_needed = False def _connect_with_token(): account = plexapi.myplex.MyPlexAccount(token=self._token) @@ -92,8 +96,33 @@ class PlexServer: self._url, self._token, session ) + def _update_plexdirect_hostname(): + account = plexapi.myplex.MyPlexAccount(token=self._token) + matching_server = [ + x.name + for x in account.resources() + if x.clientIdentifier == self._server_id + ][0] + self._plex_server = account.resource(matching_server).connect(timeout=10) + if self._url: - _connect_with_url() + try: + _connect_with_url() + except requests.exceptions.SSLError as error: + while error and not isinstance(error, ssl.SSLCertVerificationError): + error = error.__context__ # pylint: disable=no-member + if isinstance(error, ssl.SSLCertVerificationError): + domain = urlparse(self._url).netloc.split(":")[0] + if domain.endswith("plex.direct") and error.args[0].startswith( + f"hostname '{domain}' doesn't match" + ): + _LOGGER.warning( + "Plex SSL certificate's hostname changed, updating." + ) + _update_plexdirect_hostname() + config_entry_update_needed = True + else: + raise else: _connect_with_token() @@ -113,6 +142,9 @@ class PlexServer: self._version = self._plex_server.version + if config_entry_update_needed: + raise ShouldUpdateConfigEntry + def refresh_entity(self, machine_identifier, device, session): """Forward refresh dispatch to media_player.""" unique_id = f"{self.machine_identifier}:{machine_identifier}" diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 3358ac1c2cb..387ce6cac03 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -1,6 +1,7 @@ """Tests for Plex setup.""" import copy from datetime import timedelta +import ssl from asynctest import patch import plexapi @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_TOKEN, + CONF_URL, CONF_VERIFY_SSL, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -26,7 +28,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN -from .mock_classes import MockPlexServer +from .mock_classes import MockPlexAccount, MockPlexServer from tests.common import MockConfigEntry, async_fire_time_changed @@ -300,3 +302,45 @@ async def test_setup_with_photo_session(hass): sensor = hass.states.get("sensor.plex_plex_server_1") assert sensor.state == str(len(mock_plex_server.accounts)) + + +async def test_setup_when_certificate_changed(hass): + """Test setup component when the Plex certificate has changed.""" + + old_domain = "1-2-3-4.1234567890abcdef1234567890abcdef.plex.direct" + old_url = f"https://{old_domain}:32400" + + OLD_HOSTNAME_DATA = copy.deepcopy(DEFAULT_DATA) + OLD_HOSTNAME_DATA[const.PLEX_SERVER_CONFIG][CONF_URL] = old_url + + class WrongCertHostnameException(requests.exceptions.SSLError): + """Mock the exception showing a mismatched hostname.""" + + def __init__(self): + self.__context__ = ssl.SSLCertVerificationError( + f"hostname '{old_domain}' doesn't match" + ) + + old_entry = MockConfigEntry( + domain=const.DOMAIN, + data=OLD_HOSTNAME_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + new_entry = MockConfigEntry(domain=const.DOMAIN, data=DEFAULT_DATA) + + with patch( + "plexapi.server.PlexServer", side_effect=WrongCertHostnameException + ), patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + old_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(old_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert old_entry.state == ENTRY_STATE_LOADED + + assert ( + old_entry.data[const.PLEX_SERVER_CONFIG][CONF_URL] + == new_entry.data[const.PLEX_SERVER_CONFIG][CONF_URL] + )