mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Devices: several headsets, several addresses each, and live connection status
Frame Control can now manage more than one Steam Frame, and reach each at any of several addresses (LAN IPs per network, its .local name, Tailscale). A connector in the server tries them all at once, picks the best one that answers, follows ssh -v through each stage (network, finding, SSH, identity, login) and streams that to the page. The header shows it live; a new Devices tab (key 5) manages headsets, addresses and network names. - ui/frame_devices.py: registry in devices.json, imported from the managed ~/.ssh/config blocks; per-headset host key pinning; config block updates. - ui/frame_network.py: gateway IP+MAC fingerprint, Wi-Fi name, Tailscale. - ui/frame_link.py: the connector, Test now, Tailscale/mDNS discovery, API. - server.py: ensure_master delegates to the connector; /api/connection, /api/connection/events (SSE), /api/devices. - Electron: headset switcher and Devices item in the Frame menu. - frame_connect.py --alias; FRAME_CONTROL_DATA_DIR / FRAME_CONTROL_SSH_DIR keep tests off real data. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dcf9689f64
commit
a954fc83c9
24 files changed
+3597
-61
No files matched your search
@@ -0,0 +1,310 @@
|
||||
"""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 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
|
||||
Reference in new issue
Block a user