From 44f7fef1f860251397f22b8abfd1b812178fa1fa Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Apr 2026 13:25:19 +0800 Subject: [PATCH] =?UTF-8?q?v1.0.1:=20=E5=A4=9A=E6=8B=93=E6=89=91=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81Web=20SSH=E7=BB=88=E7=AB=AF=E3=80=81=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E8=BF=9B=E5=BA=A6=E4=BF=AE=E5=A4=8D=E3=80=81=E6=8B=93?= =?UTF-8?q?=E6=89=91=E8=BF=9E=E7=BA=BF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复扫描进度条不动的问题(分4阶段更新进度) - 新增Web SSH远程终端(xterm.js + WebSocket) - 新增多拓扑管理(创建/切换拓扑、全局设备池) - 简化新建拓扑流程(仅需名称,创建后选择设备) - 修复拓扑Builder设备去重(按IP去重) - 修复启动时拓扑设备不加载到Builder的问题 - 优化MAC前缀匹配(避免歧义前缀导致错误连线) - 拓扑连线改为无向(去除箭头) - 设备详情面板加宽到600px --- .gitignore | 3 + FIX_NEIGHBOR_DISCOVERY.md | 69 +++++ SSH_OUTPUT_FIX.md | 140 +++++++++ V1.0.0_COMPARISON.md | 140 +++++++++ cmd/main.go | 433 +++++++++++++++++++++++++-- debug-neighbor.bat | 6 + go.mod | 5 +- go.sum | 2 + internal/storage/storage.go | 27 +- internal/storage/topology_storage.go | 237 +++++++++++++++ internal/terminal/handler.go | 249 +++++++++++++++ internal/topology/builder.go | 43 ++- pkg/models/models.go | 31 +- test-fix.bat | 12 + web/css/style.css | 120 +++++++- web/index.html | 74 +++++ web/js/app.js | 403 ++++++++++++++++++++++++- 17 files changed, 1940 insertions(+), 54 deletions(-) create mode 100644 FIX_NEIGHBOR_DISCOVERY.md create mode 100644 SSH_OUTPUT_FIX.md create mode 100644 V1.0.0_COMPARISON.md create mode 100644 debug-neighbor.bat create mode 100644 internal/storage/topology_storage.go create mode 100644 internal/terminal/handler.go create mode 100644 test-fix.bat diff --git a/.gitignore b/.gitignore index 3a9c6c3..24855b7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ config.json # 临时文件 *.tmp *.bak + +# 运行时数据 +data/ diff --git a/FIX_NEIGHBOR_DISCOVERY.md b/FIX_NEIGHBOR_DISCOVERY.md new file mode 100644 index 0000000..e1f4d1a --- /dev/null +++ b/FIX_NEIGHBOR_DISCOVERY.md @@ -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数据包 \ No newline at end of file diff --git a/SSH_OUTPUT_FIX.md b/SSH_OUTPUT_FIX.md new file mode 100644 index 0000000..0a503d8 --- /dev/null +++ b/SSH_OUTPUT_FIX.md @@ -0,0 +1,140 @@ +# SSH命令输出处理修复说明 + +## 🔍 **问题诊断** + +从调试输出中发现的关键问题: + +### 1. **SSH命令输出混乱** +``` +[H3C ARP DEBUG] First 500 chars of ARP Output: +****************************************************************************** + +screen-length disable +screen-length disable +echo ===CMD_BOUNDARY_0=== +^ +% Unrecognized command found at '^' position. +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. **兼容性检测**:自动检测设备类型,使用正确的命令集 \ No newline at end of file diff --git a/V1.0.0_COMPARISON.md b/V1.0.0_COMPARISON.md new file mode 100644 index 0000000..a059c3c --- /dev/null +++ b/V1.0.0_COMPARISON.md @@ -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处理问题** +- **保持简单有效的策略** \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index a6d08e9..6fca01f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) diff --git a/debug-neighbor.bat b/debug-neighbor.bat new file mode 100644 index 0000000..daaaaf8 --- /dev/null +++ b/debug-neighbor.bat @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod index 6ef6dbe..be485af 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index 3e36771..099963c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 3104efe..06b8fea 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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) diff --git a/internal/storage/topology_storage.go b/internal/storage/topology_storage.go new file mode 100644 index 0000000..1c007a7 --- /dev/null +++ b/internal/storage/topology_storage.go @@ -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:]) +} diff --git a/internal/terminal/handler.go b/internal/terminal/handler.go new file mode 100644 index 0000000..a10dc34 --- /dev/null +++ b/internal/terminal/handler.go @@ -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() + } +} diff --git a/internal/topology/builder.go b/internal/topology/builder.go index 29d4d02..9139f71 100644 --- a/internal/topology/builder.go +++ b/internal/topology/builder.go @@ -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网段匹配(新增) diff --git a/pkg/models/models.go b/pkg/models/models.go index fa77aa4..421e5fd 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -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"` +} diff --git a/test-fix.bat b/test-fix.bat new file mode 100644 index 0000000..31811de --- /dev/null +++ b/test-fix.bat @@ -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 \ No newline at end of file diff --git a/web/css/style.css b/web/css/style.css index fee4c23..bbd0b23 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -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; +} diff --git a/web/index.html b/web/index.html index 0b8160a..be44866 100644 --- a/web/index.html +++ b/web/index.html @@ -6,12 +6,20 @@ 网络拓扑发现系统 + + + +

🌐 网络拓扑发现系统

+ + @@ -73,6 +81,43 @@
+ + + + + + + + + + + + diff --git a/web/js/app.js b/web/js/app.js index dceb771..cfa960a 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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) {

类型: ${device.type}

系统: ${device.os_version || 'N/A'}

运行时间: ${device.uptime || 'N/A'}

+

接口信息 (${device.interfaces.length})

@@ -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 = ''; + 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 = '

加载中...

'; + + try { + const response = await fetch('/api/devices/all'); + const devices = await response.json(); + + poolList.innerHTML = ''; + + if (devices.length === 0) { + poolList.innerHTML = '

暂无设备,请先扫描添加设备

'; + return; + } + + devices.forEach(device => { + const item = document.createElement('div'); + item.className = 'device-pool-item'; + item.innerHTML = ` + +
+
${device.ip}
+
${device.type} - ${device.hostname || 'Unknown'}
+
+ `; + // 点击整行切换选中 + 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 = '

加载失败

'; + 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 = ''; +}