959 라인
25 KiB
Go
959 라인
25 KiB
Go
package main
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"network-topology-discovery/internal/config"
|
||
"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
|
||
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 {
|
||
// 初始化拓扑存储(管理多个拓扑)
|
||
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,
|
||
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 {
|
||
log.Printf("Warning: failed to load devices from database: %v", err)
|
||
} else {
|
||
log.Printf("Loaded %d devices from storage", len(devices))
|
||
for _, dev := range devices {
|
||
app.builder.AddDevice(dev)
|
||
}
|
||
}
|
||
}
|
||
|
||
return app
|
||
}
|
||
|
||
// Start 启动应用
|
||
func (app *App) Start() error {
|
||
// 设置路由
|
||
mux := http.NewServeMux()
|
||
|
||
// 登录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 {
|
||
authMux.Handle("/", http.FileServer(http.Dir(webDir)))
|
||
} else {
|
||
log.Printf("警告: web目录不存在,静态文件服务不可用")
|
||
authMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte("<h1>网络拓扑发现系统</h1><p>Web界面文件未找到</p>"))
|
||
})
|
||
}
|
||
|
||
// API路由
|
||
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{
|
||
Addr: addr,
|
||
Handler: mux,
|
||
}
|
||
|
||
log.Printf("服务启动在 %s", addr)
|
||
return app.httpServer.ListenAndServe()
|
||
}
|
||
|
||
// 生成唯一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:])
|
||
}
|
||
|
||
// getWebDir 获取web目录路径
|
||
func getWebDir() string {
|
||
// 尝试多个可能的路径
|
||
possiblePaths := []string{
|
||
"web",
|
||
filepath.Join("cmd", "web"),
|
||
filepath.Join("..", "web"),
|
||
}
|
||
|
||
for _, path := range possiblePaths {
|
||
if _, err := os.Stat(path); err == nil {
|
||
absPath, _ := filepath.Abs(path)
|
||
return absPath
|
||
}
|
||
}
|
||
|
||
// 默认返回web
|
||
return "web"
|
||
}
|
||
|
||
// 处理扫描请求
|
||
func (app *App) handleScan(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
ScanRange string `json:"scan_range"`
|
||
SSHPort int `json:"ssh_port"`
|
||
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 req.SSHPort == 0 {
|
||
req.SSHPort = 22
|
||
}
|
||
|
||
// 创建扫描任务
|
||
taskID := generateID()
|
||
task := &models.ScanTask{
|
||
ID: taskID,
|
||
Status: "running",
|
||
StartTime: time.Now(),
|
||
Devices: []models.Device{},
|
||
}
|
||
|
||
app.mu.Lock()
|
||
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)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{"task_id": taskID})
|
||
}
|
||
|
||
// 执行扫描
|
||
func (app *App) runScan(task *models.ScanTask, cidr string, sshPort int, username, password string) {
|
||
defer func() {
|
||
task.EndTime = time.Now()
|
||
}()
|
||
|
||
// 创建扫描器
|
||
sc := scanner.NewScanner(app.config.Scanner.Concurrency, time.Duration(app.config.Scanner.Timeout)*time.Second)
|
||
|
||
// 阶段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,
|
||
models.DeviceTypeHuawei,
|
||
models.DeviceTypeH3C,
|
||
models.DeviceTypeASA,
|
||
models.DeviceTypeLinux,
|
||
models.DeviceTypeWindows,
|
||
}
|
||
|
||
var discoveredDevice *models.Device
|
||
for _, dtype := range deviceTypes {
|
||
dev, err := device.DiscoverDevice(ip, dtype, username, password)
|
||
if err == nil && dev.ScanStatus == "success" {
|
||
discoveredDevice = dev
|
||
break
|
||
}
|
||
}
|
||
|
||
if discoveredDevice != nil {
|
||
devices = append(devices, *discoveredDevice)
|
||
app.builder.AddDevice(*discoveredDevice)
|
||
|
||
// 保存到数据库
|
||
if app.storage != nil {
|
||
if err := app.storage.SaveDevice(discoveredDevice); err != nil {
|
||
log.Printf("Warning: failed to save device %s to database: %v", ip, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新进度: 50% ~ 100%
|
||
task.ScannedDevices = i + 1
|
||
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()
|
||
|
||
if !exists {
|
||
http.Error(w, "Task not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(task)
|
||
}
|
||
|
||
// 处理拓扑查询
|
||
func (app *App) handleTopology(w http.ResponseWriter, r *http.Request) {
|
||
graph := app.builder.Build()
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(graph)
|
||
}
|
||
|
||
// 处理获取所有设备
|
||
func (app *App) handleGetDevices(w http.ResponseWriter, r *http.Request) {
|
||
var devices []models.Device
|
||
|
||
// 优先从存储获取
|
||
if app.storage != nil {
|
||
var err error
|
||
devices, err = app.storage.GetAllDevices()
|
||
if err != nil {
|
||
log.Printf("Error: failed to get devices from storage: %v", err)
|
||
// 降级到 builder获取
|
||
devices = app.builder.GetDevices()
|
||
}
|
||
log.Printf("Returning %d devices from storage", len(devices))
|
||
} else {
|
||
devices = app.builder.GetDevices()
|
||
log.Printf("Returning %d devices from builder", len(devices))
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(devices)
|
||
}
|
||
|
||
// 处理添加设备
|
||
func (app *App) handleAddDevice(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
IP string `json:"ip"`
|
||
Type string `json:"type"`
|
||
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
|
||
}
|
||
|
||
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)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
json.NewEncoder(w).Encode(map[string]string{"message": err.Error()})
|
||
return
|
||
}
|
||
|
||
log.Printf("Device discovered: %s, interfaces: %d, neighbors: %d",
|
||
dev.IP, len(dev.Interfaces), len(dev.Neighbors))
|
||
|
||
app.builder.AddDevice(*dev)
|
||
|
||
// 保存到存储
|
||
if app.storage != nil {
|
||
if err := app.storage.SaveDevice(dev); err != nil {
|
||
log.Printf("Error: failed to save device %s to storage: %v", req.IP, err)
|
||
} else {
|
||
log.Printf("Device %s saved to storage successfully", req.IP)
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(dev)
|
||
}
|
||
|
||
// 处理设备详情查询
|
||
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 {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(dev)
|
||
return
|
||
}
|
||
}
|
||
|
||
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"
|
||
if len(os.Args) > 1 {
|
||
configFile = os.Args[1]
|
||
}
|
||
|
||
var cfg *config.Config
|
||
if _, err := os.Stat(configFile); err == nil {
|
||
cfg, err = config.LoadConfig(configFile)
|
||
if err != nil {
|
||
log.Printf("加载配置文件失败: %v, 使用默认配置", err)
|
||
cfg = config.DefaultConfig()
|
||
}
|
||
} else {
|
||
log.Printf("配置文件不存在, 使用默认配置")
|
||
cfg = config.DefaultConfig()
|
||
}
|
||
|
||
// 创建并启动应用
|
||
app := NewApp(cfg)
|
||
|
||
log.Println("网络拓扑发现系统启动...")
|
||
if err := app.Start(); err != nil {
|
||
log.Fatalf("服务启动失败: %v", err)
|
||
}
|
||
}
|