590 lines
24 KiB
Python
590 lines
24 KiB
Python
"""Webhook ingress for the in-process ha-mcp server (issue #1527).
|
|
|
|
Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook
|
|
(``/api/webhook/<id>``) forwards MCP traffic to the loopback server and streams
|
|
the response back, so the server is reachable through Nabu Casa remote UI (or any
|
|
reverse proxy) with the webhook id as the shared secret.
|
|
|
|
Two auth postures, chosen in the options flow:
|
|
|
|
* ``none`` — the secret webhook URL *is* the credential (matches the add-on's
|
|
default). No bearer is required.
|
|
* ``ha_auth`` — Home Assistant core is the OAuth authorization server. This
|
|
module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai /
|
|
ChatGPT can sign in with the user's HA account) and validates inbound bearer
|
|
tokens via ``hass.auth``. There is no bespoke authorization-server code here —
|
|
every protocol step is HA core's own ``/auth/*``.
|
|
|
|
The forwarding handler mirrors ``mcp_proxy._handle_webhook`` exactly (hop-by-hop
|
|
header stripping, the SSE streaming branch with anti-buffering headers, the
|
|
content-type whitelist, ``Mcp-Session-Id`` propagation, and the 502/500 error
|
|
mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's
|
|
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import logging
|
|
from contextlib import suppress
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import aiohttp
|
|
from aiohttp import web
|
|
from homeassistant.components.http import HomeAssistantView
|
|
from homeassistant.components.webhook import async_register, async_unregister
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from .const import (
|
|
DATA_WEBHOOK,
|
|
DATA_WEBHOOK_ID,
|
|
DOMAIN,
|
|
OAUTH_BASE,
|
|
WEBHOOK_AUTH_HA,
|
|
WEBHOOK_AUTH_NONE,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Human-readable webhook name shown in the HA webhook registry.
|
|
_WEBHOOK_NAME = "HA-MCP in-process server"
|
|
|
|
# Hop-by-hop / sensitive request headers never forwarded upstream (identical set
|
|
# to mcp_proxy). ``authorization`` is stripped because the server authenticates
|
|
# to HA with its own provisioned token, not the caller's bearer.
|
|
_STRIPPED_REQUEST_HEADERS = frozenset(
|
|
{
|
|
"host",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
"connection",
|
|
"cookie",
|
|
"authorization",
|
|
}
|
|
)
|
|
|
|
# Content-Types the forwarded response may carry as-is; anything else is coerced
|
|
# to JSON to prevent HTML injection / XSS through the proxy. ``text/plain`` is
|
|
# safe (a browser never executes it) and lets the server's friendly landing page
|
|
# — a plain-text 405 shown when a browser GETs the endpoint — render as text
|
|
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
|
|
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
|
|
|
|
# Long timeout for streamed MCP responses (matches mcp_proxy).
|
|
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300)
|
|
|
|
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
|
|
# for this HA session. Deliberately NOT under DOMAIN so it survives
|
|
# async_unload_entry's teardown — aiohttp cannot unregister an HTTP view until HA
|
|
# restarts, so the views (and this ownership flag) must outlive the config entry.
|
|
_OAUTH_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_metadata_views_registered"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ha_auth resource server (HA core is the OAuth authorization server)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_base_url(request: web.Request) -> str:
|
|
"""Build the public base URL from the request (host-derived).
|
|
|
|
ha_auth is always host-derived so the SAME install works via the Nabu Casa
|
|
cloud URL AND any other external URL. Reads ``X-Forwarded-Proto/Host`` as
|
|
sent: HA's forwarded middleware only validates proxy headers when
|
|
``X-Forwarded-For`` is present, so these can reach us raw. A peer can
|
|
thereby only shape the discovery/WWW-Authenticate URLs in its OWN
|
|
response (no cross-user vector), which is within SECURITY.md's
|
|
local-network trust model; treat stricter proxy validation as optional
|
|
hardening.
|
|
"""
|
|
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "")
|
|
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
|
|
return f"{scheme}://{host}"
|
|
|
|
|
|
def _authorization_server_document(base: str) -> dict[str, Any]:
|
|
"""RFC 8414 authorization-server metadata pointing at HA core's OAuth.
|
|
|
|
Advertises HA core's own ``/auth/authorize`` + ``/auth/token`` as a public
|
|
client (``token_endpoint_auth_methods_supported: ["none"]``) and
|
|
``client_id_metadata_document_supported`` so clients present a URL-shaped
|
|
``client_id`` (CIMD) that HA core's long-standing IndieAuth handling accepts —
|
|
the user never pastes a credential. No ``registration_endpoint``: HA offers no
|
|
dynamic client registration; CIMD replaces it.
|
|
"""
|
|
return {
|
|
"issuer": f"{base}{OAUTH_BASE}",
|
|
"authorization_endpoint": f"{base}/auth/authorize",
|
|
"token_endpoint": f"{base}/auth/token",
|
|
"response_types_supported": ["code"],
|
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
"code_challenge_methods_supported": ["S256"],
|
|
"token_endpoint_auth_methods_supported": ["none"],
|
|
"client_id_metadata_document_supported": True,
|
|
}
|
|
|
|
|
|
class ResourceServer:
|
|
"""ha_auth resource server: bearer validation + discovery URL building.
|
|
|
|
Owns no signing key, no client credentials, and binds no root views — HA core
|
|
is the authorization server. Held by the discovery views and the webhook
|
|
handler.
|
|
"""
|
|
|
|
def __init__(self, hass: HomeAssistant, webhook_id: str) -> None:
|
|
"""Bind to the HA instance and this install's webhook id."""
|
|
self._hass = hass
|
|
self._webhook_id = webhook_id
|
|
|
|
@property
|
|
def webhook_id(self) -> str:
|
|
"""This install's private webhook id."""
|
|
return self._webhook_id
|
|
|
|
def resource_url(self, base_url: str) -> str:
|
|
"""Absolute URL of the protected webhook resource under ``base_url``."""
|
|
return f"{base_url}/api/webhook/{self._webhook_id}"
|
|
|
|
def authorization_server_url(self, base_url: str) -> str:
|
|
"""Issuer / authorization-server URL under ``base_url``."""
|
|
return f"{base_url}{OAUTH_BASE}"
|
|
|
|
async def validate_request(self, request: web.Request) -> bool:
|
|
"""Return True iff the request carries a Bearer token HA core accepts.
|
|
|
|
A missing/malformed ``Authorization`` header is rejected without touching
|
|
the validator. ``hass.auth.async_validate_access_token`` is a synchronous
|
|
``@callback`` in HA core; it is awaited defensively in case a future
|
|
release makes it a coroutine, and any raise is treated as unauthorized so
|
|
a crafted token yields a 401 challenge rather than a 500.
|
|
"""
|
|
header = request.headers.get("Authorization", "")
|
|
if not header.lower().startswith("bearer "):
|
|
return False
|
|
token = header[7:].strip()
|
|
if not token:
|
|
return False
|
|
try:
|
|
result = self._hass.auth.async_validate_access_token(token)
|
|
if inspect.isawaitable(result):
|
|
result = await result
|
|
except Exception:
|
|
_LOGGER.debug(
|
|
"ha_auth: bearer validation raised; treating as unauthorized",
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
if result is None:
|
|
return False
|
|
# ADMIN-ONLY: the server performs every Home Assistant operation with
|
|
# its own provisioned ADMIN token, so accepting any valid login would
|
|
# grant every household member admin-equivalent control. Require an
|
|
# active, human, administrator account (mirrors the settings panel).
|
|
user = getattr(result, "user", None)
|
|
if user is None:
|
|
return False
|
|
if getattr(user, "system_generated", False):
|
|
return False
|
|
if not getattr(user, "is_active", False):
|
|
return False
|
|
return bool(getattr(user, "is_admin", False))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RFC 8414 / RFC 9728 discovery views (ha_auth mode only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None:
|
|
"""Return the CURRENT entry's ha_auth resource server, or None.
|
|
|
|
The discovery views resolve this per request instead of binding a provider
|
|
at registration time: aiohttp can't drop a bound view until HA restarts, so
|
|
a remove + re-add of the config entry (which mints a NEW webhook id in the
|
|
same HA session) would otherwise leave the views advertising the old id.
|
|
Returns None when no entry is live, the webhook auth mode is not ha_auth,
|
|
or the public endpoint is disabled (local-only mode constructs no resource
|
|
server even under ha_auth) — the views then 404 like an unregistered route.
|
|
"""
|
|
domain_data = hass.data.get(DOMAIN)
|
|
if not isinstance(domain_data, dict):
|
|
return None
|
|
cfg = domain_data.get(DATA_WEBHOOK)
|
|
if not isinstance(cfg, dict) or cfg.get("auth_mode") != WEBHOOK_AUTH_HA:
|
|
return None
|
|
provider = cfg.get("resource_server")
|
|
return provider if isinstance(provider, ResourceServer) else None
|
|
|
|
|
|
def _json_not_found() -> web.Response:
|
|
"""404 JSON body used by stale-but-bound discovery views."""
|
|
return web.json_response({"error": "not_found"}, status=404)
|
|
|
|
|
|
def _protected_resource_document(provider: ResourceServer, base: str) -> dict[str, Any]:
|
|
"""RFC 9728 protected-resource document for ``provider`` under ``base``."""
|
|
return {
|
|
"resource": provider.resource_url(base),
|
|
"authorization_servers": [provider.authorization_server_url(base)],
|
|
"bearer_methods_supported": ["header"],
|
|
"resource_documentation": "https://github.com/homeassistant-ai/ha-mcp",
|
|
}
|
|
|
|
|
|
class _ProtectedResourceMetadataView(HomeAssistantView):
|
|
"""RFC 9728 Protected Resource Metadata."""
|
|
|
|
requires_auth = False
|
|
cors_allowed = True
|
|
url = f"{OAUTH_BASE}/protected-resource"
|
|
name = "ha_mcp_tools:oauth:protected-resource"
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Bind the view to the HA instance; the provider is resolved per request."""
|
|
self._hass = hass
|
|
|
|
async def get(self, request: web.Request) -> web.Response:
|
|
"""Serve the protected-resource document (or 404 when ha_auth is off)."""
|
|
provider = _active_resource_server(self._hass)
|
|
if provider is None:
|
|
return _json_not_found()
|
|
return web.json_response(
|
|
_protected_resource_document(provider, _build_base_url(request))
|
|
)
|
|
|
|
|
|
class _AuthorizationServerMetadataView(HomeAssistantView):
|
|
"""RFC 8414 Authorization Server Metadata (points at HA core's OAuth)."""
|
|
|
|
requires_auth = False
|
|
cors_allowed = True
|
|
url = f"{OAUTH_BASE}/authorization-server"
|
|
name = "ha_mcp_tools:oauth:authorization-server"
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Bind the view to the HA instance; liveness is resolved per request."""
|
|
self._hass = hass
|
|
|
|
async def get(self, request: web.Request) -> web.Response:
|
|
"""Serve the authorization-server document (or 404 when ha_auth is off)."""
|
|
if _active_resource_server(self._hass) is None:
|
|
return _json_not_found()
|
|
base = _build_base_url(request)
|
|
return web.json_response(_authorization_server_document(base))
|
|
|
|
|
|
class _WellKnownProtectedResourceView(HomeAssistantView):
|
|
"""RFC 9728 §3.1 path-scoped Protected Resource Metadata.
|
|
|
|
Same document as :class:`_ProtectedResourceMetadataView`, served at the
|
|
well-known location derived from the webhook resource URL — claude.ai's
|
|
first fallback probe when the 401's ``resource_metadata`` pointer is
|
|
missing. The webhook id is a ROUTE PARAMETER (not baked into the path at
|
|
registration): a remove + re-add of the entry mints a new webhook id in the
|
|
same HA session, and the bound view must serve whichever id is currently
|
|
live (404 for any other). Standalone view (not a subclass of the plain
|
|
document view) because its handler takes the extra route parameter.
|
|
"""
|
|
|
|
requires_auth = False
|
|
cors_allowed = True
|
|
name = "ha_mcp_tools:oauth:wellknown-protected-resource"
|
|
url = "/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Bind the view to the HA instance; the provider is resolved per request."""
|
|
self._hass = hass
|
|
|
|
async def get(self, request: web.Request, webhook_id: str) -> web.Response:
|
|
"""Serve the document only for the CURRENT entry's webhook id."""
|
|
provider = _active_resource_server(self._hass)
|
|
if provider is None or webhook_id != provider.webhook_id:
|
|
return _json_not_found()
|
|
return web.json_response(
|
|
_protected_resource_document(provider, _build_base_url(request))
|
|
)
|
|
|
|
|
|
class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView):
|
|
"""RFC 8414 / OIDC-discovery locations for the AS metadata document.
|
|
|
|
Same document as :class:`_AuthorizationServerMetadataView`, registered at the
|
|
well-known URLs MCP clients actually probe for the issuer.
|
|
"""
|
|
|
|
def __init__(self, hass: HomeAssistant, url: str, name: str) -> None:
|
|
"""Bind and set an explicit well-known URL + unique view name."""
|
|
super().__init__(hass)
|
|
self.url = url
|
|
self.name = name
|
|
|
|
|
|
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
|
|
"""Build the seven ha_auth discovery-document views (provider-agnostic)."""
|
|
views: list[HomeAssistantView] = [
|
|
_ProtectedResourceMetadataView(hass),
|
|
_AuthorizationServerMetadataView(hass),
|
|
_WellKnownProtectedResourceView(hass),
|
|
]
|
|
for url, name in (
|
|
(
|
|
f"/.well-known/oauth-authorization-server{OAUTH_BASE}",
|
|
"ha_mcp_tools:oauth:wellknown-as-rfc8414",
|
|
),
|
|
(
|
|
f"/.well-known/openid-configuration{OAUTH_BASE}",
|
|
"ha_mcp_tools:oauth:wellknown-oidc-prefixed",
|
|
),
|
|
(
|
|
f"{OAUTH_BASE}/.well-known/openid-configuration",
|
|
"ha_mcp_tools:oauth:wellknown-oidc-suffixed",
|
|
),
|
|
(
|
|
f"{OAUTH_BASE}/.well-known/oauth-authorization-server",
|
|
"ha_mcp_tools:oauth:wellknown-as-suffixed",
|
|
),
|
|
):
|
|
views.append(
|
|
_WellKnownAuthorizationServerMetadataView(hass, url=url, name=name)
|
|
)
|
|
return views
|
|
|
|
|
|
def _register_metadata_views(hass: HomeAssistant) -> None:
|
|
"""Register the ha_auth discovery views at most once per HA session.
|
|
|
|
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add must
|
|
reuse the already-bound views — they resolve the ACTIVE provider from
|
|
hass.data per request, so a later entry (even with a new webhook id) is
|
|
served correctly. The guard flag lives at a top-level hass.data key that
|
|
survives config-entry teardown.
|
|
"""
|
|
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
|
|
return
|
|
for view in _metadata_views(hass):
|
|
hass.http.register_view(view)
|
|
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
|
|
|
|
|
|
def _build_unauthorized_response(
|
|
request: web.Request, provider: ResourceServer
|
|
) -> web.Response:
|
|
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
|
|
|
|
Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to
|
|
the protected-resource metadata URL where the client finds the authorization
|
|
server.
|
|
"""
|
|
base = _build_base_url(request)
|
|
metadata_url = f"{base}{OAUTH_BASE}/protected-resource"
|
|
return web.Response(
|
|
status=401,
|
|
text="Unauthorized",
|
|
headers={
|
|
"WWW-Authenticate": (
|
|
f'Bearer realm="HA-MCP", resource_metadata="{metadata_url}"'
|
|
)
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Webhook forwarding handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _async_handle_webhook(
|
|
hass: HomeAssistant, webhook_id: str, request: web.Request
|
|
) -> web.StreamResponse:
|
|
"""Forward an MCP request to the loopback server and stream the reply back."""
|
|
domain_data = hass.data.get(DOMAIN)
|
|
cfg = domain_data.get(DATA_WEBHOOK) if isinstance(domain_data, dict) else None
|
|
if not isinstance(cfg, dict):
|
|
return web.Response(status=503, text="MCP server is not available")
|
|
|
|
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
|
|
# = validate the bearer via HA core, and on failure emit the 401 discovery
|
|
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
|
|
# (constructed only for ha_auth) rather than a string compare, so the
|
|
# coupling "provider present <=> ha_auth" has a single owner and an
|
|
# inconsistent cfg cannot fail open.
|
|
provider = cfg.get("resource_server")
|
|
if provider is not None:
|
|
if not await provider.validate_request(request):
|
|
return _build_unauthorized_response(request, provider)
|
|
|
|
target_url: str = cfg["target_url"]
|
|
session: aiohttp.ClientSession = cfg["session"]
|
|
|
|
body = await request.read()
|
|
|
|
forward_headers = {
|
|
key: value
|
|
for key, value in request.headers.items()
|
|
if key.lower() not in _STRIPPED_REQUEST_HEADERS
|
|
}
|
|
|
|
try:
|
|
async with session.request(
|
|
method=request.method,
|
|
url=target_url,
|
|
headers=forward_headers,
|
|
data=body if body else None,
|
|
) as upstream_resp:
|
|
content_type = upstream_resp.headers.get("Content-Type", "")
|
|
|
|
resp_headers = {
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"Content-Encoding": "identity",
|
|
}
|
|
mcp_session = upstream_resp.headers.get("Mcp-Session-Id")
|
|
if mcp_session:
|
|
resp_headers["Mcp-Session-Id"] = mcp_session
|
|
|
|
if "text/event-stream" in content_type:
|
|
# SSE streaming: prevent HA's compression middleware from
|
|
# buffering/breaking the stream (supervisor#6470).
|
|
resp_headers["Content-Type"] = "text/event-stream"
|
|
resp_headers["X-Accel-Buffering"] = "no"
|
|
response = web.StreamResponse(
|
|
status=upstream_resp.status, headers=resp_headers
|
|
)
|
|
await response.prepare(request)
|
|
# Once prepare() has sent the 200 + headers, a mid-stream
|
|
# upstream failure can no longer become a 502 — returning a
|
|
# fresh Response here would be silently dropped and the client
|
|
# would see only a truncated stream with no log trail. End the
|
|
# prepared stream deterministically and log instead.
|
|
# Count forwarded bytes manually: StreamResponse.body_length
|
|
# is only assigned in write_eof(), so it is still 0 here.
|
|
bytes_forwarded = 0
|
|
try:
|
|
async for chunk in upstream_resp.content.iter_any():
|
|
await response.write(chunk)
|
|
bytes_forwarded += len(chunk)
|
|
except aiohttp.ClientError as err:
|
|
_LOGGER.error(
|
|
"MCP webhook: upstream dropped mid-stream after %d bytes: %s",
|
|
bytes_forwarded,
|
|
err,
|
|
)
|
|
with suppress(ConnectionResetError):
|
|
await response.write_eof()
|
|
return response
|
|
|
|
if not any(ct in content_type for ct in _ALLOWED_CONTENT_TYPES):
|
|
content_type = "application/json"
|
|
resp_headers["Content-Type"] = content_type
|
|
resp_body = await upstream_resp.read()
|
|
return web.Response(
|
|
status=upstream_resp.status, body=resp_body, headers=resp_headers
|
|
)
|
|
except aiohttp.ClientError as err:
|
|
_LOGGER.error("MCP webhook: upstream request failed: %s", err)
|
|
return web.Response(status=502, text="MCP server unavailable")
|
|
except Exception as err:
|
|
_LOGGER.exception("MCP webhook: unexpected error: %s", err)
|
|
return web.Response(status=500, text="MCP server internal error")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registration / teardown
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def async_register_webhook(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
*,
|
|
port: int,
|
|
secret_path: str,
|
|
auth_mode: str,
|
|
register_endpoint: bool = True,
|
|
) -> None:
|
|
"""Register the ingress webhook (and, for ha_auth, the discovery views).
|
|
|
|
Stores the forwarding config in ``hass.data[DOMAIN][DATA_WEBHOOK]`` and opens
|
|
a long-lived aiohttp session for streaming. Raises on failure with the webhook
|
|
already unregistered, so the caller never leaves a half-configured endpoint
|
|
live. ``webhook`` is a manifest dependency, so HA guarantees it is set up
|
|
before this runs.
|
|
|
|
With ``register_endpoint=False`` (remote webhook access disabled by option)
|
|
no public endpoint or ha_auth surface is created — and any leftover endpoint
|
|
from a crashed unload is cleared, so off means off; only the forwarding
|
|
config is stored, which same-host consumers — the sidebar settings panel
|
|
proxy — need to reach the loopback server (#1803).
|
|
"""
|
|
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
|
|
# Fail CLOSED on an unknown mode (corrupt/migrated options): refusing
|
|
# bring-up files a repair issue, instead of an unrecognized string
|
|
# silently taking the unauthenticated forward path.
|
|
raise ValueError(f"Unknown webhook auth mode: {auth_mode!r}")
|
|
|
|
webhook_id: str = entry.data[DATA_WEBHOOK_ID]
|
|
# Reload-safe and off-means-off: clear any leftover registration from a
|
|
# crashed unload before (re)registering — or before storing a local-only
|
|
# config (async_unregister is a no-op pop when nothing is registered).
|
|
# Runs before the session opens so a raise here cannot leak it.
|
|
async_unregister(hass, webhook_id)
|
|
target_url = f"http://127.0.0.1:{port}{secret_path}"
|
|
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
|
|
|
|
cfg: dict[str, Any] = {
|
|
"webhook_id": webhook_id,
|
|
"target_url": target_url,
|
|
"session": session,
|
|
"auth_mode": auth_mode,
|
|
"resource_server": None,
|
|
}
|
|
|
|
if register_endpoint:
|
|
try:
|
|
async_register(
|
|
hass,
|
|
DOMAIN,
|
|
_WEBHOOK_NAME,
|
|
webhook_id,
|
|
_async_handle_webhook,
|
|
allowed_methods=["POST", "GET"],
|
|
)
|
|
if auth_mode == WEBHOOK_AUTH_HA:
|
|
provider = ResourceServer(hass, webhook_id)
|
|
_register_metadata_views(hass)
|
|
cfg["resource_server"] = provider
|
|
except Exception:
|
|
# Never leave a live endpoint (or a leaked session) behind a failed
|
|
# auth-setup path. suppress: the ORIGINAL error must be what
|
|
# propagates (review finding) - a raising cleanup would mask it.
|
|
with suppress(Exception):
|
|
async_unregister(hass, webhook_id)
|
|
with suppress(Exception):
|
|
await session.close()
|
|
raise
|
|
|
|
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
|
|
|
|
|
|
async def async_unregister_webhook(hass: HomeAssistant) -> None:
|
|
"""Unregister the ingress webhook and close its aiohttp session.
|
|
|
|
Idempotent. The ha_auth discovery views are intentionally left bound (aiohttp
|
|
can't unregister them until HA restarts); they 404 while ha_auth is not live.
|
|
"""
|
|
domain_data = hass.data.get(DOMAIN)
|
|
if not isinstance(domain_data, dict):
|
|
return
|
|
cfg = domain_data.pop(DATA_WEBHOOK, None)
|
|
if not isinstance(cfg, dict):
|
|
return
|
|
webhook_id = cfg.get("webhook_id")
|
|
if webhook_id:
|
|
async_unregister(hass, webhook_id)
|
|
session = cfg.get("session")
|
|
if session is not None:
|
|
await session.close()
|