Add API option to disable fallback DNS (#3586)

* Add API option to disable fallback DNS

* DNS unsupported evaluation and fallback in sentry
This commit is contained in:
Mike Degatano 2022-04-25 12:15:40 -04:00 committed by GitHub
parent 0440437369
commit 8bb4596d04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 243 additions and 15 deletions

View File

@ -10,6 +10,7 @@ ATTR_DATA_DISK = "data_disk"
ATTR_DEVICE = "device" ATTR_DEVICE = "device"
ATTR_DT_SYNCHRONIZED = "dt_synchronized" ATTR_DT_SYNCHRONIZED = "dt_synchronized"
ATTR_DT_UTC = "dt_utc" ATTR_DT_UTC = "dt_utc"
ATTR_FALLBACK = "fallback"
ATTR_LLMNR = "llmnr" ATTR_LLMNR = "llmnr"
ATTR_LLMNR_HOSTNAME = "llmnr_hostname" ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
ATTR_MDNS = "mdns" ATTR_MDNS = "mdns"

View File

@ -26,13 +26,18 @@ from ..const import (
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import dns_server_list, version_tag 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 from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
# pylint: disable=no-value-for-parameter # 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}) 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_LOCALS: self.sys_plugins.dns.locals,
ATTR_MDNS: self.sys_plugins.dns.mdns, ATTR_MDNS: self.sys_plugins.dns.mdns,
ATTR_LLMNR: self.sys_plugins.dns.llmnr, ATTR_LLMNR: self.sys_plugins.dns.llmnr,
ATTR_FALLBACK: self.sys_plugins.dns.fallback,
} }
@api_process @api_process
async def options(self, request: web.Request) -> None: async def options(self, request: web.Request) -> None:
"""Set DNS options.""" """Set DNS options."""
body = await api_validate(SCHEMA_OPTIONS, request) body = await api_validate(SCHEMA_OPTIONS, request)
restart_required = False
if ATTR_SERVERS in body: if ATTR_SERVERS in body:
self.sys_plugins.dns.servers = body[ATTR_SERVERS] 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_create_task(self.sys_plugins.dns.restart())
self.sys_plugins.dns.save_data() self.sys_plugins.dns.save_data()

View File

@ -79,6 +79,9 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict:
], ],
"unhealthy": coresys.resolution.unhealthy, "unhealthy": coresys.resolution.unhealthy,
}, },
"misc": {
"fallback_dns": coresys.plugins.dns.fallback,
},
} }
) )

View File

@ -8,3 +8,5 @@ FILE_HASSIO_CLI = Path(SUPERVISOR_DATA, "cli.json")
FILE_HASSIO_DNS = Path(SUPERVISOR_DATA, "dns.json") FILE_HASSIO_DNS = Path(SUPERVISOR_DATA, "dns.json")
FILE_HASSIO_OBSERVER = Path(SUPERVISOR_DATA, "observer.json") FILE_HASSIO_OBSERVER = Path(SUPERVISOR_DATA, "observer.json")
FILE_HASSIO_MULTICAST = Path(SUPERVISOR_DATA, "multicast.json") FILE_HASSIO_MULTICAST = Path(SUPERVISOR_DATA, "multicast.json")
ATTR_FALLBACK = "fallback"

View File

@ -30,7 +30,7 @@ from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils.json import write_json_file from ..utils.json import write_json_file
from ..validate import dns_url from ..validate import dns_url
from .base import PluginBase from .base import PluginBase
from .const import FILE_HASSIO_DNS from .const import ATTR_FALLBACK, FILE_HASSIO_DNS
from .validate import SCHEMA_DNS_CONFIG from .validate import SCHEMA_DNS_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -116,6 +116,16 @@ class PluginDns(PluginBase):
MulticastProtocolEnabled.RESOLVE, 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: async def load(self) -> None:
"""Load DNS setup.""" """Load DNS setup."""
# Initialize CoreDNS Template # Initialize CoreDNS Template
@ -241,6 +251,7 @@ class PluginDns(PluginBase):
"""Reset DNS and hosts.""" """Reset DNS and hosts."""
# Reset manually defined DNS # Reset manually defined DNS
self.servers.clear() self.servers.clear()
self.fallback = True
self.save_data() self.save_data()
# Resets hosts # Resets hosts
@ -285,9 +296,10 @@ class PluginDns(PluginBase):
# Print some usefully debug data # Print some usefully debug data
_LOGGER.debug( _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_servers,
dns_locals, dns_locals,
"CloudFlare DoT" if self.fallback else "DISABLED",
debug, debug,
) )
@ -298,6 +310,7 @@ class PluginDns(PluginBase):
{ {
"servers": dns_servers, "servers": dns_servers,
"locals": dns_locals, "locals": dns_locals,
"fallback": self.fallback,
"debug": debug, "debug": debug,
}, },
) )

View File

@ -2,14 +2,18 @@
import voluptuous as vol import voluptuous as vol
from supervisor.plugins.const import ATTR_FALLBACK
from ..const import ATTR_ACCESS_TOKEN, ATTR_IMAGE, ATTR_SERVERS, ATTR_VERSION from ..const import ATTR_ACCESS_TOKEN, ATTR_IMAGE, ATTR_SERVERS, ATTR_VERSION
from ..validate import dns_server_list, docker_image, token, version_tag from ..validate import dns_server_list, docker_image, token, version_tag
# pylint: disable=no-value-for-parameter
SCHEMA_DNS_CONFIG = vol.Schema( SCHEMA_DNS_CONFIG = vol.Schema(
{ {
vol.Optional(ATTR_VERSION): version_tag, vol.Optional(ATTR_VERSION): version_tag,
vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional(ATTR_SERVERS, default=list): dns_server_list, vol.Optional(ATTR_SERVERS, default=list): dns_server_list,
vol.Optional(ATTR_FALLBACK, default=True): vol.Boolean(),
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )

View File

@ -34,6 +34,7 @@ class UnsupportedReason(str, Enum):
APPARMOR = "apparmor" APPARMOR = "apparmor"
CONTENT_TRUST = "content_trust" CONTENT_TRUST = "content_trust"
DBUS = "dbus" DBUS = "dbus"
DNS_SERVER = "dns_server"
DOCKER_CONFIGURATION = "docker_configuration" DOCKER_CONFIGURATION = "docker_configuration"
DOCKER_VERSION = "docker_version" DOCKER_VERSION = "docker_version"
JOB_CONDITIONS = "job_conditions" JOB_CONDITIONS = "job_conditions"

View File

@ -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
]
)

View File

@ -36,3 +36,25 @@ async def test_llmnr_mdns_info(api_client, coresys: CoreSys):
result = await resp.json() result = await resp.json()
assert result["data"]["llmnr"] is True assert result["data"]["llmnr"] is True
assert result["data"]["mdns"] 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()

View File

@ -1,17 +1,19 @@
"""Test DNS plugin.""" """Test DNS plugin."""
from ipaddress import IPv4Address
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from supervisor.const import LogLevel
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.docker.interface import DockerInterface from supervisor.docker.interface import DockerInterface
from supervisor.plugins.dns import HostEntry
@pytest.fixture(name="docker_interface") @pytest.fixture(name="docker_interface")
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]: async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
"""Mock docker interface methods.""" """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( with patch.object(DockerInterface, "run") as run, patch.object(
DockerInterface, "restart" DockerInterface, "restart"
) as restart: ) as restart:
@ -25,31 +27,98 @@ async def fixture_write_json() -> Mock:
yield write_json_file yield write_json_file
@pytest.mark.parametrize("start", [True, False])
async def test_config_write( async def test_config_write(
coresys: CoreSys, coresys: CoreSys,
docker_interface: tuple[AsyncMock, AsyncMock], docker_interface: tuple[AsyncMock, AsyncMock],
write_json: Mock, write_json: Mock,
start: bool,
): ):
"""Test config write on DNS start and restart.""" """Test config write on DNS start and restart."""
assert coresys.plugins.dns.locals == ["dns://192.168.30.1"] assert coresys.plugins.dns.locals == ["dns://192.168.30.1"]
coresys.plugins.dns.servers = ["dns://1.1.1.1", "dns://8.8.8.8"] coresys.plugins.dns.servers = ["dns://1.1.1.1", "dns://8.8.8.8"]
if start: await coresys.plugins.dns.start()
await coresys.plugins.dns.start() docker_interface[0].assert_called_once()
docker_interface[0].assert_called_once() docker_interface[1].assert_not_called()
docker_interface[1].assert_not_called()
else:
await coresys.plugins.dns.restart()
docker_interface[0].assert_not_called()
docker_interface[1].assert_called_once()
write_json.assert_called_once_with( write_json.assert_called_once_with(
Path("/data/dns/coredns.json"), Path("/data/dns/coredns.json"),
{ {
"servers": ["dns://1.1.1.1", "dns://8.8.8.8"], "servers": ["dns://1.1.1.1", "dns://8.8.8.8"],
"locals": ["dns://192.168.30.1"], "locals": ["dns://192.168.30.1"],
"fallback": True,
"debug": False, "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"],
),
]

View File

@ -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()