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

406 lines
14 KiB
Go

// Package appview implements the ATCR AppView component, which serves as the main
// OCI Distribution API server. It resolves identities (handle/DID to PDS endpoint),
// routes manifests to user's PDS, routes blobs to hold services, validates OAuth tokens,
// and issues registry JWTs. This package provides environment-based configuration,
// middleware registration, and HTTP server setup for the AppView service.
package appview
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"net/url"
"os"
"strconv"
"time"
"github.com/distribution/distribution/v3/configuration"
)
// Config represents the AppView service configuration
type Config struct {
Version string `yaml:"version"`
LogLevel string `yaml:"log_level"`
LogShipper LogShipperConfig `yaml:"log_shipper"`
Server ServerConfig `yaml:"server"`
UI UIConfig `yaml:"ui"`
Health HealthConfig `yaml:"health"`
Jetstream JetstreamConfig `yaml:"jetstream"`
Auth AuthConfig `yaml:"auth"`
CredentialHelper CredentialHelperConfig `yaml:"credential_helper"`
Legal LegalConfig `yaml:"legal"`
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
}
// LogShipperConfig defines remote log shipping settings
type LogShipperConfig struct {
// Backend selects the log shipping backend (from env: ATCR_LOG_SHIPPER_BACKEND)
// Valid values: "victoria", "opensearch", "loki", or empty to disable
Backend string `yaml:"backend"`
// URL is the remote log service endpoint (from env: ATCR_LOG_SHIPPER_URL)
URL string `yaml:"url"`
// BatchSize is the number of logs to batch before flushing (from env: ATCR_LOG_SHIPPER_BATCH_SIZE, default: 100)
BatchSize int `yaml:"batch_size"`
// FlushInterval is the max time between flushes (from env: ATCR_LOG_SHIPPER_FLUSH_INTERVAL, default: 5s)
FlushInterval time.Duration `yaml:"flush_interval"`
// Username for basic auth (from env: ATCR_LOG_SHIPPER_USERNAME, optional)
Username string `yaml:"username"`
// Password for basic auth (from env: ATCR_LOG_SHIPPER_PASSWORD, optional)
Password string `yaml:"password"`
}
// ServerConfig defines server settings
type ServerConfig struct {
// Addr is the HTTP listen address (from env: ATCR_HTTP_ADDR, default: ":5000")
Addr string `yaml:"addr"`
// BaseURL is the public URL for OAuth/JWT realm (from env: ATCR_BASE_URL)
// Auto-detected from Addr if not set
BaseURL string `yaml:"base_url"`
// DefaultHoldDID is the default hold DID for blob storage (from env: ATCR_DEFAULT_HOLD_DID)
// REQUIRED - e.g., "did:web:hold01.atcr.io"
DefaultHoldDID string `yaml:"default_hold_did"`
// TestMode enables HTTP for local DID resolution and transition:generic scope (from env: TEST_MODE)
TestMode bool `yaml:"test_mode"`
// OAuthKeyPath is the path to the OAuth client P-256 signing key (from env: ATCR_OAUTH_KEY_PATH, default: "/var/lib/atcr/oauth/client.key")
// Auto-generated on first run for production (non-localhost) deployments
OAuthKeyPath string `yaml:"oauth_key_path"`
// ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
// Shown in OAuth authorization screens
ClientName string `yaml:"client_name"`
}
// UIConfig defines web UI settings
type UIConfig struct {
// DatabasePath is the path to the UI SQLite database (from env: ATCR_UI_DATABASE_PATH, default: "/var/lib/atcr/ui.db")
DatabasePath string `yaml:"database_path"`
}
// HealthConfig defines health check and cache settings
type HealthConfig struct {
// CacheTTL is the hold health check cache TTL (from env: ATCR_HEALTH_CACHE_TTL, default: 15m)
CacheTTL time.Duration `yaml:"cache_ttl"`
// CheckInterval is the hold health check refresh interval (from env: ATCR_HEALTH_CHECK_INTERVAL, default: 15m)
CheckInterval time.Duration `yaml:"check_interval"`
}
// JetstreamConfig defines ATProto Jetstream settings
type JetstreamConfig struct {
// URL is the Jetstream WebSocket URL (from env: JETSTREAM_URL, default: wss://jetstream2.us-west.bsky.network/subscribe)
URL string `yaml:"url"`
// BackfillEnabled controls whether backfill is enabled (from env: ATCR_BACKFILL_ENABLED, default: true)
BackfillEnabled bool `yaml:"backfill_enabled"`
// RelayEndpoint is the relay endpoint for sync API (from env: ATCR_RELAY_ENDPOINT, default: https://relay1.us-east.bsky.network)
RelayEndpoint string `yaml:"relay_endpoint"`
}
// AuthConfig defines authentication settings
type AuthConfig struct {
// KeyPath is the JWT signing key path (from env: ATCR_AUTH_KEY_PATH, default: "/var/lib/atcr/auth/private-key.pem")
KeyPath string `yaml:"key_path"`
// CertPath is the JWT certificate path (from env: ATCR_AUTH_CERT_PATH, default: "/var/lib/atcr/auth/private-key.crt")
CertPath string `yaml:"cert_path"`
// TokenExpiration is the JWT expiration duration (5 minutes)
TokenExpiration time.Duration `yaml:"token_expiration"`
// ServiceName is the service name used for JWT issuer and service fields
// Derived from base URL hostname (e.g., "atcr.io")
ServiceName string `yaml:"service_name"`
}
// CredentialHelperConfig defines credential helper download settings
type CredentialHelperConfig struct {
// TangledRepo is the Tangled repository URL for downloads
// Hardcoded default: "https://tangled.org/@evan.jarrett.net/at-container-registry"
TangledRepo string `yaml:"tangled_repo"`
}
// LegalConfig defines legal page customization for self-hosted instances
type LegalConfig struct {
// CompanyName is the company/organization name displayed in legal pages
// (from env: ATCR_LEGAL_COMPANY_NAME, default: "AT Container Registry")
CompanyName string `yaml:"company_name"`
// Jurisdiction is the governing law jurisdiction for legal terms
// (from env: ATCR_LEGAL_JURISDICTION, default: "State of Texas, United States")
Jurisdiction string `yaml:"jurisdiction"`
}
// LoadConfigFromEnv builds a complete configuration from environment variables
// This follows the same pattern as the hold service (no config files, only env vars)
func LoadConfigFromEnv() (*Config, error) {
cfg := &Config{
Version: "0.1",
}
// Logging configuration
cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info")
// Log shipper configuration
cfg.LogShipper.Backend = os.Getenv("ATCR_LOG_SHIPPER_BACKEND")
cfg.LogShipper.URL = os.Getenv("ATCR_LOG_SHIPPER_URL")
cfg.LogShipper.BatchSize = getIntOrDefault("ATCR_LOG_SHIPPER_BATCH_SIZE", 100)
cfg.LogShipper.FlushInterval = getDurationOrDefault("ATCR_LOG_SHIPPER_FLUSH_INTERVAL", 5*time.Second)
cfg.LogShipper.Username = os.Getenv("ATCR_LOG_SHIPPER_USERNAME")
cfg.LogShipper.Password = os.Getenv("ATCR_LOG_SHIPPER_PASSWORD")
// Server configuration
cfg.Server.Addr = getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
cfg.Server.DefaultHoldDID = os.Getenv("ATCR_DEFAULT_HOLD_DID")
if cfg.Server.DefaultHoldDID == "" {
return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
}
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry")
// Auto-detect base URL if not explicitly set
cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
if cfg.Server.BaseURL == "" {
cfg.Server.BaseURL = autoDetectBaseURL(cfg.Server.Addr)
}
// UI configuration
cfg.UI.DatabasePath = getEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db")
// Health and cache configuration
cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute)
cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute)
// Jetstream configuration
cfg.Jetstream.URL = getEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe")
cfg.Jetstream.BackfillEnabled = os.Getenv("ATCR_BACKFILL_ENABLED") != "false"
cfg.Jetstream.RelayEndpoint = getEnvOrDefault("ATCR_RELAY_ENDPOINT", "https://relay1.us-east.bsky.network")
// Auth configuration
cfg.Auth.KeyPath = getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem")
cfg.Auth.CertPath = getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt")
// Token expiration: 5 minutes (not configurable)
cfg.Auth.TokenExpiration = 5 * time.Minute
// Derive service name from base URL or env var (used for JWT issuer and service)
cfg.Auth.ServiceName = getServiceName(cfg.Server.BaseURL)
// Credential helper configuration (hardcoded - no env vars needed)
cfg.CredentialHelper.TangledRepo = "https://tangled.org/@evan.jarrett.net/at-container-registry"
// Legal page configuration (for self-hosted instances)
cfg.Legal.CompanyName = getEnvOrDefault("ATCR_LEGAL_COMPANY_NAME", "AT Container Registry")
cfg.Legal.Jurisdiction = getEnvOrDefault("ATCR_LEGAL_JURISDICTION", "State of Texas, United States")
// Build distribution configuration for compatibility with distribution library
distConfig, err := buildDistributionConfig(cfg)
if err != nil {
return nil, fmt.Errorf("failed to build distribution config: %w", err)
}
cfg.Distribution = distConfig
return cfg, nil
}
// buildDistributionConfig creates a distribution Configuration from our Config
// This maintains compatibility with the distribution library
func buildDistributionConfig(cfg *Config) (*configuration.Configuration, error) {
distConfig := &configuration.Configuration{}
// Version
distConfig.Version = configuration.MajorMinorVersion(0, 1)
// Logging
distConfig.Log = configuration.Log{
Level: configuration.Loglevel(cfg.LogLevel),
Formatter: getEnvOrDefault("ATCR_LOG_FORMATTER", "text"),
Fields: map[string]any{
"service": "atcr-appview",
},
}
// HTTP server
httpSecret := os.Getenv("REGISTRY_HTTP_SECRET")
if httpSecret == "" {
// Generate a random 32-byte secret
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return nil, fmt.Errorf("failed to generate random secret: %w", err)
}
httpSecret = hex.EncodeToString(randomBytes)
}
distConfig.HTTP = configuration.HTTP{
Addr: cfg.Server.Addr,
Secret: httpSecret,
Headers: map[string][]string{
"X-Content-Type-Options": {"nosniff"},
},
}
// Storage (fake in-memory placeholder - all real storage is proxied)
distConfig.Storage = buildStorageConfig()
// Middleware (ATProto resolver)
distConfig.Middleware = buildMiddlewareConfig(cfg.Server.DefaultHoldDID, cfg.Server.BaseURL)
// Auth (use values from cfg.Auth)
realm := cfg.Server.BaseURL + "/auth/token"
distConfig.Auth = configuration.Auth{
"token": configuration.Parameters{
"realm": realm,
"service": cfg.Auth.ServiceName,
"issuer": cfg.Auth.ServiceName,
"rootcertbundle": cfg.Auth.CertPath,
"privatekey": cfg.Auth.KeyPath,
"expiration": int(cfg.Auth.TokenExpiration.Seconds()),
},
}
// Health checks
distConfig.Health = buildHealthConfig()
return distConfig, nil
}
// autoDetectBaseURL determines the base URL for the service from the HTTP address
func autoDetectBaseURL(httpAddr string) string {
// Auto-detect from HTTP addr
if httpAddr[0] == ':' {
// Just a port, assume localhost
// Use "127.0.0.1" per RFC 8252 (OAuth servers reject "localhost")
return fmt.Sprintf("http://127.0.0.1%s", httpAddr)
}
// Full address provided
return fmt.Sprintf("http://%s", httpAddr)
}
// buildStorageConfig creates a fake in-memory storage config
// This is required for distribution validation but is never actually used
// All storage is routed through middleware to ATProto (manifests) and hold services (blobs)
func buildStorageConfig() configuration.Storage {
storage := configuration.Storage{}
// Use in-memory storage as a placeholder
storage["inmemory"] = configuration.Parameters{}
// Disable upload purging
// NOTE: Must use map[any]any for uploadpurging (not configuration.Parameters)
// because distribution's validation code does a type assertion to map[any]any
storage["maintenance"] = configuration.Parameters{
"uploadpurging": map[any]any{
"enabled": false,
"age": 7 * 24 * time.Hour, // 168h
"interval": 24 * time.Hour, // 24h
"dryrun": false,
},
}
return storage
}
// buildMiddlewareConfig creates middleware configuration
func buildMiddlewareConfig(defaultHoldDID string, baseURL string) map[string][]configuration.Middleware {
// Check test mode
testMode := os.Getenv("TEST_MODE") == "true"
return map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"default_hold_did": defaultHoldDID,
"test_mode": testMode,
"base_url": baseURL,
},
},
},
}
}
// buildHealthConfig creates health check configuration
func buildHealthConfig() configuration.Health {
return configuration.Health{
StorageDriver: configuration.StorageDriver{
Enabled: true,
Interval: 10 * time.Second,
Threshold: 3,
},
}
}
// getServiceName extracts service name from base URL hostname
func getServiceName(baseURL string) string {
// Extract from base URL
parsed, err := url.Parse(baseURL)
if err == nil && parsed.Hostname() != "" {
hostname := parsed.Hostname()
// Strip localhost/127.0.0.1 and use default
if hostname == "localhost" || hostname == "127.0.0.1" {
return "atcr.io"
}
return hostname
}
// Default fallback
return "atcr.io"
}
// 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
}
// getDurationOrDefault parses a duration from environment variable or returns default
// Logs a warning if parsing fails
func getDurationOrDefault(envKey string, defaultValue time.Duration) time.Duration {
envVal := os.Getenv(envKey)
if envVal == "" {
return defaultValue
}
parsed, err := time.ParseDuration(envVal)
if err != nil {
slog.Warn("Invalid duration, using default", "env_key", envKey, "env_value", envVal, "default", defaultValue)
return defaultValue
}
return parsed
}
// getIntOrDefault parses an int from environment variable or returns default
// Logs a warning if parsing fails
func getIntOrDefault(envKey string, defaultValue int) int {
envVal := os.Getenv(envKey)
if envVal == "" {
return defaultValue
}
parsed, err := strconv.Atoi(envVal)
if err != nil {
slog.Warn("Invalid int, using default", "env_key", envKey, "env_value", envVal, "default", defaultValue)
return defaultValue
}
return parsed
}