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.
- somestuff/dnsstuff/__init__.py +125 -0
- somestuff/dnsstuff/mdns_who.py +430 -0
- somestuff/dnsstuff/py.typed +0 -0
- somestuff/dnsstuff/spf_ipset_updater.py +510 -0
- somestuff/dnsstuff/update_21vianet_ips.py +552 -0
- somestuff_dnsstuff-0.2.1.dist-info/METADATA +108 -0
- somestuff_dnsstuff-0.2.1.dist-info/RECORD +10 -0
- somestuff_dnsstuff-0.2.1.dist-info/WHEEL +4 -0
- somestuff_dnsstuff-0.2.1.dist-info/entry_points.txt +4 -0
- somestuff_dnsstuff-0.2.1.dist-info/licenses/LICENSE.md +163 -0
|
@@ -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
|