Files

347 lines
13 KiB
Python

"""Which network this computer is on, and whether Tailscale is up.
Frame Control remembers which of a headset's addresses worked on which network,
so it needs a stable name for "this network". The Wi-Fi name (SSID) is the
friendly one, but macOS 14+ hides it from apps without Location permission, and
wired networks have none. So every network is identified by a fingerprint of
its default gateway: the router's IP and MAC address, which stay the same for a
given home or office network. The user can give a fingerprint a name.
Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe
is a short command with a timeout, and each parser has fixtures in
tests/test_network.py.
"""
import hashlib
import ipaddress
import json
import os
import re
import socket
import subprocess
import time
import frame_host
TIMEOUT = 3
def run(argv, timeout=TIMEOUT):
"""A command's stdout, or "" if it's missing, fails or takes too long."""
try:
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout,
**({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {}))
except (OSError, subprocess.TimeoutExpired):
return ""
return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""
def valid_ip(text):
try:
ipaddress.ip_address(text)
return True
except ValueError:
return False
def norm_mac(text):
""""b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC."""
parts = re.split(r"[:-]", (text or "").strip())
if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts):
return None
mac = ":".join(p.lower().zfill(2) for p in parts)
return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac
# ---- default gateway -------------------------------------------------------
def parse_route_macos(text):
"""`route -n get default` -> (gateway, interface)."""
gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M)
iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M)
gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None
return gateway, iface.group(1) if iface else None
def parse_route_linux(text):
"""`ip -4 route show default` -> (gateway, interface) of the lowest-metric route."""
best = None
for line in text.splitlines():
m = re.search(r"^default via (\S+) dev (\S+)", line.strip())
if not m or not valid_ip(m.group(1)):
continue
metric = re.search(r"\bmetric (\d+)", line)
key = int(metric.group(1)) if metric else 0
if best is None or key < best[0]:
best = (key, m.group(1), m.group(2))
return (best[1], best[2]) if best else (None, None)
def parse_route_windows(text):
"""`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins."""
best = None
for line in text.splitlines():
f = line.split()
if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit():
if best is None or int(f[4]) < best[0]:
best = (int(f[4]), f[2], f[3])
return (best[1], best[2]) if best else (None, None)
# ---- the gateway's MAC address ----------------------------------------------
def parse_arp_macos(text, ip):
"""`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]")."""
m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text)
return norm_mac(m.group(1)) if m else None
def parse_neigh_linux(text, ip):
"""`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE")."""
for line in text.splitlines():
f = line.split()
if f and f[0] == ip and "lladdr" in f:
return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None
return None
def parse_arp_windows(text, ip):
"""`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic")."""
for line in text.splitlines():
f = line.split()
if len(f) >= 2 and f[0] == ip:
return norm_mac(f[1])
return None
# ---- Wi-Fi name --------------------------------------------------------------
def parse_summary_macos(text):
"""`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "<redacted>" without Location permission."""
kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
name = ssid.group(1) if ssid else None
if name in ("<redacted>", ""):
name = None
return name, (kind.group(1).lower() == "wifi") if kind else None
def parse_nmcli(text):
"""`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:)."""
for line in text.splitlines():
if line.startswith("yes:"):
return line[4:].replace("\\:", ":") or None
return None
def parse_netsh(text):
"""`netsh wlan show interfaces` -> the connected SSID (not the BSSID line)."""
state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
if not ssid or (state and state.group(1).lower() != "connected"):
return None
return ssid.group(1)
# ---- Tailscale -----------------------------------------------------------------
def tailscale_cli():
extra = []
if frame_host.MAC:
extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
elif frame_host.WINDOWS:
for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
if base:
extra.append(os.path.join(base, "Tailscale", "tailscale.exe"))
return frame_host.which("tailscale", *extra)
def parse_tailscale(text):
"""`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}.
Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}.
"""
try:
data = json.loads(text)
except ValueError:
return {"up": False, "peers": []}
if not isinstance(data, dict):
return {"up": False, "peers": []}
me = data.get("Self") or {}
tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None
out = {"up": data.get("BackendState") == "Running",
"ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None),
"name": (me.get("DNSName") or "").rstrip(".") or None,
"tailnet": tailnet, "peers": []}
for p in (data.get("Peer") or {}).values():
if not isinstance(p, dict):
continue
out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."),
"ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)],
"os": p.get("OS") or "", "online": bool(p.get("Online"))})
return out
def tailscale_status():
cli = tailscale_cli()
if not cli:
return {"up": False, "installed": False, "peers": []}
out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}")
out["installed"] = True
return out
TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10")
TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
def is_tailscale(host):
host = host.lower().rstrip(".")
if host.endswith(".ts.net"):
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6)
def guess_kind(host):
"""What sort of address a host is: mdns, tailscale, lan or manual."""
h = host.lower().rstrip(".")
if h.endswith(".local"):
return "mdns"
if is_tailscale(h):
return "tailscale"
try:
ip = ipaddress.ip_address(h.split("%")[0])
if ip.is_private or ip.is_link_local:
return "lan"
except ValueError:
pass
return "manual"
# ---- putting it together ----------------------------------------------------------
def network_id(gateway, mac):
"""A short, stable id for a network: its gateway's IP and MAC. None until both are known."""
if not gateway or not mac:
return None
return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10]
def local_ip(towards="192.0.2.1"):
"""This computer's address on the default route (UDP connect sends nothing)."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect((towards, 9))
return s.getsockname()[0]
except OSError:
return None
def parse_neighbours(text, local, gateway=None):
"""Numeric IPv4 neighbours on this host's /24, excluding itself and its router.
Accepts Linux `ip neigh`, macOS `arp -an` and Windows `arp -a`. The active
search uses the same /24; the cache just puts recently seen peers first.
"""
if not valid_ip(local):
return []
subnet = ipaddress.ip_network(local + "/24", strict=False)
found = []
for match in re.finditer(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])", text):
ip = match.group()
if valid_ip(ip) and ipaddress.ip_address(ip) in subnet and ip not in (local, gateway) and ip not in found:
found.append(ip)
return found
def local_scan_candidates(local=None, gateway_ip=None, neighbours=""):
"""Bounded IPv4 search order for an explicit Find action; never scan a WAN."""
local = local or local_ip()
if (not valid_ip(local) or not ipaddress.ip_address(local).is_private or
ipaddress.ip_address(local).is_loopback or is_tailscale(local)):
return []
subnet = ipaddress.ip_network(local + "/24", strict=False)
recent = parse_neighbours(neighbours, local, gateway_ip)
return recent + [str(ip) for ip in subnet.hosts() if str(ip) not in recent and str(ip) not in (local, gateway_ip)]
def neighbour_cache():
if frame_host.MAC:
return run(["arp", "-an"])
if frame_host.WINDOWS:
return run(["arp", "-a"])
return run(["ip", "-4", "neigh", "show"])
def gateway():
"""(gateway IP, interface) of the default route."""
if frame_host.MAC:
return parse_route_macos(run(["route", "-n", "get", "default"]))
if frame_host.WINDOWS:
return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"]))
return parse_route_linux(run(["ip", "-4", "route", "show", "default"]))
def gateway_mac(ip):
if frame_host.MAC:
return parse_arp_macos(run(["arp", "-n", ip]), ip)
if frame_host.WINDOWS:
return parse_arp_windows(run(["arp", "-a", ip]), ip)
return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip)
def poke(ip):
"""Make the system look up the gateway's MAC (an ARP entry can expire)."""
try:
with socket.create_connection((ip, 53), timeout=0.3):
pass
except OSError:
pass
def wifi(interface):
"""(ssid or None, is_wifi or None) for the default route's interface."""
if frame_host.MAC:
if interface:
ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface]))
if ssid or is_wifi is False:
return ssid, is_wifi
m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface]))
return (m.group(1).strip() if m else None), is_wifi
return None, None
if frame_host.WINDOWS:
ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"]))
return ssid, True if ssid else None
if frame_host.which("nmcli"):
ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]))
else:
ssid = run(["iwgetid", "-r"]).strip() or None
return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None)
def fingerprint():
"""The cheap part, polled every few seconds: (gateway, interface, gateway MAC)."""
gw, iface = gateway()
mac = None
if gw:
mac = gateway_mac(gw)
if not mac:
poke(gw)
mac = gateway_mac(gw)
return gw, iface, mac
def current_network(fp=None, with_tailscale=True):
"""Everything the connection status shows about this computer's network."""
gw, iface, mac = fp or fingerprint()
ssid, is_wifi = wifi(iface) if gw else (None, None)
net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface,
"ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()}
if with_tailscale:
ts = tailscale_status()
net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")}
return net