Files
at-container-registry/pkg/hold/config.go
T

332 lines
12 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
// via Viper with YAML file support and environment variable overrides.
package hold
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"time"
"github.com/distribution/distribution/v3/configuration"
"github.com/spf13/viper"
"atcr.io/pkg/config"
"atcr.io/pkg/hold/quota"
)
// Config represents the hold service configuration
type Config struct {
Version string `yaml:"version" comment:"Configuration format version."`
LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."`
LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."`
Storage StorageConfig `yaml:"storage" comment:"S3-compatible blob storage settings."`
Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."`
Registration RegistrationConfig `yaml:"registration" comment:"Auto-registration and bootstrap settings."`
Database DatabaseConfig `yaml:"database" comment:"Embedded PDS database settings."`
Admin AdminConfig `yaml:"admin" comment:"Admin panel settings."`
Quota quota.Config `yaml:"quota" comment:"Storage quota tiers. Empty disables quota enforcement."`
Scanner ScannerConfig `yaml:"scanner" comment:"Vulnerability scanner settings. Empty disables scanning."`
configPath string `yaml:"-"` // internal: path to YAML file for subsystem config loading
}
// ConfigPath returns the path to the YAML configuration file used to load this config.
// Subsystems (e.g. billing) use this to re-read the same file for extended fields.
func (c *Config) ConfigPath() string { return c.configPath }
// AdminConfig defines admin panel settings
type AdminConfig struct {
// Enable the web-based admin panel.
Enabled bool `yaml:"enabled" comment:"Enable the web-based admin panel for crew and storage management."`
}
// RegistrationConfig defines auto-registration settings
type RegistrationConfig struct {
// DID of the hold captain.
OwnerDID string `yaml:"owner_did" comment:"DID of the hold captain. If set, auto-creates captain and profile records on startup."`
// Allow any authenticated user to join as crew.
AllowAllCrew bool `yaml:"allow_all_crew" comment:"Create a wildcard crew record allowing any authenticated user to join."`
// URL to fetch avatar image from during bootstrap.
ProfileAvatarURL string `yaml:"profile_avatar_url" comment:"URL to fetch avatar image from during bootstrap."`
// Post to Bluesky when users push images.
EnableBlueskyPosts bool `yaml:"enable_bluesky_posts" comment:"Post to Bluesky when users push images. Synced to captain record on startup."`
// Deployment region, auto-detected from cloud metadata or S3 config.
Region string `yaml:"region" comment:"Deployment region, auto-detected from cloud metadata or S3 config."`
}
// StorageConfig holds S3 storage credentials and the internal distribution config.
type StorageConfig struct {
// S3-compatible access key.
AccessKey string `yaml:"access_key" comment:"S3-compatible access key (AWS, Storj, Minio, UpCloud)."`
// S3-compatible secret key.
SecretKey string `yaml:"secret_key" comment:"S3-compatible secret key."`
// S3 region.
Region string `yaml:"region" comment:"S3 region, e.g. \"us-east-1\". Used for request signing."`
// S3 bucket name.
Bucket string `yaml:"bucket" comment:"S3 bucket for blob storage (REQUIRED). Must already exist."`
// Custom S3 endpoint for non-AWS providers.
Endpoint string `yaml:"endpoint" comment:"Custom S3 endpoint for non-AWS providers (e.g. \"https://gateway.storjshare.io\")."`
// Internal distribution storage config, built from the above fields.
distStorage configuration.Storage `yaml:"-"`
}
// Type returns the storage driver type name (always "s3").
func (s StorageConfig) Type() string {
return "s3"
}
// Parameters returns the distribution driver parameters.
func (s StorageConfig) Parameters() configuration.Parameters {
if s.distStorage != nil {
if params, ok := s.distStorage["s3"]; ok {
return params
}
}
return nil
}
// ServerConfig defines server settings
type ServerConfig struct {
// Listen address for the HTTP server.
Addr string `yaml:"addr" comment:"Listen address, e.g. \":8080\" or \"0.0.0.0:8080\"."`
// Externally reachable URL used for did:web identity.
PublicURL string `yaml:"public_url" comment:"Externally reachable URL used for did:web identity (REQUIRED), e.g. \"https://hold.example.com\"."`
// Allow unauthenticated blob reads.
Public bool `yaml:"public" comment:"Allow unauthenticated blob reads. If false, readers need crew membership."`
// Use localhost for OAuth redirects during development.
TestMode bool `yaml:"test_mode" comment:"Use localhost for OAuth redirects during development."`
// Request crawl from this relay on startup.
RelayEndpoint string `yaml:"relay_endpoint" comment:"Request crawl from this relay on startup to make the embedded PDS discoverable."`
// ReadTimeout for HTTP requests.
ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."`
// WriteTimeout for HTTP requests.
WriteTimeout time.Duration `yaml:"write_timeout" comment:"Write timeout for HTTP requests."`
}
// ScannerConfig defines vulnerability scanner settings
type ScannerConfig struct {
// Shared secret for scanner WebSocket authentication. Empty disables scanning.
Secret string `yaml:"secret" comment:"Shared secret for scanner WebSocket auth. Empty disables scanning."`
}
// DatabaseConfig defines embedded PDS database settings
type DatabaseConfig struct {
// Directory for the embedded PDS database.
Path string `yaml:"path" comment:"Directory for the embedded PDS database (carstore + SQLite)."`
// PDS signing key path.
KeyPath string `yaml:"key_path" comment:"PDS signing key path. Defaults to {database.path}/signing.key."`
}
// setHoldDefaults registers all default values on the given Viper instance.
func setHoldDefaults(v *viper.Viper) {
v.SetDefault("version", "0.1")
v.SetDefault("log_level", "info")
// Server defaults
v.SetDefault("server.addr", ":8080")
v.SetDefault("server.public_url", "")
v.SetDefault("server.public", false)
v.SetDefault("server.test_mode", false)
v.SetDefault("server.relay_endpoint", "")
v.SetDefault("server.read_timeout", "5m")
v.SetDefault("server.write_timeout", "5m")
// Registration defaults
v.SetDefault("registration.owner_did", "")
v.SetDefault("registration.allow_all_crew", false)
v.SetDefault("registration.profile_avatar_url", "https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE")
v.SetDefault("registration.enable_bluesky_posts", false)
// Database defaults
v.SetDefault("database.path", "/var/lib/atcr-hold")
v.SetDefault("database.key_path", "")
// Admin defaults
v.SetDefault("admin.enabled", false)
// Storage defaults
v.SetDefault("storage.access_key", "")
v.SetDefault("storage.secret_key", "")
v.SetDefault("storage.region", "us-east-1")
v.SetDefault("storage.bucket", "")
v.SetDefault("storage.endpoint", "")
// Scanner defaults
v.SetDefault("scanner.secret", "")
// Log shipper defaults
v.SetDefault("log_shipper.batch_size", 100)
v.SetDefault("log_shipper.flush_interval", "5s")
}
// DefaultConfig returns a Config populated with all default values (no validation).
func DefaultConfig() *Config {
v := config.NewViper("HOLD", "")
setHoldDefaults(v)
cfg := &Config{}
_ = v.Unmarshal(cfg, config.UnmarshalOption())
return cfg
}
// ExampleYAML returns a fully-commented YAML configuration with default values.
// Includes example quota tiers for documentation (defaults have quotas disabled).
func ExampleYAML() ([]byte, error) {
cfg := DefaultConfig()
// Populate example quota tiers so operators see the structure
cfg.Quota = quota.Config{
Tiers: map[string]quota.TierConfig{
"deckhand": {Quota: "5GB"},
"bosun": {Quota: "50GB"},
"quartermaster": {Quota: "100GB"},
},
Defaults: quota.DefaultsConfig{
NewCrewTier: "deckhand",
},
}
return config.MarshalCommentedYAML("ATCR Hold Service Configuration", cfg)
}
// LoadConfig builds a complete configuration using Viper layered loading:
// defaults -> YAML file -> environment variables.
// yamlPath is optional; empty string means env-only (backward compatible).
func LoadConfig(yamlPath string) (*Config, error) {
v := config.NewViper("HOLD", yamlPath)
// Set defaults
setHoldDefaults(v)
// Bind standard AWS/S3 env vars to storage fields
_ = v.BindEnv("storage.access_key", "AWS_ACCESS_KEY_ID")
_ = v.BindEnv("storage.secret_key", "AWS_SECRET_ACCESS_KEY")
_ = v.BindEnv("storage.region", "AWS_REGION")
_ = v.BindEnv("storage.bucket", "S3_BUCKET")
_ = v.BindEnv("storage.endpoint", "S3_ENDPOINT")
// Unmarshal into config struct
cfg := &Config{}
if err := v.Unmarshal(cfg, config.UnmarshalOption()); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validation
if cfg.Server.PublicURL == "" {
return nil, fmt.Errorf("server.public_url is required (env: HOLD_SERVER_PUBLIC_URL)")
}
if cfg.Storage.Bucket == "" {
return nil, fmt.Errorf("storage.bucket is required (env: S3_BUCKET) - S3 is the only supported storage backend")
}
// Post-load: derive key path from database path if not set
if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
}
// Store config path for subsystem config loading (e.g. billing)
cfg.configPath = yamlPath
// Build distribution storage config from struct fields
cfg.Storage.distStorage = buildStorageConfigFromFields(cfg.Storage)
// Detect region from cloud metadata or S3 config
if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil {
cfg.Registration.Region = meta.Region
slog.Info("Detected cloud metadata", "region", meta.Region)
} else {
cfg.Registration.Region = cfg.Storage.Region
slog.Info("Using S3 region", "region", cfg.Registration.Region)
}
return cfg, nil
}
// buildStorageConfigFromFields creates S3 storage configuration from StorageConfig fields.
func buildStorageConfigFromFields(sc StorageConfig) configuration.Storage {
params := make(map[string]any)
params["accesskey"] = sc.AccessKey
params["secretkey"] = sc.SecretKey
params["region"] = sc.Region
params["bucket"] = sc.Bucket
if sc.Endpoint != "" {
params["regionendpoint"] = sc.Endpoint
}
storageCfg := configuration.Storage{}
storageCfg["s3"] = configuration.Parameters(params)
return storageCfg
}
// 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
}