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

404 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"
"strings"
"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"`
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"`
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
}
// 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"`
// DebugAddr is the debug/pprof HTTP listen address (from env: ATCR_DEBUG_ADDR, default: ":5001")
DebugAddr string `yaml:"debug_addr"`
// 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 {
// Enabled controls whether the web UI is enabled (from env: ATCR_UI_ENABLED, default: true)
Enabled bool `yaml:"enabled"`
// 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"`
// SkipDBMigrations controls whether to skip running database migrations (from env: SKIP_DB_MIGRATIONS, default: false)
SkipDBMigrations bool `yaml:"skip_db_migrations"`
}
// 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"`
// BackfillInterval is the backfill interval (from env: ATCR_BACKFILL_INTERVAL, default: 1h)
BackfillInterval time.Duration `yaml:"backfill_interval"`
// 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 (from env: ATCR_TOKEN_EXPIRATION, default: 300s)
TokenExpiration time.Duration `yaml:"token_expiration"`
// ServiceName is the service name used for JWT issuer and service fields
// Derived from ATCR_SERVICE_NAME env var or extracted from base URL (e.g., "atcr.io")
ServiceName string `yaml:"service_name"`
}
// CredentialHelperConfig defines credential helper version and download settings
type CredentialHelperConfig struct {
// Version is the latest credential helper version (from env: ATCR_CREDENTIAL_HELPER_VERSION)
// e.g., "v0.0.2"
Version string `yaml:"version"`
// TangledRepo is the Tangled repository URL for downloads (from env: ATCR_CREDENTIAL_HELPER_TANGLED_REPO)
// Default: "https://tangled.org/@evan.jarrett.net/at-container-registry"
TangledRepo string `yaml:"tangled_repo"`
// Checksums is a comma-separated list of platform:sha256 pairs (from env: ATCR_CREDENTIAL_HELPER_CHECKSUMS)
// e.g., "linux_amd64:abc123,darwin_arm64:def456"
Checksums map[string]string `yaml:"-"`
}
// 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")
// Server configuration
cfg.Server.Addr = getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
cfg.Server.DebugAddr = getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
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.Enabled = os.Getenv("ATCR_UI_ENABLED") != "false"
cfg.UI.DatabasePath = getEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db")
cfg.UI.SkipDBMigrations = os.Getenv("SKIP_DB_MIGRATIONS") == "true"
// 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.BackfillInterval = getDurationOrDefault("ATCR_BACKFILL_INTERVAL", 1*time.Hour)
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")
// Parse token expiration (default: 300 seconds = 5 minutes)
expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300")
expirationSecs, err := strconv.Atoi(expirationStr)
if err != nil {
return nil, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err)
}
cfg.Auth.TokenExpiration = time.Duration(expirationSecs) * time.Second
// 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
cfg.CredentialHelper.Version = os.Getenv("ATCR_CREDENTIAL_HELPER_VERSION")
cfg.CredentialHelper.TangledRepo = getEnvOrDefault("ATCR_CREDENTIAL_HELPER_TANGLED_REPO", "https://tangled.org/@evan.jarrett.net/at-container-registry")
cfg.CredentialHelper.Checksums = parseChecksums(os.Getenv("ATCR_CREDENTIAL_HELPER_CHECKSUMS"))
// 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"},
},
Debug: configuration.Debug{
Addr: cfg.Server.DebugAddr,
},
}
// 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 or uses env var
func getServiceName(baseURL string) string {
// Check env var first
if serviceName := os.Getenv("ATCR_SERVICE_NAME"); serviceName != "" {
return serviceName
}
// Try to 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
}
// parseChecksums parses a comma-separated list of platform:sha256 pairs
// e.g., "linux_amd64:abc123,darwin_arm64:def456"
func parseChecksums(checksumsStr string) map[string]string {
checksums := make(map[string]string)
if checksumsStr == "" {
return checksums
}
pairs := strings.Split(checksumsStr, ",")
for _, pair := range pairs {
parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
if len(parts) == 2 {
platform := strings.TrimSpace(parts[0])
hash := strings.TrimSpace(parts[1])
if platform != "" && hash != "" {
checksums[platform] = hash
}
}
}
return checksums
}