package v1 import ( "encoding/json" "fmt" "net/http" "strings" ) type cloudflareZone struct { ID string `json:"id"` Name string `json:"name"` } type cloudflarePermissions struct { Zone bool `json:"zone"` // can list zones DNS bool `json:"dns"` // can read/edit DNS records } type cloudflareVerifyResponse struct { TokenConfigured bool `json:"tokenConfigured"` TokenValid bool `json:"tokenValid"` TokenStatus string `json:"tokenStatus"` Zones []cloudflareZone `json:"zones"` RequiredZones []string `json:"requiredZones"` Permissions cloudflarePermissions `json:"permissions"` Error string `json:"error,omitempty"` } // CloudflareVerify checks whether the stored Cloudflare API token is valid // and returns the zones it can access. func (api *API) CloudflareVerify(w http.ResponseWriter, r *http.Request) { token := api.getCloudflareToken() if token == "" { respondJSON(w, http.StatusOK, cloudflareVerifyResponse{ TokenConfigured: false, }) return } resp := cloudflareVerifyResponse{TokenConfigured: true} tokenStatus, err := verifyCloudflareToken(token) if err != nil { resp.Error = err.Error() respondJSON(w, http.StatusOK, resp) return } resp.TokenStatus = tokenStatus resp.TokenValid = tokenStatus == "active" zones, err := listCloudflareZones(token) if err != nil { resp.Error = "Token valid but failed to list zones: " + err.Error() respondJSON(w, http.StatusOK, resp) return } resp.Zones = zones resp.Permissions.Zone = true // Test DNS access on the first available zone if len(zones) > 0 { resp.Permissions.DNS = testCloudflareDNSAccess(token, zones[0].ID) } // Derive required zones from registered domains resp.RequiredZones = api.getRequiredZones() respondJSON(w, http.StatusOK, resp) } // CloudflareGetZoneID returns the stored zone ID from secrets. func (api *API) CloudflareGetZoneID(w http.ResponseWriter, r *http.Request) { zoneID := api.getCloudflareZoneID() respondJSON(w, http.StatusOK, map[string]any{ "zoneId": zoneID, "configured": zoneID != "", }) } // CloudflareSetZoneID stores the zone ID in global secrets. func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) { var req struct { ZoneID string `json:"zoneId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } if err := api.secrets.SetSecret("cloudflare.zoneId", req.ZoneID); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save zone ID") return } respondMessage(w, http.StatusOK, "Zone ID saved") } // getRequiredZones extracts unique root zones from all registered domains. // e.g., "app.cloud.payne.io" → "payne.io" func (api *API) getRequiredZones() []string { doms, err := api.domains.List() if err != nil || len(doms) == 0 { return nil } seen := make(map[string]bool) var zones []string for _, dom := range doms { zone := rootZone(dom.DomainName) if zone != "" && !seen[zone] { seen[zone] = true zones = append(zones, zone) } } return zones } // rootZone extracts the registrable zone from a FQDN. // "app.cloud.payne.io" → "payne.io", "foo.example.com" → "example.com" func rootZone(domain string) string { parts := strings.Split(domain, ".") if len(parts) < 2 { return "" } return strings.Join(parts[len(parts)-2:], ".") } func verifyCloudflareToken(apiToken string) (string, error) { req, err := http.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var result struct { Result struct { Status string `json:"status"` } `json:"result"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } return result.Result.Status, nil } // testCloudflareDNSAccess checks if the token can list DNS records for a zone. func testCloudflareDNSAccess(apiToken, zoneID string) bool { req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?per_page=1", zoneID), nil) if err != nil { return false } req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { return false } defer resp.Body.Close() return resp.StatusCode == http.StatusOK } func listCloudflareZones(apiToken string) ([]cloudflareZone, error) { req, err := http.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/zones", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+apiToken) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var result struct { Result []cloudflareZone `json:"result"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } return result.Result, nil }