Files
openclaw 81939825d1 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 任务、渠道管理、审计页
2026-09-13 07:42:17 +08:00

225 lines
6.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}