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

349 lines
15 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 (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
"atcr.io/pkg/config"
"atcr.io/pkg/hold/gc"
"atcr.io/pkg/hold/quota"
)
// URLFromDIDWeb converts a did:web identifier to an HTTPS URL.
// This is the inverse of the did:web spec encoding:
//
// "did:web:atcr.io" → "https://atcr.io"
// "did:web:localhost%3A8080" → "https://localhost:8080"
//
// Returns empty string for non-did:web identifiers.
func URLFromDIDWeb(did string) string {
if !strings.HasPrefix(did, "did:web:") {
return ""
}
host := strings.TrimPrefix(did, "did:web:")
// Per did:web spec, %3A encodes port colon
host = strings.ReplaceAll(host, "%3A", ":")
return "https://" + host
}
// 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."`
GC gc.Config `yaml:"gc" comment:"Garbage collection 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."`
// Bluesky profile display name. Synced on every startup.
ProfileDisplayName string `yaml:"profile_display_name" comment:"Bluesky profile display name. Synced on every startup."`
// Bluesky profile description. Synced on every startup.
ProfileDescription string `yaml:"profile_description" comment:"Bluesky profile description. Synced on every startup."`
// 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.
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\")."`
// CDN pull zone URL for presigned download URLs.
PullZone string `yaml:"pull_zone" comment:"CDN pull zone URL for downloads. When set, presigned GET/HEAD URLs use this host instead of the S3 endpoint. Uploads and API calls still use the S3 endpoint."`
}
// S3Params returns a params map suitable for s3.NewS3Service.
func (s StorageConfig) S3Params() map[string]any {
params := map[string]any{
"accesskey": s.AccessKey,
"secretkey": s.SecretKey,
"region": s.Region,
"bucket": s.Bucket,
}
if s.Endpoint != "" {
params["regionendpoint"] = s.Endpoint
params["forcepathstyle"] = true
}
if s.PullZone != "" {
params["pullzone"] = s.PullZone
}
return params
}
// 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."`
// DID of successor hold for migration.
Successor string `yaml:"successor" comment:"DID of successor hold for migration. Appview redirects all requests to the successor."`
// 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."`
// DID of the appview this hold is managed by. Resolved via did:web for URL and public key discovery.
AppviewDID string `yaml:"appview_did" comment:"DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key."`
// 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."`
}
// AppviewURL derives the appview base URL from AppviewDID.
func (s ServerConfig) AppviewURL() string {
return URLFromDIDWeb(s.AppviewDID)
}
// 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."`
// Minimum interval between re-scans of the same manifest. 0 disables proactive scanning.
RescanInterval time.Duration `yaml:"rescan_interval" comment:"Minimum interval between re-scans of the same manifest. When set, the hold proactively scans manifests when the scanner is idle. Default: 168h (7 days). Set to 0 to disable."`
}
// 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."`
// DID method for hold identity: "web" (default) or "plc".
DIDMethod string `yaml:"did_method" comment:"DID method: 'web' (default, derived from public_url) or 'plc' (registered with PLC directory)."`
// Explicit DID for this hold. Used for recovery/migration with did:plc.
DID string `yaml:"did" comment:"Explicit DID for this hold. If set with did_method 'plc', adopts this identity instead of creating new. Use for recovery/migration."`
// PLC directory URL. Only used when did_method is "plc".
PLCDirectoryURL string `yaml:"plc_directory_url" comment:"PLC directory URL. Only used when did_method is 'plc'. Default: https://plc.directory"`
// Rotation key for did:plc (multibase-encoded private key, K-256 or P-256).
RotationKey string `yaml:"rotation_key" comment:"Rotation key for did:plc in multibase format (starting with 'z'). Generate with: goat key generate. Supports K-256 and P-256 curves. Controls DID identity (separate from signing key)."`
// libSQL sync URL for embedded replica mode.
LibsqlSyncURL string `yaml:"libsql_sync_url" comment:"libSQL sync URL (libsql://...). Works with Turso cloud, Bunny DB, or self-hosted libsql-server. Leave empty for local-only SQLite."`
// Auth token for libSQL sync.
LibsqlAuthToken string `yaml:"libsql_auth_token" comment:"Auth token for libSQL sync. Required if libsql_sync_url is set."`
// How often to sync with remote libSQL server.
LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"How often to sync with remote libSQL server. Default: 60s."`
}
// 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.successor", "")
v.SetDefault("server.test_mode", false)
v.SetDefault("server.relay_endpoint", "")
v.SetDefault("server.appview_did", "did:web:atcr.io")
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://atcr.io/web-app-manifest-192x192.png")
v.SetDefault("registration.profile_display_name", "Cargo Hold")
v.SetDefault("registration.profile_description", "ahoy from the cargo hold")
v.SetDefault("registration.enable_bluesky_posts", false)
// Database defaults
v.SetDefault("database.path", "/var/lib/atcr-hold")
v.SetDefault("database.key_path", "")
v.SetDefault("database.did_method", "web")
v.SetDefault("database.did", "")
v.SetDefault("database.plc_directory_url", "https://plc.directory")
v.SetDefault("database.rotation_key", "")
v.SetDefault("database.libsql_sync_url", "")
v.SetDefault("database.libsql_auth_token", "")
v.SetDefault("database.libsql_sync_interval", "60s")
// Admin defaults
v.SetDefault("admin.enabled", true)
// 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", "")
v.SetDefault("storage.pull_zone", "")
// GC defaults
v.SetDefault("gc.enabled", false)
// Scanner defaults
v.SetDefault("scanner.secret", "")
v.SetDefault("scanner.rescan_interval", "168h") // 7 days
// 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: []quota.TierConfig{
{Name: "deckhand", Quota: "5GB"},
{Name: "bosun", Quota: "50GB", ScanOnPush: true},
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true},
},
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")
_ = v.BindEnv("storage.pull_zone", "S3_PULL_ZONE")
// Bind legacy GC env vars (backward compat)
_ = v.BindEnv("gc.enabled", "GC_ENABLED")
// 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 paths from database path if not set
if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
}
// Validate DID method
if cfg.Database.DIDMethod != "" && cfg.Database.DIDMethod != "web" && cfg.Database.DIDMethod != "plc" {
return nil, fmt.Errorf("database.did_method must be 'web' or 'plc', got %q", cfg.Database.DIDMethod)
}
// Store config path for subsystem config loading (e.g. billing)
cfg.configPath = yamlPath
// 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
}