diff --git a/README.md b/README.md index a20bdd8..acee1b1 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ - 🟢 静态绑定(host):MAC + 固定 IP + 主机名 - 🟢 监听接口配置(INTERFACESv4) - 🟢 租约查询(按 IP/MAC/主机名搜索)+ 地址池使用率 +- 🟢 网络扫描(arp-scan):列出网段内活跃设备,标记 静态绑定 / 动态分配 / 未知 - 🟢 服务状态 + reload / restart + 语法检查 - 🟢 操作日志(谁在什么时间做了什么) - 🟢 用户名密码登录 + 改密 -- 🟢 所有变更自动:写文件 → 备份 → `dhcpd -t` 语法检查 → `systemctl reload isc-dhcp-server` +- 🟢 所有变更自动:写文件 → 备份 → `dhcpd -t` 语法检查 → `systemctl restart isc-dhcp-server`(带 is-active 验证) ## 文件结构 @@ -30,7 +31,7 @@ dhcp-service/ ## 安装 ```bash -apt-get install -y isc-dhcp-server # 后端 DHCP 服务 +apt-get install -y isc-dhcp-server arp-scan dhcping # 后端 DHCP 服务 + 扫描工具 pip3 install -r requirements.txt --break-system-packages ``` diff --git a/app.py b/app.py index afb97c0..8f6544e 100644 --- a/app.py +++ b/app.py @@ -459,6 +459,34 @@ def lease_list(): return render_template("leases.html", leases=leases, q=q) +# ---------- 网络扫描 (arp-scan) ---------- + +@app.route("/scan", methods=["GET", "POST"]) +@login_required +def scan(): + """网络扫描 - 用 arp-scan 列出网段内所有活跃设备""" + ifaces = dhcp_ops.get_interfaces() + iface = request.values.get("iface") or (ifaces[0] if ifaces else "enp0s31f6-ovs") + network = request.values.get("network") or dhcp_ops.get_local_network(iface) + + devices = [] + scan_msg = "" + did_scan = False + + if request.method == "POST" or request.args.get("auto") == "1": + did_scan = True + ok, scan_msg, devices = dhcp_ops.arp_scan(iface, network) + if ok: + # 交叉 dhcpd.leases 和 host 静态绑定 + hosts = get_hosts() + leases = dhcp_ops.parse_leases() + devices = dhcp_ops.classify_devices(devices, hosts, leases) + + return render_template("scan.html", + ifaces=ifaces, iface=iface, network=network, + devices=devices, scan_msg=scan_msg, did_scan=did_scan) + + # ---------- config (interfaces + 原始 conf) ---------- @app.route("/config", methods=["GET", "POST"]) diff --git a/dhcp_ops.py b/dhcp_ops.py index f56a5fa..5d693b0 100644 --- a/dhcp_ops.py +++ b/dhcp_ops.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 9b82050..3ac4676 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,6 +16,7 @@ 子网 静态绑定 租约 + 扫描 服务配置 {% if is_admin %} 日志 diff --git a/templates/scan.html b/templates/scan.html new file mode 100644 index 0000000..f090456 --- /dev/null +++ b/templates/scan.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}网络扫描{% endblock %} +{% block content %} +
+

网络扫描

+ 用 arp-scan 列出网段内活跃设备 +
+ +
+
+ + + + +
+ {% if did_scan %} +

{{ scan_msg }}

+ {% endif %} +
+ +{% if did_scan %} +
+

扫描结果({{ devices|length }} 个设备)

+ {% if devices %} + + + + + + + + + + + {% for d in devices %} + + + + + + + {% endfor %} + +
IPMAC来源操作
{{ d.ip }}{{ d.mac }} + {% if d.source == 'static' %} + 静态绑定 + {% elif d.source == 'dynamic' %} + 动态分配 + {% else %} + 未知 / 外部 + {% endif %} + + {% if d.source != 'static' and is_admin %} +
+ + + + +
+ {% endif %} +
+

+ 说明:「静态绑定」= 在 Web 静态绑定(host 声明)里; + 「动态分配」= 在 dhcpd.leases 里被分配过; + 「未知/外部」= 不在上述两类(可能手动配的或别的 DHCP 服务器给的)。 +

+ {% else %} +

未发现活跃设备(网段内无应答)

+ {% endif %} +
+{% else %} +
+

点击「扫描」开始 ARP 扫描。

+

提示:扫描时会向网段每个 IP 发 ARP 请求,3-5 秒完成。

+
+{% endif %} + + +{% endblock %} \ No newline at end of file