Add network scan page (arp-scan)
New /scan page lists all active devices on the subnet (ARP scan): - 静态绑定: matches a host declaration - 动态分配: matches a dhcpd.leases entry - 未知 / 外部: nothing matches (manually configured or from other DHCP server) Click '转为静态绑定' to convert a discovered device to a static host binding. Also includes: - arp-scan wrapper in dhcp_ops.py (get_local_network, arp_scan, classify_devices) - /scan route with optional auto-refresh every 30s - README updated with arp-scan install step + new feature bullet
This commit is contained in:
+88
-1
@@ -515,4 +515,91 @@ def get_service_status() -> Dict:
|
||||
info["status_text"] = "\n".join(info["status_text"][:5])
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return info
|
||||
return info
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 网络扫描(arp-scan)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ArpDevice:
|
||||
ip: str
|
||||
mac: str
|
||||
vendor: str = ""
|
||||
source: str = "" # 标记:static / dynamic / unknown
|
||||
|
||||
|
||||
def get_local_network(iface: str = "enp0s31f6-ovs") -> Optional[str]:
|
||||
"""从指定接口的 IPv4 地址 + 掩码计算出 /24 网络号"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ip", "-4", "-o", "addr", "show", "dev", iface],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout
|
||||
# 形如: "4: enp0s31f6-ovs inet 10.168.1.209/24 brd 10.168.1.255 scope global enp0s31f6-ovs\"
|
||||
import re as _re
|
||||
m = _re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)/(\d+)", out)
|
||||
if not m:
|
||||
return None
|
||||
ip, prefix = m.group(1), int(m.group(2))
|
||||
if prefix != 24:
|
||||
# 仅支持 /24 扫描;其他掩码返回 None 让 UI 提示用户
|
||||
return None
|
||||
return ".".join(ip.split(".")[:3]) + ".0/24"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def arp_scan(iface: str = "enp0s31f6-ovs", network: Optional[str] = None,
|
||||
timeout: int = 10) -> Tuple[bool, str, List[ArpDevice]]:
|
||||
"""
|
||||
扫描指定接口/网段,返回 (ok, msg, devices)。
|
||||
- 用 arp-scan(系统二进制)
|
||||
- 如果传 network=None,自动从 iface 推算
|
||||
"""
|
||||
if network is None:
|
||||
network = get_local_network(iface) or "10.168.1.0/24"
|
||||
|
||||
cmd = ["arp-scan", f"--interface={iface}", "--numeric", "--quiet", "--retry=1",
|
||||
"--timeout=" + str(timeout * 1000), network]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
|
||||
if r.returncode not in (0, 1): # arp-scan: 0=全部收到, 1=部分收到
|
||||
return False, (r.stderr or r.stdout).strip(), []
|
||||
except FileNotFoundError:
|
||||
return False, "未安装 arp-scan(apt-get install arp-scan)", []
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "arp-scan 超时", []
|
||||
|
||||
devices: List[ArpDevice] = []
|
||||
for line in r.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("Interface:") or "arp-scan" in line or "packets" in line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and "." in parts[0] and ":" in parts[1]:
|
||||
devices.append(ArpDevice(ip=parts[0], mac=parts[1].lower()))
|
||||
msg = f"扫描完成,发现 {len(devices)} 个设备"
|
||||
return True, msg, devices
|
||||
|
||||
|
||||
def classify_devices(devices: List[ArpDevice], hosts, leases) -> List[ArpDevice]:
|
||||
"""
|
||||
给设备打标记:
|
||||
- static: 在静态绑定(host 声明)里
|
||||
- dynamic: 在 dhcpd.leases 里
|
||||
- unknown: 都不在(手动配的或别处 DHCP 给的)
|
||||
"""
|
||||
static_macs = {h.mac.lower() for h in hosts}
|
||||
static_ips = {h.ip for h in hosts}
|
||||
lease_macs = {l.mac.lower() for l in leases if l.mac}
|
||||
lease_ips = {l.ip for l in leases}
|
||||
for d in devices:
|
||||
if d.mac in static_macs or d.ip in static_ips:
|
||||
d.source = "static"
|
||||
elif d.mac in lease_macs or d.ip in lease_ips:
|
||||
d.source = "dynamic"
|
||||
else:
|
||||
d.source = "unknown"
|
||||
return devices
|
||||
Reference in New Issue
Block a user