gsproremote/backend/app/core/mdns.py

336 lines
9.9 KiB
Python
Raw Normal View History

"""
mDNS service registration for GSPro Remote.
Allows the application to be discovered on the local network.
"""
import logging
import socket
import threading
from typing import Optional, Dict, Any
try:
from zeroconf import ServiceInfo, Zeroconf, IPVersion
except ImportError as e:
raise ImportError(f"Zeroconf library not installed: {e}")
logger = logging.getLogger(__name__)
class MDNSService:
"""
Manages mDNS/Bonjour service registration for network discovery.
"""
def __init__(
self,
name: str = "gsproapp",
port: int = 5005,
service_type: str = "_http._tcp.local.",
properties: Optional[Dict[str, Any]] = None,
):
"""
Initialize mDNS service.
Args:
name: Service name (will be accessible as {name}.local)
port: Port number the service is running on
service_type: mDNS service type
properties: Additional service properties
"""
self.name = name
self.port = port
self.service_type = service_type
self.properties = properties or {}
self.zeroconf: Optional[Zeroconf] = None
self.service_info: Optional[ServiceInfo] = None
self.is_running = False
self._lock = threading.Lock()
def _get_local_ip(self) -> str:
"""
Get the local IP address of the machine.
Returns:
Local IP address as string
"""
try:
# Create a socket to determine the local IP
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
# Connect to a public DNS server to determine local interface
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
# Fallback to localhost if can't determine
return "127.0.0.1"
def _create_service_info(self) -> ServiceInfo:
"""
Create the ServiceInfo object for registration.
Returns:
Configured ServiceInfo object
"""
local_ip = self._get_local_ip()
hostname = socket.gethostname()
# Create fully qualified service name
service_name = f"{self.name}.{self.service_type}"
# Add default properties
default_properties = {
"version": "0.1.0",
"platform": "windows",
"api": "rest",
"ui": "web",
}
# Merge with custom properties
all_properties = {**default_properties, **self.properties}
# Convert properties to bytes
properties_bytes = {}
for key, value in all_properties.items():
if isinstance(value, str):
properties_bytes[key] = value.encode("utf-8")
elif isinstance(value, bytes):
properties_bytes[key] = value
else:
properties_bytes[key] = str(value).encode("utf-8")
# Create service info
service_info = ServiceInfo(
type_=self.service_type,
name=service_name,
addresses=[socket.inet_aton(local_ip)],
port=self.port,
properties=properties_bytes,
server=f"{hostname}.local.",
)
return service_info
def start(self) -> bool:
"""
Start the mDNS service registration.
Returns:
True if service started successfully, False otherwise
"""
with self._lock:
if self.is_running:
logger.warning("mDNS service is already running")
return True
try:
# Create Zeroconf instance
self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
# Create and register service
self.service_info = self._create_service_info()
self.zeroconf.register_service(self.service_info)
self.is_running = True
logger.info(f"mDNS service registered: {self.name}.local:{self.port} (type: {self.service_type})")
return True
except Exception as e:
logger.error(f"Failed to start mDNS service: {e}")
self.cleanup()
return False
def stop(self) -> None:
"""Stop the mDNS service registration."""
with self._lock:
if not self.is_running:
return
self.cleanup()
self.is_running = False
logger.info("mDNS service stopped")
def cleanup(self) -> None:
"""Clean up mDNS resources."""
try:
if self.zeroconf and self.service_info:
self.zeroconf.unregister_service(self.service_info)
if self.zeroconf:
self.zeroconf.close()
self.zeroconf = None
self.service_info = None
except Exception as e:
logger.error(f"Error during mDNS cleanup: {e}")
def update_properties(self, properties: Dict[str, Any]) -> bool:
"""
Update service properties.
Args:
properties: New properties to set
Returns:
True if properties updated successfully, False otherwise
"""
with self._lock:
if not self.is_running:
logger.warning("Cannot update properties: service is not running")
return False
try:
self.properties.update(properties)
# Recreate and re-register service with new properties
if self.zeroconf and self.service_info:
self.zeroconf.unregister_service(self.service_info)
self.service_info = self._create_service_info()
self.zeroconf.register_service(self.service_info)
logger.info("mDNS service properties updated")
return True
except Exception as e:
logger.error(f"Failed to update mDNS properties: {e}")
return False
def get_url(self) -> str:
"""
Get the URL for accessing the service.
Returns:
Service URL
"""
return f"http://{self.name}.local:{self.port}"
def __enter__(self):
"""Context manager entry."""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.stop()
class MDNSBrowser:
"""
Browse for mDNS services on the network.
Useful for discovering other GSPro Remote instances.
"""
def __init__(self, service_type: str = "_http._tcp.local."):
"""
Initialize mDNS browser.
Args:
service_type: Type of services to browse for
"""
self.service_type = service_type
self.services: Dict[str, Dict[str, Any]] = {}
self.zeroconf: Optional[Zeroconf] = None
def browse(self, timeout: float = 5.0) -> Dict[str, Dict[str, Any]]:
"""
Browse for services on the network.
Args:
timeout: Time to wait for services (seconds)
Returns:
Dictionary of discovered services
"""
try:
from zeroconf import ServiceBrowser, ServiceListener
import time
class Listener(ServiceListener):
def __init__(self, browser):
self.browser = browser
def add_service(self, zeroconf, service_type, name):
info = zeroconf.get_service_info(service_type, name)
if info:
self.browser.services[name] = {
"name": name,
"address": socket.inet_ntoa(info.addresses[0]) if info.addresses else None,
"port": info.port,
"properties": info.properties,
}
def remove_service(self, zeroconf, service_type, name):
self.browser.services.pop(name, None)
def update_service(self, zeroconf, service_type, name):
pass
self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
listener = Listener(self)
browser = ServiceBrowser(self.zeroconf, self.service_type, listener)
# Wait for services to be discovered
time.sleep(timeout)
browser.cancel()
self.zeroconf.close()
return self.services
except Exception as e:
logger.error(f"Failed to browse for services: {e}")
return {}
# Test function for development
def test_mdns_service():
"""Test mDNS service registration."""
import time
print("Testing mDNS service registration...")
# Test service registration
service = MDNSService(
name="gsproapp-test",
port=5005,
properties={"test": "true", "instance": "development"},
)
if service.start():
print(f"✓ mDNS service started: {service.get_url()}")
print(f" You should be able to access it at: http://gsproapp-test.local:5005")
# Keep service running for 10 seconds
print(" Service will run for 10 seconds...")
time.sleep(10)
# Test property update
if service.update_properties({"status": "running", "uptime": "10s"}):
print("✓ Properties updated successfully")
service.stop()
print("✓ mDNS service stopped")
else:
print("✗ Failed to start mDNS service")
# Test service browsing
print("\nBrowsing for HTTP services on the network...")
browser = MDNSBrowser()
services = browser.browse(timeout=3.0)
if services:
print(f"Found {len(services)} services:")
for name, info in services.items():
print(f" - {name}: {info['address']}:{info['port']}")
else:
print("No services found")
print("\nmDNS test complete!")
if __name__ == "__main__":
test_mdns_service()