somestuff-dnsstuff 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ """dnsstuff — DNS utilities for SPF resolution, ipset management and mDNS diagnostics.
2
+
3
+ Bundles several standalone DNS/network tools:
4
+
5
+ * :mod:`somestuff.dnsstuff.spf_ipset_updater` — SPF record crawler → ipset allowlist updater
6
+ * :mod:`somestuff.dnsstuff.update_21vianet_ips` — Microsoft 365 21Vianet Exchange IP → ipset updater
7
+ * :mod:`somestuff.dnsstuff.mdns_who` — resolve a ``.local`` name via mDNS and show who answered
8
+
9
+ Central logging setup lives here (loguru), mirroring the other packages in this
10
+ repo. Because some of these tools use third-party libraries that log via the
11
+ stdlib ``logging`` module (``dns.resolver``, ``pyroute2``, ``urllib`` …),
12
+ :func:`configure_logging` installs an intercept handler that funnels those
13
+ records into loguru instead of rewriting every call site.
14
+ """
15
+
16
+ import inspect
17
+ import logging
18
+ import os
19
+ import sys
20
+ from collections.abc import Callable
21
+ from types import FrameType
22
+ from typing import TYPE_CHECKING
23
+
24
+ from loguru import logger as glogger
25
+ from tabulate import tabulate
26
+
27
+ if TYPE_CHECKING:
28
+ from loguru import Record
29
+
30
+ __version__ = "0.2.1"
31
+
32
+ __all__ = ["__version__", "configure_logging", "print_banner"]
33
+
34
+
35
+ def _loguru_skiplog_filter(record: "Record") -> bool:
36
+ """Decide whether a loguru record should be emitted.
37
+
38
+ Args:
39
+ record: The loguru record about to be handled by a sink.
40
+
41
+ Returns:
42
+ ``False`` for records whose ``extra['skiplog']`` flag is truthy (they are
43
+ hidden), ``True`` otherwise.
44
+ """
45
+ return not record.get("extra", {}).get("skiplog", False)
46
+
47
+
48
+ class _InterceptHandler(logging.Handler):
49
+ """Route stdlib ``logging`` records into loguru with correct call-site info.
50
+
51
+ Third-party libraries used by this package (``dns.resolver``, ``pyroute2``,
52
+ ``urllib`` …) log via ``logging.getLogger(...)``; installing this on the root
53
+ logger lets :func:`configure_logging` govern all of them (format, level,
54
+ colour) without touching the individual call sites.
55
+ """
56
+
57
+ def emit(self, record: logging.LogRecord) -> None:
58
+ """Forward a single stdlib record to loguru.
59
+
60
+ Args:
61
+ record: The stdlib log record to re-emit through loguru, preserving
62
+ its level, message, exception info and originating call site.
63
+ """
64
+ # Map the stdlib level name to a loguru level, falling back to the number.
65
+ level: str | int
66
+ try:
67
+ level = glogger.level(record.levelname).name
68
+ except ValueError:
69
+ level = record.levelno
70
+ # Walk out of the logging module so {module}/{function}/{line} point at the
71
+ # real caller rather than at this handler (canonical loguru intercept recipe).
72
+ frame: FrameType | None = inspect.currentframe()
73
+ depth = 0
74
+ while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
75
+ frame = frame.f_back
76
+ depth += 1
77
+ glogger.bind(classname=record.name).opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
78
+
79
+
80
+ def configure_logging(
81
+ verbose: bool = False, loguru_filter: Callable[["Record"], bool] = _loguru_skiplog_filter
82
+ ) -> None:
83
+ """Configure a default ``loguru`` sink and funnel stdlib logging into it.
84
+
85
+ Args:
86
+ verbose: ``True`` logs at DEBUG, otherwise honours ``LOGURU_LEVEL`` (INFO
87
+ by default).
88
+ loguru_filter: Predicate deciding, per loguru record, whether it is emitted
89
+ by the sink. Defaults to :func:`_loguru_skiplog_filter`.
90
+ """
91
+ level_name: str = "DEBUG" if verbose else os.getenv("LOGURU_LEVEL", "INFO")
92
+ os.environ["LOGURU_LEVEL"] = level_name
93
+ glogger.remove()
94
+ logger_fmt: str = (
95
+ "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{module}</cyan>::<cyan>{extra[classname]}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
96
+ )
97
+ glogger.add(sys.stderr, level=level_name, format=logger_fmt, filter=loguru_filter) # type: ignore[arg-type]
98
+ glogger.configure(extra={"classname": "None", "skiplog": False})
99
+ # Replace any stdlib handlers with the intercept so all ``logging`` output
100
+ # (third-party libraries') flows through loguru.
101
+ logging.basicConfig(handlers=[_InterceptHandler()], level=logging.getLevelNamesMapping()[level_name], force=True)
102
+
103
+
104
+ def print_banner(title: str) -> None:
105
+ """Log the startup banner with version, build time and project links.
106
+
107
+ Args:
108
+ title: Tool-specific banner title (this package bundles several CLIs, so
109
+ each one passes its own, e.g. ``"mdns_who"``).
110
+ """
111
+ startup_rows = [
112
+ ["version", __version__],
113
+ ["buildtime", os.getenv("BUILDTIME", "unknown")],
114
+ ["github", "https://github.com/vroomfondel/somestuff/tree/main/somestuff/dnsstuff"],
115
+ ["Docker Hub", "https://hub.docker.com/r/xomoxcc/somestuff"],
116
+ ]
117
+ table_str = tabulate(startup_rows, tablefmt="mixed_grid")
118
+ lines = table_str.split("\n")
119
+ table_width = len(lines[0])
120
+ banner_title = f"{title} starting up"
121
+ title_border = "┍" + "━" * (table_width - 2) + "┑"
122
+ title_row = "│ " + banner_title.center(table_width - 4) + " │"
123
+ separator = lines[0].replace("┍", "┝").replace("┑", "┥").replace("┯", "┿")
124
+
125
+ glogger.opt(raw=True).info(f"\n{title_border}\n{title_row}\n{separator}\n{'\n'.join(lines[1:])}\n")
@@ -0,0 +1,430 @@
1
+ """Resolve a ``.local`` name via mDNS and show WHO actually answered.
2
+
3
+ ``avahi-resolve-host-name`` only gives you the final IP, not the responder. This
4
+ tool sends the mDNS query itself and collects EVERY response, logging for each:
5
+
6
+ - the source IP of the response packet (the host that really answered)
7
+ - the A record(s) it advertised
8
+ - the record TTL
9
+ - the mDNS cache-flush bit (set = authoritative unique record)
10
+
11
+ Stale/cache heuristic
12
+ ---------------------
13
+ A genuine mDNS host answers authoritatively for its OWN name, so the packet's
14
+ source IP == the advertised A record. Two smells indicate a proxy / cache /
15
+ reflected (e.g. router) reply instead of the real host:
16
+
17
+ 1. source-IP != advertised-IP -> someone answers on behalf of another IP
18
+ 2. more than one distinct responder -> conflicting / cached second answerer
19
+
20
+ Optional --unicast additionally resolves the bare name and <name>.fritz.box via
21
+ the system resolver, to surface a router DHCP-lease that disagrees with mDNS
22
+ (the classic "Fritzbox hands out a stale boot-IP" case).
23
+
24
+ By default the query is sent out of EVERY local interface (so a multi-homed host
25
+ is covered on every subnet regardless of the default route); pass --iface to pin
26
+ it to a single interface.
27
+
28
+ No external DNS dependencies (raw sockets + struct). Logging/banner setup is
29
+ shared with the rest of the package via :mod:`dnsstuff`.
30
+
31
+ Usage::
32
+
33
+ python3 -m somestuff.dnsstuff.mdns_who printer.local
34
+ python3 -m somestuff.dnsstuff.mdns_who printer --timeout 3 --unicast
35
+ python3 -m somestuff.dnsstuff.mdns_who printer.local --iface 192.0.2.205 # pin the send interface
36
+
37
+ Author: vroomfondel
38
+ Source: https://github.com/vroomfondel/somestuff/blob/main/somestuff/dnsstuff/mdns_who.py
39
+ """
40
+
41
+ import argparse
42
+ import socket
43
+ import struct
44
+ import sys
45
+ import time
46
+
47
+ from loguru import logger as glogger
48
+
49
+ from somestuff.dnsstuff import configure_logging, print_banner
50
+
51
+ MCAST_GRP = "224.0.0.251"
52
+ MCAST_PORT = 5353
53
+ QTYPE_A = 1
54
+ QCLASS_IN = 1
55
+ CACHE_FLUSH_BIT = 0x8000
56
+ QU_BIT = 0x8000 # "unicast response requested" (top bit of the question qclass)
57
+
58
+ logger = glogger.bind(classname="mdns_who")
59
+
60
+ # One parsed mDNS A answer: (advertised_ip, ttl, cache_flush).
61
+ Answer = tuple[str, int, bool]
62
+ # A collected answer plus the local interface the response packet arrived on.
63
+ Record = tuple[str, int, bool, str]
64
+
65
+
66
+ # --------------------------------------------------------------------------- #
67
+ # DNS wire helpers
68
+ # --------------------------------------------------------------------------- #
69
+ def encode_name(name: str) -> bytes:
70
+ """Encode a dotted name into DNS label form (b'\\x07printer\\x05local\\x00')."""
71
+ out = bytearray()
72
+ for label in name.rstrip(".").split("."):
73
+ b = label.encode("utf-8")
74
+ if len(b) > 63:
75
+ raise ValueError("label too long: %r" % label)
76
+ out.append(len(b))
77
+ out.extend(b)
78
+ out.append(0)
79
+ return bytes(out)
80
+
81
+
82
+ def parse_name(data: bytes, offset: int) -> tuple[str, int]:
83
+ """Parse a (possibly compressed) DNS name. Returns (name, next_offset)."""
84
+ labels: list[str] = []
85
+ next_offset: int | None = None
86
+ jumped = False
87
+ # Guard against pointer loops.
88
+ for _ in range(128):
89
+ if offset >= len(data):
90
+ break
91
+ length = data[offset]
92
+ if length == 0:
93
+ offset += 1
94
+ break
95
+ if length & 0xC0 == 0xC0: # compression pointer
96
+ if offset + 1 >= len(data):
97
+ break
98
+ pointer = ((length & 0x3F) << 8) | data[offset + 1]
99
+ if not jumped:
100
+ next_offset = offset + 2
101
+ offset = pointer
102
+ jumped = True
103
+ continue
104
+ offset += 1
105
+ labels.append(data[offset : offset + length].decode("utf-8", "replace"))
106
+ offset += length
107
+ if next_offset is None:
108
+ next_offset = offset
109
+ return ".".join(labels), next_offset
110
+
111
+
112
+ def build_query(name: str, want_unicast: bool) -> tuple[bytes, bytes, bytes]:
113
+ """Build an mDNS A-record query for `name`."""
114
+ header = struct.pack(">HHHHHH", 0, 0, 1, 0, 0, 0) # id=0, flags=0, 1 question
115
+ qclass = QCLASS_IN | (QU_BIT if want_unicast else 0)
116
+ question = encode_name(name) + struct.pack(">HH", QTYPE_A, qclass)
117
+ return header, question, header + question
118
+
119
+
120
+ def parse_a_answers(data: bytes, want_name: str) -> list[Answer]:
121
+ """
122
+ Parse a response message and return the list of A records whose owner name
123
+ matches `want_name` (case-insensitive). Each entry: (ip, ttl, cache_flush).
124
+ """
125
+ try:
126
+ _id, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", data[:12])
127
+ except struct.error:
128
+ return []
129
+ if not (flags & 0x8000): # QR bit -- only look at responses, not queries
130
+ return []
131
+ offset = 12
132
+ # Skip the question section.
133
+ for _ in range(qd):
134
+ _n, offset = parse_name(data, offset)
135
+ offset += 4 # qtype + qclass
136
+ results: list[Answer] = []
137
+ total_rr = an + ns + ar
138
+ for _ in range(total_rr):
139
+ if offset >= len(data):
140
+ break
141
+ rname, offset = parse_name(data, offset)
142
+ if offset + 10 > len(data):
143
+ break
144
+ rtype, rclass, ttl, rdlength = struct.unpack(">HHIH", data[offset : offset + 10])
145
+ offset += 10
146
+ rdata = data[offset : offset + rdlength]
147
+ offset += rdlength
148
+ if rtype == QTYPE_A and rdlength == 4:
149
+ if rname.lower().rstrip(".") == want_name.lower().rstrip("."):
150
+ ip = socket.inet_ntoa(rdata)
151
+ cache_flush = bool(rclass & CACHE_FLUSH_BIT)
152
+ results.append((ip, ttl, cache_flush))
153
+ return results
154
+
155
+
156
+ # --------------------------------------------------------------------------- #
157
+ # mDNS query/collect
158
+ # --------------------------------------------------------------------------- #
159
+ def _enable_pktinfo(sock: socket.socket) -> None:
160
+ """Request IP_PKTINFO so recvmsg() reveals the interface a packet arrived on."""
161
+ if hasattr(socket, "IP_PKTINFO"):
162
+ try:
163
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_PKTINFO, 1)
164
+ except OSError as exc:
165
+ logger.debug("IP_PKTINFO not available: %s" % exc)
166
+
167
+
168
+ def open_socket() -> tuple[socket.socket, bool]:
169
+ """
170
+ Try to bind :5353 (captures ALL multicast responses, incl. proxies). Fall
171
+ back to an ephemeral port (QU / unicast replies only) if :5353 is
172
+ unavailable. Group membership and the send interface(s) are set up per
173
+ interface by :func:`send_query`. Returns (sock, listen_on_5353).
174
+ """
175
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
176
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
177
+ if hasattr(socket, "SO_REUSEPORT"):
178
+ try:
179
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
180
+ except OSError:
181
+ pass
182
+ _enable_pktinfo(sock)
183
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
184
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
185
+ try:
186
+ sock.bind(("", MCAST_PORT))
187
+ return sock, True
188
+ except OSError:
189
+ # :5353 busy in an incompatible way -> ephemeral + request unicast reply.
190
+ sock.close()
191
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
192
+ _enable_pktinfo(sock)
193
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
194
+ sock.bind(("", 0))
195
+ return sock, False
196
+
197
+
198
+ def _send_targets(iface_ip: str | None) -> list[tuple[str, bytes, bytes]]:
199
+ """Build the per-interface socket-option payloads to join on and send out of.
200
+
201
+ Each entry is ``(label, join_opt, multicast_if_opt)``:
202
+
203
+ - with `iface_ip` pinned: a single ``ip_mreq`` / ``in_addr`` addressing that IP;
204
+ - otherwise: one ``ip_mreqn`` per local interface (by index, from
205
+ :func:`socket.if_nameindex`, loopback excluded), so a multi-homed host is
206
+ covered on every subnet regardless of the default route.
207
+ """
208
+ if iface_ip:
209
+ return [
210
+ (
211
+ iface_ip,
212
+ socket.inet_aton(MCAST_GRP) + socket.inet_aton(iface_ip), # ip_mreq
213
+ socket.inet_aton(iface_ip), # in_addr
214
+ )
215
+ ]
216
+ targets: list[tuple[str, bytes, bytes]] = []
217
+ for idx, name in socket.if_nameindex():
218
+ if name == "lo": # loopback only yields the local avahi echo -> skip
219
+ continue
220
+ packed_idx = struct.pack("@i", idx)
221
+ targets.append(
222
+ (
223
+ name,
224
+ socket.inet_aton(MCAST_GRP) + socket.inet_aton("0.0.0.0") + packed_idx, # ip_mreqn
225
+ socket.inet_aton("0.0.0.0") * 2 + packed_idx, # ip_mreqn (ifindex only)
226
+ )
227
+ )
228
+ return targets
229
+
230
+
231
+ def send_query(sock: socket.socket, packet: bytes, iface_ip: str | None, join: bool) -> list[str]:
232
+ """Send `packet` to the mDNS group out of every target interface.
233
+
234
+ Joins the multicast group per interface first when `join` (i.e. we bound
235
+ :5353 and listen for multicast replies). Interfaces that cannot join/send
236
+ (down, IPv4-less) are skipped. Returns the labels actually used.
237
+ """
238
+ sent_on: list[str] = []
239
+ for label, join_opt, if_opt in _send_targets(iface_ip):
240
+ try:
241
+ if join:
242
+ try:
243
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, join_opt)
244
+ except OSError as exc:
245
+ # Already joined / no IPv4 / down -> still try to send out of it.
246
+ logger.debug("group join on %s failed: %s" % (label, exc))
247
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, if_opt)
248
+ sock.sendto(packet, (MCAST_GRP, MCAST_PORT))
249
+ sent_on.append(label)
250
+ except OSError as exc:
251
+ logger.debug("send on %s failed: %s" % (label, exc))
252
+ return sent_on
253
+
254
+
255
+ def _recv(sock: socket.socket, if_names: dict[int, str]) -> tuple[bytes, str, str]:
256
+ """Receive one datagram, returning (data, src_ip, recv_iface).
257
+
258
+ Uses recvmsg() to read the IP_PKTINFO ancillary datum (the index of the
259
+ interface the packet arrived on), falling back to ``"?"`` when unavailable.
260
+ """
261
+ if hasattr(socket, "IP_PKTINFO"):
262
+ data, ancdata, _flags, addr = sock.recvmsg(9000, socket.CMSG_SPACE(12))
263
+ recv_iface = "?"
264
+ for level, msg_type, cmsg_data in ancdata:
265
+ if level == socket.IPPROTO_IP and msg_type == socket.IP_PKTINFO:
266
+ (ifindex,) = struct.unpack("@i", cmsg_data[:4]) # in_pktinfo.ipi_ifindex
267
+ recv_iface = if_names.get(ifindex, str(ifindex))
268
+ return data, addr[0], recv_iface
269
+ data, addr = sock.recvfrom(9000)
270
+ return data, addr[0], "?"
271
+
272
+
273
+ def query_mdns(name: str, timeout: float, iface_ip: str | None) -> tuple[dict[str, list[Record]], bool, str]:
274
+ """Send the mDNS query and collect every A-record answer, keyed by responder IP.
275
+
276
+ With `iface_ip` the query goes out of that interface only; without it, out of
277
+ every local interface. Each answer is tagged with the interface its response
278
+ packet arrived on. Returns ``(responders, listen_on_5353, scope)`` where
279
+ `scope` describes the interface(s) used, for the report header.
280
+ """
281
+ sock, listen_5353 = open_socket()
282
+ # If we could not grab :5353 we depend on unicast replies -> set the QU bit.
283
+ _h, _q, packet = build_query(name, want_unicast=not listen_5353)
284
+ sent_on = send_query(sock, packet, iface_ip, join=listen_5353)
285
+ if not sent_on:
286
+ logger.warning("could not send the query on any interface")
287
+
288
+ if_names = {idx: iname for idx, iname in socket.if_nameindex()}
289
+ responders: dict[str, list[Record]] = {} # src_ip -> list of (answer_ip, ttl, cache_flush, recv_iface)
290
+ deadline = time.monotonic() + timeout
291
+ while True:
292
+ remaining = deadline - time.monotonic()
293
+ if remaining <= 0:
294
+ break
295
+ sock.settimeout(remaining)
296
+ try:
297
+ data, src_ip, recv_iface = _recv(sock, if_names)
298
+ except socket.timeout:
299
+ break
300
+ except OSError:
301
+ break
302
+ answers = parse_a_answers(data, name)
303
+ if answers:
304
+ recs = responders.setdefault(src_ip, [])
305
+ for ip, ttl, cf in answers:
306
+ rec: Record = (ip, ttl, cf, recv_iface)
307
+ if rec not in recs:
308
+ recs.append(rec)
309
+ sock.close()
310
+ scope = iface_ip if iface_ip else "all interfaces (%s)" % ", ".join(sent_on)
311
+ return responders, listen_5353, scope
312
+
313
+
314
+ # --------------------------------------------------------------------------- #
315
+ # unicast cross-check (surfaces router DHCP-lease disagreements)
316
+ # --------------------------------------------------------------------------- #
317
+ def unicast_lookup(host: str) -> list[str]:
318
+ """Resolve `host` via the system resolver, returning the sorted IPv4 addresses."""
319
+ try:
320
+ infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM)
321
+ ips = sorted({str(i[4][0]) for i in infos})
322
+ return ips
323
+ except OSError as exc:
324
+ return ["<%s>" % (exc.strerror or exc)]
325
+
326
+
327
+ # --------------------------------------------------------------------------- #
328
+ # report
329
+ # --------------------------------------------------------------------------- #
330
+ def report(name: str, responders: dict[str, list[Record]], listen_5353: bool, timeout: float, scope: str) -> None:
331
+ """Log the responder table and the stale/cache verdict for `name`."""
332
+ transport = "multicast-listen (:5353)" if listen_5353 else "unicast-reply (ephemeral)"
333
+ logger.info("mDNS query for %s [%s via %s, timeout %.1fs]" % (name, transport, scope, timeout))
334
+ logger.info("=" * 68)
335
+
336
+ if not responders:
337
+ logger.warning(" no mDNS responder answered.")
338
+ return
339
+
340
+ all_answer_ips: set[str] = set()
341
+ for src_ip in sorted(responders):
342
+ for ip, ttl, cf, recv_iface in responders[src_ip]:
343
+ all_answer_ips.add(ip)
344
+ match = ip == src_ip
345
+ tag = "self (authoritative)" if match else "PROXY/CACHE (src != answer!)"
346
+ flush = "cache-flush" if cf else "shared"
347
+ logger.info(
348
+ " responder %-15s (in via %-10s) -> A %-15s ttl=%-6d %-11s %s"
349
+ % (src_ip, recv_iface, ip, ttl, flush, tag)
350
+ )
351
+
352
+ logger.info("-" * 68)
353
+ # Verdict.
354
+ problems: list[str] = []
355
+ if len(responders) > 1:
356
+ problems.append("MULTIPLE responders (%d) -> conflicting/cached answerer" % len(responders))
357
+ mismatched = [s for s in responders for (ip, _t, _c, _if) in responders[s] if ip != s]
358
+ if mismatched:
359
+ problems.append("source-IP != advertised-IP -> proxy/cache/reflected reply")
360
+ if len(all_answer_ips) > 1:
361
+ problems.append("DISAGREEING answers: %s" % ", ".join(sorted(all_answer_ips)))
362
+ if problems:
363
+ logger.warning(" VERDICT: suspicious")
364
+ for p in problems:
365
+ logger.warning(" - " + p)
366
+ else:
367
+ only_ip = next(iter(all_answer_ips))
368
+ logger.info(
369
+ " VERDICT: clean -- single authoritative host %s answered with %s" % (next(iter(responders)), only_ip)
370
+ )
371
+
372
+
373
+ def report_unicast(name: str) -> None:
374
+ """Log the unicast cross-check that surfaces router DHCP-lease disagreements."""
375
+ bare = name[: -len(".local")] if name.endswith(".local") else name
376
+ logger.info("")
377
+ logger.info("unicast cross-check (system resolver)")
378
+ logger.info("=" * 68)
379
+ for h in (bare, bare + ".fritz.box", bare + ".local"):
380
+ logger.info(" %-24s -> %s" % (h, ", ".join(unicast_lookup(h))))
381
+ logger.info("-" * 68)
382
+ logger.info(" (a .fritz.box / bare answer that differs from the mDNS IP above is")
383
+ logger.info(" a router DHCP-lease -- e.g. a stale boot-time lease -- not the host.)")
384
+
385
+
386
+ # --------------------------------------------------------------------------- #
387
+ # main
388
+ # --------------------------------------------------------------------------- #
389
+ def main(argv: list[str] | None = None) -> int:
390
+ """Parse arguments, run the mDNS query and log the who-answered report."""
391
+ ap = argparse.ArgumentParser(
392
+ description="Resolve a .local name via mDNS and show which host answered "
393
+ "(and whether it smells like a stale/cache reply)."
394
+ )
395
+ ap.add_argument("name", help="hostname to resolve (bare name -> .local is appended)")
396
+ ap.add_argument("--timeout", type=float, default=2.0, help="seconds to collect responses (default: 2.0)")
397
+ ap.add_argument(
398
+ "--iface",
399
+ metavar="IP",
400
+ default=None,
401
+ help="pin the query to this local interface IP (default: send out of every interface)",
402
+ )
403
+ ap.add_argument(
404
+ "--unicast",
405
+ action="store_true",
406
+ help="also do a unicast lookup of the bare name and <name>.fritz.box to surface a router DHCP-lease mismatch",
407
+ )
408
+ ap.add_argument("--verbose", "-v", action="store_true", help="enable DEBUG logging")
409
+ args = ap.parse_args(argv)
410
+
411
+ configure_logging(verbose=args.verbose)
412
+ print_banner("mdns_who")
413
+
414
+ name: str = args.name
415
+ if "." not in name:
416
+ name = name + ".local"
417
+ if not name.endswith(".local") and not args.unicast:
418
+ logger.warning("note: %r is not a .local name; mDNS may return nothing." % name)
419
+
420
+ responders, listen_5353, scope = query_mdns(name, args.timeout, args.iface)
421
+ report(name, responders, listen_5353, args.timeout, scope)
422
+
423
+ if args.unicast:
424
+ report_unicast(name)
425
+
426
+ return 0
427
+
428
+
429
+ if __name__ == "__main__":
430
+ sys.exit(main())
File without changes