IP Scan

Windows LAN inventory & port scanner with a unique, frameless UI. Detect devices, IP/MAC, vendor, open ports, and SMB shares—fast.

Download for Windows (.exe)

IP Scan Source


import sys, os, re, json, csv, ipaddress, socket, subprocess, webbrowser, math
import concurrent.futures as futures
from typing import List, Dict, Any, Optional, Tuple
from PySide6.QtCore import Qt, QPoint, QRect, QSize, QThread, Signal, Slot
from PySide6.QtGui import QPainter, QColor, QPolygon, QRegion, QFont, QAction, QCursor, QIcon
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLayout, QSizePolicy, QMenu, QLabel, QLineEdit, QCheckBox, QPushButton, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QFileDialog, QGraphicsDropShadowEffect, QMessageBox, QToolButton, QToolTip, QComboBox
try:
    from mac_vendor_lookup import MacLookup
    _MAC_LOOKUP = MacLookup()
except Exception:
    _MAC_LOOKUP = None
try:
    from smb.SMBConnection import SMBConnection
    _HAS_SMB = True
except Exception:
    _HAS_SMB = False

IS_WIN = os.name == "nt"
IS_MAC = sys.platform == "darwin"
IS_LIN = sys.platform.startswith("linux")

COMMON_PORTS: List[int] = [22,23,53,80,88,123,135,137,138,139,161,389,445,515,587,631,873,902,9100,1433,3306,3389,5000,5432,5900,8008,8080,8443]

def run_cmd(cmd: List[str], timeout: float = 5.0) -> str:
    try:
        creationflags = 0
        startupinfo = None
        if IS_WIN:
            creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
            try:
                si = subprocess.STARTUPINFO()
                si.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 1)
                startupinfo = si
            except Exception:
                startupinfo = None
        p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout, shell=False, creationflags=creationflags, startupinfo=startupinfo)
        return ((p.stdout or "") + (p.stderr or "")) or ""
    except Exception:
        return ""

def ping_host(ip: str, timeout_ms: int = 400) -> bool:
    if IS_WIN:
        out = run_cmd(["ping", "-n", "1", "-w", str(timeout_ms), ip], timeout=(timeout_ms/1000.0 + 1.0))
        return "TTL=" in out
    if IS_LIN:
        secs = max(1, int(math.ceil(timeout_ms/1000.0)))
        out = run_cmd(["ping", "-c", "1", "-W", str(secs), ip], timeout=secs+1)
        return "1 received" in out or "bytes from" in out
    if IS_MAC:
        ms = str(max(200, timeout_ms))
        out = run_cmd(["ping", "-c", "1", "-W", ms, ip], timeout=(int(ms)/1000.0 + 1.0))
        return "1 packets received" in out or "bytes from" in out
    return False

def tcp_probe_up(ip: str, ports: List[int], timeout: float) -> bool:
    for p in ports:
        try:
            with socket.create_connection((ip, p), timeout=timeout):
                return True
        except Exception:
            continue
    return False

def udp_nudge(ip: str) -> None:
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(0.2); s.sendto(b"\x00", (ip, 9)); s.close()
    except Exception:
        pass

def get_mac_from_arp(ip: str) -> Optional[str]:
    if IS_WIN:
        out = run_cmd(["arp", "-a", ip], timeout=2)
        m = re.search(r"([0-9A-Fa-f]{2}[-:]){5}[0-9A-Fa-f]{2}", out)
        return m.group(0).lower().replace(":", "-") if m else None
    if IS_LIN:
        out = run_cmd(["ip", "neigh", "show", ip], timeout=2) or run_cmd(["arp", "-n", ip], timeout=2)
        m = re.search(r"lladdr\s+([0-9a-f:]{17})", out, re.I) or re.search(r"([0-9a-f]{2}(:[0-9a-f]{2}){5})", out, re.I)
        return m.group(1).lower().replace(":", "-") if m else None
    if IS_MAC:
        out = run_cmd(["arp", "-n", ip], timeout=2)
        m = re.search(r"at\s+([0-9a-f:]{17})", out, re.I)
        return m.group(1).lower().replace(":", "-") if m else None
    return None

def mac_to_vendor(mac: str) -> Optional[str]:
    if not mac or _MAC_LOOKUP is None:
        return None
    try:
        return _MAC_LOOKUP.lookup(mac)
    except Exception:
        return None

def reverse_dns(ip: str, timeout: float = 1.0) -> Optional[str]:
    with futures.ThreadPoolExecutor(max_workers=1) as ex:
        fut = ex.submit(lambda: socket.gethostbyaddr(ip)[0] if True else None)
        try:
            return fut.result(timeout=timeout)
        except Exception:
            return None

def netbios_name(ip: str, timeout: float = 2.0) -> Optional[str]:
    if not IS_WIN:
        return None
    out = run_cmd(["nbtstat", "-A", ip], timeout=timeout)
    for line in out.splitlines():
        if "<00>" in line and "UNIQUE" in line:
            nm = line.split()[0].strip()
            if nm and nm != "Name":
                return nm
    return None

def port_is_open(ip: str, port: int, timeout: float) -> bool:
    try:
        with socket.create_connection((ip, port), timeout=timeout):
            return True
    except Exception:
        return False

def scan_ports(ip: str, ports: List[int], timeout: float, max_workers: int) -> List[int]:
    if not ports:
        return []
    out: List[int] = []
    with futures.ThreadPoolExecutor(max_workers=max_workers) as ex:
        mapping = {ex.submit(port_is_open, ip, p, timeout): p for p in ports}
        for f in futures.as_completed(mapping):
            p = mapping[f]
            try:
                if f.result():
                    out.append(p)
            except Exception:
                pass
    return sorted(out)

def enumerate_smb_shares(ip: str, timeout: float = 2.5) -> List[str]:
    if not _HAS_SMB:
        return []
    try:
        conn = SMBConnection(username="", password="", my_name="LANINV", remote_name=ip, use_ntlm_v2=True, is_direct_tcp=True)
        ok = conn.connect(ip, 445, timeout=timeout)
        shares: List[str] = []
        if ok:
            try:
                for s in conn.listShares(timeout=timeout):
                    name = getattr(s, "name", "") or ""
                    if name and not (name.endswith("$") and name.lower() not in ("print$",)):
                        shares.append(name)
            except Exception:
                pass
        try:
            conn.close()
        except Exception:
            pass
        return shares
    except Exception:
        return []

def detect_printer_like(open_ports: List[int]) -> bool:
    return any(p in {9100, 515, 631} for p in open_ports)

def parse_win_networks() -> List[Dict[str,str]]:
    out = run_cmd(["ipconfig"], timeout=5)
    blocks = re.split(r"\r?\n\r?\n", out)
    nets: List[Dict[str, str]] = []
    for b in blocks:
        if "Media disconnected" in b:
            continue
        header_m = re.search(r"^(.*adapter .*?):", b, flags=re.IGNORECASE | re.MULTILINE)
        name = header_m.group(1).strip() if header_m else "Interface"
        ipv4_m = re.search(r"IPv4 Address[^\:]*:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", b)
        mask_m = re.search(r"Subnet Mask[^\:]*:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", b)
        gw_m = re.search(r"Default Gateway[^\:]*:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", b)
        if ipv4_m and mask_m:
            ip = ipv4_m.group(1); mask = mask_m.group(1); gw = gw_m.group(1) if gw_m else ""
            try:
                net = ipaddress.IPv4Network((ip, mask), strict=False)
            except Exception:
                continue
            nets.append({"name": name, "ip": ip, "mask": mask, "gateway": gw, "cidr": str(net)})
    return nets

def parse_lin_networks() -> List[Dict[str,str]]:
    a = run_cmd(["ip", "-o", "-f", "inet", "addr", "show"], timeout=5)
    r = run_cmd(["ip", "-4", "route", "show", "default"], timeout=5)
    gws = {}
    for line in r.splitlines():
        m = re.search(r"default via ([0-9.]+) dev (\S+)", line)
        if m:
            gws[m.group(2)] = m.group(1)
    nets: List[Dict[str,str]] = []
    for line in a.splitlines():
        m = re.search(r"\d+:\s+(\S+)\s+inet\s+([0-9.]+)/(\d+)", line)
        if not m:
            continue
        iface, ip, pref = m.group(1), m.group(2), int(m.group(3))
        try:
            net = ipaddress.IPv4Network(f"{ip}/{pref}", strict=False)
        except Exception:
            continue
        gw = gws.get(iface, "")
        nets.append({"name": iface, "ip": ip, "mask": str(ipaddress.IPv4Network(f"0.0.0.0/{pref}").netmask), "gateway": gw, "cidr": str(net)})
    return nets

def parse_mac_networks() -> List[Dict[str,str]]:
    f = run_cmd(["ifconfig"], timeout=5)
    rd = run_cmd(["route", "-n", "get", "default"], timeout=5)
    gw = ""
    iface = ""
    for line in rd.splitlines():
        if line.strip().startswith("gateway:"):
            gw = line.split()[-1].strip()
        if line.strip().startswith("interface:"):
            iface = line.split()[-1].strip()
    nets: List[Dict[str,str]] = []
    cur = None
    for line in f.splitlines():
        if not line.startswith("\t") and ":" in line:
            cur = line.split(":")[0].strip()
            continue
        if cur and "inet " in line:
            m = re.search(r"inet\s+([0-9.]+)\s+netmask\s+0x([0-9a-fA-F]+)", line)
            if not m:
                continue
            ip = m.group(1)
            nm_hex = int(m.group(2), 16)
            mask = ".".join(str((nm_hex >> (i*8)) & 0xFF) for i in [3,2,1,0])
            try:
                net = ipaddress.IPv4Network((ip, mask), strict=False)
            except Exception:
                continue
            nets.append({"name": cur, "ip": ip, "mask": mask, "gateway": gw if cur == iface and gw else "", "cidr": str(net)})
    return nets

def parse_all_networks() -> List[Dict[str,str]]:
    try:
        if IS_WIN:
            nets = parse_win_networks()
        elif IS_LIN:
            nets = parse_lin_networks()
        elif IS_MAC:
            nets = parse_mac_networks()
        else:
            nets = []
    except Exception:
        nets = []
    unique: List[Dict[str, str]] = []
    seen = set()
    for n in nets:
        key = (n.get("cidr",""), n.get("gateway",""), n.get("name",""))
        if key in seen or not n.get("cidr"):
            continue
        seen.add(key)
        ipn = ipaddress.IPv4Network(n["cidr"], strict=False)
        if ipn.num_addresses >= 2:
            unique.append(n)
    return unique

def expand_targets(input_text: Optional[str], dropdown_mode: str) -> List[ipaddress.IPv4Network]:
    lst: List[ipaddress.IPv4Network] = []
    nets = parse_all_networks()
    if input_text:
        parts = [p.strip() for p in input_text.split(",") if p.strip()]
        for p in parts:
            try:
                lst.append(ipaddress.IPv4Network(p, strict=False))
            except Exception:
                pass
        return lst
    if dropdown_mode == "All adapters":
        for n in nets:
            try:
                lst.append(ipaddress.IPv4Network(n["cidr"], strict=False))
            except Exception:
                pass
        return lst
    if nets:
        pri = next((n for n in nets if n.get("gateway")), nets[0])
        try:
            lst.append(ipaddress.IPv4Network(pri["cidr"], strict=False))
        except Exception:
            pass
    return lst

class FlowLayout(QLayout):
    def __init__(self, parent=None, hspacing=10, vspacing=6, margins=(0,0,0,0)):
        super().__init__(parent); self._items=[]; self._hspace=hspacing; self._vspace=vspacing; self.setContentsMargins(*margins)
    def addItem(self, item): self._items.append(item)
    def addWidget(self, w): super().addWidget(w)
    def count(self): return len(self._items)
    def itemAt(self, i): return self._items[i] if 0<=i right and line_h>0:
                x = rect.x(); y = y + line_h + spaceY; nextX = x + wid.sizeHint().width() + spaceX; line_h = 0
            if not testOnly: it.setGeometry(QRect(QPoint(x,y), wid.sizeHint()))
            x = nextX; line_h = max(line_h, wid.sizeHint().height())
        return y + line_h - rect.y()

class ScanWorker(QThread):
    progress = Signal(int, int)
    finished = Signal(list, str)
    def __init__(self, network_cidrs: List[str], do_ports: bool, do_smb: bool, timeout: float, threads: int, ports: List[int]):
        super().__init__(); self.network_cidrs=network_cidrs; self.do_ports=do_ports; self.do_smb=do_smb; self.timeout=timeout; self.threads=threads; self.ports=ports
    def host_generator(self, nets: List[ipaddress.IPv4Network]) -> List[str]:
        hosts: List[str] = []
        for n in nets:
            try:
                hosts.extend([str(ip) for ip in n.hosts()])
            except Exception:
                pass
        return hosts
    def run(self):
        try:
            nets = [ipaddress.IPv4Network(c, strict=False) for c in self.network_cidrs if c]
            if not nets:
                raise RuntimeError("No IPv4 network to scan.")
        except Exception as e:
            self.finished.emit([], f"{e}")
            return
        ips = self.host_generator(nets)
        total = len(ips)
        if total == 0:
            self.finished.emit([], "No hosts in selected network(s).")
            return
        live: List[str] = []
        probe_ports = [80, 443, 22, 139, 445, 3389, 53]
        def alive(ip):
            if ping_host(ip, int(self.timeout*1000)):
                return ip
            udp_nudge(ip)
            mac = get_mac_from_arp(ip)
            if mac:
                return ip
            if tcp_probe_up(ip, probe_ports, self.timeout/2 if self.timeout>0.2 else 0.2):
                return ip
            udp_nudge(ip)
            mac = get_mac_from_arp(ip)
            if mac:
                return ip
            return None
        with futures.ThreadPoolExecutor(max_workers=self.threads) as ex:
            for i, res in enumerate(ex.map(alive, ips), start=1):
                if res:
                    live.append(res)
                self.progress.emit(i, total)
        if not live:
            self.finished.emit([], "")
            return
        def enrich_basic(ip):
            h = reverse_dns(ip, timeout=1.0) or (netbios_name(ip, timeout=1.5) or "")
            mac = get_mac_from_arp(ip) or ""
            ven = mac_to_vendor(mac) or ""
            return {"ip": ip, "hostname": h, "mac": mac, "vendor": ven, "open_ports": [], "shares": [], "printer_like": False}
        with futures.ThreadPoolExecutor(max_workers=min(self.threads, 256)) as ex:
            results = list(ex.map(enrich_basic, live))
        if self.do_ports:
            def do_ports(rec: Dict[str,Any]) -> Dict[str,Any]:
                rec["open_ports"] = scan_ports(rec["ip"], self.ports, self.timeout, max_workers=min(256, len(self.ports) or 1))
                rec["printer_like"] = detect_printer_like(rec["open_ports"])
                return rec
            with futures.ThreadPoolExecutor(max_workers=min(self.threads, 256)) as ex:
                results = list(ex.map(do_ports, results))
        if self.do_smb and _HAS_SMB:
            def do_smb(rec: Dict[str,Any]) -> Dict[str,Any]:
                if 445 in rec.get("open_ports", []):
                    rec["shares"] = enumerate_smb_shares(rec["ip"])[:25]
                return rec
            with futures.ThreadPoolExecutor(max_workers=64) as ex:
                results = list(ex.map(do_smb, results))
        self.finished.emit(results, "")

class HexWindow(QWidget):
    def __init__(self):
        super().__init__(None, Qt.FramelessWindowHint | Qt.Window)
        self.setAttribute(Qt.WA_TranslucentBackground, True)
        self.dragging = False
        self.drag_offset = QPoint()
        self.resize(1000, 660)
        self.setMinimumSize(860, 560)
        self.container = QWidget(self)
        self.container.setObjectName("container")
        self.container.setStyleSheet("""
            #container { background: qlineargradient(x1:0,y1:0,x2:1,y2:1, stop:0 #0e1526, stop:1 #0a1020);
                         border:1px solid rgba(255,255,255,0.12); border-radius:28px; }
            QLabel, QLineEdit, QCheckBox, QPushButton, QComboBox { color:#e5e7eb; font-size:14px; }
            QCheckBox::indicator { width:16px; height:16px; }
            QLineEdit, QComboBox { background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.18);
                        border-radius:10px; padding:8px 10px; }
            QPushButton { background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.20);
                          border-radius:12px; padding:10px 16px; min-width:104px; }
            QPushButton:hover { background:rgba(255,255,255,0.14); }
            QToolButton { background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.20);
                          border-radius:10px; padding:6px 10px; }
            QToolButton::menu-indicator { image: none; }
            QTableWidget { background:rgba(255,255,255,0.04); gridline-color:rgba(255,255,255,0.18);
                           border:1px solid rgba(255,255,255,0.12); border-radius:12px; alternate-background-color:rgba(255,255,255,0.06); }
            QHeaderView::section { background:rgba(255,255,255,0.12); padding:8px; border:0px; color:#ffffff; font-weight:700; }
            QTableWidget::item { color:#e9eef8; }
            QTableWidget::item:selected { background:rgba(59,130,246,0.35); color:#ffffff; }
            QProgressBar { text-align:center; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.18);
                           border-radius:10px; color:#e5e7eb; }
            QProgressBar::chunk { background:#3b82f6; border-radius:8px; }
        """)
        shadow = QGraphicsDropShadowEffect(self.container)
        shadow.setBlurRadius(50)
        shadow.setOffset(0, 20)
        shadow.setColor(QColor(0,0,0,150))
        self.container.setGraphicsEffect(shadow)
        self.vbox = QVBoxLayout(self.container)
        self.vbox.setContentsMargins(22, 22, 22, 22)
        self.vbox.setSpacing(12)
        top = QHBoxLayout()
        top.setSpacing(10)
        self.title = QLabel("IP Scan")
        self.title.setStyleSheet("font-size:18px;font-weight:800;color:#e2e8f0;")
        top.addWidget(self.title)
        top.addStretch(1)
        self.minBtn = QPushButton("—")
        self.minBtn.setFixedSize(30, 30)
        self.minBtn.setStyleSheet("QPushButton{border-radius:15px;background:#fbbf24;color:#0b1220;font-weight:900;min-width:30px;}")
        self.minBtn.clicked.connect(self.showMinimized)
        self.closeBtn = QPushButton("×")
        self.closeBtn.setFixedSize(30, 30)
        self.closeBtn.setStyleSheet("QPushButton{border-radius:15px;background:#ef4444;color:white;font-weight:900;min-width:30px;}")
        self.closeBtn.clicked.connect(self.close)
        top.addWidget(self.minBtn); top.addWidget(self.closeBtn)
        self.vbox.addLayout(top)
        ctl = FlowLayout(hspacing=10, vspacing=8, margins=(2,2,2,2))
        self.netCombo = QComboBox(); self.netCombo.setMinimumWidth(280)
        self.netInput = QLineEdit(); self.netInput.setPlaceholderText("Auto or enter CIDR(s), comma-separated"); self.netInput.setMinimumWidth(260); self.netInput.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.refreshBtn = QToolButton(); self.refreshBtn.setText("↻"); self.refreshBtn.setToolTip("Refresh adapters"); self.refreshBtn.setFixedWidth(36)
        net_group = QWidget(); nh = QHBoxLayout(net_group); nh.setContentsMargins(0,0,0,0); nh.setSpacing(6)
        nh.addWidget(self.netCombo); nh.addWidget(self.netInput); nh.addWidget(self.refreshBtn)
        ctl.addWidget(self.wrap_labeled("Network", net_group))
        self.doPorts = QCheckBox("Port scan"); self.doPorts.setChecked(True); ctl.addWidget(self.doPorts)
        self.doSMB = QCheckBox("SMB shares"); self.doSMB.setChecked(True); ctl.addWidget(self.doSMB)
        self.timeoutInput = QLineEdit(); self.timeoutInput.setFixedWidth(80); self.timeoutInput.setText("0.5"); ctl.addWidget(self.wrap_labeled("Timeout", self.timeoutInput))
        self.threadInput = QLineEdit(); self.threadInput.setFixedWidth(80); self.threadInput.setText("400"); ctl.addWidget(self.wrap_labeled("Threads", self.threadInput))
        self.scanBtn = QPushButton("Scan"); self.scanBtn.setMinimumWidth(110); ctl.addWidget(self.scanBtn)
        self.exportBtn = QPushButton("Export CSV"); self.exportBtn.setMinimumWidth(130); self.exportBtn.setEnabled(False); ctl.addWidget(self.exportBtn)
        ctl_host = QWidget(); ctl_host.setLayout(ctl); self.vbox.addWidget(ctl_host)
        self.progress = QProgressBar(); self.progress.setRange(0,100); self.progress.setValue(0); self.vbox.addWidget(self.progress)
        self.table = QTableWidget(0, 7); self.table.setAlternatingRowColors(True)
        self.table.setHorizontalHeaderLabels(["IP","Hostname","MAC","Manufacturer","Open Ports","Printer","SMB Shares"])
        hdr = self.table.horizontalHeader(); hdr.setSectionsClickable(False); hdr.setStretchLastSection(False)
        hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive); hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        for c in [2,3,4,5,6]: hdr.setSectionResizeMode(c, QHeaderView.ResizeMode.Interactive)
        self.table.setColumnWidth(0,140); self.table.setColumnWidth(2,170); self.table.setColumnWidth(3,200); self.table.setColumnWidth(4,140); self.table.setColumnWidth(5,90); self.table.setColumnWidth(6,160)
        self.table.verticalHeader().setDefaultSectionSize(32); self.table.verticalHeader().setVisible(False)
        self.vbox.addWidget(self.table, 1)
        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
        self.table.customContextMenuRequested.connect(self.on_table_context)
        self.scanBtn.clicked.connect(self.start_scan); self.exportBtn.clicked.connect(self.export_csv)
        self.refreshBtn.clicked.connect(self.populate_networks)
        self.netCombo.currentIndexChanged.connect(self.on_network_selected)
        self.results: List[Dict[str, Any]] = []
        self.populate_networks()

    def wrap_labeled(self, text: str, widget: QWidget) -> QWidget:
        w = QWidget(); h = QHBoxLayout(w); h.setContentsMargins(0,0,0,0); h.setSpacing(6); lbl = QLabel(text); lbl.setStyleSheet("color:#cbd5e1;font-weight:700;"); h.addWidget(lbl); h.addWidget(widget); w.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed); return w

    def resizeEvent(self, e):
        super().resizeEvent(e); self.container.setGeometry(self.hex_inner_rect()); self.apply_hex_mask()

    def hex_inner_rect(self)->QRect:
        w,h=self.width(),self.height(); t=int(min(w,h)*0.18); ml=mr=t+22; mt=mb=22; return QRect(ml,mt,max(420,w-ml-mr),max(260,h-mt-mb))

    def apply_hex_mask(self):
        w,h=self.width(),self.height(); t=int(min(w,h)*0.18)
        pts=[QPoint(t,0), QPoint(w-t,0), QPoint(w,h//2), QPoint(w-t,h), QPoint(t,h), QPoint(0,h//2)]
        self.setMask(QRegion(QPolygon(pts)))

    def paintEvent(self,e): p=QPainter(self); p.setRenderHint(QPainter.Antialiasing,True); p.end()

    def mousePressEvent(self,e):
        if e.button()==Qt.LeftButton: self.dragging=True; self.drag_offset=e.globalPosition().toPoint()-self.frameGeometry().topLeft(); e.accept()

    def mouseMoveEvent(self,e):
        if self.dragging and e.buttons()&Qt.LeftButton: self.move(e.globalPosition().toPoint()-self.drag_offset); e.accept()

    def mouseReleaseEvent(self,e):
        if e.button()==Qt.LeftButton: self.dragging=False; e.accept()

    def populate_networks(self):
        self.netCombo.blockSignals(True); self.netCombo.clear(); self.netCombo.addItem("Current (auto)", userData="Current (auto)"); self.netCombo.addItem("All adapters", userData="All adapters")
        nets = parse_all_networks()
        for n in nets:
            label = f"{n['name']} — {n['cidr']}" + (f" via {n['gateway']}" if n.get('gateway') else " (no gw)")
            self.netCombo.addItem(label, userData=n["cidr"])
        self.netCombo.setCurrentIndex(0); self.netInput.clear(); self.netCombo.blockSignals(False)

    @Slot(int)
    def on_network_selected(self, idx: int):
        data = self.netCombo.itemData(idx)
        if data and data not in ("Current (auto)","All adapters"):
            self.netInput.setText(str(data))
        else:
            self.netInput.clear()

    def start_scan(self):
        if hasattr(self,"worker") and self.worker.isRunning(): return
        mode = self.netCombo.currentText()
        cidr_text = self.netInput.text().strip() or None
        try: timeout=float(self.timeoutInput.text().strip())
        except Exception: timeout=0.5
        try: threads=int(self.threadInput.text().strip())
        except Exception: threads=400
        nets = expand_targets(cidr_text, "All adapters" if mode.startswith("All adapters") else ("Current (auto)" if mode.startswith("Current") else mode))
        cidrs = [str(n) for n in nets]
        if not cidrs:
            QMessageBox.warning(self,"Network","No valid networks to scan."); return
        self.table.setRowCount(0); self.results=[]; self.exportBtn.setEnabled(False); self.progress.setRange(0,0)
        ports = COMMON_PORTS[:] if self.doPorts.isChecked() else []
        self.worker = ScanWorker(cidrs, self.doPorts.isChecked(), self.doSMB.isChecked(), timeout, threads, ports)
        self.worker.progress.connect(self.on_progress); self.worker.finished.connect(self.on_finished); self.worker.start()

    @Slot(int,int)
    def on_progress(self, cur: int, total: int):
        if total>0: self.progress.setRange(0,total); self.progress.setValue(cur)

    @Slot(list,str)
    def on_finished(self, results: List[Dict[str,Any]], error: str):
        self.progress.setRange(0,1); self.progress.setValue(1)
        if error: QMessageBox.warning(self,"Scan error",error)
        self.results = results or []; self.populate_table(self.results); self.exportBtn.setEnabled(bool(self.results))

    def _ports_button(self, ports: List[int], ip: str):
        if not ports:
            lbl = QLabel("—"); lbl.setStyleSheet("color:#94a3b8;"); return lbl
        btn = QToolButton(); btn.setText(f"{len(ports)} ports ▾")
        menu = QMenu(btn)
        for p in ports:
            a = QAction(str(p), menu); a.setEnabled(False); menu.addAction(a)
        menu.addSeparator()
        a_copy = QAction("Copy ports", menu); a_copy.triggered.connect(lambda: self.copy_to_clipboard(" ".join(map(str, ports)), "Ports copied")); menu.addAction(a_copy)
        if 443 in ports:
            a_https = QAction("Open https://"+ip, menu); a_https.triggered.connect(lambda: webbrowser.open(f"https://{ip}")); menu.addAction(a_https)
        if 80 in ports:
            a_http = QAction("Open http://"+ip, menu); a_http.triggered.connect(lambda: webbrowser.open(f"http://{ip}")); menu.addAction(a_http)
        btn.setMenu(menu); btn.setPopupMode(QToolButton.InstantPopup)
        return btn

    def _shares_button(self, shares: List[str]):
        if not shares:
            lbl = QLabel("—"); lbl.setStyleSheet("color:#94a3b8;"); return lbl
        btn = QToolButton(); btn.setText(f"{len(shares)} shares ▾")
        menu = QMenu(btn)
        for s in shares:
            a = QAction(s, menu); a.setEnabled(False); menu.addAction(a)
        menu.addSeparator()
        a_copy = QAction("Copy shares", menu); a_copy.triggered.connect(lambda: self.copy_to_clipboard("; ".join(shares), "Shares copied")); menu.addAction(a_copy)
        btn.setMenu(menu); btn.setPopupMode(QToolButton.InstantPopup)
        return btn

    def populate_table(self, results: List[Dict[str,Any]]):
        self.table.setRowCount(0)
        for r in results:
            row = self.table.rowCount(); self.table.insertRow(row)
            vals = [r.get("ip",""), r.get("hostname",""), r.get("mac",""), r.get("vendor","")]
            for col, val in enumerate(vals):
                item = QTableWidgetItem(val)
                if col in (0,2):
                    try: item.setFont(QFont("Consolas",10))
                    except Exception: pass
                self.table.setItem(row, col, item)
            self.table.setCellWidget(row,4, self._ports_button(r.get("open_ports",[]), r.get("ip","")))
            self.table.setItem(row,5, QTableWidgetItem("yes" if r.get("printer_like") else ""))
            self.table.setCellWidget(row,6, self._shares_button(r.get("shares",[])))

    def on_table_context(self, pos):
        idx = self.table.indexAt(pos)
        if not idx.isValid(): return
        row = idx.row()
        if not (0 <= row < len(self.results)): return
        rec = self.results[row]
        ip = rec.get("ip",""); host=rec.get("hostname",""); mac=rec.get("mac",""); ven=rec.get("vendor","")
        menu = QMenu(self.table)
        menu.addAction("Copy IP", lambda: self.copy_to_clipboard(ip, "IP copied"))
        menu.addAction("Copy Hostname", lambda: self.copy_to_clipboard(host, "Hostname copied"))
        menu.addAction("Copy MAC", lambda: self.copy_to_clipboard(mac, "MAC copied"))
        menu.addAction("Copy Manufacturer", lambda: self.copy_to_clipboard(ven, "Manufacturer copied"))
        menu.addSeparator()
        menu.addAction("Copy row (JSON)", lambda: self.copy_to_clipboard(json.dumps(rec, indent=2), "Row JSON copied"))
        menu.addSeparator()
        menu.addAction("Open HTTPS", lambda: webbrowser.open(f"https://{ip}"))
        menu.addAction("Open HTTP",  lambda: webbrowser.open(f"http://{ip}"))
        menu.exec(self.table.viewport().mapToGlobal(pos))

    def copy_to_clipboard(self, text: str, toast: str):
        QApplication.clipboard().setText(text or ""); QToolTip.showText(QCursor.pos(), toast, self.table, timeout=1000)

    def export_csv(self):
        if not self.results: return
        path,_ = QFileDialog.getSaveFileName(self, "Save CSV", "lan_inventory_gui.csv", "CSV Files (*.csv)")
        if not path: return
        try:
            with open(path, "w", newline="", encoding="utf-8") as f:
                w = csv.writer(f); w.writerow(["IP","Hostname","MAC","Manufacturer","Open Ports","Printer-like","SMB Shares"])
                for r in self.results:
                    w.writerow([
                        r.get("ip",""), r.get("hostname",""), r.get("mac",""), r.get("vendor",""),
                        " ".join(map(str, r.get("open_ports",[]))) if r.get("open_ports") else "",
                        "yes" if r.get("printer_like") else "",
                        "; ".join(r.get("shares",[])) if r.get("shares") else ""
                    ])
            QMessageBox.information(self, "Export", f"Saved: {path}")
        except Exception as e:
            QMessageBox.warning(self, "Export error", str(e))

if __name__ == "__main__":
    app = QApplication(sys.argv)
    w = HexWindow()
    w.setWindowIcon(QIcon())
    w.setWindowTitle("IP Scan")
    w.show()
    sys.exit(app.exec())
          
Version 1.0.0 Updated Aug 27, 2025 Size ~48 MB

What it does

LAN discovery

Auto-detects your active subnet and finds live hosts quickly.

Ports & shares

Scans common ports (RDP/HTTP/HTTPS/SMB/etc) and lists SMB shares when accessible.

IP/MAC/vendor

Pulls MAC via ARP, maps to manufacturer (OUI), and attempts reverse DNS for hostnames.

Right-click actions

Copy IP/MAC/hostname/vendor or open device via HTTP/HTTPS directly.

Multi-network dropdown

Quickly switch between adapters/VLANs/VPNs; “Current (auto)” stays the default.

No admin required

Works out of the box using ping+ARP; optional SMB and vendor lookup supported.

How it works

1
Download & run

No install needed. Just launch the EXE and click Scan.

2
Pick a network

Use the Network dropdown to target any detected adapter/subnet or keep “Current (auto)”.

3
Review results

See IP, hostname, MAC, vendor, open ports, and SMB shares. Export to CSV anytime.

FAQ

Does it require admin rights?

No. Standard user works. Some features (like advanced ARP sweep) may require admin if you enable them in future builds.

Will my antivirus flag it?

Port scanners can trigger heuristics. If you see a warning, verify the SHA-256 shown above and allow/restore.

Does it phone home?

No telemetry. Optional MAC vendor lookup uses a local OUI DB when installed; SMB listing is LAN-only.

Can it scan other VLANs?

Yes, if they’re routed/accessible from your host. The network dropdown shows local adapter subnets; routed ranges can still be scanned by entering a CIDR manually.

Download options

Version 1.0.0 Updated 2025-08-27 Size ~48 MB

Quick actions

Right-click row

Copy IP / Hostname / MAC / Vendor, or open HTTP/HTTPS.

Ports & Shares

Use the per-row dropdowns to view/copy open ports and SMB shares.

Export

Save results to CSV for tickets, audits, or inventory.

System requirements

  • Windows 10 or later
  • ~48 MB free space
  • No admin required (default features)
Need help or a custom build for your environment? Get in touch.

← Back to Home