diff --git a/supervisor/api/apps.py b/supervisor/api/apps.py index 7c72059153e..e0bda8cd9f6 100644 --- a/supervisor/api/apps.py +++ b/supervisor/api/apps.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Awaitable +from contextlib import suppress import logging from typing import Any, TypedDict @@ -11,6 +12,7 @@ from ..apps.app import App from ..apps.utils import rating_security +from ..apps.validate import SCHEMA_NETWORK_ISOLATION from ..const import ( ATTR_ADDONS, ATTR_ADVANCED, @@ -37,6 +39,7 @@ ATTR_DNS, ATTR_DOCKER_API, ATTR_DOCUMENTATION, + ATTR_DRIVER, ATTR_FORCE, ATTR_FULL_ACCESS, ATTR_GPIO, @@ -67,6 +70,7 @@ ATTR_NAME, ATTR_NETWORK, ATTR_NETWORK_DESCRIPTION, + ATTR_NETWORK_ISOLATION, ATTR_NETWORK_RX, ATTR_NETWORK_TX, ATTR_OPTIONS, @@ -99,6 +103,11 @@ AppBootConfig, ) from ..coresys import CoreSysAttributes +from ..docker.const import ExternalNetworkDriver, NetworkIsolationConfig +from ..docker.external_network import ( + MIN_EXTERNAL_NETWORK_DOCKER, + DockerExternalNetworks, +) from ..docker.stats import DockerStats from ..exceptions import ( APIAppNotInstalled, @@ -107,23 +116,47 @@ APINotFound, AppBootConfigCannotChangeError, AppConfigurationInvalidError, + AppNetworkIsolationDockerVersionError, + AppNetworkIsolationInvalidAddressError, + AppNetworkIsolationInvalidInterfaceError, + AppNetworkIsolationNotSupportedError, AppNotSupportedWriteStdinError, + DockerError, + HostNetworkNotFound, PwnedError, PwnedSecret, ) from ..validate import docker_ports -from .const import ATTR_BOOT_CONFIG, ATTR_REMOVE_CONFIG, ATTR_SIGNED +from .const import ( + ATTR_BOOT_CONFIG, + ATTR_NETWORK_ISOLATION_AVAILABLE, + ATTR_NETWORK_ISOLATION_MAC, + ATTR_REMOVE_CONFIG, + ATTR_SIGNED, +) from .utils import api_process, api_validate, json_loads _LOGGER: logging.Logger = logging.getLogger(__name__) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): str}) +# Only the macvlan driver is supported for now +SCHEMA_NETWORK_ISOLATION_OPTIONS = SCHEMA_NETWORK_ISOLATION.extend( + { + vol.Optional(ATTR_DRIVER, default=ExternalNetworkDriver.MACVLAN): vol.All( + vol.Coerce(ExternalNetworkDriver), vol.In([ExternalNetworkDriver.MACVLAN]) + ), + } +) + # pylint: disable=no-value-for-parameter SCHEMA_OPTIONS = vol.Schema( { vol.Optional(ATTR_BOOT): vol.Coerce(AppBoot), vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports), + vol.Optional(ATTR_NETWORK_ISOLATION): vol.Maybe( + SCHEMA_NETWORK_ISOLATION_OPTIONS + ), vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str), @@ -249,6 +282,12 @@ async def info_data(self, app: App) -> dict[str, Any]: ATTR_BUILD: app.need_build, ATTR_NETWORK: app.ports, ATTR_NETWORK_DESCRIPTION: app.ports_description, + ATTR_NETWORK_ISOLATION: config.to_dict() + if (config := app.network_isolation) + else None, + ATTR_NETWORK_ISOLATION_AVAILABLE: app.host_network + and self.sys_docker.external_networks.available, + ATTR_NETWORK_ISOLATION_MAC: app.external_mac_address, ATTR_HOST_NETWORK: app.host_network, ATTR_HOST_PID: app.host_pid, ATTR_HOST_IPC: app.host_ipc, @@ -337,6 +376,13 @@ async def options(self, request: web.Request) -> None: app.auto_update = body[ATTR_AUTO_UPDATE] if ATTR_NETWORK in body: app.ports = body[ATTR_NETWORK] + if ATTR_NETWORK_ISOLATION in body: + if (isolation := body[ATTR_NETWORK_ISOLATION]) is None: + app.network_isolation = None + else: + config = NetworkIsolationConfig.from_dict(isolation) + self._validate_network_isolation(app, config) + app.network_isolation = config if ATTR_AUDIO_INPUT in body: app.audio_input = body[ATTR_AUDIO_INPUT] if ATTR_AUDIO_OUTPUT in body: @@ -349,6 +395,72 @@ async def options(self, request: web.Request) -> None: await app.save_persist() + # Clean up external networks no longer referenced by any app + if ATTR_NETWORK_ISOLATION in body: + with suppress(DockerError): + await self.sys_docker.external_networks.gc() + + def _validate_network_isolation( + self, app: App, config: NetworkIsolationConfig + ) -> None: + """Validate a network isolation config against app and host state.""" + if not app.host_network: + raise AppNetworkIsolationNotSupportedError(app=app) + if not self.sys_docker.external_networks.available: + raise AppNetworkIsolationDockerVersionError( + app=app, minimum_version=str(MIN_EXTERNAL_NETWORK_DOCKER) + ) + + try: + interface = self.sys_host.network.get(config.interface) + except HostNetworkNotFound: + raise AppNetworkIsolationInvalidInterfaceError( + app=app, interface=config.interface, reason="interface not found" + ) from None + if not DockerExternalNetworks.capable_interface(interface): + raise AppNetworkIsolationInvalidInterfaceError( + app=app, + interface=config.interface, + reason="must be a connected ethernet interface with IPv4", + ) + + subnet = DockerExternalNetworks.interface_subnet(interface) + if ( + subnet is None + or config.ipv4 not in subnet + or config.ipv4 in {subnet.network_address, subnet.broadcast_address} + ): + raise AppNetworkIsolationInvalidAddressError( + app=app, + address=str(config.ipv4), + reason=f"not a usable address within subnet {subnet}", + ) + if interface.ipv4 and ( + config.ipv4 == interface.ipv4.gateway + or any( + config.ipv4 == address.ip + for address in interface.ipv4.address + if address.version == 4 + ) + ): + raise AppNetworkIsolationInvalidAddressError( + app=app, + address=str(config.ipv4), + reason="already used by the host or its gateway", + ) + + for other in self.sys_apps.installed: + if other.slug == app.slug: + continue + if ( + other_config := other.network_isolation + ) and other_config.ipv4 == config.ipv4: + raise AppNetworkIsolationInvalidAddressError( + app=app, + address=str(config.ipv4), + reason=f"already assigned to app {other.slug}", + ) + @api_process async def sys_options(self, request: web.Request) -> None: """Store system options for an app.""" diff --git a/supervisor/api/const.py b/supervisor/api/const.py index dc4ed6f834a..d74c4d4a65f 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -54,6 +54,9 @@ ATTR_MODEL = "model" ATTR_MOUNTS = "mounts" ATTR_MOUNT_POINTS = "mount_points" +ATTR_NETWORK_ISOLATION_AVAILABLE = "network_isolation_available" +ATTR_NETWORK_ISOLATION_CAPABLE = "network_isolation_capable" +ATTR_NETWORK_ISOLATION_MAC = "network_isolation_mac" ATTR_PANEL_PATH = "panel_path" ATTR_REMOVABLE = "removable" ATTR_REMOVE_CONFIG = "remove_config" diff --git a/supervisor/api/network.py b/supervisor/api/network.py index b1e7cb5ffc5..e01190ac575 100644 --- a/supervisor/api/network.py +++ b/supervisor/api/network.py @@ -47,6 +47,7 @@ DOCKER_NETWORK, ) from ..coresys import CoreSysAttributes +from ..docker.external_network import DockerExternalNetworks from ..exceptions import APIError, APINotFound, HostNetworkNotFound from ..host.configuration import ( AccessPoint, @@ -62,6 +63,7 @@ WifiConfig, ) from ..host.const import AuthMethod, InterfaceType, WifiMode +from .const import ATTR_NETWORK_ISOLATION_CAPABLE from .utils import api_process, api_validate _SCHEMA_IPV4_CONFIG = vol.Schema( @@ -172,6 +174,9 @@ def interface_struct(interface: Interface) -> dict[str, Any]: ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None, ATTR_MDNS: interface.mdns, ATTR_LLMNR: interface.llmnr, + ATTR_NETWORK_ISOLATION_CAPABLE: DockerExternalNetworks.capable_interface( + interface + ), } diff --git a/supervisor/apps/app.py b/supervisor/apps/app.py index 638bceab89b..6cc6444393f 100644 --- a/supervisor/apps/app.py +++ b/supervisor/apps/app.py @@ -37,6 +37,7 @@ ATTR_INGRESS_TOKEN, ATTR_LOCATION, ATTR_NETWORK, + ATTR_NETWORK_ISOLATION, ATTR_OPTIONS, ATTR_PORTS, ATTR_PROTECTED, @@ -60,7 +61,12 @@ ) from ..coresys import CoreSys from ..docker.app import DockerApp -from ..docker.const import EXIT_CODE_SIGTERM_DEFAULT, ContainerState +from ..docker.const import ( + EXIT_CODE_SIGTERM_DEFAULT, + ContainerState, + NetworkIsolationConfig, +) +from ..docker.external_network import DockerExternalNetworks from ..docker.manager import ExecReturn from ..docker.monitor import DockerContainerStateEvent from ..docker.stats import DockerStats @@ -164,6 +170,9 @@ def __init__(self, coresys: CoreSys, slug: str): self._device_access_missing_issue = Issue( IssueType.DEVICE_ACCESS_MISSING, ContextType.ADDON, reference=self.slug ) + self._network_isolation_failed_issue = Issue( + IssueType.NETWORK_ISOLATION_FAILED, ContextType.ADDON, reference=self.slug + ) def __repr__(self) -> str: """Return internal representation.""" @@ -179,6 +188,11 @@ def device_access_missing_issue(self) -> Issue: """Get issue used if device access is missing and can't be automatically added.""" return self._device_access_missing_issue + @property + def network_isolation_failed_issue(self) -> Issue: + """Get issue used if the isolated network endpoint can't be set up.""" + return self._network_isolation_failed_issue + @property def state(self) -> AppState: """Return current state of the app.""" @@ -352,6 +366,17 @@ def ip_address(self) -> IPv4Address: """Return IP of app instance.""" return self.instance.ip_address + @property + def external_mac_address(self) -> str | None: + """Return MAC address of the isolated network endpoint, if assigned. + + Derived from the static IP, so it is known without a running + container (the endpoint is connected with exactly this MAC). + """ + if not (config := self.network_isolation): + return None + return DockerExternalNetworks.mac_from_ip(config.ipv4) + @property def data(self) -> Data: """Return app data/config.""" @@ -595,6 +620,22 @@ def ports(self, value: dict[str, int | None] | None) -> None: self.persist[ATTR_NETWORK] = new_ports + @property + def network_isolation(self) -> NetworkIsolationConfig | None: + """Return isolated physical network endpoint assigned by the user.""" + if not (data := self.persist.get(ATTR_NETWORK_ISOLATION)): + return None + return NetworkIsolationConfig.from_dict(data) + + @network_isolation.setter + def network_isolation(self, value: NetworkIsolationConfig | None) -> None: + """Assign or clear the isolated physical network endpoint.""" + if value is None: + self.persist.pop(ATTR_NETWORK_ISOLATION, None) + return + + self.persist[ATTR_NETWORK_ISOLATION] = value.to_dict() + @property def ingress_url(self) -> str | None: """Return URL to ingress url.""" @@ -993,9 +1034,15 @@ def cleanup_config_and_audio(): await service.del_service_data(self) # Remove from app manager + had_network_isolation = self.network_isolation is not None self.sys_apps.local.pop(self.slug) await self.sys_apps.data.uninstall(self) + # Clean up external networks no longer referenced by any app + if had_network_isolation: + with suppress(DockerError): + await self.sys_docker.external_networks.gc() + # Cleanup Ingress tokens if need_ingress_token_cleanup: await self.sys_ingress.reload() diff --git a/supervisor/apps/manager.py b/supervisor/apps/manager.py index 588722c24b2..021d8668a39 100644 --- a/supervisor/apps/manager.py +++ b/supervisor/apps/manager.py @@ -126,8 +126,11 @@ async def boot(self, stage: AppStartup) -> None: for app in self.installed: if app.boot != AppBoot.AUTO or app.startup != stage: continue + # Apps with an isolated network endpoint do not run in the host + # network namespace, their traffic never passes the Docker gateway if ( app.host_network + and not app.network_isolation and UnhealthyReason.DOCKER_GATEWAY_UNPROTECTED in self.sys_resolution.unhealthy ): diff --git a/supervisor/apps/model.py b/supervisor/apps/model.py index 696c80eec58..9a8f82fa54f 100644 --- a/supervisor/apps/model.py +++ b/supervisor/apps/model.py @@ -89,7 +89,7 @@ CpuArch, ) from ..coresys import CoreSys -from ..docker.const import Capabilities +from ..docker.const import Capabilities, NetworkIsolationConfig from ..exceptions import ( AppFileReadError, AppNotSupportedArchitectureError, @@ -333,6 +333,14 @@ def host_network(self) -> bool: """Return True if app run on host network.""" return self.data[ATTR_HOST_NETWORK] + @property + def network_isolation(self) -> NetworkIsolationConfig | None: + """Return isolated physical network endpoint assigned to the app. + + Only installed apps can have one assigned (user setting). + """ + return None + @property def host_pid(self) -> bool: """Return True if app run on host PID namespace.""" diff --git a/supervisor/apps/utils.py b/supervisor/apps/utils.py index 11d29fec8f7..d8cbe11c433 100644 --- a/supervisor/apps/utils.py +++ b/supervisor/apps/utils.py @@ -67,8 +67,9 @@ def rating_security(app: AppModel) -> int: elif app.hassio_role == ROLE_ADMIN: rating += -2 - # Not secure Networking - if app.host_network: + # Not secure Networking. With an isolated physical network endpoint + # assigned the app does not run in the host network namespace. + if app.host_network and not app.network_isolation: rating += -1 # Insecure PID namespace diff --git a/supervisor/apps/validate.py b/supervisor/apps/validate.py index cc833594214..815466c4738 100644 --- a/supervisor/apps/validate.py +++ b/supervisor/apps/validate.py @@ -32,6 +32,7 @@ ATTR_DEVICETREE, ATTR_DISCOVERY, ATTR_DOCKER_API, + ATTR_DRIVER, ATTR_ENVIRONMENT, ATTR_FIELDS, ATTR_FULL_ACCESS, @@ -53,6 +54,8 @@ ATTR_INGRESS_STREAM, ATTR_INGRESS_TOKEN, ATTR_INIT, + ATTR_INTERFACE, + ATTR_IPV4, ATTR_JOURNALD, ATTR_KERNEL_MODULES, ATTR_LABELS, @@ -61,6 +64,7 @@ ATTR_MAP, ATTR_NAME, ATTR_NETWORK, + ATTR_NETWORK_ISOLATION, ATTR_OPTIONS, ATTR_PANEL_ADMIN, ATTR_PANEL_ICON, @@ -106,11 +110,12 @@ AppStartup, AppState, ) -from ..docker.const import Capabilities +from ..docker.const import Capabilities, ExternalNetworkDriver from ..validate import ( docker_image, docker_ports, docker_ports_description, + ipv4_address, network_port, token, uuid_match, @@ -520,6 +525,18 @@ def _migrate(config: dict[str, Any]): ) +SCHEMA_NETWORK_ISOLATION = vol.Schema( + { + vol.Required(ATTR_INTERFACE): str, + vol.Required(ATTR_IPV4): ipv4_address, + vol.Optional(ATTR_DRIVER, default=ExternalNetworkDriver.MACVLAN): vol.Coerce( + ExternalNetworkDriver + ), + }, + extra=vol.REMOVE_EXTRA, +) + + # pylint: disable=no-value-for-parameter SCHEMA_APP_USER = vol.Schema( { @@ -532,6 +549,7 @@ def _migrate(config: dict[str, Any]): vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_BOOT): vol.Coerce(AppBoot), vol.Optional(ATTR_NETWORK): docker_ports, + vol.Optional(ATTR_NETWORK_ISOLATION): SCHEMA_NETWORK_ISOLATION, vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str), vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(), diff --git a/supervisor/const.py b/supervisor/const.py index 4218841c5f7..ef6068291f0 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -194,6 +194,7 @@ ATTR_DOCKER_API = "docker_api" ATTR_DOCUMENTATION = "documentation" ATTR_DOMAINS = "domains" +ATTR_DRIVER = "driver" ATTR_DUPLICATE_LOG_FILE = "duplicate_log_file" ATTR_ENABLE = "enable" ATTR_ENABLE_IPV6 = "enable_ipv6" @@ -287,6 +288,7 @@ ATTR_NAMESERVERS = "nameservers" ATTR_NETWORK = "network" ATTR_NETWORK_DESCRIPTION = "network_description" +ATTR_NETWORK_ISOLATION = "network_isolation" ATTR_NETWORK_RX = "network_rx" ATTR_NETWORK_TX = "network_tx" ATTR_OBSERVER = "observer" diff --git a/supervisor/docker/app.py b/supervisor/docker/app.py index a9a2fca846c..f8a4c3e2ce5 100644 --- a/supervisor/docker/app.py +++ b/supervisor/docker/app.py @@ -35,12 +35,19 @@ DockerJobError, DockerNotFound, HardwareNotFound, + HostNetworkNotFound, ) from ..hardware.const import PolicyGroup from ..hardware.data import Device from ..jobs.const import JobConcurrency, JobCondition from ..jobs.decorator import Job -from ..resolution.const import CGROUP_V2_VERSION, ContextType, IssueType, SuggestionType +from ..resolution.const import ( + CGROUP_V2_VERSION, + ContextType, + IssueType, + SuggestionType, + UnhealthyReason, +) from ..utils.sentry import async_capture_exception from .const import ( ADDON_BUILDER_IMAGE, @@ -63,11 +70,13 @@ PATH_SSL, Capabilities, DockerMount, + ExtraNetworkEndpoint, MountBindOptions, MountType, PropagationMode, Ulimit, ) +from .external_network import EXTERNAL_NETWORK_GW_PRIORITY, DockerExternalNetworks from .interface import DockerInterface if TYPE_CHECKING: @@ -102,18 +111,21 @@ def image(self) -> str | None: @property def ip_address(self) -> IPv4Address: """Return IP address of this container.""" + # Extract IP-Address from internal network endpoint. With network + # isolation a host_network app runs attached to the internal network, + # so this is the address Supervisor and other containers can reach. + if self._meta: + try: + if ip := self._meta["NetworkSettings"]["Networks"]["hassio"][ + "IPAddress" + ]: + return IPv4Address(ip) + except KeyError, TypeError, ValueError: + pass + if self.app.host_network: return self.sys_docker.network.gateway - if not self._meta: - return NO_ADDDRESS - - # Extract IP-Address - try: - return IPv4Address( - self._meta["NetworkSettings"]["Networks"]["hassio"]["IPAddress"] - ) - except KeyError, TypeError, ValueError: - return NO_ADDDRESS + return NO_ADDDRESS @property def timeout(self) -> int: @@ -271,7 +283,7 @@ def network_mapping(self) -> dict[str, IPv4Address]: @property def network_mode(self) -> Literal["host"] | None: """Return network mode for app.""" - if self.app.host_network: + if self.app.host_network and not self.app.network_isolation: return "host" return None @@ -574,6 +586,47 @@ async def run(self) -> None: # Don't set a hostname if no separate UTS namespace is used hostname = None if self.uts_mode else self.app.hostname + network_mode = self.network_mode + extra_networks: list[ExtraNetworkEndpoint] | None = None + if config := self.app.network_isolation: + try: + network_name = await self.sys_docker.external_networks.ensure(config) + except (HostNetworkNotFound, DockerError) as err: + self.sys_resolution.create_issue( + IssueType.NETWORK_ISOLATION_FAILED, + ContextType.ADDON, + reference=self.app.slug, + ) + # The boot gate for unprotected Docker gateways does not apply + # to isolated apps, so it must not be bypassed by the fallback + if ( + UnhealthyReason.DOCKER_GATEWAY_UNPROTECTED + in self.sys_resolution.unhealthy + ): + raise DockerError( + f"Can't set up isolated network on {config.interface} for " + f"app {self.app.slug} and host network fallback is blocked " + f"by inactive gateway firewall rules: {err}", + _LOGGER.error, + ) from err + _LOGGER.warning( + "Can't set up isolated network on %s for app %s, " + "falling back to host network: %s", + config.interface, + self.app.slug, + err, + ) + network_mode = "host" + else: + extra_networks = [ + ExtraNetworkEndpoint( + network=network_name, + ipv4=config.ipv4, + mac=DockerExternalNetworks.mac_from_ip(config.ipv4), + gw_priority=EXTERNAL_NETWORK_GW_PRIORITY, + ) + ] + # Create & Run container try: await self._run( @@ -583,7 +636,8 @@ async def run(self) -> None: detach=True, init=self.app.default_init, stdin_open=self.app.with_stdin, - network_mode=self.network_mode, + network_mode=network_mode, + extra_networks=extra_networks, pid_mode=self.pid_mode, uts_mode=self.uts_mode, ports=self.ports, @@ -609,6 +663,14 @@ async def run(self) -> None: _LOGGER.info("Starting Docker app %s with version %s", self.image, self.version) + # Isolated networking is active, clear any earlier fallback issue + if extra_networks and ( + issue := self.sys_resolution.get_issue_if_present( + self.app.network_isolation_failed_issue + ) + ): + self.sys_resolution.dismiss_issue(issue) + # Write data to DNS server try: await self.sys_plugins.dns.add_host( diff --git a/supervisor/docker/const.py b/supervisor/docker/const.py index 79f015cece1..5fc1ca0e247 100644 --- a/supervisor/docker/const.py +++ b/supervisor/docker/const.py @@ -4,12 +4,13 @@ from dataclasses import dataclass from enum import StrEnum +from ipaddress import IPv4Address from pathlib import PurePath import re import signal from typing import Any -from ..const import MACHINE_ID +from ..const import ATTR_DRIVER, ATTR_INTERFACE, ATTR_IPV4, MACHINE_ID RE_RETRYING_DOWNLOAD_STATUS = re.compile(r"Retrying in \d+ seconds?") @@ -148,6 +149,49 @@ def to_dict(self) -> dict[str, str | int]: } +class ExternalNetworkDriver(StrEnum): + """Docker network driver bridging containers to a physical interface.""" + + MACVLAN = "macvlan" + IPVLAN = "ipvlan" + + +@dataclass(slots=True, frozen=True) +class NetworkIsolationConfig: + """User-assigned isolated physical network endpoint for an app.""" + + driver: ExternalNetworkDriver + interface: str + ipv4: IPv4Address + + @classmethod + def from_dict(cls, data: dict[str, str]) -> NetworkIsolationConfig: + """Create instance from dictionary representation.""" + return cls( + driver=ExternalNetworkDriver(data[ATTR_DRIVER]), + interface=data[ATTR_INTERFACE], + ipv4=IPv4Address(data[ATTR_IPV4]), + ) + + def to_dict(self) -> dict[str, str]: + """To dictionary representation.""" + return { + ATTR_DRIVER: self.driver.value, + ATTR_INTERFACE: self.interface, + ATTR_IPV4: str(self.ipv4), + } + + +@dataclass(slots=True, frozen=True) +class ExtraNetworkEndpoint: + """Additional network endpoint connected to a container before start.""" + + network: str + ipv4: IPv4Address + mac: str | None = None + gw_priority: int = 0 + + ENV_CORE_API_SOCKET = "SUPERVISOR_CORE_API_SOCKET" ENV_DUPLICATE_LOG_FILE = "HA_DUPLICATE_LOG_FILE" ENV_TIME = "TZ" diff --git a/supervisor/docker/external_network.py b/supervisor/docker/external_network.py new file mode 100644 index 00000000000..cb541c72c00 --- /dev/null +++ b/supervisor/docker/external_network.py @@ -0,0 +1,301 @@ +"""External physical network management for app network isolation. + +Apps with host networking can be assigned an isolated endpoint on a physical +host interface instead (macvlan/ipvlan Docker networks). The Docker engine +allows only one such network per parent interface, so networks are a shared +resource managed here and referenced by the apps using them. +""" + +from contextlib import suppress +from http import HTTPStatus +from ipaddress import IPv4Address, IPv4Interface, IPv4Network +import logging +from typing import Any, Final + +import aiodocker +from awesomeversion import AwesomeVersion, AwesomeVersionCompareException + +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import DockerError +from ..host.configuration import Interface +from ..host.const import InterfaceType +from .const import LABEL_MANAGED, ExtraNetworkEndpoint, NetworkIsolationConfig + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +# GwPriority on endpoints requires Docker API 1.48 +MIN_EXTERNAL_NETWORK_DOCKER: Final = AwesomeVersion("28.0.0") + +# Make the physical network win the default route over the internal +# NAT-ed hassio network (which uses the default priority of 0). +EXTERNAL_NETWORK_GW_PRIORITY: Final = 100 + +DOCKER_PARENT_INTERFACE: Final = "parent" +DOCKER_ENDPOINT_SYSCTLS: Final = "com.docker.network.endpoint.sysctls" + +# IPv6 addressing on the physical network comes from SLAAC, not Docker IPAM. +# Accept router advertisements even with IP forwarding enabled in the +# container (e.g. Thread border routers) and process RFC 4191 route +# information options (off by kernel default), so routes announced by border +# routers on the local network reach the container. +EXTERNAL_NETWORK_SYSCTLS: Final = ( + "net.ipv6.conf.IFNAME.accept_ra=2," + "net.ipv6.conf.IFNAME.accept_ra_rt_info_max_plen=64" +) + + +class DockerExternalNetworks(CoreSysAttributes): + """Manage Docker networks attached to physical host interfaces.""" + + def __init__(self, coresys: CoreSys): + """Initialize external networks manager.""" + self.coresys: CoreSys = coresys + + @property + def available(self) -> bool: + """Return True if the Docker engine supports external networks.""" + try: + return self.sys_docker.info.version >= MIN_EXTERNAL_NETWORK_DOCKER + except AwesomeVersionCompareException: + return False + + @staticmethod + def network_name(config: NetworkIsolationConfig) -> str: + """Return the Docker network name for an isolation config.""" + return f"hassio-{config.driver}-{config.interface}" + + @staticmethod + def mac_from_ip(ipv4: IPv4Address) -> str: + """Return a stable, locally administered MAC address for an IPv4. + + Same derivation Docker used for macvlan endpoints before engine 28 + switched to random MACs per endpoint creation. Pinning it keeps the + app's MAC stable across container recreations so users can rely on + it in router/firewall rules. + """ + return "02:42:" + ":".join(f"{octet:02x}" for octet in ipv4.packed) + + @staticmethod + def capable_interface(interface: Interface) -> bool: + """Return True if a host interface can host an external network.""" + return ( + interface.type == InterfaceType.ETHERNET + and interface.connected + and DockerExternalNetworks.interface_subnet(interface) is not None + ) + + @staticmethod + def interface_subnet(interface: Interface) -> IPv4Network | None: + """Return the IPv4 subnet of a host interface, if any.""" + if not interface.ipv4: + return None + for address in interface.ipv4.address: + if isinstance(address, IPv4Interface): + return address.network + return None + + @staticmethod + def _interface_ip(interface: Interface) -> IPv4Address | None: + """Return the host's own IPv4 address on the interface, if any.""" + if not interface.ipv4: + return None + for address in interface.ipv4.address: + if isinstance(address, IPv4Interface): + return address.ip + return None + + def _network_params( + self, config: NetworkIsolationConfig, interface: Interface + ) -> dict[str, Any]: + """Return Docker network create parameters for an isolation config.""" + subnet = self.interface_subnet(interface) + if subnet is None: + raise DockerError( + f"Host interface {interface.name} has no IPv4 subnet", _LOGGER.error + ) + + ipam_config: dict[str, Any] = {"Subnet": str(subnet)} + if interface.ipv4 and isinstance(interface.ipv4.gateway, IPv4Address): + ipam_config["Gateway"] = str(interface.ipv4.gateway) + if host_ip := self._interface_ip(interface): + # Reserve the host's own address so Docker IPAM can never assign it + ipam_config["AuxiliaryAddresses"] = {"host": str(host_ip)} + + return { + "Name": self.network_name(config), + "Driver": config.driver.value, + # No IPv6 subnet: the engine allocates a ULA to satisfy the + # driver, real IPv6 addressing comes from SLAAC on the LAN + "IPAM": {"Driver": "default", "Config": [ipam_config]}, + "EnableIPv6": True, + "Options": {DOCKER_PARENT_INTERFACE: interface.name}, + "Labels": {LABEL_MANAGED: ""}, + } + + @staticmethod + def _network_matches(meta: dict[str, Any], params: dict[str, Any]) -> bool: + """Return True if an existing network matches the wanted parameters.""" + if meta.get("Driver") != params["Driver"]: + return False + if ( + meta.get("Options", {}).get(DOCKER_PARENT_INTERFACE) + != params["Options"][DOCKER_PARENT_INTERFACE] + ): + return False + if meta.get("EnableIPv6", False) != params["EnableIPv6"]: + return False + + # Find the IPv4 entry; the engine may list its allocated IPv6 ULA too + wanted_ipam: dict[str, Any] = params["IPAM"]["Config"][0] + current_ipam: dict[str, Any] | None = next( + ( + entry + for entry in meta.get("IPAM", {}).get("Config") or [] + if entry.get("Subnet") == wanted_ipam["Subnet"] + ), + None, + ) + return current_ipam is not None and current_ipam.get( + "Gateway" + ) == wanted_ipam.get("Gateway") + + async def ensure(self, config: NetworkIsolationConfig) -> str: + """Ensure the Docker network for an isolation config exists. + + Returns the network name. Raises HostNetworkNotFound if the parent + interface is gone and DockerError if the network cannot be created + or drifted from the host interface configuration while still in use. + """ + interface = self.sys_host.network.get(config.interface) + if not self.capable_interface(interface): + raise DockerError( + f"Host interface {config.interface} must be a connected ethernet " + "interface with IPv4 to host an external network", + _LOGGER.error, + ) + + params = self._network_params(config, interface) + name: str = params["Name"] + + try: + network = await self.sys_docker.docker.networks.get(name) + meta = await network.show() + except aiodocker.DockerError as err: + if err.status != HTTPStatus.NOT_FOUND: + raise DockerError( + f"Can't inspect external network {name}: {err}", _LOGGER.error + ) from err + await self._create(params) + return name + + if self._network_matches(meta, params): + return name + + # The host interface configuration changed since the network was + # created. Recreate it unless containers are still attached. + if meta.get("Containers"): + raise DockerError( + f"External network {name} no longer matches the configuration of " + f"host interface {config.interface} but still has containers " + "attached. Stop the apps using it to recreate the network.", + _LOGGER.error, + ) + + _LOGGER.info( + "Recreating external network %s for changed host interface %s", + name, + config.interface, + ) + try: + await network.delete() + except aiodocker.DockerError as err: + raise DockerError( + f"Can't remove outdated external network {name}: {err}", _LOGGER.error + ) from err + await self._create(params) + return name + + async def _create(self, params: dict[str, Any]) -> None: + """Create an external network.""" + _LOGGER.info( + "Creating external network %s on host interface %s", + params["Name"], + params["Options"][DOCKER_PARENT_INTERFACE], + ) + try: + await self.sys_docker.docker.networks.create(params) + except aiodocker.DockerError as err: + raise DockerError( + f"Can't create external network {params['Name']}: {err}", _LOGGER.error + ) from err + + async def connect_container( + self, container_id: str, name: str | None, endpoint: ExtraNetworkEndpoint + ) -> None: + """Connect a container to an external network endpoint.""" + try: + network = await self.sys_docker.docker.networks.get(endpoint.network) + meta = await network.show() + except aiodocker.DockerError as err: + raise DockerError( + f"Can't get external network {endpoint.network}: {err}", _LOGGER.error + ) from err + + # Clean up stale endpoint of a previous container with the same name + # Fix: https://github.com/moby/moby/issues/23302 + if name and name in ( + val.get("Name") for val in meta.get("Containers", {}).values() + ): + with suppress(aiodocker.DockerError): + await network.disconnect({"Container": name, "Force": True}) + + endpoint_config: dict[str, Any] = { + "IPAMConfig": {"IPv4Address": str(endpoint.ipv4)}, + "GwPriority": endpoint.gw_priority, + "DriverOpts": {DOCKER_ENDPOINT_SYSCTLS: EXTERNAL_NETWORK_SYSCTLS}, + } + if endpoint.mac: + endpoint_config["MacAddress"] = endpoint.mac + try: + await network.connect( + {"Container": container_id, "EndpointConfig": endpoint_config} + ) + except aiodocker.DockerError as err: + raise DockerError( + f"Can't connect {name or container_id} to external network " + f"{endpoint.network}: {err}", + _LOGGER.error, + ) from err + + async def gc(self) -> None: + """Remove external networks no installed app references. + + Networks with containers still attached are skipped; removal is + retried on the next garbage collection. + """ + used = { + self.network_name(config) + for app in self.sys_apps.installed + if (config := app.network_isolation) + } + + try: + networks = await self.sys_docker.docker.networks.list( + filters={"label": LABEL_MANAGED} + ) + except aiodocker.DockerError as err: + raise DockerError( + f"Can't list external networks: {err}", _LOGGER.warning + ) from err + + for meta in networks: + name = meta.get("Name", "") + if name in used or not name.startswith("hassio-"): + continue + + _LOGGER.info("Removing unused external network %s", name) + try: + network = await self.sys_docker.docker.networks.get(name) + await network.delete() + except aiodocker.DockerError as err: + _LOGGER.warning("Can't remove external network %s: %s", name, err) diff --git a/supervisor/docker/manager.py b/supervisor/docker/manager.py index 19bb46cb939..1254474a5b1 100644 --- a/supervisor/docker/manager.py +++ b/supervisor/docker/manager.py @@ -52,10 +52,12 @@ LABEL_MANAGED, Capabilities, DockerMount, + ExtraNetworkEndpoint, MountType, RestartPolicy, Ulimit, ) +from .external_network import DockerExternalNetworks from .manifest import RegistryManifestFetcher from .monitor import DockerMonitor from .network import DockerNetwork @@ -279,6 +281,9 @@ def __init__(self, coresys: CoreSys): ) self._network: DockerNetwork | None = None + self._external_networks: DockerExternalNetworks = DockerExternalNetworks( + coresys + ) self._info: DockerInfo | None = None self.config: DockerConfig = DockerConfig() self._monitor: DockerMonitor = DockerMonitor(coresys, self.docker) @@ -302,6 +307,11 @@ def network(self) -> DockerNetwork: raise RuntimeError("Docker Network not initialized!") return self._network + @property + def external_networks(self) -> DockerExternalNetworks: + """Get external networks manager.""" + return self._external_networks + @property def images(self) -> DockerImages: """Return API images.""" @@ -468,6 +478,7 @@ async def _run( network_mode: str | None = None, networking_config: dict[str, Any] | None = None, ipv4: IPv4Address | None = None, + extra_networks: list[ExtraNetworkEndpoint] | None = None, skip_cidfile: bool = False, **kwargs, ) -> DockerContainer: @@ -560,6 +571,14 @@ def setup_cidfile(cidfile_path: Path) -> None: else: with suppress(DockerError): await self.network.detach_default_bridge(container.id, name) + + # Connect additional (external) network endpoints. Unlike the + # internal network these are essential for the container's + # purpose, so failures abort the start. + for endpoint in extra_networks or []: + await self.external_networks.connect_container( + container.id, name, endpoint + ) else: try: host_network = await self.docker.networks.get(DOCKER_NETWORK_HOST) diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 7c2382fe120..606b2632a19 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -516,6 +516,45 @@ def __init__( super().__init__(None, logger) +class AppNetworkIsolationNotSupportedError(AppAPIError): + """Raise when network isolation is assigned to an app without host networking.""" + + error_key = "addon_network_isolation_not_supported_error" + message_template = ( + "Network isolation can only be assigned to app {addon} if it uses " + "host networking" + ) + + +class AppNetworkIsolationDockerVersionError(AppAPIError): + """Raise when the Docker engine is too old for network isolation.""" + + error_key = "addon_network_isolation_docker_version_error" + message_template = ( + "Network isolation for app {addon} requires Docker {minimum_version} or newer" + ) + + +class AppNetworkIsolationInvalidInterfaceError(AppAPIError): + """Raise when the host interface cannot be used for network isolation.""" + + error_key = "addon_network_isolation_invalid_interface_error" + message_template = ( + "Interface {interface} cannot be used for network isolation of " + "app {addon}: {reason}" + ) + + +class AppNetworkIsolationInvalidAddressError(AppAPIError): + """Raise when the IP address cannot be used for network isolation.""" + + error_key = "addon_network_isolation_invalid_address_error" + message_template = ( + "IP address {address} cannot be used for network isolation of " + "app {addon}: {reason}" + ) + + class AppNotRunningError(AppsError, APIError): """Raise when an app is not running.""" diff --git a/supervisor/resolution/checks/app_network_isolation.py b/supervisor/resolution/checks/app_network_isolation.py new file mode 100644 index 00000000000..66cb18904ae --- /dev/null +++ b/supervisor/resolution/checks/app_network_isolation.py @@ -0,0 +1,75 @@ +"""Check that assigned app network isolation endpoints are still valid.""" + +import logging + +from ...const import CoreState +from ...coresys import CoreSys +from ...docker.const import NetworkIsolationConfig +from ...docker.external_network import DockerExternalNetworks +from ...exceptions import HostNetworkNotFound +from ..const import ContextType, IssueType +from .base import CheckBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> CheckBase: + """Check setup function.""" + return CheckAppNetworkIsolation(coresys) + + +class CheckAppNetworkIsolation(CheckBase): + """CheckAppNetworkIsolation class for check.""" + + async def run_check(self) -> None: + """Run check if not affected by issue.""" + for app in self.sys_apps.installed: + if (config := app.network_isolation) and self._endpoint_broken(config): + self.sys_resolution.create_issue( + IssueType.NETWORK_ISOLATION_FAILED, + ContextType.ADDON, + reference=app.slug, + ) + + async def approve_check(self, reference: str | None = None) -> bool: + """Approve check if it is affected by issue.""" + if not reference: + return False + + # Uninstalled + if not (app := self.sys_apps.get_local_only(reference)): + return False + + # Isolation no longer assigned + if not (config := app.network_isolation): + return False + + return self._endpoint_broken(config) + + def _endpoint_broken(self, config: NetworkIsolationConfig) -> bool: + """Return True if the endpoint no longer matches the host interface.""" + try: + interface = self.sys_host.network.get(config.interface) + except HostNetworkNotFound: + return True + + if not DockerExternalNetworks.capable_interface(interface): + return True + + subnet = DockerExternalNetworks.interface_subnet(interface) + return subnet is None or config.ipv4 not in subnet + + @property + def issue(self) -> IssueType: + """Return a IssueType enum.""" + return IssueType.NETWORK_ISOLATION_FAILED + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.ADDON + + @property + def states(self) -> list[CoreState]: + """Return a list of valid states when this check can run.""" + return [CoreState.RUNNING, CoreState.STARTUP] diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index afcb99916fd..129a138d231 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -100,6 +100,7 @@ class IssueType(StrEnum): MISSING_IMAGE = "missing_image" MOUNT_FAILED = "mount_failed" MULTIPLE_DATA_DISKS = "multiple_data_disks" + NETWORK_ISOLATION_FAILED = "network_isolation_failed" NO_CURRENT_BACKUP = "no_current_backup" NTP_SYNC_FAILED = "ntp_sync_failed" PWNED = "pwned" diff --git a/supervisor/validate.py b/supervisor/validate.py index d608212b286..3d8ae4f0df9 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -66,6 +66,16 @@ # pylint: disable=invalid-name network_port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) wait_boot = vol.All(vol.Coerce(int), vol.Range(min=1, max=60)) + + +def ipv4_address(value: str) -> str: + """Validate an IPv4 address and normalize it to its string form.""" + try: + return str(ipaddress.IPv4Address(value)) + except (ValueError, TypeError) as err: + raise vol.Invalid(f"Invalid IPv4 address: {value}") from err + + # Path component pattern for Docker image names (supports {arch}/{machine} templates) _RE_IMAGE_PATH_COMPONENT = re.compile(r"^[a-z0-9{][a-z0-9.\-_{}]*$") diff --git a/tests/api/test_apps_network_isolation.py b/tests/api/test_apps_network_isolation.py new file mode 100644 index 00000000000..b1faeadf110 --- /dev/null +++ b/tests/api/test_apps_network_isolation.py @@ -0,0 +1,204 @@ +"""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"] diff --git a/tests/apps/test_app.py b/tests/apps/test_app.py index 84f8cfde174..2abc9eb64d4 100644 --- a/tests/apps/test_app.py +++ b/tests/apps/test_app.py @@ -18,7 +18,14 @@ from supervisor.apps.app import App from supervisor.apps.const import AppBackupMode from supervisor.apps.model import AppModel -from supervisor.const import ATTR_ADVANCED, ATTR_LOCATION, AppBoot, AppState, BusEvent +from supervisor.const import ( + ATTR_ADVANCED, + ATTR_LOCATION, + ATTR_NETWORK_ISOLATION, + AppBoot, + AppState, + BusEvent, +) from supervisor.coresys import CoreSys from supervisor.docker.app import DockerApp from supervisor.docker.const import ContainerState @@ -455,7 +462,12 @@ async def test_listeners_removed_on_uninstall( listener in coresys.bus._listeners[BusEvent.DOCKER_CONTAINER_STATE_CHANGE] ) - with patch.object(App, "persist", new=PropertyMock(return_value=MagicMock())): + # No network isolation assigned (a mock would parse as truthy config) + persist_data = MagicMock() + persist_data.get.side_effect = lambda key, default=None: ( + default if key == ATTR_NETWORK_ISOLATION else MagicMock() + ) + with patch.object(App, "persist", new=PropertyMock(return_value=persist_data)): await coresys.apps.uninstall(TEST_ADDON_SLUG) for listener in listeners: diff --git a/tests/apps/test_network_isolation.py b/tests/apps/test_network_isolation.py new file mode 100644 index 00000000000..3ada685a19b --- /dev/null +++ b/tests/apps/test_network_isolation.py @@ -0,0 +1,86 @@ +"""Test app network isolation setting.""" + +from ipaddress import IPv4Address + +import pytest +import voluptuous as vol + +from supervisor.apps import validate as vd +from supervisor.apps.app import App +from supervisor.apps.utils import rating_security +from supervisor.const import ( + ATTR_DRIVER, + ATTR_HOST_NETWORK, + ATTR_INTERFACE, + ATTR_IPV4, + ATTR_NETWORK_ISOLATION, +) +from supervisor.coresys import CoreSys +from supervisor.docker.const import ExternalNetworkDriver, NetworkIsolationConfig + +from ..const import TEST_INTERFACE_ETH_NAME + +TEST_CONFIG = NetworkIsolationConfig( + driver=ExternalNetworkDriver.MACVLAN, + interface=TEST_INTERFACE_ETH_NAME, + ipv4=IPv4Address("192.168.2.50"), +) + + +def test_schema_defaults_to_macvlan(): + """Test network isolation schema applies macvlan driver default.""" + data = vd.SCHEMA_NETWORK_ISOLATION( + {ATTR_INTERFACE: TEST_INTERFACE_ETH_NAME, ATTR_IPV4: "192.168.2.50"} + ) + + assert data[ATTR_DRIVER] == ExternalNetworkDriver.MACVLAN + assert data[ATTR_IPV4] == "192.168.2.50" + + +@pytest.mark.parametrize( + "config", + [ + {ATTR_INTERFACE: TEST_INTERFACE_ETH_NAME, ATTR_IPV4: "not-an-ip"}, + {ATTR_INTERFACE: TEST_INTERFACE_ETH_NAME, ATTR_IPV4: "fd00::1"}, + {ATTR_INTERFACE: TEST_INTERFACE_ETH_NAME}, + {ATTR_IPV4: "192.168.2.50"}, + { + ATTR_INTERFACE: TEST_INTERFACE_ETH_NAME, + ATTR_IPV4: "192.168.2.50", + ATTR_DRIVER: "bridge", + }, + ], +) +def test_schema_invalid(config: dict[str, str]): + """Test invalid network isolation configurations are rejected.""" + with pytest.raises(vol.Invalid): + vd.SCHEMA_NETWORK_ISOLATION(config) + + +async def test_network_isolation_persistence(coresys: CoreSys, install_app_ssh: App): + """Test network isolation setting round-trips through persisted data.""" + assert install_app_ssh.network_isolation is None + assert install_app_ssh.external_mac_address is None + + install_app_ssh.network_isolation = TEST_CONFIG + assert install_app_ssh.persist[ATTR_NETWORK_ISOLATION] == { + ATTR_DRIVER: "macvlan", + ATTR_INTERFACE: TEST_INTERFACE_ETH_NAME, + ATTR_IPV4: "192.168.2.50", + } + assert install_app_ssh.network_isolation == TEST_CONFIG + # MAC is derived from the static IP, known without a running container + assert install_app_ssh.external_mac_address == "02:42:c0:a8:02:32" + + install_app_ssh.network_isolation = None + assert install_app_ssh.network_isolation is None + assert ATTR_NETWORK_ISOLATION not in install_app_ssh.persist + + +async def test_rating_security_with_isolation(coresys: CoreSys, install_app_ssh: App): + """Test host network rating penalty is not applied with isolation.""" + install_app_ssh.data[ATTR_HOST_NETWORK] = True + host_network_rating = rating_security(install_app_ssh) + + install_app_ssh.network_isolation = TEST_CONFIG + assert rating_security(install_app_ssh) == host_network_rating + 1 diff --git a/tests/conftest.py b/tests/conftest.py index 8c343c4cc2c..6032fbadf3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -543,6 +543,7 @@ async def coresys( coresys_obj._docker = docker coresys_obj.docker.coresys = coresys_obj docker.monitor.coresys = coresys_obj + docker.external_networks.coresys = coresys_obj # Set internet state coresys_obj.supervisor._connectivity = True diff --git a/tests/docker/test_app.py b/tests/docker/test_app.py index ccde5c0d74d..56df394826f 100644 --- a/tests/docker/test_app.py +++ b/tests/docker/test_app.py @@ -14,22 +14,34 @@ from supervisor.apps.app import App from supervisor.apps.model import Data from supervisor.apps.options import AppOptions -from supervisor.const import BusEvent +from supervisor.const import ATTR_NETWORK_ISOLATION, BusEvent from supervisor.coresys import CoreSys from supervisor.dbus.agent.cgroup import CGroup from supervisor.docker.app import DockerApp from supervisor.docker.const import ( DockerMount, + ExternalNetworkDriver, + ExtraNetworkEndpoint, MountBindOptions, MountType, + NetworkIsolationConfig, PropagationMode, ) +from supervisor.docker.external_network import ( + EXTERNAL_NETWORK_GW_PRIORITY, + DockerExternalNetworks, +) from supervisor.docker.manager import DockerAPI -from supervisor.exceptions import CoreDNSError, DockerNotFound +from supervisor.exceptions import CoreDNSError, DockerError, DockerNotFound from supervisor.hardware.data import Device from supervisor.os.manager import OSManager from supervisor.plugins.dns import PluginDns -from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.const import ( + ContextType, + IssueType, + SuggestionType, + UnhealthyReason, +) from supervisor.resolution.data import Issue, Suggestion from ..common import load_json_fixture @@ -52,6 +64,12 @@ def fixture_addonsdata_user() -> dict[str, Data]: """Mock AppsData.user.""" with patch("supervisor.apps.data.AppsData.user", new_callable=PropertyMock) as mock: mock.return_value = MagicMock() + # No network isolation assigned (a mock would parse as truthy config) + persist = mock.return_value.__getitem__.return_value + mock_value = MagicMock() + persist.get.side_effect = lambda key, default=None: ( + default if key == ATTR_NETWORK_ISOLATION else mock_value + ) yield mock @@ -572,3 +590,123 @@ async def test_ulimits_integration(coresys: CoreSys, install_app_ssh: App): assert core_limit is not None assert core_limit.soft == 0 assert core_limit.hard == 0 + + +NETWORK_ISOLATION_CONFIG = NetworkIsolationConfig( + driver=ExternalNetworkDriver.MACVLAN, + interface="eth0", + ipv4=IPv4Address("192.168.2.50"), +) + + +@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data") +async def test_app_run_with_network_isolation( + coresys: CoreSys, addonsdata_system: dict[str, Data] +): + """Test app runs in bridge mode with an extra external network endpoint.""" + await coresys.dbus.timedate.connect(coresys.dbus.bus) + docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json") + + with ( + patch.object(DockerApp, "stop"), + patch.object( + AppOptions, "validate", new=PropertyMock(return_value=lambda _: None) + ), + patch.object(PluginDns, "add_host"), + patch.object( + App, + "network_isolation", + new=PropertyMock(return_value=NETWORK_ISOLATION_CONFIG), + ), + patch.object( + DockerExternalNetworks, + "ensure", + return_value="hassio-macvlan-eth0", + ) as ensure, + patch.object(DockerExternalNetworks, "connect_container") as connect, + ): + await docker_app.run() + + ensure.assert_called_once_with(NETWORK_ISOLATION_CONFIG) + connect.assert_called_once() + assert connect.call_args.args[2] == ExtraNetworkEndpoint( + network="hassio-macvlan-eth0", + ipv4=IPv4Address("192.168.2.50"), + mac="02:42:c0:a8:02:32", + gw_priority=EXTERNAL_NETWORK_GW_PRIORITY, + ) + create_config = coresys.docker.containers.create.call_args.args[0] + assert create_config["HostConfig"]["NetworkMode"] == "default" + + +@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data") +async def test_app_run_network_isolation_fallback( + coresys: CoreSys, addonsdata_system: dict[str, Data] +): + """Test app falls back to host network if endpoint setup fails.""" + await coresys.dbus.timedate.connect(coresys.dbus.bus) + docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json") + + with ( + patch.object(DockerApp, "stop"), + patch.object( + AppOptions, "validate", new=PropertyMock(return_value=lambda _: None) + ), + patch.object(PluginDns, "add_host"), + patch.object( + App, + "network_isolation", + new=PropertyMock(return_value=NETWORK_ISOLATION_CONFIG), + ), + patch.object( + DockerExternalNetworks, + "ensure", + side_effect=DockerError("fail"), + ), + patch.object(DockerExternalNetworks, "connect_container") as connect, + ): + await docker_app.run() + + connect.assert_not_called() + create_config = coresys.docker.containers.create.call_args.args[0] + assert create_config["HostConfig"]["NetworkMode"] == "host" + assert ( + Issue( + IssueType.NETWORK_ISOLATION_FAILED, + ContextType.ADDON, + reference="test_addon", + ) + in coresys.resolution.issues + ) + + +@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data") +async def test_app_run_network_isolation_fallback_blocked( + coresys: CoreSys, addonsdata_system: dict[str, Data] +): + """Test host network fallback is blocked on unprotected Docker gateway.""" + await coresys.dbus.timedate.connect(coresys.dbus.bus) + coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER_GATEWAY_UNPROTECTED) + docker_app = get_docker_app(coresys, addonsdata_system, "basic-app-config.json") + coresys.docker.containers.create.reset_mock() + + with ( + patch.object(DockerApp, "stop"), + patch.object( + AppOptions, "validate", new=PropertyMock(return_value=lambda _: None) + ), + patch.object( + App, + "network_isolation", + new=PropertyMock(return_value=NETWORK_ISOLATION_CONFIG), + ), + patch.object( + DockerExternalNetworks, + "ensure", + side_effect=DockerError("fail"), + ), + pytest.raises(DockerError), + ): + await docker_app.run() + + coresys.docker.containers.create.assert_not_called() diff --git a/tests/docker/test_external_network.py b/tests/docker/test_external_network.py new file mode 100644 index 00000000000..7cad41a5a6a --- /dev/null +++ b/tests/docker/test_external_network.py @@ -0,0 +1,251 @@ +"""Test external network manager.""" + +from http import HTTPStatus +from ipaddress import IPv4Address +from unittest.mock import MagicMock + +import aiodocker +from aiodocker.networks import DockerNetwork as AiodockerNetwork +import pytest + +from supervisor.coresys import CoreSys +from supervisor.docker.const import ( + ExternalNetworkDriver, + ExtraNetworkEndpoint, + NetworkIsolationConfig, +) +from supervisor.docker.external_network import DockerExternalNetworks +from supervisor.exceptions import DockerError, HostNetworkNotFound + +from ..const import TEST_INTERFACE_ETH_NAME + +TEST_CONFIG = NetworkIsolationConfig( + driver=ExternalNetworkDriver.MACVLAN, + interface=TEST_INTERFACE_ETH_NAME, + ipv4=IPv4Address("192.168.2.50"), +) + +TEST_NETWORK_NAME = f"hassio-macvlan-{TEST_INTERFACE_ETH_NAME}" + +# Derived from the eth0 mock of the NetworkManager D-Bus service +EXPECTED_NETWORK_PARAMS = { + "Name": TEST_NETWORK_NAME, + "Driver": "macvlan", + "IPAM": { + "Driver": "default", + "Config": [ + { + "Subnet": "192.168.2.0/24", + "Gateway": "192.168.2.1", + "AuxiliaryAddresses": {"host": "192.168.2.148"}, + } + ], + }, + "EnableIPv6": True, + "Options": {"parent": TEST_INTERFACE_ETH_NAME}, + "Labels": {"supervisor_managed": ""}, +} + +EXPECTED_ENDPOINT_SYSCTLS = { + "com.docker.network.endpoint.sysctls": ( + "net.ipv6.conf.IFNAME.accept_ra=2," + "net.ipv6.conf.IFNAME.accept_ra_rt_info_max_plen=64" + ) +} + + +async def test_ensure_creates_network(coresys: CoreSys): + """Test network is created from host interface configuration.""" + coresys.docker.docker.networks.reset_mock() + coresys.docker.docker.networks.get.side_effect = aiodocker.DockerError( + HTTPStatus.NOT_FOUND, {"message": "not found"} + ) + + name = await coresys.docker.external_networks.ensure(TEST_CONFIG) + + assert name == TEST_NETWORK_NAME + coresys.docker.docker.networks.create.assert_called_once_with( + EXPECTED_NETWORK_PARAMS + ) + + +async def test_ensure_keeps_matching_network(coresys: CoreSys): + """Test existing matching network is kept.""" + coresys.docker.docker.networks.reset_mock() + network = MagicMock(spec=AiodockerNetwork) + # The engine lists its auto-allocated IPv6 ULA in the IPAM config + network.show.return_value = EXPECTED_NETWORK_PARAMS | { + "IPAM": { + "Driver": "default", + "Config": [ + {"Subnet": "fd61:6c69:6e74:6f6e::/64"}, + *EXPECTED_NETWORK_PARAMS["IPAM"]["Config"], + ], + }, + "Containers": {}, + } + coresys.docker.docker.networks.get.return_value = network + + assert await coresys.docker.external_networks.ensure(TEST_CONFIG) == ( + TEST_NETWORK_NAME + ) + + coresys.docker.docker.networks.create.assert_not_called() + network.delete.assert_not_called() + + +async def test_ensure_recreates_drifted_network(coresys: CoreSys): + """Test network with outdated host interface configuration is recreated.""" + coresys.docker.docker.networks.reset_mock() + network = MagicMock(spec=AiodockerNetwork) + network.show.return_value = EXPECTED_NETWORK_PARAMS | { + "IPAM": { + "Driver": "default", + "Config": [{"Subnet": "10.1.0.0/24", "Gateway": "10.1.0.1"}], + }, + "Containers": {}, + } + coresys.docker.docker.networks.get.return_value = network + + assert await coresys.docker.external_networks.ensure(TEST_CONFIG) == ( + TEST_NETWORK_NAME + ) + + network.delete.assert_called_once() + coresys.docker.docker.networks.create.assert_called_once_with( + EXPECTED_NETWORK_PARAMS + ) + + +async def test_ensure_drifted_network_in_use(coresys: CoreSys): + """Test drifted network with attached containers raises.""" + coresys.docker.docker.networks.reset_mock() + network = MagicMock(spec=AiodockerNetwork) + network.show.return_value = EXPECTED_NETWORK_PARAMS | { + "IPAM": { + "Driver": "default", + "Config": [{"Subnet": "10.1.0.0/24", "Gateway": "10.1.0.1"}], + }, + "Containers": {"abc123": {"Name": "addon_other"}}, + } + coresys.docker.docker.networks.get.return_value = network + + with pytest.raises(DockerError): + await coresys.docker.external_networks.ensure(TEST_CONFIG) + + network.delete.assert_not_called() + coresys.docker.docker.networks.create.assert_not_called() + + +async def test_ensure_recreates_ipv4_only_network(coresys: CoreSys): + """Test network without IPv6 (pre-SLAAC support) is recreated.""" + coresys.docker.docker.networks.reset_mock() + network = MagicMock(spec=AiodockerNetwork) + network.show.return_value = EXPECTED_NETWORK_PARAMS | { + "EnableIPv6": False, + "Containers": {}, + } + coresys.docker.docker.networks.get.return_value = network + + assert await coresys.docker.external_networks.ensure(TEST_CONFIG) == ( + TEST_NETWORK_NAME + ) + + network.delete.assert_called_once() + coresys.docker.docker.networks.create.assert_called_once_with( + EXPECTED_NETWORK_PARAMS + ) + + +async def test_ensure_interface_missing(coresys: CoreSys): + """Test missing host interface raises.""" + with pytest.raises(HostNetworkNotFound): + await coresys.docker.external_networks.ensure( + NetworkIsolationConfig( + driver=ExternalNetworkDriver.MACVLAN, + interface="eth42", + ipv4=IPv4Address("192.168.2.50"), + ) + ) + + +async def test_connect_container(coresys: CoreSys): + """Test connecting a container to an external network endpoint.""" + coresys.docker.docker.networks.reset_mock() + network = MagicMock(spec=AiodockerNetwork) + network.show.return_value = EXPECTED_NETWORK_PARAMS | {"Containers": {}} + coresys.docker.docker.networks.get.return_value = network + + await coresys.docker.external_networks.connect_container( + "abc123", + "addon_test", + ExtraNetworkEndpoint( + network=TEST_NETWORK_NAME, + ipv4=IPv4Address("192.168.2.50"), + mac="02:42:c0:a8:02:32", + gw_priority=100, + ), + ) + + network.connect.assert_called_once_with( + { + "Container": "abc123", + "EndpointConfig": { + "IPAMConfig": {"IPv4Address": "192.168.2.50"}, + "GwPriority": 100, + "DriverOpts": EXPECTED_ENDPOINT_SYSCTLS, + "MacAddress": "02:42:c0:a8:02:32", + }, + } + ) + + +def test_mac_from_ip(): + """Test stable MAC address derivation from IPv4.""" + assert ( + DockerExternalNetworks.mac_from_ip(IPv4Address("192.168.2.50")) + == "02:42:c0:a8:02:32" + ) + assert ( + DockerExternalNetworks.mac_from_ip(IPv4Address("10.0.0.1")) + == "02:42:0a:00:00:01" + ) + + +async def test_connect_container_stale_endpoint_cleanup(coresys: CoreSys): + """Test stale endpoint of previous container with same name is removed.""" + coresys.docker.docker.networks.reset_mock() + network = MagicMock(spec=AiodockerNetwork) + network.show.return_value = EXPECTED_NETWORK_PARAMS | { + "Containers": {"old123": {"Name": "addon_test"}} + } + coresys.docker.docker.networks.get.return_value = network + + await coresys.docker.external_networks.connect_container( + "abc123", + "addon_test", + ExtraNetworkEndpoint( + network=TEST_NETWORK_NAME, ipv4=IPv4Address("192.168.2.50") + ), + ) + + network.disconnect.assert_called_once_with( + {"Container": "addon_test", "Force": True} + ) + network.connect.assert_called_once() + + +async def test_gc_removes_unreferenced_network(coresys: CoreSys): + """Test garbage collection removes networks without app references.""" + coresys.docker.docker.networks.reset_mock() + network = MagicMock(spec=AiodockerNetwork) + coresys.docker.docker.networks.list.return_value = [ + {"Name": TEST_NETWORK_NAME}, + {"Name": "some-user-network"}, + ] + coresys.docker.docker.networks.get.return_value = network + + await coresys.docker.external_networks.gc() + + coresys.docker.docker.networks.get.assert_called_once_with(TEST_NETWORK_NAME) + network.delete.assert_called_once() diff --git a/tests/resolution/check/test_check_app_network_isolation.py b/tests/resolution/check/test_check_app_network_isolation.py new file mode 100644 index 00000000000..c9550ba5181 --- /dev/null +++ b/tests/resolution/check/test_check_app_network_isolation.py @@ -0,0 +1,88 @@ +"""Test check for app network isolation endpoints.""" + +# pylint: disable=import-error,protected-access +from ipaddress import IPv4Address + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.docker.const import ExternalNetworkDriver, NetworkIsolationConfig +from supervisor.resolution.checks.app_network_isolation import CheckAppNetworkIsolation +from supervisor.resolution.const import ContextType, IssueType + +from tests.const import TEST_INTERFACE_ETH_NAME + + +class FakeApp: + """Fake App for tests.""" + + slug = "my_test" + is_installed = True + network_isolation: NetworkIsolationConfig | None = None + + +def _config(interface: str, address: str) -> NetworkIsolationConfig: + """Create an isolation config.""" + return NetworkIsolationConfig( + driver=ExternalNetworkDriver.MACVLAN, + interface=interface, + ipv4=IPv4Address(address), + ) + + +async def test_base(coresys: CoreSys): + """Test check basics.""" + check = CheckAppNetworkIsolation(coresys) + assert check.slug == "app_network_isolation" + assert check.enabled + + +async def test_check(coresys: CoreSys): + """Test check.""" + check = CheckAppNetworkIsolation(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + app = FakeApp() + coresys.apps.local[app.slug] = app + + # No isolation assigned + await check.run_check() + assert len(coresys.resolution.issues) == 0 + + # Valid endpoint on existing interface + app.network_isolation = _config(TEST_INTERFACE_ETH_NAME, "192.168.2.50") + await check.run_check() + assert len(coresys.resolution.issues) == 0 + + # Interface is gone + app.network_isolation = _config("eth42", "192.168.2.50") + await check.run_check() + + assert len(coresys.resolution.issues) == 1 + assert coresys.resolution.issues[0].type == IssueType.NETWORK_ISOLATION_FAILED + assert coresys.resolution.issues[0].context == ContextType.ADDON + assert coresys.resolution.issues[0].reference == app.slug + + +async def test_approve(coresys: CoreSys): + """Test check approval.""" + check = CheckAppNetworkIsolation(coresys) + await coresys.core.set_state(CoreState.RUNNING) + + app = FakeApp() + coresys.apps.local[app.slug] = app + + # Address no longer within the interface subnet + app.network_isolation = _config(TEST_INTERFACE_ETH_NAME, "10.0.0.5") + assert await check.approve_check(reference=app.slug) + + # Back to a valid configuration + app.network_isolation = _config(TEST_INTERFACE_ETH_NAME, "192.168.2.50") + assert not await check.approve_check(reference=app.slug) + + # Isolation removed + app.network_isolation = None + assert not await check.approve_check(reference=app.slug) + + # App uninstalled + coresys.apps.local.pop(app.slug) + assert not await check.approve_check(reference=app.slug)