Home Assistant Unofficial Reference 2024.12.1
network.py
Go to the documentation of this file.
1 """Network helpers."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from contextlib import suppress
7 from ipaddress import ip_address
8 
9 from aiohttp import hdrs
10 from hass_nabucasa import remote
11 import yarl
12 
13 from homeassistant.components import http
14 from homeassistant.core import HomeAssistant
15 from homeassistant.exceptions import HomeAssistantError
16 from homeassistant.loader import bind_hass
17 from homeassistant.util.network import is_ip_address, is_loopback, normalize_url
18 
19 from .hassio import is_hassio
20 
21 TYPE_URL_INTERNAL = "internal_url"
22 TYPE_URL_EXTERNAL = "external_url"
23 SUPERVISOR_NETWORK_HOST = "homeassistant"
24 
25 
27  """An URL to the Home Assistant instance is not available."""
28 
29 
30 @bind_hass
31 def is_internal_request(hass: HomeAssistant) -> bool:
32  """Test if the current request is internal."""
33  try:
34  get_url(
35  hass, allow_external=False, allow_cloud=False, require_current_request=True
36  )
37  except NoURLAvailableError:
38  return False
39  return True
40 
41 
42 @bind_hass
44  hass: HomeAssistant, *, allow_ssl: bool = False
45 ) -> str | None:
46  """Get URL for home assistant within supervisor network."""
47  if hass.config.api is None or not is_hassio(hass):
48  return None
49 
50  scheme = "http"
51  if hass.config.api.use_ssl:
52  # Certificate won't be valid for hostname so this URL usually won't work
53  if not allow_ssl:
54  return None
55 
56  scheme = "https"
57 
58  return str(
59  yarl.URL.build(
60  scheme=scheme,
61  host=SUPERVISOR_NETWORK_HOST,
62  port=hass.config.api.port,
63  )
64  )
65 
66 
67 def is_hass_url(hass: HomeAssistant, url: str) -> bool:
68  """Return if the URL points at this Home Assistant instance."""
69  parsed = yarl.URL(url)
70 
71  if not parsed.is_absolute():
72  return False
73 
74  if parsed.is_default_port():
75  parsed = parsed.with_port(None)
76 
77  def host_ip() -> str | None:
78  if hass.config.api is None or is_loopback(ip_address(hass.config.api.local_ip)):
79  return None
80 
81  return str(
82  yarl.URL.build(
83  scheme="http", host=hass.config.api.local_ip, port=hass.config.api.port
84  )
85  )
86 
87  def cloud_url() -> str | None:
88  try:
89  return _get_cloud_url(hass)
90  except NoURLAvailableError:
91  return None
92 
93  potential_base_factory: Callable[[], str | None]
94  for potential_base_factory in (
95  lambda: hass.config.internal_url,
96  lambda: hass.config.external_url,
97  cloud_url,
98  host_ip,
99  lambda: get_supervisor_network_url(hass, allow_ssl=True),
100  ):
101  potential_base = potential_base_factory()
102 
103  if potential_base is None:
104  continue
105 
106  potential_parsed = yarl.URL(normalize_url(potential_base))
107 
108  if (
109  parsed.scheme == potential_parsed.scheme
110  and parsed.authority == potential_parsed.authority
111  ):
112  return True
113 
114  return False
115 
116 
117 @bind_hass
119  hass: HomeAssistant,
120  *,
121  require_current_request: bool = False,
122  require_ssl: bool = False,
123  require_standard_port: bool = False,
124  require_cloud: bool = False,
125  allow_internal: bool = True,
126  allow_external: bool = True,
127  allow_cloud: bool = True,
128  allow_ip: bool | None = None,
129  prefer_external: bool | None = None,
130  prefer_cloud: bool = False,
131 ) -> str:
132  """Get a URL to this instance."""
133  if require_current_request and http.current_request.get() is None:
134  raise NoURLAvailableError
135 
136  if prefer_external is None:
137  prefer_external = hass.config.api is not None and hass.config.api.use_ssl
138 
139  if allow_ip is None:
140  allow_ip = hass.config.api is None or not hass.config.api.use_ssl
141 
142  order = [TYPE_URL_INTERNAL, TYPE_URL_EXTERNAL]
143  if prefer_external:
144  order.reverse()
145 
146  # Try finding an URL in the order specified
147  for url_type in order:
148  if allow_internal and url_type == TYPE_URL_INTERNAL and not require_cloud:
149  with suppress(NoURLAvailableError):
150  return _get_internal_url(
151  hass,
152  allow_ip=allow_ip,
153  require_current_request=require_current_request,
154  require_ssl=require_ssl,
155  require_standard_port=require_standard_port,
156  )
157 
158  if require_cloud or (allow_external and url_type == TYPE_URL_EXTERNAL):
159  with suppress(NoURLAvailableError):
160  return _get_external_url(
161  hass,
162  allow_cloud=allow_cloud,
163  allow_ip=allow_ip,
164  prefer_cloud=prefer_cloud,
165  require_current_request=require_current_request,
166  require_ssl=require_ssl,
167  require_standard_port=require_standard_port,
168  require_cloud=require_cloud,
169  )
170  if require_cloud:
171  raise NoURLAvailableError
172 
173  # For current request, we accept loopback interfaces (e.g., 127.0.0.1),
174  # the Supervisor hostname and localhost transparently
175  request_host = _get_request_host()
176  if (
177  require_current_request
178  and request_host is not None
179  and hass.config.api is not None
180  ):
181  scheme = "https" if hass.config.api.use_ssl else "http"
182  current_url = yarl.URL.build(
183  scheme=scheme, host=request_host, port=hass.config.api.port
184  )
185 
186  known_hostnames = ["localhost"]
187  if is_hassio(hass):
188  # Local import to avoid circular dependencies
189  # pylint: disable-next=import-outside-toplevel
190  from homeassistant.components.hassio import get_host_info
191 
192  if host_info := get_host_info(hass):
193  known_hostnames.extend(
194  [host_info["hostname"], f"{host_info['hostname']}.local"]
195  )
196 
197  if (
198  (
199  (
200  allow_ip
201  and is_ip_address(request_host)
202  and is_loopback(ip_address(request_host))
203  )
204  or request_host in known_hostnames
205  )
206  and (not require_ssl or current_url.scheme == "https")
207  and (not require_standard_port or current_url.is_default_port())
208  ):
209  return normalize_url(str(current_url))
210 
211  # We have to be honest now, we have no viable option available
212  raise NoURLAvailableError
213 
214 
215 def _get_request_host() -> str | None:
216  """Get the host address of the current request."""
217  if (request := http.current_request.get()) is None:
218  raise NoURLAvailableError
219  # partition the host to remove the port
220  # because the raw host header can contain the port
221  host = request.headers.get(hdrs.HOST)
222  if host is None:
223  return None
224  # IPv6 addresses are enclosed in brackets
225  # use same logic as yarl and urllib to extract the host
226  if "[" in host:
227  return (host.partition("[")[2]).partition("]")[0]
228  if ":" in host:
229  host = host.partition(":")[0]
230  return host
231 
232 
233 @bind_hass
235  hass: HomeAssistant,
236  *,
237  allow_ip: bool = True,
238  require_current_request: bool = False,
239  require_ssl: bool = False,
240  require_standard_port: bool = False,
241 ) -> str:
242  """Get internal URL of this instance."""
243  if hass.config.internal_url:
244  internal_url = yarl.URL(hass.config.internal_url)
245  if (
246  (not require_current_request or internal_url.host == _get_request_host())
247  and (not require_ssl or internal_url.scheme == "https")
248  and (not require_standard_port or internal_url.is_default_port())
249  and (allow_ip or not is_ip_address(str(internal_url.host)))
250  ):
251  return normalize_url(str(internal_url))
252 
253  # Fallback to detected local IP
254  if allow_ip and not (
255  require_ssl or hass.config.api is None or hass.config.api.use_ssl
256  ):
257  ip_url = yarl.URL.build(
258  scheme="http", host=hass.config.api.local_ip, port=hass.config.api.port
259  )
260  if (
261  ip_url.host
262  and not is_loopback(ip_address(ip_url.host))
263  and (not require_current_request or ip_url.host == _get_request_host())
264  and (not require_standard_port or ip_url.is_default_port())
265  ):
266  return normalize_url(str(ip_url))
267 
268  raise NoURLAvailableError
269 
270 
271 @bind_hass
273  hass: HomeAssistant,
274  *,
275  allow_cloud: bool = True,
276  allow_ip: bool = True,
277  prefer_cloud: bool = False,
278  require_current_request: bool = False,
279  require_ssl: bool = False,
280  require_standard_port: bool = False,
281  require_cloud: bool = False,
282 ) -> str:
283  """Get external URL of this instance."""
284  if require_cloud:
285  return _get_cloud_url(hass, require_current_request=require_current_request)
286 
287  if prefer_cloud and allow_cloud:
288  with suppress(NoURLAvailableError):
289  return _get_cloud_url(hass)
290 
291  if hass.config.external_url:
292  external_url = yarl.URL(hass.config.external_url)
293  if (
294  (allow_ip or not is_ip_address(str(external_url.host)))
295  and (
296  not require_current_request or external_url.host == _get_request_host()
297  )
298  and (not require_standard_port or external_url.is_default_port())
299  and (
300  not require_ssl
301  or (
302  external_url.scheme == "https"
303  and not is_ip_address(str(external_url.host))
304  )
305  )
306  ):
307  return normalize_url(str(external_url))
308 
309  if allow_cloud:
310  with suppress(NoURLAvailableError):
311  return _get_cloud_url(hass, require_current_request=require_current_request)
312 
313  raise NoURLAvailableError
314 
315 
316 @bind_hass
317 def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) -> str:
318  """Get external Home Assistant Cloud URL of this instance."""
319  if "cloud" in hass.config.components:
320  # Local import to avoid circular dependencies
321  # pylint: disable-next=import-outside-toplevel
322  from homeassistant.components.cloud import (
323  CloudNotAvailable,
324  async_remote_ui_url,
325  )
326 
327  try:
328  cloud_url = yarl.URL(async_remote_ui_url(hass))
329  except CloudNotAvailable as err:
330  raise NoURLAvailableError from err
331 
332  if not require_current_request or cloud_url.host == _get_request_host():
333  return normalize_url(str(cloud_url))
334 
335  raise NoURLAvailableError
336 
337 
338 def is_cloud_connection(hass: HomeAssistant) -> bool:
339  """Return True if the current connection is a nabucasa cloud connection."""
340 
341  if "cloud" not in hass.config.components:
342  return False
343 
344  return remote.is_cloud_request.get()
str async_remote_ui_url(HomeAssistant hass)
Definition: __init__.py:227
dict[str, Any]|None get_host_info(HomeAssistant hass)
Definition: coordinator.py:79
bool is_hassio(HomeAssistant hass)
Definition: __init__.py:302
bool is_internal_request(HomeAssistant hass)
Definition: network.py:31
str|None get_supervisor_network_url(HomeAssistant hass, *bool allow_ssl=False)
Definition: network.py:45
bool is_cloud_connection(HomeAssistant hass)
Definition: network.py:338
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)
Definition: network.py:131
str _get_internal_url(HomeAssistant hass, *bool allow_ip=True, bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False)
Definition: network.py:241
str _get_external_url(HomeAssistant hass, *bool allow_cloud=True, bool allow_ip=True, bool prefer_cloud=False, bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False)
Definition: network.py:282
bool is_hass_url(HomeAssistant hass, str url)
Definition: network.py:67
str _get_cloud_url(HomeAssistant hass, bool require_current_request=False)
Definition: network.py:317
bool is_loopback(IPv4Address|IPv6Address address)
Definition: network.py:38
str normalize_url(str address)
Definition: network.py:107
bool is_ip_address(str address)
Definition: network.py:63