|
|
@@ -8,6 +8,7 @@ import (
|
|
|
"net/http"
|
|
|
"os"
|
|
|
"path/filepath"
|
|
|
+ "strings"
|
|
|
"sync"
|
|
|
"time"
|
|
|
|
|
|
@@ -29,6 +30,8 @@ type App struct {
|
|
|
tasks map[string]*models.ScanTask
|
|
|
mu sync.RWMutex
|
|
|
httpServer *http.Server
|
|
|
+ sessions map[string]time.Time // token -> expire time
|
|
|
+ sessionMu sync.RWMutex
|
|
|
}
|
|
|
|
|
|
// NewApp 创建应用
|
|
|
@@ -51,6 +54,7 @@ func NewApp(cfg *config.Config) *App {
|
|
|
storage: store,
|
|
|
topologyStorage: topoStorage,
|
|
|
tasks: make(map[string]*models.ScanTask),
|
|
|
+ sessions: make(map[string]time.Time),
|
|
|
}
|
|
|
|
|
|
// 如果有拓扑存储,切换到第一个拓扑
|
|
|
@@ -85,36 +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
|
|
|
- mux.HandleFunc("/api/topologies", app.handleTopologies)
|
|
|
- mux.HandleFunc("/api/topology/switch", app.handleSwitchTopology)
|
|
|
- mux.HandleFunc("/api/topology/{id}", app.handleTopologyDetail)
|
|
|
+ authMux.HandleFunc("/api/topologies", app.handleTopologies)
|
|
|
+ authMux.HandleFunc("/api/topology/switch", app.handleSwitchTopology)
|
|
|
+ authMux.HandleFunc("/api/topology/{id}", app.handleTopologyDetail)
|
|
|
|
|
|
// SSH终端API
|
|
|
- mux.HandleFunc("/api/terminal", app.handleTerminalConnect)
|
|
|
+ authMux.HandleFunc("/api/terminal", app.handleTerminalConnect)
|
|
|
|
|
|
// 全局设备池API
|
|
|
- mux.HandleFunc("/api/devices/all", app.handleGetAllDevices)
|
|
|
- mux.HandleFunc("/api/topology/{id}/devices", app.handleAddDevicesToTopology)
|
|
|
+ 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{
|
|
|
@@ -394,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 {
|
|
|
@@ -592,6 +674,125 @@ func (app *App) handleTerminalConnect(w http.ResponseWriter, r *http.Request) {
|
|
|
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 {
|