Files

223 lines
8.4 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 (
"fmt"
"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"`
Scanner ScannerConfig `yaml:"scanner"`
}
// 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"`
// 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"`
}
// ScannerConfig defines SBOM and vulnerability scanning settings
type ScannerConfig struct {
// Enabled controls whether SBOM scanning is enabled (from env: HOLD_SBOM_ENABLED)
Enabled bool `yaml:"enabled"`
// Workers is the number of concurrent scan workers (from env: HOLD_SBOM_WORKERS)
Workers int `yaml:"workers"`
// VulnEnabled controls whether vulnerability scanning is enabled (from env: HOLD_VULN_ENABLED)
VulnEnabled bool `yaml:"vuln_enabled"`
// VulnDBPath is the path to store Grype vulnerability database (from env: HOLD_VULN_DB_PATH)
// Defaults to {DatabasePath}/grype-db
VulnDBPath string `yaml:"vuln_db_path"`
// VulnDBUpdateInterval is how often to update the vulnerability database (from env: HOLD_VULN_DB_UPDATE_INTERVAL)
// Format: duration string (e.g., "24h", "1h30m")
// Defaults to 24 hours
VulnDBUpdateInterval time.Duration `yaml:"vuln_db_update_interval"`
}
// 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.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")
}
// Scanner configuration (optional - enables SBOM/vulnerability scanning)
cfg.Scanner.Enabled = os.Getenv("HOLD_SBOM_ENABLED") == "true"
cfg.Scanner.Workers = 2 // Default
if workersStr := os.Getenv("HOLD_SBOM_WORKERS"); workersStr != "" {
var workersInt int
if _, err := fmt.Sscanf(workersStr, "%d", &workersInt); err == nil && workersInt > 0 {
cfg.Scanner.Workers = workersInt
}
}
cfg.Scanner.VulnEnabled = os.Getenv("HOLD_VULN_ENABLED") == "true"
cfg.Scanner.VulnDBPath = getEnvOrDefault("HOLD_VULN_DB_PATH", filepath.Join(cfg.Database.Path, "grype-db"))
cfg.Scanner.VulnDBUpdateInterval = 24 * time.Hour // Default
if intervalStr := os.Getenv("HOLD_VULN_DB_UPDATE_INTERVAL"); intervalStr != "" {
if interval, err := time.ParseDuration(intervalStr); err == nil {
cfg.Scanner.VulnDBUpdateInterval = interval
}
}
// 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
}