1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-07-02 19:35:42 +01:00
Files
supervisor/tests/api/test_apps_network_isolation.py
Stefan Agner 205b7dd589 Pin stable MAC address for isolated app network endpoints
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>
2026-06-12 16:54:40 +02:00

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"]