feat: 模块化命令框架 + runbook 严格断言与查询结果回传
- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter - runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传 - cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败 - 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success - 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度 - Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
This commit is contained in:
+276
-3
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -88,6 +90,18 @@ type cfSettingsReq struct {
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
|
||||
type cfdavSettingsReq struct {
|
||||
APIBase string `json:"api_base"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPass string `json:"admin_pass"`
|
||||
}
|
||||
|
||||
type cfdavSettings struct {
|
||||
APIBase string
|
||||
AdminUser string
|
||||
AdminPass string
|
||||
}
|
||||
|
||||
type aiSettingsReq struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
@@ -352,6 +366,12 @@ func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) {
|
||||
// Cloudflare settings
|
||||
auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet)
|
||||
auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch)
|
||||
// CFDAV settings
|
||||
auth.GET("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsGet)
|
||||
auth.PATCH("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsPatch)
|
||||
auth.GET("/api/v1/admin/cfdav/users", s.handleCFDAVUsersList)
|
||||
auth.POST("/api/v1/admin/cfdav/users", s.handleCFDAVUsersCreate)
|
||||
auth.DELETE("/api/v1/admin/cfdav/users/:id", s.handleCFDAVUsersDelete)
|
||||
// AI settings
|
||||
auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet)
|
||||
auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch)
|
||||
@@ -447,6 +467,8 @@ func (s *WebServer) RegisterRoutes(r *gin.Engine) {
|
||||
auth.GET("/ops", s.handleOpsPage)
|
||||
auth.GET("/cpa", s.handleCPASettingsPage)
|
||||
auth.GET("/cf", s.handleCFSettingsPage)
|
||||
auth.GET("/cfdav", s.handleCFDAVSettingsPage)
|
||||
auth.GET("/modules", s.handleModulesPage)
|
||||
// AI 配置页临时隐藏(保留后端接口)
|
||||
|
||||
s.registerAPIV1Routes(auth)
|
||||
@@ -565,6 +587,24 @@ func (s *WebServer) handleCFSettingsPage(c *gin.Context) {
|
||||
s.renderPage(c, "cf_settings.html", u, nil)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleCFDAVSettingsPage(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if u == nil || !s.hasPermission(u, "settings.flags.read") {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
s.renderPage(c, "cfdav_settings.html", u, nil)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleModulesPage(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if u == nil || !s.hasPermission(u, "settings.flags.read") {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
s.renderPage(c, "modules.html", u, nil)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleAISettingsPage(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if u == nil || !s.hasPermission(u, "settings.flags.read") {
|
||||
@@ -645,7 +685,7 @@ func (s *WebServer) handleCPASettingsPatch(c *gin.Context) {
|
||||
}
|
||||
_ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error
|
||||
}
|
||||
|
||||
|
||||
s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success")
|
||||
respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}})
|
||||
}
|
||||
@@ -720,6 +760,233 @@ func (s *WebServer) handleCFSettingsPatch(c *gin.Context) {
|
||||
respondOK(c, "success", gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleCFDAVSettingsGet(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
|
||||
return
|
||||
}
|
||||
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
|
||||
out := map[string]string{}
|
||||
for _, k := range keys {
|
||||
var sset models.AppSetting
|
||||
if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
|
||||
out[k] = sset.Value
|
||||
} else {
|
||||
out[k] = ""
|
||||
}
|
||||
}
|
||||
respondOK(c, "ok", gin.H{"settings": out})
|
||||
}
|
||||
|
||||
func (s *WebServer) handleCFDAVSettingsPatch(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
|
||||
return
|
||||
}
|
||||
var req cfdavSettingsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if req.APIBase != "" {
|
||||
key := "cfdav_api_base"
|
||||
var sset models.AppSetting
|
||||
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
|
||||
sset = models.AppSetting{Key: key, Value: req.APIBase, UpdatedBy: u.UserID}
|
||||
_ = s.db.Create(&sset).Error
|
||||
} else {
|
||||
sset.Value = req.APIBase
|
||||
sset.UpdatedBy = u.UserID
|
||||
_ = s.db.Save(&sset).Error
|
||||
}
|
||||
}
|
||||
if req.AdminUser != "" {
|
||||
key := "cfdav_admin_user"
|
||||
var sset models.AppSetting
|
||||
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
|
||||
sset = models.AppSetting{Key: key, Value: req.AdminUser, UpdatedBy: u.UserID}
|
||||
_ = s.db.Create(&sset).Error
|
||||
} else {
|
||||
sset.Value = req.AdminUser
|
||||
sset.UpdatedBy = u.UserID
|
||||
_ = s.db.Save(&sset).Error
|
||||
}
|
||||
}
|
||||
if req.AdminPass != "" {
|
||||
key := "cfdav_admin_pass"
|
||||
var sset models.AppSetting
|
||||
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
|
||||
sset = models.AppSetting{Key: key, Value: req.AdminPass, UpdatedBy: u.UserID}
|
||||
_ = s.db.Create(&sset).Error
|
||||
} else {
|
||||
sset.Value = req.AdminPass
|
||||
sset.UpdatedBy = u.UserID
|
||||
_ = s.db.Save(&sset).Error
|
||||
}
|
||||
}
|
||||
respondOK(c, "success", gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *WebServer) loadCFDAVSettings() (*cfdavSettings, error) {
|
||||
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
|
||||
out := &cfdavSettings{}
|
||||
for _, k := range keys {
|
||||
var sset models.AppSetting
|
||||
val := ""
|
||||
if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
|
||||
val = sset.Value
|
||||
}
|
||||
switch k {
|
||||
case "cfdav_api_base":
|
||||
out.APIBase = val
|
||||
case "cfdav_admin_user":
|
||||
out.AdminUser = val
|
||||
case "cfdav_admin_pass":
|
||||
out.AdminPass = val
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(out.AdminUser) == "" || strings.TrimSpace(out.AdminPass) == "" {
|
||||
return nil, fmt.Errorf("cfdav admin 账号未配置")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *WebServer) doCFDAVRequest(method, url string, body []byte, settings *cfdavSettings) (string, int, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = strings.NewReader(string(body))
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
auth := settings.AdminUser + ":" + settings.AdminPass
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
req.Header.Set("Authorization", "Basic "+b64)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
return string(data), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (s *WebServer) handleCFDAVUsersList(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
|
||||
return
|
||||
}
|
||||
settings, err := s.loadCFDAVSettings()
|
||||
if err != nil {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(settings.APIBase) == "" {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
|
||||
resp, status, err := s.doCFDAVRequest(http.MethodGet, url, nil, settings)
|
||||
if err != nil {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
|
||||
return
|
||||
}
|
||||
if status >= 400 {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
|
||||
return
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
|
||||
return
|
||||
}
|
||||
respondOK(c, "ok", out)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleCFDAVUsersCreate(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
|
||||
return
|
||||
}
|
||||
settings, err := s.loadCFDAVSettings()
|
||||
if err != nil {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(settings.APIBase) == "" {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
|
||||
return
|
||||
}
|
||||
payload, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "读取请求失败")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
|
||||
resp, status, err := s.doCFDAVRequest(http.MethodPost, url, payload, settings)
|
||||
if err != nil {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
|
||||
return
|
||||
}
|
||||
if status >= 400 {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
|
||||
return
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
|
||||
return
|
||||
}
|
||||
s.writeAuditResult(u.UserID, "cfdav.user.create", "cfdav_user", "*", "", "", "", "success")
|
||||
respondOK(c, "ok", out)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleCFDAVUsersDelete(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
|
||||
return
|
||||
}
|
||||
settings, err := s.loadCFDAVSettings()
|
||||
if err != nil {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(settings.APIBase) == "" {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id required")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users/" + id
|
||||
resp, status, err := s.doCFDAVRequest(http.MethodDelete, url, nil, settings)
|
||||
if err != nil {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
|
||||
return
|
||||
}
|
||||
if status >= 400 {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
|
||||
return
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
|
||||
return
|
||||
}
|
||||
s.writeAuditResult(u.UserID, "cfdav.user.delete", "cfdav_user", id, "", "", "", "success")
|
||||
respondOK(c, "ok", out)
|
||||
}
|
||||
|
||||
func (s *WebServer) handleAISettingsGet(c *gin.Context) {
|
||||
u := currentUser(c)
|
||||
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
|
||||
@@ -1716,6 +1983,8 @@ func (s *WebServer) handleModulesList(c *gin.Context) {
|
||||
{Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"},
|
||||
{Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"},
|
||||
{Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"},
|
||||
{Module: "cfdav", DisplayName: "cfdav 管理", FlagKey: "enable_module_cfdav"},
|
||||
{Module: "sub", DisplayName: "订阅管理", FlagKey: "enable_module_sub"},
|
||||
}
|
||||
for i := range items {
|
||||
var ff models.FeatureFlag
|
||||
@@ -1740,6 +2009,10 @@ func (s *WebServer) handleModuleToggle(c *gin.Context) {
|
||||
flagKey = "enable_module_cf"
|
||||
case "mail":
|
||||
flagKey = "enable_module_mail"
|
||||
case "cfdav":
|
||||
flagKey = "enable_module_cfdav"
|
||||
case "sub":
|
||||
flagKey = "enable_module_sub"
|
||||
default:
|
||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module")
|
||||
return
|
||||
@@ -1797,7 +2070,7 @@ func (s *WebServer) handleDashboardOverview(c *gin.Context) {
|
||||
Module string `json:"module"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}}
|
||||
mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}, {Module: "cfdav"}, {Module: "sub"}}
|
||||
for i := range mods {
|
||||
flagKey := "enable_module_" + mods[i].Module
|
||||
var ff models.FeatureFlag
|
||||
@@ -1900,7 +2173,7 @@ func (s *WebServer) handleDashboardSummary(c *gin.Context) {
|
||||
s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed)
|
||||
s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success)
|
||||
|
||||
mods := map[string]bool{"cpa": false, "cf": false, "mail": false}
|
||||
mods := map[string]bool{"cpa": false, "cf": false, "mail": false, "cfdav": false, "sub": false}
|
||||
for k := range mods {
|
||||
var ff models.FeatureFlag
|
||||
if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil {
|
||||
|
||||
Reference in New Issue
Block a user