From 686a2d32dec59fdd2db7a90bf8abfd1599176562 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 11 May 2026 14:06:03 +0300 Subject: [PATCH 1/7] Move HTTP integration configuration to the UI --- homeassistant/components/http/__init__.py | 31 +++- homeassistant/components/http/issue.py | 49 +++++ homeassistant/components/http/storage.py | 58 ++++++ homeassistant/components/http/strings.json | 12 ++ .../components/http/websocket_api.py | 64 +++++++ tests/components/http/test_init.py | 16 +- tests/components/http/test_storage.py | 167 ++++++++++++++++++ tests/components/http/test_websocket_api.py | 136 ++++++++++++++ 8 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/http/issue.py create mode 100644 homeassistant/components/http/storage.py create mode 100644 homeassistant/components/http/websocket_api.py create mode 100644 tests/components/http/test_storage.py create mode 100644 tests/components/http/test_websocket_api.py diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 9089644474ba1..491e092cc0c48 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -65,10 +65,16 @@ from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded from .headers import setup_headers +from .issue import ( + async_create_deprecated_yaml_issue, + async_create_failed_to_start_issue, +) from .request_context import setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource +from .storage import async_get_store, to_stored from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite +from .websocket_api import async_register_websocket_api CONF_SERVER_HOST: Final = "server_host" CONF_SERVER_PORT: Final = "server_port" @@ -201,11 +207,32 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # we import aiohttp_fast_zlib (await async_import_module(hass, "aiohttp_fast_zlib")).enable() - conf: ConfData | None = config.get(DOMAIN) + yaml_conf: ConfData | None = config.get(DOMAIN) + store = async_get_store(hass) + stored = await store.async_load() - if conf is None: + if stored is None: + if yaml_conf is not None: + stored = to_stored(dict(yaml_conf)) + await store.async_save(stored) + async_create_deprecated_yaml_issue(hass) + else: + stored = to_stored(cast(dict[str, Any], HTTP_SCHEMA({}))) + await store.async_save(stored) + elif yaml_conf is not None: + async_create_deprecated_yaml_issue(hass) + + try: + conf = cast(ConfData, HTTP_SCHEMA(dict(stored))) + except vol.Invalid as err: + _LOGGER.error( + "Stored HTTP config is invalid: %s. Falling back to defaults", err + ) + async_create_failed_to_start_issue(hass, error=str(err)) conf = cast(ConfData, HTTP_SCHEMA({})) + async_register_websocket_api(hass) + if CONF_SERVER_HOST in conf and is_hassio(hass): issue_id = "server_host_deprecated_hassio" ir.async_create_issue( diff --git a/homeassistant/components/http/issue.py b/homeassistant/components/http/issue.py new file mode 100644 index 0000000000000..63d1426d555b7 --- /dev/null +++ b/homeassistant/components/http/issue.py @@ -0,0 +1,49 @@ +"""Repair issues for the HTTP integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + +# Removing YAML support outright would lock out users mid-migration. Six months +# is on the longer end of the 2-5 month window seen across other YAML->UI +# migrations, justified by HTTP affecting every installation. +BREAKS_IN_HA_VERSION = "2026.12.0" + + +@callback +def async_create_deprecated_yaml_issue( + hass: HomeAssistant, *, error: str | None = None +) -> None: + """Create a repair issue for deprecated YAML configuration.""" + if error is None: + issue_id = "deprecated_yaml" + severity = IssueSeverity.WARNING + else: + issue_id = f"deprecated_yaml_import_issue_{error}" + severity = IssueSeverity.ERROR + + async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + breaks_in_ha_version=BREAKS_IN_HA_VERSION, + severity=severity, + translation_key=issue_id, + translation_placeholders={"domain": DOMAIN}, + ) + + +@callback +def async_create_failed_to_start_issue(hass: HomeAssistant, *, error: str) -> None: + """Surface that HTTP fell back to safe defaults during startup.""" + async_create_issue( + hass, + DOMAIN, + "http_failed_to_start", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="http_failed_to_start", + translation_placeholders={"error": error}, + ) diff --git a/homeassistant/components/http/storage.py b/homeassistant/components/http/storage.py new file mode 100644 index 0000000000000..31550bb2028c5 --- /dev/null +++ b/homeassistant/components/http/storage.py @@ -0,0 +1,58 @@ +"""Persistent storage for the user-managed HTTP integration config.""" + +from ipaddress import IPv4Network, IPv6Network +from typing import Any, TypedDict, cast + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +# A separate key from STORAGE_KEY ("http") in __init__.py, which is used for the +# recovery-mode snapshot of the last successfully resolved config. The two stores +# must not share a key. +USER_CONFIG_STORAGE_KEY = "http.config" +USER_CONFIG_STORAGE_VERSION = 1 + + +class HttpUserConfig(TypedDict, total=False): + """User-managed HTTP config persisted via Store. + + Mirrors the validated output of ``HTTP_SCHEMA`` except ``trusted_proxies`` is + held as a list of strings for JSON serialization. + """ + + server_host: list[str] + server_port: int + ssl_certificate: str + ssl_peer_certificate: str + ssl_key: str + cors_allowed_origins: list[str] + use_x_forwarded_for: bool + trusted_proxies: list[str] + use_x_frame_options: bool + ip_ban_enabled: bool + login_attempts_threshold: int + ssl_profile: str + + +class HttpConfigStore(Store[HttpUserConfig]): + """Store for user-managed HTTP config.""" + + +def async_get_store(hass: HomeAssistant) -> HttpConfigStore: + """Return the user-config Store, private to the integration.""" + return HttpConfigStore( + hass, + USER_CONFIG_STORAGE_VERSION, + USER_CONFIG_STORAGE_KEY, + private=True, + ) + + +def to_stored(conf: dict[str, Any]) -> HttpUserConfig: + """Convert a validated ``HTTP_SCHEMA`` dict into a JSON-serializable form.""" + out: dict[str, Any] = dict(conf) + if "trusted_proxies" in out: + out["trusted_proxies"] = [ + str(cast(IPv4Network | IPv6Network, n)) for n in out["trusted_proxies"] + ] + return cast(HttpUserConfig, out) diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json index b74cfd457b22a..ef418c6801943 100644 --- a/homeassistant/components/http/strings.json +++ b/homeassistant/components/http/strings.json @@ -1,5 +1,17 @@ { "issues": { + "deprecated_yaml": { + "description": "Your existing HTTP configuration from `configuration.yaml` has been imported. The `http` integration is now configured from the UI under **Settings** > **System** > **Network**.\n\nPlease remove the `http:` block from your `configuration.yaml` and restart Home Assistant.", + "title": "The HTTP YAML configuration is deprecated" + }, + "deprecated_yaml_import_issue_unknown": { + "description": "Home Assistant could not import the HTTP configuration from `configuration.yaml`. The integration started with default settings.\n\nReview the values under **Settings** > **System** > **Network** and remove the `http:` block from `configuration.yaml`.", + "title": "Could not import the HTTP YAML configuration" + }, + "http_failed_to_start": { + "description": "The stored HTTP configuration is no longer valid (for example, an SSL certificate path may have moved). Home Assistant started with default HTTP settings so you can recover access.\n\nReview the values under **Settings** > **System** > **Network**.\n\nError: `{error}`", + "title": "HTTP configuration was rejected on startup" + }, "server_host_deprecated_hassio": { "description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.", "title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication" diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py new file mode 100644 index 0000000000000..65b6e64d6d2d5 --- /dev/null +++ b/homeassistant/components/http/websocket_api.py @@ -0,0 +1,64 @@ +"""WebSocket API for the HTTP integration user config.""" + +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.core import HomeAssistant + +from .storage import async_get_store, to_stored + + +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the HTTP config WebSocket commands. + + The ``websocket_api`` module is imported lazily because it imports from + ``homeassistant.components.http``; using its decorators at this module's + load time would create a circular import. + """ + from homeassistant.components import websocket_api # noqa: PLC0415 + + @websocket_api.require_admin + @websocket_api.websocket_command({vol.Required("type"): "http/config/get"}) + @websocket_api.async_response + async def ws_get_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Return the current user-managed HTTP config.""" + store = async_get_store(hass) + stored = await store.async_load() or {} + connection.send_result(msg["id"], {"config": stored}) + + @websocket_api.require_admin + @websocket_api.websocket_command( + { + vol.Required("type"): "http/config/update", + vol.Required("config"): dict, + } + ) + @websocket_api.async_response + async def ws_update_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Replace the user-managed HTTP config after validating it.""" + # Local import: HTTP_SCHEMA lives in __init__.py, which already imports + # this module to wire up the commands. + from . import HTTP_SCHEMA # noqa: PLC0415 + + try: + validated = HTTP_SCHEMA(msg["config"]) + except vol.Invalid as err: + connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) + return + + stored = to_stored(cast(dict[str, Any], validated)) + store = async_get_store(hass) + await store.async_save(stored) + connection.send_result(msg["id"], {"config": stored}) + + websocket_api.async_register_command(hass, ws_get_config) + websocket_api.async_register_command(hass, ws_update_config) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 67774d0eadd47..e974005208405 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -688,16 +688,24 @@ async def test_ssl_issue_urls_configured( "expected_issues", ), [ - (False, {}, ["0.0.0.0", "::"], set()), - (False, {"server_host": "0.0.0.0"}, ["0.0.0.0"], set()), - (True, {}, ["0.0.0.0", "::"], set()), + (False, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), + ( + False, + {"server_host": "0.0.0.0"}, + ["0.0.0.0"], + {("http", "deprecated_yaml")}, + ), + (True, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), ( True, {"server_host": "0.0.0.0"}, [ "0.0.0.0", ], - {("http", "server_host_deprecated_hassio")}, + { + ("http", "server_host_deprecated_hassio"), + ("http", "deprecated_yaml"), + }, ), ], ) diff --git a/tests/components/http/test_storage.py b/tests/components/http/test_storage.py new file mode 100644 index 0000000000000..cce49713d7cf9 --- /dev/null +++ b/tests/components/http/test_storage.py @@ -0,0 +1,167 @@ +"""Tests for the HTTP integration user-config storage and YAML migration.""" + +from ipaddress import ip_network +from typing import Any +from unittest.mock import ANY, Mock, patch + +import pytest + +from homeassistant.components.http.storage import ( + USER_CONFIG_STORAGE_KEY, + USER_CONFIG_STORAGE_VERSION, + to_stored, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +def disable_http_server(socket_enabled: None) -> None: + """Allow the HTTP server to start.""" + return + + +def test_to_stored_serializes_trusted_proxies() -> None: + """Trusted proxies are persisted with full CIDR notation as strings.""" + conf = { + "server_port": 8123, + "trusted_proxies": [ip_network("10.0.0.0/24"), ip_network("172.16.0.5/32")], + } + stored = to_stored(conf) + assert stored["trusted_proxies"] == ["10.0.0.0/24", "172.16.0.5/32"] + + +async def test_first_boot_imports_yaml( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """A populated YAML block is imported into the store on first boot.""" + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component( + hass, + "http", + { + "http": { + "server_port": 8125, + "cors_allowed_origins": ["https://example.com"], + } + }, + ) + await hass.async_block_till_done() + + stored = hass_storage[USER_CONFIG_STORAGE_KEY] + assert stored["version"] == USER_CONFIG_STORAGE_VERSION + assert stored["data"]["server_port"] == 8125 + assert stored["data"]["cors_allowed_origins"] == ["https://example.com"] + assert ("http", "deprecated_yaml") in issue_registry.issues + + +async def test_first_boot_without_yaml_writes_defaults( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """With no YAML block present, the store is initialized with defaults.""" + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, "http", {}) + await hass.async_block_till_done() + + stored = hass_storage[USER_CONFIG_STORAGE_KEY] + assert stored["data"]["server_port"] == 8123 + assert ("http", "deprecated_yaml") not in issue_registry.issues + + +async def test_second_boot_prefers_stored_over_yaml( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """When stored config exists, YAML is ignored and the deprecation remains.""" + hass_storage[USER_CONFIG_STORAGE_KEY] = { + "version": USER_CONFIG_STORAGE_VERSION, + "minor_version": 1, + "key": USER_CONFIG_STORAGE_KEY, + "data": { + "server_port": 9999, + "cors_allowed_origins": ["https://stored.example"], + "ssl_profile": "modern", + "use_x_frame_options": True, + "ip_ban_enabled": True, + "login_attempts_threshold": -1, + }, + } + + with ( + patch("asyncio.BaseEventLoop.create_server", return_value=Mock()) as srv, + patch("homeassistant.components.http.is_hassio", return_value=False), + ): + assert await async_setup_component( + hass, "http", {"http": {"server_port": 12345}} + ) + await hass.async_start() + await hass.async_block_till_done() + + # Stored port 9999 wins over YAML port 12345. + srv.assert_called_once_with( + ANY, + ["0.0.0.0", "::"], + 9999, + ssl=None, + backlog=128, + reuse_address=None, + reuse_port=None, + ) + assert ("http", "deprecated_yaml") in issue_registry.issues + + +async def test_trusted_proxies_round_trip_through_store( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """A CIDR network in YAML is preserved exactly when read back from storage.""" + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component( + hass, + "http", + { + "http": { + "use_x_forwarded_for": True, + "trusted_proxies": ["10.0.0.0/24"], + } + }, + ) + await hass.async_block_till_done() + + assert hass_storage[USER_CONFIG_STORAGE_KEY]["data"]["trusted_proxies"] == [ + "10.0.0.0/24" + ] + + +async def test_invalid_stored_config_falls_back_to_defaults( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Stored config that no longer validates triggers a fallback and a repair.""" + hass_storage[USER_CONFIG_STORAGE_KEY] = { + "version": USER_CONFIG_STORAGE_VERSION, + "minor_version": 1, + "key": USER_CONFIG_STORAGE_KEY, + "data": { + "server_port": 8123, + "ssl_certificate": "/path/that/does/not/exist.pem", + "cors_allowed_origins": ["https://cast.home-assistant.io"], + "ssl_profile": "modern", + "use_x_frame_options": True, + "ip_ban_enabled": True, + "login_attempts_threshold": -1, + }, + } + + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, "http", {}) + await hass.async_block_till_done() + + assert ("http", "http_failed_to_start") in issue_registry.issues diff --git a/tests/components/http/test_websocket_api.py b/tests/components/http/test_websocket_api.py new file mode 100644 index 0000000000000..08de0bcc68dfe --- /dev/null +++ b/tests/components/http/test_websocket_api.py @@ -0,0 +1,136 @@ +"""Tests for the HTTP integration WebSocket configuration API.""" + +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.http.storage import USER_CONFIG_STORAGE_KEY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def disable_http_server(socket_enabled: None) -> None: + """Allow the HTTP server to start.""" + return + + +@pytest.fixture +async def setup_http(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: + """Set up the HTTP integration without any pre-existing YAML.""" + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, "http", {}) + await hass.async_start() + await hass.async_block_till_done() + + +async def test_get_returns_stored_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_http: None, +) -> None: + """A get call returns the currently stored config.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "http/config/get"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["config"]["server_port"] == 8123 + + +async def test_update_persists_valid_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + setup_http: None, +) -> None: + """A valid update is persisted and returned.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "http/config/update", + "config": { + "server_port": 8124, + "cors_allowed_origins": ["https://updated.example"], + }, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["config"]["server_port"] == 8124 + assert hass_storage[USER_CONFIG_STORAGE_KEY]["data"]["server_port"] == 8124 + + +async def test_update_rejects_inclusive_proxy_violation( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_http: None, +) -> None: + """use_x_forwarded_for without trusted_proxies is rejected.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "http/config/update", + "config": {"use_x_forwarded_for": True}, + } + ) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "invalid_format" + + +async def test_update_rejects_invalid_ssl_path( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_http: None, +) -> None: + """A non-existent SSL path is rejected by the schema.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "http/config/update", + "config": {"ssl_certificate": "/path/does/not/exist.pem"}, + } + ) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "invalid_format" + assert "ssl_certificate" in response["error"]["message"] + + +async def test_get_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, + setup_http: None, +) -> None: + """A non-admin connection cannot read the config.""" + client = await hass_ws_client(hass, hass_read_only_access_token) + await client.send_json_auto_id({"type": "http/config/get"}) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "unauthorized" + + +async def test_update_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, + setup_http: None, +) -> None: + """A non-admin connection cannot update the config.""" + client = await hass_ws_client(hass, hass_read_only_access_token) + await client.send_json_auto_id( + {"type": "http/config/update", "config": {"server_port": 8124}} + ) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "unauthorized" From e43ddf18496da81a6ead4c33b2c79698f63e47b2 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 11 May 2026 14:43:47 +0300 Subject: [PATCH 2/7] Drop deprecated base_url when persisting HTTP config --- homeassistant/components/http/storage.py | 2 +- tests/components/http/test_storage.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/storage.py b/homeassistant/components/http/storage.py index 31550bb2028c5..adb63dd17051f 100644 --- a/homeassistant/components/http/storage.py +++ b/homeassistant/components/http/storage.py @@ -50,7 +50,7 @@ def async_get_store(hass: HomeAssistant) -> HttpConfigStore: def to_stored(conf: dict[str, Any]) -> HttpUserConfig: """Convert a validated ``HTTP_SCHEMA`` dict into a JSON-serializable form.""" - out: dict[str, Any] = dict(conf) + out: dict[str, Any] = {k: v for k, v in conf.items() if k != "base_url"} if "trusted_proxies" in out: out["trusted_proxies"] = [ str(cast(IPv4Network | IPv6Network, n)) for n in out["trusted_proxies"] diff --git a/tests/components/http/test_storage.py b/tests/components/http/test_storage.py index cce49713d7cf9..477cdfdc935f4 100644 --- a/tests/components/http/test_storage.py +++ b/tests/components/http/test_storage.py @@ -32,6 +32,14 @@ def test_to_stored_serializes_trusted_proxies() -> None: assert stored["trusted_proxies"] == ["10.0.0.0/24", "172.16.0.5/32"] +def test_to_stored_drops_deprecated_base_url() -> None: + """The deprecated base_url key is not persisted.""" + conf = {"server_port": 8123, "base_url": "https://old.example"} + stored = to_stored(conf) + assert "base_url" not in stored + assert stored["server_port"] == 8123 + + async def test_first_boot_imports_yaml( hass: HomeAssistant, hass_storage: dict[str, Any], From 1f371a9ce1f0b8ab7dac7eb6266748b91df079d4 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 12 May 2026 09:09:52 +0300 Subject: [PATCH 3/7] Extend HTTP YAML deprecation window to 12 months --- homeassistant/components/http/issue.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/issue.py b/homeassistant/components/http/issue.py index 63d1426d555b7..ff6f13188f613 100644 --- a/homeassistant/components/http/issue.py +++ b/homeassistant/components/http/issue.py @@ -5,10 +5,10 @@ from .const import DOMAIN -# Removing YAML support outright would lock out users mid-migration. Six months -# is on the longer end of the 2-5 month window seen across other YAML->UI -# migrations, justified by HTTP affecting every installation. -BREAKS_IN_HA_VERSION = "2026.12.0" +# Removing YAML support outright would lock out users mid-migration. HTTP +# touches every installation, so we allow a full release cycle (12 months) for +# users to migrate. +BREAKS_IN_HA_VERSION = "2027.6.0" @callback From 7f6626970842d34708e3b666930f9181498f7d62 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 12 May 2026 09:12:05 +0300 Subject: [PATCH 4/7] comment --- homeassistant/components/http/issue.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/http/issue.py b/homeassistant/components/http/issue.py index ff6f13188f613..87f362e5136f1 100644 --- a/homeassistant/components/http/issue.py +++ b/homeassistant/components/http/issue.py @@ -5,8 +5,7 @@ from .const import DOMAIN -# Removing YAML support outright would lock out users mid-migration. HTTP -# touches every installation, so we allow a full release cycle (12 months) for +# HTTP touches every installation, so we allow at least a full 12 months for # users to migrate. BREAKS_IN_HA_VERSION = "2027.6.0" From 457d61e588ca127efd89252eb27e0cd4d432810f Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 12 May 2026 09:29:58 +0300 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/http/storage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/http/storage.py b/homeassistant/components/http/storage.py index adb63dd17051f..320ade02317de 100644 --- a/homeassistant/components/http/storage.py +++ b/homeassistant/components/http/storage.py @@ -45,6 +45,7 @@ def async_get_store(hass: HomeAssistant) -> HttpConfigStore: USER_CONFIG_STORAGE_VERSION, USER_CONFIG_STORAGE_KEY, private=True, + atomic_writes=True, ) From 3cec452d358c593a0a48b38a61c6438a3955267c Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 12 May 2026 09:34:13 +0300 Subject: [PATCH 6/7] Merge partial updates in HTTP config WS API and drop unreachable import-failure repair --- homeassistant/components/http/issue.py | 17 +++--------- homeassistant/components/http/strings.json | 4 --- .../components/http/websocket_api.py | 9 ++++--- tests/components/http/test_websocket_api.py | 27 +++++++++++++++++++ 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/http/issue.py b/homeassistant/components/http/issue.py index 87f362e5136f1..028703980f8f3 100644 --- a/homeassistant/components/http/issue.py +++ b/homeassistant/components/http/issue.py @@ -11,25 +11,16 @@ @callback -def async_create_deprecated_yaml_issue( - hass: HomeAssistant, *, error: str | None = None -) -> None: +def async_create_deprecated_yaml_issue(hass: HomeAssistant) -> None: """Create a repair issue for deprecated YAML configuration.""" - if error is None: - issue_id = "deprecated_yaml" - severity = IssueSeverity.WARNING - else: - issue_id = f"deprecated_yaml_import_issue_{error}" - severity = IssueSeverity.ERROR - async_create_issue( hass, DOMAIN, - issue_id, + "deprecated_yaml", is_fixable=False, breaks_in_ha_version=BREAKS_IN_HA_VERSION, - severity=severity, - translation_key=issue_id, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", translation_placeholders={"domain": DOMAIN}, ) diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json index ef418c6801943..1a15ddc4bd52c 100644 --- a/homeassistant/components/http/strings.json +++ b/homeassistant/components/http/strings.json @@ -4,10 +4,6 @@ "description": "Your existing HTTP configuration from `configuration.yaml` has been imported. The `http` integration is now configured from the UI under **Settings** > **System** > **Network**.\n\nPlease remove the `http:` block from your `configuration.yaml` and restart Home Assistant.", "title": "The HTTP YAML configuration is deprecated" }, - "deprecated_yaml_import_issue_unknown": { - "description": "Home Assistant could not import the HTTP configuration from `configuration.yaml`. The integration started with default settings.\n\nReview the values under **Settings** > **System** > **Network** and remove the `http:` block from `configuration.yaml`.", - "title": "Could not import the HTTP YAML configuration" - }, "http_failed_to_start": { "description": "The stored HTTP configuration is no longer valid (for example, an SSL certificate path may have moved). Home Assistant started with default HTTP settings so you can recover access.\n\nReview the values under **Settings** > **System** > **Network**.\n\nError: `{error}`", "title": "HTTP configuration was rejected on startup" diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py index 65b6e64d6d2d5..a7a41ec6a6a14 100644 --- a/homeassistant/components/http/websocket_api.py +++ b/homeassistant/components/http/websocket_api.py @@ -44,19 +44,22 @@ async def ws_update_config( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Replace the user-managed HTTP config after validating it.""" + """Merge the incoming patch into the stored HTTP config and save.""" # Local import: HTTP_SCHEMA lives in __init__.py, which already imports # this module to wire up the commands. from . import HTTP_SCHEMA # noqa: PLC0415 + store = async_get_store(hass) + existing = await store.async_load() or {} + merged = {**existing, **msg["config"]} + try: - validated = HTTP_SCHEMA(msg["config"]) + validated = HTTP_SCHEMA(merged) except vol.Invalid as err: connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return stored = to_stored(cast(dict[str, Any], validated)) - store = async_get_store(hass) await store.async_save(stored) connection.send_result(msg["id"], {"config": stored}) diff --git a/tests/components/http/test_websocket_api.py b/tests/components/http/test_websocket_api.py index 08de0bcc68dfe..89025896265d1 100644 --- a/tests/components/http/test_websocket_api.py +++ b/tests/components/http/test_websocket_api.py @@ -65,6 +65,33 @@ async def test_update_persists_valid_config( assert hass_storage[USER_CONFIG_STORAGE_KEY]["data"]["server_port"] == 8124 +async def test_update_merges_partial_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + setup_http: None, +) -> None: + """A partial update keeps previously stored keys.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "http/config/update", + "config": {"cors_allowed_origins": ["https://first.example"]}, + } + ) + await client.receive_json() + + await client.send_json_auto_id( + {"type": "http/config/update", "config": {"server_port": 8125}} + ) + response = await client.receive_json() + + assert response["success"] + stored = hass_storage[USER_CONFIG_STORAGE_KEY]["data"] + assert stored["server_port"] == 8125 + assert stored["cors_allowed_origins"] == ["https://first.example"] + + async def test_update_rejects_inclusive_proxy_violation( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 28e98a9bece3ad09b9bb1668f50095d434cee06a Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 12 May 2026 09:37:35 +0300 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/http/websocket_api.py | 6 +++--- tests/components/http/test_storage.py | 4 ++-- tests/components/http/test_websocket_api.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py index a7a41ec6a6a14..e6a2f185bc79b 100644 --- a/homeassistant/components/http/websocket_api.py +++ b/homeassistant/components/http/websocket_api.py @@ -12,9 +12,9 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: """Register the HTTP config WebSocket commands. - The ``websocket_api`` module is imported lazily because it imports from - ``homeassistant.components.http``; using its decorators at this module's - load time would create a circular import. + The core ``homeassistant.components.websocket_api`` dependency is imported + lazily because it imports from ``homeassistant.components.http``; using its + decorators at this module's load time would create a circular import. """ from homeassistant.components import websocket_api # noqa: PLC0415 diff --git a/tests/components/http/test_storage.py b/tests/components/http/test_storage.py index 477cdfdc935f4..42774eb313d4d 100644 --- a/tests/components/http/test_storage.py +++ b/tests/components/http/test_storage.py @@ -17,8 +17,8 @@ @pytest.fixture(autouse=True) -def disable_http_server(socket_enabled: None) -> None: - """Allow the HTTP server to start.""" +def enable_sockets(socket_enabled: None) -> None: + """Enable sockets so the HTTP server can start in tests.""" return diff --git a/tests/components/http/test_websocket_api.py b/tests/components/http/test_websocket_api.py index 89025896265d1..b9f343eac01c0 100644 --- a/tests/components/http/test_websocket_api.py +++ b/tests/components/http/test_websocket_api.py @@ -13,7 +13,7 @@ @pytest.fixture(autouse=True) -def disable_http_server(socket_enabled: None) -> None: +def enable_sockets_for_http_server(socket_enabled: None) -> None: """Allow the HTTP server to start.""" return