Fill absent YAML server_port with the historical default during migration

A YAML config without an explicit server_port has always meant port
8123. Validating it with the schema default filled in reinterprets the
config now that the default port depends on the environment (port 80
under Supervisor): an otherwise unchanged reverse proxy YAML config
would be staged as a pending trial on the new default port,
auto-reverting with a restart five minutes later.

Fill the port with the historical YAML default (SERVER_PORT) during
migration instead, and drop the YAML schema default so an absent port
is detectable. Filling from the stable slot would be equivalent for
upgrades (the v1 store always recorded the port explicitly), but would
reintroduce the reinterpretation as port 80 when no store exists, e.g.
a YAML-only backup restored to a fresh install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-07-30 12:06:49 +02:00
parent 153baf136b
commit 5a78ef9a21
3 changed files with 105 additions and 2 deletions
+4 -2
View File
@@ -33,7 +33,7 @@ from homeassistant.setup import (
)
from homeassistant.util.async_ import create_eager_task
from .config import async_get_and_load_store, async_load_config, default_server_port
from .config import async_get_and_load_store, async_load_config
from .const import ( # noqa: F401
CONF_BASE_URL,
CONF_CORS_ORIGINS,
@@ -77,7 +77,9 @@ HTTP_SCHEMA: Final = vol.All(
vol.Optional(CONF_SERVER_HOST): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
vol.Optional(CONF_SERVER_PORT, default=default_server_port): cv.port,
# No default: an absent port is filled with the historical YAML
# default (8123) during the migration (see async_migrate_yaml).
vol.Optional(CONF_SERVER_PORT): cv.port,
vol.Optional(CONF_BASE_URL): cv.string,
vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
+7
View File
@@ -477,6 +477,13 @@ class HTTPConfigStore:
async def async_migrate_yaml(self, config: ConfData) -> None:
"""Migrate YAML config to storage as pending if not the same as the config used for recovery."""
await self.async_load()
if CONF_SERVER_PORT not in config:
# An absent port in YAML has always meant SERVER_PORT (8123).
# Filling in the current default instead would reinterpret the
# config now that the default depends on the environment (port 80
# under Supervisor) and stage an unwanted port change as a
# pending trial.
config = cast(ConfData, {**config, CONF_SERVER_PORT: SERVER_PORT})
validated_config = cast(ConfData, HTTP_STORAGE_SCHEMA(config))
if self._stable_differs_only_by_lost_proxy_masks(validated_config):
# Releases up to 2026.7.1 dropped the network mask when storing
+94
View File
@@ -1584,6 +1584,100 @@ async def test_upgrade_with_stored_old_default_config_keeps_port(
assert len(restart_calls) == 0
async def test_upgrade_yaml_without_port_keeps_stable_port(
hass: HomeAssistant,
hass_storage: dict[str, Any],
issue_registry: ir.IssueRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""A YAML config without an explicit port keeps its port on upgrade.
A YAML config without server_port (e.g. a reverse proxy setup) has been
running on the old default port 8123, which the v1 store recorded
explicitly. Migrating it with the port filled from the new Supervisor
default (80) instead of the historical YAML default would stage an
otherwise unchanged config as a pending trial: nothing promotes it, so
the trial would auto-revert with a restart five minutes later.
"""
hass_storage[DOMAIN] = {
"version": 1,
"key": DOMAIN,
"data": {
"server_port": 8123,
"use_x_forwarded_for": True,
"trusted_proxies": ["10.0.0.0/24"],
"cors_allowed_origins": ["https://cast.home-assistant.io"],
"use_x_frame_options": True,
"ip_ban_enabled": True,
"login_attempts_threshold": -1,
"ssl_profile": "modern",
},
}
yaml_conf = {
"use_x_forwarded_for": True,
"trusted_proxies": ["10.0.0.0/24"],
}
restart_calls = async_mock_service(hass, "homeassistant", "restart")
with _supervisor_default_config():
assert await async_setup_component(hass, DOMAIN, {DOMAIN: yaml_conf})
assert await async_setup_component(hass, "onboarding", {})
await hass.async_start()
await hass.async_block_till_done()
assert hass.config.api.port == 8123
store = await async_get_and_load_store(hass)
assert store.active_config_type is ActiveConfigType.STABLE
assert store.revert_deadline is None
data = hass_storage[DOMAIN]["data"]
assert data["pending"] is None
assert data["stable"]["server_port"] == 8123
assert data["stable"]["trusted_proxies"] == ["10.0.0.0/24"]
assert data["yaml_migration_done"] is True
assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is not None
# Nothing was staged for trial: no auto-revert restart fires.
freezer.tick(AUTO_REVERT_DELAY)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(restart_calls) == 0
@pytest.mark.usefixtures("freezer")
async def test_yaml_without_port_means_historical_default_without_store(
hass: HomeAssistant,
hass_storage: dict[str, Any],
) -> None:
"""An absent YAML port means 8123 even when there is no store.
Without a store (e.g. a YAML-only backup restored to a fresh install),
stable is the built-in default config — port 80 under Supervisor. The
absent YAML port must still be interpreted as the historical YAML
default 8123, not as the environment-dependent default the YAML author
never expressed.
"""
yaml_conf = {
"use_x_forwarded_for": True,
"trusted_proxies": ["10.0.0.0/24"],
}
with _supervisor_default_config():
assert await async_setup_component(hass, DOMAIN, {DOMAIN: yaml_conf})
assert await async_setup_component(hass, "onboarding", {})
await hass.async_start()
await hass.async_block_till_done()
# The YAML config differs from the default stable, so it is trialed
# as pending — on the port the YAML config always meant.
assert hass.config.api.port == 8123
store = await async_get_and_load_store(hass)
assert store.active_config_type is ActiveConfigType.PENDING
data = hass_storage[DOMAIN]["data"]
assert data["pending"]["server_port"] == 8123
assert data["pending"]["trusted_proxies"] == ["10.0.0.0/24"]
assert data["stable"]["server_port"] == 80
@pytest.mark.usefixtures("freezer")
async def test_setup_migrates_v2_1_storage_to_v2_2(
hass: HomeAssistant,