cloudflare_runtime.go
Rama main · réplica de lectura del repositorio original.
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
}