- 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 任务、渠道管理、审计页
225 lines
6.1 KiB
Go
225 lines
6.1 KiB
Go
package cf
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os/exec"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"gorm.io/gorm"
|
||
"ops-assistant/internal/core/runbook"
|
||
)
|
||
|
||
// ModuleRef 由 module.go 注入 db 句柄,避免循环依赖
|
||
var ModuleRef struct {
|
||
DB *gorm.DB
|
||
}
|
||
|
||
// cfSettings 从 app_settings 读取 CF 凭据(与 executor 相同存储约定)
|
||
func cfSettings() (accountID, apiToken string) {
|
||
if ModuleRef.DB == nil {
|
||
return "", ""
|
||
}
|
||
type setting struct {
|
||
Key string
|
||
Value string
|
||
}
|
||
var rows []setting
|
||
if err := ModuleRef.DB.Table("app_settings").Where("key IN ?", []string{"cf_account_id", "cf_api_token"}).Find(&rows).Error; err != nil {
|
||
return "", ""
|
||
}
|
||
for _, r := range rows {
|
||
switch r.Key {
|
||
case "cf_account_id":
|
||
accountID = r.Value
|
||
case "cf_api_token":
|
||
apiToken = r.Value
|
||
}
|
||
}
|
||
return accountID, apiToken
|
||
}
|
||
|
||
// ResolveZoneIDIfDomain 判断 zoneIDOrName 是否为域名(非 32 位十六进制),
|
||
// 若是则调用 zones runbook 的同款 API 逻辑解析为 zone_id。
|
||
// 返回 (解析后的 zone_id, 是否发生了替换, error)。
|
||
func ResolveZoneIDIfDomain(zoneIDOrName, accountID, apiToken string) (string, bool, error) {
|
||
s := strings.TrimSpace(zoneIDOrName)
|
||
if s == "" || isHex32(s) {
|
||
return s, false, nil
|
||
}
|
||
zones, err := fetchZones(accountID, apiToken)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
for _, z := range zones {
|
||
if strings.EqualFold(z.Name, s) {
|
||
return z.ID, true, nil
|
||
}
|
||
}
|
||
return "", false, fmt.Errorf("找不到域名 %s 对应的 zone(账号下无此域名或无权限)", s)
|
||
}
|
||
|
||
func isHex32(s string) bool {
|
||
if len(s) != 32 {
|
||
return false
|
||
}
|
||
for _, c := range s {
|
||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
type cfZone struct {
|
||
ID string
|
||
Name string
|
||
}
|
||
|
||
func fetchZones(accountID, apiToken string) ([]cfZone, error) {
|
||
// 与 runbooks/cf_zones.yaml 相同的 API 调用方式
|
||
url := "https://api.cloudflare.com/client/v4/zones?account.id=" + accountID + "&per_page=200"
|
||
body, err := cfHTTPGet(url, apiToken)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var data struct {
|
||
Success bool `json:"success"`
|
||
Result []struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
} `json:"result"`
|
||
Errors []json.RawMessage `json:"errors"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &data); err != nil {
|
||
return nil, fmt.Errorf("zones 响应解析失败: %v", err)
|
||
}
|
||
if !data.Success {
|
||
return nil, fmt.Errorf("zones API 返回 success=false")
|
||
}
|
||
out := make([]cfZone, 0, len(data.Result))
|
||
for _, z := range data.Result {
|
||
out = append(out, cfZone{ID: z.ID, Name: z.Name})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func cfHTTPGet(url, apiToken string) (string, error) {
|
||
// 通过 SSH 在 hwsg 上用 python3+requests 发请求(与 runbook 网络环境一致)
|
||
script := fmt.Sprintf(
|
||
"python3 -c \"import os,requests; token=os.environ['CF_TOKEN']; resp=requests.get(os.environ['CF_URL'], headers={'Authorization':'Bearer '+token}, timeout=15); print(resp.text)\"",
|
||
)
|
||
out, err := sshExec(script, map[string]string{"CF_TOKEN": apiToken, "CF_URL": url})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func sshExec(script string, env map[string]string) (string, error) {
|
||
if ModuleRef.DB == nil {
|
||
return "", fmt.Errorf("db 未初始化,无法解析目标主机")
|
||
}
|
||
// 解析 hwsg 目标(与 runbook targets 相同约定)
|
||
resolved := runbook.ResolveTargetForModule(ModuleRef.DB, "hwsg")
|
||
if !resolved.Found {
|
||
return "", fmt.Errorf("未找到目标主机 hwsg")
|
||
}
|
||
envStr := ""
|
||
for k, v := range env {
|
||
envStr += fmt.Sprintf("%s=%q ", k, v)
|
||
}
|
||
args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, envStr + script}
|
||
cmd := exec.Command("ssh", args...)
|
||
out, err := cmd.Output()
|
||
return string(out), err
|
||
}
|
||
|
||
// 以下为格式化函数(ResultFormatter 用)
|
||
|
||
type dnsRecord struct {
|
||
ID string `json:"id"`
|
||
Type string `json:"type"`
|
||
Name string `json:"name"`
|
||
Content string `json:"content"`
|
||
Proxied bool `json:"proxied"`
|
||
TTL int `json:"ttl"`
|
||
Comment string `json:"comment"`
|
||
}
|
||
|
||
// FormatDNSList 把 cf_dns_list 的 stdout(CF API 原始 JSON)格式化为可读消息
|
||
func FormatDNSList(out string) (string, bool) {
|
||
var data struct {
|
||
Success bool `json:"success"`
|
||
Result []dnsRecord
|
||
}
|
||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
||
return "", false
|
||
}
|
||
if !data.Success {
|
||
return "", false
|
||
}
|
||
recs := data.Result
|
||
// 排序:类型 -> 名称
|
||
sort.Slice(recs, func(i, j int) bool {
|
||
if recs[i].Type != recs[j].Type {
|
||
return recs[i].Type < recs[j].Type
|
||
}
|
||
return recs[i].Name < recs[j].Name
|
||
})
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("📋 DNS 记录(共 %d 条):\n", len(recs)))
|
||
for _, r := range recs {
|
||
proxyMark := ""
|
||
if r.Proxied {
|
||
proxyMark = " 🟠"
|
||
}
|
||
sb.WriteString(fmt.Sprintf("• %s %s → %s%s\n", r.Type, r.Name, r.Content, proxyMark))
|
||
}
|
||
return strings.TrimRight(sb.String(), "\n"), true
|
||
}
|
||
|
||
// FormatZones 把 cf_zones 的 stdout 格式化为可读消息
|
||
func FormatZones(out string) (string, bool) {
|
||
var data struct {
|
||
Success bool `json:"success"`
|
||
Zones []struct {
|
||
Name string `json:"name"`
|
||
ID string `json:"id"`
|
||
} `json:"zones"`
|
||
}
|
||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
||
return "", false
|
||
}
|
||
if !data.Success {
|
||
return "", false
|
||
}
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("🌐 Zones(共 %d 个):\n", len(data.Zones)))
|
||
for _, z := range data.Zones {
|
||
sb.WriteString(fmt.Sprintf("• %s\n %s\n", z.Name, z.ID))
|
||
}
|
||
return strings.TrimRight(sb.String(), "\n"), true
|
||
}
|
||
|
||
// FormatWorkers 把 cf_workers_list 的 stdout 格式化为可读消息
|
||
func FormatWorkers(out string) (string, bool) {
|
||
var data struct {
|
||
Workers []string `json:"workers"`
|
||
}
|
||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
||
return "", false
|
||
}
|
||
if data.Workers == nil {
|
||
return "", false
|
||
}
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("⚙️ Workers(共 %d 个):\n", len(data.Workers)))
|
||
for _, w := range data.Workers {
|
||
sb.WriteString("• " + w + "\n")
|
||
}
|
||
return strings.TrimRight(sb.String(), "\n"), true
|
||
}
|