mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
349 lines
15 KiB
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
|
|
}
|