From 067b188ee6f102383b3851632b876feb6933194c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 03:28:09 +0200 Subject: [PATCH 01/44] Add get_url helper, deprecate base_url --- homeassistant/components/config/core.py | 2 + homeassistant/components/http/__init__.py | 49 +-- homeassistant/config.py | 17 +- homeassistant/const.py | 2 + homeassistant/core.py | 16 +- homeassistant/helpers/network.py | 122 ++++++- homeassistant/util/network.py | 12 +- tests/components/config/test_core.py | 6 + tests/helpers/test_network.py | 395 ++++++++++++++++++++++ tests/test_config.py | 20 ++ tests/util/test_network.py | 10 + 11 files changed, 617 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index e9ceb7eac57e4..89f4edc95d65f 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -44,6 +44,8 @@ async def post(self, request): vol.Optional("unit_system"): cv.unit_system, vol.Optional("location_name"): str, vol.Optional("time_zone"): cv.time_zone, + vol.Optional("external_url"): vol.Any(cv.url, None), + vol.Optional("internal_url"): vol.Any(cv.url, None), } ) async def websocket_update_config(hass, connection, msg): diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 565f84fdb8a20..029bf70112589 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -63,29 +63,32 @@ STORAGE_VERSION = 1 -HTTP_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, - vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, - vol.Optional(CONF_BASE_URL): cv.string, - vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, - vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, - vol.Optional(CONF_SSL_KEY): cv.isfile, - vol.Optional(CONF_CORS_ORIGINS, default=[DEFAULT_CORS]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean, - vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All( - cv.ensure_list, [ip_network] - ), - vol.Optional( - CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD - ): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), - vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, - vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( - [SSL_INTERMEDIATE, SSL_MODERN] - ), - } +HTTP_SCHEMA = vol.All( + cv.deprecated(CONF_BASE_URL), + vol.Schema( + { + vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, + vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, + vol.Optional(CONF_BASE_URL): cv.string, + vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_KEY): cv.isfile, + vol.Optional(CONF_CORS_ORIGINS, default=[DEFAULT_CORS]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean, + vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All( + cv.ensure_list, [ip_network] + ), + vol.Optional( + CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD + ): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), + vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( + [SSL_INTERMEDIATE, SSL_MODERN] + ), + } + ), ) CONFIG_SCHEMA = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/config.py b/homeassistant/config.py index 56bbe76a045e2..f6591baafc78b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -27,7 +27,9 @@ CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, CONF_ELEVATION, + CONF_EXTERNAL_URL, CONF_ID, + CONF_INTERNAL_URL, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, @@ -74,10 +76,6 @@ # Configure a default setup of Home Assistant (frontend, api, etc) default_config: -# Uncomment this if you are using SSL/TLS, running in Docker container, etc. -# http: -# base_url: example.duckdns.org:8123 - # Text to speech tts: - platform: google_translate @@ -183,6 +181,8 @@ def _no_duplicate_auth_mfa_module( vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, CONF_UNIT_SYSTEM: cv.unit_system, CONF_TIME_ZONE: cv.time_zone, + vol.Optional(CONF_INTERNAL_URL): cv.url, + vol.Optional(CONF_EXTERNAL_URL): cv.url, vol.Optional(CONF_WHITELIST_EXTERNAL_DIRS): # pylint: disable=no-value-for-parameter vol.All(cv.ensure_list, [vol.IsDir()]), @@ -478,6 +478,8 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non CONF_ELEVATION, CONF_TIME_ZONE, CONF_UNIT_SYSTEM, + CONF_EXTERNAL_URL, + CONF_INTERNAL_URL, ] ): hac.config_source = SOURCE_YAML @@ -487,6 +489,8 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non (CONF_LONGITUDE, "longitude"), (CONF_NAME, "location_name"), (CONF_ELEVATION, "elevation"), + (CONF_INTERNAL_URL, "internal_url"), + (CONF_EXTERNAL_URL, "external_url"), ): if key in config: setattr(hac, attr, config[key]) @@ -529,10 +533,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non hac.units = METRIC_SYSTEM elif CONF_TEMPERATURE_UNIT in config: unit = config[CONF_TEMPERATURE_UNIT] - if unit == TEMP_CELSIUS: - hac.units = METRIC_SYSTEM - else: - hac.units = IMPERIAL_SYSTEM + hac.units = METRIC_SYSTEM if unit == TEMP_CELSIUS else IMPERIAL_SYSTEM _LOGGER.warning( "Found deprecated temperature unit in core " "configuration expected unit system. Replace '%s: %s' " diff --git a/homeassistant/const.py b/homeassistant/const.py index 2579e9a15ff10..a59230d447a4e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -88,6 +88,7 @@ CONF_EVENT_DATA = "event_data" CONF_EVENT_DATA_TEMPLATE = "event_data_template" CONF_EXCLUDE = "exclude" +CONF_EXTERNAL_URL = "external_url" CONF_FILE_PATH = "file_path" CONF_FILENAME = "filename" CONF_FOR = "for" @@ -102,6 +103,7 @@ CONF_ICON_TEMPLATE = "icon_template" CONF_ID = "id" CONF_INCLUDE = "include" +CONF_INTERNAL_URL = "internal_url" CONF_IP_ADDRESS = "ip_address" CONF_LATITUDE = "latitude" CONF_LIGHTS = "lights" diff --git a/homeassistant/core.py b/homeassistant/core.py index 4d11d970c53f8..4df24ec5f0379 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -29,6 +29,7 @@ Set, TypeVar, Union, + cast, ) import uuid @@ -84,6 +85,7 @@ fix_threading_exception_logging() T = TypeVar("T") +_UNDEF: dict = {} # pylint: disable=invalid-name CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) CALLBACK_TYPE = Callable[[], None] @@ -426,7 +428,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: # regardless of the state of the loop. if self.state == CoreState.not_running: # just ignore return - if self.state == CoreState.stopping or self.state == CoreState.final_write: + if self.state in [CoreState.stopping, CoreState.final_write]: _LOGGER.info("async_stop called twice: ignored") return if self.state == CoreState.starting: @@ -1301,6 +1303,8 @@ def __init__(self, hass: HomeAssistant) -> None: self.location_name: str = "Home" self.time_zone: datetime.tzinfo = dt_util.UTC self.units: UnitSystem = METRIC_SYSTEM + self.internal_url: Optional[str] = None + self.external_url: Optional[str] = None self.config_source: str = "default" @@ -1385,6 +1389,8 @@ def as_dict(self) -> Dict: "version": __version__, "config_source": self.config_source, "safe_mode": self.safe_mode, + "external_url": self.external_url, + "internal_url": self.internal_url, } def set_time_zone(self, time_zone_str: str) -> None: @@ -1408,6 +1414,8 @@ def _update( unit_system: Optional[str] = None, location_name: Optional[str] = None, time_zone: Optional[str] = None, + external_url: Optional[Union[str, dict]] = _UNDEF, + internal_url: Optional[Union[str, dict]] = _UNDEF, ) -> None: """Update the configuration from a dictionary.""" self.config_source = source @@ -1426,6 +1434,10 @@ def _update( self.location_name = location_name if time_zone is not None: self.set_time_zone(time_zone) + if external_url is not _UNDEF: + self.external_url = cast(Optional[str], external_url) + if internal_url is not _UNDEF: + self.internal_url = cast(Optional[str], internal_url) async def async_update(self, **kwargs: Any) -> None: """Update the configuration from a dictionary.""" @@ -1457,6 +1469,8 @@ async def async_store(self) -> None: "unit_system": self.units.name, "location_name": self.location_name, "time_zone": time_zone, + "external_url": self.external_url, + "internal_url": self.internal_url, } store = self.hass.helpers.storage.Store( diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index a446b5750776c..2dad57e2f61a9 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -5,8 +5,128 @@ import yarl from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.util.network import is_local +from homeassistant.util import get_local_ip +from homeassistant.util.network import is_ip_address, is_local, is_loopback + + +class NoURLAvailableError(HomeAssistantError): + """An URL to the Home Assistant instance is not available.""" + + +@bind_hass +@callback +def async_get_url( + hass: HomeAssistant, + *, + require_ssl: bool = False, + require_standard_port: bool = False, + allow_local: bool = True, +) -> Optional[str]: + """Get a URL to this instance.""" + # Return internal URL when available and matches requested citeria + if allow_local and hass.config.internal_url: + internal_url = yarl.URL(hass.config.internal_url.rstrip("/")) + if (not require_ssl or internal_url.scheme == "https") and ( + not require_standard_port or internal_url.is_default_port() + ): + return ( + str(internal_url) + if not internal_url.is_default_port() + else str(internal_url.with_port(None)) + ) + + # Return IP if local is allowed and it matches citeria requested. + # This cannot be used if SSL is required, as IP based will always result + # in a invalid SSL certificate. + if ( + allow_local + and not require_ssl + and hass.config.api is not None + and not hass.config.api.use_ssl + ): + ip_url = yarl.URL.build( + scheme="http", host=get_local_ip(), port=hass.config.api.port + ) + if not is_loopback(ip_address(ip_url.host)) and ( + not require_standard_port or ip_url.is_default_port() + ): + return ( + str(ip_url) + if not ip_url.is_default_port() + else str(ip_url.with_port(None)) + ) + + # Return user set external URL if it matches the requested citeria + if hass.config.external_url: + external_url = yarl.URL(hass.config.external_url.rstrip("/")) + if (not require_standard_port or external_url.is_default_port()) and ( + not require_ssl + or ( + external_url.scheme == "https" + and not is_ip_address(str(external_url.host)) + ) + ): + return ( + str(external_url) + if not external_url.is_default_port() + else str(external_url.with_port(None)) + ) + + # Fall back to the good old, deprecated `base_url`, with sanity + # This if statement can be removed after `base_url` is removed. + if hass.config.api is not None and hass.config.api.base_url: + base_url = yarl.URL(hass.config.api.base_url.rstrip("/")) + if ( + ( + not is_ip_address(str(base_url.host)) + or ( + is_ip_address(str(base_url.host)) + and not is_loopback(ip_address(base_url.host)) + ) + ) + and ( + not require_ssl + or ( + base_url.scheme == "https" and not is_ip_address(str(base_url.host)) + ) + ) + and (not require_standard_port or base_url.is_default_port()) + and ( + allow_local + or not is_ip_address(str(base_url.host)) + or ( + is_ip_address(str(base_url.host)) + and not is_local(ip_address(base_url.host)) + ) + ) + ): + return ( + str(base_url) + if not base_url.is_default_port() + else str(base_url.with_port(None)) + ) + + # Fallback to cloud as the last resort + if "cloud" in hass.config.components: + try: + return cast(str, hass.components.cloud.async_remote_ui_url()) + except hass.components.cloud.CloudNotAvailable: + pass + + # Fallback to the good old, deprecated `base_url`, + # without sanity, for full backward compatibility + # This if statement can be removed after `base_url` is removed. + if hass.config.api is not None and hass.config.api.base_url: + return ( + str(base_url) + if not base_url.is_default_port() + else str(base_url.with_port(None)) + ) + + # We have to be honest now, we have no viable option available + raise NoURLAvailableError @bind_hass diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index e4d376dc487ab..b5f7ca07969a8 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -1,5 +1,5 @@ """Network utilities.""" -from ipaddress import IPv4Address, IPv6Address, ip_network +from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network from typing import Union # RFC6890 - IP addresses of loopback interfaces @@ -39,3 +39,13 @@ def is_link_local(address: Union[IPv4Address, IPv6Address]) -> bool: def is_local(address: Union[IPv4Address, IPv6Address]) -> bool: """Check if an address is loopback or private.""" return is_loopback(address) or is_private(address) + + +def is_ip_address(address: str) -> bool: + """Check if a given string is an IP address.""" + try: + ip_address(address) + except ValueError: + return False + + return True diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 7c87d8da68926..6a7447d940956 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -58,6 +58,8 @@ async def test_websocket_core_update(hass, client): assert hass.config.location_name != "Huis" assert hass.config.units.name != CONF_UNIT_SYSTEM_IMPERIAL assert hass.config.time_zone.zone != "America/New_York" + assert hass.config.external_url != "https://www.example.com" + assert hass.config.internal_url != "http://example.com" await client.send_json( { @@ -69,6 +71,8 @@ async def test_websocket_core_update(hass, client): "location_name": "Huis", CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, "time_zone": "America/New_York", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", } ) @@ -83,6 +87,8 @@ async def test_websocket_core_update(hass, client): assert hass.config.location_name == "Huis" assert hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL assert hass.config.time_zone.zone == "America/New_York" + assert hass.config.external_url == "https://www.example.com" + assert hass.config.internal_url == "http://example.local" dt_util.set_default_time_zone(ORIG_TIME_ZONE) diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 0fef40a82f41c..615d2d275c924 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -1,12 +1,28 @@ """Test network helper.""" +import pytest + from homeassistant.components import cloud +import homeassistant.config as config_util +from homeassistant.core import HomeAssistant from homeassistant.helpers import network +from homeassistant.helpers.network import NoURLAvailableError, async_get_url from tests.async_mock import Mock, patch +@pytest.fixture(autouse=True) +def mock_local_ip(): + """Mock the call to get_local_ip to return a fake internal IP address.""" + with patch( + "homeassistant.helpers.network.get_local_ip", return_value="192.168.123.123" + ) as local_ip: + yield local_ip + + async def test_get_external_url(hass): """Test get_external_url.""" + assert network.async_get_external_url(hass) is None + hass.config.api = Mock(base_url="http://192.168.1.100:8123") assert network.async_get_external_url(hass) is None @@ -32,3 +48,382 @@ async def test_get_external_url(hass): return_value="https://example.nabu.casa", ): assert network.async_get_external_url(hass) == "https://example.nabu.casa" + + +async def test_get_url_ip(hass: HomeAssistant): + """Test getting an instance URL that only can result in an internal IP.""" + assert hass.config.external_url is None + assert hass.config.internal_url is None + + hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + + assert async_get_url(hass) == "http://192.168.123.123:8123" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_standard_port=True) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_local=False) + + hass.config.api = Mock(use_ssl=False, port=80, base_url=None) + + assert async_get_url(hass) == "http://192.168.123.123" + assert async_get_url(hass, require_standard_port=True) == "http://192.168.123.123" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_local=False) + + +async def test_get_url_internal(hass: HomeAssistant): + """Test getting an instance URL when the user has set an internal URL.""" + assert hass.config.internal_url is None + assert hass.config.external_url is None + + # Test with internal URL: http://example.local:8123 + await config_util.async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) + + assert hass.config.internal_url == "http://example.local:8123" + assert hass.config.external_url is None + + assert async_get_url(hass) == "http://example.local:8123" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_standard_port=True) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_local=False) + + # Test with internal URL: http://example.local:80/ + await config_util.async_process_ha_core_config( + hass, {"internal_url": "http://example.local:80/"}, + ) + + assert hass.config.internal_url == "http://example.local:80/" + assert async_get_url(hass) == "http://example.local" + assert async_get_url(hass, require_standard_port=True) == "http://example.local" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_local=False) + + # Test with internal url: http://example.local:8123 + await config_util.async_process_ha_core_config( + hass, {"internal_url": "https://example.local/"}, + ) + assert hass.config.internal_url == "https://example.local/" + assert async_get_url(hass) == "https://example.local" + assert async_get_url(hass, require_ssl=True) == "https://example.local" + assert async_get_url(hass, require_standard_port=True) == "https://example.local" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_local=False) + + # Test with internal URL: https://example.local:80 + await config_util.async_process_ha_core_config( + hass, {"internal_url": "https://example.local:80"}, + ) + assert hass.config.internal_url == "https://example.local:80" + assert async_get_url(hass) == "https://example.local:80" + assert async_get_url(hass, require_ssl=True) == "https://example.local:80" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_local=False) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_local=False) + + +async def test_get_url_external(hass: HomeAssistant): + """Test getting an instance URL when the user has set an external URL.""" + assert hass.config.internal_url is None + assert hass.config.external_url is None + + # Test with external URL: http://www.example.com:8123 + await config_util.async_process_ha_core_config( + hass, {"external_url": "http://www.example.com:8123"}, + ) + + assert hass.config.internal_url is None + assert hass.config.external_url == "http://www.example.com:8123" + + assert async_get_url(hass) == "http://www.example.com:8123" + assert async_get_url(hass, allow_local=False) == "http://www.example.com:8123" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_standard_port=True) + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_ssl=True) + + # Test with external URL: http://www.example.com:80/ + await config_util.async_process_ha_core_config( + hass, {"external_url": "http://www.example.com:80/"}, + ) + + assert hass.config.external_url == "http://www.example.com:80/" + assert async_get_url(hass) == "http://www.example.com" + assert async_get_url(hass, require_standard_port=True) == "http://www.example.com" + assert async_get_url(hass, allow_local=False) == "http://www.example.com" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_ssl=True) + + # Test with external url: https://www.example.com/ + await config_util.async_process_ha_core_config( + hass, {"external_url": "https://www.example.com/"}, + ) + assert hass.config.external_url == "https://www.example.com/" + assert async_get_url(hass) == "https://www.example.com" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com" + assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" + assert async_get_url(hass, allow_local=False) == "https://www.example.com" + + # Test with external url: https://www.example.com:443 + await config_util.async_process_ha_core_config( + hass, {"external_url": "https://www.example.com:443"}, + ) + assert hass.config.external_url == "https://www.example.com:443" + assert async_get_url(hass) == "https://www.example.com" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com" + assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" + assert async_get_url(hass, allow_local=False) == "https://www.example.com" + + # Test with external URL: https://www.example.com:80 + await config_util.async_process_ha_core_config( + hass, {"external_url": "https://www.example.com:80"}, + ) + assert hass.config.external_url == "https://www.example.com:80" + assert async_get_url(hass) == "https://www.example.com:80" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com:80" + assert async_get_url(hass, allow_local=False) == "https://www.example.com:80" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_standard_port=True) + + # Test with external URL: https://192.168.0.1 + await config_util.async_process_ha_core_config( + hass, {"external_url": "https://192.168.0.1"}, + ) + assert hass.config.external_url == "https://192.168.0.1" + assert async_get_url(hass) == "https://192.168.0.1" + assert async_get_url(hass, require_standard_port=True) == "https://192.168.0.1" + assert async_get_url(hass, allow_local=False) == "https://192.168.0.1" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, require_ssl=True) + + +async def test_get_url_cloud(hass: HomeAssistant): + """Test getting an instance URL when the user has set an external URL.""" + assert hass.config.internal_url is None + assert hass.config.external_url is None + + hass.config.components.add("cloud") + + with patch.object( + hass.components.cloud, + "async_remote_ui_url", + return_value="https://example.nabu.casa", + ): + assert async_get_url(hass, allow_local=False) == "https://example.nabu.casa" + assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" + + with patch.object( + hass.components.cloud, + "async_remote_ui_url", + side_effect=cloud.CloudNotAvailable, + ): + with pytest.raises(Exception): + async_get_url(hass, allow_local=False) + + with pytest.raises(Exception): + async_get_url(hass, require_ssl=True) + + +async def test_get_url_fallback(hass: HomeAssistant): + """Test getting an instance URL using fallback scenarios.""" + assert hass.config.internal_url is None + assert hass.config.external_url is None + + # Test fallback to IP URL with internal URL set + hass.config.api = Mock(use_ssl=False, port=80, base_url=None) + await config_util.async_process_ha_core_config( + hass, {"internal_url": "https://example.local:8123"}, + ) + + assert hass.config.internal_url == "https://example.local:8123" + assert async_get_url(hass) == "https://example.local:8123" + assert async_get_url(hass, require_standard_port=True) == "http://192.168.123.123" + + # Test not falling back to IP URL + hass.config.api = Mock(use_ssl=True, port=443, base_url=None) + await config_util.async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) + assert async_get_url(hass) == "http://example.local:8123" + with pytest.raises(Exception): + async_get_url(hass, require_ssl=True) + + hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + with pytest.raises(Exception): + async_get_url(hass, require_standard_port=True) + + # Test falling back to external URL + hass.config.api = Mock(use_ssl=True, port=443, base_url=None) + await config_util.async_process_ha_core_config( + hass, + { + "internal_url": "http://example.local:8123", + "external_url": "https://www.example.com:8123", + }, + ) + assert hass.config.internal_url == "http://example.local:8123" + assert hass.config.external_url == "https://www.example.com:8123" + assert async_get_url(hass) == "http://example.local:8123" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" + + with pytest.raises(Exception): + async_get_url(hass, require_standard_port=True) + + await config_util.async_process_ha_core_config( + hass, + { + "internal_url": "http://example.local:8123", + "external_url": "https://www.example.com", + }, + ) + assert hass.config.internal_url == "http://example.local:8123" + assert hass.config.external_url == "https://www.example.com" + assert async_get_url(hass) == "http://example.local:8123" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com" + assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" + assert async_get_url(hass, allow_local=False) == "https://www.example.com" + assert ( + async_get_url( + hass, allow_local=False, require_ssl=True, require_standard_port=True + ) + == "https://www.example.com" + ) + + # Test fallback to Cloud URL + hass.config.components.add("cloud") + await config_util.async_process_ha_core_config( + hass, + { + "internal_url": "http://example.local:8123", + "external_url": "https://www.example.com:8123", + }, + ) + assert hass.config.internal_url == "http://example.local:8123" + assert hass.config.external_url == "https://www.example.com:8123" + + with patch.object( + hass.components.cloud, + "async_remote_ui_url", + return_value="https://example.nabu.casa", + ): + assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" + assert ( + async_get_url(hass, require_standard_port=True) + == "https://example.nabu.casa" + ) + assert ( + async_get_url(hass, require_ssl=True, require_standard_port=True) + == "https://example.nabu.casa" + ) + + # Test fallback to Cloud when external URL contains an IP address + await config_util.async_process_ha_core_config( + hass, + { + "internal_url": "http://example.local:8123", + "external_url": "https://192.168.0.1", + }, + ) + assert hass.config.internal_url == "http://example.local:8123" + assert hass.config.external_url == "https://192.168.0.1" + + with patch.object( + hass.components.cloud, + "async_remote_ui_url", + return_value="https://example.nabu.casa", + ): + assert async_get_url(hass, require_standard_port=True) == "https://192.168.0.1" + assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" + assert ( + async_get_url(hass, require_ssl=True, require_standard_port=True) + == "https://example.nabu.casa" + ) + + +async def test_get_url_base_url_fallback(hass: HomeAssistant): + """Test getting an instance URL using deprecated base_url fallback scenarios.""" + assert hass.config.internal_url is None + assert hass.config.external_url is None + + hass.config.api = Mock(use_ssl=False, port=8123, base_url="https://www.example.com") + + assert async_get_url(hass) == "http://192.168.123.123:8123" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com" + assert async_get_url(hass, allow_local=False) == "https://www.example.com" + assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" + + hass.config.api = Mock( + use_ssl=False, port=8123, base_url="https://www.example.com:8123/" + ) + assert async_get_url(hass) == "http://192.168.123.123:8123" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" + assert async_get_url(hass, allow_local=False) == "https://www.example.com:8123" + # Last fallback, without sanity, to `base_url` + assert ( + async_get_url(hass, require_standard_port=True) + == "https://www.example.com:8123" + ) + + hass.config.components.add("cloud") + with patch.object( + hass.components.cloud, + "async_remote_ui_url", + return_value="https://example.nabu.casa", + ): + # Ensure without sanity fallback doesn't apply when cloud is available + assert ( + async_get_url(hass, require_standard_port=True) + == "https://example.nabu.casa" + ) + + # Ensure we fallback to cloud when SSL is needed but not in base_url + hass.config.api = Mock( + use_ssl=False, port=8123, base_url="http://www.example.com" + ) + assert async_get_url(hass, allow_local=False) == "http://www.example.com" + assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" + + # Ensure we fallback to cloud when SSL is needed but not in base_url + hass.config.api = Mock( + use_ssl=False, port=8123, base_url="https://www.example.com:8123" + ) + assert async_get_url(hass, allow_local=False) == "https://www.example.com:8123" + assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" + assert ( + async_get_url(hass, require_ssl=True, require_standard_port=True) + == "https://example.nabu.casa" + ) + + # With local base URL, with an IP address and SSL + hass.config.api = Mock(use_ssl=False, port=8123, base_url="https://192.168.0.1") + assert async_get_url(hass, allow_local=False) == "https://example.nabu.casa" + assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" diff --git a/tests/test_config.py b/tests/test_config.py index 5d9fa7d8da1a6..ab9eeb639e689 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -178,6 +178,8 @@ def test_core_config_schema(): {"time_zone": "non-exist"}, {"latitude": "91"}, {"longitude": -181}, + {"external_url": "not an url"}, + {"internal_url": "not an url"}, {"customize": "bla"}, {"customize": {"light.sensor": 100}}, {"customize": {"entity_id": []}}, @@ -190,6 +192,8 @@ def test_core_config_schema(): "name": "Test name", "latitude": "-23.45", "longitude": "123.45", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, "customize": {"sensor.temperature": {"hidden": True}}, } @@ -342,6 +346,8 @@ async def test_loading_configuration_from_storage(hass, hass_storage): "longitude": 13, "time_zone": "Europe/Copenhagen", "unit_system": "metric", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", }, "key": "core.config", "version": 1, @@ -356,6 +362,8 @@ async def test_loading_configuration_from_storage(hass, hass_storage): assert hass.config.location_name == "Home" assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC assert hass.config.time_zone.zone == "Europe/Copenhagen" + assert hass.config.external_url == "https://www.example.com" + assert hass.config.internal_url == "http://example.local" assert len(hass.config.whitelist_external_dirs) == 2 assert "/etc" in hass.config.whitelist_external_dirs assert hass.config.config_source == SOURCE_STORAGE @@ -371,6 +379,8 @@ async def test_updating_configuration(hass, hass_storage): "longitude": 13, "time_zone": "Europe/Copenhagen", "unit_system": "metric", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", }, "key": "core.config", "version": 1, @@ -428,6 +438,8 @@ async def test_loading_configuration(hass): CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, "time_zone": "America/New_York", "whitelist_external_dirs": "/etc", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", }, ) @@ -437,6 +449,8 @@ async def test_loading_configuration(hass): assert hass.config.location_name == "Huis" assert hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL assert hass.config.time_zone.zone == "America/New_York" + assert hass.config.external_url == "https://www.example.com" + assert hass.config.internal_url == "http://example.local" assert len(hass.config.whitelist_external_dirs) == 2 assert "/etc" in hass.config.whitelist_external_dirs assert hass.config.config_source == config_util.SOURCE_YAML @@ -453,6 +467,8 @@ async def test_loading_configuration_temperature_unit(hass): "name": "Huis", CONF_TEMPERATURE_UNIT: "C", "time_zone": "America/New_York", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", }, ) @@ -462,6 +478,8 @@ async def test_loading_configuration_temperature_unit(hass): assert hass.config.location_name == "Huis" assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC assert hass.config.time_zone.zone == "America/New_York" + assert hass.config.external_url == "https://www.example.com" + assert hass.config.internal_url == "http://example.local" assert hass.config.config_source == config_util.SOURCE_YAML @@ -476,6 +494,8 @@ async def test_loading_configuration_from_packages(hass): "name": "Huis", CONF_TEMPERATURE_UNIT: "C", "time_zone": "Europe/Madrid", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", "packages": { "package_1": {"wake_on_lan": None}, "package_2": { diff --git a/tests/util/test_network.py b/tests/util/test_network.py index c4c33c8d1871d..aafc72edcab49 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -38,3 +38,13 @@ def test_is_local(): assert network_util.is_local(ip_address("192.168.0.1")) assert network_util.is_local(ip_address("127.0.0.1")) assert not network_util.is_local(ip_address("208.5.4.2")) + + +def test_is_ip_address(): + """Test if strings are IP addresses.""" + assert network_util.is_ip_address("192.168.0.1") + assert network_util.is_ip_address("8.8.8.8") + assert network_util.is_ip_address("::ffff:127.0.0.0") + assert not network_util.is_ip_address("192.168.0.999") + assert not network_util.is_ip_address("192.168.0.0/24") + assert not network_util.is_ip_address("example.com") From d77404969c3937c93178b76ad1f351fe72beaba8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 13:02:05 +0200 Subject: [PATCH 02/44] Fix tests --- tests/test_core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 3221bfcd39df6..fd8349954e215 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -913,6 +913,8 @@ def test_as_dict(self): "version": __version__, "config_source": "default", "safe_mode": False, + "external_url": None, + "internal_url": None, } assert expected == self.config.as_dict() From 5ba6600adf694cfebf0260dc3ed9d3a16412e308 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 13:11:03 +0200 Subject: [PATCH 03/44] Return value is not Optional actually --- homeassistant/helpers/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 2dad57e2f61a9..d86832b342188 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -23,7 +23,7 @@ def async_get_url( require_ssl: bool = False, require_standard_port: bool = False, allow_local: bool = True, -) -> Optional[str]: +) -> str: """Get a URL to this instance.""" # Return internal URL when available and matches requested citeria if allow_local and hass.config.internal_url: From 5fda03e7d409cab0a6a8d623935010c1d527a2de Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 13:11:58 +0200 Subject: [PATCH 04/44] Add normalize_url util --- homeassistant/helpers/network.py | 43 +++++++++++--------------------- homeassistant/util/network.py | 10 ++++++++ tests/util/test_network.py | 21 ++++++++++++++++ 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index d86832b342188..4f7752e928788 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -8,7 +8,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util import get_local_ip -from homeassistant.util.network import is_ip_address, is_local, is_loopback +from homeassistant.util.network import ( + is_ip_address, + is_local, + is_loopback, + normalize_url, +) class NoURLAvailableError(HomeAssistantError): @@ -27,15 +32,11 @@ def async_get_url( """Get a URL to this instance.""" # Return internal URL when available and matches requested citeria if allow_local and hass.config.internal_url: - internal_url = yarl.URL(hass.config.internal_url.rstrip("/")) + internal_url = yarl.URL(hass.config.internal_url) if (not require_ssl or internal_url.scheme == "https") and ( not require_standard_port or internal_url.is_default_port() ): - return ( - str(internal_url) - if not internal_url.is_default_port() - else str(internal_url.with_port(None)) - ) + return normalize_url(str(internal_url)) # Return IP if local is allowed and it matches citeria requested. # This cannot be used if SSL is required, as IP based will always result @@ -52,15 +53,11 @@ def async_get_url( if not is_loopback(ip_address(ip_url.host)) and ( not require_standard_port or ip_url.is_default_port() ): - return ( - str(ip_url) - if not ip_url.is_default_port() - else str(ip_url.with_port(None)) - ) + return normalize_url(str(ip_url)) # Return user set external URL if it matches the requested citeria if hass.config.external_url: - external_url = yarl.URL(hass.config.external_url.rstrip("/")) + external_url = yarl.URL(hass.config.external_url) if (not require_standard_port or external_url.is_default_port()) and ( not require_ssl or ( @@ -68,16 +65,12 @@ def async_get_url( and not is_ip_address(str(external_url.host)) ) ): - return ( - str(external_url) - if not external_url.is_default_port() - else str(external_url.with_port(None)) - ) + return normalize_url(str(external_url)) # Fall back to the good old, deprecated `base_url`, with sanity # This if statement can be removed after `base_url` is removed. if hass.config.api is not None and hass.config.api.base_url: - base_url = yarl.URL(hass.config.api.base_url.rstrip("/")) + base_url = yarl.URL(hass.config.api.base_url) if ( ( not is_ip_address(str(base_url.host)) @@ -102,11 +95,7 @@ def async_get_url( ) ) ): - return ( - str(base_url) - if not base_url.is_default_port() - else str(base_url.with_port(None)) - ) + return normalize_url(str(base_url)) # Fallback to cloud as the last resort if "cloud" in hass.config.components: @@ -119,11 +108,7 @@ def async_get_url( # without sanity, for full backward compatibility # This if statement can be removed after `base_url` is removed. if hass.config.api is not None and hass.config.api.base_url: - return ( - str(base_url) - if not base_url.is_default_port() - else str(base_url.with_port(None)) - ) + return normalize_url(str(base_url)) # We have to be honest now, we have no viable option available raise NoURLAvailableError diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index b5f7ca07969a8..94b43ad78033b 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -2,6 +2,8 @@ from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network from typing import Union +import yarl + # RFC6890 - IP addresses of loopback interfaces LOOPBACK_NETWORKS = ( ip_network("127.0.0.0/8"), @@ -49,3 +51,11 @@ def is_ip_address(address: str) -> bool: return False return True + + +def normalize_url(address: str) -> str: + """Normalize a given URL.""" + url = yarl.URL(address.rstrip("/")) + if url.is_default_port(): + return str(url.with_port(None)) + return str(url) diff --git a/tests/util/test_network.py b/tests/util/test_network.py index aafc72edcab49..2cd710e1d6c46 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -48,3 +48,24 @@ def test_is_ip_address(): assert not network_util.is_ip_address("192.168.0.999") assert not network_util.is_ip_address("192.168.0.0/24") assert not network_util.is_ip_address("example.com") + + +def test_normalize_url(): + """Test the normalizing of URLs.""" + assert network_util.normalize_url("http://example.com") == "http://example.com" + assert network_util.normalize_url("https://example.com") == "https://example.com" + assert network_util.normalize_url("https://example.com/") == "https://example.com" + assert ( + network_util.normalize_url("https://example.com:443") == "https://example.com" + ) + assert network_util.normalize_url("http://example.com:80") == "http://example.com" + assert ( + network_util.normalize_url("https://example.com:80") == "https://example.com:80" + ) + assert ( + network_util.normalize_url("http://example.com:443") == "http://example.com:443" + ) + assert ( + network_util.normalize_url("https://example.com:443/test/") + == "https://example.com/test" + ) From 9820729893ee27ccea37a664fe8979a5c66e9656 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 14:54:32 +0200 Subject: [PATCH 05/44] Fix broad exception checking in tests --- tests/helpers/test_network.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 615d2d275c924..8d42c721aee26 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -246,10 +246,10 @@ async def test_get_url_cloud(hass: HomeAssistant): "async_remote_ui_url", side_effect=cloud.CloudNotAvailable, ): - with pytest.raises(Exception): + with pytest.raises(NoURLAvailableError): async_get_url(hass, allow_local=False) - with pytest.raises(Exception): + with pytest.raises(NoURLAvailableError): async_get_url(hass, require_ssl=True) @@ -274,11 +274,11 @@ async def test_get_url_fallback(hass: HomeAssistant): hass, {"internal_url": "http://example.local:8123"}, ) assert async_get_url(hass) == "http://example.local:8123" - with pytest.raises(Exception): + with pytest.raises(NoURLAvailableError): async_get_url(hass, require_ssl=True) hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) - with pytest.raises(Exception): + with pytest.raises(NoURLAvailableError): async_get_url(hass, require_standard_port=True) # Test falling back to external URL @@ -295,7 +295,7 @@ async def test_get_url_fallback(hass: HomeAssistant): assert async_get_url(hass) == "http://example.local:8123" assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" - with pytest.raises(Exception): + with pytest.raises(NoURLAvailableError): async_get_url(hass, require_standard_port=True) await config_util.async_process_ha_core_config( From 1a9f89dee7377b5c99317945985ad126e3140298 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 16:18:37 +0200 Subject: [PATCH 06/44] Migrate cast to get_url helper --- homeassistant/components/cast/home_assistant_cast.py | 11 ++--------- tests/components/cast/test_home_assistant_cast.py | 6 ++++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index c933136d14032..d8d53d928fbc7 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -7,6 +7,7 @@ from homeassistant import auth, config_entries, core from homeassistant.const import ATTR_ENTITY_ID from homeassistant.helpers import config_validation as cv, dispatcher +from homeassistant.helpers.network import async_get_url from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW @@ -40,15 +41,7 @@ async def async_setup_ha_cast( async def handle_show_view(call: core.ServiceCall): """Handle a Show View service call.""" - hass_url = hass.config.api.base_url - - # Home Assistant Cast only works with https urls. If user has no configured - # base url, use their remote url. - if not hass_url.lower().startswith("https://"): - try: - hass_url = hass.components.cloud.async_remote_ui_url() - except hass.components.cloud.CloudNotAvailable: - pass + hass_url = async_get_url(hass, require_ssl=True) controller = HomeAssistantController( # If you are developing Home Assistant Cast, uncomment and set to your dev app id. diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py index 50685d5f3e748..f697b80137a92 100644 --- a/tests/components/cast/test_home_assistant_cast.py +++ b/tests/components/cast/test_home_assistant_cast.py @@ -56,12 +56,14 @@ async def test_service_show_view_dashboard(hass): async def test_use_cloud_url(hass): """Test that we fall back to cloud url.""" hass.config.api = Mock(base_url="http://example.com") + hass.config.components.add("cloud") + await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry()) calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW) with patch( "homeassistant.components.cloud.async_remote_ui_url", - return_value="https://something.nabu.acas", + return_value="https://something.nabu.casa", ): await hass.services.async_call( "cast", @@ -72,4 +74,4 @@ async def test_use_cloud_url(hass): assert len(calls) == 1 controller = calls[0][0] - assert controller.hass_url == "https://something.nabu.acas" + assert controller.hass_url == "https://something.nabu.casa" From 6048e703c2f51cf72757ee04da336b7cac3fecea Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 16:24:21 +0200 Subject: [PATCH 07/44] Migrate doorbird to get_url helper --- homeassistant/components/doorbird/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index b70b0a3061cf4..d903dce1d8286 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.network import async_get_url from homeassistant.util import dt as dt_util, slugify from .const import CONF_EVENTS, DOMAIN, DOOR_STATION, DOOR_STATION_INFO, PLATFORMS @@ -252,7 +253,7 @@ def token(self): def register_events(self, hass): """Register events on device.""" # Get the URL of this server - hass_url = hass.config.api.base_url + hass_url = async_get_url(hass) # Override url if another is specified in the configuration if self.custom_url is not None: From 4499eca85dd3e692ee5cd6fa75a60fd6d3213426 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 11:32:53 +0200 Subject: [PATCH 08/44] Accept optional string on is_ip_address util --- homeassistant/util/network.py | 7 +++++-- tests/util/test_network.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 94b43ad78033b..c098eeb521297 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -1,6 +1,6 @@ """Network utilities.""" from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network -from typing import Union +from typing import Optional, Union import yarl @@ -43,8 +43,11 @@ def is_local(address: Union[IPv4Address, IPv6Address]) -> bool: return is_loopback(address) or is_private(address) -def is_ip_address(address: str) -> bool: +def is_ip_address(address: Optional[str]) -> bool: """Check if a given string is an IP address.""" + if address is None: + return False + try: ip_address(address) except ValueError: diff --git a/tests/util/test_network.py b/tests/util/test_network.py index 2cd710e1d6c46..c5fa237214d8d 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -45,6 +45,7 @@ def test_is_ip_address(): assert network_util.is_ip_address("192.168.0.1") assert network_util.is_ip_address("8.8.8.8") assert network_util.is_ip_address("::ffff:127.0.0.0") + assert not network_util.is_ip_address(None) assert not network_util.is_ip_address("192.168.0.999") assert not network_util.is_ip_address("192.168.0.0/24") assert not network_util.is_ip_address("example.com") From 82f23cc431d824dce3890cf002b513bdea843201 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 14:26:15 +0200 Subject: [PATCH 09/44] Adjust helper strategy --- homeassistant/helpers/network.py | 231 ++++++--- tests/helpers/test_network.py | 808 ++++++++++++++++++++----------- 2 files changed, 694 insertions(+), 345 deletions(-) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 4f7752e928788..0b1cec8ecbada 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -1,6 +1,6 @@ """Network helpers.""" from ipaddress import ip_address -from typing import Optional, cast +from typing import cast import yarl @@ -12,9 +12,13 @@ is_ip_address, is_local, is_loopback, + is_private, normalize_url, ) +TYPE_URL_INTERNAL = "internal_url" +TYPE_URL_EXTERNAL = "external_url" + class NoURLAvailableError(HomeAssistantError): """An URL to the Home Assistant instance is not available.""" @@ -27,25 +31,83 @@ def async_get_url( *, require_ssl: bool = False, require_standard_port: bool = False, - allow_local: bool = True, + allow_internal: bool = True, + allow_external: bool = True, + allow_cloud: bool = True, + allow_ip: bool = True, + prefer_external: bool = False, + prefer_cloud: bool = False, ) -> str: """Get a URL to this instance.""" - # Return internal URL when available and matches requested citeria - if allow_local and hass.config.internal_url: + order = [TYPE_URL_INTERNAL, TYPE_URL_EXTERNAL] + if prefer_external: + order.reverse() + + # Try finding an URL in the order specified + for url_type in order: + + if allow_internal and url_type == TYPE_URL_INTERNAL: + try: + return _async_get_internal_url( + hass, + allow_ip=allow_ip, + require_ssl=require_ssl, + require_standard_port=require_standard_port, + ) + except NoURLAvailableError: + pass + + if allow_external and url_type == TYPE_URL_EXTERNAL: + try: + return _async_get_external_url( + hass, + allow_cloud=allow_cloud, + allow_ip=allow_ip, + prefer_cloud=prefer_cloud, + require_ssl=require_ssl, + require_standard_port=require_standard_port, + ) + except NoURLAvailableError: + pass + + # We have to be honest now, we have no viable option available + raise NoURLAvailableError + + +@bind_hass +@callback +def _async_get_internal_url( + hass: HomeAssistant, + *, + allow_ip: bool = True, + require_ssl: bool = False, + require_standard_port: bool = False, +) -> str: + """Get internal URL of this instance.""" + if hass.config.internal_url: internal_url = yarl.URL(hass.config.internal_url) - if (not require_ssl or internal_url.scheme == "https") and ( - not require_standard_port or internal_url.is_default_port() + if ( + (not require_ssl or internal_url.scheme == "https") + and (not require_standard_port or internal_url.is_default_port()) + and (allow_ip or not is_ip_address(internal_url.host)) ): return normalize_url(str(internal_url)) - # Return IP if local is allowed and it matches citeria requested. - # This cannot be used if SSL is required, as IP based will always result - # in a invalid SSL certificate. - if ( - allow_local - and not require_ssl - and hass.config.api is not None - and not hass.config.api.use_ssl + # Fallback to old base_url + try: + return _async_get_deprecated_base_url( + hass, + internal=True, + allow_ip=allow_ip, + require_ssl=require_ssl, + require_standard_port=require_standard_port, + ) + except NoURLAvailableError: + pass + + # Fallback to detected local IP + if allow_ip and not ( + require_ssl or hass.config.api is None or hass.config.api.use_ssl ): ip_url = yarl.URL.build( scheme="http", host=get_local_ip(), port=hass.config.api.port @@ -55,89 +117,114 @@ def async_get_url( ): return normalize_url(str(ip_url)) - # Return user set external URL if it matches the requested citeria + raise NoURLAvailableError + + +@bind_hass +@callback +def _async_get_external_url( + hass: HomeAssistant, + *, + allow_cloud: bool = True, + allow_ip: bool = True, + prefer_cloud: bool = False, + require_ssl: bool = False, + require_standard_port: bool = False, +) -> str: + """Get external URL of this instance.""" + if prefer_cloud and allow_cloud: + try: + return _async_get_cloud_url(hass) + except NoURLAvailableError: + pass + if hass.config.external_url: external_url = yarl.URL(hass.config.external_url) - if (not require_standard_port or external_url.is_default_port()) and ( - not require_ssl - or ( - external_url.scheme == "https" - and not is_ip_address(str(external_url.host)) - ) - ): - return normalize_url(str(external_url)) - - # Fall back to the good old, deprecated `base_url`, with sanity - # This if statement can be removed after `base_url` is removed. - if hass.config.api is not None and hass.config.api.base_url: - base_url = yarl.URL(hass.config.api.base_url) if ( - ( - not is_ip_address(str(base_url.host)) - or ( - is_ip_address(str(base_url.host)) - and not is_loopback(ip_address(base_url.host)) - ) - ) + (allow_ip or not is_ip_address(external_url.host)) + and (not require_standard_port or external_url.is_default_port()) and ( not require_ssl or ( - base_url.scheme == "https" and not is_ip_address(str(base_url.host)) - ) - ) - and (not require_standard_port or base_url.is_default_port()) - and ( - allow_local - or not is_ip_address(str(base_url.host)) - or ( - is_ip_address(str(base_url.host)) - and not is_local(ip_address(base_url.host)) + external_url.scheme == "https" + and not is_ip_address(external_url.host) ) ) ): - return normalize_url(str(base_url)) + return normalize_url(str(external_url)) - # Fallback to cloud as the last resort - if "cloud" in hass.config.components: + try: + return _async_get_deprecated_base_url( + hass, + allow_ip=allow_ip, + require_ssl=require_ssl, + require_standard_port=require_standard_port, + ) + except NoURLAvailableError: + pass + + if allow_cloud: try: - return cast(str, hass.components.cloud.async_remote_ui_url()) - except hass.components.cloud.CloudNotAvailable: + return _async_get_cloud_url(hass) + except NoURLAvailableError: pass - # Fallback to the good old, deprecated `base_url`, - # without sanity, for full backward compatibility - # This if statement can be removed after `base_url` is removed. - if hass.config.api is not None and hass.config.api.base_url: - return normalize_url(str(base_url)) - - # We have to be honest now, we have no viable option available raise NoURLAvailableError @bind_hass @callback -def async_get_external_url(hass: HomeAssistant) -> Optional[str]: - """Get external url of this instance. - - Note: currently it takes 30 seconds after Home Assistant starts for - cloud.async_remote_ui_url to work. - """ +def _async_get_cloud_url(hass: HomeAssistant) -> str: + """Get external Home Assistant Cloud URL of this instance.""" if "cloud" in hass.config.components: try: return cast(str, hass.components.cloud.async_remote_ui_url()) except hass.components.cloud.CloudNotAvailable: pass - if hass.config.api is None: - return None + raise NoURLAvailableError + + +@bind_hass +@callback +def _async_get_deprecated_base_url( + hass: HomeAssistant, + *, + internal: bool = False, + allow_ip: bool = True, + require_ssl: bool = False, + require_standard_port: bool = False, +) -> str: + """Work with the deprecated `base_url`, used as fallback.""" + if hass.config.api is None or not hass.config.api.base_url: + raise NoURLAvailableError base_url = yarl.URL(hass.config.api.base_url) + # Rules that apply to both internal and external + if ( + (allow_ip or not is_ip_address(base_url.host)) + and (not require_ssl or base_url.scheme == "https") + and (not require_standard_port or base_url.is_default_port()) + ): + # Check to ensure an internal URL + if internal and ( + str(base_url.host).endswith(".local") + or ( + is_ip_address(base_url.host) + and not is_loopback(ip_address(base_url.host)) + and is_private(ip_address(base_url.host)) + ) + ): + return normalize_url(str(base_url)) - try: - if is_local(ip_address(base_url.host)): - return None - except ValueError: - # ip_address raises ValueError if host is not an IP address - pass + # Check to ensure an external URL (a little) + if ( + not internal + and not str(base_url.host).endswith(".local") + and not ( + is_ip_address(base_url.host) and is_local(ip_address(base_url.host)) + ) + ): + return normalize_url(str(base_url)) - return str(base_url) + raise NoURLAvailableError diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 8d42c721aee26..8d76a38ae7b64 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -2,10 +2,16 @@ import pytest from homeassistant.components import cloud -import homeassistant.config as config_util +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.helpers import network -from homeassistant.helpers.network import NoURLAvailableError, async_get_url +from homeassistant.helpers.network import ( + NoURLAvailableError, + _async_get_cloud_url, + _async_get_deprecated_base_url, + _async_get_external_url, + _async_get_internal_url, + async_get_url, +) from tests.async_mock import Mock, patch @@ -19,218 +25,255 @@ def mock_local_ip(): yield local_ip -async def test_get_external_url(hass): - """Test get_external_url.""" - assert network.async_get_external_url(hass) is None +async def test_get_url_internal(hass: HomeAssistant): + """Test getting an instance URL when the user has set an internal URL.""" + assert hass.config.internal_url is None - hass.config.api = Mock(base_url="http://192.168.1.100:8123") + # Test with internal URL: http://example.local:8123 + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) - assert network.async_get_external_url(hass) is None + assert hass.config.internal_url == "http://example.local:8123" + assert _async_get_internal_url(hass) == "http://example.local:8123" + assert _async_get_internal_url(hass, allow_ip=False) == "http://example.local:8123" - hass.config.api = Mock(base_url="http://example.duckdns.org:8123") + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_standard_port=True) - assert network.async_get_external_url(hass) == "http://example.duckdns.org:8123" + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_ssl=True) - hass.config.components.add("cloud") + # Test with internal URL: https://example.local:8123 + await async_process_ha_core_config( + hass, {"internal_url": "https://example.local:8123"}, + ) - assert network.async_get_external_url(hass) == "http://example.duckdns.org:8123" + assert hass.config.internal_url == "https://example.local:8123" + assert _async_get_internal_url(hass) == "https://example.local:8123" + assert _async_get_internal_url(hass, allow_ip=False) == "https://example.local:8123" + assert ( + _async_get_internal_url(hass, require_ssl=True) == "https://example.local:8123" + ) - with patch.object( - hass.components.cloud, - "async_remote_ui_url", - side_effect=cloud.CloudNotAvailable, - ): - assert network.async_get_external_url(hass) == "http://example.duckdns.org:8123" + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_standard_port=True) - with patch.object( - hass.components.cloud, - "async_remote_ui_url", - return_value="https://example.nabu.casa", - ): - assert network.async_get_external_url(hass) == "https://example.nabu.casa" + # Test with internal URL: http://example.local:80/ + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:80/"}, + ) + assert hass.config.internal_url == "http://example.local:80/" + assert _async_get_internal_url(hass) == "http://example.local" + assert _async_get_internal_url(hass, allow_ip=False) == "http://example.local" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "http://example.local" + ) -async def test_get_url_ip(hass: HomeAssistant): - """Test getting an instance URL that only can result in an internal IP.""" - assert hass.config.external_url is None - assert hass.config.internal_url is None + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_ssl=True) - hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + # Test with internal URL: https://example.local:443 + await async_process_ha_core_config( + hass, {"internal_url": "https://example.local:443"}, + ) - assert async_get_url(hass) == "http://192.168.123.123:8123" + assert hass.config.internal_url == "https://example.local:443" + assert _async_get_internal_url(hass) == "https://example.local" + assert _async_get_internal_url(hass, allow_ip=False) == "https://example.local" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "https://example.local" + ) + assert _async_get_internal_url(hass, require_ssl=True) == "https://example.local" - with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_standard_port=True) + # Test with internal URL: https://192.168.0.1 + await async_process_ha_core_config( + hass, {"internal_url": "https://192.168.0.1"}, + ) - with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + assert hass.config.internal_url == "https://192.168.0.1" + assert _async_get_internal_url(hass) == "https://192.168.0.1" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "https://192.168.0.1" + ) + assert _async_get_internal_url(hass, require_ssl=True) == "https://192.168.0.1" with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) + _async_get_internal_url(hass, allow_ip=False) - hass.config.api = Mock(use_ssl=False, port=80, base_url=None) + # Test with internal URL: http://192.168.0.1:8123 + await async_process_ha_core_config( + hass, {"internal_url": "http://192.168.0.1:8123"}, + ) - assert async_get_url(hass) == "http://192.168.123.123" - assert async_get_url(hass, require_standard_port=True) == "http://192.168.123.123" + assert hass.config.internal_url == "http://192.168.0.1:8123" + assert _async_get_internal_url(hass) == "http://192.168.0.1:8123" with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + _async_get_internal_url(hass, require_standard_port=True) with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) - + _async_get_internal_url(hass, require_ssl=True) -async def test_get_url_internal(hass: HomeAssistant): - """Test getting an instance URL when the user has set an internal URL.""" - assert hass.config.internal_url is None - assert hass.config.external_url is None + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, allow_ip=False) - # Test with internal URL: http://example.local:8123 - await config_util.async_process_ha_core_config( - hass, {"internal_url": "http://example.local:8123"}, - ) - assert hass.config.internal_url == "http://example.local:8123" - assert hass.config.external_url is None +async def test_get_url_internal_fallback(hass: HomeAssistant): + """Test getting an instance URL when the user has not set an internal URL.""" + assert hass.config.internal_url is None - assert async_get_url(hass) == "http://example.local:8123" + hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + assert _async_get_internal_url(hass) == "http://192.168.123.123:8123" with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_standard_port=True) + _async_get_internal_url(hass, allow_ip=False) with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + _async_get_internal_url(hass, require_standard_port=True) with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) + _async_get_internal_url(hass, require_ssl=True) - # Test with internal URL: http://example.local:80/ - await config_util.async_process_ha_core_config( - hass, {"internal_url": "http://example.local:80/"}, + hass.config.api = Mock(use_ssl=False, port=80, base_url=None) + assert _async_get_internal_url(hass) == "http://192.168.123.123" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "http://192.168.123.123" ) - assert hass.config.internal_url == "http://example.local:80/" - assert async_get_url(hass) == "http://example.local" - assert async_get_url(hass, require_standard_port=True) == "http://example.local" - with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + _async_get_internal_url(hass, allow_ip=False) with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) - - # Test with internal url: http://example.local:8123 - await config_util.async_process_ha_core_config( - hass, {"internal_url": "https://example.local/"}, - ) - assert hass.config.internal_url == "https://example.local/" - assert async_get_url(hass) == "https://example.local" - assert async_get_url(hass, require_ssl=True) == "https://example.local" - assert async_get_url(hass, require_standard_port=True) == "https://example.local" + _async_get_internal_url(hass, require_ssl=True) + hass.config.api = Mock(use_ssl=True, port=443, base_url=None) with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) + _async_get_internal_url(hass) - # Test with internal URL: https://example.local:80 - await config_util.async_process_ha_core_config( - hass, {"internal_url": "https://example.local:80"}, - ) - assert hass.config.internal_url == "https://example.local:80" - assert async_get_url(hass) == "https://example.local:80" - assert async_get_url(hass, require_ssl=True) == "https://example.local:80" + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_standard_port=True) with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) + _async_get_internal_url(hass, allow_ip=False) with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) + _async_get_internal_url(hass, require_ssl=True) + + # Do no accept any local loopback address as fallback + with patch( + "homeassistant.helpers.network.get_local_ip", return_value="127.0.0.127" + ): + hass.config.api = Mock(use_ssl=False, port=80, base_url=None) + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass) + + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_standard_port=True) + + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_ssl=True) async def test_get_url_external(hass: HomeAssistant): """Test getting an instance URL when the user has set an external URL.""" - assert hass.config.internal_url is None assert hass.config.external_url is None - # Test with external URL: http://www.example.com:8123 - await config_util.async_process_ha_core_config( - hass, {"external_url": "http://www.example.com:8123"}, + # Test with external URL: http://example.com:8123 + await async_process_ha_core_config( + hass, {"external_url": "http://example.com:8123"}, ) - assert hass.config.internal_url is None - assert hass.config.external_url == "http://www.example.com:8123" - - assert async_get_url(hass) == "http://www.example.com:8123" - assert async_get_url(hass, allow_local=False) == "http://www.example.com:8123" + assert hass.config.external_url == "http://example.com:8123" + assert _async_get_external_url(hass) == "http://example.com:8123" + assert _async_get_external_url(hass, allow_cloud=False) == "http://example.com:8123" + assert _async_get_external_url(hass, allow_ip=False) == "http://example.com:8123" + assert _async_get_external_url(hass, prefer_cloud=True) == "http://example.com:8123" with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_standard_port=True) + _async_get_external_url(hass, require_standard_port=True) with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + _async_get_external_url(hass, require_ssl=True) - # Test with external URL: http://www.example.com:80/ - await config_util.async_process_ha_core_config( - hass, {"external_url": "http://www.example.com:80/"}, + # Test with external URL: http://example.com:80/ + await async_process_ha_core_config( + hass, {"external_url": "http://example.com:80/"}, ) - assert hass.config.external_url == "http://www.example.com:80/" - assert async_get_url(hass) == "http://www.example.com" - assert async_get_url(hass, require_standard_port=True) == "http://www.example.com" - assert async_get_url(hass, allow_local=False) == "http://www.example.com" + assert hass.config.external_url == "http://example.com:80/" + assert _async_get_external_url(hass) == "http://example.com" + assert _async_get_external_url(hass, allow_cloud=False) == "http://example.com" + assert _async_get_external_url(hass, allow_ip=False) == "http://example.com" + assert _async_get_external_url(hass, prefer_cloud=True) == "http://example.com" + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "http://example.com" + ) with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + _async_get_external_url(hass, require_ssl=True) - # Test with external url: https://www.example.com/ - await config_util.async_process_ha_core_config( - hass, {"external_url": "https://www.example.com/"}, + # Test with external url: https://example.com:443/ + await async_process_ha_core_config( + hass, {"external_url": "https://example.com:443/"}, ) - assert hass.config.external_url == "https://www.example.com/" - assert async_get_url(hass) == "https://www.example.com" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com" - assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" - assert async_get_url(hass, allow_local=False) == "https://www.example.com" - - # Test with external url: https://www.example.com:443 - await config_util.async_process_ha_core_config( - hass, {"external_url": "https://www.example.com:443"}, + assert hass.config.external_url == "https://example.com:443/" + assert _async_get_external_url(hass) == "https://example.com" + assert _async_get_external_url(hass, allow_cloud=False) == "https://example.com" + assert _async_get_external_url(hass, allow_ip=False) == "https://example.com" + assert _async_get_external_url(hass, prefer_cloud=True) == "https://example.com" + assert _async_get_external_url(hass, require_ssl=False) == "https://example.com" + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "https://example.com" ) - assert hass.config.external_url == "https://www.example.com:443" - assert async_get_url(hass) == "https://www.example.com" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com" - assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" - assert async_get_url(hass, allow_local=False) == "https://www.example.com" - # Test with external URL: https://www.example.com:80 - await config_util.async_process_ha_core_config( - hass, {"external_url": "https://www.example.com:80"}, + # Test with external URL: https://example.com:80 + await async_process_ha_core_config( + hass, {"external_url": "https://example.com:80"}, ) - assert hass.config.external_url == "https://www.example.com:80" - assert async_get_url(hass) == "https://www.example.com:80" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com:80" - assert async_get_url(hass, allow_local=False) == "https://www.example.com:80" + assert hass.config.external_url == "https://example.com:80" + assert _async_get_external_url(hass) == "https://example.com:80" + assert _async_get_external_url(hass, allow_cloud=False) == "https://example.com:80" + assert _async_get_external_url(hass, allow_ip=False) == "https://example.com:80" + assert _async_get_external_url(hass, prefer_cloud=True) == "https://example.com:80" + assert _async_get_external_url(hass, require_ssl=True) == "https://example.com:80" with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_standard_port=True) + _async_get_external_url(hass, require_standard_port=True) # Test with external URL: https://192.168.0.1 - await config_util.async_process_ha_core_config( + await async_process_ha_core_config( hass, {"external_url": "https://192.168.0.1"}, ) assert hass.config.external_url == "https://192.168.0.1" - assert async_get_url(hass) == "https://192.168.0.1" - assert async_get_url(hass, require_standard_port=True) == "https://192.168.0.1" - assert async_get_url(hass, allow_local=False) == "https://192.168.0.1" + assert _async_get_external_url(hass) == "https://192.168.0.1" + assert _async_get_external_url(hass, allow_cloud=False) == "https://192.168.0.1" + assert _async_get_external_url(hass, prefer_cloud=True) == "https://192.168.0.1" + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "https://192.168.0.1" + ) with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + _async_get_external_url(hass, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_external_url(hass, require_ssl=True) -async def test_get_url_cloud(hass: HomeAssistant): +async def test_get_cloud_url(hass: HomeAssistant): """Test getting an instance URL when the user has set an external URL.""" - assert hass.config.internal_url is None assert hass.config.external_url is None - hass.config.components.add("cloud") with patch.object( @@ -238,8 +281,7 @@ async def test_get_url_cloud(hass: HomeAssistant): "async_remote_ui_url", return_value="https://example.nabu.casa", ): - assert async_get_url(hass, allow_local=False) == "https://example.nabu.casa" - assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" + assert _async_get_cloud_url(hass) == "https://example.nabu.casa" with patch.object( hass.components.cloud, @@ -247,183 +289,403 @@ async def test_get_url_cloud(hass: HomeAssistant): side_effect=cloud.CloudNotAvailable, ): with pytest.raises(NoURLAvailableError): - async_get_url(hass, allow_local=False) - - with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) + _async_get_cloud_url(hass) -async def test_get_url_fallback(hass: HomeAssistant): - """Test getting an instance URL using fallback scenarios.""" - assert hass.config.internal_url is None +async def test_get_external_url_cloud_fallback(hass: HomeAssistant): + """Test getting an external instance URL with cloud fallback.""" assert hass.config.external_url is None - # Test fallback to IP URL with internal URL set - hass.config.api = Mock(use_ssl=False, port=80, base_url=None) - await config_util.async_process_ha_core_config( - hass, {"internal_url": "https://example.local:8123"}, - ) - - assert hass.config.internal_url == "https://example.local:8123" - assert async_get_url(hass) == "https://example.local:8123" - assert async_get_url(hass, require_standard_port=True) == "http://192.168.123.123" - - # Test not falling back to IP URL - hass.config.api = Mock(use_ssl=True, port=443, base_url=None) - await config_util.async_process_ha_core_config( - hass, {"internal_url": "http://example.local:8123"}, - ) - assert async_get_url(hass) == "http://example.local:8123" - with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_ssl=True) - - hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) - with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_standard_port=True) - - # Test falling back to external URL - hass.config.api = Mock(use_ssl=True, port=443, base_url=None) - await config_util.async_process_ha_core_config( - hass, - { - "internal_url": "http://example.local:8123", - "external_url": "https://www.example.com:8123", - }, + # Test with external URL: http://1.1.1.1:8123 + await async_process_ha_core_config( + hass, {"external_url": "http://1.1.1.1:8123"}, ) - assert hass.config.internal_url == "http://example.local:8123" - assert hass.config.external_url == "https://www.example.com:8123" - assert async_get_url(hass) == "http://example.local:8123" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" - - with pytest.raises(NoURLAvailableError): - async_get_url(hass, require_standard_port=True) - await config_util.async_process_ha_core_config( - hass, - { - "internal_url": "http://example.local:8123", - "external_url": "https://www.example.com", - }, - ) - assert hass.config.internal_url == "http://example.local:8123" - assert hass.config.external_url == "https://www.example.com" - assert async_get_url(hass) == "http://example.local:8123" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com" - assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" - assert async_get_url(hass, allow_local=False) == "https://www.example.com" - assert ( - async_get_url( - hass, allow_local=False, require_ssl=True, require_standard_port=True - ) - == "https://www.example.com" - ) + assert hass.config.external_url == "http://1.1.1.1:8123" + assert _async_get_external_url(hass, prefer_cloud=True) == "http://1.1.1.1:8123" - # Test fallback to Cloud URL + # Add Cloud to the previous test hass.config.components.add("cloud") - await config_util.async_process_ha_core_config( - hass, - { - "internal_url": "http://example.local:8123", - "external_url": "https://www.example.com:8123", - }, - ) - assert hass.config.internal_url == "http://example.local:8123" - assert hass.config.external_url == "https://www.example.com:8123" - with patch.object( hass.components.cloud, "async_remote_ui_url", return_value="https://example.nabu.casa", ): - assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" + assert _async_get_external_url(hass, allow_cloud=False) == "http://1.1.1.1:8123" + assert ( + _async_get_external_url(hass, allow_ip=False) == "https://example.nabu.casa" + ) + assert ( + _async_get_external_url(hass, prefer_cloud=False) == "http://1.1.1.1:8123" + ) + assert ( + _async_get_external_url(hass, prefer_cloud=True) + == "https://example.nabu.casa" + ) assert ( - async_get_url(hass, require_standard_port=True) + _async_get_external_url(hass, require_ssl=True) == "https://example.nabu.casa" ) assert ( - async_get_url(hass, require_ssl=True, require_standard_port=True) + _async_get_external_url(hass, require_standard_port=True) == "https://example.nabu.casa" ) - # Test fallback to Cloud when external URL contains an IP address - await config_util.async_process_ha_core_config( - hass, - { - "internal_url": "http://example.local:8123", - "external_url": "https://192.168.0.1", - }, + # Test with external URL: https://example.com + await async_process_ha_core_config( + hass, {"external_url": "https://example.com"}, ) - assert hass.config.internal_url == "http://example.local:8123" - assert hass.config.external_url == "https://192.168.0.1" + assert hass.config.external_url == "https://example.com" + assert _async_get_external_url(hass, prefer_cloud=True) == "https://example.com" + + # Add Cloud to the previous test + hass.config.components.add("cloud") with patch.object( hass.components.cloud, "async_remote_ui_url", return_value="https://example.nabu.casa", ): - assert async_get_url(hass, require_standard_port=True) == "https://192.168.0.1" - assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" + assert _async_get_external_url(hass, allow_cloud=False) == "https://example.com" + assert _async_get_external_url(hass, allow_ip=False) == "https://example.com" + assert ( + _async_get_external_url(hass, prefer_cloud=False) == "https://example.com" + ) assert ( - async_get_url(hass, require_ssl=True, require_standard_port=True) + _async_get_external_url(hass, prefer_cloud=True) == "https://example.nabu.casa" ) + assert _async_get_external_url(hass, require_ssl=True) == "https://example.com" + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "https://example.com" + ) + assert ( + _async_get_external_url(hass, prefer_cloud=True, allow_cloud=False) + == "https://example.com" + ) -async def test_get_url_base_url_fallback(hass: HomeAssistant): - """Test getting an instance URL using deprecated base_url fallback scenarios.""" - assert hass.config.internal_url is None +async def test_get_url(hass: HomeAssistant): + """Test getting an instance URL.""" assert hass.config.external_url is None + assert hass.config.internal_url is None - hass.config.api = Mock(use_ssl=False, port=8123, base_url="https://www.example.com") + with pytest.raises(NoURLAvailableError): + async_get_url(hass) + hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) assert async_get_url(hass) == "http://192.168.123.123:8123" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com" - assert async_get_url(hass, allow_local=False) == "https://www.example.com" - assert async_get_url(hass, require_standard_port=True) == "https://www.example.com" + assert async_get_url(hass, prefer_external=True) == "http://192.168.123.123:8123" + + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_internal=False) - hass.config.api = Mock( - use_ssl=False, port=8123, base_url="https://www.example.com:8123/" + # Test only external + hass.config.api = None + await async_process_ha_core_config( + hass, {"external_url": "https://example.com"}, ) - assert async_get_url(hass) == "http://192.168.123.123:8123" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" - assert async_get_url(hass, allow_local=False) == "https://www.example.com:8123" - # Last fallback, without sanity, to `base_url` + assert hass.config.external_url == "https://example.com" + assert hass.config.internal_url is None + assert async_get_url(hass) == "https://example.com" + + # Test preference or allowance + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local", "external_url": "https://example.com"}, + ) + assert hass.config.external_url == "https://example.com" + assert hass.config.internal_url == "http://example.local" + assert async_get_url(hass) == "http://example.local" + assert async_get_url(hass, prefer_external=True) == "https://example.com" + assert async_get_url(hass, allow_internal=False) == "https://example.com" assert ( - async_get_url(hass, require_standard_port=True) - == "https://www.example.com:8123" + async_get_url(hass, prefer_external=True, allow_external=False) + == "http://example.local" ) - hass.config.components.add("cloud") - with patch.object( - hass.components.cloud, - "async_remote_ui_url", - return_value="https://example.nabu.casa", - ): - # Ensure without sanity fallback doesn't apply when cloud is available - assert ( - async_get_url(hass, require_standard_port=True) - == "https://example.nabu.casa" - ) + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_external=False, require_ssl=True) - # Ensure we fallback to cloud when SSL is needed but not in base_url - hass.config.api = Mock( - use_ssl=False, port=8123, base_url="http://www.example.com" - ) - assert async_get_url(hass, allow_local=False) == "http://www.example.com" - assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" + with pytest.raises(NoURLAvailableError): + async_get_url(hass, allow_external=False, allow_internal=False) - # Ensure we fallback to cloud when SSL is needed but not in base_url - hass.config.api = Mock( - use_ssl=False, port=8123, base_url="https://www.example.com:8123" - ) - assert async_get_url(hass, allow_local=False) == "https://www.example.com:8123" - assert async_get_url(hass, require_ssl=True) == "https://www.example.com:8123" - assert ( - async_get_url(hass, require_ssl=True, require_standard_port=True) - == "https://example.nabu.casa" - ) - # With local base URL, with an IP address and SSL - hass.config.api = Mock(use_ssl=False, port=8123, base_url="https://192.168.0.1") - assert async_get_url(hass, allow_local=False) == "https://example.nabu.casa" - assert async_get_url(hass, require_ssl=True) == "https://example.nabu.casa" +async def test_get_deprecated_base_url_internal(hass: HomeAssistant): + """Test getting an internal instance URL from the deprecated base_url.""" + # Test with SSL local URL + hass.config.api = Mock(base_url="https://example.local") + assert ( + _async_get_deprecated_base_url(hass, internal=True) == "https://example.local" + ) + assert ( + _async_get_deprecated_base_url(hass, internal=True, allow_ip=False) + == "https://example.local" + ) + assert ( + _async_get_deprecated_base_url(hass, internal=True, require_ssl=True) + == "https://example.local" + ) + assert ( + _async_get_deprecated_base_url(hass, internal=True, require_standard_port=True) + == "https://example.local" + ) + + # Test with no SSL, local IP URL + hass.config.api = Mock(base_url="http://10.10.10.10:8123") + assert ( + _async_get_deprecated_base_url(hass, internal=True) == "http://10.10.10.10:8123" + ) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, require_standard_port=True) + + # Test with SSL, local IP URL + hass.config.api = Mock(base_url="https://10.10.10.10") + assert _async_get_deprecated_base_url(hass, internal=True) == "https://10.10.10.10" + assert ( + _async_get_deprecated_base_url(hass, internal=True, require_ssl=True) + == "https://10.10.10.10" + ) + assert ( + _async_get_deprecated_base_url(hass, internal=True, require_standard_port=True) + == "https://10.10.10.10" + ) + + # Test external URL + hass.config.api = Mock(base_url="https://example.com") + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, require_standard_port=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, allow_ip=False) + + # Test with loopback + hass.config.api = Mock(base_url="https://127.0.0.42") + with pytest.raises(NoURLAvailableError): + assert _async_get_deprecated_base_url(hass, internal=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, internal=True, require_standard_port=True) + + +async def test_get_deprecated_base_url_external(hass: HomeAssistant): + """Test getting an external instance URL from the deprecated base_url.""" + # Test with SSL and external domain on standard port + hass.config.api = Mock(base_url="https://example.com:443/") + assert _async_get_deprecated_base_url(hass) == "https://example.com" + assert ( + _async_get_deprecated_base_url(hass, require_ssl=True) == "https://example.com" + ) + assert ( + _async_get_deprecated_base_url(hass, require_standard_port=True) + == "https://example.com" + ) + + # Test without SSL and external domain on non-standard port + hass.config.api = Mock(base_url="http://example.com:8123/") + assert _async_get_deprecated_base_url(hass) == "http://example.com:8123" + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_standard_port=True) + + # Test SSL on external IP + hass.config.api = Mock(base_url="https://1.1.1.1") + assert _async_get_deprecated_base_url(hass) == "https://1.1.1.1" + assert _async_get_deprecated_base_url(hass, require_ssl=True) == "https://1.1.1.1" + assert ( + _async_get_deprecated_base_url(hass, require_standard_port=True) + == "https://1.1.1.1" + ) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, allow_ip=False) + + # Test with private IP + hass.config.api = Mock(base_url="https://10.10.10.10") + with pytest.raises(NoURLAvailableError): + assert _async_get_deprecated_base_url(hass) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_standard_port=True) + + # Test with local domain + hass.config.api = Mock(base_url="https://example.local") + with pytest.raises(NoURLAvailableError): + assert _async_get_deprecated_base_url(hass) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_standard_port=True) + + # Test with loopback + hass.config.api = Mock(base_url="https://127.0.0.42") + with pytest.raises(NoURLAvailableError): + assert _async_get_deprecated_base_url(hass) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_ssl=True) + + with pytest.raises(NoURLAvailableError): + _async_get_deprecated_base_url(hass, require_standard_port=True) + + +async def test_get_internal_url_with_base_url_fallback(hass: HomeAssistant): + """Test getting an internal instance URL with the deprecated base_url fallback.""" + hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + assert hass.config.internal_url is None + assert _async_get_internal_url(hass) == "http://192.168.123.123:8123" + + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, allow_ip=False) + + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_standard_port=True) + + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_ssl=True) + + # Add base_url + hass.config.api = Mock(use_ssl=False, port=8123, base_url="https://example.local") + assert _async_get_internal_url(hass) == "https://example.local" + assert _async_get_internal_url(hass, allow_ip=False) == "https://example.local" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "https://example.local" + ) + assert _async_get_internal_url(hass, require_ssl=True) == "https://example.local" + + # Add internal URL + await async_process_ha_core_config( + hass, {"internal_url": "https://internal.local"}, + ) + assert _async_get_internal_url(hass) == "https://internal.local" + assert _async_get_internal_url(hass, allow_ip=False) == "https://internal.local" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "https://internal.local" + ) + assert _async_get_internal_url(hass, require_ssl=True) == "https://internal.local" + + # Add internal URL, mixed results + await async_process_ha_core_config( + hass, {"internal_url": "http://internal.local:8123"}, + ) + assert _async_get_internal_url(hass) == "http://internal.local:8123" + assert _async_get_internal_url(hass, allow_ip=False) == "http://internal.local:8123" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "https://example.local" + ) + assert _async_get_internal_url(hass, require_ssl=True) == "https://example.local" + + # Add internal URL set to an IP + await async_process_ha_core_config( + hass, {"internal_url": "http://10.10.10.10:8123"}, + ) + assert _async_get_internal_url(hass) == "http://10.10.10.10:8123" + assert _async_get_internal_url(hass, allow_ip=False) == "https://example.local" + assert ( + _async_get_internal_url(hass, require_standard_port=True) + == "https://example.local" + ) + assert _async_get_internal_url(hass, require_ssl=True) == "https://example.local" + + +async def test_get_external_url_with_base_url_fallback(hass: HomeAssistant): + """Test getting an external instance URL with the deprecated base_url fallback.""" + hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + assert hass.config.internal_url is None + + with pytest.raises(NoURLAvailableError): + _async_get_external_url(hass) + + # Test with SSL and external domain on standard port + hass.config.api = Mock(base_url="https://example.com:443/") + assert _async_get_external_url(hass) == "https://example.com" + assert _async_get_external_url(hass, allow_ip=False) == "https://example.com" + assert _async_get_external_url(hass, require_ssl=True) == "https://example.com" + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "https://example.com" + ) + + # Add external URL + await async_process_ha_core_config( + hass, {"external_url": "https://external.example.com"}, + ) + assert _async_get_external_url(hass) == "https://external.example.com" + assert ( + _async_get_external_url(hass, allow_ip=False) == "https://external.example.com" + ) + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "https://external.example.com" + ) + assert ( + _async_get_external_url(hass, require_ssl=True) + == "https://external.example.com" + ) + + # Add external URL, mixed results + await async_process_ha_core_config( + hass, {"external_url": "http://external.example.com:8123"}, + ) + assert _async_get_external_url(hass) == "http://external.example.com:8123" + assert ( + _async_get_external_url(hass, allow_ip=False) + == "http://external.example.com:8123" + ) + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "https://example.com" + ) + assert _async_get_external_url(hass, require_ssl=True) == "https://example.com" + + # Add external URL set to an IP + await async_process_ha_core_config( + hass, {"external_url": "http://1.1.1.1:8123"}, + ) + assert _async_get_external_url(hass) == "http://1.1.1.1:8123" + assert _async_get_external_url(hass, allow_ip=False) == "https://example.com" + assert ( + _async_get_external_url(hass, require_standard_port=True) + == "https://example.com" + ) + assert _async_get_external_url(hass, require_ssl=True) == "https://example.com" From f51913d8ee585d286ba3be964fa7ca8f5a3b08f4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 14:27:08 +0200 Subject: [PATCH 10/44] Migrate almond to get_url helper --- homeassistant/components/almond/__init__.py | 21 +++++++++++---------- tests/components/almond/test_init.py | 12 ++++++++++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py index 58fada7a196d1..b858e49e2492a 100644 --- a/homeassistant/components/almond/__init__.py +++ b/homeassistant/components/almond/__init__.py @@ -147,16 +147,17 @@ async def _configure_almond_for_ha( hass: HomeAssistant, entry: config_entries.ConfigEntry, api: WebAlmondAPI ): """Configure Almond to connect to HA.""" - - if entry.data["type"] == TYPE_OAUTH2: - # If we're connecting over OAuth2, we will only set up connection - # with Home Assistant if we're remotely accessible. - hass_url = network.async_get_external_url(hass) - else: - hass_url = hass.config.api.base_url - - # If hass_url is None, we're not going to configure Almond to connect to HA. - if hass_url is None: + try: + if entry.data["type"] == TYPE_OAUTH2: + # If we're connecting over OAuth2, we will only set up connection + # with Home Assistant if we're remotely accessible. + hass_url = network.async_get_url( + hass, allow_internal=False, prefer_cloud=True + ) + else: + hass_url = network.async_get_url(hass) + except network.NoURLAvailableError: + # If no URL is available, we're not going to configure Almond to connect to HA. return _LOGGER.debug("Configuring Almond to connect to Home Assistant at %s", hass_url) diff --git a/tests/components/almond/test_init.py b/tests/components/almond/test_init.py index 56286b9186c69..3040707f43f29 100644 --- a/tests/components/almond/test_init.py +++ b/tests/components/almond/test_init.py @@ -5,6 +5,7 @@ from homeassistant import config_entries, core from homeassistant.components.almond import const +from homeassistant.config import async_process_ha_core_config from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -39,8 +40,9 @@ async def test_set_up_oauth_remote_url(hass, aioclient_mock): assert entry.state == config_entries.ENTRY_STATE_LOADED + hass.config.components.add("cloud") with patch("homeassistant.components.almond.ALMOND_SETUP_DELAY", 0), patch( - "homeassistant.helpers.network.async_get_external_url", + "homeassistant.helpers.network.async_get_url", return_value="https://example.nabu.casa", ), patch("pyalmond.WebAlmondAPI.async_create_device") as mock_create_device: hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -93,7 +95,13 @@ async def test_set_up_hassio(hass, aioclient_mock): async def test_set_up_local(hass, aioclient_mock): - """Test we do not set up Almond to connect to HA if we use Hass.io.""" + """Test we do not set up Almond to connect to HA if we use local.""" + + # Set up an internal URL, as Almond won't be set up if there is no URL available + await async_process_ha_core_config( + hass, {"internal_url": "https://192.168.0.1"}, + ) + entry = MockConfigEntry( domain="almond", data={"type": const.TYPE_LOCAL, "host": "http://localhost:9999"}, From 3c57db9f3df2da64f901bcafe3afe8ce52e3612f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 14:28:21 +0200 Subject: [PATCH 11/44] Fix Home Assistant Cast tests --- tests/components/cast/test_home_assistant_cast.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py index f697b80137a92..da6edbebc269e 100644 --- a/tests/components/cast/test_home_assistant_cast.py +++ b/tests/components/cast/test_home_assistant_cast.py @@ -7,7 +7,7 @@ async def test_service_show_view(hass): """Test we don't set app id in prod.""" - hass.config.api = Mock(base_url="http://example.com") + hass.config.api = Mock(base_url="https://example.com") await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry()) calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW) @@ -20,7 +20,7 @@ async def test_service_show_view(hass): assert len(calls) == 1 controller, entity_id, view_path, url_path = calls[0] - assert controller.hass_url == "http://example.com" + assert controller.hass_url == "https://example.com" assert controller.client_id is None # Verify user did not accidentally submit their dev app id assert controller.supporting_app_id == "B12CE3CA" @@ -31,7 +31,7 @@ async def test_service_show_view(hass): async def test_service_show_view_dashboard(hass): """Test casting a specific dashboard.""" - hass.config.api = Mock(base_url="http://example.com") + hass.config.api = Mock(base_url="https://example.com") await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry()) calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW) From 4f6d01f67ffb755e56961248f691e0ecdde9cd6b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 14:29:16 +0200 Subject: [PATCH 12/44] Migrate alexa to get_url helper --- homeassistant/components/alexa/entities.py | 8 +++-- homeassistant/components/alexa/handlers.py | 9 +++++- tests/components/alexa/test_smart_home.py | 34 +++++++++++++--------- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 09ce71bb3bc8a..7df54d14f5715 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -1,7 +1,6 @@ """Alexa entity adapters.""" import logging from typing import List -from urllib.parse import urlparse from homeassistant.components import ( alarm_control_panel, @@ -799,8 +798,11 @@ def _check_requirements(self): ) return False - url = urlparse(network.async_get_external_url(self.hass)) - if url.scheme != "https": + try: + network.async_get_url( + self.hass, allow_internal=False, prefer_cloud=True, require_ssl=True + ) + except network.NoURLAvailableError: _LOGGER.debug( "%s requires HTTPS for AlexaCameraStreamController", self.entity_id ) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 6b903665c174c..cc095701d00db 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1533,7 +1533,14 @@ async def async_api_initialize_camera_stream(hass, config, directive, context): entity = directive.entity stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls") camera_image = hass.states.get(entity.entity_id).attributes["entity_picture"] - external_url = network.async_get_external_url(hass) + + try: + external_url = network.async_get_url( + hass, allow_internal=False, prefer_cloud=True, require_ssl=True + ) + except network.NoURLAvailableError: + external_url = None + payload = { "cameraStreams": [ { diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0da4c4da6fd26..8948a12bee716 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -22,6 +22,7 @@ SUPPORT_VOLUME_STEP, ) import homeassistant.components.vacuum as vacuum +from homeassistant.config import async_process_ha_core_config from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import Context, callback from homeassistant.helpers import entityfilter @@ -3784,8 +3785,11 @@ async def test_camera_discovery(hass, mock_stream): "idle", {"friendly_name": "Test camera", "supported_features": 3}, ) - with patch( - "homeassistant.helpers.network.async_get_external_url", + + hass.config.components.add("cloud") + with patch.object( + hass.components.cloud, + "async_remote_ui_url", return_value="https://example.nabu.casa", ): appliance = await discovery_test(device, hass) @@ -3812,8 +3816,11 @@ async def test_camera_discovery_without_stream(hass): "idle", {"friendly_name": "Test camera", "supported_features": 3}, ) - with patch( - "homeassistant.helpers.network.async_get_external_url", + + hass.config.components.add("cloud") + with patch.object( + hass.components.cloud, + "async_remote_ui_url", return_value="https://example.nabu.casa", ): appliance = await discovery_test(device, hass) @@ -3826,7 +3833,6 @@ async def test_camera_discovery_without_stream(hass): [ ("http://nohttpswrongport.org:8123", 2), ("http://nohttpsport443.org:443", 2), - ("tls://nohttpsport443.org:443", 2), ("https://httpsnnonstandport.org:8123", 3), ("https://correctschemaandport.org:443", 3), ("https://correctschemaandport.org", 3), @@ -3839,11 +3845,12 @@ async def test_camera_hass_urls(hass, mock_stream, url, result): "idle", {"friendly_name": "Test camera", "supported_features": 3}, ) - with patch( - "homeassistant.helpers.network.async_get_external_url", return_value=url - ): - appliance = await discovery_test(device, hass) - assert len(appliance["capabilities"]) == result + await async_process_ha_core_config( + hass, {"external_url": url}, + ) + + appliance = await discovery_test(device, hass) + assert len(appliance["capabilities"]) == result async def test_initialize_camera_stream(hass, mock_camera, mock_stream): @@ -3852,12 +3859,13 @@ async def test_initialize_camera_stream(hass, mock_camera, mock_stream): "Alexa.CameraStreamController", "InitializeCameraStreams", "camera#demo_camera" ) + await async_process_ha_core_config( + hass, {"external_url": "https://mycamerastream.test"}, + ) + with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="rtsp://example.local", - ), patch( - "homeassistant.helpers.network.async_get_external_url", - return_value="https://mycamerastream.test", ): msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) await hass.async_block_till_done() From 6676d048d7e82cf5d798803dbc2f4ac7d0347126 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 14:29:55 +0200 Subject: [PATCH 13/44] Migrate webhook to get_url helper --- homeassistant/components/webhook/__init__.py | 6 +++++- tests/components/owntracks/test_config_flow.py | 9 +++++++++ tests/components/push/test_camera.py | 9 +++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 4a90a247e754b..6b07a38fde9fb 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -10,6 +10,7 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import HTTP_OK from homeassistant.core import callback +from homeassistant.helpers.network import async_get_url from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -55,7 +56,10 @@ def async_generate_id(): @bind_hass def async_generate_url(hass, webhook_id): """Generate the full URL for a webhook_id.""" - return "{}{}".format(hass.config.api.base_url, async_generate_path(webhook_id)) + return "{}{}".format( + async_get_url(hass, prefer_external=True, allow_cloud=False), + async_generate_path(webhook_id), + ) @callback diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py index 676e47523fa5a..a4a530b908309 100644 --- a/tests/components/owntracks/test_config_flow.py +++ b/tests/components/owntracks/test_config_flow.py @@ -5,6 +5,7 @@ from homeassistant.components.owntracks import config_flow from homeassistant.components.owntracks.config_flow import CONF_CLOUDHOOK, CONF_SECRET from homeassistant.components.owntracks.const import DOMAIN +from homeassistant.config import async_process_ha_core_config from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.setup import async_setup_component @@ -86,6 +87,10 @@ async def test_import(hass, webhook_id, secret): async def test_import_setup(hass): """Test that we automatically create a config flow.""" + await async_process_ha_core_config( + hass, {"external_url": "http://example.com"}, + ) + assert not hass.config_entries.async_entries(DOMAIN) assert await async_setup_component(hass, DOMAIN, {"owntracks": {}}) await hass.async_block_till_done() @@ -124,6 +129,10 @@ async def test_user_not_supports_encryption(hass, not_supports_encryption): async def test_unload(hass): """Test unloading a config flow.""" + await async_process_ha_core_config( + hass, {"external_url": "http://example.com"}, + ) + with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" ) as mock_forward: diff --git a/tests/components/push/test_camera.py b/tests/components/push/test_camera.py index b5803b96889fe..8f4bb43045e7d 100644 --- a/tests/components/push/test_camera.py +++ b/tests/components/push/test_camera.py @@ -3,12 +3,17 @@ import io from homeassistant import core as ha +from homeassistant.config import async_process_ha_core_config from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util async def test_bad_posting(hass, aiohttp_client): """Test that posting to wrong api endpoint fails.""" + await async_process_ha_core_config( + hass, {"external_url": "http://example.com"}, + ) + await async_setup_component( hass, "camera", @@ -35,6 +40,10 @@ async def test_bad_posting(hass, aiohttp_client): async def test_posting_url(hass, aiohttp_client): """Test that posting to api endpoint works.""" + await async_process_ha_core_config( + hass, {"external_url": "http://example.com"}, + ) + await async_setup_component( hass, "camera", From 33811e7607f4a54b20f5463a6d57015d7f03acce Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 15:21:28 +0200 Subject: [PATCH 14/44] Migrate browser.open in main to get_url --- homeassistant/__main__.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 728ee3c5985ef..ca9c684e903e3 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -6,7 +6,9 @@ import subprocess import sys import threading -from typing import List +from typing import List, Optional + +import yarl from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ @@ -256,10 +258,29 @@ async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int: if hass is None: return 1 - if args.open_ui and hass.config.api is not None: + if args.open_ui: import webbrowser # pylint: disable=import-outside-toplevel + from homeassistant.helpers import ( + network, + ) # pylint: disable=import-outside-toplevel + + url: Optional[str] = None + try: + url = network.async_get_url(hass) + except network.NoURLAvailableError: + pass + + if url is None and hass.config.api is not None: + # Fallback to localhost + scheme = "https" if hass.config.api.use_ssl else "http" + url = str( + yarl.URL.build( + scheme=scheme, host="127.0.0.1", port=hass.config.api.port + ) + ) - hass.add_job(webbrowser.open, hass.config.api.base_url) + if url is not None: + hass.add_job(webbrowser.open, url) return await hass.async_run() From ff78396730eee04bb15a788a99882ddb41b630c0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 15:37:15 +0200 Subject: [PATCH 15/44] Migrate OAuth2 config entry flow to get_url --- homeassistant/helpers/config_entry_oauth2_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 0c5a5c3873ec9..19e322a046c2e 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -21,6 +21,7 @@ from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.network import async_get_url from .aiohttp_client import async_get_clientsession @@ -117,7 +118,7 @@ def domain(self) -> str: @property def redirect_uri(self) -> str: """Return the redirect uri.""" - return f"{self.hass.config.api.base_url}{AUTH_CALLBACK_PATH}" # type: ignore + return f"{async_get_url(self.hass)}{AUTH_CALLBACK_PATH}" async def async_generate_authorize_url(self, flow_id: str) -> str: """Generate a url for the user to authorize.""" From 1b6950bcc11883dbfa71f40cd89989df4c828c2a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 17:23:05 +0200 Subject: [PATCH 16/44] Migrate ambiclimate to get_url helper --- homeassistant/components/ambiclimate/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 4996a458a1f34..c73a64816f7b2 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -7,6 +7,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.network import async_get_url from .const import ( AUTH_CALLBACK_NAME, @@ -122,16 +123,15 @@ def _generate_oauth(self): clientsession = async_get_clientsession(self.hass) callback_url = self._cb_url() - oauth = ambiclimate.AmbiclimateOAuth( + return ambiclimate.AmbiclimateOAuth( config.get(CONF_CLIENT_ID), config.get(CONF_CLIENT_SECRET), callback_url, clientsession, ) - return oauth def _cb_url(self): - return f"{self.hass.config.api.base_url}{AUTH_CALLBACK_PATH}" + return f"{async_get_url(self.hass)}{AUTH_CALLBACK_PATH}" async def _get_authorize_url(self): oauth = self._generate_oauth() From 8c8a84f9229ce06027bb9d8ffa75667ae749ce9e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 17:32:55 +0200 Subject: [PATCH 17/44] Fix Withings tests --- tests/components/withings/common.py | 2 +- tests/components/withings/test_init.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index f57b2d9b0c8e5..fc30820d5d14e 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -124,7 +124,7 @@ async def configure_integration( assert result["url"] == ( "https://account.withings.com/oauth2_user/authorize2?" "response_type=code&client_id=my_client_id&" - "redirect_uri=http://127.0.0.1:8080/auth/external/callback&" + "redirect_uri=http://example.local/auth/external/callback&" f"state={state}" "&scope=user.info,user.metrics,user.activity" ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 5bb3e8534c353..7d61c74c50a0b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -14,6 +14,7 @@ async_setup_entry, const, ) +from homeassistant.config import async_process_ha_core_config from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -164,6 +165,10 @@ async def test_upgrade_token( config = await setup_hass(hass) profiles = config[const.DOMAIN][const.PROFILES] + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local"}, + ) + await configure_integration( hass=hass, aiohttp_client=aiohttp_client, @@ -234,6 +239,10 @@ async def test_auth_failure( config = await setup_hass(hass) profiles = config[const.DOMAIN][const.PROFILES] + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local"}, + ) + await configure_integration( hass=hass, aiohttp_client=aiohttp_client, @@ -269,6 +278,10 @@ async def test_full_setup(hass: HomeAssistant, aiohttp_client, aioclient_mock) - config = await setup_hass(hass) profiles = config[const.DOMAIN][const.PROFILES] + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local"}, + ) + await configure_integration( hass=hass, aiohttp_client=aiohttp_client, From d4ee5d9dbce6c90ba5e0beaf4018120c850b3eac Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 17:33:28 +0200 Subject: [PATCH 18/44] Migrate fitbit to get_url helper --- homeassistant/components/fitbit/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 66c283f20ef86..b7c34ec72a31e 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -24,6 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.helpers.network import async_get_url from homeassistant.util.json import load_json, save_json _CONFIGURING = {} @@ -180,7 +181,7 @@ def fitbit_configuration_callback(callback_data): else: setup_platform(hass, config, add_entities, discovery_info) - start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" + start_url = f"{async_get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}" description = f"""Please create a Fitbit developer app at https://dev.fitbit.com/apps/new. @@ -215,7 +216,7 @@ def request_oauth_completion(hass): def fitbit_configuration_callback(callback_data): """Handle configuration updates.""" - start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_START}" + start_url = f"{async_get_url(hass)}{FITBIT_AUTH_START}" description = f"Please authorize Fitbit by visiting {start_url}" @@ -307,7 +308,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET) ) - redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" + redirect_uri = f"{async_get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}" fitbit_auth_start_url, _ = oauth.authorize_token_url( redirect_uri=redirect_uri, @@ -352,7 +353,7 @@ def get(self, request): result = None if data.get("code") is not None: - redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" + redirect_uri = f"{async_get_url(hass)}{FITBIT_AUTH_CALLBACK_PATH}" try: result = self.oauth.fetch_access_token(data.get("code"), redirect_uri) From bafbfc67ac25898e26986e3398b6eb720a4a718e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 17:34:01 +0200 Subject: [PATCH 19/44] Migrate telegram_bot to get_url helper --- homeassistant/components/telegram_bot/webhooks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 16da2e741e4b4..ab43b8d03d739 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -11,6 +11,7 @@ HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, ) +from homeassistant.helpers.network import async_get_url from . import ( CONF_ALLOWED_CHAT_IDS, @@ -32,7 +33,9 @@ async def async_setup_platform(hass, config): bot = initialize_bot(config) current_status = await hass.async_add_job(bot.getWebhookInfo) - base_url = config.get(CONF_URL, hass.config.api.base_url) + base_url = config.get( + CONF_URL, async_get_url(hass, require_ssl=True, allow_internal=False) + ) # Some logging of Bot current status: last_error_date = getattr(current_status, "last_error_date", None) From cbd6e3fa8c10fe169d27f38e3a59c734f40babe2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 17:34:25 +0200 Subject: [PATCH 20/44] Migrate wink to get_url helper --- homeassistant/components/wink/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 2d20183cb3dab..27a70bdf495ad 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -29,6 +29,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.network import async_get_url from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) @@ -231,7 +232,7 @@ def wink_configuration_callback(callback_data): _configurator = hass.data[DOMAIN]["configuring"][DOMAIN] configurator.notify_errors(_configurator, error_msg) - start_url = f"{hass.config.api.base_url}{WINK_AUTH_CALLBACK_PATH}" + start_url = f"{async_get_url(hass)}{WINK_AUTH_CALLBACK_PATH}" description = f"""Please create a Wink developer app at https://developer.wink.com. @@ -269,7 +270,7 @@ def wink_configuration_callback(callback_data): """Call setup again.""" setup(hass, config) - start_url = f"{hass.config.api.base_url}{WINK_AUTH_START}" + start_url = f"{async_get_url(hass)}{WINK_AUTH_START}" description = f"Please authorize Wink by visiting {start_url}" @@ -349,7 +350,7 @@ def setup(hass, config): # Home . else: - redirect_uri = f"{hass.config.api.base_url}{WINK_AUTH_CALLBACK_PATH}" + redirect_uri = f"{async_get_url(hass)}{WINK_AUTH_CALLBACK_PATH}" wink_auth_start_url = pywink.get_authorization_url( config_file.get(ATTR_CLIENT_ID), redirect_uri From ebd7227143250a83b7ad8e0d80d9bfeeeab93879 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 17:44:48 +0200 Subject: [PATCH 21/44] Migrate smartthings to get_url helper --- homeassistant/components/smartthings/smartapp.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 7d02a04d2ff9e..900482176148f 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -30,6 +30,7 @@ async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.network import NoURLAvailableError, async_get_url from homeassistant.helpers.typing import HomeAssistantType from .const import ( @@ -111,7 +112,11 @@ def get_webhook_url(hass: HomeAssistantType) -> str: def _get_app_template(hass: HomeAssistantType): - endpoint = f"at {hass.config.api.base_url}" + try: + endpoint = f"at {async_get_url(hass, allow_cloud=False, prefer_external=True)}" + except NoURLAvailableError: + endpoint = "" + cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] if cloudhook_url is not None: endpoint = "via Nabu Casa" From 365258bd9da289c79fc3c5d568d390fd3afd1960 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 19:31:13 +0200 Subject: [PATCH 22/44] Migrate media_player to get_url helper --- homeassistant/components/media_player/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index fe970189e5cbd..a98ea0dedda95 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -48,6 +48,7 @@ ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.network import async_get_url from homeassistant.loader import bind_hass from .const import ( @@ -820,7 +821,7 @@ async def _async_fetch_image(hass, url): cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] if urlparse(url).hostname is None: - url = hass.config.api.base_url + url + url = f"{async_get_url(hass)}{url}" if url not in cache_images: cache_images[url] = {CACHE_LOCK: asyncio.Lock()} From bb671d72bdfa4952f1aaffdc77cda30fa05e83af Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 19:38:57 +0200 Subject: [PATCH 23/44] Migrate plex to get_url helper --- homeassistant/components/plex/config_flow.py | 5 ++- tests/components/plex/test_config_flow.py | 36 +++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index ff6231b0586e2..78d1677f33829 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -22,6 +22,7 @@ from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.network import async_get_url from .const import ( # pylint: disable=unused-import AUTH_CALLBACK_NAME, @@ -278,7 +279,9 @@ async def async_step_plex_website_auth(self): session = async_get_clientsession(self.hass) self.plexauth = PlexAuth(payload, session) await self.plexauth.initiate_auth() - forward_url = f"{self.hass.config.api.base_url}{AUTH_CALLBACK_PATH}?flow_id={self.flow_id}" + forward_url = ( + f"{async_get_url(self.hass)}{AUTH_CALLBACK_PATH}?flow_id={self.flow_id}" + ) auth_url = self.plexauth.auth_url(forward_url) return self.async_external_step(step_id="obtain_token", url=auth_url) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index d901f7e62f0cd..a8c8d5c0f2e33 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -20,6 +20,7 @@ PLEX_UPDATE_PLATFORMS_SIGNAL, SERVERS, ) +from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ENTRY_STATE_LOADED from homeassistant.const import ( CONF_HOST, @@ -30,7 +31,6 @@ CONF_VERIFY_SSL, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.setup import async_setup_component from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN from .mock_classes import MockPlexAccount, MockPlexServer @@ -41,6 +41,9 @@ async def test_bad_credentials(hass): """Test when provided credentials are rejected.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -111,6 +114,9 @@ async def test_import_bad_hostname(hass): async def test_unknown_exception(hass): """Test when an unknown exception is encountered.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -137,7 +143,9 @@ async def test_unknown_exception(hass): async def test_no_servers_found(hass): """Test when no servers are on an account.""" - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -169,7 +177,9 @@ async def test_single_available_server(hass): mock_plex_server = MockPlexServer() - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -206,7 +216,9 @@ async def test_multiple_servers_with_selection(hass): mock_plex_server = MockPlexServer() - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -251,7 +263,9 @@ async def test_adding_last_unconfigured_server(hass): mock_plex_server = MockPlexServer() - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) MockConfigEntry( domain=DOMAIN, @@ -325,7 +339,9 @@ async def test_already_configured(hass): async def test_all_available_servers_configured(hass): """Test when all available servers are already configured.""" - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) MockConfigEntry( domain=DOMAIN, @@ -467,7 +483,9 @@ async def test_option_flow_new_users_available(hass, caplog): async def test_external_timed_out(hass): """Test when external flow times out.""" - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -494,7 +512,9 @@ async def test_external_timed_out(hass): async def test_callback_view(hass, aiohttp_client): """Test callback view.""" - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} From 7c991fd52043fbfaae21ba4b6d05a917a394e0ec Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 21:14:27 +0200 Subject: [PATCH 24/44] Migrate zeroconf service info --- homeassistant/components/zeroconf/__init__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index fc87abd4bc167..c94dab180ad5f 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -22,6 +22,7 @@ ) from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.network import NoURLAvailableError, async_get_url _LOGGER = logging.getLogger(__name__) @@ -63,11 +64,27 @@ def setup(hass, config): params = { "version": __version__, - "base_url": hass.config.api.base_url, + "external_url": None, + "internal_url": None, + # Old base URL, for backward compatibility + "base_url": None, # Always needs authentication "requires_api_password": True, } + try: + params["external_url"] = async_get_url(hass, allow_internal=False) + except NoURLAvailableError: + pass + + try: + params["internal_url"] = async_get_url(hass, allow_external=False) + except NoURLAvailableError: + pass + + # Set old base URL based on external or internal + params["base_url"] = params["external_url"] or params["internal_url"] + host_ip = util.get_local_ip() try: From 591b32c75d25529f67aad9a158bd701e8a0d1ab5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 21:17:49 +0200 Subject: [PATCH 25/44] Migrate konnected to get_url helper --- homeassistant/components/konnected/panel.py | 3 ++- tests/components/konnected/test_init.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index efb1e83a72878..417ca4ea27fac 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -23,6 +23,7 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.network import async_get_url from .const import ( CONF_ACTIVATION, @@ -297,7 +298,7 @@ def async_desired_settings_payload(self): # keeping self.hass.data check for backwards compatibility # newly configured integrations store this in the config entry desired_api_host = self.options.get(CONF_API_HOST) or ( - self.hass.data[DOMAIN].get(CONF_API_HOST) or self.hass.config.api.base_url + self.hass.data[DOMAIN].get(CONF_API_HOST) or async_get_url(self.hass) ) desired_api_endpoint = desired_api_host + ENDPOINT_ROOT diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index 1bdd1278b93d8..74e3b931f6107 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -3,6 +3,7 @@ from homeassistant.components import konnected from homeassistant.components.konnected import config_flow +from homeassistant.config import async_process_ha_core_config from homeassistant.const import HTTP_NOT_FOUND from homeassistant.setup import async_setup_component @@ -385,6 +386,9 @@ async def test_config_passed_to_config_entry(hass): async def test_unload_entry(hass, mock_panel): """Test being able to unload an entry.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) entry = MockConfigEntry( domain=konnected.DOMAIN, data={konnected.CONF_ID: "aabbccddeeff"} ) @@ -563,7 +567,9 @@ async def test_api(hass, aiohttp_client, mock_panel): async def test_state_updates_zone(hass, aiohttp_client, mock_panel): """Test callback view.""" - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) device_config = config_flow.CONFIG_ENTRY_SCHEMA( { @@ -711,7 +717,9 @@ async def test_state_updates_zone(hass, aiohttp_client, mock_panel): async def test_state_updates_pin(hass, aiohttp_client, mock_panel): """Test callback view.""" - await async_setup_component(hass, "http", {"http": {}}) + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) device_config = config_flow.CONFIG_ENTRY_SCHEMA( { From 4b87f78a31b3aab916f9088aa97147f795f3bb9b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 22:11:16 +0200 Subject: [PATCH 26/44] Migrate camera to get_url helper --- homeassistant/components/camera/__init__.py | 3 ++- tests/components/camera/test_init.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 2862805a333ed..ebcabbfc81b2a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -46,6 +46,7 @@ ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.network import async_get_url from homeassistant.loader import bind_hass from homeassistant.setup import async_when_setup @@ -684,7 +685,7 @@ async def async_handle_play_stream_service(camera, service_call): ) data = { ATTR_ENTITY_ID: entity_ids, - ATTR_MEDIA_CONTENT_ID: f"{hass.config.api.base_url}{url}", + ATTR_MEDIA_CONTENT_ID: f"{async_get_url(hass, require_ssl=True)}{url}", ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt], } diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 3eaef6575ac2f..36ee9b8aabf9f 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -9,6 +9,7 @@ from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM from homeassistant.components.camera.prefs import CameraEntityPreferences from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -241,6 +242,9 @@ async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): async def test_handle_play_stream_service(hass, mock_camera, mock_stream): """Test camera play_stream service.""" + await async_process_ha_core_config( + hass, {"external_url": "https://example.com"}, + ) await async_setup_component(hass, "media_player", {}) with patch( "homeassistant.components.camera.request_stream" From 4f3a937b1c54bc25169cc4c89f27328604714f11 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 22:22:54 +0200 Subject: [PATCH 27/44] Adjust alexa get_url params based on feedback --- homeassistant/components/alexa/entities.py | 6 +++++- homeassistant/components/alexa/handlers.py | 6 +++++- tests/components/alexa/test_smart_home.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7df54d14f5715..59c91844b51df 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -800,7 +800,11 @@ def _check_requirements(self): try: network.async_get_url( - self.hass, allow_internal=False, prefer_cloud=True, require_ssl=True + self.hass, + allow_internal=False, + allow_ip=False, + require_ssl=True, + require_standard_port=True, ) except network.NoURLAvailableError: _LOGGER.debug( diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index cc095701d00db..9d633a5f45a5d 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1536,7 +1536,11 @@ async def async_api_initialize_camera_stream(hass, config, directive, context): try: external_url = network.async_get_url( - hass, allow_internal=False, prefer_cloud=True, require_ssl=True + hass, + allow_internal=False, + allow_ip=False, + require_ssl=True, + require_standard_port=True, ) except network.NoURLAvailableError: external_url = None diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 8948a12bee716..591d200ef90cb 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3833,7 +3833,7 @@ async def test_camera_discovery_without_stream(hass): [ ("http://nohttpswrongport.org:8123", 2), ("http://nohttpsport443.org:443", 2), - ("https://httpsnnonstandport.org:8123", 3), + ("https://httpsnnonstandport.org:8123", 2), ("https://correctschemaandport.org:443", 3), ("https://correctschemaandport.org", 3), ], From 63520a566839e28761f9761674fcf38c2229825c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 22:27:36 +0200 Subject: [PATCH 28/44] Migrate google_assistant to get_url helper --- homeassistant/components/google_assistant/helpers.py | 3 ++- homeassistant/components/google_assistant/trait.py | 6 +++++- tests/components/google_assistant/test_smart_home.py | 9 ++++++--- tests/components/google_assistant/test_trait.py | 9 ++++++--- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 6c2fa3d82e1e1..7682db0105c33 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -18,6 +18,7 @@ ) from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.network import async_get_url from homeassistant.helpers.storage import Store from . import trait @@ -425,7 +426,7 @@ async def sync_serialize(self, agent_user_id): "webhookId": self.config.local_sdk_webhook_id, "httpPort": self.hass.http.server_port, "httpSSL": self.hass.config.api.use_ssl, - "baseUrl": self.hass.config.api.base_url, + "baseUrl": async_get_url(self.hass, prefer_external=True), "proxyDeviceId": agent_user_id, } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index ab04589623554..fa9ef1f375e5a 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -50,6 +50,7 @@ TEMP_FAHRENHEIT, ) from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.helpers.network import async_get_url from homeassistant.util import color as color_util, temperature as temp_util from .const import ( @@ -248,7 +249,10 @@ async def execute(self, command, data, params, challenge): self.state.entity_id, "hls" ) self.stream_info = { - "cameraStreamAccessUrl": self.hass.config.api.base_url + url + "cameraStreamAccessUrl": async_get_url( + self.hass, allow_ip=False, require_ssl=True + ) + + url } diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index c469b8b9efe76..e619356717f2f 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -20,6 +20,7 @@ smart_home as sh, trait, ) +from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, __version__ from homeassistant.core import EVENT_CALL_SERVICE, State from homeassistant.helpers import device_registry @@ -27,7 +28,7 @@ from . import BASIC_CONFIG, MockConfig -from tests.async_mock import Mock, patch +from tests.async_mock import patch from tests.common import mock_area_registry, mock_device_registry, mock_registry REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -798,7 +799,9 @@ async def test_query_disconnect(hass): async def test_trait_execute_adding_query_data(hass): """Test a trait execute influencing query data.""" - hass.config.api = Mock(base_url="http://1.1.1.1:8123") + await async_process_ha_core_config( + hass, {"external_url": "https://example.com"}, + ) hass.states.async_set( "camera.office", "idle", {"supported_features": camera.SUPPORT_STREAM} ) @@ -852,7 +855,7 @@ async def test_trait_execute_adding_query_data(hass): "status": "SUCCESS", "states": { "online": True, - "cameraStreamAccessUrl": "http://1.1.1.1:8123/api/streams/bla", + "cameraStreamAccessUrl": "https://example.com/api/streams/bla", }, } ] diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index f4e54176b195e..801f4c1b5ba2c 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -22,6 +22,7 @@ ) from homeassistant.components.climate import const as climate from homeassistant.components.google_assistant import const, error, helpers, trait +from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_DEVICE_CLASS, @@ -44,7 +45,7 @@ from . import BASIC_CONFIG, MockConfig -from tests.async_mock import Mock, patch +from tests.async_mock import patch from tests.common import async_mock_service _LOGGER = logging.getLogger(__name__) @@ -99,7 +100,9 @@ async def test_brightness_light(hass): async def test_camera_stream(hass): """Test camera stream trait support for camera domain.""" - hass.config.api = Mock(base_url="http://1.1.1.1:8123") + await async_process_ha_core_config( + hass, {"external_url": "https://example.com"}, + ) assert helpers.get_google_type(camera.DOMAIN, None) is not None assert trait.CameraStreamTrait.supported(camera.DOMAIN, camera.SUPPORT_STREAM, None) @@ -122,7 +125,7 @@ async def test_camera_stream(hass): await trt.execute(trait.COMMAND_GET_CAMERA_STREAM, BASIC_DATA, {}, {}) assert trt.query_attributes() == { - "cameraStreamAccessUrl": "http://1.1.1.1:8123/api/streams/bla" + "cameraStreamAccessUrl": "https://example.com/api/streams/bla" } From b7dd93886cab9bbdfe4be5e0d29953ceb3a5ae6b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 22:48:06 +0200 Subject: [PATCH 29/44] Migrate tts to get_url helper --- homeassistant/components/tts/__init__.py | 3 +- tests/components/tts/test_init.py | 55 ++++++++++++------------ 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 1946207337bae..4db62bee76f48 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -33,6 +33,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.network import async_get_url from homeassistant.helpers.typing import HomeAssistantType from homeassistant.setup import async_prepare_setup_platform @@ -114,7 +115,7 @@ async def async_setup(hass, config): use_cache = conf.get(CONF_CACHE, DEFAULT_CACHE) cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - base_url = conf.get(CONF_BASE_URL) or hass.config.api.base_url + base_url = conf.get(CONF_BASE_URL) or async_get_url(hass) await tts.async_init_cache(use_cache, cache_dir, time_memory, base_url) except (HomeAssistantError, KeyError) as err: diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 3fbf1245fa3d9..7afc69490c6fd 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -14,6 +14,7 @@ ) import homeassistant.components.tts as tts from homeassistant.components.tts import _get_cache_files +from homeassistant.config import async_process_ha_core_config from homeassistant.const import HTTP_NOT_FOUND from homeassistant.setup import async_setup_component @@ -84,6 +85,14 @@ def mutagen_mock(): yield +@pytest.fixture(autouse=True) +async def internal_url_mock(hass): + """Mock internal URL of the instance.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) + + async def test_setup_component_demo(hass): """Set up the demo platform with defaults.""" config = {tts.DOMAIN: {"platform": "demo"}} @@ -127,10 +136,9 @@ async def test_setup_component_and_test_service(hass, empty_cache_dir): assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format( - hass.config.api.base_url + assert ( + calls[0].data[ATTR_MEDIA_CONTENT_ID] + == "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" ) await hass.async_block_till_done() assert ( @@ -160,10 +168,9 @@ async def test_setup_component_and_test_service_with_config_language( ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format( - hass.config.api.base_url + assert ( + calls[0].data[ATTR_MEDIA_CONTENT_ID] + == "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" ) await hass.async_block_till_done() assert ( @@ -202,10 +209,9 @@ async def test_setup_component_and_test_service_with_service_language( ) assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format( - hass.config.api.base_url + assert ( + calls[0].data[ATTR_MEDIA_CONTENT_ID] + == "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" ) await hass.async_block_till_done() assert ( @@ -267,10 +273,9 @@ async def test_setup_component_and_test_service_with_service_options( assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format( - hass.config.api.base_url, opt_hash + assert ( + calls[0].data[ATTR_MEDIA_CONTENT_ID] + == "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3" ) await hass.async_block_till_done() assert ( @@ -305,10 +310,9 @@ async def test_setup_component_and_test_with_service_options_def(hass, empty_cac assert len(calls) == 1 assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format( - hass.config.api.base_url, opt_hash + assert ( + calls[0].data[ATTR_MEDIA_CONTENT_ID] + == f"http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" ) await hass.async_block_till_done() assert ( @@ -603,10 +607,9 @@ async def test_setup_component_test_with_cache_dir( blocking=True, ) assert len(calls) == 1 - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format( - hass.config.api.base_url + assert ( + calls[0].data[ATTR_MEDIA_CONTENT_ID] + == "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" ) @@ -662,9 +665,7 @@ async def test_setup_component_and_web_get_url(hass, hass_client): assert req.status == 200 response = await req.json() assert response.get("url") == ( - "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format( - hass.config.api.base_url - ) + "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" ) From 0549d66b365c8345814063ff2f962797134d60a4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 22:55:14 +0200 Subject: [PATCH 30/44] Fix plex tests after rebase --- tests/components/plex/test_config_flow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index a8c8d5c0f2e33..78f2af551831b 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -554,6 +554,9 @@ async def test_multiple_servers_with_import(hass): async def test_manual_config(hass): """Test creating via manual configuration.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"}, + ) class WrongCertValidaitionException(requests.exceptions.SSLError): """Mock the exception showing an unmatched error.""" From 0dec544c01d3752616959a62dcdcba33146ff34a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 23:38:49 +0200 Subject: [PATCH 31/44] Fix google_translate TTS tests --- tests/components/google_translate/test_tts.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 280258125dfb2..8e9ec9b7e1c7e 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -9,6 +9,7 @@ SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts +from homeassistant.config import async_process_ha_core_config from homeassistant.setup import setup_component from tests.async_mock import patch @@ -23,6 +24,13 @@ def setup_method(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + asyncio.run_coroutine_threadsafe( + async_process_ha_core_config( + self.hass, {"internal_url": "http://example.local:8123"} + ), + self.hass.loop, + ) + self.url = "https://translate.google.com/translate_tts" self.url_param = { "tl": "en", From 8f0ec6bebe4de42a77fdc8263a5768586c6e66d2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 23:39:22 +0200 Subject: [PATCH 32/44] Fix marytts TTS tests --- tests/components/marytts/test_tts.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 70a29fe11e184..637ed1900b84c 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -1,4 +1,5 @@ """The tests for the MaryTTS speech platform.""" +import asyncio import os import shutil from urllib.parse import urlencode @@ -11,6 +12,7 @@ SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts +from homeassistant.config import async_process_ha_core_config from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR from homeassistant.setup import setup_component @@ -24,6 +26,13 @@ def setup_method(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + asyncio.run_coroutine_threadsafe( + async_process_ha_core_config( + self.hass, {"internal_url": "http://example.local:8123"} + ), + self.hass.loop, + ) + self.host = "localhost" self.port = 59125 self.params = { From 5a63f6c9f33ec2e98c45f4e85a2ae7e5d0100fb0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 23:39:41 +0200 Subject: [PATCH 33/44] Fix voicerrs TTS tests --- tests/components/voicerss/test_tts.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index a65201735ae45..6d7dfcf3d7f38 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -9,6 +9,7 @@ SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts +from homeassistant.config import async_process_ha_core_config from homeassistant.setup import setup_component from tests.common import assert_setup_component, get_test_home_assistant, mock_service @@ -22,6 +23,13 @@ def setup_method(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + asyncio.run_coroutine_threadsafe( + async_process_ha_core_config( + self.hass, {"internal_url": "http://example.local:8123"} + ), + self.hass.loop, + ) + self.url = "https://api.voicerss.org/" self.form_data = { "key": "1234567xx", From be86e304a66d9969fa8c33d2c18bcbb8e36f7fe6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 23:40:04 +0200 Subject: [PATCH 34/44] Fix yandextts TTS tests --- tests/components/yandextts/test_tts.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index b1bf1bc8ab53c..d13ba867cd82f 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -8,6 +8,7 @@ SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts +from homeassistant.config import async_process_ha_core_config from homeassistant.const import HTTP_FORBIDDEN from homeassistant.setup import setup_component @@ -25,6 +26,13 @@ def setup_method(self): self.hass = get_test_home_assistant() self._base_url = "https://tts.voicetech.yandex.net/generate?" + asyncio.run_coroutine_threadsafe( + async_process_ha_core_config( + self.hass, {"internal_url": "http://example.local:8123"} + ), + self.hass.loop, + ) + def teardown_method(self): """Stop everything that was started.""" default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR) From 447e0c48877cff5516673bc4af2db2644fbb3e06 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 May 2020 23:50:17 +0200 Subject: [PATCH 35/44] Fix tts tests --- tests/components/tts/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 7afc69490c6fd..527fb559eb1d2 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -275,7 +275,7 @@ async def test_setup_component_and_test_service_with_service_options( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC assert ( calls[0].data[ATTR_MEDIA_CONTENT_ID] - == "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3" + == f"http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" ) await hass.async_block_till_done() assert ( From af91fb2674c6266e4d1735abf0bff4474faee147 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 7 May 2020 06:53:34 +0200 Subject: [PATCH 36/44] Remove SSL requirement from camera integration --- homeassistant/components/camera/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index ebcabbfc81b2a..d6213b2789248 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -685,7 +685,7 @@ async def async_handle_play_stream_service(camera, service_call): ) data = { ATTR_ENTITY_ID: entity_ids, - ATTR_MEDIA_CONTENT_ID: f"{async_get_url(hass, require_ssl=True)}{url}", + ATTR_MEDIA_CONTENT_ID: f"{async_get_url(hass)}{url}", ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt], } From 8cd58b33b39beb0163ce7c3f4a3b448d3cdaec01 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 7 May 2020 06:54:34 +0200 Subject: [PATCH 37/44] Remove camera stream SSL requirement from google_assistant integration --- homeassistant/components/google_assistant/trait.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index fa9ef1f375e5a..36ea501216156 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -248,12 +248,7 @@ async def execute(self, command, data, params, challenge): url = await self.hass.components.camera.async_request_stream( self.state.entity_id, "hls" ) - self.stream_info = { - "cameraStreamAccessUrl": async_get_url( - self.hass, allow_ip=False, require_ssl=True - ) - + url - } + self.stream_info = {"cameraStreamAccessUrl": f"{async_get_url(self.hass)}{url}"} @register_trait From d948b53077bfbc833bdcea33b7610cfebae7e132 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 7 May 2020 06:55:26 +0200 Subject: [PATCH 38/44] Always use 127.0.0.1 for main browser.open --- homeassistant/__main__.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index ca9c684e903e3..2e946b53e5e95 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -6,7 +6,7 @@ import subprocess import sys import threading -from typing import List, Optional +from typing import List import yarl @@ -260,26 +260,14 @@ async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int: if args.open_ui: import webbrowser # pylint: disable=import-outside-toplevel - from homeassistant.helpers import ( - network, - ) # pylint: disable=import-outside-toplevel - url: Optional[str] = None - try: - url = network.async_get_url(hass) - except network.NoURLAvailableError: - pass - - if url is None and hass.config.api is not None: - # Fallback to localhost + if hass.config.api is not None: scheme = "https" if hass.config.api.use_ssl else "http" url = str( yarl.URL.build( scheme=scheme, host="127.0.0.1", port=hass.config.api.port ) ) - - if url is not None: hass.add_job(webbrowser.open, url) return await hass.async_run() From 4061968ea2210bea6c887ccb40fc293503e0ded6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 7 May 2020 18:28:23 +0200 Subject: [PATCH 39/44] Add local_ip to http ApiConfig --- homeassistant/components/http/__init__.py | 12 ++++-- homeassistant/helpers/network.py | 3 +- tests/components/http/test_init.py | 30 ++++++++++----- tests/helpers/test_network.py | 46 +++++++++++------------ 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 029bf70112589..4c36eda42e611 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -105,9 +105,14 @@ class ApiConfig: """Configuration settings for API server.""" def __init__( - self, host: str, port: Optional[int] = SERVER_PORT, use_ssl: bool = False + self, + local_ip: str, + host: str, + port: Optional[int] = SERVER_PORT, + use_ssl: bool = False, ) -> None: """Initialize a new API config object.""" + self.local_ip = local_ip self.host = host self.port = port self.use_ssl = use_ssl @@ -185,6 +190,7 @@ async def start_server(event): hass.http = server host = conf.get(CONF_BASE_URL) + local_ip = await hass.async_add_executor_job(hass_util.get_local_ip) if host: port = None @@ -192,10 +198,10 @@ async def start_server(event): host = server_host port = server_port else: - host = hass_util.get_local_ip() + host = local_ip port = server_port - hass.config.api = ApiConfig(host, port, ssl_certificate is not None) + hass.config.api = ApiConfig(local_ip, host, port, ssl_certificate is not None) return True diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 0b1cec8ecbada..9d013fa04a8c0 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -7,7 +7,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.util import get_local_ip from homeassistant.util.network import ( is_ip_address, is_local, @@ -110,7 +109,7 @@ def _async_get_internal_url( require_ssl or hass.config.api is None or hass.config.api.use_ssl ): ip_url = yarl.URL.build( - scheme="http", host=get_local_ip(), port=hass.config.api.port + scheme="http", host=hass.config.api.local_ip, port=hass.config.api.port ) if not is_loopback(ip_address(ip_url.host)) and ( not require_standard_port or ip_url.is_default_port() diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 651783ec47ea7..557ccbb4aa59b 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -39,49 +39,54 @@ class TestApiConfig(unittest.TestCase): def test_api_base_url_with_domain(hass): """Test setting API URL with domain.""" - api_config = http.ApiConfig("example.com") + api_config = http.ApiConfig("127.0.0.1", "example.com") assert api_config.base_url == "http://example.com:8123" def test_api_base_url_with_ip(hass): """Test setting API URL with IP.""" - api_config = http.ApiConfig("1.1.1.1") + api_config = http.ApiConfig("127.0.0.1", "1.1.1.1") assert api_config.base_url == "http://1.1.1.1:8123" def test_api_base_url_with_ip_and_port(hass): """Test setting API URL with IP and port.""" - api_config = http.ApiConfig("1.1.1.1", 8124) + api_config = http.ApiConfig("127.0.0.1", "1.1.1.1", 8124) assert api_config.base_url == "http://1.1.1.1:8124" def test_api_base_url_with_protocol(hass): """Test setting API URL with protocol.""" - api_config = http.ApiConfig("https://example.com") + api_config = http.ApiConfig("127.0.0.1", "https://example.com") assert api_config.base_url == "https://example.com:8123" def test_api_base_url_with_protocol_and_port(hass): """Test setting API URL with protocol and port.""" - api_config = http.ApiConfig("https://example.com", 433) + api_config = http.ApiConfig("127.0.0.1", "https://example.com", 433) assert api_config.base_url == "https://example.com:433" def test_api_base_url_with_ssl_enable(hass): """Test setting API URL with use_ssl enabled.""" - api_config = http.ApiConfig("example.com", use_ssl=True) + api_config = http.ApiConfig("127.0.0.1", "example.com", use_ssl=True) assert api_config.base_url == "https://example.com:8123" def test_api_base_url_with_ssl_enable_and_port(hass): """Test setting API URL with use_ssl enabled and port.""" - api_config = http.ApiConfig("1.1.1.1", use_ssl=True, port=8888) + api_config = http.ApiConfig("127.0.0.1", "1.1.1.1", use_ssl=True, port=8888) assert api_config.base_url == "https://1.1.1.1:8888" def test_api_base_url_with_protocol_and_ssl_enable(hass): """Test setting API URL with specific protocol and use_ssl enabled.""" - api_config = http.ApiConfig("http://example.com", use_ssl=True) + api_config = http.ApiConfig("127.0.0.1", "http://example.com", use_ssl=True) assert api_config.base_url == "http://example.com:8123" def test_api_base_url_removes_trailing_slash(hass): """Test a trialing slash is removed when setting the API URL.""" - api_config = http.ApiConfig("http://example.com/") + api_config = http.ApiConfig("127.0.0.1", "http://example.com/") assert api_config.base_url == "http://example.com:8123" + def test_api_local_ip(hass): + """Test a trialing slash is removed when setting the API URL.""" + api_config = http.ApiConfig("127.0.0.1", "http://example.com/") + assert api_config.local_ip == "127.0.0.1" + async def test_api_base_url_with_domain(hass): """Test setting API URL.""" @@ -117,6 +122,13 @@ async def test_api_no_base_url(hass): assert hass.config.api.base_url == "http://127.0.0.1:8123" +async def test_api_local_ip(hass): + """Test setting api url.""" + result = await async_setup_component(hass, "http", {"http": {}}) + assert result + assert hass.config.api.local_ip == "127.0.0.1" + + async def test_api_base_url_removes_trailing_slash(hass): """Test setting api url.""" result = await async_setup_component( diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 8d76a38ae7b64..492a11b5f57b0 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -16,15 +16,6 @@ from tests.async_mock import Mock, patch -@pytest.fixture(autouse=True) -def mock_local_ip(): - """Mock the call to get_local_ip to return a fake internal IP address.""" - with patch( - "homeassistant.helpers.network.get_local_ip", return_value="192.168.123.123" - ) as local_ip: - yield local_ip - - async def test_get_url_internal(hass: HomeAssistant): """Test getting an instance URL when the user has set an internal URL.""" assert hass.config.internal_url is None @@ -127,7 +118,9 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): """Test getting an instance URL when the user has not set an internal URL.""" assert hass.config.internal_url is None - hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + hass.config.api = Mock( + use_ssl=False, port=8123, base_url=None, local_ip="192.168.123.123" + ) assert _async_get_internal_url(hass) == "http://192.168.123.123:8123" with pytest.raises(NoURLAvailableError): @@ -139,7 +132,9 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): with pytest.raises(NoURLAvailableError): _async_get_internal_url(hass, require_ssl=True) - hass.config.api = Mock(use_ssl=False, port=80, base_url=None) + hass.config.api = Mock( + use_ssl=False, port=80, base_url=None, local_ip="192.168.123.123" + ) assert _async_get_internal_url(hass) == "http://192.168.123.123" assert ( _async_get_internal_url(hass, require_standard_port=True) @@ -166,21 +161,18 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): _async_get_internal_url(hass, require_ssl=True) # Do no accept any local loopback address as fallback - with patch( - "homeassistant.helpers.network.get_local_ip", return_value="127.0.0.127" - ): - hass.config.api = Mock(use_ssl=False, port=80, base_url=None) - with pytest.raises(NoURLAvailableError): - _async_get_internal_url(hass) + hass.config.api = Mock(use_ssl=False, port=80, base_url=None, local_ip="127.0.0.1") + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass) - with pytest.raises(NoURLAvailableError): - _async_get_internal_url(hass, require_standard_port=True) + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_standard_port=True) - with pytest.raises(NoURLAvailableError): - _async_get_internal_url(hass, allow_ip=False) + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, allow_ip=False) - with pytest.raises(NoURLAvailableError): - _async_get_internal_url(hass, require_ssl=True) + with pytest.raises(NoURLAvailableError): + _async_get_internal_url(hass, require_ssl=True) async def test_get_url_external(hass: HomeAssistant): @@ -374,7 +366,9 @@ async def test_get_url(hass: HomeAssistant): with pytest.raises(NoURLAvailableError): async_get_url(hass) - hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + hass.config.api = Mock( + use_ssl=False, port=8123, base_url=None, local_ip="192.168.123.123" + ) assert async_get_url(hass) == "http://192.168.123.123:8123" assert async_get_url(hass, prefer_external=True) == "http://192.168.123.123:8123" @@ -568,7 +562,9 @@ async def test_get_deprecated_base_url_external(hass: HomeAssistant): async def test_get_internal_url_with_base_url_fallback(hass: HomeAssistant): """Test getting an internal instance URL with the deprecated base_url fallback.""" - hass.config.api = Mock(use_ssl=False, port=8123, base_url=None) + hass.config.api = Mock( + use_ssl=False, port=8123, base_url=None, local_ip="192.168.123.123" + ) assert hass.config.internal_url is None assert _async_get_internal_url(hass) == "http://192.168.123.123:8123" From 8cbe55c58a1418f392739bc0ebea1bc57aed5bbd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 7 May 2020 18:29:23 +0200 Subject: [PATCH 40/44] Raise exception in Alexa if no suitable URL found Co-authored-by: Paulus Schoutsen --- homeassistant/components/alexa/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 9d633a5f45a5d..69fd787ec1206 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1543,7 +1543,7 @@ async def async_api_initialize_camera_stream(hass, config, directive, context): require_standard_port=True, ) except network.NoURLAvailableError: - external_url = None + raise AlexaInvalidValueError("Failed to find suitable URL to serve to Alexa") payload = { "cameraStreams": [ From 5227ef959ebc9787955ef4193ed7b62157b06ce5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 7 May 2020 18:51:07 +0200 Subject: [PATCH 41/44] Remove Optional parameter from is_ip_address --- homeassistant/helpers/network.py | 13 +++++++------ homeassistant/util/network.py | 7 ++----- tests/util/test_network.py | 1 - 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 9d013fa04a8c0..6658b92070de4 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -88,7 +88,7 @@ def _async_get_internal_url( if ( (not require_ssl or internal_url.scheme == "https") and (not require_standard_port or internal_url.is_default_port()) - and (allow_ip or not is_ip_address(internal_url.host)) + and (allow_ip or not is_ip_address(str(internal_url.host))) ): return normalize_url(str(internal_url)) @@ -140,13 +140,13 @@ def _async_get_external_url( if hass.config.external_url: external_url = yarl.URL(hass.config.external_url) if ( - (allow_ip or not is_ip_address(external_url.host)) + (allow_ip or not is_ip_address(str(external_url.host))) and (not require_standard_port or external_url.is_default_port()) and ( not require_ssl or ( external_url.scheme == "https" - and not is_ip_address(external_url.host) + and not is_ip_address(str(external_url.host)) ) ) ): @@ -201,7 +201,7 @@ def _async_get_deprecated_base_url( base_url = yarl.URL(hass.config.api.base_url) # Rules that apply to both internal and external if ( - (allow_ip or not is_ip_address(base_url.host)) + (allow_ip or not is_ip_address(str(base_url.host))) and (not require_ssl or base_url.scheme == "https") and (not require_standard_port or base_url.is_default_port()) ): @@ -209,7 +209,7 @@ def _async_get_deprecated_base_url( if internal and ( str(base_url.host).endswith(".local") or ( - is_ip_address(base_url.host) + is_ip_address(str(base_url.host)) and not is_loopback(ip_address(base_url.host)) and is_private(ip_address(base_url.host)) ) @@ -221,7 +221,8 @@ def _async_get_deprecated_base_url( not internal and not str(base_url.host).endswith(".local") and not ( - is_ip_address(base_url.host) and is_local(ip_address(base_url.host)) + is_ip_address(str(base_url.host)) + and is_local(ip_address(str(base_url.host))) ) ): return normalize_url(str(base_url)) diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index c098eeb521297..94b43ad78033b 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -1,6 +1,6 @@ """Network utilities.""" from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network -from typing import Optional, Union +from typing import Union import yarl @@ -43,11 +43,8 @@ def is_local(address: Union[IPv4Address, IPv6Address]) -> bool: return is_loopback(address) or is_private(address) -def is_ip_address(address: Optional[str]) -> bool: +def is_ip_address(address: str) -> bool: """Check if a given string is an IP address.""" - if address is None: - return False - try: ip_address(address) except ValueError: diff --git a/tests/util/test_network.py b/tests/util/test_network.py index c5fa237214d8d..2cd710e1d6c46 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -45,7 +45,6 @@ def test_is_ip_address(): assert network_util.is_ip_address("192.168.0.1") assert network_util.is_ip_address("8.8.8.8") assert network_util.is_ip_address("::ffff:127.0.0.0") - assert not network_util.is_ip_address(None) assert not network_util.is_ip_address("192.168.0.999") assert not network_util.is_ip_address("192.168.0.0/24") assert not network_util.is_ip_address("example.com") From 6d1ffe845ea2614dde4eea4738d8a49f1eff295c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 7 May 2020 20:06:05 +0200 Subject: [PATCH 42/44] Migrate base_url to core on first load --- homeassistant/core.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 4df24ec5f0379..3161ef7c6f877 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -9,6 +9,7 @@ import datetime import enum import functools +from ipaddress import ip_address import logging import os import pathlib @@ -36,6 +37,7 @@ from async_timeout import timeout import attr import voluptuous as vol +import yarl from homeassistant import block_async_io, loader, util from homeassistant.const import ( @@ -69,7 +71,7 @@ ServiceNotFound, Unauthorized, ) -from homeassistant.util import location +from homeassistant.util import location, network from homeassistant.util.async_ import fire_coroutine_threadsafe, run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.thread import fix_threading_exception_logging @@ -1454,6 +1456,34 @@ async def async_load(self) -> None: if not data: return + async def migrate_base_url(_: Event) -> None: + """Migrate base_url to internal_url/external_url.""" + if self.hass.config.api is not None: + base_url = yarl.URL(self.hass.config.api.base_url) + + # Check if this is an internal URL + if str(base_url.host).endswith(".local") or ( + network.is_ip_address(str(base_url.host)) + and network.is_private(ip_address(base_url.host)) + ): + await self.async_update( + internal_url=network.normalize_url(str(base_url)) + ) + return + + # External, ensure this is not a loopback address + if not ( + network.is_ip_address(str(base_url.host)) + and network.is_loopback(ip_address(base_url.host)) + ): + await self.async_update( + external_url=network.normalize_url(str(base_url)) + ) + + # Try to migrate base_url to internal_url/external_url + if "external_url" not in data: + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, migrate_base_url) + self._update(source=SOURCE_STORAGE, **data) async def async_store(self) -> None: From 122378c00e27923f1dc9193a1c21dec85bd7cf99 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 7 May 2020 15:22:13 -0700 Subject: [PATCH 43/44] Test migration base_url -> internal/external --- homeassistant/core.py | 54 +++++++++++++++++++++++-------------------- tests/test_core.py | 40 ++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 3161ef7c6f877..64d99c30838db 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1453,38 +1453,42 @@ async def async_load(self) -> None: CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True ) data = await store.async_load() - if not data: + + if data and "external_url" in data: + self._update(source=SOURCE_STORAGE, **data) return async def migrate_base_url(_: Event) -> None: """Migrate base_url to internal_url/external_url.""" - if self.hass.config.api is not None: - base_url = yarl.URL(self.hass.config.api.base_url) - - # Check if this is an internal URL - if str(base_url.host).endswith(".local") or ( - network.is_ip_address(str(base_url.host)) - and network.is_private(ip_address(base_url.host)) - ): - await self.async_update( - internal_url=network.normalize_url(str(base_url)) - ) - return - - # External, ensure this is not a loopback address - if not ( - network.is_ip_address(str(base_url.host)) - and network.is_loopback(ip_address(base_url.host)) - ): - await self.async_update( - external_url=network.normalize_url(str(base_url)) - ) + if self.hass.config.api is None: + return + + base_url = yarl.URL(self.hass.config.api.base_url) + + # Check if this is an internal URL + if str(base_url.host).endswith(".local") or ( + network.is_ip_address(str(base_url.host)) + and network.is_private(ip_address(base_url.host)) + ): + await self.async_update( + internal_url=network.normalize_url(str(base_url)) + ) + return + + # External, ensure this is not a loopback address + if not ( + network.is_ip_address(str(base_url.host)) + and network.is_loopback(ip_address(base_url.host)) + ): + await self.async_update( + external_url=network.normalize_url(str(base_url)) + ) # Try to migrate base_url to internal_url/external_url - if "external_url" not in data: - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, migrate_base_url) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, migrate_base_url) - self._update(source=SOURCE_STORAGE, **data) + if data: + self._update(source=SOURCE_STORAGE, **data) async def async_store(self) -> None: """Store [homeassistant] core config.""" diff --git a/tests/test_core.py b/tests/test_core.py index fd8349954e215..e04b874e03ef8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -21,6 +21,7 @@ EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_FINAL_WRITE, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, @@ -34,7 +35,7 @@ import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM -from tests.async_mock import MagicMock, patch +from tests.async_mock import MagicMock, Mock, patch from tests.common import async_mock_service, get_test_home_assistant PST = pytz.timezone("America/Los_Angeles") @@ -950,7 +951,7 @@ def test_is_allowed_path(self): self.config.is_allowed_path(None) -async def test_event_on_update(hass, hass_storage): +async def test_event_on_update(hass): """Test that event is fired on update.""" events = [] @@ -1283,3 +1284,38 @@ def test_valid_entity_id(): "light.something_yoo", ]: assert ha.valid_entity_id(valid), valid + + +async def test_migration_base_url_internal(hass, hass_storage): + """Test that we migrate base url to internal url.""" + config = ha.Config(hass) + stored = {"version": 1, "data": {}} + hass_storage[ha.CORE_STORAGE_KEY] = stored + with patch.object(hass.bus, "async_listen_once") as mock_listen: + # Empty config + await config.async_load() + assert len(mock_listen.mock_calls) == 1 + + # With just a name + stored["data"] = {"location_name": "Test Name"} + await config.async_load() + assert len(mock_listen.mock_calls) == 2 + + # With external url + stored["data"]["external_url"] = "https://example.com" + await config.async_load() + assert len(mock_listen.mock_calls) == 2 + + # Test that the event listener works + assert mock_listen.mock_calls[0][1][0] == EVENT_HOMEASSISTANT_START + + # External + hass.config.api = Mock(base_url="https://loaded-example.com") + await mock_listen.mock_calls[0][1][1](None) + assert config.external_url == "https://loaded-example.com" + + # Internal + for internal in ("http://hass.local", "http://192.168.1.100:8123"): + hass.config.api = Mock(base_url=internal) + await mock_listen.mock_calls[0][1][1](None) + assert config.internal_url == internal From e991cc7f4592a668aace1e7a5cbc720317b09806 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 7 May 2020 15:23:16 -0700 Subject: [PATCH 44/44] Better test name --- tests/test_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index e04b874e03ef8..bac17479c876d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1286,8 +1286,8 @@ def test_valid_entity_id(): assert ha.valid_entity_id(valid), valid -async def test_migration_base_url_internal(hass, hass_storage): - """Test that we migrate base url to internal url.""" +async def test_migration_base_url(hass, hass_storage): + """Test that we migrate base url to internal/external url.""" config = ha.Config(hass) stored = {"version": 1, "data": {}} hass_storage[ha.CORE_STORAGE_KEY] = stored