feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]: - 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式) - 告警引擎 isSilenced 集成 (alert_engine.go) - 前端 SilenceRuleTab + SilenceDialog - 路由 /alerts/silences 已注册 F2 审计日志导出 [done]: - 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM) - 支持 module/action/username/startTime/endTime 筛选 - 导出限制 10000 条防溢出,自动记录审计日志 - JSON 格式向后兼容 (format=json) - 前端 exportLogs 传递查询参数 同时包含累积的前期工作: - 目录重构 internal/ -> controller/model/router (v0.55-v0.57) - 后端分层架构 dto/repository/service/utils - 审计模块全功能 (终端审计/操作审计/登录日志) - asciicast v2 录制格式 - SFTP 文件浏览器 - StatStrip 紧凑统计条 - 在线会话自动刷新 - LiveMonitor WebSocket 诊断修复 验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
This commit is contained in:
1 parent
e57718998a
commit
77c234b060
314 files changed
+10050
-4065
No files matched your search
@@ -0,0 +1,324 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ezops/model"
|
||||
)
|
||||
|
||||
// SFTPHandler 文件浏览器处理器 — 基于 SFTP 的服务器文件管理
|
||||
type SFTPHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSFTPHandler(db *gorm.DB) *SFTPHandler {
|
||||
return &SFTPHandler{db: db}
|
||||
}
|
||||
|
||||
// sftpClient 建立到目标服务器的 SFTP 连接
|
||||
func (h *SFTPHandler) sftpClient(serverID uint) (*sftp.Client, *ssh.Client, error) {
|
||||
var server model.Server
|
||||
if err := h.db.First(&server, serverID).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("server not found: %w", err)
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Timeout: 10 * time.Second,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
switch server.AuthType {
|
||||
case "key":
|
||||
signer, err := ssh.ParsePrivateKey([]byte(server.SSHKey))
|
||||
if err != nil {
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(server.Password)}
|
||||
} else {
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||
}
|
||||
default:
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(server.Password)}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.IP, server.Port)
|
||||
sshClient, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("SSH connect failed: %w", err)
|
||||
}
|
||||
|
||||
sftpClient, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
sshClient.Close()
|
||||
return nil, nil, fmt.Errorf("SFTP init failed: %w", err)
|
||||
}
|
||||
|
||||
return sftpClient, sshClient, nil
|
||||
}
|
||||
|
||||
// FileInfo JSON 响应结构
|
||||
type FileItem struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
ModTime string `json:"mod_time"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// ListFiles 列出目录内容
|
||||
// GET /api/v1/servers/:id/files?path=/
|
||||
func (h *SFTPHandler) ListFiles(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"})
|
||||
return
|
||||
}
|
||||
|
||||
dirPath := c.Query("path")
|
||||
if dirPath == "" {
|
||||
dirPath = "/"
|
||||
}
|
||||
|
||||
sftpClient, sshClient, err := h.sftpClient(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
defer sftpClient.Close()
|
||||
|
||||
entries, err := sftpClient.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("read dir failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
var items []FileItem
|
||||
for _, entry := range entries {
|
||||
items = append(items, FileItem{
|
||||
Name: entry.Name(),
|
||||
Path: path.Join(dirPath, entry.Name()),
|
||||
Size: entry.Size(),
|
||||
IsDir: entry.IsDir(),
|
||||
ModTime: entry.ModTime().Format("2006-01-02 15:04:05"),
|
||||
Mode: entry.Mode().String(),
|
||||
})
|
||||
}
|
||||
|
||||
// 构建面包屑
|
||||
breadcrumbs := buildBreadcrumbs(dirPath)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"path": dirPath,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadFile 下载文件
|
||||
// GET /api/v1/servers/:id/files/download?path=/etc/hosts
|
||||
func (h *SFTPHandler) DownloadFile(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := c.Query("path")
|
||||
if filePath == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
|
||||
return
|
||||
}
|
||||
|
||||
sftpClient, sshClient, err := h.sftpClient(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
defer sftpClient.Close()
|
||||
|
||||
stat, err := sftpClient.Stat(filePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
if stat.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot download directory"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := sftpClient.Open(filePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "open failed"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileName := path.Base(filePath)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
io.Copy(c.Writer, file)
|
||||
}
|
||||
|
||||
// UploadFile 上传文件
|
||||
// POST /api/v1/servers/:id/files/upload?path=/tmp/
|
||||
func (h *SFTPHandler) UploadFile(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"})
|
||||
return
|
||||
}
|
||||
|
||||
dirPath := c.Query("path")
|
||||
if dirPath == "" {
|
||||
dirPath = "/tmp/"
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
sftpClient, sshClient, err := h.sftpClient(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
defer sftpClient.Close()
|
||||
|
||||
destPath := path.Join(dirPath, header.Filename)
|
||||
dest, err := sftpClient.Create(destPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "create file failed"})
|
||||
return
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
written, err := io.Copy(dest, file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[SFTP] Uploaded %s (%d bytes) to server %d", destPath, written, id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "uploaded", "path": destPath, "size": written})
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件或空目录
|
||||
// DELETE /api/v1/servers/:id/files?path=/tmp/test.txt
|
||||
func (h *SFTPHandler) DeleteFile(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := c.Query("path")
|
||||
if filePath == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
|
||||
return
|
||||
}
|
||||
|
||||
sftpClient, sshClient, err := h.sftpClient(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
defer sftpClient.Close()
|
||||
|
||||
stat, err := sftpClient.Stat(filePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if stat.IsDir() {
|
||||
if err := sftpClient.RemoveDirectory(filePath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove dir failed (may not be empty)"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := sftpClient.Remove(filePath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// Mkdir 创建目录
|
||||
// POST /api/v1/servers/:id/files/mkdir
|
||||
func (h *SFTPHandler) Mkdir(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
|
||||
return
|
||||
}
|
||||
|
||||
sftpClient, sshClient, err := h.sftpClient(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
defer sftpClient.Close()
|
||||
|
||||
if err := sftpClient.MkdirAll(req.Path); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "mkdir failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "created", "path": req.Path})
|
||||
}
|
||||
|
||||
// buildBreadcrumbs 构建路径面包屑
|
||||
func buildBreadcrumbs(dirPath string) []map[string]string {
|
||||
if dirPath == "/" || dirPath == "" {
|
||||
return []map[string]string{{"name": "/", "path": "/"}}
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(dirPath, "/"), "/")
|
||||
var crumbs []map[string]string
|
||||
crumbs = append(crumbs, map[string]string{"name": "/", "path": "/"})
|
||||
|
||||
accum := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
accum += "/" + part
|
||||
crumbs = append(crumbs, map[string]string{
|
||||
"name": part,
|
||||
"path": accum,
|
||||
})
|
||||
}
|
||||
|
||||
return crumbs
|
||||
}
|
||||
Reference in new issue
Block a user