194 lines
5.0 KiB
Go
194 lines
5.0 KiB
Go
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
|
|
}
|