feat: Add opt-in IPv6 for containers (#5879)

Configurable and w/ migrations between IPv4-Only and Dual-Stack

Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Stefan Agner <stefan@agner.ch>
This commit is contained in:
David Rapan 2025-06-12 11:32:24 +02:00 committed by GitHub
parent 52b24e177f
commit d5b5a328d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 376 additions and 84 deletions

View File

@ -8,7 +8,7 @@ from typing import Any
from aiohttp import hdrs, web from aiohttp import hdrs, web
from ..const import AddonState from ..const import SUPERVISOR_DOCKER_NAME, AddonState
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIAddonNotInstalled, HostNotSupportedError from ..exceptions import APIAddonNotInstalled, HostNotSupportedError
from ..utils.sentry import async_capture_exception from ..utils.sentry import async_capture_exception
@ -426,7 +426,7 @@ class RestAPI(CoreSysAttributes):
async def get_supervisor_logs(*args, **kwargs): async def get_supervisor_logs(*args, **kwargs):
try: try:
return await self._api_host.advanced_logs_handler( return await self._api_host.advanced_logs_handler(
*args, identifier="hassio_supervisor", **kwargs *args, identifier=SUPERVISOR_DOCKER_NAME, **kwargs
) )
except Exception as err: # pylint: disable=broad-exception-caught except Exception as err: # pylint: disable=broad-exception-caught
# Supervisor logs are critical, so catch everything, log the exception # Supervisor logs are critical, so catch everything, log the exception
@ -789,6 +789,7 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes( self.webapp.add_routes(
[ [
web.get("/docker/info", api_docker.info), web.get("/docker/info", api_docker.info),
web.post("/docker/options", api_docker.options),
web.get("/docker/registries", api_docker.registries), web.get("/docker/registries", api_docker.registries),
web.post("/docker/registries", api_docker.create_registry), web.post("/docker/registries", api_docker.create_registry),
web.delete("/docker/registries/{hostname}", api_docker.remove_registry), web.delete("/docker/registries/{hostname}", api_docker.remove_registry),

View File

@ -7,6 +7,7 @@ from aiohttp import web
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
ATTR_ENABLE_IPV6,
ATTR_HOSTNAME, ATTR_HOSTNAME,
ATTR_LOGGING, ATTR_LOGGING,
ATTR_PASSWORD, ATTR_PASSWORD,
@ -30,10 +31,39 @@ SCHEMA_DOCKER_REGISTRY = vol.Schema(
} }
) )
# pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLE_IPV6): vol.Boolean()})
class APIDocker(CoreSysAttributes): class APIDocker(CoreSysAttributes):
"""Handle RESTful API for Docker configuration.""" """Handle RESTful API for Docker configuration."""
@api_process
async def info(self, request: web.Request):
"""Get docker info."""
data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items():
data_registries[hostname] = {
ATTR_USERNAME: registry[ATTR_USERNAME],
}
return {
ATTR_VERSION: self.sys_docker.info.version,
ATTR_ENABLE_IPV6: self.sys_docker.config.enable_ipv6,
ATTR_STORAGE: self.sys_docker.info.storage,
ATTR_LOGGING: self.sys_docker.info.logging,
ATTR_REGISTRIES: data_registries,
}
@api_process
async def options(self, request: web.Request) -> None:
"""Set docker options."""
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_ENABLE_IPV6 in body:
self.sys_docker.config.enable_ipv6 = body[ATTR_ENABLE_IPV6]
await self.sys_docker.config.save_data()
@api_process @api_process
async def registries(self, request) -> dict[str, Any]: async def registries(self, request) -> dict[str, Any]:
"""Return the list of registries.""" """Return the list of registries."""
@ -64,18 +94,3 @@ class APIDocker(CoreSysAttributes):
del self.sys_docker.config.registries[hostname] del self.sys_docker.config.registries[hostname]
await self.sys_docker.config.save_data() await self.sys_docker.config.save_data()
@api_process
async def info(self, request: web.Request):
"""Get docker info."""
data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items():
data_registries[hostname] = {
ATTR_USERNAME: registry[ATTR_USERNAME],
}
return {
ATTR_VERSION: self.sys_docker.info.version,
ATTR_STORAGE: self.sys_docker.info.storage,
ATTR_LOGGING: self.sys_docker.info.logging,
ATTR_REGISTRIES: data_registries,
}

View File

@ -40,8 +40,8 @@ from ..const import (
ATTR_TYPE, ATTR_TYPE,
ATTR_VLAN, ATTR_VLAN,
ATTR_WIFI, ATTR_WIFI,
DOCKER_IPV4_NETWORK_MASK,
DOCKER_NETWORK, DOCKER_NETWORK,
DOCKER_NETWORK_MASK,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APINotFound, HostNetworkNotFound from ..exceptions import APIError, APINotFound, HostNetworkNotFound
@ -203,7 +203,7 @@ class APINetwork(CoreSysAttributes):
], ],
ATTR_DOCKER: { ATTR_DOCKER: {
ATTR_INTERFACE: DOCKER_NETWORK, ATTR_INTERFACE: DOCKER_NETWORK,
ATTR_ADDRESS: str(DOCKER_NETWORK_MASK), ATTR_ADDRESS: str(DOCKER_IPV4_NETWORK_MASK),
ATTR_GATEWAY: str(self.sys_docker.network.gateway), ATTR_GATEWAY: str(self.sys_docker.network.gateway),
ATTR_DNS: str(self.sys_docker.network.dns), ATTR_DNS: str(self.sys_docker.network.dns),
}, },

View File

@ -2,7 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from ipaddress import IPv4Network from ipaddress import IPv4Network, IPv6Network
from pathlib import Path from pathlib import Path
from sys import version_info as systemversion from sys import version_info as systemversion
from typing import Self from typing import Self
@ -12,6 +12,10 @@ from aiohttp import __version__ as aiohttpversion
SUPERVISOR_VERSION = "9999.09.9.dev9999" SUPERVISOR_VERSION = "9999.09.9.dev9999"
SERVER_SOFTWARE = f"HomeAssistantSupervisor/{SUPERVISOR_VERSION} aiohttp/{aiohttpversion} Python/{systemversion[0]}.{systemversion[1]}" SERVER_SOFTWARE = f"HomeAssistantSupervisor/{SUPERVISOR_VERSION} aiohttp/{aiohttpversion} Python/{systemversion[0]}.{systemversion[1]}"
DOCKER_PREFIX: str = "hassio"
OBSERVER_DOCKER_NAME: str = f"{DOCKER_PREFIX}_observer"
SUPERVISOR_DOCKER_NAME: str = f"{DOCKER_PREFIX}_supervisor"
URL_HASSIO_ADDONS = "https://github.com/home-assistant/addons" URL_HASSIO_ADDONS = "https://github.com/home-assistant/addons"
URL_HASSIO_APPARMOR = "https://version.home-assistant.io/apparmor_{channel}.txt" URL_HASSIO_APPARMOR = "https://version.home-assistant.io/apparmor_{channel}.txt"
URL_HASSIO_VERSION = "https://version.home-assistant.io/{channel}.json" URL_HASSIO_VERSION = "https://version.home-assistant.io/{channel}.json"
@ -41,8 +45,10 @@ SYSTEMD_JOURNAL_PERSISTENT = Path("/var/log/journal")
SYSTEMD_JOURNAL_VOLATILE = Path("/run/log/journal") SYSTEMD_JOURNAL_VOLATILE = Path("/run/log/journal")
DOCKER_NETWORK = "hassio" DOCKER_NETWORK = "hassio"
DOCKER_NETWORK_MASK = IPv4Network("172.30.32.0/23") DOCKER_NETWORK_DRIVER = "bridge"
DOCKER_NETWORK_RANGE = IPv4Network("172.30.33.0/24") DOCKER_IPV6_NETWORK_MASK = IPv6Network("fd0c:ac1e:2100::/48")
DOCKER_IPV4_NETWORK_MASK = IPv4Network("172.30.32.0/23")
DOCKER_IPV4_NETWORK_RANGE = IPv4Network("172.30.33.0/24")
# This needs to match the dockerd --cpu-rt-runtime= argument. # This needs to match the dockerd --cpu-rt-runtime= argument.
DOCKER_CPU_RUNTIME_TOTAL = 950_000 DOCKER_CPU_RUNTIME_TOTAL = 950_000
@ -172,6 +178,7 @@ ATTR_DOCKER_API = "docker_api"
ATTR_DOCUMENTATION = "documentation" ATTR_DOCUMENTATION = "documentation"
ATTR_DOMAINS = "domains" ATTR_DOMAINS = "domains"
ATTR_ENABLE = "enable" ATTR_ENABLE = "enable"
ATTR_ENABLE_IPV6 = "enable_ipv6"
ATTR_ENABLED = "enabled" ATTR_ENABLED = "enabled"
ATTR_ENVIRONMENT = "environment" ATTR_ENVIRONMENT = "environment"
ATTR_EVENT = "event" ATTR_EVENT = "event"

View File

@ -96,7 +96,7 @@ class NetworkConnection(DBusInterfaceProxy):
@ipv4.setter @ipv4.setter
def ipv4(self, ipv4: IpConfiguration | None) -> None: def ipv4(self, ipv4: IpConfiguration | None) -> None:
"""Set ipv4 configuration.""" """Set IPv4 configuration."""
if self._ipv4 and self._ipv4 is not ipv4: if self._ipv4 and self._ipv4 is not ipv4:
self._ipv4.shutdown() self._ipv4.shutdown()
@ -109,7 +109,7 @@ class NetworkConnection(DBusInterfaceProxy):
@ipv6.setter @ipv6.setter
def ipv6(self, ipv6: IpConfiguration | None) -> None: def ipv6(self, ipv6: IpConfiguration | None) -> None:
"""Set ipv6 configuration.""" """Set IPv6 configuration."""
if self._ipv6 and self._ipv6 is not ipv6: if self._ipv6 and self._ipv6 is not ipv6:
self._ipv6.shutdown() self._ipv6.shutdown()

View File

@ -152,12 +152,12 @@ class NetworkSetting(DBusInterface):
@property @property
def ipv4(self) -> Ip4Properties | None: def ipv4(self) -> Ip4Properties | None:
"""Return ipv4 properties if any.""" """Return IPv4 properties if any."""
return self._ipv4 return self._ipv4
@property @property
def ipv6(self) -> Ip6Properties | None: def ipv6(self) -> Ip6Properties | None:
"""Return ipv6 properties if any.""" """Return IPv6 properties if any."""
return self._ipv6 return self._ipv6
@property @property

View File

@ -22,6 +22,7 @@ from docker.types.daemon import CancellableStream
import requests import requests
from ..const import ( from ..const import (
ATTR_ENABLE_IPV6,
ATTR_REGISTRIES, ATTR_REGISTRIES,
DNS_SUFFIX, DNS_SUFFIX,
DOCKER_NETWORK, DOCKER_NETWORK,
@ -93,6 +94,16 @@ class DockerConfig(FileConfiguration):
"""Initialize the JSON configuration.""" """Initialize the JSON configuration."""
super().__init__(FILE_HASSIO_DOCKER, SCHEMA_DOCKER_CONFIG) super().__init__(FILE_HASSIO_DOCKER, SCHEMA_DOCKER_CONFIG)
@property
def enable_ipv6(self) -> bool:
"""Return IPv6 configuration for docker network."""
return self._data.get(ATTR_ENABLE_IPV6, False)
@enable_ipv6.setter
def enable_ipv6(self, value: bool) -> None:
"""Set IPv6 configuration for docker network."""
self._data[ATTR_ENABLE_IPV6] = value
@property @property
def registries(self) -> dict[str, Any]: def registries(self) -> dict[str, Any]:
"""Return credentials for docker registries.""" """Return credentials for docker registries."""
@ -124,9 +135,11 @@ class DockerAPI:
timeout=900, timeout=900,
), ),
) )
self._network = DockerNetwork(self._docker)
self._info = DockerInfo.new(self.docker.info()) self._info = DockerInfo.new(self.docker.info())
await self.config.read_data() await self.config.read_data()
self._network = await DockerNetwork(self.docker).post_init(
self.config.enable_ipv6
)
return self return self
@property @property

View File

@ -1,17 +1,52 @@
"""Internal network manager for Supervisor.""" """Internal network manager for Supervisor."""
import asyncio
from contextlib import suppress from contextlib import suppress
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
from typing import Self
import docker import docker
import requests import requests
from ..const import DOCKER_NETWORK, DOCKER_NETWORK_MASK, DOCKER_NETWORK_RANGE from ..const import (
ATTR_AUDIO,
ATTR_CLI,
ATTR_DNS,
ATTR_ENABLE_IPV6,
ATTR_OBSERVER,
ATTR_SUPERVISOR,
DOCKER_IPV4_NETWORK_MASK,
DOCKER_IPV4_NETWORK_RANGE,
DOCKER_IPV6_NETWORK_MASK,
DOCKER_NETWORK,
DOCKER_NETWORK_DRIVER,
DOCKER_PREFIX,
OBSERVER_DOCKER_NAME,
SUPERVISOR_DOCKER_NAME,
)
from ..exceptions import DockerError from ..exceptions import DockerError
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
DOCKER_ENABLEIPV6 = "EnableIPv6"
DOCKER_NETWORK_PARAMS = {
"name": DOCKER_NETWORK,
"driver": DOCKER_NETWORK_DRIVER,
"ipam": docker.types.IPAMConfig(
pool_configs=[
docker.types.IPAMPool(subnet=str(DOCKER_IPV6_NETWORK_MASK)),
docker.types.IPAMPool(
subnet=str(DOCKER_IPV4_NETWORK_MASK),
gateway=str(DOCKER_IPV4_NETWORK_MASK[1]),
iprange=str(DOCKER_IPV4_NETWORK_RANGE),
),
]
),
ATTR_ENABLE_IPV6: True,
"options": {"com.docker.network.bridge.name": DOCKER_NETWORK},
}
class DockerNetwork: class DockerNetwork:
"""Internal Supervisor Network. """Internal Supervisor Network.
@ -22,7 +57,14 @@ class DockerNetwork:
def __init__(self, docker_client: docker.DockerClient): def __init__(self, docker_client: docker.DockerClient):
"""Initialize internal Supervisor network.""" """Initialize internal Supervisor network."""
self.docker: docker.DockerClient = docker_client self.docker: docker.DockerClient = docker_client
self._network: docker.models.networks.Network = self._get_network() self._network: docker.models.networks.Network | None = None
async def post_init(self, enable_ipv6: bool = False) -> Self:
"""Post init actions that must be done in event loop."""
self._network = await asyncio.get_running_loop().run_in_executor(
None, self._get_network, enable_ipv6
)
return self
@property @property
def name(self) -> str: def name(self) -> str:
@ -42,56 +84,102 @@ class DockerNetwork:
@property @property
def gateway(self) -> IPv4Address: def gateway(self) -> IPv4Address:
"""Return gateway of the network.""" """Return gateway of the network."""
return DOCKER_NETWORK_MASK[1] return DOCKER_IPV4_NETWORK_MASK[1]
@property @property
def supervisor(self) -> IPv4Address: def supervisor(self) -> IPv4Address:
"""Return supervisor of the network.""" """Return supervisor of the network."""
return DOCKER_NETWORK_MASK[2] return DOCKER_IPV4_NETWORK_MASK[2]
@property @property
def dns(self) -> IPv4Address: def dns(self) -> IPv4Address:
"""Return dns of the network.""" """Return dns of the network."""
return DOCKER_NETWORK_MASK[3] return DOCKER_IPV4_NETWORK_MASK[3]
@property @property
def audio(self) -> IPv4Address: def audio(self) -> IPv4Address:
"""Return audio of the network.""" """Return audio of the network."""
return DOCKER_NETWORK_MASK[4] return DOCKER_IPV4_NETWORK_MASK[4]
@property @property
def cli(self) -> IPv4Address: def cli(self) -> IPv4Address:
"""Return cli of the network.""" """Return cli of the network."""
return DOCKER_NETWORK_MASK[5] return DOCKER_IPV4_NETWORK_MASK[5]
@property @property
def observer(self) -> IPv4Address: def observer(self) -> IPv4Address:
"""Return observer of the network.""" """Return observer of the network."""
return DOCKER_NETWORK_MASK[6] return DOCKER_IPV4_NETWORK_MASK[6]
def _get_network(self) -> docker.models.networks.Network: def _get_network(self, enable_ipv6: bool = False) -> docker.models.networks.Network:
"""Get supervisor network.""" """Get supervisor network."""
try: try:
return self.docker.networks.get(DOCKER_NETWORK) if network := self.docker.networks.get(DOCKER_NETWORK):
if network.attrs.get(DOCKER_ENABLEIPV6) == enable_ipv6:
return network
_LOGGER.info(
"Migrating Supervisor network to %s",
"IPv4/IPv6 Dual-Stack" if enable_ipv6 else "IPv4-Only",
)
if (containers := network.containers) and (
containers_all := all(
container.name in (OBSERVER_DOCKER_NAME, SUPERVISOR_DOCKER_NAME)
for container in containers
)
):
for container in containers:
with suppress(
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
):
network.disconnect(container, force=True)
if not containers or containers_all:
try:
network.remove()
except docker.errors.APIError:
_LOGGER.warning("Failed to remove existing Supervisor network")
return network
else:
_LOGGER.warning(
"System appears to be running, "
"not applying Supervisor network change. "
"Reboot your system to apply the change."
)
return network
except docker.errors.NotFound: except docker.errors.NotFound:
_LOGGER.info("Can't find Supervisor network, creating a new network") _LOGGER.info("Can't find Supervisor network, creating a new network")
ipam_pool = docker.types.IPAMPool( network_params = DOCKER_NETWORK_PARAMS.copy()
subnet=str(DOCKER_NETWORK_MASK), network_params[ATTR_ENABLE_IPV6] = enable_ipv6
gateway=str(self.gateway),
iprange=str(DOCKER_NETWORK_RANGE), try:
self._network = self.docker.networks.create(**network_params)
except docker.errors.APIError as err:
raise DockerError(
f"Can't create Supervisor network: {err}", _LOGGER.error
) from err
with suppress(DockerError):
self.attach_container_by_name(
SUPERVISOR_DOCKER_NAME, [ATTR_SUPERVISOR], self.supervisor
) )
ipam_config = docker.types.IPAMConfig(pool_configs=[ipam_pool]) with suppress(DockerError):
self.attach_container_by_name(
return self.docker.networks.create( OBSERVER_DOCKER_NAME, [ATTR_OBSERVER], self.observer
DOCKER_NETWORK,
driver="bridge",
ipam=ipam_config,
enable_ipv6=False,
options={"com.docker.network.bridge.name": DOCKER_NETWORK},
) )
for name, ip in (
(ATTR_CLI, self.cli),
(ATTR_DNS, self.dns),
(ATTR_AUDIO, self.audio),
):
with suppress(DockerError):
self.attach_container_by_name(f"{DOCKER_PREFIX}_{name}", [name], ip)
return self._network
def attach_container( def attach_container(
self, self,
container: docker.models.containers.Container, container: docker.models.containers.Container,
@ -102,8 +190,6 @@ class DockerNetwork:
Need run inside executor. Need run inside executor.
""" """
ipv4_address = str(ipv4) if ipv4 else None
# Reload Network information # Reload Network information
with suppress(docker.errors.DockerException, requests.RequestException): with suppress(docker.errors.DockerException, requests.RequestException):
self.network.reload() self.network.reload()
@ -116,12 +202,43 @@ class DockerNetwork:
# Attach Network # Attach Network
try: try:
self.network.connect(container, aliases=alias, ipv4_address=ipv4_address) self.network.connect(
except docker.errors.APIError as err: container, aliases=alias, ipv4_address=str(ipv4) if ipv4 else None
)
except (
docker.errors.NotFound,
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError( raise DockerError(
f"Can't link container to hassio-net: {err}", _LOGGER.error f"Can't connect {container.name} to Supervisor network: {err}",
_LOGGER.error,
) from err ) from err
def attach_container_by_name(
self,
name: str,
alias: list[str] | None = None,
ipv4: IPv4Address | None = None,
) -> None:
"""Attach container to Supervisor network.
Need run inside executor.
"""
try:
container = self.docker.containers.get(name)
except (
docker.errors.NotFound,
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError(f"Can't find {name}: {err}", _LOGGER.error) from err
if container.id not in self.containers:
self.attach_container(container, alias, ipv4)
def detach_default_bridge( def detach_default_bridge(
self, container: docker.models.containers.Container self, container: docker.models.containers.Container
) -> None: ) -> None:
@ -130,25 +247,33 @@ class DockerNetwork:
Need run inside executor. Need run inside executor.
""" """
try: try:
default_network = self.docker.networks.get("bridge") default_network = self.docker.networks.get(DOCKER_NETWORK_DRIVER)
default_network.disconnect(container) default_network.disconnect(container)
except docker.errors.NotFound: except docker.errors.NotFound:
return pass
except (
except docker.errors.APIError as err: docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError( raise DockerError(
f"Can't disconnect container from default: {err}", _LOGGER.warning f"Can't disconnect {container.name} from default network: {err}",
_LOGGER.warning,
) from err ) from err
def stale_cleanup(self, container_name: str): def stale_cleanup(self, name: str) -> None:
"""Remove force a container from Network. """Force remove a container from Network.
Fix: https://github.com/moby/moby/issues/23302 Fix: https://github.com/moby/moby/issues/23302
""" """
try: try:
self.network.disconnect(container_name, force=True) self.network.disconnect(name, force=True)
except docker.errors.NotFound: except (
pass docker.errors.APIError,
except (docker.errors.DockerException, requests.RequestException) as err: docker.errors.DockerException,
raise DockerError() from err requests.RequestException,
) as err:
raise DockerError(
f"Can't disconnect {name} from Supervisor network: {err}",
_LOGGER.warning,
) from err

View File

@ -2,7 +2,7 @@
import logging import logging
from ..const import DOCKER_NETWORK_MASK from ..const import DOCKER_IPV4_NETWORK_MASK, OBSERVER_DOCKER_NAME
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import DockerJobError from ..exceptions import DockerJobError
from ..jobs.const import JobExecutionLimit from ..jobs.const import JobExecutionLimit
@ -12,7 +12,6 @@ from .interface import DockerInterface
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
OBSERVER_DOCKER_NAME: str = "hassio_observer"
ENV_NETWORK_MASK: str = "NETWORK_MASK" ENV_NETWORK_MASK: str = "NETWORK_MASK"
@ -49,7 +48,7 @@ class DockerObserver(DockerInterface, CoreSysAttributes):
environment={ environment={
ENV_TIME: self.sys_timezone, ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_plugins.observer.supervisor_token, ENV_TOKEN: self.sys_plugins.observer.supervisor_token,
ENV_NETWORK_MASK: DOCKER_NETWORK_MASK, ENV_NETWORK_MASK: DOCKER_IPV4_NETWORK_MASK,
}, },
mounts=[MOUNT_DOCKER], mounts=[MOUNT_DOCKER],
ports={"80/tcp": 4357}, ports={"80/tcp": 4357},

View File

@ -9,7 +9,7 @@ from aiohttp import hdrs
import attr import attr
from sentry_sdk.types import Event, Hint from sentry_sdk.types import Event, Hint
from ..const import DOCKER_NETWORK_MASK, HEADER_TOKEN, HEADER_TOKEN_OLD, CoreState from ..const import DOCKER_IPV4_NETWORK_MASK, HEADER_TOKEN, HEADER_TOKEN_OLD, CoreState
from ..coresys import CoreSys from ..coresys import CoreSys
from ..exceptions import AddonConfigurationError from ..exceptions import AddonConfigurationError
@ -21,7 +21,7 @@ def sanitize_host(host: str) -> str:
try: try:
# Allow internal URLs # Allow internal URLs
ip = ipaddress.ip_address(host) ip = ipaddress.ip_address(host)
if ip in ipaddress.ip_network(DOCKER_NETWORK_MASK): if ip in ipaddress.ip_network(DOCKER_IPV4_NETWORK_MASK):
return host return host
except ValueError: except ValueError:
pass pass

View File

@ -20,6 +20,7 @@ from .const import (
ATTR_DIAGNOSTICS, ATTR_DIAGNOSTICS,
ATTR_DISPLAYNAME, ATTR_DISPLAYNAME,
ATTR_DNS, ATTR_DNS,
ATTR_ENABLE_IPV6,
ATTR_FORCE_SECURITY, ATTR_FORCE_SECURITY,
ATTR_HASSOS, ATTR_HASSOS,
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
@ -81,7 +82,7 @@ def dns_url(url: str) -> str:
raise vol.Invalid("Doesn't start with dns://") from None raise vol.Invalid("Doesn't start with dns://") from None
address: str = url[6:] # strip the dns:// off address: str = url[6:] # strip the dns:// off
try: try:
ip = ipaddress.ip_address(address) # matches ipv4 or ipv6 addresses ip = ipaddress.ip_address(address) # matches IPv4 or IPv6 addresses
except ValueError: except ValueError:
raise vol.Invalid(f"Invalid DNS URL: {url}") from None raise vol.Invalid(f"Invalid DNS URL: {url}") from None
@ -180,7 +181,8 @@ SCHEMA_DOCKER_CONFIG = vol.Schema(
vol.Required(ATTR_PASSWORD): str, vol.Required(ATTR_PASSWORD): str,
} }
} }
) ),
vol.Optional(ATTR_ENABLE_IPV6): vol.Boolean(),
} }
) )

View File

@ -3,6 +3,8 @@
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
import pytest import pytest
from supervisor.coresys import CoreSys
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_docker_info(api_client: TestClient): async def test_api_docker_info(api_client: TestClient):
@ -15,6 +17,21 @@ async def test_api_docker_info(api_client: TestClient):
assert result["data"]["version"] == "1.0.0" assert result["data"]["version"] == "1.0.0"
async def test_api_network_enable_ipv6(coresys: CoreSys, api_client: TestClient):
"""Test setting docker network for enabled IPv6."""
assert coresys.docker.config.enable_ipv6 is False
resp = await api_client.post("/docker/options", json={"enable_ipv6": True})
assert resp.status == 200
assert coresys.docker.config.enable_ipv6 is True
resp = await api_client.get("/docker/info")
assert resp.status == 200
body = await resp.json()
assert body["data"]["enable_ipv6"] is True
async def test_registry_not_found(api_client: TestClient): async def test_registry_not_found(api_client: TestClient):
"""Test registry not found error.""" """Test registry not found error."""
resp = await api_client.delete("/docker/registries/bad") resp = await api_client.delete("/docker/registries/bad")

View File

@ -6,7 +6,7 @@ from aiohttp.test_utils import TestClient
from dbus_fast import Variant from dbus_fast import Variant
import pytest import pytest
from supervisor.const import DOCKER_NETWORK, DOCKER_NETWORK_MASK from supervisor.const import DOCKER_IPV4_NETWORK_MASK, DOCKER_NETWORK
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from tests.const import ( from tests.const import (
@ -61,7 +61,7 @@ async def test_api_network_info(api_client: TestClient, coresys: CoreSys):
} }
assert result["data"]["docker"]["interface"] == DOCKER_NETWORK assert result["data"]["docker"]["interface"] == DOCKER_NETWORK
assert result["data"]["docker"]["address"] == str(DOCKER_NETWORK_MASK) assert result["data"]["docker"]["address"] == str(DOCKER_IPV4_NETWORK_MASK)
assert result["data"]["docker"]["dns"] == str(coresys.docker.network.dns) assert result["data"]["docker"]["dns"] == str(coresys.docker.network.dns)
assert result["data"]["docker"]["gateway"] == str(coresys.docker.network.gateway) assert result["data"]["docker"]["gateway"] == str(coresys.docker.network.gateway)

View File

@ -56,7 +56,7 @@ async def test_active_connection(
async def test_old_ipv4_disconnect( async def test_old_ipv4_disconnect(
network_manager: NetworkManager, active_connection_service: ActiveConnectionService network_manager: NetworkManager, active_connection_service: ActiveConnectionService
): ):
"""Test old ipv4 disconnects on ipv4 change.""" """Test old IPv4 disconnects on IPv4 change."""
connection = network_manager.get(TEST_INTERFACE_ETH_NAME).connection connection = network_manager.get(TEST_INTERFACE_ETH_NAME).connection
ipv4 = connection.ipv4 ipv4 = connection.ipv4
assert ipv4.is_connected is True assert ipv4.is_connected is True
@ -71,7 +71,7 @@ async def test_old_ipv4_disconnect(
async def test_old_ipv6_disconnect( async def test_old_ipv6_disconnect(
network_manager: NetworkManager, active_connection_service: ActiveConnectionService network_manager: NetworkManager, active_connection_service: ActiveConnectionService
): ):
"""Test old ipv6 disconnects on ipv6 change.""" """Test old IPv6 disconnects on IPv6 change."""
connection = network_manager.get(TEST_INTERFACE_ETH_NAME).connection connection = network_manager.get(TEST_INTERFACE_ETH_NAME).connection
ipv6 = connection.ipv6 ipv6 = connection.ipv6
assert ipv6.is_connected is True assert ipv6.is_connected is True

View File

@ -31,7 +31,7 @@ async def fixture_ip6config_service(dbus_session_bus: MessageBus) -> IP4ConfigSe
async def test_ipv4_configuration( async def test_ipv4_configuration(
ip4config_service: IP4ConfigService, dbus_session_bus: MessageBus ip4config_service: IP4ConfigService, dbus_session_bus: MessageBus
): ):
"""Test ipv4 configuration object.""" """Test IPv4 configuration object."""
ip4 = IpConfiguration("/org/freedesktop/NetworkManager/IP4Config/1") ip4 = IpConfiguration("/org/freedesktop/NetworkManager/IP4Config/1")
assert ip4.gateway is None assert ip4.gateway is None
@ -55,7 +55,7 @@ async def test_ipv4_configuration(
async def test_ipv6_configuration( async def test_ipv6_configuration(
ip6config_service: IP6ConfigService, dbus_session_bus: MessageBus ip6config_service: IP6ConfigService, dbus_session_bus: MessageBus
): ):
"""Test ipv4 configuration object.""" """Test IPv6 configuration object."""
ip6 = IpConfiguration("/org/freedesktop/NetworkManager/IP6Config/1", ip4=False) ip6 = IpConfiguration("/org/freedesktop/NetworkManager/IP6Config/1", ip4=False)
assert ip6.gateway is None assert ip6.gateway is None

View File

@ -0,0 +1,113 @@
"""Test Internal network manager for Supervisor."""
from unittest.mock import MagicMock, PropertyMock, patch
import docker
import pytest
from supervisor.const import (
ATTR_ENABLE_IPV6,
DOCKER_NETWORK,
OBSERVER_DOCKER_NAME,
SUPERVISOR_DOCKER_NAME,
)
from supervisor.docker.network import (
DOCKER_ENABLEIPV6,
DOCKER_NETWORK_PARAMS,
DockerNetwork,
)
class MockContainer:
"""Mock implementation of a Docker container."""
def __init__(self, name: str) -> None:
"""Initialize a mock container."""
self.name = name
class MockNetwork:
"""Mock implementation of internal network."""
def __init__(
self, raise_error: bool, containers: list[str], enableIPv6: bool
) -> None:
"""Initialize a mock network."""
self.raise_error = raise_error
self.containers = [MockContainer(container) for container in containers or []]
self.attrs = {DOCKER_ENABLEIPV6: enableIPv6}
def remove(self) -> None:
"""Simulate a network removal."""
if self.raise_error:
raise docker.errors.APIError("Simulated removal error")
def reload(self, *args, **kwargs) -> None:
"""Simulate a network reload."""
def connect(self, *args, **kwargs) -> None:
"""Simulate a network connection."""
def disconnect(self, *args, **kwargs) -> None:
"""Simulate a network disconnection."""
@pytest.mark.parametrize(
("raise_error", "containers", f"old_{ATTR_ENABLE_IPV6}", f"new_{ATTR_ENABLE_IPV6}"),
[
(False, [OBSERVER_DOCKER_NAME, SUPERVISOR_DOCKER_NAME], False, True),
(False, [OBSERVER_DOCKER_NAME, SUPERVISOR_DOCKER_NAME], True, False),
(False, ["test_container"], False, True),
(False, None, False, True),
(True, None, False, True),
(False, None, True, True),
],
)
async def test_network_recreation(
raise_error: bool,
containers: list[str] | None,
old_enable_ipv6: bool,
new_enable_ipv6: bool,
):
"""Test network recreation with IPv6 enabled/disabled."""
with (
patch(
"supervisor.docker.network.DockerNetwork.docker",
new_callable=PropertyMock,
return_value=MagicMock(),
create=True,
),
patch(
"supervisor.docker.network.DockerNetwork.docker.networks",
new_callable=PropertyMock,
return_value=MagicMock(),
create=True,
),
patch(
"supervisor.docker.network.DockerNetwork.docker.networks.get",
return_value=MockNetwork(raise_error, containers, old_enable_ipv6),
) as mock_get,
patch(
"supervisor.docker.network.DockerNetwork.docker.networks.create",
return_value=MockNetwork(raise_error, containers, new_enable_ipv6),
) as mock_create,
):
network = (await DockerNetwork(MagicMock()).post_init(new_enable_ipv6)).network
mock_get.assert_called_with(DOCKER_NETWORK)
assert network is not None
assert network.attrs.get(DOCKER_ENABLEIPV6) is (
new_enable_ipv6
if not raise_error and (not containers or len(containers) > 1)
else old_enable_ipv6
)
if (
not raise_error and (not containers or len(containers) > 1)
) and old_enable_ipv6 != new_enable_ipv6:
network_params = DOCKER_NETWORK_PARAMS.copy()
network_params[ATTR_ENABLE_IPV6] = new_enable_ipv6
mock_create.assert_called_with(**network_params)

View File

@ -36,14 +36,14 @@ IMAGE_NAME_BAD = [
] ]
async def test_dns_url_v4_good(): def test_dns_url_v4_good():
"""Test the DNS validator with known-good ipv6 DNS URLs.""" """Test the DNS validator with known-good IPv4 DNS URLs."""
for url in DNS_GOOD_V4: for url in DNS_GOOD_V4:
assert validate.dns_url(url) assert validate.dns_url(url)
def test_dns_url_v6_good(): def test_dns_url_v6_good():
"""Test the DNS validator with known-good ipv6 DNS URLs.""" """Test the DNS validator with known-good IPv6 DNS URLs."""
with pytest.raises(vol.error.Invalid): with pytest.raises(vol.error.Invalid):
for url in DNS_GOOD_V6: for url in DNS_GOOD_V6:
assert validate.dns_url(url) assert validate.dns_url(url)