Every site name your device has just revealed, newest first
Only an encrypted tunnel
Your provider can see that you are connected to , and how much data flows. No site names at all.
#!/usr/bin/env python3 """ isp-sees: watch, live, what your internet provider can see. Mirrors an iPhone's traffic over a USB cable (or captures this Mac's own traffic) and shows every site name that leaves the device in the clear: DNS lookups, TLS SNI and QUIC SNI. It also notices when all you send is an encrypted WireGuard tunnel. Everything runs on your Mac. Nothing is sent anywhere. sudo python3 isp_sees.py iPhone connected by cable sudo python3 isp_sees.py --mac this Mac's own traffic python3 isp_sees.py --pcap file.pcap replay a saved capture MIT License """ import argparse import atexit import collections import hashlib import hmac import ipaddress import json import os import queue import re import shutil import socket import struct import subprocess import sys import threading import time import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # When re-launched through sudo, root can't see packages the user installed # with "pip3 install --user". Add the original user's site-packages back. def _add_user_site(): user = os.environ.get("SUDO_USER") if not user: return home = os.path.expanduser("~" + user) ver = "%d.%d" % sys.version_info[:2] for path in ( os.path.join(home, "Library", "Python", ver, "lib", "python", "site-packages"), os.path.join(home, ".local", "lib", "python" + ver, "site-packages"), ): if os.path.isdir(path) and path not in sys.path: sys.path.append(path) _add_user_site() try: # only needed to read site names inside QUIC (HTTP/3) packets from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers.aead import AESGCM HAVE_CRYPTO = True except Exception: HAVE_CRYPTO = False # -------------------------------------------------------------------------- # What kind of site is this? # -------------------------------------------------------------------------- TRACKERS = ( "doubleclick.net", "googlesyndication.com", "googleadservices.com", "google-analytics.com", "googletagmanager.com", "googletagservices.com", "app-measurement.com", "adservice.google.com", "crashlytics.com", "firebaselogging-pa.googleapis.com", "connect.facebook.net", "an.facebook.com", "facebook.net", "analytics.tiktok.com", "ads.tiktok.com", "appsflyer.com", "appsflyersdk.com", "adjust.com", "adjust.world", "branch.io", "app.link", "amplitude.com", "mixpanel.com", "segment.io", "segment.com", "braze.com", "onesignal.com", "scorecardresearch.com", "criteo.com", "criteo.net", "taboola.com", "outbrain.com", "amazon-adsystem.com", "adsrvr.org", "rubiconproject.com", "pubmatic.com", "openx.net", "casalemedia.com", "adnxs.com", "moatads.com", "quantserve.com", "hotjar.com", "clarity.ms", "bat.bing.com", "mc.yandex.ru", "sentry.io", "nr-data.net", "newrelic.com", "unityads.unity3d.com", "applovin.com", "applvn.com", "ironsrc.mobi", "vungle.com", "chartboost.com", "inmobi.com", "startappservice.com", "bugsnag.com", "demdex.net", "omtrdc.net", "everesttech.net", "krxd.net", "bluekai.com", "adsafeprotected.com", "doubleverify.com", "chartbeat.com", "chartbeat.net", "parsely.com", "tealiumiq.com", "optimizely.com", "mxpnl.com", "kochava.com", "singular.net", "datadoghq.com", "browser-intake-datadoghq.com", "smartadserver.com", "teads.tv", "yieldmo.com", "3lift.com", "sharethrough.com", "media.net", "ads-twitter.com", "analytics.twitter.com", "sc-analytics.appspot.com", "tr.snapchat.com", "ct.pinterest.com", "px.ads.linkedin.com", "snap.licdn.com", ) TRACKER_FIRST_LABELS = { "analytics", "metrics", "telemetry", "tracking", "tracker", "pixel", "ads", "adservice", "adserver", "stats", "beacon", "collector", } SOCIAL = ( "facebook.com", "fbcdn.net", "fbsbx.com", "instagram.com", "cdninstagram.com", "whatsapp.net", "whatsapp.com", "tiktok.com", "tiktokcdn.com", "tiktokv.com", "tiktokcdn-us.com", "byteoversea.com", "ibytedtos.com", "twitter.com", "x.com", "twimg.com", "snapchat.com", "sc-cdn.net", "snapkit.com", "reddit.com", "redd.it", "redditmedia.com", "redditstatic.com", "linkedin.com", "licdn.com", "pinterest.com", "pinimg.com", "telegram.org", "t.me", "discord.com", "discord.gg", "discordapp.com", "threads.net", "threads.com", "youtube.com", "ytimg.com", "googlevideo.com", "youtu.be", "vk.com", "userapi.com", "bsky.app", "tumblr.com", "twitch.tv", "onlyfans.com", "tinder.com", "gotinder.com", "bumble.com", "hinge.co", "badoo.com", ) APPLE = ( "apple.com", "icloud.com", "icloud-content.com", "mzstatic.com", "apple-dns.net", "aaplimg.com", "cdn-apple.com", "apple-cloudkit.com", "apple.news", "itunes.com", "apple-mapkit.com", "me.com", "push-apple.com.akadns.net", ) GOOGLE = ( "google.com", "gstatic.com", "googleapis.com", "googleusercontent.com", "gmail.com", "ggpht.com", "google.com.ua", "gvt1.com", "gvt2.com", ) TWO_LEVEL_SUFFIXES = { "co.uk", "org.uk", "ac.uk", "gov.uk", "com.ua", "net.ua", "org.ua", "in.ua", "kiev.ua", "kyiv.ua", "gov.ua", "com.au", "net.au", "org.au", "co.jp", "ne.jp", "or.jp", "com.br", "com.tr", "co.in", "co.kr", "com.cn", "com.mx", "co.za", "com.pl", "com.sg", "com.hk", "co.nz", "co.il", "com.ar", "akadns.net", "cloudfront.net", "amazonaws.com", "azureedge.net", "edgekey.net", } def _matches(host, suffixes): for s in suffixes: if host == s or host.endswith("." + s): return True return False def classify(host): if _matches(host, TRACKERS) or host.split(".", 1)[0] in TRACKER_FIRST_LABELS: return "tracker" if _matches(host, SOCIAL): return "social" if _matches(host, APPLE): return "apple" if _matches(host, GOOGLE): return "google" return "other" def base_domain(host): parts = host.split(".") if len(parts) <= 2: return host if ".".join(parts[-2:]) in TWO_LEVEL_SUFFIXES and len(parts) >= 3: return ".".join(parts[-3:]) return ".".join(parts[-2:]) def clean_host(name): name = name.strip().strip(".").lower() if not name or "." not in name or len(name) > 253: return None if name.endswith((".arpa", ".local", ".localhost", ".lan", ".home", ".internal")): return None if name.startswith("_"): return None if not re.match(r"^[a-z0-9._-]+$", name): return None return name # -------------------------------------------------------------------------- # Event hub: keeps history and streams events to the dashboard # -------------------------------------------------------------------------- class Hub: def __init__(self): self.lock = threading.Lock() self.clients = [] self.history = collections.deque(maxlen=4000) self.recent = {} self.status = {"kind": "status", "source": "Starting", "link": "", "started": time.time(), "quic": HAVE_CRYPTO} self.packets = 0 self.bytes = 0 self.seen_hosts = set() self.echo = True def subscribe(self): q = queue.Queue(maxsize=20000) with self.lock: snapshot = {"status": self.status, "events": list(self.history), "packets": self.packets, "bytes": self.bytes} self.clients.append(q) return q, snapshot def unsubscribe(self, q): with self.lock: if q in self.clients: self.clients.remove(q) def _broadcast(self, ev, keep=True): with self.lock: if keep: self.history.append(ev) dead = [] for q in self.clients: try: q.put_nowait(ev) except queue.Full: dead.append(q) for q in dead: self.clients.remove(q) def set_status(self, **kw): self.status.update(kw) self._broadcast(dict(self.status), keep=False) def clear(self): with self.lock: self.history.clear() self.recent.clear() self.seen_hosts.clear() def count_packet(self, size): self.packets += 1 self.bytes += size def domain(self, host, via, app=None, ech=False): now = time.time() key = (host, via) last = self.recent.get(key) if last is not None and now - last < 2.0: return self.recent[key] = now if len(self.recent) > 5000: cutoff = now - 10 self.recent = {k: v for k, v in self.recent.items() if v > cutoff} ev = {"kind": "domain", "t": now, "host": host, "base": base_domain(host), "cat": classify(host), "via": via} if app: ev["app"] = app if ech: ev["ech"] = True if self.echo and host not in self.seen_hosts: print(" %s %-4s %s" % (time.strftime("%H:%M:%S"), via, host), flush=True) self.seen_hosts.add(host) self._broadcast(ev) def tunnel(self, peer, port, packets, size): ev = {"kind": "tunnel", "t": time.time(), "peer": peer, "port": port, "packets": packets, "bytes": size} self._broadcast(ev) def stats_loop(self): while True: time.sleep(2) self._broadcast({"kind": "stats", "packets": self.packets, "bytes": self.bytes}, keep=False) # -------------------------------------------------------------------------- # Reading captures (pcap and pcapng, from tcpdump or a file) # -------------------------------------------------------------------------- def read_exact(f, n): buf = b"" while len(buf) < n: chunk = f.read(n - len(buf)) if not chunk: return None buf += chunk return buf def read_capture(f): """Yield (linktype, timestamp, frame) from a pcap or pcapng stream.""" magic = read_exact(f, 4) if magic is None: return if magic == b"\x0a\x0d\x0d\x0a": yield from _read_pcapng(f) return if magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): e = "<" elif magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): e = ">" else: raise ValueError("Not a pcap/pcapng capture") nano = magic in (b"\x4d\x3c\xb2\xa1", b"\xa1\xb2\x3c\x4d") hdr = read_exact(f, 20) if hdr is None: return linktype = struct.unpack(e + "HHiIII", hdr)[5] & 0x0FFFFFFF div = 1e9 if nano else 1e6 while True: rh = read_exact(f, 16) if rh is None: return sec, frac, incl, _orig = struct.unpack(e + "IIII", rh) data = read_exact(f, incl) if data is None: return yield linktype, sec + frac / div, data def _read_pcapng(f): rest = read_exact(f, 8) if rest is None: return e = "<" if rest[4:8] == b"\x4d\x3c\x2b\x1a" else ">" total = struct.unpack(e + "I", rest[:4])[0] if read_exact(f, total - 12) is None: return ifaces = [] while True: bh = read_exact(f, 8) if bh is None: return btype, blen = struct.unpack(e + "II", bh) if btype == 0x0A0D0D0A: more = read_exact(f, 4) if more is None: return e = "<" if more == b"\x4d\x3c\x2b\x1a" else ">" blen = struct.unpack(e + "I", bh[4:8])[0] if read_exact(f, blen - 12) is None: return ifaces = [] continue if blen < 12: return body = read_exact(f, blen - 8) if body is None: return body = body[:-4] if btype == 1 and len(body) >= 8: lt = struct.unpack(e + "H", body[:2])[0] res = 1e-6 p = 8 while p + 4 <= len(body): code, olen = struct.unpack(e + "HH", body[p:p + 4]) if code == 0: break if code == 9 and olen >= 1: v = body[p + 4] res = 2.0 ** -(v & 0x7F) if v & 0x80 else 10.0 ** -v p += 4 + ((olen + 3) & ~3) ifaces.append((lt, res)) elif btype == 6 and len(body) >= 20: iid, th, tl, cap, _orig = struct.unpack(e + "IIIII", body[:20]) lt, res = ifaces[iid] if iid < len(ifaces) else (1, 1e-6) yield lt, ((th << 32) | tl) * res, body[20:20 + cap] elif btype == 3 and len(body) >= 4: lt = ifaces[0][0] if ifaces else 1 yield lt, time.time(), body[4:] # -------------------------------------------------------------------------- # Link layer -> IP # -------------------------------------------------------------------------- def _ipv4_checksum_ok(h): if len(h) < 20 or len(h) % 2: return False s = sum(struct.unpack("!%dH" % (len(h) // 2), h)) while s >> 16: s = (s & 0xFFFF) + (s >> 16) return s == 0xFFFF def find_ip(d, limit=256): """Last resort: scan for a valid IPv4/IPv6 header.""" n = len(d) for off in range(0, min(limit, max(0, n - 20))): b = d[off] if 0x45 <= b <= 0x4F: ihl = (b & 0xF) * 4 if off + ihl <= n: tl = struct.unpack(">H", d[off + 2:off + 4])[0] if ihl <= tl <= n - off and _ipv4_checksum_ok(d[off:off + ihl]): return d[off:] elif b >> 4 == 6 and n - off >= 40: pl = struct.unpack(">H", d[off + 4:off + 6])[0] if pl + 40 == n - off and d[off + 6] in (6, 17, 58, 0, 43, 44, 60): return d[off:] return None def from_dlt(dlt, d): if dlt == 1: # Ethernet if len(d) < 14: return None et = struct.unpack(">H", d[12:14])[0] off = 14 while et in (0x8100, 0x88A8) and len(d) >= off + 4: et = struct.unpack(">H", d[off + 2:off + 4])[0] off += 4 return d[off:] if et in (0x0800, 0x86DD) else None if dlt in (0, 108): # BSD null / loopback return d[4:] if dlt in (12, 14, 101, 228, 229): # raw IP return d if dlt == 113: # Linux cooked return d[16:] if dlt == 276: # Linux cooked v2 return d[20:] return find_ip(d) def decode_link(linktype, data): """Return (ip_packet, interface_name, process_name) or None.""" if linktype in (258, 149): # Apple PKTAP: per-packet interface + process if len(data) < 12: return None for e in ("<", ">"): hlen = struct.unpack(e + "I", data[0:4])[0] if 12 <= hlen <= len(data): break else: ip = find_ip(data) return (ip, "", "") if ip else None dlt = struct.unpack(e + "I", data[8:12])[0] ifname = data[12:36].split(b"\0", 1)[0].decode("ascii", "replace") if hlen >= 36 else "" proc = data[56:73].split(b"\0", 1)[0].decode("utf-8", "replace") if hlen >= 73 else "" ip = from_dlt(dlt, data[hlen:]) return (ip, ifname, proc) if ip else None ip = from_dlt(linktype, data) return (ip, "", "") if ip else None def parse_ip(p): """Return (proto, src, dst, l4_bytes) or None.""" if not p: return None v = p[0] >> 4 if v == 4: if len(p) < 20: return None ihl = (p[0] & 0xF) * 4 tl = struct.unpack(">H", p[2:4])[0] if tl < ihl or tl > len(p): tl = len(p) if struct.unpack(">H", p[6:8])[0] & 0x1FFF: return None src = socket.inet_ntop(socket.AF_INET, p[12:16]) dst = socket.inet_ntop(socket.AF_INET, p[16:20]) return p[9], src, dst, p[ihl:tl] if v == 6: if len(p) < 40: return None nh = p[6] pl = struct.unpack(">H", p[4:6])[0] end = 40 + pl if pl and 40 + pl <= len(p) else len(p) src = socket.inet_ntop(socket.AF_INET6, p[8:24]) dst = socket.inet_ntop(socket.AF_INET6, p[24:40]) off = 40 while nh in (0, 43, 44, 51, 60): if off + 8 > len(p): return None if nh == 44: if struct.unpack(">H", p[off + 2:off + 4])[0] >> 3: return None nh, off = p[off], off + 8 elif nh == 51: nh, off = p[off], off + (p[off + 1] + 2) * 4 else: nh, off = p[off], off + (p[off + 1] + 1) * 8 return nh, src, dst, p[off:end] return None # -------------------------------------------------------------------------- # Protocol parsers # -------------------------------------------------------------------------- def _read_dns_name(d, off): labels = [] jumps = 0 end = None while True: if off >= len(d): return None, off ln = d[off] if ln == 0: off += 1 break if ln & 0xC0 == 0xC0: if off + 1 >= len(d): return None, off if end is None: end = off + 2 off = ((ln & 0x3F) << 8) | d[off + 1] jumps += 1 if jumps > 10: return None, off continue off += 1 labels.append(d[off:off + ln]) off += ln name = ".".join(x.decode("ascii", "replace") for x in labels) return name, (end if end is not None else off) def dns_query_names(d): if len(d) < 12: return [] flags, qd = struct.unpack(">HH", d[2:6]) if flags & 0x8000 or (flags >> 11) & 0xF: return [] out = [] off = 12 for _ in range(min(qd, 4)): name, off = _read_dns_name(d, off) if name is None or off + 4 > len(d): break qtype = struct.unpack(">H", d[off:off + 2])[0] off += 4 if qtype != 12: # skip reverse lookups out.append(name) return out def parse_client_hello(hs): """hs starts at a TLS handshake header. Return (sni, ech, complete) or None.""" if len(hs) < 4 or hs[0] != 1: return None ln = int.from_bytes(hs[1:4], "big") body = hs[4:4 + ln] complete = len(body) >= ln sni, ech = None, False try: p = 34 p += 1 + body[p] p += 2 + struct.unpack(">H", body[p:p + 2])[0] p += 1 + body[p] el = struct.unpack(">H", body[p:p + 2])[0] p += 2 end = min(len(body), p + el) while p + 4 <= end: et, elen = struct.unpack(">HH", body[p:p + 4]) p += 4 if p + elen > len(body): break # this extension hasn't fully arrived yet ed = body[p:p + elen] p += elen if et == 0 and len(ed) >= 5: q = 2 while q + 3 <= len(ed): nt = ed[q] nl = struct.unpack(">H", ed[q + 1:q + 3])[0] q += 3 if nt == 0 and q + nl <= len(ed): sni = ed[q:q + nl].decode("ascii", "replace") break q += nl elif et == 0xFE0D: ech = True except (IndexError, struct.error): pass return sni, ech, complete def tls_handshake_from_stream(buf): """Join handshake bytes from the TLS records at the start of a TCP stream.""" out = bytearray() p = 0 while p + 5 <= len(buf) and buf[p] == 0x16: rl = struct.unpack(">H", buf[p + 3:p + 5])[0] out += buf[p + 5:p + 5 + rl] p += 5 + rl return bytes(out) def read_varint(d, p): if p >= len(d): return None, p b = d[p] ln = 1 << (b >> 6) if p + ln > len(d): return None, p v = b & 0x3F for i in range(1, ln): v = (v << 8) | d[p + i] return v, p + ln QUIC_V1, QUIC_V2 = 0x00000001, 0x6B3343CF QUIC_SALT = { QUIC_V1: bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a"), QUIC_V2: bytes.fromhex("0dede3def700a6db819381be6e269dcbf9bd2ed9"), } QUIC_LABELS = { QUIC_V1: (b"quic key", b"quic iv", b"quic hp"), QUIC_V2: (b"quicv2 key", b"quicv2 iv", b"quicv2 hp"), } QUIC_INITIAL_TYPE = {QUIC_V1: 0, QUIC_V2: 1} def _hkdf_expand_label(secret, label, length): full = b"tls13 " + label info = struct.pack(">H", length) + bytes([len(full)]) + full + b"\x00" out, t, i = b"", b"", 1 while len(out) < length: t = hmac.new(secret, t + info + bytes([i]), hashlib.sha256).digest() out += t i += 1 return out[:length] _quic_keys = {} def _quic_client_keys(version, dcid): k = (version, dcid) if k not in _quic_keys: if len(_quic_keys) > 2000: _quic_keys.clear() initial = hmac.new(QUIC_SALT[version], dcid, hashlib.sha256).digest() client = _hkdf_expand_label(initial, b"client in", 32) lk, li, lh = QUIC_LABELS[version] _quic_keys[k] = (_hkdf_expand_label(client, lk, 16), _hkdf_expand_label(client, li, 12), _hkdf_expand_label(client, lh, 16)) return _quic_keys[k] def quic_client_initial(d): """Decrypt a QUIC client Initial. Return (dcid, [(offset, crypto_bytes)]) or None. Initial packets are only protected with keys anyone can compute from the packet itself, which is why a provider can read the site name in them.""" if not HAVE_CRYPTO or len(d) < 30 or not d[0] & 0x80: return None version = struct.unpack(">I", d[1:5])[0] if version not in QUIC_SALT or (d[0] >> 4) & 3 != QUIC_INITIAL_TYPE[version]: return None p = 5 dl = d[p] p += 1 if dl > 20: return None dcid = bytes(d[p:p + dl]) p += dl if p >= len(d): return None p += 1 + d[p] tl, p = read_varint(d, p) if tl is None: return None p += tl length, p = read_varint(d, p) if length is None or length < 20 or p + length > len(d): return None key, iv, hp = _quic_client_keys(version, dcid) sample = bytes(d[p + 4:p + 20]) enc = Cipher(algorithms.AES(hp), modes.ECB()).encryptor() mask = enc.update(sample) + enc.finalize() first = d[0] ^ (mask[0] & 0x0F) pn_len = (first & 3) + 1 pn_bytes = bytes(d[p + i] ^ mask[1 + i] for i in range(pn_len)) pn = int.from_bytes(pn_bytes, "big") header = bytes([first]) + bytes(d[1:p]) + pn_bytes nonce = bytearray(iv) for i in range(8): nonce[11 - i] ^= (pn >> (8 * i)) & 0xFF try: plain = AESGCM(key).decrypt(bytes(nonce), bytes(d[p + pn_len:p + length]), header) except Exception: return None frames = [] q = 0 while q < len(plain): ft, q = read_varint(plain, q) if ft is None: break if ft in (0x00, 0x01): continue if ft in (0x02, 0x03): vals = [] for _ in range(4): v, q = read_varint(plain, q) vals.append(v) if None in vals: break for _ in range(vals[2] * 2 + (3 if ft == 0x03 else 0)): v, q = read_varint(plain, q) if v is None: break continue if ft == 0x06: off, q = read_varint(plain, q) ln, q = read_varint(plain, q) if off is None or ln is None: break frames.append((off, plain[q:q + ln])) q += ln continue break return dcid, frames def wireguard_kind(d): if len(d) < 32 or d[1:4] != b"\x00\x00\x00": return None t, n = d[0], len(d) if (t, n) in ((1, 148), (2, 92), (3, 64)): return t if t == 4 and (n - 16) % 16 == 0: return t return None def _is_global(ip): try: return ipaddress.ip_address(ip).is_global except ValueError: return False # -------------------------------------------------------------------------- # Analyzer: turns packets into "your provider saw X" events # -------------------------------------------------------------------------- HIDDEN_IFACES = ("utun", "ipsec", "lo", "awdl", "llw", "anpi", "ap1", "bridge-") QUIET_PROCS = {"", "kernel_task", "mDNSResponder"} class Analyzer: def __init__(self, hub, include_tunnels=False): self.hub = hub self.include_tunnels = include_tunnels self.tcp = {} self.quic = {} self.wg = {} self.ifaces = set() self.last_gc = time.time() def feed(self, linktype, ts, frame): self.hub.count_packet(len(frame)) r = decode_link(linktype, frame) if r is None: return ip, ifname, proc = r hidden = bool(ifname) and ifname.startswith(HIDDEN_IFACES) if ifname and ifname not in self.ifaces: self.ifaces.add(ifname) if self.hub.echo: note = " (inside a VPN tunnel, hidden)" if hidden and not self.include_tunnels else "" print(" seeing traffic on %s%s" % (ifname, note), flush=True) if hidden and not self.include_tunnels: return pkt = parse_ip(ip) if pkt is None: return proto, src, dst, l4 = pkt app = None if proc in QUIET_PROCS else proc if proto == 17 and len(l4) >= 8: sport, dport = struct.unpack(">HH", l4[:4]) self.udp(src, sport, dst, dport, l4[8:], app) elif proto == 6 and len(l4) >= 20: sport, dport, seq = struct.unpack(">HHI", l4[:8]) doff = (l4[12] >> 4) * 4 data = l4[doff:] if data: self.tcp_segment(src, sport, dst, dport, seq, data, app) now = time.time() if now - self.last_gc > 5: self.gc(now) def gc(self, now): self.last_gc = now self.tcp = {k: v for k, v in self.tcp.items() if now - v["t"] < 15} self.quic = {k: v for k, v in self.quic.items() if now - v["t"] < 15} def emit(self, name, via, app=None, ech=False): host = clean_host(name) if host: self.hub.domain(host, via, app, ech) def udp(self, src, sport, dst, dport, data, app): if dport == 53: for name in dns_query_names(data): self.emit(name, "DNS") return if data and data[0] & 0xC0 == 0xC0: # QUIC long header r = quic_client_initial(data) if r: self.quic_frames((src, sport, r[0]), r[1], app) return kind = wireguard_kind(data) if kind: self.wireguard(src, sport, dst, dport, len(data)) def quic_frames(self, key, frames, app): st = self.quic.get(key) if st is None: st = self.quic[key] = {"frags": {}, "t": time.time(), "done": False} if st["done"]: return st["t"] = time.time() for off, chunk in frames: st["frags"][off] = chunk buf = bytearray() for off in sorted(st["frags"]): chunk = st["frags"][off] if off > len(buf): break if off + len(chunk) > len(buf): buf += chunk[len(buf) - off:] res = parse_client_hello(bytes(buf)) if res is None: return sni, ech, complete = res if sni: st["done"] = True self.emit(sni, "QUIC", app, ech) elif complete: st["done"] = True def tcp_segment(self, src, sport, dst, dport, seq, data, app): key = (src, sport, dst, dport) st = self.tcp.get(key) if st is None: if len(data) < 6 or data[0] != 0x16 or data[1] != 0x03 or data[5] != 0x01: return st = self.tcp[key] = {"buf": bytearray(data), "next": (seq + len(data)) & 0xFFFFFFFF, "t": time.time()} else: if seq != st["next"]: return st["buf"] += data st["next"] = (seq + len(data)) & 0xFFFFFFFF st["t"] = time.time() res = parse_client_hello(tls_handshake_from_stream(st["buf"])) if res is None: self.tcp.pop(key, None) return sni, ech, complete = res if sni: self.tcp.pop(key, None) self.emit(sni, "TLS", app, ech) elif complete or len(st["buf"]) > 32768: self.tcp.pop(key, None) def wireguard(self, src, sport, dst, dport, size): if _is_global(dst) != _is_global(src): peer, port = (dst, dport) if _is_global(dst) else (src, sport) elif 51820 in (sport, dport): peer, port = (dst, dport) if dport == 51820 else (src, sport) else: peer, port = (dst, dport) if dport <= sport else (src, sport) st = self.wg.setdefault(peer, {"packets": 0, "bytes": 0, "sent": 0.0}) st["packets"] += 1 st["bytes"] += size now = time.time() if now - st["sent"] > 1.5: st["sent"] = now self.hub.tunnel(peer, port, st["packets"], st["bytes"]) # -------------------------------------------------------------------------- # Dashboard # -------------------------------------------------------------------------- DASHBOARD = r"""
Every site name your device has just revealed, newest first
Your provider can see that you are connected to , and how much data flows. No site names at all.