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.
This commit is contained in:
Stefan Agner 2024-09-05 09:19:13 +02:00 committed by GitHub
parent 05e0c7c3ab
commit f5b996b66c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 129 additions and 87 deletions

View File

@ -2,7 +2,6 @@
import asyncio import asyncio
from collections.abc import Awaitable from collections.abc import Awaitable
from dataclasses import replace
from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface
from typing import Any from typing import Any
@ -208,24 +207,26 @@ class APINetwork(CoreSysAttributes):
# Apply config # Apply config
for key, config in body.items(): for key, config in body.items():
if key == ATTR_IPV4: if key == ATTR_IPV4:
interface.ipv4setting = replace( interface.ipv4setting = IpSetting(
interface.ipv4setting config.get(ATTR_METHOD, InterfaceMethod.STATIC),
or IpSetting(InterfaceMethod.STATIC, [], None, []), config.get(ATTR_ADDRESS, []),
**config, config.get(ATTR_GATEWAY),
config.get(ATTR_NAMESERVERS, []),
) )
elif key == ATTR_IPV6: elif key == ATTR_IPV6:
interface.ipv6setting = replace( interface.ipv6setting = IpSetting(
interface.ipv6setting config.get(ATTR_METHOD, InterfaceMethod.STATIC),
or IpSetting(InterfaceMethod.STATIC, [], None, []), config.get(ATTR_ADDRESS, []),
**config, config.get(ATTR_GATEWAY),
config.get(ATTR_NAMESERVERS, []),
) )
elif key == ATTR_WIFI: elif key == ATTR_WIFI:
interface.wifi = replace( interface.wifi = WifiConfig(
interface.wifi config.get(ATTR_MODE, WifiMode.INFRASTRUCTURE),
or WifiConfig( config.get(ATTR_SSID, ""),
WifiMode.INFRASTRUCTURE, "", AuthMethod.OPEN, None, None config.get(ATTR_AUTH, AuthMethod.OPEN),
), config.get(ATTR_PSK, None),
**config, None,
) )
elif key == ATTR_ENABLED: elif key == ATTR_ENABLED:
interface.enabled = config interface.enabled = config

View File

@ -74,7 +74,7 @@ class IpProperties:
method: str | None method: str | None
address_data: list[IpAddress] | None address_data: list[IpAddress] | None
gateway: str | None gateway: str | None
dns: list[str] | None dns: list[bytes | int] | None
@dataclass(slots=True) @dataclass(slots=True)

View File

@ -50,6 +50,89 @@ if TYPE_CHECKING:
from ....host.configuration import Interface 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( def get_connection_from_interface(
interface: Interface, interface: Interface,
network_manager: NetworkManager, network_manager: NetworkManager,
@ -94,66 +177,9 @@ def get_connection_from_interface(
else: else:
conn[CONF_ATTR_CONNECTION]["interface-name"] = Variant("s", interface.name) conn[CONF_ATTR_CONNECTION]["interface-name"] = Variant("s", interface.name)
ipv4 = {} conn[CONF_ATTR_IPV4] = _get_ipv4_connection_settings(interface.ipv4setting)
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
],
)
address_data = [] conn[CONF_ATTR_IPV6] = _get_ipv6_connection_settings(interface.ipv6setting)
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
if interface.type == InterfaceType.ETHERNET: if interface.type == InterfaceType.ETHERNET:
conn[CONF_ATTR_802_ETHERNET] = { conn[CONF_ATTR_802_ETHERNET] = {

View File

@ -105,7 +105,9 @@ class Interface:
] ]
if inet.settings.ipv4.address_data if inet.settings.ipv4.address_data
else [], else [],
gateway=inet.settings.ipv4.gateway, gateway=IPv4Address(inet.settings.ipv4.gateway)
if inet.settings.ipv4.gateway
else None,
nameservers=[ nameservers=[
IPv4Address(socket.ntohl(ip)) for ip in inet.settings.ipv4.dns IPv4Address(socket.ntohl(ip)) for ip in inet.settings.ipv4.dns
] ]
@ -124,7 +126,9 @@ class Interface:
] ]
if inet.settings.ipv6.address_data if inet.settings.ipv6.address_data
else [], 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] nameservers=[IPv6Address(bytes(ip)) for ip in inet.settings.ipv6.dns]
if inet.settings.ipv6.dns if inet.settings.ipv6.dns
else [], else [],

View File

@ -184,7 +184,7 @@ async def test_api_network_interface_update_ethernet(
assert settings["ipv4"]["dns"] == Variant("au", [16843009]) assert settings["ipv4"]["dns"] == Variant("au", [16843009])
assert settings["ipv4"]["gateway"] == Variant("s", "192.168.2.1") 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( resp = await api_client.post(
f"/network/interface/{TEST_INTERFACE_ETH_NAME}/update", f"/network/interface/{TEST_INTERFACE_ETH_NAME}/update",
json={ json={
@ -205,8 +205,8 @@ async def test_api_network_interface_update_ethernet(
"aa{sv}", "aa{sv}",
[{"address": Variant("s", "192.168.2.149"), "prefix": Variant("u", 24)}], [{"address": Variant("s", "192.168.2.149"), "prefix": Variant("u", 24)}],
) )
assert settings["ipv4"]["dns"] == Variant("au", [16843009]) assert "dns" not in settings["ipv4"]
assert settings["ipv4"]["gateway"] == Variant("s", "192.168.2.1") assert "gateway" not in settings["ipv4"]
# Auto configuration, clears all settings (represents frontend auto config) # Auto configuration, clears all settings (represents frontend auto config)
resp = await api_client.post( resp = await api_client.post(
@ -214,6 +214,7 @@ async def test_api_network_interface_update_ethernet(
json={ json={
"ipv4": { "ipv4": {
"method": "auto", "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 "address-data" not in settings["ipv4"]
assert "addresses" not in settings["ipv4"] assert "addresses" not in settings["ipv4"]
assert "dns-data" 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"] assert "gateway" not in settings["ipv4"]

View File

@ -77,7 +77,8 @@ async def test_ethernet_update(
assert "ipv4" in settings assert "ipv4" in settings
assert settings["ipv4"]["method"] == Variant("s", "auto") assert settings["ipv4"]["method"] == Variant("s", "auto")
assert "gateway" not in settings["ipv4"] 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 "dns-data" not in settings["ipv4"]
assert "address-data" not in settings["ipv4"] assert "address-data" not in settings["ipv4"]
assert "addresses" not in settings["ipv4"] assert "addresses" not in settings["ipv4"]
@ -94,7 +95,10 @@ async def test_ethernet_update(
assert "ipv6" in settings assert "ipv6" in settings
assert settings["ipv6"]["method"] == Variant("s", "auto") assert settings["ipv6"]["method"] == Variant("s", "auto")
assert "gateway" not in settings["ipv6"] 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 "dns-data" not in settings["ipv6"]
assert "address-data" not in settings["ipv6"] assert "address-data" not in settings["ipv6"]
assert "addresses" not in settings["ipv6"] assert "addresses" not in settings["ipv6"]

View File

@ -72,13 +72,15 @@ async def test_load(
assert name_dict["eth0"].ipv4setting.method == InterfaceMethod.AUTO assert name_dict["eth0"].ipv4setting.method == InterfaceMethod.AUTO
assert name_dict["eth0"].ipv4setting.address == [] assert name_dict["eth0"].ipv4setting.address == []
assert name_dict["eth0"].ipv4setting.gateway is None 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.gateway == IPv6Address("fe80::da58:d7ff:fe00:9c69")
assert name_dict["eth0"].ipv6.ready is True assert name_dict["eth0"].ipv6.ready is True
assert name_dict["eth0"].ipv6setting.method == InterfaceMethod.AUTO assert name_dict["eth0"].ipv6setting.method == InterfaceMethod.AUTO
assert name_dict["eth0"].ipv6setting.address == [] assert name_dict["eth0"].ipv6setting.address == []
assert name_dict["eth0"].ipv6setting.gateway is None 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 "wlan0" in name_dict
assert name_dict["wlan0"].enabled is False assert name_dict["wlan0"].enabled is False
@ -87,13 +89,17 @@ async def test_load(
"aa{sv}", [] "aa{sv}", []
) )
assert "gateway" not in connection_settings_service.settings["ipv4"] 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"]["method"].value == "auto"
assert connection_settings_service.settings["ipv6"]["address-data"] == Variant( assert connection_settings_service.settings["ipv6"]["address-data"] == Variant(
"aa{sv}", [] "aa{sv}", []
) )
assert "gateway" not in connection_settings_service.settings["ipv6"] 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 == [ assert network_manager_service.ActivateConnection.calls == [
( (