#!/usr/bin/env python3
"""
CloakwireX Sensor — cross-platform companion (Windows / Linux / macOS).

iOS won't let an app list the Wi-Fi networks around you or read neighbour MAC
addresses. This little program does both on a computer sharing your network and
serves them to the CloakwireX iPhone app over your LAN — no cloud, no account.

It speaks the exact same pairing contract as the macOS Sensor app, so the phone
pairs with it identically:

    Pair string : cloakwirex-sensor://<host>:8787?token=XXXX-XXXX-XXXX-XXXX
    Endpoints   : GET /cloakwirex/info  (open)          -> { name }
                  GET /cloakwirex/wifi  (Bearer token)  -> nearby access points
                  GET /cloakwirex/arp   (Bearer token)  -> IP -> MAC neighbours

Run it:   python3 cloakwirex_sensor.py     (Windows: py cloakwirex_sensor.py)
Then on the phone: Details -> Advanced -> Nearby Wi-Fi (Sensor) -> Add Sensor ->
"Enter manually", and type the Address and Code it prints.

Pure standard library — needs only Python 3.8+. No pip install.
"""

import hmac
import json
import os
import platform
import re
import secrets
import socket
import subprocess
import sys
import threading
import time
import webbrowser
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# Make this script's own folder importable so the bundled "qrcode" package is
# found even under Python's Windows "embeddable" build — its ._pth restricts
# sys.path and does NOT add the script directory, which would otherwise make the
# QR silently unavailable on exactly the platform we ship the QR for.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

PORT = 8787
SYSTEM = platform.system()  # 'Windows' | 'Linux' | 'Darwin'
TOKEN_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".sensor-token")
TOKEN_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"  # no look-alikes (0/O, 1/I)


# ---------------------------------------------------------------- token + net

def load_or_make_token() -> str:
    try:
        with open(TOKEN_FILE) as f:
            t = f.read().strip()
            if t:
                return t
    except OSError:
        pass
    t = "-".join("".join(secrets.choice(TOKEN_CHARS) for _ in range(4)) for _ in range(4))
    try:
        # Write the pairing secret owner-only (0600 on POSIX). The token is the
        # whole auth boundary — it should be no more readable than an SSH key.
        flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
        fd = os.open(TOKEN_FILE, flags, 0o600)
        with os.fdopen(fd, "w") as f:
            f.write(t)
        try:
            os.chmod(TOKEN_FILE, 0o600)   # in case it pre-existed with looser perms
        except OSError:
            pass
    except OSError:
        pass
    return t


def primary_ip() -> str:
    """Best-effort LAN address of this machine (no traffic actually sent)."""
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("8.8.8.8", 80))
        return s.getsockname()[0]
    except OSError:
        return "127.0.0.1"
    finally:
        s.close()


def _run(cmd) -> str:
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=12,
                             errors="replace")
        return out.stdout or ""
    except (OSError, subprocess.SubprocessError):
        return ""


def band_for_channel(ch: int) -> str:
    if ch <= 0:
        return "—"
    if ch <= 14:
        return "2.4 GHz"
    return "5 GHz"


def pct_to_dbm(pct: int) -> int:
    # Windows/nmcli report signal as 0-100%. Common linear map: 100%≈-50, 0%≈-100.
    return max(-100, min(-40, (pct // 2) - 100))


# ---------------------------------------------------------------- Wi-Fi scan

def scan_wifi():
    if SYSTEM == "Windows":
        return _scan_wifi_windows()
    if SYSTEM == "Linux":
        return _scan_wifi_linux()
    if SYSTEM == "Darwin":
        return _scan_wifi_macos()
    return []


def _scan_wifi_windows():
    """Parse `netsh wlan show networks mode=bssid`."""
    text = _run(["netsh", "wlan", "show", "networks", "mode=bssid"])
    aps, ssid, auth = [], None, "?"
    cur = None
    for line in text.splitlines():
        s = line.strip()
        m = re.match(r"SSID\s+\d+\s*:\s*(.*)$", s)
        if m:
            ssid = m.group(1).strip() or None
            auth = "?"
            continue
        m = re.match(r"Authentication\s*:\s*(.+)$", s)
        if m:
            auth = m.group(1).strip()
            continue
        m = re.match(r"BSSID\s+\d+\s*:\s*([0-9A-Fa-f:\-]{17})", s)
        if m:
            cur = {"ssid": ssid, "bssid": _norm_mac(m.group(1)), "rssi_dbm": -100,
                   "channel": 0, "band": "—", "security": auth}
            aps.append(cur)
            continue
        if cur is not None:
            m = re.match(r"Signal\s*:\s*(\d+)%", s)
            if m:
                cur["rssi_dbm"] = pct_to_dbm(int(m.group(1)))
                continue
            m = re.match(r"Channel\s*:\s*(\d+)", s)
            if m:
                cur["channel"] = int(m.group(1))
                cur["band"] = band_for_channel(cur["channel"])
    return aps


def _scan_wifi_linux():
    """Prefer nmcli (NetworkManager); parse its terse output."""
    text = _run(["nmcli", "-t", "-e", "no", "-f",
                 "SSID,BSSID,SIGNAL,CHAN,SECURITY", "dev", "wifi"])
    aps = []
    for line in text.splitlines():
        # nmcli escapes ':' inside the BSSID as '\:'
        parts = re.split(r"(?<!\\):", line)
        parts = [p.replace("\\:", ":") for p in parts]
        if len(parts) < 5:
            continue
        ssid, bssid, sig, chan, sec = parts[0], parts[1], parts[2], parts[3], parts[4]
        try:
            ch = int(chan)
        except ValueError:
            ch = 0
        try:
            dbm = pct_to_dbm(int(sig))
        except ValueError:
            dbm = -100
        aps.append({"ssid": ssid or None, "bssid": _norm_mac(bssid),
                    "rssi_dbm": dbm, "channel": ch, "band": band_for_channel(ch),
                    "security": sec or "Open"})
    return aps


def _scan_wifi_macos():
    """Best-effort via the airport tool (the Swift Sensor app is preferred on Mac)."""
    airport = ("/System/Library/PrivateFrameworks/Apple80211.framework/"
               "Versions/Current/Resources/airport")
    text = _run([airport, "-s"])
    aps = []
    for line in text.splitlines()[1:]:
        m = re.match(r"\s*(.+?)\s+([0-9a-fA-F:]{17})\s+(-?\d+)\s+(\d+)", line)
        if not m:
            continue
        ssid, bssid, rssi, chan = m.group(1).strip(), m.group(2), int(m.group(3)), int(m.group(4))
        sec = line[m.end():].strip().split()[-1] if line[m.end():].strip() else "?"
        aps.append({"ssid": ssid or None, "bssid": _norm_mac(bssid), "rssi_dbm": rssi,
                    "channel": chan, "band": band_for_channel(chan), "security": sec})
    return aps


# ---------------------------------------------------------------- ARP table

def read_arp():
    if SYSTEM == "Linux":
        rows = _arp_linux()
        if rows:
            return rows
    return _arp_generic()


def _arp_generic():
    text = _run(["arp", "-a"])
    out = []
    for line in text.splitlines():
        ip_m = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", line)
        mac_m = re.search(r"([0-9A-Fa-f]{2}(?:[:\-][0-9A-Fa-f]{2}){5})", line)
        if not ip_m or not mac_m:
            continue
        mac = _norm_mac(mac_m.group(1))
        if mac in ("FF:FF:FF:FF:FF:FF", "00:00:00:00:00:00"):
            continue
        out.append({"ip": ip_m.group(1), "mac": mac, "vendor": vendor_for(mac)})
    return out


def _arp_linux():
    text = _run(["ip", "neigh"])
    out = []
    for line in text.splitlines():
        ip_m = re.match(r"(\d{1,3}(?:\.\d{1,3}){3})", line)
        mac_m = re.search(r"lladdr\s+([0-9A-Fa-f:]{17})", line)
        if not ip_m or not mac_m:
            continue
        out.append({"ip": ip_m.group(1), "mac": _norm_mac(mac_m.group(1)),
                    "vendor": vendor_for(_norm_mac(mac_m.group(1)))})
    return out


def _norm_mac(mac: str) -> str:
    return mac.replace("-", ":").upper()


# Optional vendor lookup via the repo's OUI database, if present next door.
_OUI = None
def vendor_for(mac: str):
    global _OUI
    if _OUI is None:
        _OUI = {}
        try:
            # Only import oui_db from THIS script's own folder (already on sys.path),
            # never the parent directory — importing from a writable shared dir (e.g.
            # Downloads) would be a local code-execution vector.
            import oui_db  # type: ignore
            _OUI = {"lookup": getattr(oui_db, "lookup", None)}
        except Exception:
            _OUI = {"lookup": None}
    fn = _OUI.get("lookup")
    if callable(fn):
        try:
            return fn(mac) or None
        except Exception:
            return None
    return None


# ---------------------------------------------------------------- HTTP server

def iso_now() -> str:
    return datetime.now(timezone.utc).isoformat()


# ---------------------------------------------------------------- QR pairing

def qr_matrix(data):
    """QR module matrix (list[list[bool]]) via the bundled pure-Python qrcode,
    or None if it isn't available (then we fall back to the printed code)."""
    try:
        import qrcode
        qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
        qr.add_data(data)
        qr.make(fit=True)
        return qr.get_matrix()
    except Exception:
        return None


def qr_svg(m, scale=10):
    if not m:
        return ""
    n = len(m); size = n * scale
    rects = []
    for y, row in enumerate(m):
        for x, cell in enumerate(row):
            if cell:
                rects.append('<rect x="%d" y="%d" width="%d" height="%d"/>' % (x * scale, y * scale, scale, scale))
    return ('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" '
            'shape-rendering="crispEdges"><rect width="%d" height="%d" fill="#fff"/>'
            '<g fill="#000">%s</g></svg>') % (size, size, size, size, size, size, "".join(rects))


def pair_page(svg, ip, port, token):
    """Self-contained HTML shown in the operator's browser — scan to pair."""
    return ("""<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pair CloakwireX Sensor</title>
<style>body{margin:0;background:#05080e;color:#e8eef6;font-family:system-ui,Segoe UI,Arial,sans-serif;
display:flex;min-height:100vh;align-items:center;justify-content:center}
.card{background:#0e1520;border:1px solid rgba(150,175,205,.15);border-radius:20px;padding:28px 32px;text-align:center;max-width:420px}
h1{font-size:20px;margin:0 0 4px}p{color:#93a2b6;font-size:14px;margin:6px 0}
.qr{background:#fff;border-radius:14px;padding:14px;display:inline-block;margin:14px 0}
.qr svg{display:block;width:260px;height:260px}
code{background:rgba(34,211,238,.12);color:#22d3ee;padding:2px 7px;border-radius:6px;font-size:13px}
.man{margin-top:14px;font-size:13px;color:#93a2b6;line-height:1.7}</style>
<div class="card">
<h1>Pair your iPhone</h1>
<p>In CloakwireX: <b>Details &rarr; Advanced tools &rarr; Nearby Wi&#8209;Fi (Sensor) &rarr; Add Sensor</b>, then scan this code.</p>
<div class="qr">""" + svg + """</div>
<div class="man">No camera? Tap <b>"Enter manually"</b> and type:<br>
Address <code>""" + ("%s:%d" % (ip, port)) + """</code><br>Code <code>""" + token + """</code></div>
</div>""")


class Handler(BaseHTTPRequestHandler):
    token = ""
    name = "CloakwireX Sensor"

    def log_message(self, *_):  # quiet
        pass

    def _send(self, obj, code=200):
        body = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _authed(self) -> bool:
        ok = hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer " + self.token)
        if ok:
            Handler.last_active = time.time()   # feed the idle-shutdown watchdog
        return ok

    pair_html = None
    allowed_hosts = {"127.0.0.1", "localhost", "::1"}   # + the LAN IP, set in main()
    last_active = time.time()

    def _rebind_guard(self) -> bool:
        # Anti-DNS-rebinding: only answer requests addressed to our own IP or
        # localhost, and refuse any browser-origin (cross-site) request. A native
        # phone client sends neither a foreign Host nor an Origin header; only a
        # web page (the rebinding attacker) does — so this closes the browser-driven
        # attack surface (token theft via the pairing page, LAN recon via /info).
        host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]").lower()
        if host not in Handler.allowed_hosts:
            self._send({"error": "bad host"}, 403); return False
        if self.headers.get("Origin"):
            self._send({"error": "forbidden"}, 403); return False
        return True

    def do_GET(self):
        if not self._rebind_guard():
            return
        path = self.path.split("?")[0]
        if path in ("/", "/pair"):
            # Pairing page carries the token — serve it ONLY to a genuine loopback
            # browser (not the LAN IP, not a rebound host).
            host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]").lower()
            if self.client_address[0] not in ("127.0.0.1", "::1") or host not in ("127.0.0.1", "localhost", "::1"):
                return self._send({"error": "not found"}, 404)
            body = (self.pair_html or "<p>pairing page unavailable</p>").encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        if path == "/cloakwirex/info":
            # Minimal + non-identifying: no hostname or OS leaked to the network.
            self._send({"schema": "cloakwirex.sensor.info.v1",
                        "name": "CloakwireX Sensor", "generated_utc": iso_now()})
        elif path == "/cloakwirex/wifi":
            if not self._authed():
                return self._send({"error": "unauthorized"}, 401)
            aps = scan_wifi()
            self._send({"schema": "cloakwirex.sensor.wifi.v1",
                        "names_visible": any(a.get("ssid") for a in aps),
                        "generated_utc": iso_now(), "access_points": aps})
        elif path == "/cloakwirex/arp":
            if not self._authed():
                return self._send({"error": "unauthorized"}, 401)
            self._send({"schema": "cloakwirex.sensor.arp.v1",
                        "generated_utc": iso_now(), "neighbors": read_arp()})
        else:
            self._send({"error": "not found"}, 404)


def main():
    try:
        sys.stdout.reconfigure(line_buffering=True)  # show the pairing info immediately
    except Exception:
        pass
    token = load_or_make_token()
    Handler.token = token
    Handler.name = f"{socket.gethostname()} ({SYSTEM})"
    ip = primary_ip()
    pair_url = "cloakwirex-sensor://%s:%d?token=%s" % (ip, PORT, token)
    m = qr_matrix(pair_url)
    Handler.pair_html = pair_page(qr_svg(m), ip, PORT, token)

    print("=" * 56)
    print("  CloakwireX Sensor  —  running")
    print("=" * 56)
    print(f"  Platform : {SYSTEM}  ({platform.platform()})")
    print()
    print("  Pair your iPhone:  Details -> Advanced -> Nearby Wi-Fi")
    print("  (Sensor) -> Add Sensor -> \"Enter manually\", then type:")
    print()
    print(f"      Address :  {ip}:{PORT}")
    print(f"      Code    :  {token}")
    print()
    print(f"  (or paste this whole line):  {ip}:{PORT}  {token}")
    if m:
        print()
        print("  A browser window is opening with a QR code — scan it in the")
        print("  app to pair instantly. If it didn't open, go to:")
        print(f"      http://127.0.0.1:{PORT}/")
    print()
    # Quick self-check so the operator sees it's working.
    try:
        aps = scan_wifi()
        print(f"  Wi-Fi scan: {len(aps)} network(s) visible right now.")
        if not aps and SYSTEM == "Linux":
            print("  (No networks? Install NetworkManager/`nmcli`, or run with Wi-Fi on.)")
        if not aps and SYSTEM == "Darwin":
            print("  (No networks? macOS needs Location access for airport; the")
            print("   native 'CloakwireX Sensor' Mac app is the better choice here.)")
    except Exception as e:
        print(f"  Wi-Fi scan not available: {e}")
    print("=" * 56)
    print("  Leave this window open. Ctrl+C to stop.")
    print("=" * 56)

    # Only answer requests addressed to us (localhost + our LAN IP); a rebinding or
    # cross-site request carries some other Host and is refused (see _rebind_guard).
    Handler.allowed_hosts = {"127.0.0.1", "localhost", "::1", ip}
    Handler.timeout = 15                 # per-request read timeout — blunts slowloris
    Handler.last_active = time.time()

    server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    server.daemon_threads = True         # don't leak threads on dropped connections

    # Idle auto-shutdown: this is a use-it-then-close tool, not a resident daemon.
    IDLE_LIMIT = 60 * 60                 # stop after 60 min with no paired activity
    def _watchdog():
        while True:
            time.sleep(60)
            if time.time() - Handler.last_active > IDLE_LIMIT:
                print("\nSensor stopped after inactivity — double-click to start it again.")
                server.shutdown(); return
    threading.Thread(target=_watchdog, daemon=True).start()

    if m:
        try:
            webbrowser.open("http://127.0.0.1:%d/" % PORT)
        except Exception:
            pass
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nSensor stopped.")
        server.shutdown()


if __name__ == "__main__":
    main()
