"""Randomize and restore NIC MAC addresses (Admin required).""" from __future__ import annotations import logging import random import re import subprocess from dataclasses import dataclass from .firewall import is_admin from .win_compat import probe as _win_probe log = logging.getLogger(__name__) _MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$") @dataclass class NicMac: name: str mac: str description: str = "" def _run(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]: try: r = subprocess.run( args, capture_output=True, text=True, timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) return r.returncode, r.stdout or "", r.stderr or "" except Exception as e: return 1, "", str(e) def list_nics() -> list[NicMac]: """Physical/up adapters with current MAC. Falls back to ``wmic nic`` for hosts without PowerShell 3.0 (Server 2008 R2). """ compat = _win_probe() if not compat.has_net_cmdlets: return _list_nics_wmic() script = ( "Get-NetAdapter | Where-Object { $_.Status -ne 'Disabled' } | " "Select-Object Name, MacAddress, InterfaceDescription | " "ConvertTo-Json -Compress" ) code, out, _ = _run(["powershell", "-NoProfile", "-Command", script]) if code != 0 or not out.strip(): return _list_nics_wmic() import json try: data = json.loads(out) except json.JSONDecodeError: return [] rows = data if isinstance(data, list) else [data] nics: list[NicMac] = [] for row in rows: if not isinstance(row, dict): continue name = str(row.get("Name", "")).strip() mac = str(row.get("MacAddress", "")).strip().replace("-", ":") desc = str(row.get("InterfaceDescription", "")).strip() if name and mac and mac != "00:00:00:00:00:00": nics.append(NicMac(name=name, mac=mac, description=desc)) return nics def random_mac() -> str: """Locally administered unicast MAC.""" b = [random.randint(0, 255) for _ in range(6)] b[0] = (b[0] | 0x02) & 0xFE return ":".join(f"{x:02X}" for x in b) def _list_nics_wmic() -> list[NicMac]: """Server 2008 R2 / PowerShell 2.0 fallback via wmic.""" code, out, _ = _run([ "wmic", "nic", "where", "NetEnabled=true", "get", "NetConnectionID,MACAddress,Name", "/format:list", ]) if code != 0 or not out: return [] nics: list[NicMac] = [] cur = {"id": "", "mac": "", "name": ""} for ln in out.splitlines(): s = ln.strip() if not s: if cur["id"] and cur["mac"]: mac = cur["mac"].replace("-", ":") if mac and mac != "00:00:00:00:00:00": nics.append(NicMac(name=cur["id"], mac=mac, description=cur["name"])) cur = {"id": "", "mac": "", "name": ""} continue if s.startswith("NetConnectionID="): cur["id"] = s.split("=", 1)[1].strip() elif s.startswith("MACAddress="): cur["mac"] = s.split("=", 1)[1].strip() elif s.startswith("Name="): cur["name"] = s.split("=", 1)[1].strip() if cur["id"] and cur["mac"]: mac = cur["mac"].replace("-", ":") if mac and mac != "00:00:00:00:00:00": nics.append(NicMac(name=cur["id"], mac=mac, description=cur["name"])) return nics def set_mac(adapter: str, mac: str) -> tuple[bool, str]: if not is_admin(): return False, "Administrator required to change MAC." if not _win_probe().has_net_cmdlets: return False, ( "MAC change requires PowerShell 3.0+ (Windows 8 / Server 2012+). " "Detected legacy PowerShell — feature unavailable on this host." ) mac = mac.replace("-", ":").upper() if not _MAC_RE.match(mac.replace(":", "-")): return False, f"Invalid MAC: {mac}" ps = ( f'$a = Get-NetAdapter -Name "{adapter}" -ErrorAction Stop; ' f'Set-NetAdapter -Name $a.Name -MacAddress "{mac}" -Confirm:$false' ) code, _, err = _run(["powershell", "-NoProfile", "-Command", ps]) if code != 0: return False, err or "Set-NetAdapter failed" log.info("MAC set %s → %s", adapter, mac) return True, f"{adapter} → {mac}" def spoof_all_physical(snapshot: dict[str, str] | None = None) -> tuple[dict[str, str], list[str]]: """Randomize MAC on non-virtual adapters. Returns (original_map, log lines).""" originals: dict[str, str] = dict(snapshot or {}) logs: list[str] = [] skip_hints = ("virtual", "vmware", "hyper-v", "loopback", "bluetooth", "wan miniport") for nic in list_nics(): low = (nic.description + nic.name).lower() if any(h in low for h in skip_hints): continue if nic.name not in originals: originals[nic.name] = nic.mac new_mac = random_mac() ok, msg = set_mac(nic.name, new_mac) logs.append(msg if ok else f"{nic.name}: {msg}") return originals, logs def restore_macs(originals: dict[str, str]) -> list[str]: logs: list[str] = [] for name, mac in originals.items(): ok, msg = set_mac(name, mac) logs.append(msg if ok else f"{name}: {msg}") return logs