Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Webhooks for Home Assistant."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Awaitable, Callable, Iterable
6 from http import HTTPStatus
7 from ipaddress import ip_address
8 import logging
9 import secrets
10 from typing import TYPE_CHECKING, Any
11 
12 from aiohttp import StreamReader
13 from aiohttp.hdrs import METH_GET, METH_HEAD, METH_POST, METH_PUT
14 from aiohttp.web import Request, Response
15 import voluptuous as vol
16 
17 from homeassistant.components import websocket_api
18 from homeassistant.components.http import KEY_HASS, HomeAssistantView
19 from homeassistant.core import HomeAssistant, callback
20 from homeassistant.helpers import config_validation as cv
21 from homeassistant.helpers.network import get_url, is_cloud_connection
22 from homeassistant.helpers.typing import ConfigType
23 from homeassistant.loader import bind_hass
24 from homeassistant.util import network
25 from homeassistant.util.aiohttp import MockRequest, MockStreamReader, serialize_response
26 
27 _LOGGER = logging.getLogger(__name__)
28 
29 DOMAIN = "webhook"
30 
31 DEFAULT_METHODS = (METH_POST, METH_PUT)
32 SUPPORTED_METHODS = (METH_GET, METH_HEAD, METH_POST, METH_PUT)
33 URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}"
34 
35 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
36 
37 
38 @callback
39 @bind_hass
41  hass: HomeAssistant,
42  domain: str,
43  name: str,
44  webhook_id: str,
45  handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]],
46  *,
47  local_only: bool | None = False,
48  allowed_methods: Iterable[str] | None = None,
49 ) -> None:
50  """Register a webhook."""
51  handlers = hass.data.setdefault(DOMAIN, {})
52 
53  if webhook_id in handlers:
54  raise ValueError("Handler is already defined!")
55 
56  if allowed_methods is None:
57  allowed_methods = DEFAULT_METHODS
58  allowed_methods = frozenset(allowed_methods)
59 
60  if not allowed_methods.issubset(SUPPORTED_METHODS):
61  raise ValueError(
62  f"Unexpected method: {allowed_methods.difference(SUPPORTED_METHODS)}"
63  )
64 
65  handlers[webhook_id] = {
66  "domain": domain,
67  "name": name,
68  "handler": handler,
69  "local_only": local_only,
70  "allowed_methods": allowed_methods,
71  }
72 
73 
74 @callback
75 @bind_hass
76 def async_unregister(hass: HomeAssistant, webhook_id: str) -> None:
77  """Remove a webhook."""
78  handlers = hass.data.setdefault(DOMAIN, {})
79  handlers.pop(webhook_id, None)
80 
81 
82 @callback
83 def async_generate_id() -> str:
84  """Generate a webhook_id."""
85  return secrets.token_hex(32)
86 
87 
88 @callback
89 @bind_hass
91  hass: HomeAssistant,
92  webhook_id: str,
93  allow_internal: bool = True,
94  allow_external: bool = True,
95  allow_ip: bool | None = None,
96  prefer_external: bool | None = True,
97 ) -> str:
98  """Generate the full URL for a webhook_id."""
99  return (
100  f"{get_url(
101  hass,
102  allow_internal=allow_internal,
103  allow_external=allow_external,
104  allow_cloud=False,
105  allow_ip=allow_ip,
106  prefer_external=prefer_external,
107  )}"
108  f"{async_generate_path(webhook_id)}"
109  )
110 
111 
112 @callback
113 def async_generate_path(webhook_id: str) -> str:
114  """Generate the path component for a webhook_id."""
115  return URL_WEBHOOK_PATH.format(webhook_id=webhook_id)
116 
117 
118 @bind_hass
120  hass: HomeAssistant, webhook_id: str, request: Request | MockRequest
121 ) -> Response:
122  """Handle a webhook."""
123  handlers: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {})
124 
125  content_stream: StreamReader | MockStreamReader
126  if isinstance(request, MockRequest):
127  received_from = request.mock_source
128  content_stream = request.content
129  method_name = request.method
130  else:
131  received_from = request.remote
132  content_stream = request.content
133  method_name = request.method
134 
135  # Always respond successfully to not give away if a hook exists or not.
136  if (webhook := handlers.get(webhook_id)) is None:
137  _LOGGER.info(
138  "Received message for unregistered webhook %s from %s",
139  webhook_id,
140  received_from,
141  )
142  # Look at content to provide some context for received webhook
143  # Limit to 64 chars to avoid flooding the log
144  content = await content_stream.read(64)
145  _LOGGER.debug("%s", content)
146  return Response(status=HTTPStatus.OK)
147 
148  if method_name not in webhook["allowed_methods"]:
149  if method_name == METH_HEAD:
150  # Allow websites to verify that the URL exists.
151  return Response(status=HTTPStatus.OK)
152 
153  _LOGGER.warning(
154  "Webhook %s only supports %s methods but %s was received from %s",
155  webhook_id,
156  ",".join(webhook["allowed_methods"]),
157  method_name,
158  received_from,
159  )
160  return Response(status=HTTPStatus.METHOD_NOT_ALLOWED)
161 
162  if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest):
163  is_local = not is_cloud_connection(hass)
164  if is_local:
165  if TYPE_CHECKING:
166  assert isinstance(request, Request)
167  assert request.remote is not None
168 
169  try:
170  request_remote = ip_address(request.remote)
171  except ValueError:
172  _LOGGER.debug("Unable to parse remote ip %s", request.remote)
173  return Response(status=HTTPStatus.OK)
174 
175  is_local = network.is_local(request_remote)
176 
177  if not is_local:
178  _LOGGER.warning("Received remote request for local webhook %s", webhook_id)
179  if webhook["local_only"]:
180  return Response(status=HTTPStatus.OK)
181  if not webhook.get("warned_about_deprecation"):
182  webhook["warned_about_deprecation"] = True
183  _LOGGER.warning(
184  "Deprecation warning: "
185  "Webhook '%s' does not provide a value for local_only. "
186  "This webhook will be blocked after the 2023.11.0 release. "
187  "Use `local_only: false` to keep this webhook operating as-is",
188  webhook_id,
189  )
190 
191  try:
192  response: Response | None = await webhook["handler"](hass, webhook_id, request)
193  if response is None:
194  response = Response(status=HTTPStatus.OK)
195  except Exception:
196  _LOGGER.exception("Error processing webhook %s", webhook_id)
197  return Response(status=HTTPStatus.OK)
198  return response
199 
200 
201 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
202  """Initialize the webhook component."""
203  hass.http.register_view(WebhookView)
204  websocket_api.async_register_command(hass, websocket_list)
205  websocket_api.async_register_command(hass, websocket_handle)
206  return True
207 
208 
209 class WebhookView(HomeAssistantView):
210  """Handle incoming webhook requests."""
211 
212  url = URL_WEBHOOK_PATH
213  name = "api:webhook"
214  requires_auth = False
215  cors_allowed = True
216 
217  async def _handle(self, request: Request, webhook_id: str) -> Response:
218  """Handle webhook call."""
219  _LOGGER.debug("Handling webhook %s payload for %s", request.method, webhook_id)
220  hass = request.app[KEY_HASS]
221  return await async_handle_webhook(hass, webhook_id, request)
222 
223  get = _handle
224  head = _handle
225  post = _handle
226  put = _handle
227 
228 
229 @websocket_api.websocket_command( { "type": "webhook/list", } )
230 @callback
231 def websocket_list(
232  hass: HomeAssistant,
233  connection: websocket_api.ActiveConnection,
234  msg: dict[str, Any],
235 ) -> None:
236  """Return a list of webhooks."""
237  handlers = hass.data.setdefault(DOMAIN, {})
238  result = [
239  {
240  "webhook_id": webhook_id,
241  "domain": info["domain"],
242  "name": info["name"],
243  "local_only": info["local_only"],
244  "allowed_methods": sorted(info["allowed_methods"]),
245  }
246  for webhook_id, info in handlers.items()
247  ]
248 
249  connection.send_message(websocket_api.result_message(msg["id"], result))
250 
251 
252 @websocket_api.websocket_command( { vol.Required("type"): "webhook/handle",
253  vol.Required("webhook_id"): str,
254  vol.Required("method"): vol.In(SUPPORTED_METHODS),
255  vol.Optional("body", default=""): str,
256  vol.Optional("headers", default={}): {str: str},
257  vol.Optional("query", default=""): str,
258  }
259 )
260 @websocket_api.async_response
261 async def websocket_handle(
262  hass: HomeAssistant,
263  connection: websocket_api.ActiveConnection,
264  msg: dict[str, Any],
265 ) -> None:
266  """Handle an incoming webhook via the WS API."""
267  request = MockRequest(
268  content=msg["body"].encode("utf-8"),
269  headers=msg["headers"],
270  method=msg["method"],
271  query_string=msg["query"],
272  mock_source=f"{DOMAIN}/ws",
273  )
274 
275  response = await async_handle_webhook(hass, msg["webhook_id"], request)
276 
277  response_dict = serialize_response(response)
278  body = response_dict.get("body")
279 
280  connection.send_result(
281  msg["id"],
282  {
283  "body": body,
284  "status": response_dict["status"],
285  "headers": {"Content-Type": response.content_type},
286  },
287  )
288 
Response _handle(self, Request request, str webhook_id)
Definition: __init__.py:217
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:201
None websocket_list(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: __init__.py:239
None async_unregister(HomeAssistant hass, str webhook_id)
Definition: __init__.py:76
None async_register(HomeAssistant hass, str domain, str name, str webhook_id, Callable[[HomeAssistant, str, Request], Awaitable[Response|None]] handler, *bool|None local_only=False, Iterable[str]|None allowed_methods=None)
Definition: __init__.py:49
Response async_handle_webhook(HomeAssistant hass, str webhook_id, Request|MockRequest request)
Definition: __init__.py:121
None websocket_handle(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: __init__.py:271
str async_generate_path(str webhook_id)
Definition: __init__.py:113
str async_generate_url(HomeAssistant hass, str webhook_id, bool allow_internal=True, bool allow_external=True, bool|None allow_ip=None, bool|None prefer_external=True)
Definition: __init__.py:97
bool is_cloud_connection(HomeAssistant hass)
Definition: network.py:338
dict[str, Any] serialize_response(web.Response response)
Definition: aiohttp.py:107