Skip to content

Commit 8041b55

Browse files
fix: sidebar settings panel 503s forever when webhook access is disabled (#1806)
* fix: keep the sidebar settings panel working when webhook access is disabled (#1803) The panel proxy reads its loopback target from the webhook forwarding config, which was only created when the remote-webhook option was on -- with it off the proxy returned 503 forever even though the server was running. Store the forwarding config whenever the server starts and gate only the public webhook endpoint (and ha_auth surface) on the option. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address review findings on the webhook-disabled forwarding path - clear any leftover webhook registration even in local-only mode (off means off after a crashed unload); runs before the session opens so it cannot leak - pin the producer->consumer seam: the panel proxy forwards using a cfg stored by async_register_webhook(register_endpoint=False) - assert the stored session identity and the skipped endpoint in the ha_auth local-only test - tighten the _active_resource_server and ui_panel docstrings for the decoupled forwarding config Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 51f4ae5 commit 8041b55

7 files changed

Lines changed: 154 additions & 44 deletions

File tree

custom_components/ha_mcp_tools/embedded_setup.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,18 @@ async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None
106106
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
107107
secret_path = str(entry.data[DATA_SECRET_PATH])
108108
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
109-
if webhook_enabled:
110-
await async_register_webhook(
111-
hass,
112-
entry,
113-
port=manager.port,
114-
secret_path=secret_path,
115-
auth_mode=auth_mode,
116-
)
117-
else:
109+
# Always set up the loopback forwarding config — the sidebar settings
110+
# panel proxies through it (#1803); the option gates only the public
111+
# webhook endpoint.
112+
await async_register_webhook(
113+
hass,
114+
entry,
115+
port=manager.port,
116+
secret_path=secret_path,
117+
auth_mode=auth_mode,
118+
register_endpoint=webhook_enabled,
119+
)
120+
if not webhook_enabled:
118121
_LOGGER.info(
119122
"Webhook access disabled by option - the server is local-only "
120123
"(direct port + sidebar panel)"

custom_components/ha_mcp_tools/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@
1919
"requirements": [
2020
"ruamel.yaml>=0.18.0"
2121
],
22-
"version": "1.0.3"
22+
"version": "1.0.4"
2323
}

custom_components/ha_mcp_tools/mcp_webhook.py

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,9 @@ def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None:
206206
at registration time: aiohttp can't drop a bound view until HA restarts, so
207207
a remove + re-add of the config entry (which mints a NEW webhook id in the
208208
same HA session) would otherwise leave the views advertising the old id.
209-
Returns None when no entry is live or the webhook auth mode is not ha_auth
210-
— the views then 404 like an unregistered route.
209+
Returns None when no entry is live, the webhook auth mode is not ha_auth,
210+
or the public endpoint is disabled (local-only mode constructs no resource
211+
server even under ha_auth) — the views then 404 like an unregistered route.
211212
"""
212213
domain_data = hass.data.get(DOMAIN)
213214
if not isinstance(domain_data, dict):
@@ -502,6 +503,7 @@ async def async_register_webhook(
502503
port: int,
503504
secret_path: str,
504505
auth_mode: str,
506+
register_endpoint: bool = True,
505507
) -> None:
506508
"""Register the ingress webhook (and, for ha_auth, the discovery views).
507509
@@ -510,6 +512,12 @@ async def async_register_webhook(
510512
already unregistered, so the caller never leaves a half-configured endpoint
511513
live. ``webhook`` is a manifest dependency, so HA guarantees it is set up
512514
before this runs.
515+
516+
With ``register_endpoint=False`` (remote webhook access disabled by option)
517+
no public endpoint or ha_auth surface is created — and any leftover endpoint
518+
from a crashed unload is cleared, so off means off; only the forwarding
519+
config is stored, which same-host consumers — the sidebar settings panel
520+
proxy — need to reach the loopback server (#1803).
513521
"""
514522
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
515523
# Fail CLOSED on an unknown mode (corrupt/migrated options): refusing
@@ -518,6 +526,11 @@ async def async_register_webhook(
518526
raise ValueError(f"Unknown webhook auth mode: {auth_mode!r}")
519527

520528
webhook_id: str = entry.data[DATA_WEBHOOK_ID]
529+
# Reload-safe and off-means-off: clear any leftover registration from a
530+
# crashed unload before (re)registering — or before storing a local-only
531+
# config (async_unregister is a no-op pop when nothing is registered).
532+
# Runs before the session opens so a raise here cannot leak it.
533+
async_unregister(hass, webhook_id)
521534
target_url = f"http://127.0.0.1:{port}{secret_path}"
522535
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
523536

@@ -529,31 +542,29 @@ async def async_register_webhook(
529542
"resource_server": None,
530543
}
531544

532-
try:
533-
# Reload-safe: clear any leftover registration from a crashed unload
534-
# before (re)registering (async_unregister is a no-op pop).
535-
async_unregister(hass, webhook_id)
536-
async_register(
537-
hass,
538-
DOMAIN,
539-
_WEBHOOK_NAME,
540-
webhook_id,
541-
_async_handle_webhook,
542-
allowed_methods=["POST", "GET"],
543-
)
544-
if auth_mode == WEBHOOK_AUTH_HA:
545-
provider = ResourceServer(hass, webhook_id)
546-
_register_metadata_views(hass)
547-
cfg["resource_server"] = provider
548-
except Exception:
549-
# Never leave a live endpoint (or a leaked session) behind a failed
550-
# auth-setup path. suppress: the ORIGINAL error must be what
551-
# propagates (review finding) - a raising cleanup would mask it.
552-
with suppress(Exception):
553-
async_unregister(hass, webhook_id)
554-
with suppress(Exception):
555-
await session.close()
556-
raise
545+
if register_endpoint:
546+
try:
547+
async_register(
548+
hass,
549+
DOMAIN,
550+
_WEBHOOK_NAME,
551+
webhook_id,
552+
_async_handle_webhook,
553+
allowed_methods=["POST", "GET"],
554+
)
555+
if auth_mode == WEBHOOK_AUTH_HA:
556+
provider = ResourceServer(hass, webhook_id)
557+
_register_metadata_views(hass)
558+
cfg["resource_server"] = provider
559+
except Exception:
560+
# Never leave a live endpoint (or a leaked session) behind a failed
561+
# auth-setup path. suppress: the ORIGINAL error must be what
562+
# propagates (review finding) - a raising cleanup would mask it.
563+
with suppress(Exception):
564+
async_unregister(hass, webhook_id)
565+
with suppress(Exception):
566+
await session.close()
567+
raise
557568

558569
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
559570

custom_components/ha_mcp_tools/ui_panel.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@
3838
bearer) validates that cookie against a live admin user on every request and
3939
forwards to the loopback settings server.
4040
41-
The proxy reuses the ingress webhook's loopback target + aiohttp session
42-
(``hass.data[DOMAIN][DATA_WEBHOOK]``), so it is available exactly while the server
43-
is running and returns 503 otherwise.
41+
The proxy reuses the server's loopback forwarding config + aiohttp session
42+
(``hass.data[DOMAIN][DATA_WEBHOOK]`` — stored whenever the server is running,
43+
even when the public webhook endpoint is disabled), so it is available exactly
44+
while the server is running and returns 503 otherwise.
4445
"""
4546

4647
from __future__ import annotations

tests/src/unit/test_embedded_setup.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,14 @@ async def test_success_clears_stale_repair_issues(self, fake_manager):
149149
esetup.ISSUE_UPDATE_HELD,
150150
}
151151

152-
async def test_local_only_skips_webhook_registration(self, fake_manager, caplog):
152+
async def test_local_only_skips_endpoint_but_keeps_forwarding(
153+
self, fake_manager, caplog
154+
):
153155
# Owner request: enable_webhook=False must never register the webhook
154-
# (Nabu Casa path dead) while the server still starts; the log carries
155-
# the local-only note.
156+
# endpoint (Nabu Casa path dead) while the server still starts; the log
157+
# carries the local-only note. The forwarding config must still be set
158+
# up (register_endpoint=False) or the sidebar settings panel 503s
159+
# forever (#1803).
156160
import logging
157161

158162
hass = _make_hass()
@@ -162,7 +166,9 @@ async def test_local_only_skips_webhook_registration(self, fake_manager, caplog)
162166
await esetup.async_bring_up_server(hass, entry)
163167

164168
fake_manager.async_start.assert_awaited_once()
165-
esetup.async_register_webhook.assert_not_awaited()
169+
esetup.async_register_webhook.assert_awaited_once()
170+
kwargs = esetup.async_register_webhook.await_args.kwargs
171+
assert kwargs["register_endpoint"] is False
166172
esetup._surface_connect_urls.assert_called_once()
167173
assert "local-only" in caplog.text
168174

@@ -177,6 +183,7 @@ async def test_passes_auth_mode_port_and_secret_to_webhook(self, fake_manager):
177183
assert kwargs["auth_mode"] == WEBHOOK_AUTH_HA
178184
assert kwargs["port"] == 9584
179185
assert kwargs["secret_path"] == "/private_secret"
186+
assert kwargs["register_endpoint"] is True
180187

181188
async def test_package_failure_files_package_issue_and_skips_webhook(
182189
self, fake_manager

tests/src/unit/test_mcp_webhook.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,3 +724,56 @@ async def test_unregister_is_idempotent(self):
724724
# No cfg present — must be a clean no-op.
725725
await mw.async_unregister_webhook(hass)
726726
await mw.async_unregister_webhook(hass)
727+
728+
async def test_register_endpoint_false_stores_forwarding_only(self, monkeypatch):
729+
# #1803: with remote webhook access disabled, the loopback forwarding
730+
# config must still be stored (the sidebar settings panel proxies
731+
# through it) while NO public endpoint is registered.
732+
hass = _register_hass()
733+
fake_session = FakeSession()
734+
monkeypatch.setattr(mw.aiohttp, "ClientSession", lambda **kw: fake_session)
735+
736+
await mw.async_register_webhook(
737+
hass,
738+
_entry(),
739+
port=9584,
740+
secret_path="/private_x",
741+
auth_mode=WEBHOOK_AUTH_NONE,
742+
register_endpoint=False,
743+
)
744+
745+
cfg = hass.data[DOMAIN][DATA_WEBHOOK]
746+
assert cfg["target_url"] == "http://127.0.0.1:9584/private_x"
747+
assert cfg["session"] is fake_session
748+
assert cfg["resource_server"] is None
749+
mw.async_register.assert_not_called()
750+
# Off means off: a leftover endpoint from a crashed unload is cleared
751+
# even though nothing gets (re)registered.
752+
mw.async_unregister.assert_called_once_with(hass, WEBHOOK_ID)
753+
754+
# Teardown still drops the cfg and closes the session.
755+
await mw.async_unregister_webhook(hass)
756+
assert DATA_WEBHOOK not in hass.data[DOMAIN]
757+
assert fake_session.closed is True
758+
759+
async def test_register_endpoint_false_skips_ha_auth_surface(self, monkeypatch):
760+
# Even with ha_auth configured, a disabled endpoint must not construct
761+
# the resource server or bind the discovery views — the per-request
762+
# resolver would otherwise advertise a webhook that does not exist.
763+
hass = _register_hass()
764+
monkeypatch.setattr(mw.aiohttp, "ClientSession", lambda **kw: FakeSession())
765+
766+
await mw.async_register_webhook(
767+
hass,
768+
_entry(),
769+
port=9584,
770+
secret_path="/private_x",
771+
auth_mode=WEBHOOK_AUTH_HA,
772+
register_endpoint=False,
773+
)
774+
775+
cfg = hass.data[DOMAIN][DATA_WEBHOOK]
776+
assert cfg["resource_server"] is None
777+
mw.async_register.assert_not_called()
778+
hass.http.register_view.assert_not_called()
779+
assert mw._active_resource_server(hass) is None

tests/src/unit/test_ui_panel.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,41 @@ async def test_path_traversal_rejected(self):
231231
resp = await ui_panel._ProxyView().get(request, "../secrets")
232232
assert resp.status == 400
233233

234+
async def test_forwards_with_cfg_from_local_only_setup(self, monkeypatch):
235+
# #1803 end-to-end at unit level: the forwarding config stored by
236+
# async_register_webhook(register_endpoint=False) must be directly
237+
# consumable by the panel proxy — a cfg key rename on either side of
238+
# the seam would 503 the sidebar panel again with both halves' own
239+
# tests still green.
240+
from custom_components.ha_mcp_tools import mcp_webhook as mw
241+
from custom_components.ha_mcp_tools.const import DATA_WEBHOOK_ID
242+
243+
upstream = FakeUpstream(
244+
status=200,
245+
headers={"Content-Type": "text/html; charset=utf-8"},
246+
body=b"<html>settings</html>",
247+
)
248+
session = FakeSession(upstream=upstream)
249+
monkeypatch.setattr(mw.aiohttp, "ClientSession", lambda **kw: session)
250+
hass = _make_hass(user=_make_user())
251+
entry = MagicMock()
252+
entry.data = {DATA_WEBHOOK_ID: "wh-seam"}
253+
254+
await mw.async_register_webhook(
255+
hass,
256+
entry,
257+
port=9584,
258+
secret_path="/private_x",
259+
auth_mode=mw.WEBHOOK_AUTH_NONE,
260+
register_endpoint=False,
261+
)
262+
request = _make_request(hass=hass, cookies=_valid_cookie(hass))
263+
264+
resp = await ui_panel._ProxyView().get(request, "settings")
265+
266+
assert resp.status == 200
267+
assert session.calls[0]["url"] == "http://127.0.0.1:9584/private_x/settings"
268+
234269
async def test_forwards_page_and_passes_through_html(self):
235270
upstream = FakeUpstream(
236271
status=200,

0 commit comments

Comments
 (0)