// 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 }