kiwi/app/services/community/mdns.py

111 lines
3.5 KiB
Python

# app/services/community/mdns.py
# MIT License
# mDNS advertisement for Kiwi instances on the local network.
# Advertises _kiwi._tcp.local so other Kiwi instances (and discovery apps)
# can find this one without manual configuration.
#
# Opt-in only: enabled=False by default. Users are prompted on first community
# tab access. Never advertised without explicit consent (a11y requirement).
from __future__ import annotations
import logging
import socket
from typing import Any
logger = logging.getLogger(__name__)
# Deferred import — avoid hard failure when zeroconf is not installed.
try:
from zeroconf import ServiceInfo, Zeroconf
_ZEROCONF_AVAILABLE = True
except ImportError: # pragma: no cover
_ZEROCONF_AVAILABLE = False
class KiwiMDNS:
"""Context manager that advertises this Kiwi instance via mDNS (_kiwi._tcp.local).
Defaults to disabled. User must explicitly opt in via Settings.
feed_url is broadcast in the TXT record so peer instances know where to fetch posts.
Usage:
mdns = KiwiMDNS(
enabled=settings.MDNS_ENABLED,
port=8512,
feed_url="http://10.0.0.5:8512/api/v1/community/local-feed",
)
mdns.start() # in lifespan startup
mdns.stop() # in lifespan shutdown
"""
SERVICE_TYPE = "_kiwi._tcp.local."
def __init__(
self,
port: int = 8512,
name: str | None = None,
feed_url: str = "",
enabled: bool = False,
) -> None:
self._port = port
self._name = name or f"kiwi-{socket.gethostname()}"
self._feed_url = feed_url
self._enabled = enabled
self._zc: Any = None
self._info: Any = None
def start(self) -> None:
if not self._enabled:
logger.info("mDNS advertisement disabled (user opt-in required)")
return
try:
local_ip = _get_local_ip()
props = {b"product": b"kiwi", b"version": b"1"}
if self._feed_url:
props[b"feed"] = self._feed_url.encode()
self._info = ServiceInfo(
type_=self.SERVICE_TYPE,
name=f"{self._name}.{self.SERVICE_TYPE}",
addresses=[socket.inet_aton(local_ip)],
port=self._port,
properties=props,
server=f"{socket.gethostname()}.local.",
)
self._zc = Zeroconf()
self._zc.register_service(self._info)
logger.info("mDNS: advertising %s on %s:%d", self._name, local_ip, self._port)
except Exception as exc:
logger.warning("mDNS advertisement failed (non-fatal): %s", exc)
self._zc = None
self._info = None
def stop(self) -> None:
if self._zc and self._info:
try:
self._zc.unregister_service(self._info)
self._zc.close()
logger.info("mDNS: unregistered %s", self._name)
except Exception as exc:
logger.warning("mDNS unregister failed (non-fatal): %s", exc)
finally:
self._zc = None
self._info = None
def __enter__(self) -> "KiwiMDNS":
self.start()
return self
def __exit__(self, *_: object) -> None:
self.stop()
def _get_local_ip() -> str:
"""Return the primary non-loopback IPv4 address of this host."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"