iotaudit 0.1.0__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.
iotaudit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """iotaudit - audit your own smart-home devices."""
2
+
3
+ __version__ = "0.1.0"
iotaudit/analyze.py ADDED
@@ -0,0 +1,260 @@
1
+ """Turn captured flows into per-device findings and grades."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .capture import Collector
6
+ from .enrich import (
7
+ PORT_LABELS,
8
+ Geo,
9
+ asn_org,
10
+ is_cgnat,
11
+ is_special_use,
12
+ is_tracker,
13
+ mac_vendor,
14
+ org_for,
15
+ special_use_label,
16
+ )
17
+ from .grading import findings_for, grade, summarize
18
+ from .models import Destination, Device, TrafficClass
19
+ from .profiles import is_expected, load_profiles, match_profile
20
+ from .traffic import classify_traffic, detect_bursts, encryption_verdict, merge_series
21
+
22
+
23
+ def _apply_flow(d: Destination, f) -> None:
24
+ d.ports.add(f.dport)
25
+ d.bytes_out += f.bytes
26
+ d.bytes_in += getattr(f, "bytes_in", 0) or 0
27
+ d.packets += f.packets
28
+ d.packets_in += getattr(f, "packets_in", 0) or 0
29
+ d.lan = d.lan or f.lan
30
+ if f.sni:
31
+ d.domains.add(f.sni.lower())
32
+ if f.http_host:
33
+ d.domains.add(f.http_host.split(":")[0].lower())
34
+ for k, v in (f.series or {}).items():
35
+ d.series[k] = d.series.get(k, 0) + v
36
+ if f.dport in PORT_LABELS:
37
+ d.protocols.add(PORT_LABELS[f.dport])
38
+ if f.dport == 3478:
39
+ d.protocols.add("STUN/TURN")
40
+
41
+ if f.tls or f.dport in (443, 8883, 853):
42
+ ver = encryption_verdict(f.payload_samples, assumed_tls=True)
43
+ elif f.plaintext_http:
44
+ ver = {
45
+ "kind": "plaintext",
46
+ "confidence": "certain",
47
+ "readable_fraction": 1.0,
48
+ "detail": "HTTP plaintext observed.",
49
+ }
50
+ elif f.dport == 3478:
51
+ ver = {
52
+ "kind": "stun",
53
+ "confidence": "certain",
54
+ "readable_fraction": 0.0,
55
+ "detail": "STUN/TURN signalling (not app-payload encryption).",
56
+ }
57
+ else:
58
+ ver = encryption_verdict(f.payload_samples, assumed_tls=False)
59
+
60
+ order = {"plaintext": 0, "unknown": 1, "custom-encryption": 2, "stun": 3, "tls": 4}
61
+ prev = d.encryption_kind
62
+ if prev is None or order.get(ver["kind"], 9) < order.get(prev, 9):
63
+ d.encryption_kind = ver["kind"]
64
+ d.encryption_detail = ver["detail"]
65
+ d.readable_fraction = ver.get("readable_fraction")
66
+ d.encrypted = d.encryption_kind != "plaintext"
67
+
68
+
69
+ def _enrich(d: Destination, geo: Geo, dns_map: dict) -> None:
70
+ d.domains |= set(dns_map.get(d.ip, set()))
71
+ d.country = geo.country(d.ip)
72
+ d.org = org_for(d.domains)
73
+ if not d.org:
74
+ _asn, org = asn_org(d.ip)
75
+ if org:
76
+ d.org = org
77
+ if is_cgnat(d.ip):
78
+ d.org = d.org or "CGNAT / P2P peer"
79
+ d.protocols.add("CGNAT-peer")
80
+ if is_special_use(d.ip):
81
+ d.__dict__["special_use"] = True
82
+ d.org = d.org or "special-use / bogon"
83
+ d.protocols.add("special-use")
84
+ d.__dict__.setdefault("looks_like", special_use_label(d.ip))
85
+ d.tracker = is_tracker(d.domains)
86
+ if d.series:
87
+ duration = max(d.series) - min(d.series) + 10
88
+ avg_pkt = (d.bytes_out / d.packets) if d.packets else None
89
+ tc = classify_traffic(d.bytes_out, duration, d.ports, avg_pkt, d.protocols)
90
+ d.traffic_class = TrafficClass(
91
+ label=tc["label"], confidence=tc["confidence"], evidence=tc.get("evidence") or {}
92
+ )
93
+ d.__dict__["_tc_detail"] = tc.get("detail")
94
+ d.bursts = detect_bursts(d.series)
95
+
96
+
97
+
98
+ def _idle_upload_verdict(device: Device) -> str:
99
+ """Compare low-activity baseline vs larger outbound bursts on internet destinations."""
100
+ series = device.series_total or {}
101
+ if len(series) < 3:
102
+ return "insufficient data"
103
+ rates = sorted(v * 8 / 10 for v in series.values())
104
+ n = max(1, len(rates) // 5)
105
+ baseline = sum(rates[:n]) / n
106
+ threshold = max(baseline * 3, 50_000)
107
+ spikes = [r for r in rates if r >= threshold]
108
+ if not spikes:
109
+ return "none detected"
110
+ wan = sum(d.bytes_out for d in device.destinations.values())
111
+ if wan < 100_000:
112
+ return "none detected (bursts were small / likely signalling; check LAN peers for video)"
113
+ return "detected"
114
+
115
+
116
+ def build_devices(
117
+ col: Collector,
118
+ geo: Geo | None = None,
119
+ extra_profiles: str | None = None,
120
+ scans: dict[str, list] | None = None,
121
+ peer_labels: dict[str, str] | None = None,
122
+ ) -> list[Device]:
123
+ geo = geo or Geo()
124
+ profiles = load_profiles(extra_profiles)
125
+ devices: dict[str, Device] = {}
126
+
127
+ def ensure(ip: str) -> Device:
128
+ return devices.setdefault(ip, Device(ip=ip, mac=col.macs.get(ip)))
129
+
130
+ from .capture import is_broadcast_or_multicast
131
+
132
+ all_flows = list(col.flows.values()) + list(getattr(col, "lan_flows", {}).values())
133
+ for f in all_flows:
134
+ dest = ensure(f.src)
135
+ if f.lan and is_broadcast_or_multicast(f.dst):
136
+ # Discovery broadcasts (e.g. Tuya 255.255.255.255:6667) — not media peers
137
+ b = next((x for x in dest.broadcasts if x.get("ip") == f.dst and x.get("port") == f.dport), None)
138
+ if not b:
139
+ b = {"ip": f.dst, "port": f.dport, "proto": f.proto, "bytes_out": 0, "packets": 0}
140
+ dest.broadcasts.append(b)
141
+ b["bytes_out"] += f.bytes
142
+ b["packets"] += f.packets
143
+ continue
144
+ bucket = dest.lan_peers if f.lan else dest.destinations
145
+ d = bucket.setdefault(f.dst, Destination(ip=f.dst, lan=f.lan))
146
+ _apply_flow(d, f)
147
+
148
+ for ip, qs in col.queries.items():
149
+ if ip in devices:
150
+ devices[ip].dns_queries |= qs
151
+
152
+ for dest in devices.values():
153
+ dest.vendor = mac_vendor(dest.mac)
154
+ for d in list(dest.destinations.values()) + list(dest.lan_peers.values()):
155
+ _enrich(d, geo, col.dns)
156
+ for port in d.ports:
157
+ if port in PORT_LABELS:
158
+ d.protocols.add(PORT_LABELS[port])
159
+
160
+ prof = match_profile(dest, profiles)
161
+ soft_hint = None
162
+ cloud_dns = any("." in n and not n.endswith(".local") for n in dest.dns_queries) or any(
163
+ d.domains for d in dest.destinations.values()
164
+ )
165
+ if not prof:
166
+ ports = {p for d in dest.destinations.values() for p in d.ports}
167
+ orgs = {d.org or "" for d in dest.destinations.values()}
168
+ aws_mqtt = 8883 in ports and any(("Amazon" in o or "AWS" in o) for o in orgs)
169
+ if aws_mqtt and not cloud_dns:
170
+ soft_hint = (
171
+ "MQTT cloud device, possibly Tuya (AWS :8883, no DNS/SNI)",
172
+ "low",
173
+ )
174
+ dest.profile = prof.get("name") if prof else None
175
+ if soft_hint and not dest.profile:
176
+ dest.__dict__["_soft_hint"] = soft_hint
177
+ for d in dest.destinations.values():
178
+ d.expected = is_expected(d.domains, prof)
179
+ if scans and dest.ip in scans:
180
+ dest.open_ports = scans[dest.ip]
181
+ if peer_labels:
182
+ for ip, d in dest.lan_peers.items():
183
+ if ip in peer_labels:
184
+ d.__dict__["looks_like"] = peer_labels[ip]
185
+
186
+ dest.series_total = merge_series([d.series for d in dest.destinations.values()])
187
+ dest.bursts = detect_bursts(dest.series_total)
188
+ dest.idle_upload = _idle_upload_verdict(dest)
189
+ try:
190
+ dest.__dict__["ipv6"] = col.ipv6_summary(dest.mac)
191
+ except Exception: # noqa: BLE001
192
+ dest.__dict__["ipv6"] = {}
193
+ dest.findings = findings_for(dest)
194
+ dest.score, dest.grade = grade(dest.findings)
195
+ dest.summary = summarize(dest)
196
+
197
+ return sorted(devices.values(), key=lambda d: d.ip)
198
+
199
+
200
+ def _dest_dict(d: Destination) -> dict:
201
+ tc = None
202
+ if d.traffic_class:
203
+ tc = {
204
+ "label": d.traffic_class.label,
205
+ "confidence": d.traffic_class.confidence,
206
+ "detail": d.__dict__.get("_tc_detail"),
207
+ "evidence": d.traffic_class.evidence,
208
+ }
209
+ return {
210
+ "ip": d.ip,
211
+ "looks_like": d.__dict__.get("looks_like"),
212
+ "domains": sorted(d.domains),
213
+ "country": d.country,
214
+ "org": d.org,
215
+ "ports": sorted(d.ports),
216
+ "bytes_out": d.bytes_out,
217
+ "bytes_in": d.bytes_in,
218
+ "packets": d.packets,
219
+ "packets_in": d.packets_in,
220
+ "encrypted": d.encrypted,
221
+ "encryption_kind": d.encryption_kind,
222
+ "encryption_detail": d.encryption_detail,
223
+ "readable_fraction": d.readable_fraction,
224
+ "tracker": d.tracker,
225
+ "expected": d.expected,
226
+ "protocols": sorted(d.protocols),
227
+ "traffic_class": tc,
228
+ "series": {str(k): v for k, v in sorted(d.series.items())},
229
+ "bursts": d.bursts,
230
+ "lan": d.lan,
231
+ "special_use": bool(d.__dict__.get("special_use")),
232
+ }
233
+
234
+
235
+ def to_dict(dev: Device) -> dict:
236
+ return {
237
+ "ip": dev.ip,
238
+ "mac": dev.mac,
239
+ "vendor": dev.vendor,
240
+ "profile": dev.profile,
241
+ "grade": dev.grade,
242
+ "score": dev.score,
243
+ "summary": dev.summary,
244
+ "idle_upload": dev.idle_upload,
245
+ "findings": dev.findings,
246
+ "open_ports": dev.open_ports,
247
+ "dns_queries": sorted(dev.dns_queries),
248
+ "destinations": [
249
+ _dest_dict(d)
250
+ for d in sorted(dev.destinations.values(), key=lambda x: -x.bytes_out)
251
+ ],
252
+ "lan_peers": [
253
+ _dest_dict(d)
254
+ for d in sorted(dev.lan_peers.values(), key=lambda x: -x.bytes_out)
255
+ ],
256
+ "broadcasts": dev.broadcasts,
257
+ "series_total": {str(k): v for k, v in sorted(dev.series_total.items())},
258
+ "bursts": dev.bursts,
259
+ "ipv6": dev.__dict__.get("ipv6", {}),
260
+ }
iotaudit/arp.py ADDED
@@ -0,0 +1,313 @@
1
+ """ARP mode (own network only): ARP-spoof one owned device so its traffic flows through this
2
+ computer, capture it, forward it, and restore ARP tables on exit.
3
+
4
+ Uses sendp() with an explicit Ether(dst=<known MAC>) so scapy never ARP-resolves L2 itself
5
+ (which on macOS produced 'MAC address to reach destination not found. Using broadcast.' and
6
+ empty captures). MACs should come from the LAN scan; getmacbyip is only a last resort.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ipaddress
12
+ import logging
13
+ import os
14
+ import platform
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import warnings
20
+
21
+ from .enrich import normalize_mac
22
+
23
+
24
+ class IPForwarding:
25
+ """Enable OS IP forwarding; restore the prior value on exit."""
26
+
27
+ KEYS = {"Darwin": "net.inet.ip.forwarding", "Linux": "net.ipv4.ip_forward"}
28
+
29
+ def __init__(self):
30
+ self.key = self.KEYS.get(platform.system())
31
+ self.prev: str | None = None
32
+
33
+ def _get(self) -> str | None:
34
+ r = subprocess.run(["sysctl", "-n", self.key], capture_output=True, text=True, check=False)
35
+ return r.stdout.strip() if r.returncode == 0 else None
36
+
37
+ def __enter__(self):
38
+ if not self.key:
39
+ raise RuntimeError("IP forwarding toggle is only supported on macOS and Linux.")
40
+ self.prev = self._get()
41
+ subprocess.run(["sysctl", "-w", f"{self.key}=1"], capture_output=True, text=True, check=False)
42
+ now = self._get()
43
+ if now != "1":
44
+ raise RuntimeError(
45
+ f"Could not enable IP forwarding ({self.key}={now!r}). "
46
+ "ARP mode needs it so the device keeps internet access. Re-run with sudo."
47
+ )
48
+ print(f"IP forwarding enabled ({self.key}=1; was {self.prev}).", file=sys.stderr)
49
+ return self
50
+
51
+ def __exit__(self, *exc):
52
+ if self.prev is not None:
53
+ subprocess.run(["sysctl", "-w", f"{self.key}={self.prev}"],
54
+ capture_output=True, text=True, check=False)
55
+ return False
56
+
57
+
58
+ def _require_private(*ips: str) -> None:
59
+ for ip in ips:
60
+ if not ipaddress.ip_address(ip).is_private:
61
+ raise ValueError(f"ARP mode only works on your private LAN; {ip} is not private.")
62
+
63
+
64
+ def default_gateway() -> str | None:
65
+ if platform.system() == "Darwin":
66
+ r = subprocess.run(["route", "-n", "get", "default"], capture_output=True, text=True, check=False)
67
+ for line in r.stdout.splitlines():
68
+ if "gateway:" in line:
69
+ return line.split(":", 1)[1].strip()
70
+ else:
71
+ r = subprocess.run(["ip", "route", "show", "default"], capture_output=True, text=True, check=False)
72
+ parts = r.stdout.split()
73
+ if "via" in parts:
74
+ return parts[parts.index("via") + 1]
75
+ return None
76
+
77
+
78
+ def resolve_iface(iface: str | None = None) -> str:
79
+ """Turn 'auto'/None into the real interface that has the default route (e.g. en0)."""
80
+ if iface and iface not in ("auto", "any", "default"):
81
+ return iface
82
+ if platform.system() == "Darwin":
83
+ r = subprocess.run(["route", "-n", "get", "default"], capture_output=True, text=True, check=False)
84
+ for line in r.stdout.splitlines():
85
+ if line.strip().startswith("interface:"):
86
+ return line.split(":", 1)[1].strip()
87
+ try:
88
+ from scapy.all import conf
89
+
90
+ # conf.route.route("0.0.0.0") -> (iface, gw, ip)
91
+ route = conf.route.route("0.0.0.0")
92
+ if route and route[0]:
93
+ return route[0]
94
+ except Exception: # noqa: BLE001
95
+ pass
96
+ if platform.system() != "Darwin":
97
+ r = subprocess.run(["ip", "route", "show", "default"], capture_output=True, text=True, check=False)
98
+ parts = r.stdout.split()
99
+ if "dev" in parts:
100
+ return parts[parts.index("dev") + 1]
101
+ raise RuntimeError("Could not resolve capture interface; pass --iface en0 (or your Wi-Fi iface).")
102
+
103
+
104
+ def our_mac(iface: str) -> str:
105
+ """MAC address of our own interface."""
106
+ try:
107
+ from scapy.all import get_if_hwaddr
108
+
109
+ mac = normalize_mac(get_if_hwaddr(iface))
110
+ if mac:
111
+ return mac
112
+ except Exception: # noqa: BLE001
113
+ pass
114
+ if platform.system() == "Darwin":
115
+ r = subprocess.run(["ifconfig", iface], capture_output=True, text=True, check=False)
116
+ for line in r.stdout.splitlines():
117
+ if "ether" in line:
118
+ mac = normalize_mac(line.split()[1])
119
+ if mac:
120
+ return mac
121
+ raise RuntimeError(f"Could not read MAC of interface {iface}.")
122
+
123
+
124
+ def _resolve_mac(ip: str, iface: str | None, known: str | None = None) -> str:
125
+ if known:
126
+ mac = normalize_mac(known)
127
+ if mac:
128
+ return mac
129
+ from scapy.layers.l2 import getmacbyip
130
+
131
+ for _ in range(3):
132
+ mac = normalize_mac(getmacbyip(ip) or "")
133
+ if mac:
134
+ return mac
135
+ time.sleep(0.4)
136
+ raise RuntimeError(
137
+ f"Could not resolve MAC for {ip}. Re-run without --device so the LAN scan "
138
+ "caches it, or check 2.4GHz / client isolation."
139
+ )
140
+
141
+
142
+ def _quiet_scapy():
143
+ logging.getLogger("scapy").setLevel(logging.ERROR)
144
+ logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
145
+ warnings.filterwarnings("ignore", module="scapy")
146
+
147
+
148
+ class ArpSpoofer:
149
+ """Two-way ARP spoof between target and gateway via sendp() + known MACs."""
150
+
151
+ def __init__(self, target_ip: str, gateway_ip: str, iface: str,
152
+ target_mac: str | None = None, gateway_mac: str | None = None,
153
+ our_mac_addr: str | None = None, interval: float = 2.0,
154
+ extra_peers: list[tuple[str, str]] | None = None):
155
+ """extra_peers: [(ip, mac), ...] other LAN hosts to poison both ways with target."""
156
+ _require_private(target_ip, gateway_ip)
157
+ self.target_ip = target_ip
158
+ self.gateway_ip = gateway_ip
159
+ self.iface = iface
160
+ self.interval = interval
161
+ self._known_target_mac = target_mac
162
+ self._known_gateway_mac = gateway_mac
163
+ self.our_mac = our_mac_addr
164
+ self.extra_peers = list(extra_peers or [])
165
+ self._stop = threading.Event()
166
+ self._thread: threading.Thread | None = None
167
+ self.target_mac: str | None = None
168
+ self.gateway_mac: str | None = None
169
+
170
+ def _sendp(self, dst_mac: str, pdst: str, hwdst: str, psrc: str, hwsrc: str | None = None):
171
+ from scapy.layers.l2 import ARP, Ether
172
+ from scapy.sendrecv import sendp
173
+
174
+ pkt = Ether(dst=dst_mac, src=self.our_mac) / ARP(
175
+ op=2, pdst=pdst, hwdst=hwdst, psrc=psrc, hwsrc=hwsrc or self.our_mac,
176
+ )
177
+ sendp(pkt, iface=self.iface, verbose=False, count=1)
178
+
179
+ def _loop(self):
180
+ while not self._stop.is_set():
181
+ # Tell target: "I (our MAC) am the gateway"
182
+ self._sendp(self.target_mac, self.target_ip, self.target_mac, self.gateway_ip)
183
+ # Tell gateway: "I (our MAC) am the target"
184
+ self._sendp(self.gateway_mac, self.gateway_ip, self.gateway_mac, self.target_ip)
185
+ # Optional: poison target <-> each LAN peer (see phone P2P video)
186
+ for peer_ip, peer_mac in self.extra_peers:
187
+ self._sendp(self.target_mac, self.target_ip, self.target_mac, peer_ip)
188
+ self._sendp(peer_mac, peer_ip, peer_mac, self.target_ip)
189
+ self._stop.wait(self.interval)
190
+
191
+ def start(self):
192
+ _quiet_scapy()
193
+ if not self.our_mac:
194
+ self.our_mac = our_mac(self.iface)
195
+ self.target_mac = _resolve_mac(self.target_ip, self.iface, self._known_target_mac)
196
+ self.gateway_mac = _resolve_mac(self.gateway_ip, self.iface, self._known_gateway_mac)
197
+ self._thread = threading.Thread(target=self._loop, name="arp-spoof", daemon=True)
198
+ self._thread.start()
199
+ return self
200
+
201
+ def restore(self):
202
+ if not (self.target_mac and self.gateway_mac and self.our_mac):
203
+ return
204
+ _quiet_scapy()
205
+ for _ in range(5):
206
+ self._sendp("ff:ff:ff:ff:ff:ff", self.target_ip, "ff:ff:ff:ff:ff:ff",
207
+ self.gateway_ip, hwsrc=self.gateway_mac)
208
+ self._sendp("ff:ff:ff:ff:ff:ff", self.gateway_ip, "ff:ff:ff:ff:ff:ff",
209
+ self.target_ip, hwsrc=self.target_mac)
210
+ for peer_ip, peer_mac in self.extra_peers:
211
+ self._sendp("ff:ff:ff:ff:ff:ff", self.target_ip, "ff:ff:ff:ff:ff:ff",
212
+ peer_ip, hwsrc=peer_mac)
213
+ self._sendp("ff:ff:ff:ff:ff:ff", peer_ip, "ff:ff:ff:ff:ff:ff",
214
+ self.target_ip, hwsrc=self.target_mac)
215
+
216
+ def stop(self):
217
+ self._stop.set()
218
+ if self._thread:
219
+ self._thread.join(timeout=3)
220
+ self.restore()
221
+
222
+
223
+ def _ll_from_mac(mac: str) -> str | None:
224
+ """Derive the EUI-64 IPv6 link-local address (fe80::/64) from a MAC."""
225
+ try:
226
+ from scapy.utils6 import in6_mactoifaceid
227
+
228
+ iface_id = in6_mactoifaceid(mac).lower()
229
+ return "fe80::" + iface_id
230
+ except Exception: # noqa: BLE001
231
+ return None
232
+
233
+
234
+ class NDPSpoofer:
235
+ """Experimental IPv6 neighbor-discovery spoof so device<->peer link-local unicast passes
236
+ through us, the IPv6 analogue of ARP spoofing. Sends unsolicited Neighbor Advertisements
237
+ (override set) claiming our MAC for each side's link-local address. Best-effort: on a Wi-Fi
238
+ network with client isolation the stream may still never reach us, which the report notes."""
239
+
240
+ def __init__(self, iface: str, our_mac_addr: str,
241
+ pairs: list[tuple[str, str]] | None = None, interval: float = 2.0):
242
+ """pairs: [(ipv6_or_mac_a, ipv6_or_mac_b), ...] — each side given as a link-local
243
+ address or a MAC (we derive the link-local via EUI-64)."""
244
+ self.iface = iface
245
+ self.our_mac = our_mac_addr
246
+ self.interval = interval
247
+ self.interfaces: list[tuple[str, str]] = [] # resolved (ll_a, ll_b)
248
+ for a, b in (pairs or []):
249
+ la = a if a.lower().startswith("fe80") else _ll_from_mac(a)
250
+ lb = b if b.lower().startswith("fe80") else _ll_from_mac(b)
251
+ if la and lb:
252
+ self.interfaces.append((la, lb))
253
+ self._stop = threading.Event()
254
+ self._thread: threading.Thread | None = None
255
+
256
+ def _advert(self, victim_ll: str, claimed_ll: str):
257
+ from scapy.layers.inet6 import ICMPv6ND_NA, ICMPv6NDOptDstLLAddr, IPv6
258
+ from scapy.layers.l2 import Ether
259
+ from scapy.sendrecv import sendp
260
+
261
+ pkt = (Ether(src=self.our_mac)
262
+ / IPv6(src=claimed_ll, dst=victim_ll)
263
+ / ICMPv6ND_NA(tgt=claimed_ll, R=0, S=0, O=1)
264
+ / ICMPv6NDOptDstLLAddr(lladdr=self.our_mac))
265
+ sendp(pkt, iface=self.iface, verbose=False, count=1)
266
+
267
+ def _loop(self):
268
+ while not self._stop.is_set():
269
+ for la, lb in self.interfaces:
270
+ self._advert(la, lb) # tell A: our MAC is B
271
+ self._advert(lb, la) # tell B: our MAC is A
272
+ self._stop.wait(self.interval)
273
+
274
+ def start(self):
275
+ _quiet_scapy()
276
+ if not self.interfaces:
277
+ return self
278
+ self._thread = threading.Thread(target=self._loop, name="ndp-spoof", daemon=True)
279
+ self._thread.start()
280
+ print(f"NDP mode (experimental): spoofing {len(self.interfaces)} IPv6 link-local "
281
+ f"pair(s) on {self.iface}.", file=sys.stderr)
282
+ return self
283
+
284
+ def stop(self):
285
+ self._stop.set()
286
+ if self._thread:
287
+ self._thread.join(timeout=3)
288
+
289
+
290
+ def start(target_ip: str, gateway_ip: str | None = None, iface: str | None = None,
291
+ target_mac: str | None = None, gateway_mac: str | None = None,
292
+ extra_peers: list[tuple[str, str]] | None = None):
293
+ """Start spoofing. Returns ArpSpoofer (caller must stop()). Needs root."""
294
+ if hasattr(os, "geteuid") and os.geteuid() != 0:
295
+ raise PermissionError("ARP mode needs root. Re-run with sudo.")
296
+ gw = gateway_ip or default_gateway()
297
+ if not gw:
298
+ raise RuntimeError("Could not determine the default gateway; pass --gateway.")
299
+ real_iface = resolve_iface(iface)
300
+ us = our_mac(real_iface)
301
+ peers = list(extra_peers or [])
302
+ print(
303
+ f"ARP mode: spoofing {target_ip} ({target_mac or '?'}) <-> "
304
+ f"gateway {gw} ({gateway_mac or '?'})"
305
+ + (f" + {len(peers)} LAN peer(s)" if peers else "")
306
+ + f" on {real_iface} (us {us}).",
307
+ file=sys.stderr,
308
+ )
309
+ return ArpSpoofer(
310
+ target_ip, gw, real_iface,
311
+ target_mac=target_mac, gateway_mac=gateway_mac, our_mac_addr=us,
312
+ extra_peers=peers,
313
+ ).start()