chore: initial import from KNEL/PFVCluster@041d311 [#769]
Split per O&M lane work order. Full history: KNEL/PFVCluster. https://projects.knownelement.com/issues/769#note-4152
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mdns-hap-bridge: unicast-probe HomeKit devices, replay records via multicast.
|
||||
|
||||
Why: WiFi-side HomeKit accessories (ecobee, locks) are invisible to multicast
|
||||
browsers on the wired segment (AP-side multicast filtering, see #619). They DO
|
||||
answer unicast mDNS queries. This daemon probes configured devices and
|
||||
re-announces their records on the LAN so Home Assistant's zeroconf/HomeKit
|
||||
Controller discovers them normally.
|
||||
|
||||
Design notes:
|
||||
- Runs on the pfv-bms HAOS host (docker --restart=always), host network.
|
||||
- Config: /usr/local/etc/hap-bridge.conf — {"devices": ["192.168.1.212", ...]}
|
||||
- Record fidelity: PTR/SRV names are captured as text and re-encoded WITHOUT
|
||||
compression pointers (a raw replay would carry dangling pointers into the
|
||||
original packet); TXT replayed verbatim; A/AAAA raw.
|
||||
- SO_REUSEADDR + multicast group membership: coexists with host avahi.
|
||||
- Answers multicast queries for _hap._tcp.local and announces periodically.
|
||||
- Stdlib only. [#619]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
CONFIG_PATH = os.environ.get("CONFIG_PATH", "/usr/local/etc/hap-bridge.conf")
|
||||
MDNS_GROUP = "224.0.0.251"
|
||||
MDNS_PORT = 5353
|
||||
REFRESH_INTERVAL = 60
|
||||
REPLAY_INTERVAL = 30
|
||||
SERVICE = "_hap._tcp.local"
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("hap-bridge")
|
||||
|
||||
|
||||
def read_name(data, off):
|
||||
parts = []
|
||||
while True:
|
||||
length = data[off]
|
||||
if length == 0:
|
||||
off += 1
|
||||
break
|
||||
if length & 0xC0:
|
||||
ptr = struct.unpack(">H", data[off:off + 2])[0] & 0x3FFF
|
||||
parts.append(read_name(data, ptr)[0])
|
||||
off += 2
|
||||
break
|
||||
parts.append(data[off + 1:off + 1 + length].decode("latin1"))
|
||||
off += 1 + length
|
||||
return ".".join(parts), off
|
||||
|
||||
|
||||
def encode_name(name):
|
||||
out = b""
|
||||
for part in name.split("."):
|
||||
out += bytes([len(part)]) + part.encode("latin1")
|
||||
return out + b"\x00"
|
||||
|
||||
|
||||
def decode_rr_header(data, off):
|
||||
"""Return (rtype, rclass, ttl, rdata_len) for the RR header at offset."""
|
||||
return struct.unpack(">HHIH", data[off:off + 10])
|
||||
|
||||
|
||||
def merge_records(prev_recs, new_recs):
|
||||
"""Union record sets by (name, type); new records replace same-key old ones.
|
||||
|
||||
Devices often answer a multi-question probe with partial record sets
|
||||
(ecobee returns only the PTR, #619); accumulate across probes so a thin
|
||||
response never discards previously learned SRV/TXT/A records.
|
||||
"""
|
||||
merged = {(r["name"], r["type"]): r for r in prev_recs}
|
||||
for r in new_recs:
|
||||
merged[(r["name"], r["type"])] = r
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def probe_device(ip, timeout=2.0):
|
||||
"""Return normalized records from a device's mDNS response."""
|
||||
qname = encode_name(SERVICE)
|
||||
packet = struct.pack(">HHHHHH", 0x4242, 0, 3, 0, 0, 0)
|
||||
packet += b"".join(qname + struct.pack(">HH", t, 1) for t in (12, 33, 16))
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.sendto(packet, (ip, MDNS_PORT))
|
||||
while True:
|
||||
data, _ = s.recvfrom(16384)
|
||||
if not (data[2] & 0x80):
|
||||
continue
|
||||
qd = struct.unpack(">H", data[4:6])[0]
|
||||
an = struct.unpack(">H", data[6:8])[0]
|
||||
ns = struct.unpack(">H", data[8:10])[0]
|
||||
off = 12
|
||||
for _ in range(qd):
|
||||
_, off = read_name(data, off)
|
||||
off += 4
|
||||
records = []
|
||||
for _ in range(an + ns):
|
||||
name, name_end = read_name(data, off)
|
||||
rtype, _rclass, ttl, rlen = decode_rr_header(data, name_end)
|
||||
rec = {"name": name, "type": rtype, "ttl": min(ttl, 4500)}
|
||||
if rtype == 12: # PTR
|
||||
rec["ptr_target"] = read_name(data, name_end + 10)[0]
|
||||
elif rtype == 33: # SRV
|
||||
pri, weight, port = struct.unpack(">HHH", data[name_end + 10:name_end + 16])
|
||||
rec.update({
|
||||
"srv_prio": pri,
|
||||
"srv_weight": weight,
|
||||
"srv_port": port,
|
||||
"srv_target": read_name(data, name_end + 16)[0],
|
||||
})
|
||||
else: # TXT / A / AAAA / anything self-contained
|
||||
rec["rdata_hex"] = data[name_end + 10:name_end + 10 + rlen].hex()
|
||||
records.append(rec)
|
||||
off = name_end + 10 + rlen
|
||||
return records
|
||||
except (socket.timeout, OSError):
|
||||
return []
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def encode_record(rec):
|
||||
if rec["type"] == 12:
|
||||
rdata = encode_name(rec["ptr_target"])
|
||||
elif rec["type"] == 33:
|
||||
rdata = struct.pack(">HHH", rec["srv_prio"], rec["srv_weight"], rec["srv_port"])
|
||||
rdata += encode_name(rec["srv_target"])
|
||||
else:
|
||||
rdata = bytes.fromhex(rec["rdata_hex"])
|
||||
wire = encode_name(rec["name"])
|
||||
wire += struct.pack(">HHIH", rec["type"], 1, rec["ttl"], len(rdata))
|
||||
return wire + rdata
|
||||
|
||||
|
||||
def build_packet(records):
|
||||
header = struct.pack(">HHHHHH", 0, 0x8400, 0, len(records), 0, 0)
|
||||
return header + b"".join(encode_record(r) for r in records)
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f).get("devices", [])
|
||||
|
||||
|
||||
def main():
|
||||
devices = load_config()
|
||||
log.info("starting; devices: %s", devices)
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("", MDNS_PORT))
|
||||
mreq = socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0")
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
||||
|
||||
state = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
def refresh():
|
||||
for ip in devices:
|
||||
recs = probe_device(ip)
|
||||
with lock:
|
||||
if recs:
|
||||
state[ip] = merge_records(state.get(ip, []), recs)
|
||||
log.info("probe %s: %s", ip, f"{len(recs)} records" if recs else "no response")
|
||||
|
||||
def replay():
|
||||
with lock:
|
||||
snapshot = list(state.values())
|
||||
for recs in snapshot:
|
||||
try:
|
||||
sock.sendto(build_packet(recs), (MDNS_GROUP, MDNS_PORT))
|
||||
except OSError as exc:
|
||||
log.error("replay failed: %s", exc)
|
||||
log.info("replayed %d device(s)", len(snapshot))
|
||||
|
||||
def responder():
|
||||
while True:
|
||||
try:
|
||||
data, _addr = sock.recvfrom(4096)
|
||||
except OSError:
|
||||
continue
|
||||
if data[2] & 0x80:
|
||||
continue
|
||||
qd = struct.unpack(">H", data[4:6])[0]
|
||||
off = 12
|
||||
wants = False
|
||||
try:
|
||||
for _ in range(qd):
|
||||
name, off = read_name(data, off)
|
||||
if SERVICE in name:
|
||||
wants = True
|
||||
off += 4
|
||||
except (IndexError, struct.error):
|
||||
continue
|
||||
if wants:
|
||||
replay()
|
||||
|
||||
threading.Thread(target=responder, daemon=True).start()
|
||||
while True:
|
||||
refresh()
|
||||
replay()
|
||||
time.sleep(REPLAY_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mdns-browse: one-shot multicast mDNS enumeration from this vantage.
|
||||
|
||||
Sends PTR queries for the DNS-SD service-enumeration name plus common
|
||||
service types, listens briefly, prints every answer record. Stdlib only.
|
||||
Diagnostic tool for #619 (WiFi-side multicast reachability)."""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
MDNS_GROUP = "224.0.0.251"
|
||||
MDNS_PORT = 5353
|
||||
LISTEN_SECONDS = float(sys.argv[1]) if len(sys.argv) > 1 else 6.0
|
||||
TYPES = {
|
||||
1: "A", 28: "AAAA", 12: "PTR", 16: "TXT", 33: "SRV", 47: "NONE",
|
||||
}
|
||||
QUERIES = [
|
||||
"_services._dns-sd._udp.local",
|
||||
"_hap._tcp.local",
|
||||
"_airplay._tcp.local",
|
||||
"_googlecast._tcp.local",
|
||||
]
|
||||
|
||||
|
||||
def encode_name(name):
|
||||
out = b""
|
||||
for part in name.split("."):
|
||||
out += bytes([len(part)]) + part.encode("latin1")
|
||||
return out + b"\x00"
|
||||
|
||||
|
||||
def read_name(data, off):
|
||||
parts = []
|
||||
seen = set()
|
||||
while True:
|
||||
if off in seen or len(seen) > 64:
|
||||
return ".".join(parts), off
|
||||
seen.add(off)
|
||||
length = data[off]
|
||||
if length == 0:
|
||||
off += 1
|
||||
break
|
||||
if length & 0xC0:
|
||||
ptr = struct.unpack(">H", data[off:off + 2])[0] & 0x3FFF
|
||||
parts.append(read_name(data, ptr)[0])
|
||||
off += 2
|
||||
break
|
||||
parts.append(data[off + 1:off + 1 + length].decode("latin1"))
|
||||
off += 1 + length
|
||||
return ".".join(parts), off
|
||||
|
||||
|
||||
def build_query(names):
|
||||
packet = struct.pack(">HHHHHH", 0, 0, len(names), 0, 0, 0)
|
||||
for name in names:
|
||||
packet += encode_name(name) + struct.pack(">HH", 12, 1)
|
||||
return packet
|
||||
|
||||
|
||||
def main():
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("", MDNS_PORT))
|
||||
mreq = socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0")
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
||||
sock.settimeout(0.5)
|
||||
sock.sendto(build_query(QUERIES), (MDNS_GROUP, MDNS_PORT))
|
||||
deadline = time.time() + LISTEN_SECONDS
|
||||
found = set()
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, addr = sock.recvfrom(16384)
|
||||
except socket.timeout:
|
||||
continue
|
||||
an = struct.unpack(">H", data[6:8])[0]
|
||||
off = 12
|
||||
qd = struct.unpack(">H", data[4:6])[0]
|
||||
try:
|
||||
for _ in range(qd):
|
||||
_, off = read_name(data, off)
|
||||
off += 4
|
||||
for _ in range(an):
|
||||
name, name_end = read_name(data, off)
|
||||
rtype, _rclass, ttl, rlen = struct.unpack(
|
||||
">HHIH", data[name_end:name_end + 10])
|
||||
detail = ""
|
||||
if rtype == 12:
|
||||
detail = " -> " + read_name(data, name_end + 10)[0]
|
||||
found.add((TYPES.get(rtype, rtype), name, detail, addr[0]))
|
||||
off = name_end + 10 + rlen
|
||||
except (IndexError, struct.error):
|
||||
continue
|
||||
for rtype, name, detail, src in sorted(found):
|
||||
print(f"{src:15} {rtype:5} {name}{detail}")
|
||||
print(f"-- {len(found)} records from {LISTEN_SECONDS}s listen --")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for hap-bridge record handling. [#619]
|
||||
|
||||
Run: python3 -m unittest discover -s netinfra/mdns -p 'test_*.py'
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import unittest
|
||||
|
||||
_SPEC = importlib.util.spec_from_file_location(
|
||||
"hap_bridge", os.path.join(os.path.dirname(__file__), "hap-bridge.py")
|
||||
)
|
||||
hap_bridge = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(hap_bridge)
|
||||
|
||||
PTR = {"name": "_hap._tcp.local", "type": 12, "ttl": 4500, "ptr_target": "dev1._hap._tcp.local"}
|
||||
SRV_OLD = {"name": "dev1._hap._tcp.local", "type": 33, "ttl": 120, "srv_prio": 0,
|
||||
"srv_weight": 0, "srv_port": 8080, "srv_target": "dev1.local"}
|
||||
SRV_NEW = {"name": "dev1._hap._tcp.local", "type": 33, "ttl": 120, "srv_prio": 0,
|
||||
"srv_weight": 0, "srv_port": 8081, "srv_target": "dev1.local"}
|
||||
TXT = {"name": "dev1._hap._tcp.local", "type": 16, "ttl": 4500, "rdata_hex": "0141"}
|
||||
|
||||
|
||||
class TestMergeRecords(unittest.TestCase):
|
||||
def keyset(self, recs):
|
||||
return {(r["name"], r["type"]): r for r in recs}
|
||||
|
||||
def test_empty_prev_starts_fresh(self):
|
||||
merged = hap_bridge.merge_records([], [PTR, SRV_OLD, TXT])
|
||||
self.assertEqual(len(merged), 3)
|
||||
self.assertEqual(self.keyset(merged)[(PTR["name"], 12)], PTR)
|
||||
|
||||
def test_same_key_replaces(self):
|
||||
merged = hap_bridge.merge_records([PTR, SRV_OLD], [SRV_NEW])
|
||||
by_key = self.keyset(merged)
|
||||
self.assertEqual(by_key[(SRV_NEW["name"], 33)]["srv_port"], 8081)
|
||||
self.assertEqual(by_key[(PTR["name"], 12)], PTR)
|
||||
|
||||
def test_new_keys_union_in(self):
|
||||
merged = hap_bridge.merge_records([PTR], [SRV_NEW, TXT])
|
||||
self.assertEqual(len(merged), 3)
|
||||
|
||||
def test_empty_new_keeps_prev(self):
|
||||
merged = hap_bridge.merge_records([PTR, SRV_OLD], [])
|
||||
self.assertEqual(len(merged), 2)
|
||||
self.assertEqual(self.keyset(merged)[(SRV_OLD["name"], 33)]["srv_port"], 8080)
|
||||
|
||||
|
||||
class TestWireRoundTrip(unittest.TestCase):
|
||||
def test_ptr_record_encodes_without_compression(self):
|
||||
wire = hap_bridge.encode_record(PTR)
|
||||
name, off = hap_bridge.read_name(wire, 0)
|
||||
self.assertEqual(name, "_hap._tcp.local")
|
||||
rtype, rclass, ttl, rlen = hap_bridge.decode_rr_header(wire, off)
|
||||
self.assertEqual((rtype, rclass, rlen), (12, 1, len(hap_bridge.encode_name(PTR["ptr_target"]))))
|
||||
self.assertEqual(ttl, 4500)
|
||||
self.assertNotIn(b"\xc0", wire) # no compression pointers in replay
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user