/* CDN Client — BunnyCDN blob and manifest operations for Volt CAS. Handles pull (public, unauthenticated) and push (authenticated via AccessKey) to the BunnyCDN storage and pull-zone endpoints that back Stellarium. Copyright (c) Armored Gates LLC. All rights reserved. */ package cdn import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "gopkg.in/yaml.v3" ) // ── Defaults ───────────────────────────────────────────────────────────────── const ( DefaultBlobsURL = "https://blobs.3kb.io" DefaultManifestsURL = "https://manifests.3kb.io" DefaultRegion = "ny" ) // ── Manifest ───────────────────────────────────────────────────────────────── // Manifest represents a CAS build manifest as stored on the CDN. type Manifest struct { Name string `json:"name"` CreatedAt string `json:"created_at"` Objects map[string]string `json:"objects"` // relative path → sha256 hash } // ── Client ─────────────────────────────────────────────────────────────────── // Client handles blob upload/download to BunnyCDN. type Client struct { BlobsBaseURL string // pull-zone URL for blobs, e.g. https://blobs.3kb.io ManifestsBaseURL string // pull-zone URL for manifests, e.g. https://manifests.3kb.io StorageAPIKey string // BunnyCDN storage zone API key StorageZoneName string // BunnyCDN storage zone name Region string // BunnyCDN region, e.g. "ny" HTTPClient *http.Client } // ── CDN Config (from config.yaml) ──────────────────────────────────────────── // CDNConfig represents the cdn section of /etc/volt/config.yaml. type CDNConfig struct { BlobsURL string `yaml:"blobs_url"` ManifestsURL string `yaml:"manifests_url"` StorageAPIKey string `yaml:"storage_api_key"` StorageZone string `yaml:"storage_zone"` Region string `yaml:"region"` } // voltConfig is a minimal representation of the config file, just enough to // extract the cdn block. type voltConfig struct { CDN CDNConfig `yaml:"cdn"` } // ── Constructors ───────────────────────────────────────────────────────────── // NewClient creates a CDN client by reading config from /etc/volt/config.yaml // (if present) and falling back to environment variables. func NewClient() (*Client, error) { return NewClientFromConfigFile("") } // NewClientFromConfigFile creates a CDN client from a specific config file // path. If configPath is empty, it tries /etc/volt/config.yaml. func NewClientFromConfigFile(configPath string) (*Client, error) { var cfg CDNConfig // Try to load from config file. if configPath == "" { configPath = "/etc/volt/config.yaml" } if data, err := os.ReadFile(configPath); err == nil { var vc voltConfig if err := yaml.Unmarshal(data, &vc); err == nil { cfg = vc.CDN } } // Expand environment variable references in config values (e.g. "${BUNNY_API_KEY}"). cfg.BlobsURL = expandEnv(cfg.BlobsURL) cfg.ManifestsURL = expandEnv(cfg.ManifestsURL) cfg.StorageAPIKey = expandEnv(cfg.StorageAPIKey) cfg.StorageZone = expandEnv(cfg.StorageZone) cfg.Region = expandEnv(cfg.Region) // Override with environment variables if config values are empty. if cfg.BlobsURL == "" { cfg.BlobsURL = os.Getenv("VOLT_CDN_BLOBS_URL") } if cfg.ManifestsURL == "" { cfg.ManifestsURL = os.Getenv("VOLT_CDN_MANIFESTS_URL") } if cfg.StorageAPIKey == "" { cfg.StorageAPIKey = os.Getenv("BUNNY_API_KEY") } if cfg.StorageZone == "" { cfg.StorageZone = os.Getenv("BUNNY_STORAGE_ZONE") } if cfg.Region == "" { cfg.Region = os.Getenv("BUNNY_REGION") } // Apply defaults. if cfg.BlobsURL == "" { cfg.BlobsURL = DefaultBlobsURL } if cfg.ManifestsURL == "" { cfg.ManifestsURL = DefaultManifestsURL } if cfg.Region == "" { cfg.Region = DefaultRegion } return &Client{ BlobsBaseURL: strings.TrimRight(cfg.BlobsURL, "/"), ManifestsBaseURL: strings.TrimRight(cfg.ManifestsURL, "/"), StorageAPIKey: cfg.StorageAPIKey, StorageZoneName: cfg.StorageZone, Region: cfg.Region, HTTPClient: &http.Client{ Timeout: 5 * time.Minute, }, }, nil } // NewClientFromConfig creates a CDN client from explicit parameters. func NewClientFromConfig(blobsURL, manifestsURL, apiKey, zoneName string) *Client { if blobsURL == "" { blobsURL = DefaultBlobsURL } if manifestsURL == "" { manifestsURL = DefaultManifestsURL } return &Client{ BlobsBaseURL: strings.TrimRight(blobsURL, "/"), ManifestsBaseURL: strings.TrimRight(manifestsURL, "/"), StorageAPIKey: apiKey, StorageZoneName: zoneName, Region: DefaultRegion, HTTPClient: &http.Client{ Timeout: 5 * time.Minute, }, } } // ── Pull Operations (public, no auth) ──────────────────────────────────────── // PullBlob downloads a blob by hash from the CDN pull zone and verifies its // SHA-256 integrity. Returns the raw content. func (c *Client) PullBlob(hash string) ([]byte, error) { url := fmt.Sprintf("%s/sha256:%s", c.BlobsBaseURL, hash) resp, err := c.HTTPClient.Get(url) if err != nil { return nil, fmt.Errorf("cdn pull blob %s: %w", hash[:12], err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("cdn pull blob %s: HTTP %d", hash[:12], resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("cdn pull blob %s: read body: %w", hash[:12], err) } // Verify integrity. actualHash := sha256Hex(data) if actualHash != hash { return nil, fmt.Errorf("cdn pull blob %s: integrity check failed (got %s)", hash[:12], actualHash[:12]) } return data, nil } // PullManifest downloads a manifest by name from the CDN manifests pull zone. func (c *Client) PullManifest(name string) (*Manifest, error) { url := fmt.Sprintf("%s/v2/public/%s/latest.json", c.ManifestsBaseURL, name) resp, err := c.HTTPClient.Get(url) if err != nil { return nil, fmt.Errorf("cdn pull manifest %s: %w", name, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cdn pull manifest %s: not found", name) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("cdn pull manifest %s: HTTP %d", name, resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("cdn pull manifest %s: read body: %w", name, err) } var m Manifest if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("cdn pull manifest %s: unmarshal: %w", name, err) } return &m, nil } // BlobExists checks whether a blob exists on the CDN using a HEAD request. func (c *Client) BlobExists(hash string) (bool, error) { url := fmt.Sprintf("%s/sha256:%s", c.BlobsBaseURL, hash) req, err := http.NewRequest(http.MethodHead, url, nil) if err != nil { return false, fmt.Errorf("cdn blob exists %s: %w", hash[:12], err) } resp, err := c.HTTPClient.Do(req) if err != nil { return false, fmt.Errorf("cdn blob exists %s: %w", hash[:12], err) } resp.Body.Close() switch resp.StatusCode { case http.StatusOK: return true, nil case http.StatusNotFound: return false, nil default: return false, fmt.Errorf("cdn blob exists %s: HTTP %d", hash[:12], resp.StatusCode) } } // ── Push Operations (authenticated) ────────────────────────────────────────── // PushBlob uploads a blob to BunnyCDN storage. The hash must match the SHA-256 // of the data. Requires StorageAPIKey and StorageZoneName to be set. func (c *Client) PushBlob(hash string, data []byte) error { if c.StorageAPIKey == "" { return fmt.Errorf("cdn push blob: StorageAPIKey not configured") } if c.StorageZoneName == "" { return fmt.Errorf("cdn push blob: StorageZoneName not configured") } // Verify the hash matches the data. actualHash := sha256Hex(data) if actualHash != hash { return fmt.Errorf("cdn push blob: hash mismatch (expected %s, got %s)", hash[:12], actualHash[:12]) } // BunnyCDN storage upload endpoint. url := fmt.Sprintf("https://%s.storage.bunnycdn.com/%s/sha256:%s", c.Region, c.StorageZoneName, hash) req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(data))) if err != nil { return fmt.Errorf("cdn push blob %s: create request: %w", hash[:12], err) } req.Header.Set("AccessKey", c.StorageAPIKey) req.Header.Set("Content-Type", "application/octet-stream") req.ContentLength = int64(len(data)) resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("cdn push blob %s: %w", hash[:12], err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("cdn push blob %s: HTTP %d: %s", hash[:12], resp.StatusCode, string(body)) } return nil } // PushManifest uploads a manifest to BunnyCDN storage under the conventional // path: v2/public/{name}/latest.json func (c *Client) PushManifest(name string, manifest *Manifest) error { if c.StorageAPIKey == "" { return fmt.Errorf("cdn push manifest: StorageAPIKey not configured") } if c.StorageZoneName == "" { return fmt.Errorf("cdn push manifest: StorageZoneName not configured") } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return fmt.Errorf("cdn push manifest %s: marshal: %w", name, err) } // Upload to manifests storage zone path. url := fmt.Sprintf("https://%s.storage.bunnycdn.com/%s/v2/public/%s/latest.json", c.Region, c.StorageZoneName, name) req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(data))) if err != nil { return fmt.Errorf("cdn push manifest %s: create request: %w", name, err) } req.Header.Set("AccessKey", c.StorageAPIKey) req.Header.Set("Content-Type", "application/json") req.ContentLength = int64(len(data)) resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("cdn push manifest %s: %w", name, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("cdn push manifest %s: HTTP %d: %s", name, resp.StatusCode, string(body)) } return nil } // ── Helpers ────────────────────────────────────────────────────────────────── // sha256Hex computes the SHA-256 hex digest of data. func sha256Hex(data []byte) string { h := sha256.Sum256(data) return hex.EncodeToString(h[:]) } // expandEnv expands "${VAR}" patterns in a string. Only the ${VAR} form is // expanded (not $VAR) to avoid accidental substitution. func expandEnv(s string) string { if !strings.Contains(s, "${") { return s } return os.Expand(s, os.Getenv) }