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 ✅
325 lines
8.0 KiB
Go
325 lines
8.0 KiB
Go
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
|
|
}
|