diff --git a/supervisor/api/const.py b/supervisor/api/const.py index e2ae99aa7..cfe4a5827 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -10,6 +10,7 @@ ATTR_DATA_DISK = "data_disk" ATTR_DEVICE = "device" ATTR_DT_SYNCHRONIZED = "dt_synchronized" ATTR_DT_UTC = "dt_utc" +ATTR_FALLBACK = "fallback" ATTR_LLMNR = "llmnr" ATTR_LLMNR_HOSTNAME = "llmnr_hostname" ATTR_MDNS = "mdns" diff --git a/supervisor/api/dns.py b/supervisor/api/dns.py index 11af3640c..a13092809 100644 --- a/supervisor/api/dns.py +++ b/supervisor/api/dns.py @@ -26,13 +26,18 @@ from ..const import ( from ..coresys import CoreSysAttributes from ..exceptions import APIError from ..validate import dns_server_list, version_tag -from .const import ATTR_LLMNR, ATTR_MDNS +from .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS from .utils import api_process, api_process_raw, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) # pylint: disable=no-value-for-parameter -SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_SERVERS): dns_server_list}) +SCHEMA_OPTIONS = vol.Schema( + { + vol.Optional(ATTR_SERVERS): dns_server_list, + vol.Optional(ATTR_FALLBACK): vol.Boolean(), + } +) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) @@ -52,15 +57,24 @@ class APICoreDNS(CoreSysAttributes): ATTR_LOCALS: self.sys_plugins.dns.locals, ATTR_MDNS: self.sys_plugins.dns.mdns, ATTR_LLMNR: self.sys_plugins.dns.llmnr, + ATTR_FALLBACK: self.sys_plugins.dns.fallback, } @api_process async def options(self, request: web.Request) -> None: """Set DNS options.""" body = await api_validate(SCHEMA_OPTIONS, request) + restart_required = False if ATTR_SERVERS in body: self.sys_plugins.dns.servers = body[ATTR_SERVERS] + restart_required = True + + if ATTR_FALLBACK in body: + self.sys_plugins.dns.fallback = body[ATTR_FALLBACK] + restart_required = True + + if restart_required: self.sys_create_task(self.sys_plugins.dns.restart()) self.sys_plugins.dns.save_data() diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py index d3064370b..f82191099 100644 --- a/supervisor/misc/filter.py +++ b/supervisor/misc/filter.py @@ -79,6 +79,9 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: ], "unhealthy": coresys.resolution.unhealthy, }, + "misc": { + "fallback_dns": coresys.plugins.dns.fallback, + }, } ) diff --git a/supervisor/plugins/const.py b/supervisor/plugins/const.py index da8244ba2..d84c585cf 100644 --- a/supervisor/plugins/const.py +++ b/supervisor/plugins/const.py @@ -8,3 +8,5 @@ FILE_HASSIO_CLI = Path(SUPERVISOR_DATA, "cli.json") FILE_HASSIO_DNS = Path(SUPERVISOR_DATA, "dns.json") FILE_HASSIO_OBSERVER = Path(SUPERVISOR_DATA, "observer.json") FILE_HASSIO_MULTICAST = Path(SUPERVISOR_DATA, "multicast.json") + +ATTR_FALLBACK = "fallback" diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index 123f57fb9..3686f1cbf 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -30,7 +30,7 @@ from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils.json import write_json_file from ..validate import dns_url from .base import PluginBase -from .const import FILE_HASSIO_DNS +from .const import ATTR_FALLBACK, FILE_HASSIO_DNS from .validate import SCHEMA_DNS_CONFIG _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -116,6 +116,16 @@ class PluginDns(PluginBase): MulticastProtocolEnabled.RESOLVE, ] + @property + def fallback(self) -> bool: + """Fallback DNS enabled.""" + return self._data[ATTR_FALLBACK] + + @fallback.setter + def fallback(self, value: bool) -> None: + """Set fallback DNS enabled.""" + self._data[ATTR_FALLBACK] = value + async def load(self) -> None: """Load DNS setup.""" # Initialize CoreDNS Template @@ -241,6 +251,7 @@ class PluginDns(PluginBase): """Reset DNS and hosts.""" # Reset manually defined DNS self.servers.clear() + self.fallback = True self.save_data() # Resets hosts @@ -285,9 +296,10 @@ class PluginDns(PluginBase): # Print some usefully debug data _LOGGER.debug( - "config-dns = %s, local-dns = %s , backup-dns = CloudFlare DoT / debug: %s", + "config-dns = %s, local-dns = %s , backup-dns = %s / debug: %s", dns_servers, dns_locals, + "CloudFlare DoT" if self.fallback else "DISABLED", debug, ) @@ -298,6 +310,7 @@ class PluginDns(PluginBase): { "servers": dns_servers, "locals": dns_locals, + "fallback": self.fallback, "debug": debug, }, ) diff --git a/supervisor/plugins/validate.py b/supervisor/plugins/validate.py index be194b34b..c317e9dd2 100644 --- a/supervisor/plugins/validate.py +++ b/supervisor/plugins/validate.py @@ -2,14 +2,18 @@ import voluptuous as vol +from supervisor.plugins.const import ATTR_FALLBACK + from ..const import ATTR_ACCESS_TOKEN, ATTR_IMAGE, ATTR_SERVERS, ATTR_VERSION from ..validate import dns_server_list, docker_image, token, version_tag +# pylint: disable=no-value-for-parameter SCHEMA_DNS_CONFIG = vol.Schema( { vol.Optional(ATTR_VERSION): version_tag, vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_SERVERS, default=list): dns_server_list, + vol.Optional(ATTR_FALLBACK, default=True): vol.Boolean(), }, extra=vol.REMOVE_EXTRA, ) diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index c187727b3..f5f95d8fa 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -34,6 +34,7 @@ class UnsupportedReason(str, Enum): APPARMOR = "apparmor" CONTENT_TRUST = "content_trust" DBUS = "dbus" + DNS_SERVER = "dns_server" DOCKER_CONFIGURATION = "docker_configuration" DOCKER_VERSION = "docker_version" JOB_CONDITIONS = "job_conditions" diff --git a/supervisor/resolution/evaluations/dns_server.py b/supervisor/resolution/evaluations/dns_server.py new file mode 100644 index 000000000..048d3064a --- /dev/null +++ b/supervisor/resolution/evaluations/dns_server.py @@ -0,0 +1,40 @@ +"""Evaluation class for DNS server.""" + +from ...const import CoreState +from ...coresys import CoreSys +from ..const import ContextType, UnsupportedReason +from .base import EvaluateBase + + +def setup(coresys: CoreSys) -> EvaluateBase: + """Initialize evaluation-setup function.""" + return EvaluateDNSServer(coresys) + + +class EvaluateDNSServer(EvaluateBase): + """Evaluate job conditions.""" + + @property + def reason(self) -> UnsupportedReason: + """Return a UnsupportedReason enum.""" + return UnsupportedReason.DNS_SERVER + + @property + def on_failure(self) -> str: + """Return a string that is printed when self.evaluate is False.""" + return "Found unsupported DNS server and fallback is disabled." + + @property + def states(self) -> list[CoreState]: + """Return a list of valid states when this evaluation can run.""" + return [CoreState.RUNNING] + + async def evaluate(self) -> None: + """Run evaluation.""" + return not self.sys_plugins.dns.fallback and 0 < len( + [ + issue + for issue in self.sys_resolution.issues + if issue.context == ContextType.DNS_SERVER + ] + ) diff --git a/tests/api/test_dns.py b/tests/api/test_dns.py index 15dc312d2..bcae9724b 100644 --- a/tests/api/test_dns.py +++ b/tests/api/test_dns.py @@ -36,3 +36,25 @@ async def test_llmnr_mdns_info(api_client, coresys: CoreSys): result = await resp.json() assert result["data"]["llmnr"] is True assert result["data"]["mdns"] is True + + +async def test_options(api_client, coresys: CoreSys): + """Test options api.""" + assert coresys.plugins.dns.servers == [] + assert coresys.plugins.dns.fallback is True + + with patch.object(type(coresys.plugins.dns), "restart") as restart: + await api_client.post( + "/dns/options", json={"servers": ["dns://8.8.8.8"], "fallback": False} + ) + + assert coresys.plugins.dns.servers == ["dns://8.8.8.8"] + assert coresys.plugins.dns.fallback is False + restart.assert_called_once() + + restart.reset_mock() + await api_client.post("/dns/options", json={"fallback": True}) + + assert coresys.plugins.dns.servers == ["dns://8.8.8.8"] + assert coresys.plugins.dns.fallback is True + restart.assert_called_once() diff --git a/tests/plugins/test_dns.py b/tests/plugins/test_dns.py index 06024c797..abf673df3 100644 --- a/tests/plugins/test_dns.py +++ b/tests/plugins/test_dns.py @@ -1,17 +1,19 @@ """Test DNS plugin.""" +from ipaddress import IPv4Address from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest +from supervisor.const import LogLevel from supervisor.coresys import CoreSys from supervisor.docker.interface import DockerInterface +from supervisor.plugins.dns import HostEntry @pytest.fixture(name="docker_interface") async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]: """Mock docker interface methods.""" - # with patch("supervisor.docker.interface.DockerInterface.run"), patch("supervisor.docker.interface.DockerInterface.restart") with patch.object(DockerInterface, "run") as run, patch.object( DockerInterface, "restart" ) as restart: @@ -25,31 +27,98 @@ async def fixture_write_json() -> Mock: yield write_json_file -@pytest.mark.parametrize("start", [True, False]) async def test_config_write( coresys: CoreSys, docker_interface: tuple[AsyncMock, AsyncMock], write_json: Mock, - start: bool, ): """Test config write on DNS start and restart.""" assert coresys.plugins.dns.locals == ["dns://192.168.30.1"] coresys.plugins.dns.servers = ["dns://1.1.1.1", "dns://8.8.8.8"] - if start: - await coresys.plugins.dns.start() - docker_interface[0].assert_called_once() - docker_interface[1].assert_not_called() - else: - await coresys.plugins.dns.restart() - docker_interface[0].assert_not_called() - docker_interface[1].assert_called_once() + await coresys.plugins.dns.start() + docker_interface[0].assert_called_once() + docker_interface[1].assert_not_called() write_json.assert_called_once_with( Path("/data/dns/coredns.json"), { "servers": ["dns://1.1.1.1", "dns://8.8.8.8"], "locals": ["dns://192.168.30.1"], + "fallback": True, "debug": False, }, ) + + docker_interface[0].reset_mock() + write_json.reset_mock() + coresys.plugins.dns.servers = ["dns://8.8.8.8"] + coresys.plugins.dns.fallback = False + coresys.config.logging = LogLevel.DEBUG + + await coresys.plugins.dns.restart() + docker_interface[0].assert_not_called() + docker_interface[1].assert_called_once() + + write_json.assert_called_once_with( + Path("/data/dns/coredns.json"), + { + "servers": ["dns://8.8.8.8"], + "locals": ["dns://192.168.30.1"], + "fallback": False, + "debug": True, + }, + ) + + +async def test_reset(coresys: CoreSys): + """Test reset returns dns plugin to defaults.""" + coresys.plugins.dns.servers = ["dns://1.1.1.1", "dns://8.8.8.8"] + coresys.plugins.dns.fallback = False + coresys.plugins.dns._loop = True # pylint: disable=protected-access + assert len(coresys.addons.installed) == 0 + + with patch.object( + type(coresys.plugins.dns.hosts), "unlink" + ) as unlink, patch.object(type(coresys.plugins.dns), "write_hosts") as write_hosts: + await coresys.plugins.dns.reset() + + assert coresys.plugins.dns.servers == [] + assert coresys.plugins.dns.fallback is True + assert coresys.plugins.dns._loop is False # pylint: disable=protected-access + unlink.assert_called_once() + write_hosts.assert_called_once() + + # pylint: disable=protected-access + assert coresys.plugins.dns._hosts == [ + HostEntry( + ip_address=IPv4Address("127.0.0.1"), + names=["localhost", "localhost.local.hass.io"], + ), + HostEntry( + ip_address=IPv4Address("172.30.32.2"), + names=[ + "hassio", + "hassio.local.hass.io", + "supervisor", + "supervisor.local.hass.io", + ], + ), + HostEntry( + ip_address=IPv4Address("172.30.32.1"), + names=[ + "homeassistant", + "homeassistant.local.hass.io", + "home-assistant", + "home-assistant.local.hass.io", + ], + ), + HostEntry( + ip_address=IPv4Address("172.30.32.3"), + names=["dns", "dns.local.hass.io"], + ), + HostEntry( + ip_address=IPv4Address("172.30.32.6"), + names=["observer", "observer.local.hass.io"], + ), + ] diff --git a/tests/resolution/evaluation/test_evaluate_dns_server.py b/tests/resolution/evaluation/test_evaluate_dns_server.py new file mode 100644 index 000000000..da9017a41 --- /dev/null +++ b/tests/resolution/evaluation/test_evaluate_dns_server.py @@ -0,0 +1,59 @@ +"""Test DNS server evaluation.""" +from unittest.mock import patch + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, IssueType +from supervisor.resolution.evaluations.dns_server import EvaluateDNSServer + + +async def test_evaluation(coresys: CoreSys): + """Test evaluation.""" + dns_server = EvaluateDNSServer(coresys) + coresys.core.state = CoreState.RUNNING + + assert dns_server.reason not in coresys.resolution.unsupported + assert coresys.plugins.dns.fallback is True + assert len(coresys.resolution.issues) == 0 + + await dns_server() + assert dns_server.reason not in coresys.resolution.unsupported + + coresys.plugins.dns.fallback = False + await dns_server() + assert dns_server.reason not in coresys.resolution.unsupported + + coresys.plugins.dns.fallback = True + coresys.resolution.create_issue( + IssueType.DNS_SERVER_FAILED, + ContextType.DNS_SERVER, + reference="dns://192.168.30.1", + ) + await dns_server() + assert dns_server.reason not in coresys.resolution.unsupported + + coresys.plugins.dns.fallback = False + await dns_server() + assert dns_server.reason in coresys.resolution.unsupported + + +async def test_did_run(coresys: CoreSys): + """Test that the evaluation ran as expected.""" + dns_server = EvaluateDNSServer(coresys) + should_run = [CoreState.RUNNING] + should_not_run = [state for state in CoreState if state not in should_run] + assert len(should_run) != 0 + assert len(should_not_run) != 0 + + with patch.object(EvaluateDNSServer, "evaluate", return_value=None) as evaluate: + for state in should_run: + coresys.core.state = state + await dns_server() + evaluate.assert_called_once() + evaluate.reset_mock() + + for state in should_not_run: + coresys.core.state = state + await dns_server() + evaluate.assert_not_called() + evaluate.reset_mock()