主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
527 lines
14 KiB
Go
527 lines
14 KiB
Go
package controller
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"ezops/model"
|
|
"ezops/pkg/crypto"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// CredentialHandler 凭据管理处理器 v2(多类型动态字段)
|
|
type CredentialHandler struct {
|
|
db *gorm.DB
|
|
crypt *crypto.SecretBox
|
|
}
|
|
|
|
func NewCredentialHandler(db *gorm.DB, masterKey string) *CredentialHandler {
|
|
return &CredentialHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
|
}
|
|
|
|
// ────── Helpers ──────
|
|
|
|
func genEnvPrefix() string {
|
|
b := make([]byte, 4)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)[:8]
|
|
}
|
|
|
|
func buildContent(req model.CreateSecretRequest) (string, string, error) {
|
|
switch req.SecretType {
|
|
case "ssh":
|
|
data := map[string]interface{}{
|
|
"private_key": req.SSHPrivateKey,
|
|
"public_key": req.SSHPublicKey,
|
|
"ssh_user": req.SSHUser,
|
|
"ssh_port": req.SSHPort,
|
|
"password": req.SSHPassword,
|
|
}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
|
|
case "rdp":
|
|
if strings.TrimSpace(req.RDPUsername) == "" {
|
|
return "", "", fmt.Errorf("RDP 用户不能为空")
|
|
}
|
|
if strings.TrimSpace(req.RDPPassword) == "" {
|
|
return "", "", fmt.Errorf("RDP 密码不能为空")
|
|
}
|
|
security := req.RDPSecurity
|
|
if security == "" {
|
|
security = "nla"
|
|
}
|
|
data := map[string]string{
|
|
"username": strings.TrimSpace(req.RDPUsername),
|
|
"password": req.RDPPassword,
|
|
"domain": strings.TrimSpace(req.RDPDomain),
|
|
"security": security,
|
|
}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), rdpSummary(req.RDPUsername, req.RDPDomain, security), nil
|
|
case "docker":
|
|
data := map[string]string{
|
|
"server": req.DockerServer,
|
|
"username": req.DockerUsername,
|
|
"password": req.DockerPassword,
|
|
}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), req.DockerServer, nil
|
|
case "tls":
|
|
data := map[string]string{"cert": req.TLSCert, "key": req.TLSKey, "ca": req.TLSCA}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), "TLS 证书已配置", nil
|
|
case "aksk":
|
|
data := map[string]string{
|
|
"provider": req.AKSKProvider,
|
|
"access_key": req.AKSKAccessKey,
|
|
"secret_key": req.AKSKSecretKey,
|
|
"region": req.AKSKRegion,
|
|
}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), req.AKSKProvider, nil
|
|
case "database":
|
|
data := map[string]interface{}{
|
|
"db_type": req.DBType,
|
|
"host": req.DBHost,
|
|
"port": req.DBPort,
|
|
"db_name": req.DBName,
|
|
"username": req.DBUsername,
|
|
"password": req.DBPassword,
|
|
}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), fmt.Sprintf("%s://%s:%d/%s", req.DBType, req.DBHost, req.DBPort, req.DBName), nil
|
|
case "api":
|
|
data := map[string]string{"api_key": req.APIKey, "endpoint": req.APIEndpoint}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), req.APIEndpoint, nil
|
|
case "generic":
|
|
data := map[string]string{"generic_key": req.GenericKey, "generic_value": req.GenericValue}
|
|
b, _ := json.Marshal(data)
|
|
return string(b), req.GenericKey, nil
|
|
}
|
|
return "{}", "", fmt.Errorf("unknown type: %s", req.SecretType)
|
|
}
|
|
|
|
func rdpSummary(username, domain, security string) string {
|
|
username = strings.TrimSpace(username)
|
|
domain = strings.TrimSpace(domain)
|
|
security = strings.TrimSpace(security)
|
|
if security == "" || security == "<nil>" {
|
|
security = "nla"
|
|
}
|
|
account := username
|
|
if domain != "" && domain != "<nil>" {
|
|
account = username + "@" + domain
|
|
}
|
|
return fmt.Sprintf("Windows RDP %s / %s", account, strings.ToUpper(security))
|
|
}
|
|
|
|
// ────── List ──────
|
|
|
|
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
|
|
var query model.SecretListQuery
|
|
c.ShouldBindQuery(&query)
|
|
|
|
db := h.db.Model(&model.Credential{})
|
|
if query.SecretType != "" {
|
|
db = db.Where("secret_type = ?", query.SecretType)
|
|
}
|
|
if query.Search != "" {
|
|
s := "%" + query.Search + "%"
|
|
db = db.Where("name LIKE ? OR description LIKE ? OR variable_name LIKE ?", s, s, s)
|
|
}
|
|
if query.Tags != "" {
|
|
for _, tag := range strings.Split(query.Tags, ",") {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag != "" {
|
|
db = db.Where("tags LIKE ?", "%\""+tag+"\"%")
|
|
}
|
|
}
|
|
}
|
|
|
|
var total int64
|
|
db.Count(&total)
|
|
|
|
page := query.Page
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
pageSize := query.PageSize
|
|
if pageSize < 1 || pageSize > 100 {
|
|
pageSize = 20
|
|
}
|
|
|
|
var creds []model.Credential
|
|
db.Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&creds)
|
|
|
|
items := make([]model.SecretListResponse, 0, len(creds))
|
|
for _, cred := range creds {
|
|
tags := []string{}
|
|
if cred.Tags != "" {
|
|
json.Unmarshal([]byte(cred.Tags), &tags)
|
|
}
|
|
items = append(items, model.SecretListResponse{
|
|
ID: cred.ID,
|
|
Name: cred.Name,
|
|
VariableName: cred.VariableName,
|
|
SecretType: cred.SecretType,
|
|
Summary: cred.Summary,
|
|
Tags: tags,
|
|
Description: cred.Description,
|
|
ExpiresAt: cred.ExpiresAt,
|
|
IsActive: cred.IsActive,
|
|
EnvPrefix: cred.EnvPrefix,
|
|
CreatedAt: cred.CreatedAt,
|
|
UpdatedAt: cred.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{
|
|
List: items, Total: total,
|
|
}})
|
|
}
|
|
|
|
// ────── Create ──────
|
|
|
|
func (h *CredentialHandler) CreateCredential(c *gin.Context) {
|
|
var req model.CreateSecretRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
plainContent, summary, err := buildContent(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
|
|
encrypted, err := h.crypt.Encrypt(plainContent)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "加密失败"})
|
|
return
|
|
}
|
|
|
|
tagsJSON, _ := json.Marshal(req.Tags)
|
|
|
|
isActive := true
|
|
if req.IsActive != nil {
|
|
isActive = *req.IsActive
|
|
}
|
|
|
|
cred := model.Credential{
|
|
Name: req.Name,
|
|
VariableName: req.VariableName,
|
|
SecretType: req.SecretType,
|
|
Summary: summary,
|
|
Content: encrypted,
|
|
Tags: string(tagsJSON),
|
|
Description: req.Description,
|
|
ExpiresAt: req.ExpiresAt,
|
|
IsActive: isActive,
|
|
EnvPrefix: genEnvPrefix(),
|
|
}
|
|
|
|
if err := h.db.Create(&cred).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "创建成功", Data: cred.ID})
|
|
}
|
|
|
|
// ────── Get Detail ──────
|
|
|
|
func (h *CredentialHandler) GetCredential(c *gin.Context) {
|
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
var cred model.Credential
|
|
if h.db.First(&cred, id).Error != nil {
|
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "凭据不存在"})
|
|
return
|
|
}
|
|
|
|
// Decrypt content
|
|
plain, err := h.crypt.Decrypt(cred.Content)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "解密失败"})
|
|
return
|
|
}
|
|
|
|
var contentData map[string]interface{}
|
|
json.Unmarshal([]byte(plain), &contentData)
|
|
|
|
tags := []string{}
|
|
if cred.Tags != "" {
|
|
json.Unmarshal([]byte(cred.Tags), &tags)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
|
"id": cred.ID,
|
|
"name": cred.Name,
|
|
"variable_name": cred.VariableName,
|
|
"secret_type": cred.SecretType,
|
|
"content": contentData,
|
|
"tags": tags,
|
|
"description": cred.Description,
|
|
"expires_at": cred.ExpiresAt,
|
|
"is_active": cred.IsActive,
|
|
"env_prefix": cred.EnvPrefix,
|
|
"created_at": cred.CreatedAt,
|
|
"updated_at": cred.UpdatedAt,
|
|
}})
|
|
}
|
|
|
|
// ────── Update ──────
|
|
|
|
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
|
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
var cred model.Credential
|
|
if h.db.First(&cred, id).Error != nil {
|
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "凭据不存在"})
|
|
return
|
|
}
|
|
|
|
var req model.UpdateSecretRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"})
|
|
return
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
|
|
if req.Name != nil {
|
|
updates["name"] = *req.Name
|
|
}
|
|
if req.VariableName != nil {
|
|
updates["variable_name"] = *req.VariableName
|
|
}
|
|
if req.Description != nil {
|
|
updates["description"] = *req.Description
|
|
}
|
|
if req.ExpiresAt != nil {
|
|
updates["expires_at"] = req.ExpiresAt
|
|
}
|
|
if req.IsActive != nil {
|
|
updates["is_active"] = *req.IsActive
|
|
}
|
|
|
|
if len(req.Tags) > 0 {
|
|
b, _ := json.Marshal(req.Tags)
|
|
updates["tags"] = string(b)
|
|
}
|
|
|
|
// Rebuild content if any type-specific field changed
|
|
if hasTypeFieldUpdate(req) {
|
|
// Decrypt old content
|
|
oldPlain, _ := h.crypt.Decrypt(cred.Content)
|
|
var old map[string]interface{}
|
|
json.Unmarshal([]byte(oldPlain), &old)
|
|
if old == nil {
|
|
old = map[string]interface{}{}
|
|
}
|
|
|
|
mergeUpdate(req, old)
|
|
|
|
newPlain, _ := json.Marshal(old)
|
|
enc, err := h.crypt.Encrypt(string(newPlain))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "加密失败"})
|
|
return
|
|
}
|
|
updates["content"] = enc
|
|
|
|
// Update summary
|
|
updates["summary"] = buildSummaryFromRaw(old, cred.SecretType)
|
|
}
|
|
|
|
if len(updates) > 0 {
|
|
h.db.Model(&cred).Updates(updates)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功"})
|
|
}
|
|
|
|
func hasTypeFieldUpdate(req model.UpdateSecretRequest) bool {
|
|
return req.SSHPrivateKey != nil || req.SSHPublicKey != nil || req.SSHUser != nil || req.SSHPort != nil || req.SSHPassword != nil ||
|
|
req.RDPUsername != nil || req.RDPPassword != nil || req.RDPDomain != nil || req.RDPSecurity != nil ||
|
|
req.DockerServer != nil || req.DockerUsername != nil || req.DockerPassword != nil ||
|
|
req.TLSCert != nil || req.TLSKey != nil || req.TLSCA != nil ||
|
|
req.AKSKProvider != nil || req.AKSKAccessKey != nil || req.AKSKSecretKey != nil || req.AKSKRegion != nil ||
|
|
req.DBType != nil || req.DBHost != nil || req.DBPort != nil || req.DBName != nil || req.DBUsername != nil || req.DBPassword != nil ||
|
|
req.APIKey != nil || req.APIEndpoint != nil ||
|
|
req.GenericKey != nil || req.GenericValue != nil
|
|
}
|
|
|
|
func mergeUpdate(req model.UpdateSecretRequest, old map[string]interface{}) {
|
|
if req.SSHPrivateKey != nil {
|
|
old["private_key"] = *req.SSHPrivateKey
|
|
}
|
|
if req.SSHPublicKey != nil {
|
|
old["public_key"] = *req.SSHPublicKey
|
|
}
|
|
if req.SSHUser != nil {
|
|
old["ssh_user"] = *req.SSHUser
|
|
}
|
|
if req.SSHPort != nil {
|
|
old["ssh_port"] = *req.SSHPort
|
|
}
|
|
if req.SSHPassword != nil {
|
|
old["password"] = *req.SSHPassword
|
|
}
|
|
if req.RDPUsername != nil {
|
|
old["username"] = *req.RDPUsername
|
|
}
|
|
if req.RDPPassword != nil {
|
|
old["password"] = *req.RDPPassword
|
|
}
|
|
if req.RDPDomain != nil {
|
|
old["domain"] = *req.RDPDomain
|
|
}
|
|
if req.RDPSecurity != nil {
|
|
security := strings.TrimSpace(*req.RDPSecurity)
|
|
if security == "" {
|
|
security = "nla"
|
|
}
|
|
old["security"] = security
|
|
}
|
|
if req.DockerServer != nil {
|
|
old["server"] = *req.DockerServer
|
|
}
|
|
if req.DockerUsername != nil {
|
|
old["username"] = *req.DockerUsername
|
|
}
|
|
if req.DockerPassword != nil {
|
|
old["password"] = *req.DockerPassword
|
|
}
|
|
if req.TLSCert != nil {
|
|
old["cert"] = *req.TLSCert
|
|
}
|
|
if req.TLSKey != nil {
|
|
old["key"] = *req.TLSKey
|
|
}
|
|
if req.TLSCA != nil {
|
|
old["ca"] = *req.TLSCA
|
|
}
|
|
if req.AKSKProvider != nil {
|
|
old["provider"] = *req.AKSKProvider
|
|
}
|
|
if req.AKSKAccessKey != nil {
|
|
old["access_key"] = *req.AKSKAccessKey
|
|
}
|
|
if req.AKSKSecretKey != nil {
|
|
old["secret_key"] = *req.AKSKSecretKey
|
|
}
|
|
if req.AKSKRegion != nil {
|
|
old["region"] = *req.AKSKRegion
|
|
}
|
|
if req.DBType != nil {
|
|
old["db_type"] = *req.DBType
|
|
}
|
|
if req.DBHost != nil {
|
|
old["host"] = *req.DBHost
|
|
}
|
|
if req.DBPort != nil {
|
|
old["port"] = *req.DBPort
|
|
}
|
|
if req.DBName != nil {
|
|
old["db_name"] = *req.DBName
|
|
}
|
|
if req.DBUsername != nil {
|
|
old["username"] = *req.DBUsername
|
|
}
|
|
if req.DBPassword != nil {
|
|
old["password"] = *req.DBPassword
|
|
}
|
|
if req.APIKey != nil {
|
|
old["api_key"] = *req.APIKey
|
|
}
|
|
if req.APIEndpoint != nil {
|
|
old["endpoint"] = *req.APIEndpoint
|
|
}
|
|
if req.GenericKey != nil {
|
|
old["generic_key"] = *req.GenericKey
|
|
}
|
|
if req.GenericValue != nil {
|
|
old["generic_value"] = *req.GenericValue
|
|
}
|
|
}
|
|
|
|
func buildSummaryFromRaw(data map[string]interface{}, stype string) string {
|
|
switch stype {
|
|
case "ssh":
|
|
return fmt.Sprintf("SSH %v@%v", data["ssh_user"], data["ssh_port"])
|
|
case "rdp":
|
|
return rdpSummary(fmt.Sprint(data["username"]), fmt.Sprint(data["domain"]), fmt.Sprint(data["security"]))
|
|
case "docker":
|
|
return fmt.Sprintf("%v", data["server"])
|
|
case "aksk":
|
|
return fmt.Sprintf("%v", data["provider"])
|
|
case "database":
|
|
return fmt.Sprintf("%v://%v:%v/%v", data["db_type"], data["host"], data["port"], data["db_name"])
|
|
case "api":
|
|
return fmt.Sprintf("%v", data["endpoint"])
|
|
case "generic":
|
|
return fmt.Sprintf("%v", data["generic_key"])
|
|
case "tls":
|
|
return "TLS 证书已配置"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ────── Patch (toggle is_active) ──────
|
|
|
|
func (h *CredentialHandler) PatchCredential(c *gin.Context) {
|
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
var body map[string]interface{}
|
|
c.ShouldBindJSON(&body)
|
|
|
|
updates := map[string]interface{}{}
|
|
if v, ok := body["is_active"]; ok {
|
|
updates["is_active"] = v
|
|
}
|
|
if len(updates) > 0 {
|
|
h.db.Model(&model.Credential{}).Where("id = ?", id).Updates(updates)
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
|
}
|
|
|
|
// ────── Delete ──────
|
|
|
|
func (h *CredentialHandler) DeleteCredential(c *gin.Context) {
|
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if h.db.Delete(&model.Credential{}, id).Error != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
|
|
}
|
|
|
|
// ────── Tags ──────
|
|
|
|
func (h *CredentialHandler) GetTags(c *gin.Context) {
|
|
var rows []string
|
|
h.db.Model(&model.Credential{}).Where("tags != ''").Pluck("tags", &rows)
|
|
|
|
tagSet := map[string]bool{}
|
|
for _, r := range rows {
|
|
var tags []string
|
|
if json.Unmarshal([]byte(r), &tags) == nil {
|
|
for _, t := range tags {
|
|
tagSet[t] = true
|
|
}
|
|
}
|
|
}
|
|
tags := make([]string, 0, len(tagSet))
|
|
for t := range tagSet {
|
|
tags = append(tags, t)
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: tags})
|
|
}
|