Initial commit: GSPro Remote MVP - Phase 1 complete
This commit is contained in:
commit
74ca4b38eb
50 changed files with 12818 additions and 0 deletions
335
backend/app/core/mdns.py
Normal file
335
backend/app/core/mdns.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"""
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue