mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-07-02 19:35:42 +01:00
205b7dd589
Docker 28 assigns a random MAC address to macvlan endpoints on every endpoint creation, while older engines derived it from the endpoint's IP address. As app containers are recreated on every start, the isolated endpoint would get a different MAC address on each restart, making it impossible to reference the app in router or firewall rules. Pin the MAC address in the endpoint configuration instead, using the same derivation older engines used (02:42 followed by the static IPv4 octets). With the static IP enforced per app, the MAC address stays stable across restarts and updates and only changes when the user assigns a different IP address. Since the MAC address is now fully determined by the configuration, report it in the app info API from the configured IP rather than from the running container's network metadata. This makes it available as soon as isolation is configured, so users can set up router or firewall rules before the first start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
205 lines
6.2 KiB
Python
205 lines
6.2 KiB
Python
"""Test apps API network isolation options."""
|
|
|
|
from ipaddress import IPv4Address
|
|
from unittest.mock import MagicMock
|
|
|
|
from aiohttp.test_utils import TestClient
|
|
from awesomeversion import AwesomeVersion
|
|
import pytest
|
|
|
|
from supervisor.apps.app import App
|
|
from supervisor.const import ATTR_HOST_NETWORK
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.const import ExternalNetworkDriver, NetworkIsolationConfig
|
|
from supervisor.docker.manager import DockerInfo
|
|
|
|
from ..const import TEST_ADDON_SLUG, TEST_INTERFACE_ETH_NAME
|
|
|
|
|
|
@pytest.fixture(name="docker_supports_isolation")
|
|
def fixture_docker_supports_isolation(coresys: CoreSys) -> None:
|
|
"""Set a Docker version that supports network isolation."""
|
|
coresys.docker._info = DockerInfo( # pylint: disable=protected-access
|
|
AwesomeVersion("28.0.0"), "overlay2", "journald", "2", False
|
|
)
|
|
|
|
|
|
async def test_options_requires_host_network(
|
|
app_api_client_with_root: tuple[TestClient, str],
|
|
install_app_ssh: App,
|
|
):
|
|
"""Test isolation cannot be assigned to an app without host networking."""
|
|
client, root = app_api_client_with_root
|
|
|
|
resp = await client.post(
|
|
f"{root}/{TEST_ADDON_SLUG}/options",
|
|
json={
|
|
"network_isolation": {
|
|
"interface": TEST_INTERFACE_ETH_NAME,
|
|
"ipv4": "192.168.2.50",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "host networking" in body["message"]
|
|
assert install_app_ssh.network_isolation is None
|
|
|
|
|
|
async def test_options_requires_docker_version(
|
|
app_api_client_with_root: tuple[TestClient, str],
|
|
install_app_ssh: App,
|
|
):
|
|
"""Test isolation requires a recent Docker engine."""
|
|
client, root = app_api_client_with_root
|
|
install_app_ssh.data[ATTR_HOST_NETWORK] = True
|
|
|
|
resp = await client.post(
|
|
f"{root}/{TEST_ADDON_SLUG}/options",
|
|
json={
|
|
"network_isolation": {
|
|
"interface": TEST_INTERFACE_ETH_NAME,
|
|
"ipv4": "192.168.2.50",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "requires Docker 28.0.0" in body["message"]
|
|
|
|
|
|
@pytest.mark.usefixtures("docker_supports_isolation")
|
|
async def test_options_set_and_clear(
|
|
app_api_client_with_root: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
install_app_ssh: App,
|
|
):
|
|
"""Test assigning and clearing network isolation."""
|
|
client, root = app_api_client_with_root
|
|
install_app_ssh.data[ATTR_HOST_NETWORK] = True
|
|
|
|
resp = await client.post(
|
|
f"{root}/{TEST_ADDON_SLUG}/options",
|
|
json={
|
|
"network_isolation": {
|
|
"interface": TEST_INTERFACE_ETH_NAME,
|
|
"ipv4": "192.168.2.50",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
assert install_app_ssh.network_isolation == NetworkIsolationConfig(
|
|
driver=ExternalNetworkDriver.MACVLAN,
|
|
interface=TEST_INTERFACE_ETH_NAME,
|
|
ipv4=IPv4Address("192.168.2.50"),
|
|
)
|
|
|
|
resp = await client.get(f"{root}/{TEST_ADDON_SLUG}/info")
|
|
result = await resp.json()
|
|
assert result["data"]["network_isolation"] == {
|
|
"driver": "macvlan",
|
|
"interface": TEST_INTERFACE_ETH_NAME,
|
|
"ipv4": "192.168.2.50",
|
|
}
|
|
assert result["data"]["network_isolation_available"] is True
|
|
# MAC is derived from the static IP, known without a running container
|
|
assert result["data"]["network_isolation_mac"] == "02:42:c0:a8:02:32"
|
|
|
|
resp = await client.post(
|
|
f"{root}/{TEST_ADDON_SLUG}/options", json={"network_isolation": None}
|
|
)
|
|
assert resp.status == 200
|
|
assert install_app_ssh.network_isolation is None
|
|
|
|
|
|
@pytest.mark.usefixtures("docker_supports_isolation")
|
|
@pytest.mark.parametrize(
|
|
("address", "reason"),
|
|
[
|
|
("10.0.0.5", "not a usable address"),
|
|
("192.168.2.255", "not a usable address"),
|
|
("192.168.2.1", "already used by the host"),
|
|
("192.168.2.148", "already used by the host"),
|
|
],
|
|
)
|
|
async def test_options_invalid_address(
|
|
app_api_client_with_root: tuple[TestClient, str],
|
|
install_app_ssh: App,
|
|
address: str,
|
|
reason: str,
|
|
):
|
|
"""Test invalid IP addresses are rejected."""
|
|
client, root = app_api_client_with_root
|
|
install_app_ssh.data[ATTR_HOST_NETWORK] = True
|
|
|
|
resp = await client.post(
|
|
f"{root}/{TEST_ADDON_SLUG}/options",
|
|
json={
|
|
"network_isolation": {
|
|
"interface": TEST_INTERFACE_ETH_NAME,
|
|
"ipv4": address,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert reason in body["message"]
|
|
assert install_app_ssh.network_isolation is None
|
|
|
|
|
|
@pytest.mark.usefixtures("docker_supports_isolation")
|
|
async def test_options_invalid_interface(
|
|
app_api_client_with_root: tuple[TestClient, str],
|
|
install_app_ssh: App,
|
|
):
|
|
"""Test unusable host interfaces are rejected."""
|
|
client, root = app_api_client_with_root
|
|
install_app_ssh.data[ATTR_HOST_NETWORK] = True
|
|
|
|
resp = await client.post(
|
|
f"{root}/{TEST_ADDON_SLUG}/options",
|
|
json={"network_isolation": {"interface": "eth42", "ipv4": "192.168.2.50"}},
|
|
)
|
|
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "interface not found" in body["message"]
|
|
|
|
|
|
@pytest.mark.usefixtures("docker_supports_isolation")
|
|
async def test_options_address_conflict(
|
|
app_api_client_with_root: tuple[TestClient, str],
|
|
coresys: CoreSys,
|
|
install_app_ssh: App,
|
|
):
|
|
"""Test IP addresses already assigned to another app are rejected."""
|
|
client, root = app_api_client_with_root
|
|
install_app_ssh.data[ATTR_HOST_NETWORK] = True
|
|
|
|
other = MagicMock(spec=App)
|
|
other.slug = "other_addon"
|
|
other.network_isolation = NetworkIsolationConfig(
|
|
driver=ExternalNetworkDriver.MACVLAN,
|
|
interface=TEST_INTERFACE_ETH_NAME,
|
|
ipv4=IPv4Address("192.168.2.50"),
|
|
)
|
|
coresys.apps.local[other.slug] = other
|
|
|
|
resp = await client.post(
|
|
f"{root}/{TEST_ADDON_SLUG}/options",
|
|
json={
|
|
"network_isolation": {
|
|
"interface": TEST_INTERFACE_ETH_NAME,
|
|
"ipv4": "192.168.2.50",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "already assigned to app other_addon" in body["message"]
|