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 }