7 Commits

Author SHA1 Message Date
Your Name a7e51c5bea feat: 优化拓扑匹配策略,使用12字符MAC前缀提高匹配精度 2026-04-27 18:32:29 +08:00
Your Name 07adc3ac5c feat: 修复网络拓扑匹配逻辑,使用双向LLDP对称匹配策略 2026-04-27 00:02:09 +08:00
Your Name 606e29a53c 优化设备类型顺序:H3C优先 2026-04-26 22:21:50 +08:00
Your Name 2a97f458a9 prod 2026-04-26 22:00:03 +08:00
Your Name bcad5b8e27 CDN本地化: 所有前端JS/CSS资源改为本地vendor目录 2026-04-26 13:37:02 +08:00
Your Name 44f7fef1f8 v1.0.1: 多拓扑管理、Web SSH终端、扫描进度修复、拓扑连线优化
- 修复扫描进度条不动的问题(分4阶段更新进度)
- 新增Web SSH远程终端(xterm.js + WebSocket)
- 新增多拓扑管理(创建/切换拓扑、全局设备池)
- 简化新建拓扑流程(仅需名称,创建后选择设备)
- 修复拓扑Builder设备去重(按IP去重)
- 修复启动时拓扑设备不加载到Builder的问题
- 优化MAC前缀匹配(避免歧义前缀导致错误连线)
- 拓扑连线改为无向(去除箭头)
- 设备详情面板加宽到600px
2026-04-26 13:25:19 +08:00
Your Name 6e1b010c17 feat: 优化拓扑匹配策略,修复环路问题
- 禁用端口号匹配策略(策略6),避免产生错误连接和环路
- 保留精确匹配策略:MAC精确匹配、MAC前缀匹配、子网匹配
- 优化虚拟机邻居过滤(过滤9844、9400等虚拟机MAC前缀)
- 实现设备间去重逻辑(同一对设备只保留一条边)

最终拓扑结构:
- 5个节点,4条边
- 防火墙(172.16.8.1)  核心交换机(172.16.8.6)
- 核心交换机(172.16.8.6)  接入交换机(172.16.8.8/9/10)
- 星型拓扑,无环路
2026-04-26 06:16:15 +08:00
27 changed files with 3314 additions and 115 deletions
+3
View File
@@ -23,3 +23,6 @@ config.json
# 临时文件
*.tmp
*.bak
# 运行时数据
data/
+69
View File
@@ -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数据包
+140
View File
@@ -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. **兼容性检测**:自动检测设备类型,使用正确的命令集
+140
View File
@@ -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处理问题**
- **保持简单有效的策略**
+620 -32
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -15,36 +16,59 @@ 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
sessions map[string]time.Time // token -> expire time
sessionMu sync.RWMutex
}
// 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),
sessions: make(map[string]time.Time),
}
// 从数据库加载设备到拓扑构建器
// 如果有拓扑存储,切换到第一个拓扑
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 {
@@ -65,24 +89,56 @@ func (app *App) Start() error {
// 设置路由
mux := http.NewServeMux()
// 静态文件服务 - 使用文件系统而非embed
// 登录API(无需认证)
mux.HandleFunc("/api/login", app.handleLogin)
mux.HandleFunc("/api/logout", app.handleLogout)
// 登录页面(无需认证)
mux.HandleFunc("/login", app.handleLoginPage)
// 认证中间件保护的路由
authMux := http.NewServeMux()
// 静态文件服务
webDir := getWebDir()
if _, err := os.Stat(webDir); err == nil {
mux.Handle("/", http.FileServer(http.Dir(webDir)))
authMux.Handle("/", http.FileServer(http.Dir(webDir)))
} else {
log.Printf("警告: web目录不存在,静态文件服务不可用")
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
authMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<h1>网络拓扑发现系统</h1><p>Web界面文件未找到</p>"))
})
}
// API路由
mux.HandleFunc("/api/scan", app.handleScan)
mux.HandleFunc("/api/scan/{id}", app.handleScanProgress)
mux.HandleFunc("/api/topology", app.handleTopology)
mux.HandleFunc("/api/devices", app.handleGetDevices)
mux.HandleFunc("/api/device", app.handleAddDevice)
mux.HandleFunc("/api/device/{id}", app.handleDeviceDetail)
authMux.HandleFunc("/api/scan", app.handleScan)
authMux.HandleFunc("/api/scan/{id}", app.handleScanProgress)
authMux.HandleFunc("/api/topology", app.handleTopology)
authMux.HandleFunc("/api/devices", app.handleGetDevices)
authMux.HandleFunc("/api/device", app.handleAddDevice)
authMux.HandleFunc("/api/device/{id}", app.handleDeviceDetail)
// 拓扑管理API
authMux.HandleFunc("/api/topologies", app.handleTopologies)
authMux.HandleFunc("/api/topology/switch", app.handleSwitchTopology)
authMux.HandleFunc("/api/topology/{id}", app.handleTopologyDetail)
// SSH终端API
authMux.HandleFunc("/api/terminal", app.handleTerminalConnect)
// 全局设备池API
authMux.HandleFunc("/api/devices/all", app.handleGetAllDevices)
authMux.HandleFunc("/api/topology/{id}/devices", app.handleAddDevicesToTopology)
// 根据认证配置决定是否启用中间件
var handler http.Handler = authMux
if app.config.Auth.Enabled {
handler = app.authMiddleware(authMux)
log.Printf("认证已启用, 用户: %s", app.config.Auth.Username)
} else {
log.Printf("认证未启用")
}
mux.Handle("/", handler)
addr := fmt.Sprintf("%s:%d", app.config.Web.Host, app.config.Web.Port)
app.httpServer = &http.Server{
@@ -157,6 +213,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 +239,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 +296,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 +305,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 +345,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 +386,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 +396,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)
@@ -329,6 +418,64 @@ func (app *App) handleAddDevice(w http.ResponseWriter, r *http.Request) {
func (app *App) handleDeviceDetail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if r.Method == http.MethodDelete {
// 删除设备
if app.storage == nil {
http.Error(w, "Storage not available", http.StatusInternalServerError)
return
}
if err := app.storage.DeleteDevice(id); err != nil {
log.Printf("Failed to delete device %s: %v", id, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 同时从 builder 中移除
app.builder.RemoveDevice(id)
log.Printf("Deleted device: %s", id)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
return
}
if r.Method == http.MethodPut {
// 修改设备信息(Hostname、Type
var req struct {
Hostname string `json:"hostname"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if app.storage == nil {
http.Error(w, "Storage not available", http.StatusInternalServerError)
return
}
dev, err := app.storage.GetDevice(id)
if err != nil {
http.Error(w, "Device not found", http.StatusNotFound)
return
}
if req.Hostname != "" {
dev.Hostname = req.Hostname
}
if req.Type != "" {
dev.Type = models.DeviceType(req.Type)
}
if err := app.storage.SaveDevice(dev); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 同时更新 builder 中的设备
app.builder.AddDevice(*dev)
log.Printf("Updated device: %s", id)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(dev)
return
}
// GET: 获取设备详情
devices := app.builder.GetDevices()
for _, dev := range devices {
if dev.ID == id || dev.IP == id {
@@ -341,6 +488,447 @@ 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)
}
// ==================== 认证相关 ====================
// authMiddleware 认证中间件
func (app *App) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从 cookie 获取 token
cookie, err := r.Cookie("session_token")
if err != nil || !app.isValidSession(cookie.Value) {
// API 请求返回 401,页面请求重定向到登录
if strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, `{"error": "Unauthorized"}`, http.StatusUnauthorized)
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
next.ServeHTTP(w, r)
})
}
// isValidSession 验证会话是否有效
func (app *App) isValidSession(token string) bool {
if token == "" {
return false
}
app.sessionMu.RLock()
expire, exists := app.sessions[token]
app.sessionMu.RUnlock()
if !exists {
return false
}
if time.Now().After(expire) {
app.sessionMu.Lock()
delete(app.sessions, token)
app.sessionMu.Unlock()
return false
}
return true
}
// generateToken 生成随机 token
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// handleLogin 处理登录
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !app.config.Auth.Enabled {
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
return
}
if req.Username == app.config.Auth.Username && req.Password == app.config.Auth.Password {
token := generateToken()
expire := time.Now().Add(24 * time.Hour)
app.sessionMu.Lock()
app.sessions[token] = expire
app.sessionMu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: token,
Path: "/",
Expires: expire,
HttpOnly: true,
})
log.Printf("用户 %s 登录成功", req.Username)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
} else {
log.Printf("登录失败: 用户名或密码错误 (username=%s)", req.Username)
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "用户名或密码错误"})
}
}
// handleLogout 处理登出
func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("session_token"); err == nil {
app.sessionMu.Lock()
delete(app.sessions, cookie.Value)
app.sessionMu.Unlock()
}
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
http.Redirect(w, r, "/login", http.StatusFound)
}
// handleLoginPage 返回登录页面
func (app *App) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if !app.config.Auth.Enabled {
http.Redirect(w, r, "/", http.StatusFound)
return
}
http.ServeFile(w, r, filepath.Join(getWebDir(), "login.html"))
}
// 获取所有拓扑中的全部设备(全局设备池)
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 +950,7 @@ func main() {
// 创建并启动应用
app := NewApp(cfg)
log.Println("网络拓扑发现系统启动...")
if err := app.Start(); err != nil {
log.Fatalf("服务启动失败: %v", err)
+5
View File
@@ -30,5 +30,10 @@
"scanner": {
"concurrency": 10,
"timeout": 2
},
"auth": {
"enabled": true,
"username": "admin",
"password": "admin123"
}
}
+6
View File
@@ -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
View File
@@ -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
)
+2
View File
@@ -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=
+20 -6
View File
@@ -3,8 +3,8 @@ package config
import (
"encoding/json"
"fmt"
"os"
"network-topology-discovery/pkg/models"
"os"
)
// Config 应用配置
@@ -14,16 +14,17 @@ type Config struct {
SSH SSHConfig `json:"ssh"`
Web WebConfig `json:"web"`
Scanner ScannerConfig `json:"scanner"`
Auth AuthConfig `json:"auth"`
}
// DeviceConfig 设备配置
type DeviceConfig struct {
IP string `json:"ip"`
IP string `json:"ip"`
Type models.DeviceType `json:"type"`
Username string `json:"username"`
Password string `json:"password"`
KeyFile string `json:"key_file"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
KeyFile string `json:"key_file"`
Port int `json:"port"`
}
// SSHConfig SSH配置
@@ -39,6 +40,13 @@ type WebConfig struct {
Host string `json:"host"`
}
// AuthConfig 认证配置
type AuthConfig struct {
Enabled bool `json:"enabled"`
Username string `json:"username"`
Password string `json:"password"`
}
// ScannerConfig 扫描器配置
type ScannerConfig struct {
Concurrency int `json:"concurrency"`
@@ -122,4 +130,10 @@ func (c *Config) setDefaults() {
if c.Scanner.Timeout == 0 {
c.Scanner.Timeout = 2
}
if c.Auth.Enabled && c.Auth.Username == "" {
c.Auth.Username = "admin"
}
if c.Auth.Enabled && c.Auth.Password == "" {
c.Auth.Password = "admin"
}
}
+197 -15
View File
@@ -37,16 +37,27 @@ func (p *H3CParser) Parse(device *models.Device, outputs []string) error {
// outputs[2] 是 display interface
fmt.Printf("[H3C DEBUG] display interface output length: %d\n", len(outputs[2]))
if len(outputs[2]) > 0 {
// 如果输出小于1000字节,完整输出以便调试
if len(outputs[2]) <= 1000 {
fmt.Printf("[H3C DEBUG] Complete output:\n%s\n", outputs[2])
if len(outputs[2]) <= 3000 {
fmt.Printf("[H3C DEBUG] Complete interface output:\n%s\n", outputs[2])
} else {
fmt.Printf("[H3C DEBUG] First 200 chars: %q\n", outputs[2][:200])
fmt.Printf("[H3C DEBUG] First 3000 chars:\n%s\n", outputs[2][:3000])
}
} else {
fmt.Printf("[H3C DEBUG] display interface output is EMPTY!\n")
}
// outputs[3] 是 display interface brief
fmt.Printf("[H3C BRIEF DEBUG] display interface brief output length: %d\n", len(outputs[3]))
if len(outputs[3]) > 0 {
if len(outputs[3]) <= 3000 {
fmt.Printf("[H3C BRIEF DEBUG] Complete brief output:\n%s\n", outputs[3])
} else {
fmt.Printf("[H3C BRIEF DEBUG] First 3000 chars:\n%s\n", outputs[3][:3000])
}
} else {
fmt.Printf("[H3C BRIEF DEBUG] display interface brief output is EMPTY!\n")
}
if outputs[2] == "" {
fmt.Printf("Warning: 'display interface' output is empty for device %s\n", device.IP)
} else {
@@ -111,9 +122,9 @@ func (p *H3CParser) parseVersion(device *models.Device, output string) {
}
}
uptimeRegex := regexp.MustCompile(`uptime is\s+(\d+\s+\S+)`)
uptimeRegex := regexp.MustCompile(`uptime is\s+(.+)`)
if matches := uptimeRegex.FindStringSubmatch(output); len(matches) > 1 {
device.Uptime = matches[1]
device.Uptime = strings.TrimSpace(matches[1])
}
}
@@ -142,6 +153,15 @@ func (p *H3CParser) parseInterfaces(interfaceOutput, briefOutput string) []model
if brief, ok := briefMap[currentInterface.Name]; ok {
currentInterface.IP = brief.IP
if currentInterface.Speed == "" {
currentInterface.Speed = brief.Speed
}
if currentInterface.Duplex == "" {
currentInterface.Duplex = brief.Duplex
}
if currentInterface.VLAN == "" {
currentInterface.VLAN = brief.VLAN
}
}
pendingInterfaceName = ""
continue
@@ -162,6 +182,15 @@ func (p *H3CParser) parseInterfaces(interfaceOutput, briefOutput string) []model
if brief, ok := briefMap[currentInterface.Name]; ok {
currentInterface.IP = brief.IP
if currentInterface.Speed == "" {
currentInterface.Speed = brief.Speed
}
if currentInterface.Duplex == "" {
currentInterface.Duplex = brief.Duplex
}
if currentInterface.VLAN == "" {
currentInterface.VLAN = brief.VLAN
}
}
pendingInterfaceName = ""
continue
@@ -192,9 +221,48 @@ func (p *H3CParser) parseInterfaces(interfaceOutput, briefOutput string) []model
currentInterface.Mask = cidrToMask(matches[2])
}
if speedRegex := regexp.MustCompile(`(\d+)\s+(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
// 匹配速度格式1: Speed : 1000Mbps 或 Speed : 10Gbps
if speedRegex := regexp.MustCompile(`Speed\s*:\s*(\d+)\s*(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
matches := speedRegex.FindStringSubmatch(line)
currentInterface.Speed = matches[1] + " " + matches[2]
currentInterface.Speed = matches[1] + matches[2]
}
// 匹配速度格式2: Speed: 1000, Loopback: not set (纯数字Mbps)
if speedRegex2 := regexp.MustCompile(`Speed\s*:\s*(\d+)(?:,|$)`); speedRegex2.MatchString(line) && currentInterface.Speed == "" {
matches := speedRegex2.FindStringSubmatch(line)
currentInterface.Speed = matches[1] + "Mbps"
}
// 匹配双工: Duplex: Full 或 Duplex: Half
if duplexRegex := regexp.MustCompile(`Duplex\s*:\s*(\S+)`); duplexRegex.MatchString(line) && currentInterface.Duplex == "" {
currentInterface.Duplex = duplexRegex.FindStringSubmatch(line)[1]
}
// 匹配VLAN信息: Port link-type: Access / Trunk / Hybrid
if linkTypeRegex := regexp.MustCompile(`Port link-type\s*:\s*(\S+)`); linkTypeRegex.MatchString(line) {
linkType := linkTypeRegex.FindStringSubmatch(line)[1]
currentInterface.VLAN = linkType
}
// 匹配Untagged VLAN: Untagged VLAN ID: 10
if untaggedRegex := regexp.MustCompile(`Untagged VLAN ID\s*:\s*(\S+)`); untaggedRegex.MatchString(line) {
untagged := untaggedRegex.FindStringSubmatch(line)[1]
if untagged != "none" && untagged != "--" {
if currentInterface.VLAN != "" {
currentInterface.VLAN = currentInterface.VLAN + " " + untagged
} else {
currentInterface.VLAN = untagged
}
}
}
// 匹配Tagged VLAN: Tagged VLAN ID: 100-200
if taggedRegex := regexp.MustCompile(`Tagged VLAN ID\s*:\s*(\S+)`); taggedRegex.MatchString(line) {
tagged := taggedRegex.FindStringSubmatch(line)[1]
if tagged != "none" && tagged != "--" {
if currentInterface.VLAN != "" {
currentInterface.VLAN = currentInterface.VLAN + " (T:" + tagged + ")"
} else {
currentInterface.VLAN = "T:" + tagged
}
}
}
}
}
@@ -276,19 +344,133 @@ func normalizeMACFormat(mac string) string {
return mac
}
// expandH3CInterfaceName 将H3C brief中的缩写接口名展开为全名
func expandH3CInterfaceName(shortName string) string {
// GE1/0/10 -> GigabitEthernet1/0/10
if strings.HasPrefix(shortName, "GE") {
return "GigabitEthernet" + shortName[2:]
}
// TGE1/0/52 -> Ten-GigabitEthernet1/0/52
if strings.HasPrefix(shortName, "TGE") {
return "Ten-GigabitEthernet" + shortName[3:]
}
// FGE1/0/53 -> FortyGigE1/0/53
if strings.HasPrefix(shortName, "FGE") {
return "FortyGigE" + shortName[3:]
}
// HGE1/0/1 -> HundredGigE1/0/1
if strings.HasPrefix(shortName, "HGE") {
return "HundredGigE" + shortName[3:]
}
// XGE1/0/1 -> Ten-GigabitEthernet1/0/1 (有些型号用XGE)
if strings.HasPrefix(shortName, "XGE") {
return "Ten-GigabitEthernet" + shortName[3:]
}
// Vlan8 -> Vlan-interface8
if strings.HasPrefix(shortName, "Vlan") {
return "Vlan-interface" + shortName[4:]
}
return shortName
}
func (p *H3CParser) parseInterfaceBrief(output string) map[string]models.Interface {
interfaces := make(map[string]models.Interface)
lines := strings.Split(output, "\n")
// 状态机: 0=未开始, 1=route mode数据, 2=bridge mode数据
mode := 0
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 4 {
iface := models.Interface{
Name: fields[0],
IP: fields[1],
Status: strings.ToLower(fields[3]),
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "---") {
continue
}
// 检测 route mode section
if strings.Contains(trimmed, "route mode") {
mode = 1
continue
}
// 检测 bridge mode section
if strings.Contains(trimmed, "bridge mode") {
mode = 2
continue
}
// 跳过表头行
fields := strings.Fields(trimmed)
if len(fields) > 0 && (fields[0] == "Interface" || fields[0] == "interface") {
continue
}
// Route mode: Interface Link Protocol Primary IP Description
if mode == 1 && len(fields) >= 3 {
shortName := fields[0]
fullName := expandH3CInterfaceName(shortName)
ip := ""
if len(fields) >= 4 && fields[3] != "--" {
ip = fields[3]
}
interfaces[iface.Name] = iface
iface := models.Interface{
Name: fullName,
Status: strings.ToLower(fields[1]),
IP: ip,
}
interfaces[fullName] = iface
}
// Bridge mode: Interface Link Speed Duplex Type PVID Description
if mode == 2 && len(fields) >= 5 {
shortName := fields[0]
fullName := expandH3CInterfaceName(shortName)
speed := fields[2]
if speed == "auto" {
speed = "auto"
}
duplex := fields[3]
switch duplex {
case "A":
duplex = "auto"
case "F(a)", "F":
duplex = "full"
case "H":
duplex = "half"
}
// VLAN: Type(A/T/H) + PVID
vlan := ""
if len(fields) >= 5 {
linkType := fields[4]
switch linkType {
case "A":
vlan = "Access"
case "T":
vlan = "Trunk"
case "H":
vlan = "Hybrid"
}
}
if len(fields) >= 6 {
vlan = vlan + " " + fields[5]
}
iface := models.Interface{
Name: fullName,
Status: strings.ToLower(fields[1]),
Speed: speed,
Duplex: duplex,
VLAN: strings.TrimSpace(vlan),
}
// 如果 route mode 已有此接口(通常不会有), 保留 IP 信息
if existing, ok := interfaces[fullName]; ok {
if existing.IP != "" {
iface.IP = existing.IP
}
}
interfaces[fullName] = iface
}
}
+119 -10
View File
@@ -18,7 +18,7 @@ func (p *HuaweiParser) GetCommands() []string {
return []string{
"display version",
"display interface",
"display ip interface brief",
"display interface brief",
"display lldp neighbor",
}
}
@@ -84,6 +84,15 @@ func (p *HuaweiParser) parseInterfaces(interfaceOutput, briefOutput string) []mo
if brief, ok := briefMap[currentInterface.Name]; ok {
currentInterface.IP = brief.IP
if currentInterface.Speed == "" {
currentInterface.Speed = brief.Speed
}
if currentInterface.Duplex == "" {
currentInterface.Duplex = brief.Duplex
}
if currentInterface.VLAN == "" {
currentInterface.VLAN = brief.VLAN
}
}
}
@@ -105,11 +114,41 @@ func (p *HuaweiParser) parseInterfaces(interfaceOutput, briefOutput string) []mo
currentInterface.Mask = matches[2]
}
// 带宽
if speedRegex := regexp.MustCompile(`(\d+)\s+(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
// 带宽格式1: Speed : 1000Mbps
if speedRegex := regexp.MustCompile(`Speed\s*:\s*(\d+)\s*(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
matches := speedRegex.FindStringSubmatch(line)
currentInterface.Speed = matches[1] + matches[2]
}
// 带宽格式2: BW 1000000 Kbit
if speedRegex2 := regexp.MustCompile(`BW\s+(\d+)\s+(Kbit|Mbit|Gbit)`); speedRegex2.MatchString(line) && currentInterface.Speed == "" {
matches := speedRegex2.FindStringSubmatch(line)
currentInterface.Speed = matches[1] + " " + matches[2]
}
// VLAN信息: Port link-type
if linkTypeRegex := regexp.MustCompile(`Port link-type\s*:\s*(\S+)`); linkTypeRegex.MatchString(line) {
currentInterface.VLAN = linkTypeRegex.FindStringSubmatch(line)[1]
}
if untaggedRegex := regexp.MustCompile(`Untagged VLAN ID\s*:\s*(\S+)`); untaggedRegex.MatchString(line) {
untagged := untaggedRegex.FindStringSubmatch(line)[1]
if untagged != "none" && untagged != "--" {
if currentInterface.VLAN != "" {
currentInterface.VLAN = currentInterface.VLAN + " " + untagged
} else {
currentInterface.VLAN = untagged
}
}
}
if taggedRegex := regexp.MustCompile(`Tagged VLAN ID\s*:\s*(\S+)`); taggedRegex.MatchString(line) {
tagged := taggedRegex.FindStringSubmatch(line)[1]
if tagged != "none" && tagged != "--" {
if currentInterface.VLAN != "" {
currentInterface.VLAN = currentInterface.VLAN + " (T:" + tagged + ")"
} else {
currentInterface.VLAN = "T:" + tagged
}
}
}
}
}
@@ -124,15 +163,85 @@ func (p *HuaweiParser) parseInterfaceBrief(output string) map[string]models.Inte
interfaces := make(map[string]models.Interface)
lines := strings.Split(output, "\n")
mode := 0 // 0=未开始, 1=route mode, 2=bridge mode
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 4 {
iface := models.Interface{
Name: fields[0],
IP: fields[1],
Status: strings.ToLower(fields[3]),
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "---") {
continue
}
if strings.Contains(trimmed, "route mode") {
mode = 1
continue
}
if strings.Contains(trimmed, "bridge mode") {
mode = 2
continue
}
fields := strings.Fields(trimmed)
if len(fields) > 0 && (fields[0] == "Interface" || fields[0] == "interface") {
continue
}
// Route mode: Interface Link Protocol Primary IP Description
if mode == 1 && len(fields) >= 3 {
fullName := expandH3CInterfaceName(fields[0])
ip := ""
if len(fields) >= 4 && fields[3] != "--" {
ip = fields[3]
}
interfaces[iface.Name] = iface
iface := models.Interface{
Name: fullName,
Status: strings.ToLower(fields[1]),
IP: ip,
}
interfaces[fullName] = iface
}
// Bridge mode: Interface Link Speed Duplex Type PVID Description
if mode == 2 && len(fields) >= 5 {
fullName := expandH3CInterfaceName(fields[0])
speed := fields[2]
duplex := fields[3]
switch duplex {
case "A":
duplex = "auto"
case "F(a)", "F":
duplex = "full"
case "H":
duplex = "half"
}
vlan := ""
if len(fields) >= 5 {
switch fields[4] {
case "A":
vlan = "Access"
case "T":
vlan = "Trunk"
case "H":
vlan = "Hybrid"
}
}
if len(fields) >= 6 {
vlan = vlan + " " + fields[5]
}
iface := models.Interface{
Name: fullName,
Status: strings.ToLower(fields[1]),
Speed: speed,
Duplex: duplex,
VLAN: strings.TrimSpace(vlan),
}
if existing, ok := interfaces[fullName]; ok {
if existing.IP != "" {
iface.IP = existing.IP
}
}
interfaces[fullName] = iface
}
}
+25 -2
View File
@@ -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)
+237
View File
@@ -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:])
}
+249
View File
@@ -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
// 请求 PTYxterm 终端)
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()
}
}
+170 -31
View File
@@ -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)
}
@@ -26,6 +32,16 @@ func (b *Builder) AddDevices(devices []models.Device) {
b.devices = append(b.devices, devices...)
}
// RemoveDevice 根据ID或IP移除设备
func (b *Builder) RemoveDevice(id string) {
for i, d := range b.devices {
if d.ID == id || d.IP == id {
b.devices = append(b.devices[:i], b.devices[i+1:]...)
return
}
}
}
// Build 构建拓扑图
func (b *Builder) Build() models.TopologyGraph {
graph := models.TopologyGraph{
@@ -85,6 +101,16 @@ func (b *Builder) Build() models.TopologyGraph {
device.IP, len(device.Neighbors), len(device.MACAddresses))
for _, neighbor := range device.Neighbors {
// 过滤虚拟机邻居:识别MAC地址前缀,只保留真实网络设备
if neighbor.RemoteMAC != "" {
macPrefix := getMACPrefix(neighbor.RemoteMAC)
if isVirtualMAC(macPrefix) {
fmt.Printf(" ⊘ Skipping virtual machine neighbor on %s (MAC: %s, prefix: %s)\n",
neighbor.LocalInterface, neighbor.RemoteMAC, macPrefix)
continue
}
}
fmt.Printf(" Processing neighbor on %s: RemoteMAC=%s, RemoteDevice=%s, RemoteIP=%s\n",
neighbor.LocalInterface, neighbor.RemoteMAC, neighbor.RemoteDevice, neighbor.RemoteIP)
@@ -131,7 +157,90 @@ func (b *Builder) Build() models.TopologyGraph {
}
}
// 策略3b: MAC前缀匹配(使用12字符前缀 - 更精确)
// 同品牌设备虽然4字符前缀相同,但12字符前缀通常不同
if targetIP == "" && neighbor.RemoteMAC != "" {
// 使用12字符前缀(例如:642f-c7e0-03
macPrefix := ""
if len(neighbor.RemoteMAC) >= 12 {
macPrefix = neighbor.RemoteMAC[:12]
} else {
macPrefix = neighbor.RemoteMAC
}
fmt.Printf(" Trying MAC prefix match (12 chars): %s (prefix: %s)\n", neighbor.RemoteMAC, macPrefix)
var matchedDevices []string
for _, d := range b.devices {
if d.IP == device.IP {
continue // 跳过自己
}
// 检查设备d的MAC地址是否有匹配的前缀
matchingMACs := 0
for _, mac := range d.MACAddresses {
if len(mac) >= 12 && mac[:12] == macPrefix {
matchingMACs++
}
}
if matchingMACs >= 3 {
matchedDevices = append(matchedDevices, d.IP)
}
}
// 只有唯一匹配时才建立连接
if len(matchedDevices) == 1 {
targetIP = matchedDevices[0]
matchMethod = fmt.Sprintf("MAC-prefix-12(%s)", macPrefix)
fmt.Printf(" ✓ Matched by MAC prefix-12 (unique): %s -> %s\n", neighbor.RemoteMAC, targetIP)
} else if len(matchedDevices) > 1 {
fmt.Printf(" ✗ Ambiguous MAC prefix-12 match: %s matched %v\n", macPrefix, matchedDevices)
} else {
fmt.Printf(" ✗ No device matches MAC prefix-12 %s\n", macPrefix)
}
}
// 策略3c: LLDP接口匹配(改进 - 单向但要求唯一性)
// 如果设备A的LLDP信息显示它连接到接口Y
// 而设备B有接口Y,则A连接到B
// 但如果有多个设备都有接口Y,则不匹配(避免歧义)
if targetIP == "" && neighbor.RemoteMAC != "" && neighbor.RemoteInterface != "" {
fmt.Printf(" Trying LLDP interface match: looking for device with interface %s\n", neighbor.RemoteInterface)
var matchedDevices []string
for _, d := range b.devices {
if d.IP == device.IP {
continue // 跳过自己
}
// 检查设备d是否有这个接口
for _, iface := range d.Interfaces {
if iface.Name == neighbor.RemoteInterface {
matchedDevices = append(matchedDevices, d.IP)
break
}
}
}
// 只有唯一匹配时才建立连接
if len(matchedDevices) == 1 {
targetIP = matchedDevices[0]
matchMethod = fmt.Sprintf("LLDP-interface(%s->%s)", neighbor.LocalInterface, neighbor.RemoteInterface)
fmt.Printf(" ✓ Matched by LLDP interface (unique): %s %s -> %s %s\n",
device.IP, neighbor.LocalInterface, targetIP, neighbor.RemoteInterface)
} else if len(matchedDevices) > 1 {
fmt.Printf(" ✗ Ambiguous: multiple devices have interface %s: %v\n",
neighbor.RemoteInterface, matchedDevices)
} else {
fmt.Printf(" ✗ No device found with interface %s\n", neighbor.RemoteInterface)
}
}
// 策略4: 通过本地接口IP网段匹配(新增)
// 注意:此策略容易产生误匹配,仅在必要时使用
if targetIP == "" {
// 查找本地接口的IP地址
localInterfaceIP := ""
@@ -143,7 +252,7 @@ func (b *Builder) Build() models.TopologyGraph {
}
if localInterfaceIP != "" {
fmt.Printf(" Trying subnet match: local interface %s has IP %s\n",
fmt.Printf(" Trying subnet match (risky): local interface %s has IP %s\n",
neighbor.LocalInterface, localInterfaceIP)
// 计算本地接口的网段
@@ -159,7 +268,7 @@ func (b *Builder) Build() models.TopologyGraph {
if getSubnet(d.IP) == localSubnet {
targetIP = d.IP
matchMethod = "subnet(management IP)"
fmt.Printf(" Matched by subnet: %s in %s\n", d.IP, localSubnet)
fmt.Printf(" Matched by subnet (risky): %s in %s\n", d.IP, localSubnet)
break
}
@@ -168,7 +277,7 @@ func (b *Builder) Build() models.TopologyGraph {
if iface.IP != "" && getSubnet(iface.IP) == localSubnet {
targetIP = d.IP
matchMethod = fmt.Sprintf("subnet(interface %s)", iface.Name)
fmt.Printf(" Matched by subnet: %s (%s) in %s\n",
fmt.Printf(" Matched by subnet (risky): %s (%s) in %s\n",
d.IP, iface.Name, localSubnet)
break
}
@@ -178,27 +287,9 @@ func (b *Builder) Build() models.TopologyGraph {
}
}
} else {
// 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(新增
// 注意:只有当该网段只有2台设备时才使用此策略(点对点连接)
fmt.Printf(" Trying subnet match with management IP: %s\n", device.IP)
localSubnet := getSubnet(device.IP)
// 统计在该网段的设备数量
var devicesInSubnet []string
for _, d := range b.devices {
if d.IP != device.IP && getSubnet(d.IP) == localSubnet {
devicesInSubnet = append(devicesInSubnet, d.IP)
}
}
// 只有当网段中恰好有1台其他设备时才匹配(点对点)
if len(devicesInSubnet) == 1 {
targetIP = devicesInSubnet[0]
matchMethod = "subnet(management IP both sides)"
fmt.Printf(" ✓ Matched by management subnet: %s in %s (only device in subnet)\n", targetIP, localSubnet)
} else if len(devicesInSubnet) > 1 {
fmt.Printf(" ✗ Skipping subnet match: %d devices in subnet %s (ambiguous)\n", len(devicesInSubnet)+1, localSubnet)
}
// 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(高风险策略 - 已禁用
// 此策略容易产生错误连接,暂时禁用
fmt.Printf(" ✗ Skipping subnet match with management IP (disabled - too risky)\n")
}
}
@@ -231,6 +322,9 @@ func (b *Builder) Build() models.TopologyGraph {
}
}
// 策略6: 通过邻居端口号反向匹配(已禁用 - 容易产生错误连接)
// 暂时禁用,因为端口号匹配不够精确,可能产生环路
if targetIP == "" {
fmt.Printf(" ✗ Could not match neighbor on %s (MAC: %s)\n", neighbor.LocalInterface, neighbor.RemoteMAC)
continue
@@ -239,15 +333,24 @@ func (b *Builder) Build() models.TopologyGraph {
fmt.Printf(" ✓ Creating edge: %s (%s) -> %s via %s, matched by %s\n",
device.IP, neighbor.LocalInterface, targetIP, neighbor.RemoteInterface, matchMethod)
// 创建唯一的边ID
edgeID := fmt.Sprintf("%s-%s-%s", device.IP, neighbor.LocalInterface, targetIP)
reverseEdgeID := fmt.Sprintf("%s-%s-%s", targetIP, neighbor.RemoteInterface, device.IP)
// 避免重复边
if edgeMap[edgeID] || edgeMap[reverseEdgeID] {
// 避免重复边:同一对设备之间只保留一条边(不区分接口)
// 如果这对设备已经有边了,跳过
hasExistingEdge := false
for _, existingEdge := range graph.Edges {
if (existingEdge.Source == device.IP && existingEdge.Target == targetIP) ||
(existingEdge.Source == targetIP && existingEdge.Target == device.IP) {
fmt.Printf(" ⚠ Skipping: edge between %s and %s already exists\n", device.IP, targetIP)
hasExistingEdge = true
break
}
}
if hasExistingEdge {
continue
}
// 创建唯一的边ID(包含接口信息用于调试)
edgeID := fmt.Sprintf("%s-%s-%s", device.IP, neighbor.LocalInterface, targetIP)
edge := models.TopologyEdge{
ID: edgeID,
Source: device.IP,
@@ -287,6 +390,42 @@ func normalizeMAC(mac string) string {
return strings.ToLower(result)
}
// getMACPrefix 获取MAC地址前缀(前4个字符)
func getMACPrefix(mac string) string {
normalized := normalizeMAC(mac)
if len(normalized) >= 4 {
return normalized[0:4]
}
return normalized
}
// isVirtualMAC 判断是否是虚拟机MAC地址前缀
func isVirtualMAC(prefix string) bool {
// 根据用户反馈,这些前缀都是虚拟机
userVirtualPrefixes := []string{"9844", "9400", "cc05", "44a1"}
for _, vp := range userVirtualPrefixes {
if prefix == vp {
return true
}
}
// 常见虚拟机/虚拟网卡MAC前缀(VMware、Hyper-V、KVM等)
virtualPrefixes := []string{
"0005", "000c", "0015", "0050", // VMware
"0017", "001d", "0025", "5254", // KVM/QEMU
"0015", "0017", "00ff", // Xen
}
for _, vp := range virtualPrefixes {
if prefix == vp {
return true
}
}
return false
}
// getSubnet 获取IP地址的C类网段(简化版,适用于/24网络)
func getSubnet(ip string) string {
parts := strings.Split(ip, ".")
+23 -9
View File
@@ -41,6 +41,7 @@ type Interface struct {
Speed string `json:"speed"`
Duplex string `json:"duplex"`
MTU int `json:"mtu"`
VLAN string `json:"vlan"` // VLAN信息(PVID/Access/Trunk
InBytes int64 `json:"in_bytes"`
OutBytes int64 `json:"out_bytes"`
InPackets int64 `json:"in_packets"`
@@ -59,15 +60,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 +95,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"`
}
+12
View File
@@ -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
+175 -1
View File
@@ -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 {
@@ -64,6 +82,16 @@ header h1 {
color: white;
}
.btn-secondary {
background: #607D8B;
color: white;
}
.btn-danger {
background: #f44336;
color: white;
}
.main-content {
display: flex;
flex: 1;
@@ -72,6 +100,8 @@ header h1 {
.sidebar {
width: 300px;
min-width: 200px;
max-width: 600px;
background: white;
padding: 20px;
overflow-y: auto;
@@ -162,6 +192,7 @@ header h1 {
.content {
flex: 1;
position: relative;
min-width: 400px;
}
#cy {
@@ -171,18 +202,48 @@ header h1 {
}
.detail-panel {
width: 350px;
width: 600px;
min-width: 300px;
max-width: 1000px;
background: white;
padding: 20px;
overflow-y: auto;
box-shadow: -2px 0 5px rgba(0,0,0,0.1);
display: none;
position: relative;
}
.detail-panel.active {
display: block;
}
/* 拖拽手柄样式 */
.resizer {
width: 5px;
cursor: col-resize;
background: #e0e0e0;
position: relative;
z-index: 10;
transition: background 0.2s;
}
.resizer:hover,
.resizer.resizing {
background: #667eea;
}
.resizer::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 3px;
height: 30px;
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.detail-panel h3 {
margin-bottom: 15px;
color: #667eea;
@@ -269,3 +330,116 @@ header h1 {
color: #FF9800;
font-weight: bold;
}
/* SSH终端样式 */
.terminal-modal-content {
width: 95vw;
max-width: 1200px;
margin: 20px auto;
max-height: 90vh;
display: flex;
flex-direction: column;
cursor: default;
}
.terminal-modal-content .modal-header-drag {
cursor: move;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 5px;
user-select: none;
}
.terminal-container {
width: 100%;
height: calc(85vh - 80px);
background: #1e1e1e;
border-radius: 5px;
overflow: hidden;
margin-top: 10px;
}
.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;
}
+114 -4
View File
@@ -5,22 +5,31 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网络拓扑发现系统</title>
<link rel="stylesheet" href="/css/style.css">
<script src="https://unpkg.com/cytoscape@3.26.0/dist/cytoscape.min.js"></script>
<script src="/vendor/cytoscape.min.js"></script>
<link rel="stylesheet" href="/vendor/xterm.css">
<script src="/vendor/xterm.js"></script>
<script src="/vendor/xterm-addon-fit.js"></script>
<script src="/vendor/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>
<button id="btn-logout" class="btn btn-danger" style="margin-left:auto">登出</button>
</div>
</header>
<div class="main-content">
<!-- 侧边栏 -->
<aside class="sidebar">
<aside class="sidebar" id="sidebar">
<div class="panel">
<h3>扫描配置</h3>
<form id="scan-form">
@@ -60,11 +69,17 @@
</div>
</aside>
<!-- 左侧拖拽手柄 -->
<div class="resizer" id="resizer-left"></div>
<!-- 主内容区 -->
<main class="content">
<div id="cy"></div>
</main>
<!-- 右侧拖拽手柄 -->
<div class="resizer" id="resizer-right"></div>
<!-- 详情面板 -->
<aside class="detail-panel" id="detail-panel">
<h3>设备详情</h3>
@@ -73,6 +88,45 @@
</div>
</div>
<!-- SSH终端模态框 -->
<div id="modal-terminal" class="modal">
<div class="modal-content terminal-modal-content">
<div class="modal-header-drag">
<span class="close-terminal">&times;</span>
<h2 style="display:inline;margin:0">SSH 终端 - <span id="terminal-device-name"></span></h2>
</div>
<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">&times;</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">
@@ -86,9 +140,9 @@
<div class="form-group">
<label for="device-type">设备类型:</label>
<select id="device-type" required>
<option value="cisco">Cisco</option>
<option value="huawei">华为</option>
<option value="h3c">H3C</option>
<option value="huawei">华为</option>
<option value="cisco">Cisco</option>
<option value="asa">ASA防火墙</option>
<option value="linux">Linux服务器</option>
<option value="windows">Windows Server</option>
@@ -107,6 +161,62 @@
</div>
</div>
<!-- 新建拓扑模态框 -->
<div id="modal-new-topology" class="modal">
<div class="modal-content">
<span class="close-new-topology">&times;</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-edit-device" class="modal">
<div class="modal-content">
<span class="close-edit-device">&times;</span>
<h2>编辑设备</h2>
<form id="edit-device-form" onsubmit="saveEditDevice(event)">
<input type="hidden" id="edit-device-id">
<div class="form-group">
<label for="edit-hostname">主机名:</label>
<input type="text" id="edit-hostname" required placeholder="例: BJ-SW01">
</div>
<div class="form-group">
<label for="edit-type">设备类型:</label>
<select id="edit-type" required>
<option value="h3c">H3C</option>
<option value="huawei">Huawei</option>
<option value="cisco">Cisco</option>
<option value="asa">ASA</option>
<option value="linux">Linux</option>
<option value="windows">Windows</option>
</select>
</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">&times;</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>
+600 -4
View File
@@ -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,27 @@ 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.querySelector('.close-edit-device').addEventListener('click', function() {
document.getElementById('modal-edit-device').classList.remove('active');
});
// 新建拓扑表单
document.getElementById('new-topology-form').addEventListener('submit', createTopology);
// 扫描按钮
document.getElementById('btn-scan').addEventListener('click', startScan);
@@ -102,6 +124,57 @@ function initEventListeners() {
// 导出按钮
document.getElementById('btn-export').addEventListener('click', exportTopology);
// 登出按钮
var logoutBtn = document.getElementById('btn-logout');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
window.location.href = '/api/logout';
});
}
// SSH终端相关
document.querySelector('.close-terminal').addEventListener('click', closeTerminal);
// 终端模态框拖动功能
(function() {
var dragHandle = document.querySelector('.modal-header-drag');
var modal = document.querySelector('.terminal-modal-content');
var isDragging = false;
var offsetX = 0, offsetY = 0;
if (dragHandle && modal) {
dragHandle.addEventListener('mousedown', function(e) {
if (e.target.classList.contains('close-terminal')) return;
isDragging = true;
var rect = modal.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
modal.style.margin = '0';
modal.style.position = 'absolute';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
modal.style.left = (e.clientX - offsetX) + 'px';
modal.style.top = (e.clientY - offsetY) + 'px';
});
document.addEventListener('mouseup', function() {
isDragging = false;
});
}
})();
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 +218,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 +251,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);
}
};
@@ -291,6 +382,14 @@ async function loadDeviceList() {
async function showDeviceDetail(deviceId) {
try {
const response = await fetch(`/api/device/${deviceId}`);
if (response.status === 401) {
window.location.href = '/login';
return;
}
if (!response.ok) {
console.error('获取设备详情失败:', response.status, response.statusText);
return;
}
const device = await response.json();
const detailPanel = document.getElementById('detail-panel');
@@ -304,6 +403,11 @@ 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>
<div style="display:flex;gap:10px;margin-top:10px">
<button class="btn btn-primary" style="flex:1" onclick="openSSHTerminal('${device.ip}', '${device.hostname || device.ip}')">SSH 连接</button>
<button class="btn btn-secondary" style="flex:1" onclick="openEditDevice('${device.id}', '${device.hostname}', '${device.type}')">编辑</button>
<button class="btn btn-danger" style="flex:1" onclick="deleteDevice('${device.id}')">删除</button>
</div>
</div>
<div class="detail-section">
<h4>接口信息 (${device.interfaces.length})</h4>
@@ -315,6 +419,8 @@ async function showDeviceDetail(deviceId) {
<p>IP: ${iface.ip || 'N/A'}</p>
<p>MAC: ${iface.mac || 'N/A'}</p>
<p>速度: ${iface.speed || 'N/A'}</p>
<p>双工: ${iface.duplex || 'N/A'}</p>
<p>VLAN: ${iface.vlan || 'N/A'}</p>
</div>
</div>
`).join('')}
@@ -390,3 +496,493 @@ 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 = '';
// 重置终端模态框位置
var modal = document.querySelector('.terminal-modal-content');
if (modal) {
modal.style.position = '';
modal.style.left = '';
modal.style.top = '';
modal.style.margin = '';
}
}
// 删除设备
async function deleteDevice(deviceId) {
if (!confirm('确定要删除设备 ' + deviceId + ' 吗?')) return;
try {
const response = await fetch(`/api/device/${deviceId}`, {
method: 'DELETE'
});
if (response.ok) {
alert('删除成功');
document.getElementById('detail-panel').classList.remove('active');
// 重新加载拓扑和设备列表
loadTopology();
loadDeviceList();
} else {
alert('删除失败');
}
} catch (error) {
alert('删除失败: ' + error.message);
}
}
// 打开编辑设备模态框
function openEditDevice(id, hostname, type) {
document.getElementById('edit-device-id').value = id;
document.getElementById('edit-hostname').value = hostname;
document.getElementById('edit-type').value = type;
document.getElementById('modal-edit-device').classList.add('active');
}
// 保存编辑设备
async function saveEditDevice(event) {
event.preventDefault();
const id = document.getElementById('edit-device-id').value;
const hostname = document.getElementById('edit-hostname').value;
const type = document.getElementById('edit-type').value;
try {
const response = await fetch(`/api/device/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, type })
});
if (response.ok) {
alert('修改成功');
document.getElementById('modal-edit-device').classList.remove('active');
document.getElementById('detail-panel').classList.remove('active');
loadTopology();
loadDeviceList();
} else {
const data = await response.json();
alert('修改失败: ' + (data.error || '未知错误'));
}
} catch (error) {
alert('修改失败: ' + error.message);
}
}
// 面板拖拽调整大小功能
(function() {
// 左侧面板拖拽
var resizerLeft = document.getElementById('resizer-left');
var sidebar = document.getElementById('sidebar');
if (resizerLeft && sidebar) {
var isResizingLeft = false;
resizerLeft.addEventListener('mousedown', function(e) {
isResizingLeft = true;
resizerLeft.classList.add('resizing');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isResizingLeft) return;
var newWidth = e.clientX;
var minWidth = 200;
var maxWidth = 600;
if (newWidth >= minWidth && newWidth <= maxWidth) {
sidebar.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', function() {
if (isResizingLeft) {
isResizingLeft = false;
resizerLeft.classList.remove('resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// 右侧面板拖拽
var resizerRight = document.getElementById('resizer-right');
var detailPanel = document.getElementById('detail-panel');
if (resizerRight && detailPanel) {
var isResizingRight = false;
resizerRight.addEventListener('mousedown', function(e) {
isResizingRight = true;
resizerRight.classList.add('resizing');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isResizingRight) return;
var containerWidth = document.querySelector('.main-content').offsetWidth;
var newWidth = containerWidth - e.clientX;
var minWidth = 300;
var maxWidth = 1000;
if (newWidth >= minWidth && newWidth <= maxWidth) {
detailPanel.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', function() {
if (isResizingRight) {
isResizingRight = false;
resizerRight.classList.remove('resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
})();
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 网络拓扑发现系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 50px 40px;
width: 400px;
max-width: 90vw;
}
.login-card h1 {
text-align: center;
margin-bottom: 10px;
font-size: 24px;
color: #333;
}
.login-card p {
text-align: center;
color: #999;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
color: #555;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
transition: border-color 0.3s;
outline: none;
}
.form-group input:focus {
border-color: #667eea;
}
.login-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.3s;
}
.login-btn:hover {
opacity: 0.9;
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-msg {
color: #f44336;
text-align: center;
margin-top: 15px;
font-size: 14px;
display: none;
}
</style>
</head>
<body>
<div class="login-card">
<h1>🌐 网络拓扑发现系统</h1>
<p>请登录以继续</p>
<form id="login-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required autofocus placeholder="请输入用户名">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required placeholder="请输入密码">
</div>
<button type="submit" class="login-btn" id="login-btn">登 录</button>
<div class="error-msg" id="error-msg"></div>
</form>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault();
var btn = document.getElementById('login-btn');
var errEl = document.getElementById('error-msg');
btn.disabled = true;
btn.textContent = '登录中...';
errEl.style.display = 'none';
var username = document.getElementById('username').value;
var password = document.getElementById('password').value;
try {
var resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, password: password })
});
var data = await resp.json();
if (resp.ok && data.success) {
window.location.href = '/';
} else {
errEl.textContent = data.message || '登录失败';
errEl.style.display = 'block';
}
} catch (err) {
errEl.textContent = '网络错误,请重试';
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = '登 录';
});
</script>
</body>
</html>
+32
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})()));
//# sourceMappingURL=xterm-addon-web-links.js.map
+209
View File
@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+2
View File
File diff suppressed because one or more lines are too long