Ctrl K
ComunidadGuías…

cloudflare_runtime.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"strings"
	"time"

	"netgoat.xyz/agent/internal/cloudflare"
	"netgoat.xyz/agent/internal/config"
)

const (
	cloudflareAPITokenEnvironment           = "CLOUDFLARE_API_TOKEN"
	cloudflareReconciliationDeadline        = 2 * time.Minute
	maxCloudflareReconciliationOperations   = 32
	maxCloudflareReconciliationPayloadBytes = 64 << 10
	maxCloudflareAccessJWKSCacheSeconds     = 24 * 60 * 60
	maxCloudflareAccessClockSkewSeconds     = 5 * 60
	maxCloudflareAccessFetchTimeoutSeconds  = 30
	maxCloudflareAPIRequestTimeoutSeconds   = 60
)

type cloudflareReconciliationClient interface {
	ReconcileDNSRecord(context.Context, string, string, any) (cloudflare.APIResult, error)
	DeleteDNSRecord(context.Context, string, string) (cloudflare.APIResult, error)
	CreateTunnel(context.Context, any) (cloudflare.APIResult, error)
	ReconcileTunnel(context.Context, string, any) (cloudflare.APIResult, error)
	DeleteTunnel(context.Context, string) (cloudflare.APIResult, error)
}

var newCloudflareAPIClient = func(settings cloudflare.APIConfig) (cloudflareReconciliationClient, error) {
	return cloudflare.NewAPIClient(settings)
}

// configureCloudflareAccess constructs the request verifier without fetching
// keys. The validator fetches and caches JWKS material only when an assertion
// arrives, and its middleware rejects that request if verification fails.
func configureCloudflareAccess(cfg *config.Config) (*cloudflare.AccessValidator, error) {
	settings, err := cloudflareAccessSettingsFromConfig(cfg)
	if err != nil {
		return nil, err
	}
	if !settings.Enabled {
		return nil, nil
	}
	validator, err := cloudflare.NewAccessValidator(settings)
	if err != nil {
		return nil, fmt.Errorf("configure Cloudflare Access: %w", err)
	}
	return validator, nil
}

func cloudflareAccessSettingsFromConfig(cfg *config.Config) (cloudflare.AccessConfig, error) {
	if cfg == nil || !cfg.Cloudflare.Access.Enabled {
		return cloudflare.AccessConfig{}, nil
	}

	access := cfg.Cloudflare.Access
	cacheTTL, err := cloudflareDurationFromSeconds("cloudflare.access.jwks_cache_seconds", access.JWKSCacheSeconds, maxCloudflareAccessJWKSCacheSeconds)
	if err != nil {
		return cloudflare.AccessConfig{}, err
	}
	clockSkew, err := cloudflareDurationFromSeconds("cloudflare.access.clock_skew_seconds", access.ClockSkewSeconds, maxCloudflareAccessClockSkewSeconds)
	if err != nil {
		return cloudflare.AccessConfig{}, err
	}
	fetchTimeout, err := cloudflareDurationFromSeconds("cloudflare.access.fetch_timeout_seconds", access.FetchTimeoutSeconds, maxCloudflareAccessFetchTimeoutSeconds)
	if err != nil {
		return cloudflare.AccessConfig{}, err
	}

	settings := cloudflare.AccessConfig{
		Enabled:      true,
		Issuer:       access.Issuer,
		Audience:     append([]string(nil), access.Audience...),
		JWKSURL:      access.JWKSURL,
		Header:       access.Header,
		Cookie:       access.Cookie,
		CacheTTL:     cacheTTL,
		ClockSkew:    clockSkew,
		FetchTimeout: fetchTimeout,
	}
	if err := settings.Validate(); err != nil {
		return cloudflare.AccessConfig{}, fmt.Errorf("invalid cloudflare.access configuration: %w", err)
	}
	return settings, nil
}

type cloudflareReconciliationPlan struct {
	apiConfig cloudflare.APIConfig
	dns       []cloudflareDNSOperation
	tunnels   []cloudflareTunnelOperation
}

type cloudflareDNSOperation struct {
	zoneID   string
	recordID string
	delete   bool
	desired  map[string]any
}

type cloudflareTunnelOperation struct {
	tunnelID string
	delete   bool
	desired  map[string]any
}

// cloudflareReconciliationPlanFromConfig validates a finite, explicit startup
// plan before any API request is made. Its default dry-run behavior ensures a
// config review cannot modify Cloudflare until dry_run is explicitly false.
func cloudflareReconciliationPlanFromConfig(cfg *config.Config) (*cloudflareReconciliationPlan, error) {
	if cfg == nil || !cfg.Cloudflare.Reconciliation.Enabled {
		return nil, nil
	}

	reconciliation := cfg.Cloudflare.Reconciliation
	operations := len(reconciliation.DNSRecords) + len(reconciliation.Tunnels)
	if operations == 0 {
		return nil, errors.New("cloudflare.reconciliation.enabled requires at least one DNS record or tunnel")
	}
	if operations > maxCloudflareReconciliationOperations {
		return nil, fmt.Errorf("cloudflare reconciliation has %d operations; maximum is %d", operations, maxCloudflareReconciliationOperations)
	}

	requestTimeout, err := cloudflareDurationFromSeconds(
		"cloudflare.reconciliation.request_timeout_seconds",
		reconciliation.RequestTimeoutSeconds,
		maxCloudflareAPIRequestTimeoutSeconds,
	)
	if err != nil {
		return nil, err
	}
	token := strings.TrimSpace(os.Getenv(cloudflareAPITokenEnvironment))
	if token == "" {
		return nil, fmt.Errorf("%s must be set when cloudflare.reconciliation is enabled", cloudflareAPITokenEnvironment)
	}
	dryRun := true
	if reconciliation.DryRun != nil {
		dryRun = *reconciliation.DryRun
	}
	apiConfig := cloudflare.APIConfig{
		Enabled:        true,
		APIToken:       token,
		AccountID:      strings.TrimSpace(reconciliation.AccountID),
		DryRun:         dryRun,
		RequestTimeout: requestTimeout,
	}
	if err := apiConfig.Validate(); err != nil {
		return nil, fmt.Errorf("invalid cloudflare.reconciliation configuration: %w", err)
	}

	plan := &cloudflareReconciliationPlan{
		apiConfig: apiConfig,
		dns:       make([]cloudflareDNSOperation, 0, len(reconciliation.DNSRecords)),
		tunnels:   make([]cloudflareTunnelOperation, 0, len(reconciliation.Tunnels)),
	}
	for index, record := range reconciliation.DNSRecords {
		zoneID := strings.TrimSpace(record.ZoneID)
		recordID := strings.TrimSpace(record.RecordID)
		if zoneID == "" {
			return nil, fmt.Errorf("cloudflare DNS record %d is missing zone_id", index+1)
		}
		if record.Delete {
			if recordID == "" {
				return nil, fmt.Errorf("cloudflare DNS record %d deletion requires record_id", index+1)
			}
			if len(record.Record) != 0 {
				return nil, fmt.Errorf("cloudflare DNS record %d cannot set record when delete is true", index+1)
			}
			plan.dns = append(plan.dns, cloudflareDNSOperation{zoneID: zoneID, recordID: recordID, delete: true})
			continue
		}
		desired, err := cloudflareDesiredObject(fmt.Sprintf("cloudflare DNS record %d", index+1), record.Record)
		if err != nil {
			return nil, err
		}
		plan.dns = append(plan.dns, cloudflareDNSOperation{zoneID: zoneID, recordID: recordID, desired: desired})
	}

	if len(reconciliation.Tunnels) > 0 && apiConfig.AccountID == "" {
		return nil, errors.New("cloudflare.reconciliation.account_id is required when tunnels are configured")
	}
	for index, tunnel := range reconciliation.Tunnels {
		tunnelID := strings.TrimSpace(tunnel.TunnelID)
		if tunnel.Delete {
			if tunnelID == "" {
				return nil, fmt.Errorf("cloudflare tunnel %d deletion requires tunnel_id", index+1)
			}
			if len(tunnel.Tunnel) != 0 {
				return nil, fmt.Errorf("cloudflare tunnel %d cannot set tunnel when delete is true", index+1)
			}
			plan.tunnels = append(plan.tunnels, cloudflareTunnelOperation{tunnelID: tunnelID, delete: true})
			continue
		}
		desired, err := cloudflareDesiredObject(fmt.Sprintf("cloudflare tunnel %d", index+1), tunnel.Tunnel)
		if err != nil {
			return nil, err
		}
		plan.tunnels = append(plan.tunnels, cloudflareTunnelOperation{tunnelID: tunnelID, desired: desired})
	}
	if err := validateCloudflareReconciliationPlan(plan); err != nil {
		return nil, err
	}
	return plan, nil
}

func cloudflareDesiredObject(name string, source map[string]any) (map[string]any, error) {
	if len(source) == 0 {
		return nil, fmt.Errorf("%s requires a non-empty declarative payload", name)
	}
	encoded, err := json.Marshal(source)
	if err != nil {
		return nil, fmt.Errorf("%s payload is not valid JSON: %w", name, err)
	}
	if len(encoded) > maxCloudflareReconciliationPayloadBytes {
		return nil, fmt.Errorf("%s payload exceeds %d bytes", name, maxCloudflareReconciliationPayloadBytes)
	}
	var copied map[string]any
	if err := json.Unmarshal(encoded, &copied); err != nil || len(copied) == 0 {
		return nil, fmt.Errorf("%s payload is not a JSON object", name)
	}
	return copied, nil
}

// validateCloudflareReconciliationPlan exercises the API client's identifier
// and request-payload validation against a forced dry-run client. It performs
// no network I/O, but catches malformed IDs before an earlier non-dry-run
// operation could be applied.
func validateCloudflareReconciliationPlan(plan *cloudflareReconciliationPlan) error {
	if plan == nil {
		return nil
	}
	settings := plan.apiConfig
	settings.DryRun = true
	client, err := cloudflare.NewAPIClient(settings)
	if err != nil {
		return fmt.Errorf("validate Cloudflare reconciliation client: %w", err)
	}
	for index, operation := range plan.dns {
		if operation.delete {
			_, err = client.DeleteDNSRecord(context.Background(), operation.zoneID, operation.recordID)
		} else {
			_, err = client.ReconcileDNSRecord(context.Background(), operation.zoneID, operation.recordID, operation.desired)
		}
		if err != nil {
			return fmt.Errorf("invalid Cloudflare DNS record %d: %w", index+1, err)
		}
	}
	for index, operation := range plan.tunnels {
		if operation.delete {
			_, err = client.DeleteTunnel(context.Background(), operation.tunnelID)
		} else if operation.tunnelID == "" {
			_, err = client.CreateTunnel(context.Background(), operation.desired)
		} else {
			_, err = client.ReconcileTunnel(context.Background(), operation.tunnelID, operation.desired)
		}
		if err != nil {
			return fmt.Errorf("invalid Cloudflare tunnel %d: %w", index+1, err)
		}
	}
	return nil
}

// reconcileCloudflare applies the already bounded plan exactly once. It does
// not schedule retries or make background changes; a restart is required for
// another reconciliation run.
func reconcileCloudflare(ctx context.Context, cfg *config.Config) ([]cloudflare.APIResult, error) {
	plan, err := cloudflareReconciliationPlanFromConfig(cfg)
	if err != nil || plan == nil {
		return nil, err
	}
	client, err := newCloudflareAPIClient(plan.apiConfig)
	if err != nil {
		return nil, fmt.Errorf("create Cloudflare API client: %w", err)
	}
	if ctx == nil {
		ctx = context.Background()
	}

	results := make([]cloudflare.APIResult, 0, len(plan.dns)+len(plan.tunnels))
	for index, operation := range plan.dns {
		var result cloudflare.APIResult
		if operation.delete {
			result, err = client.DeleteDNSRecord(ctx, operation.zoneID, operation.recordID)
		} else {
			result, err = client.ReconcileDNSRecord(ctx, operation.zoneID, operation.recordID, operation.desired)
		}
		if err != nil {
			return results, fmt.Errorf("reconcile Cloudflare DNS record %d: %w", index+1, err)
		}
		results = append(results, result)
	}
	for index, operation := range plan.tunnels {
		var result cloudflare.APIResult
		if operation.delete {
			result, err = client.DeleteTunnel(ctx, operation.tunnelID)
		} else if operation.tunnelID == "" {
			result, err = client.CreateTunnel(ctx, operation.desired)
		} else {
			result, err = client.ReconcileTunnel(ctx, operation.tunnelID, operation.desired)
		}
		if err != nil {
			return results, fmt.Errorf("reconcile Cloudflare tunnel %d: %w", index+1, err)
		}
		results = append(results, result)
	}
	return results, nil
}

func cloudflareDurationFromSeconds(name string, seconds, maximum int) (time.Duration, error) {
	if seconds < 0 || seconds > maximum {
		return 0, fmt.Errorf("%s must be between zero and %d seconds", name, maximum)
	}
	return time.Duration(seconds) * time.Second, nil
}