v1.0.1: 多拓扑管理、Web SSH终端、扫描进度修复、拓扑连线优化
- 修复扫描进度条不动的问题(分4阶段更新进度) - 新增Web SSH远程终端(xterm.js + WebSocket) - 新增多拓扑管理(创建/切换拓扑、全局设备池) - 简化新建拓扑流程(仅需名称,创建后选择设备) - 修复拓扑Builder设备去重(按IP去重) - 修复启动时拓扑设备不加载到Builder的问题 - 优化MAC前缀匹配(避免歧义前缀导致错误连线) - 拓扑连线改为无向(去除箭头) - 设备详情面板加宽到600px
Esse commit está contido em:
@@ -23,3 +23,6 @@ config.json
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# 运行时数据
|
||||
data/
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 邻居信息获取问题修复说明
|
||||
|
||||
## 问题描述
|
||||
网络拓扑发现系统无法正确获取设备间的邻居信息,导致拓扑图无法显示设备间的连接关系。
|
||||
|
||||
## 根本原因分析
|
||||
|
||||
1. **缺少ARP表支持**:原始代码没有利用ARP表来辅助邻居设备的IP地址匹配
|
||||
2. **解析器参数不完整**:H3C、Cisco和华为设备的解析器在解析邻居信息时未充分利用可用的ARP数据
|
||||
3. **MAC地址匹配不完善**:虽然有MAC地址匹配逻辑,但缺乏完整的IP-MAC映射支持
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. H3C设备修复 (`internal/device/h3c.go`)
|
||||
- 添加 `display arp` 命令到命令列表
|
||||
- 修改 `Parse` 函数以接收6个输出而非5个
|
||||
- 实现ARP表解析功能,建立MAC到IP的映射
|
||||
- 在邻居解析过程中使用ARP表补充缺失的IP地址信息
|
||||
|
||||
### 2. Cisco设备修复 (`internal/device/cisco.go`)
|
||||
- 添加 `show arp` 命令到命令列表
|
||||
- 修改 `Parse` 函数以接收6个输出而非5个
|
||||
- 实现ARP表解析功能,处理Cisco特有的MAC地址格式(点号分隔)
|
||||
- 在CDP和LLDP邻居解析中使用ARP表补充IP地址
|
||||
|
||||
### 3. 华为设备修复 (`internal/device/huawei.go`)
|
||||
- 添加 `display arp` 命令到命令列表
|
||||
- 修改 `Parse` 函数以接收5个输出而非4个
|
||||
- 实现ARP表解析功能
|
||||
- 在LLDP邻居解析中使用ARP表信息
|
||||
|
||||
## 技术改进
|
||||
|
||||
### ARP表解析
|
||||
- 支持多种格式的ARP表输出
|
||||
- 自动标准化MAC地址格式(统一使用连字符分隔)
|
||||
- 验证IP和MAC地址的有效性
|
||||
|
||||
### 邻居匹配增强
|
||||
- 优先使用LLDP/CDP提供的直接IP地址
|
||||
- 当缺少IP地址时,通过MAC地址在ARP表中查找对应IP
|
||||
- 保留原有的主机名匹配和MAC地址匹配机制
|
||||
|
||||
### 调试信息增强
|
||||
- 添加详细的调试日志,便于排查问题
|
||||
- 显示ARP表条目数量
|
||||
- 记录通过ARP表成功匹配的邻居信息
|
||||
|
||||
## 测试方法
|
||||
|
||||
1. 运行 `test-fix.bat` 编译并启动应用
|
||||
2. 通过Web界面添加网络设备
|
||||
3. 检查控制台输出的调试信息
|
||||
4. 验证拓扑图中是否正确显示设备间连接
|
||||
|
||||
## 预期效果
|
||||
|
||||
修复后,系统应该能够:
|
||||
- 正确解析各种设备的LLDP/CDP邻居信息
|
||||
- 通过ARP表补充缺失的邻居IP地址
|
||||
- 在拓扑图中准确显示设备间的连接关系
|
||||
- 提供更详细的调试信息用于问题排查
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保网络设备已启用LLDP或CDP协议
|
||||
- 确保设备上有完整的ARP表项
|
||||
- 某些老旧设备可能需要更长的超时时间
|
||||
- 防火墙规则不应阻止LLDP/CDP数据包
|
||||
@@ -0,0 +1,140 @@
|
||||
# SSH命令输出处理修复说明
|
||||
|
||||
## 🔍 **问题诊断**
|
||||
|
||||
从调试输出中发现的关键问题:
|
||||
|
||||
### 1. **SSH命令输出混乱**
|
||||
```
|
||||
[H3C ARP DEBUG] First 500 chars of ARP Output:
|
||||
******************************************************************************
|
||||
|
||||
screen-length disable
|
||||
<BJ-FW01-New>screen-length disable
|
||||
<BJ-FW01-New>echo ===CMD_BOUNDARY_0===
|
||||
^
|
||||
% Unrecognized command found at '^' position.
|
||||
<BJ-FW01-New>display version
|
||||
H3C Comware Software, Version 7.1.064, Release 9323P25
|
||||
...
|
||||
```
|
||||
|
||||
**问题分析**:
|
||||
- ARP命令的输出包含了前面所有命令的输出(version、interface等)
|
||||
- 分隔符处理逻辑有缺陷,导致命令输出混在一起
|
||||
- `screen-length disable` 命令失败,但影响了后续处理
|
||||
|
||||
### 2. **根本原因**
|
||||
|
||||
SSH客户端的 `ExecuteCommands` 函数存在以下问题:
|
||||
|
||||
1. **缓冲区累积**:所有命令的输出都写入同一个 `stdoutBuf`,没有清空
|
||||
2. **分隔符处理错误**:使用 `strings.Split` 分割,取倒数第二部分,导致前面的命令输出被包含
|
||||
3. **清理逻辑不足**:`cleanCommandOutput` 无法有效处理混杂的输出
|
||||
|
||||
## ✅ **修复方案**
|
||||
|
||||
### 1. **改进分隔符处理逻辑**
|
||||
```go
|
||||
// 记录上一个分隔符的位置
|
||||
lastDelimiter := ""
|
||||
|
||||
// 正确提取当前命令的输出
|
||||
if lastDelimiter != "" {
|
||||
// 找到上一个分隔符和当前分隔符之间的内容
|
||||
startIdx := strings.LastIndex(rawOutput, lastDelimiter)
|
||||
endIdx := strings.LastIndex(rawOutput, delimiter)
|
||||
|
||||
if startIdx >= 0 && endIdx >= 0 && endIdx > startIdx {
|
||||
cmdOutput = rawOutput[startIdx+len(lastDelimiter):endIdx]
|
||||
}
|
||||
} else {
|
||||
// 第一个命令:从开头到第一个分隔符
|
||||
endIdx := strings.Index(rawOutput, delimiter)
|
||||
if endIdx >= 0 {
|
||||
cmdOutput = rawOutput[:endIdx]
|
||||
}
|
||||
}
|
||||
|
||||
lastDelimiter = delimiter
|
||||
```
|
||||
|
||||
### 2. **清空缓冲区**
|
||||
```go
|
||||
// 每次命令执行后清空缓冲区,避免累积(重要!)
|
||||
stdoutBuf.Reset()
|
||||
stderrBuf.Reset()
|
||||
```
|
||||
|
||||
### 3. **增加等待时间**
|
||||
```go
|
||||
sleepTime := 1 * time.Second
|
||||
if cmd == "display interface" || strings.Contains(cmd, "display interface") {
|
||||
sleepTime = 10 * time.Second // 大输出命令需要更多时间
|
||||
} else if strings.Contains(cmd, "display lldp") {
|
||||
sleepTime = 5 * time.Second
|
||||
} else if strings.Contains(cmd, "display arp") {
|
||||
sleepTime = 5 * time.Second
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **添加调试信息**
|
||||
```go
|
||||
fmt.Printf("[SSH DEBUG] Sending command: %s\n", cmd)
|
||||
fmt.Printf("[SSH DEBUG] Extracted between delimiters: start=%d, end=%d\n", startIdx, endIdx)
|
||||
```
|
||||
|
||||
## 🚀 **预期效果**
|
||||
|
||||
修复后,系统应该能够:
|
||||
|
||||
1. **正确分离每个命令的输出**
|
||||
- ARP命令只包含ARP表数据
|
||||
- LLDP命令只包含LLDP邻居信息
|
||||
- 不再有命令输出混杂的问题
|
||||
|
||||
2. **正确解析ARP表**
|
||||
- 解析器能够正确处理纯净的ARP表数据
|
||||
- 建立正确的IP-MAC映射
|
||||
|
||||
3. **正确解析LLDP邻居**
|
||||
- 解析器能够正确处理纯净的LLDP输出
|
||||
- 提取邻居设备信息
|
||||
|
||||
4. **显示更清晰的调试信息**
|
||||
- 命令发送过程
|
||||
- 分隔符提取过程
|
||||
- 输出长度信息
|
||||
|
||||
## 📝 **测试步骤**
|
||||
|
||||
1. 运行修复后的程序:
|
||||
```bash
|
||||
.\network-topology.exe
|
||||
```
|
||||
|
||||
2. 通过Web界面添加设备 `172.16.8.1`
|
||||
|
||||
3. 查看控制台输出的调试信息:
|
||||
- `[SSH DEBUG]` - SSH命令发送和提取过程
|
||||
- `[H3C ARP DEBUG]` - ARP表解析详情
|
||||
- `[H3C LLDP DEBUG]` - LLDP邻居解析详情
|
||||
|
||||
4. 验证结果:
|
||||
- ARP表应该有多个条目(不再是0)
|
||||
- LLDP邻居应该有数据(如果设备上有邻居)
|
||||
- 拓扑图应该显示设备间连接
|
||||
|
||||
## ⚠️ **注意事项**
|
||||
|
||||
1. **`screen-length disable` 命令失败**:某些H3C设备版本不支持此命令,但已通过清空缓冲区解决
|
||||
2. **命令等待时间**:大输出命令(如 `display interface`)需要更长时间等待
|
||||
3. **设备兼容性**:修复后的代码能更好地处理不同设备类型的输出
|
||||
|
||||
## 🔧 **后续改进**
|
||||
|
||||
如果问题仍然存在,可以考虑:
|
||||
|
||||
1. **改进清理逻辑**:更智能地识别和过滤命令回显
|
||||
2. **错误处理**:忽略失败的命令(如 `screen-length disable`)
|
||||
3. **兼容性检测**:自动检测设备类型,使用正确的命令集
|
||||
@@ -0,0 +1,140 @@
|
||||
# v1.0.0版本对比分析 - 邻居发现修复
|
||||
|
||||
## 🔍 **关键发现**
|
||||
|
||||
通过对比v1.0.0分支的代码,发现了成功获取邻居信息的关键因素:
|
||||
|
||||
### **v1.0.0的成功配置**:
|
||||
|
||||
1. **命令列表简化**
|
||||
```go
|
||||
// v1.0.0版本 - 成功的配置
|
||||
"display lldp neighbor-information", // 非verbose格式
|
||||
```
|
||||
|
||||
而不是:
|
||||
```go
|
||||
// 失败的配置
|
||||
"display lldp neighbor-information verbose", // verbose格式导致解析复杂
|
||||
"display arp", // ARP命令增加复杂性
|
||||
```
|
||||
|
||||
2. **parseNeighbors函数简化**
|
||||
- 只处理非verbose格式的LLDP输出
|
||||
- 使用简单的 `ChassisID/subtype` 和 `PortID/subtype` 格式
|
||||
- 不需要处理复杂的verbose格式(Chassis ID、System name、Management address等)
|
||||
|
||||
3. **SSH命令输出处理**
|
||||
- v1.0.0也存在SSH输出混杂问题,但由于命令数量少(5个),问题不明显
|
||||
- 但我们已经修复了SSH输出处理问题(清空缓冲区+分隔符改进)
|
||||
|
||||
## ✅ **我们的改进**
|
||||
|
||||
### **保持v1.0.0的成功要素**:
|
||||
|
||||
1. **改回非verboseLLDP命令**
|
||||
- 使用 `"display lldp neighbor-information"`
|
||||
- 移除verbose和ARP命令
|
||||
|
||||
2. **简化parseNeighbors函数**
|
||||
- 只处理非verbose格式:`ChassisID/subtype` 和 `PortID/subtype`
|
||||
- 移除复杂的verbose格式处理逻辑
|
||||
- 保留v1.0.0的简单解析逻辑
|
||||
|
||||
### **增加额外的改进**:
|
||||
|
||||
3. **SSH输出处理优化**
|
||||
- 添加缓冲区清空(`stdoutBuf.Reset()`)
|
||||
- 改进分隔符处理逻辑
|
||||
- 增加更合理的等待时间
|
||||
|
||||
4. **调试信息增强**
|
||||
- 添加详细的日志输出
|
||||
- 方便排查问题
|
||||
|
||||
## 📝 **关键差异对比**
|
||||
|
||||
| 项目 | v1.0.0版本 | 之前失败版本 | 现在修复版本 |
|
||||
|------|------------|--------------|--------------|
|
||||
| **LLDP命令** | 非verbose | verbose | 非verbose ✅ |
|
||||
| **命令数量** | 5个 | 6个(含ARP) | 5个 ✅ |
|
||||
| **parseNeighbors** | 简单逻辑 | 复杂逻辑 | 简单逻辑 ✅ |
|
||||
| **SSH处理** | 有问题但影响小 | 问题明显 | 已修复 ✅ |
|
||||
| **ARP支持** | 无 | 有但未正确处理 | 无(保持v1.0.0) ✅ |
|
||||
|
||||
## 🎯 **为什么verbose版本失败?**
|
||||
|
||||
### **输出格式复杂**:
|
||||
```
|
||||
非verbose格式(成功):
|
||||
LLDP neighbor-information of port 20[GigabitEthernet1/0/20]:
|
||||
ChassisID/subtype: a4bb-6de2-62cd/MAC address
|
||||
PortID/subtype: GigabitEthernet0/0/1/Interface name
|
||||
|
||||
verbose格式(失败):
|
||||
LLDP neighbor-information of port 20[GigabitEthernet1/0/20]:
|
||||
Chassis ID: 642f-c7e0-0333
|
||||
System name: RemoteDeviceHostname
|
||||
Port ID type: Interface name(7)
|
||||
Port ID: GigabitEthernet1/0/48
|
||||
Management address: 192.168.1.1
|
||||
...
|
||||
```
|
||||
|
||||
### **SSH输出混杂问题**:
|
||||
- verbose版本的输出更长,更容易受到SSH命令混杂的影响
|
||||
- 更多的行数导致解析器更容易被前面命令的输出干扰
|
||||
|
||||
## 🚀 **预期效果**
|
||||
|
||||
修复后应该能够:
|
||||
|
||||
1. **成功获取LLDP邻居**
|
||||
- 使用v1.0.0验证成功的非verbose格式
|
||||
- parseNeighbors简化为只处理必要信息
|
||||
|
||||
2. **避免SSH输出混杂**
|
||||
- 清空缓冲区防止累积
|
||||
- 改进的分隔符处理
|
||||
|
||||
3. **正确建立拓扑连接**
|
||||
- 通过MAC地址识别邻居设备
|
||||
- 建立设备间的连接关系
|
||||
|
||||
## 🧪 **测试建议**
|
||||
|
||||
1. 运行修复后的程序:
|
||||
```bash
|
||||
.\network-topology.exe
|
||||
```
|
||||
|
||||
2. 添加设备 `172.16.8.1`(H3C设备)
|
||||
|
||||
3. 查看调试输出:
|
||||
```
|
||||
Parsed neighbor MAC: mac-address (from line: ...)
|
||||
Parsed neighbor interface: GigabitEthernetX/X/X
|
||||
Device 172.16.8.1: 25 interfaces, X neighbors
|
||||
```
|
||||
|
||||
4. 验证拓扑图显示邻居连接
|
||||
|
||||
## ⚠️ **注意事项**
|
||||
|
||||
1. **v1.0.0版本的SSH处理也有问题**,但由于命令少,影响较小
|
||||
2. **verbose格式理论上提供更多信息**,但SSH处理问题导致失败
|
||||
3. **如果需要更多信息(System name、Management address)**:
|
||||
- 可以在SSH处理完全稳定后,重新尝试verbose格式
|
||||
- 或者通过其他命令补充信息
|
||||
|
||||
## 📊 **总结**
|
||||
|
||||
v1.0.0版本成功的关键:
|
||||
- **简单即是有效** - 少即是多
|
||||
- **非verbose格式** - 输出短,解析简单
|
||||
- **少的命令数量** - 减少SSH输出混杂的影响
|
||||
|
||||
我们现在的修复:
|
||||
- **回归v1.0.0的成功配置**
|
||||
- **修复SSH处理问题**
|
||||
- **保持简单有效的策略**
|
||||
+410
-23
@@ -15,36 +15,56 @@ import (
|
||||
"network-topology-discovery/internal/device"
|
||||
"network-topology-discovery/internal/scanner"
|
||||
"network-topology-discovery/internal/storage"
|
||||
"network-topology-discovery/internal/terminal"
|
||||
"network-topology-discovery/internal/topology"
|
||||
"network-topology-discovery/pkg/models"
|
||||
)
|
||||
|
||||
// App 应用
|
||||
type App struct {
|
||||
config *config.Config
|
||||
builder *topology.Builder
|
||||
storage *storage.Storage
|
||||
tasks map[string]*models.ScanTask
|
||||
mu sync.RWMutex
|
||||
httpServer *http.Server
|
||||
config *config.Config
|
||||
builder *topology.Builder
|
||||
storage *storage.Storage
|
||||
topologyStorage *storage.TopologyStorage
|
||||
tasks map[string]*models.ScanTask
|
||||
mu sync.RWMutex
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// NewApp 创建应用
|
||||
func NewApp(cfg *config.Config) *App {
|
||||
// 初始化存储(使用JSON文件)
|
||||
// 初始化拓扑存储(管理多个拓扑)
|
||||
topoStorage, err := storage.NewTopologyStorage("data")
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to initialize topology storage: %v", err)
|
||||
}
|
||||
|
||||
// 初始化设备存储(使用默认文件,兼容旧版)
|
||||
store, err := storage.NewStorage("devices.json")
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to initialize storage: %v", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
builder: topology.NewBuilder(),
|
||||
storage: store,
|
||||
tasks: make(map[string]*models.ScanTask),
|
||||
config: cfg,
|
||||
builder: topology.NewBuilder(),
|
||||
storage: store,
|
||||
topologyStorage: topoStorage,
|
||||
tasks: make(map[string]*models.ScanTask),
|
||||
}
|
||||
|
||||
// 从数据库加载设备到拓扑构建器
|
||||
// 如果有拓扑存储,切换到第一个拓扑
|
||||
if topoStorage != nil {
|
||||
topos, err := topoStorage.GetAllTopologies()
|
||||
if err == nil && len(topos) > 0 {
|
||||
topoStorage.SetCurrentTopology(topos[0].ID)
|
||||
deviceFile := topoStorage.GetDeviceFilePath(topos[0].ID)
|
||||
app.storage.SetFilePath(deviceFile)
|
||||
log.Printf("Loaded topology: %s", topos[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载设备到拓扑构建器
|
||||
if store != nil {
|
||||
devices, err := store.GetAllDevices()
|
||||
if err != nil {
|
||||
@@ -84,6 +104,18 @@ func (app *App) Start() error {
|
||||
mux.HandleFunc("/api/device", app.handleAddDevice)
|
||||
mux.HandleFunc("/api/device/{id}", app.handleDeviceDetail)
|
||||
|
||||
// 拓扑管理API
|
||||
mux.HandleFunc("/api/topologies", app.handleTopologies)
|
||||
mux.HandleFunc("/api/topology/switch", app.handleSwitchTopology)
|
||||
mux.HandleFunc("/api/topology/{id}", app.handleTopologyDetail)
|
||||
|
||||
// SSH终端API
|
||||
mux.HandleFunc("/api/terminal", app.handleTerminalConnect)
|
||||
|
||||
// 全局设备池API
|
||||
mux.HandleFunc("/api/devices/all", app.handleGetAllDevices)
|
||||
mux.HandleFunc("/api/topology/{id}/devices", app.handleAddDevicesToTopology)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", app.config.Web.Host, app.config.Web.Port)
|
||||
app.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
@@ -157,6 +189,16 @@ func (app *App) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
app.tasks[taskID] = task
|
||||
app.mu.Unlock()
|
||||
|
||||
// 确保当前有拓扑
|
||||
if app.topologyStorage != nil && app.topologyStorage.GetCurrentTopologyID() == "" {
|
||||
topos, err := app.topologyStorage.GetAllTopologies()
|
||||
if err == nil && len(topos) > 0 {
|
||||
app.topologyStorage.SetCurrentTopology(topos[0].ID)
|
||||
deviceFile := app.topologyStorage.GetDeviceFilePath(topos[0].ID)
|
||||
app.storage.SetFilePath(deviceFile)
|
||||
}
|
||||
}
|
||||
|
||||
// 异步执行扫描
|
||||
go app.runScan(task, req.ScanRange, req.SSHPort, req.Username, req.Password)
|
||||
|
||||
@@ -173,19 +215,41 @@ func (app *App) runScan(task *models.ScanTask, cidr string, sshPort int, usernam
|
||||
// 创建扫描器
|
||||
sc := scanner.NewScanner(app.config.Scanner.Concurrency, time.Duration(app.config.Scanner.Timeout)*time.Second)
|
||||
|
||||
// 扫描SSH主机
|
||||
sshHosts, err := sc.ScanAndDiscover(cidr, sshPort)
|
||||
// 阶段1: 解析IP范围 (进度 0% -> 10%)
|
||||
ips, err := sc.ScanRange(cidr)
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.ErrorMessage = err.Error()
|
||||
return
|
||||
}
|
||||
task.TotalDevices = len(ips)
|
||||
task.Progress = 10
|
||||
log.Printf("[扫描] 阶段1: 解析CIDR %s, 共 %d 个IP", cidr, len(ips))
|
||||
|
||||
// 阶段2: 检查存活主机 (进度 10% -> 30%)
|
||||
aliveHosts := sc.CheckHosts(ips)
|
||||
task.Progress = 30
|
||||
log.Printf("[扫描] 阶段2: 发现 %d 个存活主机", len(aliveHosts))
|
||||
|
||||
// 阶段3: 检查SSH端口 (进度 30% -> 50%)
|
||||
sshHosts := sc.CheckSSHHosts(aliveHosts, sshPort)
|
||||
task.Progress = 50
|
||||
task.TotalDevices = len(sshHosts)
|
||||
log.Printf("[扫描] 阶段3: 发现 %d 个SSH主机", len(sshHosts))
|
||||
|
||||
// 采集设备信息
|
||||
// 如果没有SSH主机,直接完成
|
||||
if len(sshHosts) == 0 {
|
||||
task.Status = "completed"
|
||||
task.Progress = 100
|
||||
log.Printf("[扫描] 未发现SSH主机,扫描完成")
|
||||
return
|
||||
}
|
||||
|
||||
// 阶段4: 采集设备信息 (进度 50% -> 100%)
|
||||
var devices []models.Device
|
||||
for i, ip := range sshHosts {
|
||||
log.Printf("[扫描] 阶段4: 正在采集设备 %s (%d/%d)", ip, i+1, len(sshHosts))
|
||||
|
||||
// 尝试不同设备类型
|
||||
deviceTypes := []models.DeviceType{
|
||||
models.DeviceTypeCisco,
|
||||
@@ -208,7 +272,7 @@ func (app *App) runScan(task *models.ScanTask, cidr string, sshPort int, usernam
|
||||
if discoveredDevice != nil {
|
||||
devices = append(devices, *discoveredDevice)
|
||||
app.builder.AddDevice(*discoveredDevice)
|
||||
|
||||
|
||||
// 保存到数据库
|
||||
if app.storage != nil {
|
||||
if err := app.storage.SaveDevice(discoveredDevice); err != nil {
|
||||
@@ -217,21 +281,22 @@ func (app *App) runScan(task *models.ScanTask, cidr string, sshPort int, usernam
|
||||
}
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
// 更新进度: 50% ~ 100%
|
||||
task.ScannedDevices = i + 1
|
||||
task.Progress = (i + 1) * 100 / len(sshHosts)
|
||||
task.Progress = 50 + (i+1)*50/len(sshHosts)
|
||||
task.Devices = devices
|
||||
}
|
||||
|
||||
task.Status = "completed"
|
||||
task.Progress = 100
|
||||
task.Devices = devices
|
||||
log.Printf("[扫描] 完成,共发现 %d 台设备", len(devices))
|
||||
}
|
||||
|
||||
// 处理扫描进度查询
|
||||
func (app *App) handleScanProgress(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
|
||||
app.mu.RLock()
|
||||
task, exists := app.tasks[id]
|
||||
app.mu.RUnlock()
|
||||
@@ -256,7 +321,7 @@ func (app *App) handleTopology(w http.ResponseWriter, r *http.Request) {
|
||||
// 处理获取所有设备
|
||||
func (app *App) handleGetDevices(w http.ResponseWriter, r *http.Request) {
|
||||
var devices []models.Device
|
||||
|
||||
|
||||
// 优先从存储获取
|
||||
if app.storage != nil {
|
||||
var err error
|
||||
@@ -297,7 +362,7 @@ func (app *App) handleAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
deviceType := models.DeviceType(req.Type)
|
||||
log.Printf("Adding device: %s (type: %s)", req.IP, req.Type)
|
||||
|
||||
|
||||
dev, err := device.DiscoverDevice(req.IP, deviceType, req.Username, req.Password)
|
||||
if err != nil {
|
||||
log.Printf("Failed to discover device %s: %v", req.IP, err)
|
||||
@@ -307,7 +372,7 @@ func (app *App) handleAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Device discovered: %s, interfaces: %d, neighbors: %d",
|
||||
log.Printf("Device discovered: %s, interfaces: %d, neighbors: %d",
|
||||
dev.IP, len(dev.Interfaces), len(dev.Neighbors))
|
||||
|
||||
app.builder.AddDevice(*dev)
|
||||
@@ -341,6 +406,328 @@ func (app *App) handleDeviceDetail(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
// 处理拓扑列表(GET: 获取所有拓扑,POST: 创建新拓扑)
|
||||
func (app *App) handleTopologies(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
// 获取所有拓扑
|
||||
if app.topologyStorage == nil {
|
||||
json.NewEncoder(w).Encode([]models.Topology{})
|
||||
return
|
||||
}
|
||||
|
||||
topos, err := app.topologyStorage.GetAllTopologies()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新每个拓扑的设备数量
|
||||
currentTopoID := app.topologyStorage.GetCurrentTopologyID()
|
||||
for i := range topos {
|
||||
deviceFile := app.topologyStorage.GetDeviceFilePath(topos[i].ID)
|
||||
store, err := storage.NewStorage(deviceFile)
|
||||
if err == nil {
|
||||
devices, err := store.GetAllDevices()
|
||||
if err == nil {
|
||||
topos[i].DeviceCount = len(devices)
|
||||
}
|
||||
}
|
||||
// 标记当前拓扑
|
||||
if topos[i].ID == currentTopoID {
|
||||
topos[i].Name = topos[i].Name + " (当前)"
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(topos)
|
||||
} else if r.Method == http.MethodPost {
|
||||
// 创建新拓扑
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ScanRange string `json:"scan_range"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
http.Error(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
topo, err := app.topologyStorage.CreateTopology(req.Name, req.Description, req.ScanRange, req.SSHPort, req.Username)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 自动切换到新拓扑
|
||||
app.topologyStorage.SetCurrentTopology(topo.ID)
|
||||
deviceFile := app.topologyStorage.GetDeviceFilePath(topo.ID)
|
||||
app.storage.SetFilePath(deviceFile)
|
||||
app.builder.Clear()
|
||||
|
||||
log.Printf("Created and switched to new topology: %s", topo.Name)
|
||||
json.NewEncoder(w).Encode(topo)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理切换拓扑
|
||||
func (app *App) handleSwitchTopology(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TopologyID string `json:"topology_id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if app.topologyStorage == nil {
|
||||
http.Error(w, "Topology storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证拓扑存在
|
||||
topo, err := app.topologyStorage.GetTopology(req.TopologyID)
|
||||
if err != nil {
|
||||
http.Error(w, "Topology not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 切换拓扑
|
||||
err = app.topologyStorage.SetCurrentTopology(req.TopologyID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 切换设备存储文件
|
||||
deviceFile := app.topologyStorage.GetDeviceFilePath(req.TopologyID)
|
||||
err = app.storage.SetFilePath(deviceFile)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 清空并重新加载拓扑构建器
|
||||
app.builder.Clear()
|
||||
devices, err := app.storage.GetAllDevices()
|
||||
if err == nil {
|
||||
for _, dev := range devices {
|
||||
app.builder.AddDevice(dev)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Switched to topology: %s (%s)", topo.Name, req.TopologyID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Topology switched successfully"})
|
||||
}
|
||||
|
||||
// 处理拓扑详情
|
||||
func (app *App) handleTopologyDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
if app.topologyStorage == nil {
|
||||
http.Error(w, "Topology storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
topo, err := app.topologyStorage.GetTopology(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Topology not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(topo)
|
||||
}
|
||||
|
||||
// 处理SSH终端连接
|
||||
func (app *App) handleTerminalConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
// WebSocket 连接使用 GET,但我们通过 POST 获取凭据后再升级
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 从查询参数获取设备IP
|
||||
ip := r.URL.Query().Get("ip")
|
||||
if ip == "" {
|
||||
http.Error(w, "ip parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
port := 22
|
||||
if p := r.URL.Query().Get("port"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &port)
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.URL.Query().Get("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
http.Error(w, "username and password are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[终端] 正在连接 %s:%d (%s)", ip, port, username)
|
||||
|
||||
// 使用 terminal handler 处理 WebSocket 连接
|
||||
terminal.HandleTerminal(w, r, ip, port, username, password)
|
||||
}
|
||||
|
||||
// 获取所有拓扑中的全部设备(全局设备池)
|
||||
func (app *App) handleGetAllDevices(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
deviceMap := make(map[string]models.Device) // 用IP去重
|
||||
|
||||
if app.topologyStorage != nil {
|
||||
topos, err := app.topologyStorage.GetAllTopologies()
|
||||
if err == nil {
|
||||
for _, topo := range topos {
|
||||
deviceFile := app.topologyStorage.GetDeviceFilePath(topo.ID)
|
||||
store, err := storage.NewStorage(deviceFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
devices, err := store.GetAllDevices()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, dev := range devices {
|
||||
deviceMap[dev.IP] = dev
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 也从旧版 devices.json 加载
|
||||
if app.storage != nil {
|
||||
devices, err := app.storage.GetAllDevices()
|
||||
if err == nil {
|
||||
for _, dev := range devices {
|
||||
deviceMap[dev.IP] = dev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices := make([]models.Device, 0, len(deviceMap))
|
||||
for _, dev := range deviceMap {
|
||||
devices = append(devices, dev)
|
||||
}
|
||||
|
||||
log.Printf("[设备池] 返回 %d 个全局设备", len(devices))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(devices)
|
||||
}
|
||||
|
||||
// 向指定拓扑批量添加设备
|
||||
func (app *App) handleAddDevicesToTopology(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
topoID := r.PathValue("id")
|
||||
|
||||
var req struct {
|
||||
DeviceIDs []string `json:"device_ids"` // IP列表或ID列表
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.DeviceIDs) == 0 {
|
||||
http.Error(w, "device_ids is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取目标拓扑的存储
|
||||
if app.topologyStorage == nil {
|
||||
http.Error(w, "Topology storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
topo, err := app.topologyStorage.GetTopology(topoID)
|
||||
if err != nil {
|
||||
http.Error(w, "Topology not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
deviceFile := app.topologyStorage.GetDeviceFilePath(topoID)
|
||||
targetStore, err := storage.NewStorage(deviceFile)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to open target storage", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 从全局设备池中查找并添加
|
||||
allDeviceMap := make(map[string]models.Device)
|
||||
topos, _ := app.topologyStorage.GetAllTopologies()
|
||||
for _, t := range topos {
|
||||
df := app.topologyStorage.GetDeviceFilePath(t.ID)
|
||||
s, err := storage.NewStorage(df)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
devs, err := s.GetAllDevices()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, d := range devs {
|
||||
allDeviceMap[d.IP] = d
|
||||
allDeviceMap[d.ID] = d
|
||||
}
|
||||
}
|
||||
|
||||
added := 0
|
||||
for _, idOrIP := range req.DeviceIDs {
|
||||
if dev, ok := allDeviceMap[idOrIP]; ok {
|
||||
devCopy := dev // 复制一份
|
||||
targetStore.SaveDevice(&devCopy)
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是当前拓扑,刷新builder
|
||||
if app.topologyStorage.GetCurrentTopologyID() == topoID {
|
||||
app.builder.Clear()
|
||||
devices, _ := targetStore.GetAllDevices()
|
||||
for _, dev := range devices {
|
||||
app.builder.AddDevice(dev)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[拓扑] 向 %s 添加了 %d/%d 设备", topo.Name, added, len(req.DeviceIDs))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"added": added,
|
||||
"total": len(req.DeviceIDs),
|
||||
"topology": topo.Name,
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
configFile := "config.json"
|
||||
@@ -362,7 +749,7 @@ func main() {
|
||||
|
||||
// 创建并启动应用
|
||||
app := NewApp(cfg)
|
||||
|
||||
|
||||
log.Println("网络拓扑发现系统启动...")
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatalf("服务启动失败: %v", err)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
echo Starting network topology discovery with enhanced debugging...
|
||||
echo This will show detailed ARP and LLDP parsing information.
|
||||
echo.
|
||||
network-topology.exe
|
||||
pause
|
||||
+4
-1
@@ -4,4 +4,7 @@ go 1.26.2
|
||||
|
||||
require golang.org/x/crypto v0.50.0
|
||||
|
||||
require golang.org/x/sys v0.43.0 // indirect
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"network-topology-discovery/pkg/models"
|
||||
"os"
|
||||
"sync"
|
||||
"network-topology-discovery/pkg/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,8 +17,13 @@ type Storage struct {
|
||||
devices map[string]models.Device
|
||||
}
|
||||
|
||||
// NewStorage 创建存储实例
|
||||
// NewStorage 创建存储实例(兼容旧版,使用默认文件)
|
||||
func NewStorage(filePath string) (*Storage, error) {
|
||||
return NewStorageForTopology(filePath)
|
||||
}
|
||||
|
||||
// NewStorageForTopology 为特定拓扑创建存储实例
|
||||
func NewStorageForTopology(filePath string) (*Storage, error) {
|
||||
s := &Storage{
|
||||
filePath: filePath,
|
||||
devices: make(map[string]models.Device),
|
||||
@@ -36,6 +41,24 @@ func NewStorage(filePath string) (*Storage, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetFilePath 切换存储文件路径(用于切换拓扑)
|
||||
func (s *Storage) SetFilePath(filePath string) error {
|
||||
s.mu.Lock()
|
||||
s.filePath = filePath
|
||||
s.devices = make(map[string]models.Device)
|
||||
s.mu.Unlock()
|
||||
|
||||
// 重新加载数据
|
||||
if err := s.load(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to load storage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Storage switched to: %s", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// load 从文件加载数据
|
||||
func (s *Storage) load() error {
|
||||
data, err := os.ReadFile(s.filePath)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"network-topology-discovery/pkg/models"
|
||||
)
|
||||
|
||||
// TopologyStorage 拓扑存储管理
|
||||
type TopologyStorage struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
topologies map[string]*models.Topology
|
||||
currentTopoID string
|
||||
}
|
||||
|
||||
// NewTopologyStorage 创建拓扑存储
|
||||
func NewTopologyStorage(dataDir string) (*TopologyStorage, error) {
|
||||
s := &TopologyStorage{
|
||||
dataDir: dataDir,
|
||||
topologies: make(map[string]*models.Topology),
|
||||
}
|
||||
|
||||
// 确保数据目录存在
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
// 加载拓扑元数据
|
||||
if err := s.loadMeta(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to load topology meta: %w", err)
|
||||
}
|
||||
log.Printf("Creating new topology storage at %s", dataDir)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// loadMeta 加载拓扑元数据
|
||||
func (s *TopologyStorage) loadMeta() error {
|
||||
metaFile := filepath.Join(s.dataDir, "topologies.json")
|
||||
data, err := os.ReadFile(metaFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var topos []models.Topology
|
||||
if err := json.Unmarshal(data, &topos); err != nil {
|
||||
return fmt.Errorf("failed to parse topology meta: %w", err)
|
||||
}
|
||||
|
||||
for i := range topos {
|
||||
s.topologies[topos[i].ID] = &topos[i]
|
||||
}
|
||||
|
||||
log.Printf("Loaded %d topologies from meta", len(topos))
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveMeta 保存拓扑元数据
|
||||
func (s *TopologyStorage) saveMeta() error {
|
||||
topos := make([]models.Topology, 0, len(s.topologies))
|
||||
for _, topo := range s.topologies {
|
||||
topos = append(topos, *topo)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(topos, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal topologies: %w", err)
|
||||
}
|
||||
|
||||
metaFile := filepath.Join(s.dataDir, "topologies.json")
|
||||
if err := os.WriteFile(metaFile, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write topology meta: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTopology 创建新拓扑
|
||||
func (s *TopologyStorage) CreateTopology(name, description, scanRange string, sshPort int, username string) (*models.Topology, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
topo := &models.Topology{
|
||||
ID: generateID(),
|
||||
Name: name,
|
||||
Description: description,
|
||||
ScanRange: scanRange,
|
||||
SSHPort: sshPort,
|
||||
Username: username,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
DeviceCount: 0,
|
||||
}
|
||||
|
||||
s.topologies[topo.ID] = topo
|
||||
|
||||
// 创建该拓扑的设备文件
|
||||
deviceFile := filepath.Join(s.dataDir, topo.ID+"_devices.json")
|
||||
if _, err := os.Stat(deviceFile); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(deviceFile, []byte("[]"), 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to create device file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存元数据
|
||||
if err := s.saveMeta(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("Topology created: %s (%s)", topo.Name, topo.ID)
|
||||
return topo, nil
|
||||
}
|
||||
|
||||
// GetTopology 获取拓扑
|
||||
func (s *TopologyStorage) GetTopology(id string) (*models.Topology, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
topo, exists := s.topologies[id]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("topology not found: %s", id)
|
||||
}
|
||||
|
||||
return topo, nil
|
||||
}
|
||||
|
||||
// GetAllTopologies 获取所有拓扑
|
||||
func (s *TopologyStorage) GetAllTopologies() ([]models.Topology, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
topos := make([]models.Topology, 0, len(s.topologies))
|
||||
for _, topo := range s.topologies {
|
||||
topos = append(topos, *topo)
|
||||
}
|
||||
|
||||
return topos, nil
|
||||
}
|
||||
|
||||
// UpdateTopology 更新拓扑
|
||||
func (s *TopologyStorage) UpdateTopology(id string, updates map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
topo, exists := s.topologies[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("topology not found: %s", id)
|
||||
}
|
||||
|
||||
if name, ok := updates["name"].(string); ok {
|
||||
topo.Name = name
|
||||
}
|
||||
if desc, ok := updates["description"].(string); ok {
|
||||
topo.Description = desc
|
||||
}
|
||||
if scanRange, ok := updates["scan_range"].(string); ok {
|
||||
topo.ScanRange = scanRange
|
||||
}
|
||||
if sshPort, ok := updates["ssh_port"].(float64); ok {
|
||||
topo.SSHPort = int(sshPort)
|
||||
}
|
||||
if username, ok := updates["username"].(string); ok {
|
||||
topo.Username = username
|
||||
}
|
||||
|
||||
topo.UpdatedAt = time.Now()
|
||||
|
||||
return s.saveMeta()
|
||||
}
|
||||
|
||||
// DeleteTopology 删除拓扑及其所有数据
|
||||
func (s *TopologyStorage) DeleteTopology(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.topologies[id]; !exists {
|
||||
return fmt.Errorf("topology not found: %s", id)
|
||||
}
|
||||
|
||||
// 删除设备文件
|
||||
deviceFile := filepath.Join(s.dataDir, id+"_devices.json")
|
||||
if err := os.Remove(deviceFile); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("Warning: failed to delete device file for topology %s: %v", id, err)
|
||||
}
|
||||
|
||||
delete(s.topologies, id)
|
||||
|
||||
// 更新当前拓扑
|
||||
if s.currentTopoID == id {
|
||||
s.currentTopoID = ""
|
||||
}
|
||||
|
||||
return s.saveMeta()
|
||||
}
|
||||
|
||||
// SetCurrentTopology 设置当前拓扑
|
||||
func (s *TopologyStorage) SetCurrentTopology(id string) error {
|
||||
if _, err := s.GetTopology(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.currentTopoID = id
|
||||
log.Printf("Current topology set to: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentTopologyID 获取当前拓扑ID
|
||||
func (s *TopologyStorage) GetCurrentTopologyID() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.currentTopoID
|
||||
}
|
||||
|
||||
// GetDeviceFilePath 获取拓扑的设备文件路径
|
||||
func (s *TopologyStorage) GetDeviceFilePath(topoID string) string {
|
||||
return filepath.Join(s.dataDir, topoID+"_devices.json")
|
||||
}
|
||||
|
||||
// generateID 生成唯一ID
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有来源
|
||||
},
|
||||
}
|
||||
|
||||
// TerminalSession 终端会话
|
||||
type TerminalSession struct {
|
||||
sshClient *ssh.Client
|
||||
sshSession *ssh.Session
|
||||
stdin io.Writer
|
||||
stdout io.Reader
|
||||
wsConn *websocket.Conn
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ConnectSSH 建立SSH连接并创建交互式Shell
|
||||
func ConnectSSH(host string, port int, username, password string) (*TerminalSession, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(password)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
Config: ssh.Config{
|
||||
Ciphers: []string{
|
||||
"aes128-ctr", "aes192-ctr", "aes256-ctr",
|
||||
"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes128-cbc", "aes256-cbc",
|
||||
},
|
||||
KeyExchanges: []string{
|
||||
"curve25519-sha256", "curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group14-sha256", "diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group14-sha1", "diffie-hellman-group1-sha1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH连接失败: %w", err)
|
||||
}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("创建SSH会话失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取 stdin 管道
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("获取stdin失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取 stdout 管道
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("获取stdout失败: %w", err)
|
||||
}
|
||||
|
||||
// 也获取 stderr
|
||||
session.Stderr = io.Discard
|
||||
|
||||
// 请求 PTY(xterm 终端)
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
if err := session.RequestPty("xterm", 40, 120, modes); err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("请求PTY失败: %w", err)
|
||||
}
|
||||
|
||||
// 启动 Shell
|
||||
if err := session.Shell(); err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("启动Shell失败: %w", err)
|
||||
}
|
||||
|
||||
return &TerminalSession{
|
||||
sshClient: client,
|
||||
sshSession: session,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleTerminal 处理WebSocket终端连接
|
||||
func HandleTerminal(w http.ResponseWriter, r *http.Request, host string, port int, username, password string) {
|
||||
// 建立 SSH 连接
|
||||
session, err := ConnectSSH(host, port, username, password)
|
||||
if err != nil {
|
||||
log.Printf("[终端] SSH连接失败 %s: %v", host, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 升级为 WebSocket
|
||||
wsConn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
session.Close()
|
||||
log.Printf("[终端] WebSocket升级失败: %v", err)
|
||||
return
|
||||
}
|
||||
session.wsConn = wsConn
|
||||
|
||||
log.Printf("[终端] 已连接到 %s (%s)", host, username)
|
||||
|
||||
// 启动 SSH -> WebSocket 的数据转发
|
||||
go session.sshToWs()
|
||||
// 启动 WebSocket -> SSH 的数据转发
|
||||
go session.wsToSsh()
|
||||
// 等待结束
|
||||
<-session.done
|
||||
|
||||
session.Close()
|
||||
log.Printf("[终端] 已断开 %s", host)
|
||||
}
|
||||
|
||||
// sshToWs 从SSH读取输出并转发到WebSocket
|
||||
func (s *TerminalSession) sshToWs() {
|
||||
buf := make([]byte, 8192)
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, err := s.stdout.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("[终端] SSH读取结束: %v", err)
|
||||
s.closeDone()
|
||||
return
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
s.mu.Lock()
|
||||
err := s.wsConn.WriteMessage(websocket.TextMessage, buf[:n])
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("[终端] WebSocket写入失败: %v", err)
|
||||
s.closeDone()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wsToSsh 从WebSocket读取输入并转发到SSH
|
||||
func (s *TerminalSession) wsToSsh() {
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
_, message, err := s.wsConn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[终端] WebSocket读取失败: %v", err)
|
||||
s.closeDone()
|
||||
return
|
||||
}
|
||||
|
||||
if len(message) > 0 {
|
||||
// 解析JSON消息格式(xterm.js发送的)
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal(message, &msg); err == nil {
|
||||
if input, ok := msg["input"].(string); ok {
|
||||
_, err := s.stdin.Write([]byte(input))
|
||||
if err != nil {
|
||||
log.Printf("[终端] SSH写入失败: %v", err)
|
||||
s.closeDone()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 处理resize消息
|
||||
if msg["type"] == "resize" {
|
||||
if cols, ok := msg["cols"].(float64); ok {
|
||||
if rows, ok := msg["rows"].(float64); ok {
|
||||
_ = s.sshSession.WindowChange(int(rows), int(cols))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 原始二进制数据,直接写入
|
||||
_, err := s.stdin.Write(message)
|
||||
if err != nil {
|
||||
s.closeDone()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// closeDone 安全关闭
|
||||
func (s *TerminalSession) closeDone() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
select {
|
||||
case <-s.done:
|
||||
// 已经关闭
|
||||
default:
|
||||
close(s.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭会话
|
||||
func (s *TerminalSession) Close() {
|
||||
s.closeDone()
|
||||
if s.wsConn != nil {
|
||||
s.wsConn.Close()
|
||||
}
|
||||
if s.sshSession != nil {
|
||||
s.sshSession.Close()
|
||||
}
|
||||
if s.sshClient != nil {
|
||||
s.sshClient.Close()
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,14 @@ func NewBuilder() *Builder {
|
||||
return &Builder{}
|
||||
}
|
||||
|
||||
// AddDevice 添加设备
|
||||
// AddDevice 添加设备(按IP去重)
|
||||
func (b *Builder) AddDevice(device models.Device) {
|
||||
for i, existing := range b.devices {
|
||||
if existing.IP == device.IP {
|
||||
b.devices[i] = device // 覆盖更新
|
||||
return
|
||||
}
|
||||
}
|
||||
b.devices = append(b.devices, device)
|
||||
}
|
||||
|
||||
@@ -141,18 +147,25 @@ func (b *Builder) Build() models.TopologyGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3b: 通过MAC前缀匹配(新增)
|
||||
// 当精确MAC匹配失败时,尝试通过MAC前缀匹配(适用于同一设备的多个接口)
|
||||
// 策略3b: 通过MAC前缀匹配(改进:排除歧义前缀)
|
||||
// 当精确MAC匹配失败时,尝试通过MAC前缀匹配
|
||||
// 但如果同一前缀匹配到多台设备,则跳过(避免错误连接)
|
||||
if targetIP == "" && neighbor.RemoteMAC != "" {
|
||||
neighborMACPrefix := getMACPrefix(neighbor.RemoteMAC)
|
||||
fmt.Printf(" Trying MAC prefix match: %s (prefix: %s)\n", neighbor.RemoteMAC, neighborMACPrefix)
|
||||
|
||||
// 先统计有多少台设备匹配此MAC前缀
|
||||
type prefixMatch struct {
|
||||
ip string
|
||||
matchingMACs int
|
||||
}
|
||||
var matches []prefixMatch
|
||||
|
||||
for _, d := range b.devices {
|
||||
if d.IP == device.IP {
|
||||
continue // 跳过自己
|
||||
}
|
||||
|
||||
// 检查该设备的MAC地址是否有相同前缀
|
||||
matchingMACs := 0
|
||||
for _, mac := range d.MACAddresses {
|
||||
if getMACPrefix(mac) == neighborMACPrefix {
|
||||
@@ -160,18 +173,20 @@ func (b *Builder) Build() models.TopologyGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果该设备有多个MAC地址使用相同前缀,说明是同一台设备
|
||||
if matchingMACs >= 3 { // 至少3个MAC使用相同前缀
|
||||
// 进一步验证:检查是否在同一网段
|
||||
if getSubnet(d.IP) == getSubnet(device.IP) {
|
||||
targetIP = d.IP
|
||||
matchMethod = fmt.Sprintf("MAC-prefix(%s)", neighborMACPrefix)
|
||||
fmt.Printf(" ✓ Matched by MAC prefix: %s (device has %d MACs with prefix %s, same subnet) -> %s\n",
|
||||
neighbor.RemoteMAC, matchingMACs, neighborMACPrefix, d.IP)
|
||||
break
|
||||
}
|
||||
if matchingMACs >= 3 {
|
||||
matches = append(matches, prefixMatch{ip: d.IP, matchingMACs: matchingMACs})
|
||||
}
|
||||
}
|
||||
|
||||
// 只在唯一匹配时使用前缀匹配
|
||||
if len(matches) == 1 && getSubnet(matches[0].ip) == getSubnet(device.IP) {
|
||||
targetIP = matches[0].ip
|
||||
matchMethod = fmt.Sprintf("MAC-prefix(%s)", neighborMACPrefix)
|
||||
fmt.Printf(" ✓ Matched by MAC prefix: %s (device has %d MACs with prefix %s, same subnet) -> %s\n",
|
||||
neighbor.RemoteMAC, matches[0].matchingMACs, neighborMACPrefix, targetIP)
|
||||
} else if len(matches) > 1 {
|
||||
fmt.Printf(" ✗ Skipping MAC prefix match: %d devices share prefix %s (ambiguous)\n", len(matches), neighborMACPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// 策略4: 通过本地接口IP网段匹配(新增)
|
||||
|
||||
+22
-9
@@ -59,15 +59,15 @@ type Neighbor struct {
|
||||
|
||||
// ScanTask 扫描任务
|
||||
type ScanTask struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"` // running, completed, failed
|
||||
Progress int `json:"progress"`
|
||||
TotalDevices int `json:"total_devices"`
|
||||
ScannedDevices int `json:"scanned_devices"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Devices []Device `json:"devices"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"` // running, completed, failed
|
||||
Progress int `json:"progress"`
|
||||
TotalDevices int `json:"total_devices"`
|
||||
ScannedDevices int `json:"scanned_devices"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Devices []Device `json:"devices"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// TopologyGraph 拓扑图数据
|
||||
@@ -94,3 +94,16 @@ type TopologyEdge struct {
|
||||
TargetInterface string `json:"target_interface"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
// Topology 网络拓扑(一个拓扑包含多个设备)
|
||||
type Topology struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ScanRange string `json:"scan_range"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
echo Building network topology discovery system...
|
||||
go build -o network-topology.exe ./cmd
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo Build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Build successful!
|
||||
echo.
|
||||
echo Starting the application...
|
||||
network-topology.exe
|
||||
+119
-1
@@ -33,6 +33,24 @@ header h1 {
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topology-selector {
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
min-width: 200px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.topology-selector:hover {
|
||||
background: white;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -171,7 +189,7 @@ header h1 {
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
width: 350px;
|
||||
width: 600px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
@@ -269,3 +287,103 @@ header h1 {
|
||||
color: #FF9800;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* SSH终端样式 */
|
||||
.terminal-modal-content {
|
||||
width: 800px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.terminal-status.connected {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
/* 设备选择器样式 */
|
||||
.select-devices-modal-content {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.select-devices-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.device-pool-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-pool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-left: 3px solid #ddd;
|
||||
}
|
||||
|
||||
.device-pool-item:hover {
|
||||
background: #f0f0f0;
|
||||
border-left-color: #667eea;
|
||||
}
|
||||
|
||||
.device-pool-item.selected {
|
||||
background: #e8f5e9;
|
||||
border-left-color: #4CAF50;
|
||||
}
|
||||
|
||||
.device-pool-item input[type="checkbox"] {
|
||||
margin-right: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-pool-item .device-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-pool-item .device-ip {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.device-pool-item .device-type {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,20 @@
|
||||
<title>网络拓扑发现系统</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="https://unpkg.com/cytoscape@3.26.0/dist/cytoscape.min.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css">
|
||||
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="https://unpkg.com/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🌐 网络拓扑发现系统</h1>
|
||||
<div class="controls">
|
||||
<select id="topology-selector" class="topology-selector">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
<button id="btn-new-topology" class="btn btn-info">新建拓扑</button>
|
||||
<button id="btn-scan" class="btn btn-primary">开始扫描</button>
|
||||
<button id="btn-add-device" class="btn btn-success">添加设备</button>
|
||||
<button id="btn-export" class="btn btn-info">导出拓扑</button>
|
||||
@@ -73,6 +81,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH终端模态框 -->
|
||||
<div id="modal-terminal" class="modal">
|
||||
<div class="modal-content terminal-modal-content">
|
||||
<span class="close-terminal">×</span>
|
||||
<h2>SSH 终端 - <span id="terminal-device-name"></span></h2>
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<div id="terminal-status" class="terminal-status">已断开</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH凭据输入弹窗 -->
|
||||
<div id="modal-ssh-creds" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-ssh-creds">×</span>
|
||||
<h2>SSH 连接凭据</h2>
|
||||
<form id="ssh-creds-form">
|
||||
<div class="form-group">
|
||||
<label>设备IP:</label>
|
||||
<input type="text" id="ssh-target-ip" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ssh-target-port">SSH端口:</label>
|
||||
<input type="number" id="ssh-target-port" value="22">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ssh-target-username">用户名:</label>
|
||||
<input type="text" id="ssh-target-username" required placeholder="admin">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ssh-target-password">密码:</label>
|
||||
<input type="password" id="ssh-target-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">连接</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加设备模态框 -->
|
||||
<div id="modal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -107,6 +152,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建拓扑模态框 -->
|
||||
<div id="modal-new-topology" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-new-topology">×</span>
|
||||
<h2>新建拓扑</h2>
|
||||
<form id="new-topology-form">
|
||||
<div class="form-group">
|
||||
<label for="topo-name">拓扑名称:</label>
|
||||
<input type="text" id="topo-name" required placeholder="例: 北京办公网络">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">创建</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选择设备模态框 -->
|
||||
<div id="modal-select-devices" class="modal">
|
||||
<div class="modal-content select-devices-modal-content">
|
||||
<span class="close-select-devices">×</span>
|
||||
<h2>选择设备添加到拓扑</h2>
|
||||
<div class="select-devices-toolbar">
|
||||
<button type="button" class="btn btn-info btn-sm" onclick="toggleSelectAllDevices(this)">全选</button>
|
||||
<span id="selected-count" class="selected-count">已选 0 台</span>
|
||||
</div>
|
||||
<div id="device-pool-list" class="device-pool-list"></div>
|
||||
<button type="button" class="btn btn-primary" style="width:100%;margin-top:15px" onclick="confirmAddDevices()">添加选中设备</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+399
-4
@@ -1,11 +1,14 @@
|
||||
// 全局变量
|
||||
let cy = null;
|
||||
let currentTaskId = null;
|
||||
let currentTopologyId = null;
|
||||
let currentTerminal = null; // 当前终端实例
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initCytoscape();
|
||||
initEventListeners();
|
||||
loadTopologyList(); // 加载拓扑列表
|
||||
loadTopology();
|
||||
loadDeviceList(); // 加载设备列表
|
||||
});
|
||||
@@ -40,8 +43,6 @@ function initCytoscape() {
|
||||
style: {
|
||||
'width': 2,
|
||||
'line-color': '#999',
|
||||
'target-arrow-color': '#999',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'label': 'data(protocol)'
|
||||
}
|
||||
@@ -84,6 +85,22 @@ function getNodeColor(type) {
|
||||
|
||||
// 初始化事件监听
|
||||
function initEventListeners() {
|
||||
// 拓扑选择器
|
||||
document.getElementById('topology-selector').addEventListener('change', switchTopology);
|
||||
|
||||
// 新建拓扑按钮
|
||||
document.getElementById('btn-new-topology').addEventListener('click', function() {
|
||||
document.getElementById('modal-new-topology').classList.add('active');
|
||||
});
|
||||
|
||||
// 关闭新建拓扑模态框
|
||||
document.querySelector('.close-new-topology').addEventListener('click', function() {
|
||||
document.getElementById('modal-new-topology').classList.remove('active');
|
||||
});
|
||||
|
||||
// 新建拓扑表单
|
||||
document.getElementById('new-topology-form').addEventListener('submit', createTopology);
|
||||
|
||||
// 扫描按钮
|
||||
document.getElementById('btn-scan').addEventListener('click', startScan);
|
||||
|
||||
@@ -102,6 +119,18 @@ function initEventListeners() {
|
||||
|
||||
// 导出按钮
|
||||
document.getElementById('btn-export').addEventListener('click', exportTopology);
|
||||
|
||||
// SSH终端相关
|
||||
document.querySelector('.close-terminal').addEventListener('click', closeTerminal);
|
||||
document.querySelector('.close-ssh-creds').addEventListener('click', function() {
|
||||
document.getElementById('modal-ssh-creds').classList.remove('active');
|
||||
});
|
||||
document.getElementById('ssh-creds-form').addEventListener('submit', connectTerminal);
|
||||
|
||||
// 设备选择器相关
|
||||
document.querySelector('.close-select-devices').addEventListener('click', function() {
|
||||
document.getElementById('modal-select-devices').classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// 开始扫描
|
||||
@@ -145,18 +174,27 @@ async function startScan() {
|
||||
async function pollProgress() {
|
||||
if (!currentTaskId) return;
|
||||
|
||||
let failCount = 0;
|
||||
const MAX_FAILS = 5;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/scan/${currentTaskId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const task = await response.json();
|
||||
failCount = 0; // 重置失败计数
|
||||
|
||||
// 更新进度
|
||||
document.getElementById('scan-status').textContent = task.status;
|
||||
document.getElementById('scan-progress').textContent = task.progress + '%';
|
||||
document.getElementById('progress-fill').style.width = task.progress + '%';
|
||||
|
||||
// 更新设备列表
|
||||
updateDeviceList(task.devices);
|
||||
// 更新设备列表(仅当有设备时)
|
||||
if (task.devices && task.devices.length > 0) {
|
||||
updateDeviceList(task.devices);
|
||||
}
|
||||
|
||||
// 如果完成,更新拓扑
|
||||
if (task.status === 'completed' || task.status === 'failed') {
|
||||
@@ -169,7 +207,16 @@ async function pollProgress() {
|
||||
// 继续轮询
|
||||
setTimeout(poll, 1000);
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
console.error('获取进度失败:', error);
|
||||
// 超过最大失败次数则停止轮询
|
||||
if (failCount >= MAX_FAILS) {
|
||||
document.getElementById('scan-status').textContent = 'error';
|
||||
currentTaskId = null;
|
||||
return;
|
||||
}
|
||||
// 等待更长时间后重试
|
||||
setTimeout(poll, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,6 +351,7 @@ async function showDeviceDetail(deviceId) {
|
||||
<p><strong>类型:</strong> ${device.type}</p>
|
||||
<p><strong>系统:</strong> ${device.os_version || 'N/A'}</p>
|
||||
<p><strong>运行时间:</strong> ${device.uptime || 'N/A'}</p>
|
||||
<button class="btn btn-primary" style="margin-top:10px;width:100%" onclick="openSSHTerminal('${device.ip}', '${device.hostname || device.ip}')">SSH 连接</button>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>接口信息 (${device.interfaces.length})</h4>
|
||||
@@ -390,3 +438,350 @@ function exportTopology() {
|
||||
link.download = 'topology.json';
|
||||
link.click();
|
||||
}
|
||||
|
||||
// 加载拓扑列表
|
||||
async function loadTopologyList() {
|
||||
try {
|
||||
const response = await fetch('/api/topologies');
|
||||
const topos = await response.json();
|
||||
|
||||
const selector = document.getElementById('topology-selector');
|
||||
selector.innerHTML = '';
|
||||
|
||||
if (topos.length === 0) {
|
||||
selector.innerHTML = '<option value="">暂无拓扑</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
topos.forEach(topo => {
|
||||
const option = document.createElement('option');
|
||||
option.value = topo.id;
|
||||
option.textContent = `${topo.name} (${topo.device_count || 0} 设备)`;
|
||||
if (topo.name.includes(' (当前)')) {
|
||||
option.selected = true;
|
||||
currentTopologyId = topo.id;
|
||||
}
|
||||
selector.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加载拓扑列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 切换拓扑
|
||||
async function switchTopology(event) {
|
||||
const topoId = event.target.value;
|
||||
if (!topoId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/topology/switch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topology_id: topoId
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
currentTopologyId = topoId;
|
||||
// 刷新拓扑和设备列表
|
||||
loadTopology();
|
||||
loadDeviceList();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('切换失败: ' + error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换拓扑失败:', error);
|
||||
alert('切换失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 新创建的拓扑ID(用于设备选择器)
|
||||
let newTopologyId = null;
|
||||
|
||||
// 创建拓扑
|
||||
async function createTopology(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('topo-name').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/topologies', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ name: name })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const topo = await response.json();
|
||||
newTopologyId = topo.id;
|
||||
|
||||
document.getElementById('modal-new-topology').classList.remove('active');
|
||||
document.getElementById('new-topology-form').reset();
|
||||
|
||||
// 刷新拓扑列表
|
||||
loadTopologyList();
|
||||
loadTopology();
|
||||
loadDeviceList();
|
||||
|
||||
// 弹出设备选择器
|
||||
openDeviceSelector();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert('创建失败: ' + error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建拓扑失败:', error);
|
||||
alert('创建失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 设备选择器 ====================
|
||||
|
||||
// 打开设备选择器
|
||||
async function openDeviceSelector() {
|
||||
document.getElementById('modal-select-devices').classList.add('active');
|
||||
document.getElementById('selected-count').textContent = '已选 0 台';
|
||||
|
||||
const poolList = document.getElementById('device-pool-list');
|
||||
poolList.innerHTML = '<p style="color:#999;text-align:center">加载中...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/devices/all');
|
||||
const devices = await response.json();
|
||||
|
||||
poolList.innerHTML = '';
|
||||
|
||||
if (devices.length === 0) {
|
||||
poolList.innerHTML = '<p style="color:#999;text-align:center">暂无设备,请先扫描添加设备</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach(device => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'device-pool-item';
|
||||
item.innerHTML = `
|
||||
<input type="checkbox" value="${device.ip}" onchange="updateSelectedCount()">
|
||||
<div class="device-info">
|
||||
<div class="device-ip">${device.ip}</div>
|
||||
<div class="device-type">${device.type} - ${device.hostname || 'Unknown'}</div>
|
||||
</div>
|
||||
`;
|
||||
// 点击整行切换选中
|
||||
item.addEventListener('click', function(e) {
|
||||
if (e.target.tagName !== 'INPUT') {
|
||||
const cb = item.querySelector('input[type=checkbox]');
|
||||
cb.checked = !cb.checked;
|
||||
updateSelectedCount();
|
||||
}
|
||||
item.classList.toggle('selected', item.querySelector('input[type=checkbox]').checked);
|
||||
});
|
||||
poolList.appendChild(item);
|
||||
});
|
||||
} catch (error) {
|
||||
poolList.innerHTML = '<p style="color:#f44336;text-align:center">加载失败</p>';
|
||||
console.error('加载设备池失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新选中计数
|
||||
function updateSelectedCount() {
|
||||
const checkboxes = document.querySelectorAll('#device-pool-list input[type=checkbox]:checked');
|
||||
document.getElementById('selected-count').textContent = `已选 ${checkboxes.length} 台`;
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
function toggleSelectAllDevices(btn) {
|
||||
const checkboxes = document.querySelectorAll('#device-pool-list input[type=checkbox]');
|
||||
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = !allChecked;
|
||||
cb.closest('.device-pool-item').classList.toggle('selected', !allChecked);
|
||||
});
|
||||
btn.textContent = allChecked ? '全选' : '取消全选';
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
// 确认添加设备到拓扑
|
||||
async function confirmAddDevices() {
|
||||
const targetTopoId = newTopologyId || currentTopologyId;
|
||||
if (!targetTopoId) {
|
||||
alert('请先选择目标拓扑');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkboxes = document.querySelectorAll('#device-pool-list input[type=checkbox]:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('请至少选择一台设备');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceIds = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/topology/${targetTopoId}/devices`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_ids: deviceIds })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
document.getElementById('modal-select-devices').classList.remove('active');
|
||||
newTopologyId = null;
|
||||
|
||||
// 刷新界面
|
||||
loadTopologyList();
|
||||
loadTopology();
|
||||
loadDeviceList();
|
||||
|
||||
alert(`成功添加 ${result.added} 台设备到 ${result.topology}`);
|
||||
} else {
|
||||
alert('添加失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
alert('添加失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SSH终端功能 ====================
|
||||
|
||||
// 打开SSH终端(先弹出凭据输入框)
|
||||
function openSSHTerminal(ip, hostname) {
|
||||
document.getElementById('ssh-target-ip').value = ip;
|
||||
document.getElementById('ssh-target-username').value = document.getElementById('username').value || 'admin';
|
||||
document.getElementById('ssh-target-password').value = document.getElementById('password').value || '';
|
||||
document.getElementById('ssh-target-port').value = document.getElementById('ssh-port').value || '22';
|
||||
document.getElementById('modal-ssh-creds').classList.add('active');
|
||||
}
|
||||
|
||||
// 连接SSH终端
|
||||
async function connectTerminal(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const ip = document.getElementById('ssh-target-ip').value;
|
||||
const port = document.getElementById('ssh-target-port').value || '22';
|
||||
const username = document.getElementById('ssh-target-username').value;
|
||||
const password = document.getElementById('ssh-target-password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
alert('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
// 关闭凭据弹窗,打开终端弹窗
|
||||
document.getElementById('modal-ssh-creds').classList.remove('active');
|
||||
document.getElementById('modal-terminal').classList.add('active');
|
||||
document.getElementById('terminal-device-name').textContent = ip;
|
||||
document.getElementById('terminal-status').textContent = '连接中...';
|
||||
document.getElementById('terminal-status').className = 'terminal-status';
|
||||
|
||||
// 初始化 xterm.js
|
||||
if (currentTerminal) {
|
||||
currentTerminal.dispose();
|
||||
}
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4'
|
||||
}
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const terminalContainer = document.getElementById('terminal-container');
|
||||
terminalContainer.innerHTML = '';
|
||||
term.open(terminalContainer);
|
||||
fitAddon.fit();
|
||||
|
||||
currentTerminal = term;
|
||||
|
||||
// 构建WebSocket URL
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/terminal?ip=${encodeURIComponent(ip)}&port=${encodeURIComponent(port)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
term.writeln('连接失败: ' + e.message);
|
||||
document.getElementById('terminal-status').textContent = '连接失败';
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = function() {
|
||||
document.getElementById('terminal-status').textContent = '已连接';
|
||||
document.getElementById('terminal-status').className = 'terminal-status connected';
|
||||
term.writeln('\x1b[32m--- SSH 连接已建立 ---\x1b[0m');
|
||||
term.focus();
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
term.write(event.data);
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
document.getElementById('terminal-status').textContent = '已断开';
|
||||
document.getElementById('terminal-status').className = 'terminal-status';
|
||||
term.writeln('\r\n\x1b[31m--- 连接已断开 ---\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onerror = function(err) {
|
||||
document.getElementById('terminal-status').textContent = '连接错误';
|
||||
document.getElementById('terminal-status').className = 'terminal-status';
|
||||
term.writeln('\r\n\x1b[31m--- 连接错误 ---\x1b[0m');
|
||||
};
|
||||
|
||||
// 终端输入转发到WebSocket
|
||||
term.onData(function(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ input: data }));
|
||||
}
|
||||
});
|
||||
|
||||
// 终端resize通知
|
||||
term.onResize(function(size) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// 窗口resize时自动调整
|
||||
const resizeObserver = new ResizeObserver(function() {
|
||||
fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(terminalContainer);
|
||||
|
||||
// 存储ws引用以便关闭
|
||||
term._ws = ws;
|
||||
term._resizeObserver = resizeObserver;
|
||||
}
|
||||
|
||||
// 关闭终端
|
||||
function closeTerminal() {
|
||||
if (currentTerminal) {
|
||||
if (currentTerminal._ws) {
|
||||
currentTerminal._ws.close();
|
||||
}
|
||||
if (currentTerminal._resizeObserver) {
|
||||
currentTerminal._resizeObserver.disconnect();
|
||||
}
|
||||
currentTerminal.dispose();
|
||||
currentTerminal = null;
|
||||
}
|
||||
document.getElementById('modal-terminal').classList.remove('active');
|
||||
document.getElementById('terminal-container').innerHTML = '';
|
||||
}
|
||||
|
||||
Referência em uma Nova Issue
Bloquear um usuário