Updated apps
This commit is contained in:
@@ -33,6 +33,9 @@ from .const import (
|
||||
COMPONENT_MANIFEST_AT_TAG_URL,
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_MANAGER,
|
||||
DATA_OAUTH_CLIENT_ID,
|
||||
DATA_OAUTH_CLIENT_SECRET,
|
||||
DATA_OAUTH_SIGNING_KEY,
|
||||
DATA_PENDING_UPDATE_NOTIFY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
@@ -43,8 +46,8 @@ from .const import (
|
||||
DEFAULT_PIP_SPEC,
|
||||
DEFAULT_SERVER_PORT,
|
||||
DOMAIN,
|
||||
HACS_COMPONENT_URL,
|
||||
ISSUE_COMPONENT_OUTDATED,
|
||||
ISSUE_LEGACY_OAUTH_RESTART,
|
||||
ISSUE_PACKAGE_FAILED,
|
||||
ISSUE_START_FAILED,
|
||||
ISSUE_UPDATE_HELD,
|
||||
@@ -58,12 +61,16 @@ from .const import (
|
||||
OPT_PIP_SPEC,
|
||||
OPT_SERVER_PORT,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
UPDATE_HOLD_DOCS_URL,
|
||||
WEBHOOK_AUTH_LEGACY,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
channel_for_dist,
|
||||
)
|
||||
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
|
||||
from .hacs_nudge import async_schedule_hacs_nudge
|
||||
from .llm_api import async_register_llm_api, async_unregister_llm_api
|
||||
from .mcp_webhook import async_register_webhook, async_unregister_webhook
|
||||
from .oauth_legacy import legacy_credentials_active
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -111,23 +118,51 @@ async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None
|
||||
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
|
||||
secret_path = str(entry.data[DATA_SECRET_PATH])
|
||||
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
||||
oauth_client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||
oauth_client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||
# Always set up the loopback forwarding config — the sidebar settings
|
||||
# panel proxies through it (#1803); the option gates only the public
|
||||
# webhook endpoint.
|
||||
await async_register_webhook(
|
||||
# webhook endpoint. oauth_* args are ignored unless auth_mode is legacy.
|
||||
oauth_restart_needed = await async_register_webhook(
|
||||
hass,
|
||||
entry,
|
||||
port=manager.port,
|
||||
secret_path=secret_path,
|
||||
auth_mode=auth_mode,
|
||||
register_endpoint=webhook_enabled,
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
oauth_signing_key=entry.data.get(DATA_OAUTH_SIGNING_KEY),
|
||||
)
|
||||
_async_update_legacy_oauth_issue(hass, oauth_restart_needed)
|
||||
if not webhook_enabled:
|
||||
_LOGGER.info(
|
||||
"Webhook access disabled by option - the server is local-only "
|
||||
"(direct port + sidebar panel)"
|
||||
)
|
||||
_surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled)
|
||||
# Only surface cleartext credentials once the bound provider actually
|
||||
# serves them: while a rotation is pending restart, an old-identity
|
||||
# token still validates and can read this log (see
|
||||
# legacy_credentials_active).
|
||||
oauth_creds_active = True
|
||||
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
oauth_creds_active = legacy_credentials_active(
|
||||
hass,
|
||||
str(oauth_client_id or ""),
|
||||
str(oauth_client_secret or ""),
|
||||
str(entry.data.get(DATA_OAUTH_SIGNING_KEY) or ""),
|
||||
)
|
||||
_surface_connect_urls(
|
||||
hass,
|
||||
entry,
|
||||
auth_mode,
|
||||
webhook_enabled=webhook_enabled,
|
||||
extra_hosts=await async_get_lan_hosts(hass),
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
oauth_creds_active=oauth_creds_active,
|
||||
oauth_restart_pending=oauth_restart_needed,
|
||||
)
|
||||
# Conversation-agent LLM API (#1745), gated on its option (default on).
|
||||
# Advisory: registration failures are contained inside (logged, feature
|
||||
# absent) — the running server must never be taken down by them.
|
||||
@@ -189,6 +224,87 @@ async def async_revoke_credentials_on_remove(
|
||||
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
|
||||
_clear_issues(hass)
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
# Clear the legacy-OAuth restart repair too: it is filed only from bring-up,
|
||||
# which never runs again for a removed entry, so a restart that was still
|
||||
# pending at removal would otherwise leave a dangling warning for a server
|
||||
# that no longer exists. (Re-enabling legacy on a fresh entry re-files it.)
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
|
||||
|
||||
|
||||
async def async_get_lan_hosts(hass: HomeAssistant) -> list[str]:
|
||||
"""Every IPv4 address on an enabled network adapter, in adapter order (#1862).
|
||||
|
||||
Lets the connect-URL surfaces list one entry per interface on a
|
||||
multi-interface / multi-VLAN host, instead of only the single address
|
||||
``get_url`` resolves. IPv4 only: ``async_get_adapters`` returns a bare
|
||||
IPv6 ``address`` with a separate ``scope_id`` int, so a link-local adapter
|
||||
address is not a usable URL host without rejoining that zone id (and every
|
||||
IPv6 host additionally needs bracket-wrapping), so building those correctly
|
||||
is out of scope; the reported setups are IPv4. Best-effort: any failure
|
||||
yields an empty list so URL
|
||||
surfacing degrades to the single ``get_url`` host rather than taking down
|
||||
the caller (bring-up would otherwise file a repair issue for a display-only
|
||||
lookup).
|
||||
"""
|
||||
try:
|
||||
from homeassistant.components import network
|
||||
|
||||
adapters = await network.async_get_adapters(hass)
|
||||
# The whole extraction is inside the try (not just the fetch): a
|
||||
# malformed adapter entry must degrade to the single get_url host too,
|
||||
# never escape into async_bring_up_server's handler, which would tear
|
||||
# the running server down and file a start-failure for a display-only
|
||||
# lookup.
|
||||
return [
|
||||
ipv4["address"]
|
||||
for adapter in adapters
|
||||
if adapter["enabled"]
|
||||
for ipv4 in adapter["ipv4"]
|
||||
]
|
||||
except Exception: # display-only enumeration; must never fail the caller
|
||||
_LOGGER.warning(
|
||||
"Adapter enumeration failed; using the single resolved host",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _swap_url_host(base: str, host: str) -> str:
|
||||
"""Return ``base`` with its host replaced by ``host`` (scheme/port/path kept)."""
|
||||
parsed = urlparse(base)
|
||||
netloc = host if parsed.port is None else f"{host}:{parsed.port}"
|
||||
return parsed._replace(netloc=netloc).geturl()
|
||||
|
||||
|
||||
def _dedup_hosts(primary: str | None, extra: list[str] | None) -> list[str]:
|
||||
"""Ordered unique hosts, ``primary`` (the canonical get_url host) first."""
|
||||
ordered: list[str] = []
|
||||
for host in (primary, *(extra or [])):
|
||||
if host and host not in ordered:
|
||||
ordered.append(host)
|
||||
return ordered
|
||||
|
||||
|
||||
def _resolve_local_url(hass: HomeAssistant) -> tuple[str | None, str | None]:
|
||||
"""The get_url internal base URL and its host, or ``(None, None)``."""
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
try:
|
||||
base = get_url(hass, allow_external=False, prefer_external=False)
|
||||
except NoURLAvailableError:
|
||||
return None, None # No internal/local URL configured - hint form instead.
|
||||
return base, urlparse(base).hostname
|
||||
|
||||
|
||||
def _local_webhook_urls(
|
||||
local_base: str, local_host: str | None, lan_hosts: list[str], webhook_id: str
|
||||
) -> list[str]:
|
||||
"""One local webhook URL per LAN host (canonical ``local_base`` verbatim)."""
|
||||
return [
|
||||
f"{local_base if host == local_host else _swap_url_host(local_base, host)}"
|
||||
f"/api/webhook/{webhook_id}"
|
||||
for host in lan_hosts
|
||||
]
|
||||
|
||||
|
||||
def build_connect_urls(
|
||||
@@ -196,6 +312,7 @@ def build_connect_urls(
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
extra_hosts: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve the entry's connect URLs (webhook forms first, then direct).
|
||||
|
||||
@@ -203,9 +320,13 @@ def build_connect_urls(
|
||||
log on start-up and the entry's Configure screen (the notification
|
||||
deliberately carries none - it is visible to every signed-in user). Each
|
||||
source is best-effort: a URL that cannot be resolved is omitted.
|
||||
"""
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
``extra_hosts`` (from :func:`async_get_lan_hosts`) adds one webhook and one
|
||||
direct-access URL per additional LAN address, so a multi-interface /
|
||||
multi-VLAN host surfaces every reachable interface rather than only the
|
||||
single host ``get_url`` picks (#1862). The ``get_url`` host stays canonical
|
||||
and first; any repeat of it in ``extra_hosts`` is deduped away.
|
||||
"""
|
||||
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
|
||||
urls: list[str] = []
|
||||
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
|
||||
@@ -234,14 +355,15 @@ def build_connect_urls(
|
||||
except ImportError:
|
||||
pass # Cloud integration not installed (e.g. HA Core) - local URL only.
|
||||
|
||||
local_host: str | None = None
|
||||
try:
|
||||
local_base = get_url(hass, allow_external=False, prefer_external=False)
|
||||
local_host = urlparse(local_base).hostname
|
||||
if webhook_id:
|
||||
urls.append(f"{local_base}/api/webhook/{webhook_id}")
|
||||
except NoURLAvailableError:
|
||||
pass # No internal/local URL configured - fall through to the hint form.
|
||||
local_base, local_host = _resolve_local_url(hass)
|
||||
|
||||
# One entry per enabled LAN address so a multi-interface / multi-VLAN host
|
||||
# surfaces every reachable interface rather than get_url's single pick
|
||||
# (#1862). The get_url host stays canonical and first.
|
||||
lan_hosts = _dedup_hosts(local_host, extra_hosts)
|
||||
|
||||
if local_base and webhook_id:
|
||||
urls.extend(_local_webhook_urls(local_base, local_host, lan_hosts, webhook_id))
|
||||
|
||||
if not urls and webhook_id:
|
||||
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
|
||||
@@ -253,9 +375,9 @@ def build_connect_urls(
|
||||
# Direct-access URL: admin-gated surfaces only (log + Configure screen).
|
||||
# Guarded on the secret path so a missing one omits the line instead of
|
||||
# rendering a valid-looking URL without its credential segment.
|
||||
urls.append(
|
||||
f"http://{local_host or '<home-assistant-ip>'}:{port}{secret_path}"
|
||||
" (direct access)"
|
||||
urls.extend(
|
||||
f"http://{host}:{port}{secret_path} (direct access)"
|
||||
for host in lan_hosts or ["<home-assistant-ip>"]
|
||||
)
|
||||
return urls
|
||||
|
||||
@@ -266,23 +388,83 @@ def _surface_connect_urls(
|
||||
auth_mode: str,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
extra_hosts: list[str] | None = None,
|
||||
oauth_client_id: str | None = None,
|
||||
oauth_client_secret: str | None = None,
|
||||
oauth_creds_active: bool = True,
|
||||
oauth_restart_pending: bool = False,
|
||||
) -> None:
|
||||
"""Log the connect URLs and (re)create a persistent notification."""
|
||||
urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled)
|
||||
auth_note = (
|
||||
"Webhook access is disabled (local-only mode)."
|
||||
if not webhook_enabled
|
||||
else "The webhook URL is the shared secret (no bearer required)."
|
||||
if auth_mode == WEBHOOK_AUTH_NONE
|
||||
else "Clients authenticate with your Home Assistant account (ha_auth)."
|
||||
urls = build_connect_urls(
|
||||
hass, entry, webhook_enabled=webhook_enabled, extra_hosts=extra_hosts
|
||||
)
|
||||
if not webhook_enabled:
|
||||
auth_note = "Webhook access is disabled (local-only mode)."
|
||||
elif auth_mode == WEBHOOK_AUTH_NONE:
|
||||
auth_note = "The webhook URL is the shared secret (no bearer required)."
|
||||
elif auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
# Kept secret-free (unlike the log line below) — see the SECURITY note
|
||||
# on the persistent notification further down, which reuses this text.
|
||||
creds_where = (
|
||||
"the Home Assistant log or the entry's Configure screen"
|
||||
if oauth_creds_active
|
||||
else "the entry's Configure screen"
|
||||
)
|
||||
auth_note = (
|
||||
"OAuth (Beta) is ENABLED for this URL (legacy mode) - see "
|
||||
f"{creds_where} for the Client ID and Client Secret to paste "
|
||||
"into your MCP client."
|
||||
)
|
||||
else:
|
||||
auth_note = "Clients authenticate with your Home Assistant account (ha_auth)."
|
||||
|
||||
url_lines = "\n".join(f"- {url}" for url in urls)
|
||||
_LOGGER.info(
|
||||
"HA-MCP in-process server is running. Connect URL(s):\n%s\n%s",
|
||||
url_lines,
|
||||
auth_note,
|
||||
log_message = (
|
||||
"HA-MCP in-process server is running. "
|
||||
f"Connect URL(s):\n{url_lines}\n{auth_note}"
|
||||
)
|
||||
if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
if oauth_creds_active:
|
||||
# Admin-only log (mirrors the webhook-proxy add-on's own startup
|
||||
# log, start.py). Cleartext credentials — deliberately NOT in the
|
||||
# persistent notification below, which every signed-in user can
|
||||
# see.
|
||||
log_message += (
|
||||
f"\n OAuth Client ID: {oauth_client_id}"
|
||||
f"\n OAuth Client Secret: {oauth_client_secret}"
|
||||
)
|
||||
if oauth_restart_pending:
|
||||
# First-enable mid-session late-binds the root views, so
|
||||
# /authorize is not live until the restart the repair asks
|
||||
# for. The credentials ARE the ones that will be served
|
||||
# (oauth_creds_active is True), but pasting them now gets a
|
||||
# connection that fails until the restart — same caveat the
|
||||
# rotation branch, the options hint, and the oauth_regenerate
|
||||
# help text carry.
|
||||
log_message += (
|
||||
"\n Legacy OAuth is not live until the restart Home "
|
||||
"Assistant is asking for; these credentials work once "
|
||||
"you restart."
|
||||
)
|
||||
log_message += (
|
||||
"\n Paste both into your MCP client's OAuth connector setup "
|
||||
"(e.g. Google Gemini Spark: Advanced settings)."
|
||||
)
|
||||
else:
|
||||
# SECURITY (review finding on #1880): while a credential rotation
|
||||
# is pending the restart, the bound root views still serve the OLD
|
||||
# identity, so a token issued under it stays valid — and could
|
||||
# read this log through the server's own log tools. Logging the
|
||||
# NEW credentials here would hand them to exactly the party the
|
||||
# rotation is meant to evict, so they are withheld until the
|
||||
# restart makes them active (which also kills every old token).
|
||||
log_message += (
|
||||
"\n The OAuth credentials were rotated and take effect after "
|
||||
"the restart Home Assistant is asking for; until then the "
|
||||
"previous credentials remain active. The new Client ID and "
|
||||
"Client Secret are on the entry's Configure screen."
|
||||
)
|
||||
_LOGGER.info(log_message)
|
||||
if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)):
|
||||
# Notification suppressed by option: clear any notification created
|
||||
# before the toggle was turned off, then skip creating a fresh one. The
|
||||
@@ -322,6 +504,29 @@ def _surface_connect_urls(
|
||||
)
|
||||
|
||||
|
||||
def _async_update_legacy_oauth_issue(hass: HomeAssistant, restart_needed: bool) -> None:
|
||||
"""File/clear the legacy-OAuth restart repair per ``async_register_webhook``'s
|
||||
return value.
|
||||
|
||||
Raised on BOTH transitions (see that function's docstring): enabling
|
||||
legacy mode (the root views just bound, or bound with different
|
||||
credentials than before) and disabling it (the views are still bound from
|
||||
a prior legacy registration). aiohttp can neither bind nor release an HTTP
|
||||
view without a full Home Assistant restart either way.
|
||||
"""
|
||||
if restart_needed:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ISSUE_LEGACY_OAUTH_RESTART,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_LEGACY_OAUTH_RESTART,
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART)
|
||||
|
||||
|
||||
_ISSUE_BY_KIND = {
|
||||
"package": ISSUE_PACKAGE_FAILED,
|
||||
"start": ISSUE_START_FAILED,
|
||||
@@ -450,8 +655,13 @@ async def async_maybe_auto_update(
|
||||
"shipped": shipped,
|
||||
"running": running,
|
||||
},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
learn_more_url=UPDATE_HOLD_DOCS_URL,
|
||||
)
|
||||
# A newer component exists but HACS may not surface it for ~48h; ask
|
||||
# HACS to refresh this repository now so the update becomes visible
|
||||
# promptly. Fire-and-forget + throttled per shipped version; fully
|
||||
# advisory (see hacs_nudge).
|
||||
async_schedule_hacs_nudge(hass, shipped)
|
||||
return
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
|
||||
|
||||
@@ -707,7 +917,12 @@ async def _async_check_component_compat(
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_COMPONENT_OUTDATED,
|
||||
translation_placeholders={"required": required, "installed": own},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
learn_more_url=UPDATE_HOLD_DOCS_URL,
|
||||
)
|
||||
# The server needs a newer component than HACS has surfaced; ask HACS
|
||||
# to refresh this repository now so the required update becomes visible
|
||||
# promptly. Fire-and-forget + throttled per required version; fully
|
||||
# advisory (see hacs_nudge).
|
||||
async_schedule_hacs_nudge(hass, required)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
|
||||
Reference in New Issue
Block a user