Files
at-container-registry/pkg/hold/config.go
2025-12-18 12:29:20 -06:00

237 lines
8.2 KiB
Go

// Package hold implements the ATCR hold service, which provides BYOS
// (Bring Your Own Storage) functionality. It includes an embedded PDS for
// storing captain and crew records, generates presigned URLs for blob storage,
// and handles authorization based on crew membership. Configuration is loaded
// entirely from environment variables.
package hold
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"github.com/distribution/distribution/v3/configuration"
)
// Config represents the hold service configuration
type Config struct {
Version string `yaml:"version"`
LogLevel string `yaml:"log_level"`
Storage StorageConfig `yaml:"storage"`
Server ServerConfig `yaml:"server"`
Registration RegistrationConfig `yaml:"registration"`
Database DatabaseConfig `yaml:"database"`
}
// RegistrationConfig defines auto-registration settings
type RegistrationConfig struct {
// OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER)
// If set, auto-registration is enabled
OwnerDID string `yaml:"owner_did"`
// AllowAllCrew controls whether to create a wildcard crew record (from env: HOLD_ALLOW_ALL_CREW)
// If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users)
// If false, deletes the wildcard crew record if it exists
AllowAllCrew bool `yaml:"allow_all_crew"`
// ProfileAvatarURL is the URL to download the avatar image from (from env: HOLD_PROFILE_AVATAR)
// If set, the avatar will be downloaded and uploaded as a blob during bootstrap
ProfileAvatarURL string `yaml:"profile_avatar_url"`
// EnableBlueskyPosts controls whether to create Bluesky posts for manifest uploads (from env: HOLD_BLUESKY_POSTS_ENABLED)
// If true, creates posts when users push images
// Synced to captain record's enableBlueskyPosts field on startup
EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"`
}
// StorageConfig wraps distribution's storage configuration
type StorageConfig struct {
configuration.Storage `yaml:",inline"`
}
// ServerConfig defines server settings
type ServerConfig struct {
// Addr is the address to listen on (e.g., ":8080")
Addr string `yaml:"addr"`
// PublicURL is the public URL of this hold service (e.g., "https://hold.example.com")
PublicURL string `yaml:"public_url"`
// Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC)
Public bool `yaml:"public"`
// TestMode uses localhost for OAuth redirects while storing real URL in hold record (from env: TEST_MODE)
TestMode bool `yaml:"test_mode"`
// DisablePresignedURLs forces proxy mode even with S3 configured (for testing) (from env: DISABLE_PRESIGNED_URLS)
DisablePresignedURLs bool `yaml:"disable_presigned_urls"`
// RelayEndpoint is the ATProto relay URL to request crawl from on startup (from env: HOLD_RELAY_ENDPOINT)
// If empty, no crawl request is made. Default: https://bsky.network
RelayEndpoint string `yaml:"relay_endpoint"`
// ReadTimeout for HTTP requests
ReadTimeout time.Duration `yaml:"read_timeout"`
// WriteTimeout for HTTP requests
WriteTimeout time.Duration `yaml:"write_timeout"`
}
// DatabaseConfig defines embedded PDS database settings
type DatabaseConfig struct {
// Path is the directory path for carstore (from env: HOLD_DATABASE_DIR)
// If empty, embedded PDS is disabled
Path string `yaml:"path"`
// KeyPath is the path to the signing key (from env: HOLD_KEY_PATH)
// Defaults to {Path}/signing.key
KeyPath string `yaml:"key_path"`
}
// LoadConfigFromEnv loads all configuration from environment variables
func LoadConfigFromEnv() (*Config, error) {
cfg := &Config{
Version: "0.1",
}
// Logging configuration
cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info")
// Server configuration
cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080")
cfg.Server.PublicURL = os.Getenv("HOLD_PUBLIC_URL")
if cfg.Server.PublicURL == "" {
return nil, fmt.Errorf("HOLD_PUBLIC_URL is required")
}
cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true"
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
cfg.Server.DisablePresignedURLs = os.Getenv("DISABLE_PRESIGNED_URLS") == "true"
cfg.Server.RelayEndpoint = os.Getenv("HOLD_RELAY_ENDPOINT")
cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads
cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads
// Registration configuration (optional)
cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true"
cfg.Registration.ProfileAvatarURL = getEnvOrDefault("HOLD_PROFILE_AVATAR", "https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE")
cfg.Registration.EnableBlueskyPosts = os.Getenv("HOLD_BLUESKY_POSTS_ENABLED") == "true"
// Database configuration (optional - enables embedded PDS)
// Note: HOLD_DATABASE_DIR is a directory path, carstore creates db.sqlite3 inside it
cfg.Database.Path = getEnvOrDefault("HOLD_DATABASE_DIR", "/var/lib/atcr-hold")
cfg.Database.KeyPath = os.Getenv("HOLD_KEY_PATH")
if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
// Default: signing key in same directory as carstore
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
}
// Storage configuration - build from env vars based on storage type
storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
var err error
cfg.Storage, err = buildStorageConfig(storageType)
if err != nil {
return nil, fmt.Errorf("failed to build storage config: %w", err)
}
return cfg, nil
}
// buildStorageConfig creates storage configuration based on driver type
func buildStorageConfig(driver string) (StorageConfig, error) {
params := make(map[string]any)
switch driver {
case "s3":
// S3/Storj/Minio configuration from standard AWS env vars
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
region := getEnvOrDefault("AWS_REGION", "us-east-1")
bucket := os.Getenv("S3_BUCKET")
endpoint := os.Getenv("S3_ENDPOINT") // For Storj/Minio
if bucket == "" {
return StorageConfig{}, fmt.Errorf("S3_BUCKET is required for S3 storage")
}
params["accesskey"] = accessKey
params["secretkey"] = secretKey
params["region"] = region
params["bucket"] = bucket
if endpoint != "" {
params["regionendpoint"] = endpoint
}
case "filesystem":
// Filesystem configuration
rootDir := getEnvOrDefault("STORAGE_ROOT_DIR", "/var/lib/atcr/hold")
params["rootdirectory"] = rootDir
default:
return StorageConfig{}, fmt.Errorf("unsupported storage driver: %s", driver)
}
// Build distribution Storage config
storageCfg := configuration.Storage{}
storageCfg[driver] = configuration.Parameters(params)
return StorageConfig{Storage: storageCfg}, nil
}
// getEnvOrDefault gets an environment variable or returns a default value
func getEnvOrDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
// This makes the hold's PDS discoverable by the relay network.
func RequestCrawl(relayEndpoint, publicURL string) error {
if relayEndpoint == "" {
return nil // No relay configured, skip
}
// Extract hostname from public URL
parsed, err := url.Parse(publicURL)
if err != nil {
return fmt.Errorf("failed to parse public URL: %w", err)
}
hostname := parsed.Host
// Build the request URL
requestURL := relayEndpoint + "/xrpc/com.atproto.sync.requestCrawl"
// Create request body
body := map[string]string{"hostname": hostname}
bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
// Make the request
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("POST", requestURL, bytes.NewReader(bodyJSON))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("relay returned status %d", resp.StatusCode)
}
return nil
}