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:
@@ -9,10 +9,11 @@
|
|||||||
- 🟢 静态绑定(host):MAC + 固定 IP + 主机名
|
- 🟢 静态绑定(host):MAC + 固定 IP + 主机名
|
||||||
- 🟢 监听接口配置(INTERFACESv4)
|
- 🟢 监听接口配置(INTERFACESv4)
|
||||||
- 🟢 租约查询(按 IP/MAC/主机名搜索)+ 地址池使用率
|
- 🟢 租约查询(按 IP/MAC/主机名搜索)+ 地址池使用率
|
||||||
|
- 🟢 网络扫描(arp-scan):列出网段内活跃设备,标记 静态绑定 / 动态分配 / 未知
|
||||||
- 🟢 服务状态 + reload / restart + 语法检查
|
- 🟢 服务状态 + 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
|
```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
|
pip3 install -r requirements.txt --break-system-packages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -459,6 +459,34 @@ def lease_list():
|
|||||||
return render_template("leases.html", leases=leases, q=q)
|
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) ----------
|
# ---------- config (interfaces + 原始 conf) ----------
|
||||||
|
|
||||||
@app.route("/config", methods=["GET", "POST"])
|
@app.route("/config", methods=["GET", "POST"])
|
||||||
|
|||||||
+88
-1
@@ -515,4 +515,91 @@ def get_service_status() -> Dict:
|
|||||||
info["status_text"] = "\n".join(info["status_text"][:5])
|
info["status_text"] = "\n".join(info["status_text"][:5])
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
pass
|
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
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<a href="{{ url_for('subnet_list') }}" class="{% if request.endpoint in ['subnet_list','subnet_new','subnet_edit'] %}active{% endif %}">子网</a>
|
<a href="{{ url_for('subnet_list') }}" class="{% if request.endpoint in ['subnet_list','subnet_new','subnet_edit'] %}active{% endif %}">子网</a>
|
||||||
<a href="{{ url_for('host_list') }}" class="{% if request.endpoint in ['host_list','host_new','host_edit'] %}active{% endif %}">静态绑定</a>
|
<a href="{{ url_for('host_list') }}" class="{% if request.endpoint in ['host_list','host_new','host_edit'] %}active{% endif %}">静态绑定</a>
|
||||||
<a href="{{ url_for('lease_list') }}" class="{% if request.endpoint=='lease_list' %}active{% endif %}">租约</a>
|
<a href="{{ url_for('lease_list') }}" class="{% if request.endpoint=='lease_list' %}active{% endif %}">租约</a>
|
||||||
|
<a href="{{ url_for('scan') }}" class="{% if request.endpoint=='scan' %}active{% endif %}">扫描</a>
|
||||||
<a href="{{ url_for('config_view') }}" class="{% if request.endpoint=='config_view' %}active{% endif %}">服务配置</a>
|
<a href="{{ url_for('config_view') }}" class="{% if request.endpoint=='config_view' %}active{% endif %}">服务配置</a>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<a href="{{ url_for('log_list') }}" class="{% if request.endpoint=='log_list' %}active{% endif %}">日志</a>
|
<a href="{{ url_for('log_list') }}" class="{% if request.endpoint=='log_list' %}active{% endif %}">日志</a>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}网络扫描{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>网络扫描</h1>
|
||||||
|
<span class="muted small">用 arp-scan 列出网段内活跃设备</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" class="form-inline">
|
||||||
|
<label>接口
|
||||||
|
<select name="iface">
|
||||||
|
{% for i in ifaces %}
|
||||||
|
<option value="{{ i }}" {% if i==iface %}selected{% endif %}>{{ i }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not ifaces %}
|
||||||
|
<option value="{{ iface }}">{{ iface }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>网段
|
||||||
|
<input type="text" name="network" value="{{ network or '' }}" placeholder="10.168.1.0/24">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary">扫描</button>
|
||||||
|
<label class="checkbox-row" style="margin-left:12px">
|
||||||
|
<input type="checkbox" id="autoRefresh" checked>
|
||||||
|
自动刷新(30 秒)
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
{% if did_scan %}
|
||||||
|
<p class="muted small" style="margin-top:8px">{{ scan_msg }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if did_scan %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>扫描结果({{ devices|length }} 个设备)</h2>
|
||||||
|
{% if devices %}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>MAC</th>
|
||||||
|
<th>来源</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in devices %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ d.ip }}</code></td>
|
||||||
|
<td><code>{{ d.mac }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if d.source == 'static' %}
|
||||||
|
<span class="badge badge-ok">静态绑定</span>
|
||||||
|
{% elif d.source == 'dynamic' %}
|
||||||
|
<span class="badge badge-warn">动态分配</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">未知 / 外部</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
{% if d.source != 'static' and is_admin %}
|
||||||
|
<form method="post" action="{{ url_for('host_new') }}" style="display:inline">
|
||||||
|
<input type="hidden" name="mac" value="{{ d.mac }}">
|
||||||
|
<input type="hidden" name="ip" value="{{ d.ip }}">
|
||||||
|
<input type="hidden" name="name" value="dev-{{ d.ip|replace('.', '-') }}">
|
||||||
|
<button type="submit" class="btn btn-sm">转为静态绑定</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted small">
|
||||||
|
说明:「静态绑定」= 在 Web 静态绑定(host 声明)里;
|
||||||
|
「动态分配」= 在 dhcpd.leases 里被分配过;
|
||||||
|
「未知/外部」= 不在上述两类(可能手动配的或别的 DHCP 服务器给的)。
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">未发现活跃设备(网段内无应答)</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card empty">
|
||||||
|
<p>点击「扫描」开始 ARP 扫描。</p>
|
||||||
|
<p class="muted small">提示:扫描时会向网段每个 IP 发 ARP 请求,3-5 秒完成。</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let timer = null;
|
||||||
|
function setupAutoRefresh() {
|
||||||
|
const cb = document.getElementById('autoRefresh');
|
||||||
|
if (!cb) return;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (!cb.checked) return;
|
||||||
|
// 只在已经扫过一次的情况下自动刷新
|
||||||
|
if (document.querySelector('table.data-table')) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const f = document.querySelector('form.form-inline');
|
||||||
|
if (f) f.submit();
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', setupAutoRefresh);
|
||||||
|
document.getElementById('autoRefresh')?.addEventListener('change', setupAutoRefresh);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user