From 97b86828183aad8449c870028660f1d3298024c6 Mon Sep 17 00:00:00 2001 From: Santiago Lo Coco Date: Tue, 5 Nov 2024 19:34:45 +0100 Subject: [PATCH] Add files --- .gitignore | 137 +++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 38 +++++++++++ README.md | 30 ++++++++ src/broadcast.py | 27 ++++++++ src/broadcast_multiple.py | 43 ++++++++++++ src/client.py | 22 ++++++ src/requirements.txt | 1 + src/rfc_client.py | 139 ++++++++++++++++++++++++++++++++++++++ src/setup.cfg | 2 + 9 files changed, 439 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 src/broadcast.py create mode 100644 src/broadcast_multiple.py create mode 100644 src/client.py create mode 100644 src/requirements.txt create mode 100644 src/rfc_client.py create mode 100644 src/setup.cfg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d9e72b --- /dev/null +++ b/.gitignore @@ -0,0 +1,137 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pdm +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +html/ +other/ +old/ +zeroconf_test.py \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e8fcea1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + exclude: (observability|testing/tavern) + - id: debug-statements + exclude: tests/ + - id: destroyed-symlinks + - id: end-of-file-fixer + files: \.(py|sh|rst|yml|yaml)$ + - id: mixed-line-ending + - id: trailing-whitespace + files: \.(py|sh|rst|yml|yaml)$ + - repo: https://github.com/ambv/black + rev: 23.10.0 + hooks: + - id: black + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + args: [--config, src/setup.cfg] + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: [ + '--profile', + 'black', + '--src-path', + 'src', + ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..265e616 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Zeroconf + +This Python project broadcasts a service on a local network using UDP to enable client devices to discover and connect to it. It is especially useful when DNS is unavailable, or setting up DNS records is not feasible. By listening to UDP multicast messages, client applications (such as a Unity-based HoloLens app) can dynamically discover the service location without needing hardcoded addresses. + +## Features + +- **Local service discovery**: Allows clients on the same network to find the service by listening to a specific multicast address and port (`224.0.0.251:5353`) +- **Broadcasted service information**: Periodically sends out service details, including IP and port, for clients to pick up and use. +- **Reliable fallback option**: Offers service discovery without relying on DNS, suitable for local networks and ad-hoc setups/networks. + +## Prerequisites + +- `python >= 3.11` + +## Usage + +Run the script: + +```bash +python broadcast.py +``` + +## How clients receive the service announcement + +Clients can listen to the same multicast address and port to receive service information. For instance, in Unity, you could set up a UDP listener on the multicast address, parse the received data, and use it to connect to the service. For debugging purposes, we include a `client.py` that you can run to test that the UDP broadcast is working as expected. + +## Limitations + +- **Local network only**: The UDP multicast approach is intended for devices on the same local network, and may not work over the internet or in networks that restrict multicast traffic. +- **No authentication**: Broadcasts are open to any listener on the network, so it should only be used in secure, controlled environments. diff --git a/src/broadcast.py b/src/broadcast.py new file mode 100644 index 0000000..c316bea --- /dev/null +++ b/src/broadcast.py @@ -0,0 +1,27 @@ +from zeroconf import Zeroconf, ServiceInfo +import socket + +service_type = "_http._tcp.local." +service_name = "RoomF2703._http._tcp.local." +server_ip = "192.168.137.1" +server_port = 5000 + +ip_address = socket.inet_aton(server_ip) +zeroconf = Zeroconf() +info = ServiceInfo( + type_=service_type, + name=service_name, + addresses=[ip_address], + server="RoomF2703.local.", + port=server_port, + properties={"path": "/api/endpoints"} +) + +zeroconf.register_service(info) +print(f"Service {service_name} is now discoverable on {server_ip}:{server_port}") + +try: + input("Press enter to exit...\n\n") +finally: + zeroconf.unregister_service(info) + zeroconf.close() diff --git a/src/broadcast_multiple.py b/src/broadcast_multiple.py new file mode 100644 index 0000000..be98a3f --- /dev/null +++ b/src/broadcast_multiple.py @@ -0,0 +1,43 @@ +from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser +import socket +import threading +import time + +service_type = "_http._tcp.local." +server_ip = "192.168.137.1" +server_port = 5000 +room_services = [ + "room2704", + "room2705", + "room2706", + "room2707", + "room2708" +] + +zeroconf = Zeroconf() +ip_address = socket.inet_aton(server_ip) +service_infos = [] + +def register_services(): + for room in room_services: + info = ServiceInfo( + type_=service_type, + name=f"{room}.{service_type}", + addresses=[ip_address], + server=f"{room}.local.", + port=server_port, + properties={"path": "/api/endpoints"} + ) + zeroconf.register_service(info) + service_infos.append(info) + print(f"Service {room} is now discoverable on {server_ip}:{server_port}") + +if __name__ == "__main__": + register_services() + + try: + input("Press enter to exit...\n\n") + finally: + for info in service_infos: + zeroconf.unregister_service(info) + zeroconf.close() diff --git a/src/client.py b/src/client.py new file mode 100644 index 0000000..d919aa8 --- /dev/null +++ b/src/client.py @@ -0,0 +1,22 @@ +from zeroconf import ServiceBrowser, ServiceListener, Zeroconf + + +class MyListener(ServiceListener): + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: + print(f"Service {name} updated") + + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: + print(f"Service {name} removed") + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + info = zc.get_service_info(type_, name) + print(f"Service {name} added, service info: {info}") + + +zeroconf = Zeroconf() +listener = MyListener() +browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener) +try: + input("Press enter to exit...\n\n") +finally: + zeroconf.close() \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..42ed9a4 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1 @@ +zerconf==0.136.0 \ No newline at end of file diff --git a/src/rfc_client.py b/src/rfc_client.py new file mode 100644 index 0000000..432daf9 --- /dev/null +++ b/src/rfc_client.py @@ -0,0 +1,139 @@ +import socket +import struct +import time + +def parse_mdns_response(data): + transaction_id, flags, questions, answer_rrs, authority_rrs, additional_rrs = struct.unpack('>HHHHHH', data[:12]) + offset = 12 + + for _ in range(questions): + offset = skip_name(data, offset) + offset += 4 + + for _ in range(answer_rrs): + offset = parse_record(data, offset) + + for _ in range(additional_rrs): + offset = parse_record(data, offset) + +def parse_record(data, offset): + name, offset = read_name(data, offset) + + record_type, record_class, ttl, data_length = struct.unpack('>HHIH', data[offset:offset + 10]) + offset += 10 + + if ttl == 0: + print(f"Zero TTL for {name}") + return offset + data_length + if record_type == 12: # PTR + target, _ = read_name(data, offset) + print(f"PTR Record: {name} -> {target}") + elif record_type == 1: # A + ip_address = socket.inet_ntoa(data[offset:offset + data_length]) + print(f"A Record: {name} -> {ip_address}") + elif record_type == 33: # SRV + priority, weight, port = struct.unpack('>HHH', data[offset:offset + 6]) + target, _ = read_name(data, offset + 6) + print(f"SRV Record: {name} -> {target}:{port} (priority: {priority}, weight: {weight})") + elif record_type == 16: # TXT + print(data_length) + txt_data = data[offset:offset + data_length].decode('utf-8') + print(f"TXT Record: {name} -> {txt_data}") + elif record_type == 47: # NSEC + next_domain, _ = read_name(data, offset) + print(f"NSEC Record: {name} -> {next_domain}") + else: + print(f"{hex(record_type)}") + print(f"Unknown Record Type {record_type} for {name}") + + return offset + data_length + +def read_name(data, offset): + labels = [] + original_offset = offset + jumped = False + + while True: + length = data[offset] + + if length & 0xC0 == 0xC0: + if not jumped: + original_offset = offset + 2 + + pointer = struct.unpack('>H', data[offset:offset + 2])[0] & 0x3FFF + offset = pointer + jumped = True + elif length == 0: + offset += 1 + break + else: + offset += 1 + labels.append(data[offset:offset + length].decode('utf-8', errors='ignore')) + offset += length + + if jumped: + return '.'.join(labels), original_offset + else: + return '.'.join(labels), offset + +def skip_name(data, offset): + while data[offset] != 0: + if data[offset] & 0xC0 == 0xC0: + return offset + 2 + offset += data[offset] + 1 + return offset + 1 + + +def send_mdns_query(service_type): + multicast_address = "224.0.0.251" + multicast_port = 5353 + interface_ip = "192.168.137.1" + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((interface_ip, multicast_port)) + sock.setsockopt( + socket.IPPROTO_IP, + socket.IP_ADD_MEMBERSHIP, + struct.pack("=4s4s", socket.inet_aton(multicast_address), socket.inet_aton(interface_ip)) + ) + + def create_query(): + query = bytearray() + query += b'\x00\x00' + query += b'\x00\x00' + query += b'\x00\x01' + query += b'\x00\x00' + query += b'\x00\x00' + query += b'\x00\x00' + + for part in service_type.split('.'): + query += bytes([len(part)]) + part.encode() + query += b'\x00' + query += b'\x00\x0C' + query += b'\x00\x01' + + return query + + query_packet = create_query() + sock.sendto(query_packet, (multicast_address, multicast_port)) + print(f"Sent mDNS query for {service_type}") + + try: + while True: + data, addr = sock.recvfrom(1024) + print(f"Received response from {addr[0]}") + + flags = struct.unpack('>H', data[2:4])[0] + if flags == 0: + print("Ignoring non-response packet") + continue + + parse_mdns_response(data) + except KeyboardInterrupt: + print("Client stopped.") + finally: + sock.close() + +if __name__ == "__main__": + send_mdns_query("_http._tcp.local") diff --git a/src/setup.cfg b/src/setup.cfg new file mode 100644 index 0000000..fedd799 --- /dev/null +++ b/src/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 110 \ No newline at end of file