mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 15:56:58 +00:00
332 lines
12 KiB
Go
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
|
|
}
|