Add airOS (insecure ssl) support for legacy v6 devices (#172954)

This commit is contained in:
Tom
2026-06-22 17:21:03 +02:00
committed by GitHub
parent 956e26196a
commit 11b49607bd
9 changed files with 413 additions and 25 deletions
+62 -15
View File
@@ -2,6 +2,7 @@
import logging
from aiohttp import ClientSession, TCPConnector
from airos.airos6 import AirOS6
from airos.airos8 import AirOS8
from airos.exceptions import (
@@ -10,6 +11,7 @@ from airos.exceptions import (
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSKeyDataMissingError,
AirOSTLSCompatibilityError,
)
from airos.helpers import DetectDeviceData, async_get_firmware_data
@@ -30,13 +32,20 @@ from homeassistant.exceptions import (
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADDITIONAL_SETTINGS
from .const import (
CONF_LEGACY_SSL,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
SECTION_ADDITIONAL_SETTINGS,
)
from .coordinator import (
AirOSConfigEntry,
AirOSDataUpdateCoordinator,
AirOSFirmwareUpdateCoordinator,
AirOSRuntimeData,
)
from .helpers import build_legacy_context
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -51,41 +60,60 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Set up Ubiquiti airOS from a config entry."""
owns_session = False
verify_ssl = entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
hass, verify_ssl=entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
)
session = async_get_clientsession(hass, verify_ssl=verify_ssl)
if entry.data.get(CONF_LEGACY_SSL, False):
session = ClientSession(
connector=TCPConnector(ssl=build_legacy_context(verify_ssl=verify_ssl))
)
owns_session = True
conn_data = {
CONF_HOST: entry.data[CONF_HOST],
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
"use_ssl": entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
"session": session,
"use_ssl": entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
}
async def close_session() -> None:
"""Close legacy session before raising if needed."""
if owns_session:
await session.close()
# Determine firmware version before creating the device instance
try:
device_data: DetectDeviceData = await async_get_firmware_data(**conn_data)
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
AirOSTLSCompatibilityError,
TimeoutError,
) as err:
await close_session()
raise ConfigEntryNotReady from err
except (
AirOSConnectionAuthenticationError,
AirOSDataMissingError,
) as err:
await close_session()
raise ConfigEntryAuthFailed from err
except AirOSKeyDataMissingError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryError("key_data_missing") from err
await close_session()
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="key_data_missing"
) from err
except Exception as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryError("unknown") from err
await close_session()
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="unknown"
) from err
airos_class: type[AirOS8 | AirOS6] = (
AirOS8 if device_data["fw_major"] == 8 else AirOS6
@@ -96,16 +124,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
data_coordinator = AirOSDataUpdateCoordinator(
hass, entry, device_data, airos_device
)
await data_coordinator.async_config_entry_first_refresh()
firmware_coordinator: AirOSFirmwareUpdateCoordinator | None = None
if device_data["fw_major"] >= 8:
firmware_coordinator = AirOSFirmwareUpdateCoordinator(hass, entry, airos_device)
await firmware_coordinator.async_config_entry_first_refresh()
try:
await data_coordinator.async_config_entry_first_refresh()
firmware_coordinator: AirOSFirmwareUpdateCoordinator | None = None
if device_data["fw_major"] >= 8:
firmware_coordinator = AirOSFirmwareUpdateCoordinator(
hass, entry, airos_device
)
await firmware_coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady, ConfigEntryAuthFailed:
await close_session()
raise
except Exception as err:
await close_session()
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="unknown"
) from err
entry.runtime_data = AirOSRuntimeData(
status=data_coordinator,
firmware=firmware_coordinator,
owns_session=owns_session,
session=session,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
@@ -182,4 +224,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
unload_state = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
# Clean up legacy session if needed
if unload_state and entry.runtime_data.owns_session:
await entry.runtime_data.session.close()
return unload_state
+33 -5
View File
@@ -5,6 +5,7 @@ from collections.abc import Mapping
import logging
from typing import Any, override
from aiohttp import ClientSession, TCPConnector
from airos.airos6 import AirOS6
from airos.airos8 import AirOS8
from airos.discovery import airos_discover_devices
@@ -16,6 +17,7 @@ from airos.exceptions import (
AirOSEndpointError,
AirOSKeyDataMissingError,
AirOSListenerError,
AirOSTLSCompatibilityError,
)
from airos.helpers import DetectDeviceData, async_get_firmware_data
import voluptuous as vol
@@ -44,6 +46,7 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_LEGACY_SSL,
DEFAULT_SSL,
DEFAULT_USERNAME,
DEFAULT_VERIFY_SSL,
@@ -54,6 +57,7 @@ from .const import (
MAC_ADDRESS,
SECTION_ADDITIONAL_SETTINGS,
)
from .helpers import build_legacy_context
_LOGGER = logging.getLogger(__name__)
@@ -128,15 +132,24 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def _validate_and_get_device_info(
self, config_data: dict[str, Any]
self,
config_data: dict[str, Any],
legacy: bool = False,
) -> dict[str, Any] | None:
"""Validate user input with the device API."""
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
self.hass,
verify_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL],
)
# Older airOS 6 devices may still lack proper levels
close_session = False
verify_ssl = config_data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
session = async_get_clientsession(self.hass, verify_ssl=verify_ssl)
if legacy:
session = ClientSession(
connector=TCPConnector(ssl=build_legacy_context(verify_ssl=verify_ssl))
)
close_session = True
try:
device_data: DetectDeviceData = await async_get_firmware_data(
@@ -147,6 +160,17 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
use_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
)
except AirOSTLSCompatibilityError:
# If already in legacy, stop iteration
if legacy:
self.errors["base"] = "cannot_connect"
else:
retry_config = dict(config_data)
retry_config[CONF_LEGACY_SSL] = True
return await self._validate_and_get_device_info(
config_data=retry_config, legacy=True
)
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
@@ -169,6 +193,10 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
return {"title": device_data["hostname"], "data": config_data}
finally:
if close_session:
await session.close()
return None
async def async_step_reauth(
+2
View File
@@ -20,3 +20,5 @@ HOSTNAME = "hostname"
IP_ADDRESS = "ip_address"
MAC_ADDRESS = "mac_address"
DEVICE_NAME = "airOS device"
CONF_LEGACY_SSL = "legacy_ssl"
@@ -5,6 +5,7 @@ from dataclasses import dataclass
import logging
from typing import Any, TypeVar, override
from aiohttp import ClientSession
from airos.airos6 import AirOS6, AirOS6Data
from airos.airos8 import AirOS8, AirOS8Data
from airos.exceptions import (
@@ -39,6 +40,8 @@ class AirOSRuntimeData:
status: AirOSDataUpdateCoordinator
firmware: AirOSFirmwareUpdateCoordinator | None
session: ClientSession
owns_session: bool = False
async def async_fetch_airos_data(
+16
View File
@@ -0,0 +1,16 @@
"""Helpers for airOS."""
import ssl
def build_legacy_context(*, verify_ssl: bool) -> ssl.SSLContext:
"""Build an SSL context compatible with legacy airOS 6 devices."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
ctx.minimum_version = ssl.TLSVersion.TLSv1
if not verify_ssl:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
@@ -207,6 +207,9 @@
"reboot_failed": {
"message": "The device did not accept the reboot request. Try again, or check your device web interface for errors."
},
"unknown": {
"message": "[%key:common::config_flow::error::unknown%]"
},
"update_connection_authentication_error": {
"message": "Authentication or connection failed during firmware update"
},
+101 -4
View File
@@ -1,7 +1,6 @@
"""Test the Ubiquiti airOS config flow."""
from typing import Any
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from airos.exceptions import (
AirOSConnectionAuthenticationError,
@@ -10,12 +9,14 @@ from airos.exceptions import (
AirOSEndpointError,
AirOSKeyDataMissingError,
AirOSListenerError,
AirOSTLSCompatibilityError,
)
from airos.helpers import DetectDeviceData
import pytest
import voluptuous as vol
from homeassistant.components.airos.const import (
CONF_LEGACY_SSL,
DEFAULT_USERNAME,
DOMAIN,
HOSTNAME,
@@ -84,7 +85,7 @@ MOCK_DISC_EXISTS = {
async def test_manual_flow_creates_entry(
hass: HomeAssistant,
ap_status_fixture: dict[str, Any],
ap_status_fixture: AirOSData,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
mock_setup_entry: AsyncMock,
@@ -159,7 +160,7 @@ async def test_form_duplicate_entry(
async def test_form_exception_handling(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
ap_status_fixture: dict[str, Any],
ap_status_fixture: AirOSData,
mock_airos_client: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
exception: Exception,
@@ -876,3 +877,99 @@ async def test_dhcp_ip_unchanged(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_manual_flow_retries_with_legacy_tls(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_async_get_firmware_data: AsyncMock,
ap_status_fixture: AirOSData,
) -> None:
"""Test manual flow retries with legacy TLS and creates an entry."""
legacy_session = MagicMock()
legacy_session.close = AsyncMock()
mock_async_get_firmware_data.side_effect = [
AirOSTLSCompatibilityError(),
{
"mac": ap_status_fixture.derived.mac,
"hostname": ap_status_fixture.host.hostname,
},
]
with (
patch(
"homeassistant.components.airos.config_flow.TCPConnector",
return_value=MagicMock(),
),
patch(
"homeassistant.components.airos.config_flow.ClientSession",
return_value=legacy_session,
) as mock_client_session,
patch(
"homeassistant.components.airos.config_flow.build_legacy_context",
return_value=MagicMock(),
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "manual"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_LEGACY_SSL] is True
assert mock_async_get_firmware_data.await_count == 2
mock_client_session.assert_called_once()
legacy_session.close.assert_awaited_once()
async def test_validate_raise_on_attempted_legacy(
hass: HomeAssistant,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test legacy mode re-raises TLS compatibility errors."""
legacy_session = MagicMock()
legacy_session.close = AsyncMock()
mock_async_get_firmware_data.side_effect = AirOSTLSCompatibilityError()
with (
patch(
"homeassistant.components.airos.config_flow.TCPConnector",
return_value=MagicMock(),
),
patch(
"homeassistant.components.airos.config_flow.ClientSession",
return_value=legacy_session,
) as mock_client_session,
patch(
"homeassistant.components.airos.config_flow.build_legacy_context",
return_value=MagicMock(),
) as mock_build_legacy_context,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "manual"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {"base": "cannot_connect"}
assert mock_async_get_firmware_data.await_count == 2
mock_client_session.assert_called_once()
mock_build_legacy_context.assert_called_once_with(
verify_ssl=MOCK_CONFIG[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
)
legacy_session.close.assert_awaited_once()
+24
View File
@@ -0,0 +1,24 @@
"""Tests for airOS helpers."""
import ssl
from homeassistant.components.airos.helpers import build_legacy_context
def test_build_legacy_context() -> None:
"""Test building a legacy SSL context."""
context = build_legacy_context(verify_ssl=False)
assert isinstance(context, ssl.SSLContext)
assert context.minimum_version == ssl.TLSVersion.TLSv1
assert context.check_hostname is False
assert context.verify_mode == ssl.CERT_NONE
def test_build_legacy_context_verify_ssl() -> None:
"""Test building a legacy SSL context with verification enabled."""
context = build_legacy_context(verify_ssl=True)
assert isinstance(context, ssl.SSLContext)
assert context.minimum_version == ssl.TLSVersion.TLSv1
assert context.check_hostname is True
+169 -1
View File
@@ -1,6 +1,6 @@
"""Test for airOS integration setup."""
from unittest.mock import ANY, AsyncMock, MagicMock
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from airos.exceptions import (
AirOSConnectionAuthenticationError,
@@ -11,6 +11,7 @@ from airos.exceptions import (
import pytest
from homeassistant.components.airos.const import (
CONF_LEGACY_SSL,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
@@ -22,6 +23,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntryAuthFailed,
ConfigEntryNotReady,
ConfigEntryState,
)
from homeassistant.const import (
@@ -293,3 +295,169 @@ async def test_fetch_airos_data_auth_error(mock_airos_client: MagicMock) -> None
with pytest.raises(ConfigEntryAuthFailed):
await async_fetch_airos_data(mock_airos_client, mock_airos_client.status)
async def test_setup_entry_with_legacy_ssl(
hass: HomeAssistant,
mock_airos_class: MagicMock,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
) -> None:
"""Test setting up a config entry with legacy SSL session ownership."""
legacy_entry = MockConfigEntry(
domain=DOMAIN,
title="NanoStation",
unique_id="01:23:45:67:89:AB",
data={**MOCK_CONFIG_V1_2, CONF_LEGACY_SSL: True},
)
legacy_entry.add_to_hass(hass)
legacy_session = MagicMock()
legacy_session.close = AsyncMock()
with (
patch(
"homeassistant.components.airos.ClientSession",
return_value=legacy_session,
) as mock_client_session,
patch(
"homeassistant.components.airos.TCPConnector",
return_value=MagicMock(),
),
patch(
"homeassistant.components.airos.build_legacy_context",
return_value=MagicMock(),
) as mock_build_legacy_context,
):
await hass.config_entries.async_setup(legacy_entry.entry_id)
await hass.async_block_till_done()
assert legacy_entry.state is ConfigEntryState.LOADED
mock_client_session.assert_called_once()
mock_build_legacy_context.assert_called_once_with(verify_ssl=DEFAULT_VERIFY_SSL)
mock_airos_class.assert_called_once_with(
host=MOCK_CONFIG_V1_2[CONF_HOST],
username=MOCK_CONFIG_V1_2[CONF_USERNAME],
password=MOCK_CONFIG_V1_2[CONF_PASSWORD],
session=legacy_session,
use_ssl=DEFAULT_SSL,
)
assert await hass.config_entries.async_unload(legacy_entry.entry_id)
await hass.async_block_till_done()
legacy_session.close.assert_awaited_once()
@pytest.mark.parametrize(
("exception", "state"),
[
(AirOSDeviceConnectionError, ConfigEntryState.SETUP_RETRY),
],
)
async def test_setup_entry_with_legacy_ssl_fails_firmware_detect(
hass: HomeAssistant,
mock_airos_class: MagicMock,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
exception: Exception,
state: ConfigEntryState,
) -> None:
"""Test handling legacy SSL connection failure."""
legacy_entry = MockConfigEntry(
domain=DOMAIN,
title="NanoStation",
unique_id="01:23:45:67:89:AB",
data={**MOCK_CONFIG_V1_2, CONF_LEGACY_SSL: True},
)
legacy_entry.add_to_hass(hass)
legacy_session = MagicMock()
legacy_session.close = AsyncMock()
mock_async_get_firmware_data.side_effect = exception
with (
patch(
"homeassistant.components.airos.ClientSession",
return_value=legacy_session,
) as mock_client_session,
patch(
"homeassistant.components.airos.TCPConnector",
return_value=MagicMock(),
),
patch(
"homeassistant.components.airos.build_legacy_context",
return_value=MagicMock(),
) as mock_build_legacy_context,
):
result = await hass.config_entries.async_setup(legacy_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert legacy_entry.state is state
mock_client_session.assert_called_once()
mock_build_legacy_context.assert_called_once_with(verify_ssl=DEFAULT_VERIFY_SSL)
legacy_session.close.assert_awaited_once()
@pytest.mark.parametrize(
("exception", "state"),
[
(ConfigEntryNotReady, ConfigEntryState.SETUP_RETRY),
(Exception, ConfigEntryState.SETUP_ERROR),
],
)
async def test_setup_entry_with_legacy_ssl_fails_coordinator(
hass: HomeAssistant,
mock_airos_class: MagicMock,
mock_airos_client: MagicMock,
mock_async_get_firmware_data: AsyncMock,
exception: Exception,
state: ConfigEntryState,
) -> None:
"""Test legacy session is closed when status coordinator first refresh fails."""
legacy_entry = MockConfigEntry(
domain=DOMAIN,
title="NanoStation",
unique_id="01:23:45:67:89:AB",
data={**MOCK_CONFIG_V1_2, CONF_LEGACY_SSL: True},
)
legacy_entry.add_to_hass(hass)
legacy_session = MagicMock()
legacy_session.close = AsyncMock()
mock_status_coordinator = MagicMock()
mock_status_coordinator.async_config_entry_first_refresh = AsyncMock(
side_effect=exception
)
with (
patch(
"homeassistant.components.airos.ClientSession",
return_value=legacy_session,
) as mock_client_session,
patch(
"homeassistant.components.airos.TCPConnector",
return_value=MagicMock(),
),
patch(
"homeassistant.components.airos.build_legacy_context",
return_value=MagicMock(),
) as mock_build_legacy_context,
patch(
"homeassistant.components.airos.AirOSDataUpdateCoordinator",
return_value=mock_status_coordinator,
),
):
result = await hass.config_entries.async_setup(legacy_entry.entry_id)
await hass.async_block_till_done()
assert result is False
assert legacy_entry.state is state
mock_client_session.assert_called_once()
mock_build_legacy_context.assert_called_once_with(verify_ssl=DEFAULT_VERIFY_SSL)
legacy_session.close.assert_awaited_once()