mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-12 11:46:31 +00:00
Change handling kernel udev events (#2484)
* Change handling kernel udev events / add USB events * finish USB * map dev ro * fix bind
This commit is contained in:
parent
10b14132b9
commit
b8a976b344
@ -27,8 +27,8 @@ from ..const import (
|
||||
SECURITY_PROFILE,
|
||||
)
|
||||
from ..coresys import CoreSys
|
||||
from ..exceptions import CoreDNSError, DockerError
|
||||
from ..hardware.const import PolicyGroup, UdevSubsystem
|
||||
from ..exceptions import CoreDNSError, DockerError, HardwareNotFound
|
||||
from ..hardware.const import PolicyGroup
|
||||
from ..utils import process_lock
|
||||
from .interface import DockerInterface
|
||||
|
||||
@ -124,64 +124,21 @@ class DockerAddon(DockerInterface):
|
||||
ENV_TOKEN_HASSIO: self.addon.supervisor_token,
|
||||
}
|
||||
|
||||
@property
|
||||
def devices(self) -> Optional[List[str]]:
|
||||
"""Return needed devices."""
|
||||
devices = set()
|
||||
|
||||
def _create_dev(device_path: Path) -> str:
|
||||
"""Add device to list."""
|
||||
devices.add(f"{device_path.as_posix()}:{device_path.as_posix()}:rwm")
|
||||
|
||||
# Static devices
|
||||
for device_path in self.addon.static_devices:
|
||||
if not self.sys_hardware.exists_device_node(device_path):
|
||||
_LOGGER.debug("Ignore static device path %s", device_path)
|
||||
continue
|
||||
_create_dev(device_path)
|
||||
|
||||
# Dynamic devices
|
||||
for device in self.addon.devices:
|
||||
_create_dev(device.path)
|
||||
|
||||
# Auto mapping UART devices / LINKS
|
||||
if self.addon.with_uart:
|
||||
for device in self.sys_hardware.filter_devices(
|
||||
subsystem=UdevSubsystem.SERIAL
|
||||
):
|
||||
_create_dev(device.path)
|
||||
|
||||
# Auto mapping GPIO
|
||||
if self.addon.with_gpio:
|
||||
for subsystem in (
|
||||
UdevSubsystem.GPIO,
|
||||
UdevSubsystem.GPIOMEM,
|
||||
):
|
||||
for device in self.sys_hardware.filter_devices(subsystem=subsystem):
|
||||
_create_dev(device.path)
|
||||
|
||||
# Auto mapping Video
|
||||
if self.addon.with_video:
|
||||
for subsystem in (
|
||||
UdevSubsystem.VIDEO,
|
||||
UdevSubsystem.CEC,
|
||||
UdevSubsystem.VCHIQ,
|
||||
UdevSubsystem.MEDIA,
|
||||
):
|
||||
for device in self.sys_hardware.filter_devices(subsystem=subsystem):
|
||||
_create_dev(device.path)
|
||||
|
||||
# Return None if no devices is present
|
||||
if devices:
|
||||
return list(devices)
|
||||
return None
|
||||
|
||||
@property
|
||||
def cgroups_rules(self) -> Optional[List[str]]:
|
||||
"""Return a list of needed cgroups permission."""
|
||||
rules = set()
|
||||
|
||||
# Attach correct cgroups
|
||||
# Attach correct cgroups for static devices
|
||||
for device_path in self.addon.static_devices:
|
||||
try:
|
||||
device = self.sys_hardware.get_by_path(device_path)
|
||||
except HardwareNotFound:
|
||||
_LOGGER.debug("Ignore static device path %s", device_path)
|
||||
else:
|
||||
rules.add(self.sys_hardware.policy.get_cgroups_rule(device))
|
||||
|
||||
# Attach correct cgroups for devices
|
||||
for device in self.addon.devices:
|
||||
rules.add(self.sys_hardware.policy.get_cgroups_rule(device))
|
||||
|
||||
@ -197,6 +154,10 @@ class DockerAddon(DockerInterface):
|
||||
if self.addon.with_uart:
|
||||
rules.update(self.sys_hardware.policy.get_cgroups_rules(PolicyGroup.UART))
|
||||
|
||||
# USB
|
||||
if self.addon.with_usb:
|
||||
rules.update(self.sys_hardware.policy.get_cgroups_rules(PolicyGroup.USB))
|
||||
|
||||
# Return None if no rules is present
|
||||
if rules:
|
||||
return list(rules)
|
||||
@ -267,7 +228,10 @@ class DockerAddon(DockerInterface):
|
||||
"""Generate volumes for mappings."""
|
||||
addon_mapping = self.addon.map_volumes
|
||||
|
||||
volumes = {str(self.addon.path_extern_data): {"bind": "/data", "mode": "rw"}}
|
||||
volumes = {
|
||||
"/dev": {"bind": "/dev", "mode": "ro"},
|
||||
str(self.addon.path_extern_data): {"bind": "/data", "mode": "rw"},
|
||||
}
|
||||
|
||||
# setup config mappings
|
||||
if MAP_CONFIG in addon_mapping:
|
||||
@ -354,10 +318,6 @@ class DockerAddon(DockerInterface):
|
||||
if self.addon.with_udev:
|
||||
volumes.update({"/run/udev": {"bind": "/run/udev", "mode": "ro"}})
|
||||
|
||||
# USB support
|
||||
if self.addon.with_usb and self.sys_hardware.helper.support_usb:
|
||||
volumes.update({"/dev/bus/usb": {"bind": "/dev/bus/usb", "mode": "rw"}})
|
||||
|
||||
# Kernel Modules support
|
||||
if self.addon.with_kernel_modules:
|
||||
volumes.update({"/lib/modules": {"bind": "/lib/modules", "mode": "ro"}})
|
||||
@ -391,13 +351,6 @@ class DockerAddon(DockerInterface):
|
||||
}
|
||||
)
|
||||
|
||||
# With UART
|
||||
if self.addon.with_uart:
|
||||
mount = self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.SERIAL)
|
||||
volumes.update(
|
||||
{str(mount.as_posix()): {"bind": str(mount.as_posix()), "mode": "ro"}}
|
||||
)
|
||||
|
||||
return volumes
|
||||
|
||||
def _run(self) -> None:
|
||||
@ -430,7 +383,6 @@ class DockerAddon(DockerInterface):
|
||||
pid_mode=self.pid_mode,
|
||||
ports=self.ports,
|
||||
extra_hosts=self.network_mapping,
|
||||
devices=self.devices,
|
||||
device_cgroup_rules=self.cgroups_rules,
|
||||
cap_add=self.addon.privileged,
|
||||
security_opt=self.security_opt,
|
||||
|
@ -1,6 +1,5 @@
|
||||
"""Audio docker object."""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from ..const import ENV_TIME, MACHINE_ID
|
||||
@ -30,6 +29,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
def volumes(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Return Volumes for the mount."""
|
||||
volumes = {
|
||||
"/dev": {"bind": "/dev", "mode": "ro"},
|
||||
str(self.sys_config.path_extern_audio): {"bind": "/data", "mode": "rw"},
|
||||
"/run/dbus": {"bind": "/run/dbus", "mode": "ro"},
|
||||
"/run/udev": {"bind": "/run/udev", "mode": "ro"},
|
||||
@ -39,12 +39,6 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
if MACHINE_ID.exists():
|
||||
volumes.update({str(MACHINE_ID): {"bind": str(MACHINE_ID), "mode": "ro"}})
|
||||
|
||||
# SND support
|
||||
if Path("/dev/snd").exists():
|
||||
volumes.update({"/dev/snd": {"bind": "/dev/snd", "mode": "rw"}})
|
||||
else:
|
||||
_LOGGER.warning("Kernel have no audio support")
|
||||
|
||||
return volumes
|
||||
|
||||
def _run(self) -> None:
|
||||
|
@ -8,7 +8,7 @@ import requests
|
||||
|
||||
from ..const import ENV_TIME, ENV_TOKEN, ENV_TOKEN_HASSIO, LABEL_MACHINE, MACHINE_ID
|
||||
from ..exceptions import DockerError
|
||||
from ..hardware.const import PolicyGroup, UdevSubsystem
|
||||
from ..hardware.const import PolicyGroup
|
||||
from .interface import CommandReturn, DockerInterface
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@ -55,12 +55,14 @@ class DockerHomeAssistant(DockerInterface):
|
||||
self.sys_hardware.policy.get_cgroups_rules(PolicyGroup.UART)
|
||||
+ self.sys_hardware.policy.get_cgroups_rules(PolicyGroup.VIDEO)
|
||||
+ self.sys_hardware.policy.get_cgroups_rules(PolicyGroup.GPIO)
|
||||
+ self.sys_hardware.policy.get_cgroups_rules(PolicyGroup.USB)
|
||||
)
|
||||
|
||||
@property
|
||||
def volumes(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Return Volumes for the mount."""
|
||||
volumes = {
|
||||
"/dev": {"bind": "/dev", "mode": "ro"},
|
||||
"/run/dbus": {"bind": "/run/dbus", "mode": "ro"},
|
||||
"/run/udev": {"bind": "/run/udev", "mode": "ro"},
|
||||
}
|
||||
@ -106,16 +108,6 @@ class DockerHomeAssistant(DockerInterface):
|
||||
}
|
||||
)
|
||||
|
||||
# udev helper
|
||||
for mount in (
|
||||
self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.SERIAL),
|
||||
self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.DISK),
|
||||
self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.INPUT),
|
||||
):
|
||||
volumes.update(
|
||||
{str(mount.as_posix()): {"bind": str(mount.as_posix()), "mode": "ro"}}
|
||||
)
|
||||
|
||||
return volumes
|
||||
|
||||
def _run(self) -> None:
|
||||
|
@ -35,3 +35,10 @@ class PolicyGroup(str, Enum):
|
||||
USB = "usb"
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
|
||||
|
||||
class UdevAction(str, Enum):
|
||||
"""Udev device action."""
|
||||
|
||||
ADD = "add"
|
||||
REMOVE = "remove"
|
||||
|
@ -1,73 +0,0 @@
|
||||
"""Handle udev for container."""
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from supervisor.const import AddonState
|
||||
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..docker.interface import DockerInterface
|
||||
from ..exceptions import DockerError, DockerNotFound, HardwareError
|
||||
from .const import UdevSubsystem
|
||||
from .data import Device
|
||||
|
||||
_UDEV_BY_ID = {
|
||||
UdevSubsystem.SERIAL: Path("/dev/serial/by-id"),
|
||||
UdevSubsystem.DISK: Path("/dev/disk"),
|
||||
UdevSubsystem.INPUT: Path("/dev/input/by-id"),
|
||||
}
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HwContainer(CoreSysAttributes):
|
||||
"""Representation of an interface to udev / container."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Init hardware object."""
|
||||
self.coresys = coresys
|
||||
|
||||
def get_udev_id_mount(self, subystem: UdevSubsystem) -> Optional[Path]:
|
||||
"""Return mount path for udev device by-id path."""
|
||||
return _UDEV_BY_ID.get(subystem)
|
||||
|
||||
async def process_serial_device(self, device: Device) -> None:
|
||||
"""Process a new Serial device."""
|
||||
# Add to all needed add-ons
|
||||
for addon in self.sys_addons.installed:
|
||||
if addon.state != AddonState.STARTED or addon.with_uart:
|
||||
continue
|
||||
with suppress(HardwareError):
|
||||
await self._create_device(addon.instance, device)
|
||||
|
||||
# Process on Home Assistant Core
|
||||
with suppress(HardwareError):
|
||||
await self._create_device(self.sys_homeassistant.core.instance, device)
|
||||
|
||||
async def process_input_device(self, device: Device) -> None:
|
||||
"""Process a new Serial device."""
|
||||
# Process on Home Assistant Core
|
||||
with suppress(HardwareError):
|
||||
await self._create_device(self.sys_homeassistant.core.instance, device)
|
||||
|
||||
async def _create_device(self, instance: DockerInterface, device: Device) -> None:
|
||||
"""Add device into container."""
|
||||
try:
|
||||
answer = await instance.run_inside(
|
||||
f'sh -c "mknod -m 660 {device.path.as_posix()} c {device.cgroups_major} {device.cgroups_minor}"'
|
||||
)
|
||||
except DockerNotFound:
|
||||
return
|
||||
except DockerError as err:
|
||||
_LOGGER.warning("Can't add new device %s to %s", device.path, instance.name)
|
||||
raise HardwareError() from err
|
||||
|
||||
if answer.exit_code == 0:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Container response with '%s' during process %s",
|
||||
answer.output.encode(),
|
||||
device.path,
|
||||
)
|
@ -9,7 +9,6 @@ from supervisor.hardware.const import UdevSubsystem
|
||||
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import HardwareNotFound
|
||||
from .container import HwContainer
|
||||
from .data import Device
|
||||
from .helper import HwHelper
|
||||
from .monitor import HwMonitor
|
||||
@ -30,7 +29,6 @@ class HardwareManager(CoreSysAttributes):
|
||||
self._montior: HwMonitor = HwMonitor(coresys)
|
||||
self._helper: HwHelper = HwHelper(coresys)
|
||||
self._policy: HwPolicy = HwPolicy(coresys)
|
||||
self._container: HwContainer = HwContainer(coresys)
|
||||
|
||||
@property
|
||||
def monitor(self) -> HwMonitor:
|
||||
@ -47,11 +45,6 @@ class HardwareManager(CoreSysAttributes):
|
||||
"""Return Hardware policy instance."""
|
||||
return self._policy
|
||||
|
||||
@property
|
||||
def container(self) -> HwContainer:
|
||||
"""Return Hardware container instance."""
|
||||
return self._container
|
||||
|
||||
@property
|
||||
def devices(self) -> List[Device]:
|
||||
"""Return List of devices."""
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Supervisor Hardware monitor based on udev."""
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pprint import pformat
|
||||
@ -8,8 +9,9 @@ import pyudev
|
||||
|
||||
from ..const import CoreState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import HardwareNotFound
|
||||
from ..resolution.const import UnhealthyReason
|
||||
from .const import UdevSubsystem
|
||||
from .const import PolicyGroup, UdevAction, UdevSubsystem
|
||||
from .data import Device
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@ -45,82 +47,107 @@ class HwMonitor(CoreSysAttributes):
|
||||
self.observer.stop()
|
||||
_LOGGER.info("Stopped Supervisor hardware monitor")
|
||||
|
||||
def _udev_events(self, action: str, device: pyudev.Device):
|
||||
def _udev_events(self, action: str, kernel: pyudev.Device):
|
||||
"""Incomming events from udev.
|
||||
|
||||
This is inside a observe thread and need pass into our eventloop.
|
||||
"""
|
||||
_LOGGER.debug("Hardware monitor: %s - %s", action, pformat(device))
|
||||
self.sys_loop.call_soon_threadsafe(self._async_udev_events, action, device)
|
||||
_LOGGER.debug("Hardware monitor: %s - %s", action, pformat(kernel))
|
||||
udev = None
|
||||
with suppress(pyudev.DeviceNotFoundAtPathError):
|
||||
udev = pyudev.Devices.from_sys_path(self.context, kernel.sys_path)
|
||||
|
||||
def _async_udev_events(self, action: str, udev_device: pyudev.Device):
|
||||
self.sys_loop.call_soon_threadsafe(
|
||||
self._async_udev_events, action, kernel, udev
|
||||
)
|
||||
|
||||
def _async_udev_events(
|
||||
self, action: str, kernel: pyudev.Device, udev: Optional[pyudev.Device]
|
||||
):
|
||||
"""Incomming events from udev into loop."""
|
||||
# Update device List
|
||||
if not udev_device.device_node or self.sys_hardware.helper.hide_virtual_device(
|
||||
udev_device
|
||||
if not kernel.device_node or self.sys_hardware.helper.hide_virtual_device(
|
||||
kernel
|
||||
):
|
||||
return
|
||||
|
||||
device = Device(
|
||||
udev_device.sys_name,
|
||||
Path(udev_device.device_node),
|
||||
Path(udev_device.sys_path),
|
||||
udev_device.subsystem,
|
||||
[Path(node) for node in udev_device.device_links],
|
||||
{attr: udev_device.properties[attr] for attr in udev_device.properties},
|
||||
)
|
||||
forward_action = None
|
||||
device = None
|
||||
|
||||
# Update internal Database
|
||||
if action == "add":
|
||||
##
|
||||
# Remove
|
||||
if action in ("remove", "unbind") and udev is None:
|
||||
try:
|
||||
device = self.sys_hardware.get_by_path(Path(kernel.sys_path))
|
||||
except HardwareNotFound:
|
||||
return
|
||||
else:
|
||||
self.sys_hardware.delete_device(device)
|
||||
forward_action = UdevAction.REMOVE
|
||||
|
||||
##
|
||||
# Add
|
||||
if action in ("add", "bind") and udev is not None:
|
||||
device = Device(
|
||||
udev.sys_name,
|
||||
Path(udev.device_node),
|
||||
Path(udev.sys_path),
|
||||
udev.subsystem,
|
||||
[Path(node) for node in udev.device_links],
|
||||
{attr: udev.properties[attr] for attr in udev.properties},
|
||||
)
|
||||
self.sys_hardware.update_device(device)
|
||||
if action == "remove":
|
||||
self.sys_hardware.delete_device(device)
|
||||
forward_action = UdevAction.ADD
|
||||
|
||||
# Process device
|
||||
if self.sys_core.state in (CoreState.RUNNING, CoreState.FREEZE):
|
||||
# Process Action
|
||||
if (
|
||||
device
|
||||
and forward_action
|
||||
and self.sys_core.state in (CoreState.RUNNING, CoreState.FREEZE)
|
||||
):
|
||||
# New Sound device
|
||||
if device.subsystem == UdevSubsystem.AUDIO and action == "add":
|
||||
self._action_sound_add(device)
|
||||
if device.subsystem == UdevSubsystem.AUDIO:
|
||||
self._action_sound(device, forward_action)
|
||||
|
||||
# New serial device
|
||||
if device.subsystem == UdevSubsystem.SERIAL and action == "add":
|
||||
self._action_tty_add(device)
|
||||
# serial device
|
||||
elif device.subsystem == UdevSubsystem.SERIAL:
|
||||
self._action_tty(device, forward_action)
|
||||
|
||||
# New input device
|
||||
if device.subsystem == UdevSubsystem.INPUT and action == "add":
|
||||
self._action_input_add(device)
|
||||
# input device
|
||||
elif device.subsystem == UdevSubsystem.INPUT:
|
||||
self._action_input(device, forward_action)
|
||||
|
||||
def _action_sound_add(self, device: Device):
|
||||
# USB device
|
||||
elif device.subsystem == UdevSubsystem.USB:
|
||||
self._action_usb(device, forward_action)
|
||||
|
||||
def _action_sound(self, device: Device, action: UdevAction):
|
||||
"""Process sound actions."""
|
||||
_LOGGER.info("Detecting changed audio hardware - %s", device.path)
|
||||
if not self.sys_hardware.policy.is_match_cgroup(PolicyGroup.AUDIO, device):
|
||||
return
|
||||
_LOGGER.info("Detecting %s audio hardware - %s", action, device.path)
|
||||
self.sys_loop.call_later(2, self.sys_create_task, self.sys_host.sound.update())
|
||||
|
||||
def _action_tty_add(self, device: Device):
|
||||
def _action_tty(self, device: Device, action: UdevAction):
|
||||
"""Process tty actions."""
|
||||
_LOGGER.info(
|
||||
"Detecting changed serial hardware %s - %s", device.path, device.by_id
|
||||
)
|
||||
if not device.by_id:
|
||||
if not device.by_id or not self.sys_hardware.policy.is_match_cgroup(
|
||||
PolicyGroup.UART, device
|
||||
):
|
||||
return
|
||||
|
||||
# Start process TTY
|
||||
self.sys_loop.call_later(
|
||||
2,
|
||||
self.sys_create_task,
|
||||
self.sys_hardware.container.process_serial_device(device),
|
||||
_LOGGER.info(
|
||||
"Detecting %s serial hardware %s - %s", action, device.path, device.by_id
|
||||
)
|
||||
|
||||
def _action_input_add(self, device: Device):
|
||||
def _action_input(self, device: Device, action: UdevAction):
|
||||
"""Process input actions."""
|
||||
_LOGGER.info(
|
||||
"Detecting changed serial hardware %s - %s", device.path, device.by_id
|
||||
)
|
||||
if not device.by_id:
|
||||
return
|
||||
|
||||
# Start process input
|
||||
self.sys_loop.call_later(
|
||||
2,
|
||||
self.sys_create_task,
|
||||
self.sys_hardware.container.process_serial_device(device),
|
||||
_LOGGER.info(
|
||||
"Detecting %s serial hardware %s - %s", action, device.path, device.by_id
|
||||
)
|
||||
|
||||
def _action_usb(self, device: Device, action: UdevAction):
|
||||
"""Process usb actions."""
|
||||
if not self.sys_hardware.policy.is_match_cgroup(PolicyGroup.USB, device):
|
||||
return
|
||||
_LOGGER.info("Detecting %s usb hardware %s", action, device.path)
|
||||
|
@ -14,6 +14,7 @@ _GROUP_CGROUPS: Dict[PolicyGroup, List[int]] = {
|
||||
PolicyGroup.GPIO: [254, 245],
|
||||
PolicyGroup.VIDEO: [239, 29, 81, 251, 242, 226],
|
||||
PolicyGroup.AUDIO: [116],
|
||||
PolicyGroup.USB: [189],
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +25,10 @@ class HwPolicy(CoreSysAttributes):
|
||||
"""Init hardware policy object."""
|
||||
self.coresys = coresys
|
||||
|
||||
def is_match_cgroup(self, group: PolicyGroup, device: Device) -> bool:
|
||||
"""Return true if device is in cgroup Policy."""
|
||||
return device.cgroups_major in _GROUP_CGROUPS.get(group, [])
|
||||
|
||||
def get_cgroups_rules(self, group: PolicyGroup) -> List[str]:
|
||||
"""Generate cgroups rules for a policy group."""
|
||||
return [f"c {dev}:* rwm" for dev in _GROUP_CGROUPS.get(group, [])]
|
||||
|
@ -31,3 +31,18 @@ def test_policy_group(coresys):
|
||||
"c 242:* rwm",
|
||||
"c 226:* rwm",
|
||||
]
|
||||
|
||||
|
||||
def test_device_in_policy(coresys):
|
||||
"""Test device cgroup policy."""
|
||||
device = Device(
|
||||
"ttyACM0",
|
||||
Path("/dev/ttyACM0"),
|
||||
Path("/sys/bus/usb/001"),
|
||||
"tty",
|
||||
[],
|
||||
{"MAJOR": "204", "MINOR": "10"},
|
||||
)
|
||||
|
||||
assert coresys.hardware.policy.is_match_cgroup(PolicyGroup.UART, device)
|
||||
assert not coresys.hardware.policy.is_match_cgroup(PolicyGroup.GPIO, device)
|
||||
|
Loading…
x
Reference in New Issue
Block a user