DNS IP custom ports for IPv4 (#113993)

* squash DNS IP enable port

* linting

* fix config entries in tests.

* fix more config entries

* fix parameter order

* Add defaults for legacy config entries

* test legacy config are not broken

* test driven migration

* define versions for future proofing

* remove defaults as should be covered by migrations in the future

* adds config migration

* spacing

* Review: remove unnecessary statements

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Apply suggestions from code review

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* make default ports the same

* test migration from future error

* linting

* Small tweaks

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
pull/119912/head^2
HarvsG 2024-06-18 21:24:36 +01:00 committed by GitHub
parent 9723b97f4b
commit adcd0cc2a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 246 additions and 22 deletions

View File

@ -3,9 +3,10 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_PORT
from homeassistant.core import _LOGGER, HomeAssistant
from .const import PLATFORMS
from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -25,3 +26,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload dnsip config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry to a newer version."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version < 2 and config_entry.minor_version < 2:
version = config_entry.version
minor_version = config_entry.minor_version
_LOGGER.debug(
"Migrating configuration from version %s.%s",
version,
minor_version,
)
new_options = {**config_entry.options}
new_options[CONF_PORT] = DEFAULT_PORT
new_options[CONF_PORT_IPV6] = DEFAULT_PORT
hass.config_entries.async_update_entry(
config_entry, options=new_options, minor_version=2
)
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
1,
2,
)
return True

View File

@ -16,7 +16,7 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@ -25,10 +25,12 @@ from .const import (
CONF_IPV4,
CONF_IPV6,
CONF_IPV6_V4,
CONF_PORT_IPV6,
CONF_RESOLVER,
CONF_RESOLVER_IPV6,
DEFAULT_HOSTNAME,
DEFAULT_NAME,
DEFAULT_PORT,
DEFAULT_RESOLVER,
DEFAULT_RESOLVER_IPV6,
DOMAIN,
@ -42,32 +44,42 @@ DATA_SCHEMA = vol.Schema(
DATA_SCHEMA_ADV = vol.Schema(
{
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
vol.Optional(CONF_RESOLVER, default=DEFAULT_RESOLVER): cv.string,
vol.Optional(CONF_RESOLVER_IPV6, default=DEFAULT_RESOLVER_IPV6): cv.string,
vol.Optional(CONF_RESOLVER): cv.string,
vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
vol.Optional(CONF_PORT_IPV6): cv.port,
}
)
async def async_validate_hostname(
hostname: str, resolver_ipv4: str, resolver_ipv6: str
hostname: str,
resolver_ipv4: str,
resolver_ipv6: str,
port: int,
port_ipv6: int,
) -> dict[str, bool]:
"""Validate hostname."""
async def async_check(hostname: str, resolver: str, qtype: str) -> bool:
async def async_check(
hostname: str, resolver: str, qtype: str, port: int = 53
) -> bool:
"""Return if able to resolve hostname."""
result = False
with contextlib.suppress(DNSError):
result = bool(
await aiodns.DNSResolver(nameservers=[resolver]).query(hostname, qtype)
await aiodns.DNSResolver(
nameservers=[resolver], udp_port=port, tcp_port=port
).query(hostname, qtype)
)
return result
result: dict[str, bool] = {}
tasks = await asyncio.gather(
async_check(hostname, resolver_ipv4, "A"),
async_check(hostname, resolver_ipv6, "AAAA"),
async_check(hostname, resolver_ipv4, "AAAA"),
async_check(hostname, resolver_ipv4, "A", port=port),
async_check(hostname, resolver_ipv6, "AAAA", port=port_ipv6),
async_check(hostname, resolver_ipv4, "AAAA", port=port),
)
result[CONF_IPV4] = tasks[0]
@ -81,6 +93,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for dnsip integration."""
VERSION = 1
MINOR_VERSION = 2
@staticmethod
@callback
@ -102,8 +115,12 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname
resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER)
resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6)
port = user_input.get(CONF_PORT, DEFAULT_PORT)
port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT)
validate = await async_validate_hostname(hostname, resolver, resolver_ipv6)
validate = await async_validate_hostname(
hostname, resolver, resolver_ipv6, port, port_ipv6
)
set_resolver = resolver
if validate[CONF_IPV6]:
@ -129,7 +146,9 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
},
options={
CONF_RESOLVER: resolver,
CONF_PORT: port,
CONF_RESOLVER_IPV6: set_resolver,
CONF_PORT_IPV6: port_ipv6,
},
)
@ -156,11 +175,15 @@ class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry):
errors = {}
if user_input is not None:
resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER)
port = user_input.get(CONF_PORT, DEFAULT_PORT)
resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6)
port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT)
validate = await async_validate_hostname(
self.config_entry.data[CONF_HOSTNAME],
resolver,
resolver_ipv6,
port,
port_ipv6,
)
if (
@ -178,7 +201,9 @@ class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry):
title=self.config_entry.title,
data={
CONF_RESOLVER: resolver,
CONF_PORT: port,
CONF_RESOLVER_IPV6: resolver_ipv6,
CONF_PORT_IPV6: port_ipv6,
},
)
@ -186,7 +211,9 @@ class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry):
vol.Schema(
{
vol.Optional(CONF_RESOLVER): cv.string,
vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
vol.Optional(CONF_PORT_IPV6): cv.port,
}
),
self.config_entry.options,

View File

@ -8,6 +8,7 @@ PLATFORMS = [Platform.SENSOR]
CONF_HOSTNAME = "hostname"
CONF_RESOLVER = "resolver"
CONF_RESOLVER_IPV6 = "resolver_ipv6"
CONF_PORT_IPV6 = "port_ipv6"
CONF_IPV4 = "ipv4"
CONF_IPV6 = "ipv6"
CONF_IPV6_V4 = "ipv6_v4"
@ -16,4 +17,5 @@ DEFAULT_HOSTNAME = "myip.opendns.com"
DEFAULT_IPV6 = False
DEFAULT_NAME = "myip"
DEFAULT_RESOLVER = "208.67.222.222"
DEFAULT_PORT = 53
DEFAULT_RESOLVER_IPV6 = "2620:119:53::53"

View File

@ -11,7 +11,7 @@ from aiodns.error import DNSError
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -20,6 +20,7 @@ from .const import (
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
CONF_PORT_IPV6,
CONF_RESOLVER,
CONF_RESOLVER_IPV6,
DOMAIN,
@ -53,12 +54,14 @@ async def async_setup_entry(
resolver_ipv4 = entry.options[CONF_RESOLVER]
resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
port_ipv4 = entry.options[CONF_PORT]
port_ipv6 = entry.options[CONF_PORT_IPV6]
entities = []
if entry.data[CONF_IPV4]:
entities.append(WanIpSensor(name, hostname, resolver_ipv4, False))
entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4))
if entry.data[CONF_IPV6]:
entities.append(WanIpSensor(name, hostname, resolver_ipv6, True))
entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6))
async_add_entities(entities, update_before_add=True)
@ -75,12 +78,13 @@ class WanIpSensor(SensorEntity):
hostname: str,
resolver: str,
ipv6: bool,
port: int,
) -> None:
"""Initialize the DNS IP sensor."""
self._attr_name = "IPv6" if ipv6 else None
self._attr_unique_id = f"{hostname}_{ipv6}"
self.hostname = hostname
self.resolver = aiodns.DNSResolver()
self.resolver = aiodns.DNSResolver(tcp_port=port, udp_port=port)
self.resolver.nameservers = [resolver]
self.querytype = "AAAA" if ipv6 else "A"
self._retries = DEFAULT_RETRIES

View File

@ -5,7 +5,9 @@
"data": {
"hostname": "The hostname for which to perform the DNS query",
"resolver": "Resolver for IPV4 lookup",
"resolver_ipv6": "Resolver for IPV6 lookup"
"port": "Port for IPV4 lookup",
"resolver_ipv6": "Resolver for IPV6 lookup",
"port_ipv6": "Port for IPV6 lookup"
}
}
},
@ -18,7 +20,9 @@
"init": {
"data": {
"resolver": "[%key:component::dnsip::config::step::user::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]"
"port": "[%key:component::dnsip::config::step::user::data::port%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]"
}
}
},
@ -26,7 +30,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"invalid_resolver": "Invalid IP address for resolver"
"invalid_resolver": "Invalid IP address or port for resolver"
}
}
}

View File

@ -13,12 +13,13 @@ from homeassistant.components.dnsip.const import (
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
CONF_PORT_IPV6,
CONF_RESOLVER,
CONF_RESOLVER_IPV6,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -66,6 +67,8 @@ async def test_form(hass: HomeAssistant) -> None:
assert result2["options"] == {
"resolver": "208.67.222.222",
"resolver_ipv6": "2620:119:53::53",
"port": 53,
"port_ipv6": 53,
}
assert len(mock_setup_entry.mock_calls) == 1
@ -96,6 +99,8 @@ async def test_form_adv(hass: HomeAssistant) -> None:
CONF_HOSTNAME: "home-assistant.io",
CONF_RESOLVER: "8.8.8.8",
CONF_RESOLVER_IPV6: "2620:119:53::53",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
)
await hass.async_block_till_done()
@ -111,6 +116,8 @@ async def test_form_adv(hass: HomeAssistant) -> None:
assert result2["options"] == {
"resolver": "8.8.8.8",
"resolver_ipv6": "2620:119:53::53",
"port": 53,
"port_ipv6": 53,
}
assert len(mock_setup_entry.mock_calls) == 1
@ -152,6 +159,8 @@ async def test_flow_already_exist(hass: HomeAssistant) -> None:
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::5",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
unique_id="home-assistant.io",
).add_to_hass(hass)
@ -197,6 +206,8 @@ async def test_options_flow(hass: HomeAssistant) -> None:
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::5",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
)
entry.add_to_hass(hass)
@ -218,6 +229,8 @@ async def test_options_flow(hass: HomeAssistant) -> None:
user_input={
CONF_RESOLVER: "8.8.8.8",
CONF_RESOLVER_IPV6: "2001:4860:4860::8888",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
)
await hass.async_block_till_done()
@ -226,6 +239,8 @@ async def test_options_flow(hass: HomeAssistant) -> None:
assert result["data"] == {
"resolver": "8.8.8.8",
"resolver_ipv6": "2001:4860:4860::8888",
"port": 53,
"port_ipv6": 53,
}
assert entry.state is ConfigEntryState.LOADED
@ -245,6 +260,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None:
options={
CONF_RESOLVER: "8.8.8.8",
CONF_RESOLVER_IPV6: "2620:119:53::1",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
)
entry.add_to_hass(hass)
@ -271,6 +288,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None:
assert result["data"] == {
"resolver": "208.67.222.222",
"resolver_ipv6": "2620:119:53::53",
"port": 53,
"port_ipv6": 53,
}
entry = hass.config_entries.async_get_entry(entry.entry_id)
@ -283,6 +302,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None:
assert entry.options == {
"resolver": "208.67.222.222",
"resolver_ipv6": "2620:119:53::53",
"port": 53,
"port_ipv6": 53,
}
@ -294,6 +315,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None:
CONF_NAME: "home-assistant.io",
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::5",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
CONF_IPV4: True,
CONF_IPV6: False,
},
@ -302,6 +325,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None:
CONF_NAME: "home-assistant.io",
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::5",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
CONF_IPV4: False,
CONF_IPV6: True,
},
@ -334,6 +359,8 @@ async def test_options_error(hass: HomeAssistant, p_input: dict[str, str]) -> No
{
CONF_RESOLVER: "192.168.200.34",
CONF_RESOLVER_IPV6: "2001:4860:4860::8888",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
)
await hass.async_block_till_done()

View File

@ -8,12 +8,14 @@ from homeassistant.components.dnsip.const import (
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
CONF_PORT_IPV6,
CONF_RESOLVER,
CONF_RESOLVER_IPV6,
DEFAULT_PORT,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from . import RetrieveDNS
@ -35,6 +37,8 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None:
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::53",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
entry_id="1",
unique_id="home-assistant.io",
@ -52,3 +56,77 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_port_migration(
hass: HomeAssistant,
) -> None:
"""Test migration of the config entry from no ports to with ports."""
entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
data={
CONF_HOSTNAME: "home-assistant.io",
CONF_NAME: "home-assistant.io",
CONF_IPV4: True,
CONF_IPV6: True,
},
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::53",
},
entry_id="1",
unique_id="home-assistant.io",
version=1,
minor_version=1,
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
return_value=RetrieveDNS(),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 1
assert entry.minor_version == 2
assert entry.options[CONF_PORT] == DEFAULT_PORT
assert entry.options[CONF_PORT_IPV6] == DEFAULT_PORT
assert entry.state is ConfigEntryState.LOADED
async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
"""Test a future version isn't migrated."""
entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
data={
CONF_HOSTNAME: "home-assistant.io",
CONF_NAME: "home-assistant.io",
CONF_IPV4: True,
CONF_IPV6: True,
"some_new_data": "new_value",
},
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::53",
},
entry_id="1",
unique_id="home-assistant.io",
version=2,
minor_version=1,
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
return_value=RetrieveDNS(),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entry = hass.config_entries.async_get_entry(entry.entry_id)
assert entry.state is ConfigEntryState.MIGRATION_ERROR

View File

@ -12,13 +12,14 @@ from homeassistant.components.dnsip.const import (
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
CONF_PORT_IPV6,
CONF_RESOLVER,
CONF_RESOLVER_IPV6,
DOMAIN,
)
from homeassistant.components.dnsip.sensor import SCAN_INTERVAL
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE
from homeassistant.const import CONF_NAME, CONF_PORT, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from . import RetrieveDNS
@ -40,6 +41,8 @@ async def test_sensor(hass: HomeAssistant) -> None:
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::53",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
entry_id="1",
unique_id="home-assistant.io",
@ -67,6 +70,49 @@ async def test_sensor(hass: HomeAssistant) -> None:
]
async def test_legacy_sensor(hass: HomeAssistant) -> None:
"""Test the DNS IP sensor configured before the addition of ports."""
entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
data={
CONF_HOSTNAME: "home-assistant.io",
CONF_NAME: "home-assistant.io",
CONF_IPV4: True,
CONF_IPV6: True,
},
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::53",
},
entry_id="1",
unique_id="home-assistant.io",
version=1,
minor_version=1,
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dnsip.sensor.aiodns.DNSResolver",
return_value=RetrieveDNS(),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
state1 = hass.states.get("sensor.home_assistant_io")
state2 = hass.states.get("sensor.home_assistant_io_ipv6")
assert state1.state == "1.1.1.1"
assert state1.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"]
assert state2.state == "2001:db8::77:dead:beef"
assert state2.attributes["ip_addresses"] == [
"2001:db8::77:dead:beef",
"2001:db8:66::dead:beef",
"2001:db8:77::dead:beef",
"2001:db8:77::face:b00c",
]
async def test_sensor_no_response(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
@ -83,6 +129,8 @@ async def test_sensor_no_response(
options={
CONF_RESOLVER: "208.67.222.222",
CONF_RESOLVER_IPV6: "2620:119:53::53",
CONF_PORT: 53,
CONF_PORT_IPV6: 53,
},
entry_id="1",
unique_id="home-assistant.io",