From f5b996b66c4984fcddd37d5a9af91effd7009b5a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 5 Sep 2024 09:19:13 +0200 Subject: [PATCH] Make network API replace IP/WiFi settings (#5283) * Allow to set user DNS through API with auto mode Currently it is only possible to set DNS servers when in static mode. However, there are use cases to set DNS servers when in auto mode as well, e.g. if no local DNS server is provided by the DHCP, or the provided DNS turns out to be non-working. * Fix use separate data structure for IP configuration fallout Make sure gateway is correctly converted to the internal IP representation. Fix type info. * Overwrite WiFi settings completely too * Add test for DNS configuration * Run ruff format * ruff format * Use schema validation as source for API defaults Instead of using replace() simply set the API defaults in the API schema. * Revert "Use schema validation as source for API defaults" This reverts commit 885506fd37395eb6cea9c787ee23349dac780b75. * Use explicit dataclass initialization This avoid the unnecessary replaces from before. It also makes it more obvious that this part of the API doesn't patch existing settings. --- supervisor/api/network.py | 31 +++-- supervisor/dbus/network/configuration.py | 2 +- supervisor/dbus/network/setting/generate.py | 144 ++++++++++++-------- supervisor/host/configuration.py | 8 +- tests/api/test_network.py | 9 +- tests/dbus/network/setting/test_init.py | 8 +- tests/host/test_network.py | 14 +- 7 files changed, 129 insertions(+), 87 deletions(-) diff --git a/supervisor/api/network.py b/supervisor/api/network.py index 817bb17b3..bc8c8d24d 100644 --- a/supervisor/api/network.py +++ b/supervisor/api/network.py @@ -2,7 +2,6 @@ import asyncio from collections.abc import Awaitable -from dataclasses import replace from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface from typing import Any @@ -208,24 +207,26 @@ class APINetwork(CoreSysAttributes): # Apply config for key, config in body.items(): if key == ATTR_IPV4: - interface.ipv4setting = replace( - interface.ipv4setting - or IpSetting(InterfaceMethod.STATIC, [], None, []), - **config, + interface.ipv4setting = IpSetting( + config.get(ATTR_METHOD, InterfaceMethod.STATIC), + config.get(ATTR_ADDRESS, []), + config.get(ATTR_GATEWAY), + config.get(ATTR_NAMESERVERS, []), ) elif key == ATTR_IPV6: - interface.ipv6setting = replace( - interface.ipv6setting - or IpSetting(InterfaceMethod.STATIC, [], None, []), - **config, + interface.ipv6setting = IpSetting( + config.get(ATTR_METHOD, InterfaceMethod.STATIC), + config.get(ATTR_ADDRESS, []), + config.get(ATTR_GATEWAY), + config.get(ATTR_NAMESERVERS, []), ) elif key == ATTR_WIFI: - interface.wifi = replace( - interface.wifi - or WifiConfig( - WifiMode.INFRASTRUCTURE, "", AuthMethod.OPEN, None, None - ), - **config, + interface.wifi = WifiConfig( + config.get(ATTR_MODE, WifiMode.INFRASTRUCTURE), + config.get(ATTR_SSID, ""), + config.get(ATTR_AUTH, AuthMethod.OPEN), + config.get(ATTR_PSK, None), + None, ) elif key == ATTR_ENABLED: interface.enabled = config diff --git a/supervisor/dbus/network/configuration.py b/supervisor/dbus/network/configuration.py index 36a81e34a..b78855f85 100644 --- a/supervisor/dbus/network/configuration.py +++ b/supervisor/dbus/network/configuration.py @@ -74,7 +74,7 @@ class IpProperties: method: str | None address_data: list[IpAddress] | None gateway: str | None - dns: list[str] | None + dns: list[bytes | int] | None @dataclass(slots=True) diff --git a/supervisor/dbus/network/setting/generate.py b/supervisor/dbus/network/setting/generate.py index d2825a71a..0f59fc53f 100644 --- a/supervisor/dbus/network/setting/generate.py +++ b/supervisor/dbus/network/setting/generate.py @@ -50,6 +50,89 @@ if TYPE_CHECKING: from ....host.configuration import Interface +def _get_ipv4_connection_settings(ipv4setting) -> dict: + ipv4 = {} + if not ipv4setting or ipv4setting.method == InterfaceMethod.AUTO: + ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "auto") + elif ipv4setting.method == InterfaceMethod.DISABLED: + ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "disabled") + elif ipv4setting.method == InterfaceMethod.STATIC: + ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "manual") + + address_data = [] + for address in ipv4setting.address: + address_data.append( + { + "address": Variant("s", str(address.ip)), + "prefix": Variant("u", int(address.with_prefixlen.split("/")[-1])), + } + ) + + ipv4[CONF_ATTR_IPV4_ADDRESS_DATA] = Variant("aa{sv}", address_data) + if ipv4setting.gateway: + ipv4[CONF_ATTR_IPV4_GATEWAY] = Variant("s", str(ipv4setting.gateway)) + else: + raise RuntimeError("Invalid IPv4 InterfaceMethod") + + if ( + ipv4setting + and ipv4setting.nameservers + and ipv4setting.method + in ( + InterfaceMethod.AUTO, + InterfaceMethod.STATIC, + ) + ): + nameservers = ipv4setting.nameservers if ipv4setting else [] + ipv4[CONF_ATTR_IPV4_DNS] = Variant( + "au", + [socket.htonl(int(ip_address)) for ip_address in nameservers], + ) + + return ipv4 + + +def _get_ipv6_connection_settings(ipv6setting) -> dict: + ipv6 = {} + if not ipv6setting or ipv6setting.method == InterfaceMethod.AUTO: + ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "auto") + elif ipv6setting.method == InterfaceMethod.DISABLED: + ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "link-local") + elif ipv6setting.method == InterfaceMethod.STATIC: + ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "manual") + + address_data = [] + for address in ipv6setting.address: + address_data.append( + { + "address": Variant("s", str(address.ip)), + "prefix": Variant("u", int(address.with_prefixlen.split("/")[-1])), + } + ) + + ipv6[CONF_ATTR_IPV6_ADDRESS_DATA] = Variant("aa{sv}", address_data) + if ipv6setting.gateway: + ipv6[CONF_ATTR_IPV6_GATEWAY] = Variant("s", str(ipv6setting.gateway)) + else: + raise RuntimeError("Invalid IPv6 InterfaceMethod") + + if ( + ipv6setting + and ipv6setting.nameservers + and ipv6setting.method + in ( + InterfaceMethod.AUTO, + InterfaceMethod.STATIC, + ) + ): + nameservers = ipv6setting.nameservers if ipv6setting else [] + ipv6[CONF_ATTR_IPV6_DNS] = Variant( + "aay", + [ip_address.packed for ip_address in nameservers], + ) + return ipv6 + + def get_connection_from_interface( interface: Interface, network_manager: NetworkManager, @@ -94,66 +177,9 @@ def get_connection_from_interface( else: conn[CONF_ATTR_CONNECTION]["interface-name"] = Variant("s", interface.name) - ipv4 = {} - if ( - not interface.ipv4setting - or interface.ipv4setting.method == InterfaceMethod.AUTO - ): - ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "auto") - elif interface.ipv4setting.method == InterfaceMethod.DISABLED: - ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "disabled") - else: - ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "manual") - ipv4[CONF_ATTR_IPV4_DNS] = Variant( - "au", - [ - socket.htonl(int(ip_address)) - for ip_address in interface.ipv4setting.nameservers - ], - ) + conn[CONF_ATTR_IPV4] = _get_ipv4_connection_settings(interface.ipv4setting) - address_data = [] - for address in interface.ipv4setting.address: - address_data.append( - { - "address": Variant("s", str(address.ip)), - "prefix": Variant("u", int(address.with_prefixlen.split("/")[-1])), - } - ) - - ipv4[CONF_ATTR_IPV4_ADDRESS_DATA] = Variant("aa{sv}", address_data) - ipv4[CONF_ATTR_IPV4_GATEWAY] = Variant("s", str(interface.ipv4setting.gateway)) - - conn[CONF_ATTR_IPV4] = ipv4 - - ipv6 = {} - if ( - not interface.ipv6setting - or interface.ipv6setting.method == InterfaceMethod.AUTO - ): - ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "auto") - elif interface.ipv6setting.method == InterfaceMethod.DISABLED: - ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "link-local") - else: - ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "manual") - ipv6[CONF_ATTR_IPV6_DNS] = Variant( - "aay", - [ip_address.packed for ip_address in interface.ipv6setting.nameservers], - ) - - address_data = [] - for address in interface.ipv6setting.address: - address_data.append( - { - "address": Variant("s", str(address.ip)), - "prefix": Variant("u", int(address.with_prefixlen.split("/")[-1])), - } - ) - - ipv6[CONF_ATTR_IPV6_ADDRESS_DATA] = Variant("aa{sv}", address_data) - ipv6[CONF_ATTR_IPV6_GATEWAY] = Variant("s", str(interface.ipv6setting.gateway)) - - conn[CONF_ATTR_IPV6] = ipv6 + conn[CONF_ATTR_IPV6] = _get_ipv6_connection_settings(interface.ipv6setting) if interface.type == InterfaceType.ETHERNET: conn[CONF_ATTR_802_ETHERNET] = { diff --git a/supervisor/host/configuration.py b/supervisor/host/configuration.py index 5c6799093..cfce58831 100644 --- a/supervisor/host/configuration.py +++ b/supervisor/host/configuration.py @@ -105,7 +105,9 @@ class Interface: ] if inet.settings.ipv4.address_data else [], - gateway=inet.settings.ipv4.gateway, + gateway=IPv4Address(inet.settings.ipv4.gateway) + if inet.settings.ipv4.gateway + else None, nameservers=[ IPv4Address(socket.ntohl(ip)) for ip in inet.settings.ipv4.dns ] @@ -124,7 +126,9 @@ class Interface: ] if inet.settings.ipv6.address_data else [], - gateway=inet.settings.ipv6.gateway, + gateway=IPv6Address(inet.settings.ipv6.gateway) + if inet.settings.ipv6.gateway + else None, nameservers=[IPv6Address(bytes(ip)) for ip in inet.settings.ipv6.dns] if inet.settings.ipv6.dns else [], diff --git a/tests/api/test_network.py b/tests/api/test_network.py index 087c9b8d6..5d5f2cfe3 100644 --- a/tests/api/test_network.py +++ b/tests/api/test_network.py @@ -184,7 +184,7 @@ async def test_api_network_interface_update_ethernet( assert settings["ipv4"]["dns"] == Variant("au", [16843009]) assert settings["ipv4"]["gateway"] == Variant("s", "192.168.2.1") - # Partial static configuration, updates only provided settings (e.g. by CLI) + # Partial static configuration, clears other settings (e.g. by CLI) resp = await api_client.post( f"/network/interface/{TEST_INTERFACE_ETH_NAME}/update", json={ @@ -205,8 +205,8 @@ async def test_api_network_interface_update_ethernet( "aa{sv}", [{"address": Variant("s", "192.168.2.149"), "prefix": Variant("u", 24)}], ) - assert settings["ipv4"]["dns"] == Variant("au", [16843009]) - assert settings["ipv4"]["gateway"] == Variant("s", "192.168.2.1") + assert "dns" not in settings["ipv4"] + assert "gateway" not in settings["ipv4"] # Auto configuration, clears all settings (represents frontend auto config) resp = await api_client.post( @@ -214,6 +214,7 @@ async def test_api_network_interface_update_ethernet( json={ "ipv4": { "method": "auto", + "nameservers": ["8.8.8.8"], } }, ) @@ -228,7 +229,7 @@ async def test_api_network_interface_update_ethernet( assert "address-data" not in settings["ipv4"] assert "addresses" not in settings["ipv4"] assert "dns-data" not in settings["ipv4"] - assert "dns" not in settings["ipv4"] + assert settings["ipv4"]["dns"] == Variant("au", [134744072]) assert "gateway" not in settings["ipv4"] diff --git a/tests/dbus/network/setting/test_init.py b/tests/dbus/network/setting/test_init.py index e48bda93e..bbe516b47 100644 --- a/tests/dbus/network/setting/test_init.py +++ b/tests/dbus/network/setting/test_init.py @@ -77,7 +77,8 @@ async def test_ethernet_update( assert "ipv4" in settings assert settings["ipv4"]["method"] == Variant("s", "auto") assert "gateway" not in settings["ipv4"] - assert "dns" not in settings["ipv4"] + # Only DNS settings need to be preserved with auto + assert settings["ipv4"]["dns"] == Variant("au", [16951488]) assert "dns-data" not in settings["ipv4"] assert "address-data" not in settings["ipv4"] assert "addresses" not in settings["ipv4"] @@ -94,7 +95,10 @@ async def test_ethernet_update( assert "ipv6" in settings assert settings["ipv6"]["method"] == Variant("s", "auto") assert "gateway" not in settings["ipv6"] - assert "dns" not in settings["ipv6"] + # Only DNS settings need to be preserved with auto + assert settings["ipv6"]["dns"] == Variant( + "aay", [bytearray(b" \x01H`H`\x00\x00\x00\x00\x00\x00\x00\x00\x88\x88")] + ) assert "dns-data" not in settings["ipv6"] assert "address-data" not in settings["ipv6"] assert "addresses" not in settings["ipv6"] diff --git a/tests/host/test_network.py b/tests/host/test_network.py index 83eb21a8c..2083f72df 100644 --- a/tests/host/test_network.py +++ b/tests/host/test_network.py @@ -72,13 +72,15 @@ async def test_load( assert name_dict["eth0"].ipv4setting.method == InterfaceMethod.AUTO assert name_dict["eth0"].ipv4setting.address == [] assert name_dict["eth0"].ipv4setting.gateway is None - assert name_dict["eth0"].ipv4setting.nameservers == [] + assert name_dict["eth0"].ipv4setting.nameservers == [IPv4Address("192.168.2.1")] assert name_dict["eth0"].ipv6.gateway == IPv6Address("fe80::da58:d7ff:fe00:9c69") assert name_dict["eth0"].ipv6.ready is True assert name_dict["eth0"].ipv6setting.method == InterfaceMethod.AUTO assert name_dict["eth0"].ipv6setting.address == [] assert name_dict["eth0"].ipv6setting.gateway is None - assert name_dict["eth0"].ipv6setting.nameservers == [] + assert name_dict["eth0"].ipv6setting.nameservers == [ + IPv6Address("2001:4860:4860::8888") + ] assert "wlan0" in name_dict assert name_dict["wlan0"].enabled is False @@ -87,13 +89,17 @@ async def test_load( "aa{sv}", [] ) assert "gateway" not in connection_settings_service.settings["ipv4"] - assert "dns" not in connection_settings_service.settings["ipv4"] + assert connection_settings_service.settings["ipv4"]["dns"] == Variant( + "au", [16951488] + ) assert connection_settings_service.settings["ipv6"]["method"].value == "auto" assert connection_settings_service.settings["ipv6"]["address-data"] == Variant( "aa{sv}", [] ) assert "gateway" not in connection_settings_service.settings["ipv6"] - assert "dns" not in connection_settings_service.settings["ipv6"] + assert connection_settings_service.settings["ipv6"]["dns"] == Variant( + "aay", [bytearray(b" \x01H`H`\x00\x00\x00\x00\x00\x00\x00\x00\x88\x88")] + ) assert network_manager_service.ActivateConnection.calls == [ (