A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at codeberg-source 403 lines 15 kB view raw
1// Package appview implements the ATCR AppView component, which serves as the main 2// OCI Distribution API server. It resolves identities (handle/DID to PDS endpoint), 3// routes manifests to user's PDS, routes blobs to hold services, validates OAuth tokens, 4// and issues registry JWTs. This package provides environment-based configuration, 5// middleware registration, and HTTP server setup for the AppView service. 6package appview 7 8import ( 9 "crypto/rand" 10 "encoding/hex" 11 "fmt" 12 "log/slog" 13 "net/url" 14 "os" 15 "strconv" 16 "strings" 17 "time" 18 19 "github.com/distribution/distribution/v3/configuration" 20) 21 22// Config represents the AppView service configuration 23type Config struct { 24 Version string `yaml:"version"` 25 LogLevel string `yaml:"log_level"` 26 Server ServerConfig `yaml:"server"` 27 UI UIConfig `yaml:"ui"` 28 Health HealthConfig `yaml:"health"` 29 Jetstream JetstreamConfig `yaml:"jetstream"` 30 Auth AuthConfig `yaml:"auth"` 31 CredentialHelper CredentialHelperConfig `yaml:"credential_helper"` 32 Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility 33} 34 35// ServerConfig defines server settings 36type ServerConfig struct { 37 // Addr is the HTTP listen address (from env: ATCR_HTTP_ADDR, default: ":5000") 38 Addr string `yaml:"addr"` 39 40 // BaseURL is the public URL for OAuth/JWT realm (from env: ATCR_BASE_URL) 41 // Auto-detected from Addr if not set 42 BaseURL string `yaml:"base_url"` 43 44 // DefaultHoldDID is the default hold DID for blob storage (from env: ATCR_DEFAULT_HOLD_DID) 45 // REQUIRED - e.g., "did:web:hold01.atcr.io" 46 DefaultHoldDID string `yaml:"default_hold_did"` 47 48 // TestMode enables HTTP for local DID resolution and transition:generic scope (from env: TEST_MODE) 49 TestMode bool `yaml:"test_mode"` 50 51 // DebugAddr is the debug/pprof HTTP listen address (from env: ATCR_DEBUG_ADDR, default: ":5001") 52 DebugAddr string `yaml:"debug_addr"` 53 54 // 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") 55 // Auto-generated on first run for production (non-localhost) deployments 56 OAuthKeyPath string `yaml:"oauth_key_path"` 57 58 // ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry") 59 // Shown in OAuth authorization screens 60 ClientName string `yaml:"client_name"` 61} 62 63// UIConfig defines web UI settings 64type UIConfig struct { 65 // Enabled controls whether the web UI is enabled (from env: ATCR_UI_ENABLED, default: true) 66 Enabled bool `yaml:"enabled"` 67 68 // DatabasePath is the path to the UI SQLite database (from env: ATCR_UI_DATABASE_PATH, default: "/var/lib/atcr/ui.db") 69 DatabasePath string `yaml:"database_path"` 70 71 // SkipDBMigrations controls whether to skip running database migrations (from env: SKIP_DB_MIGRATIONS, default: false) 72 SkipDBMigrations bool `yaml:"skip_db_migrations"` 73} 74 75// HealthConfig defines health check and cache settings 76type HealthConfig struct { 77 // CacheTTL is the hold health check cache TTL (from env: ATCR_HEALTH_CACHE_TTL, default: 15m) 78 CacheTTL time.Duration `yaml:"cache_ttl"` 79 80 // CheckInterval is the hold health check refresh interval (from env: ATCR_HEALTH_CHECK_INTERVAL, default: 15m) 81 CheckInterval time.Duration `yaml:"check_interval"` 82} 83 84// JetstreamConfig defines ATProto Jetstream settings 85type JetstreamConfig struct { 86 // URL is the Jetstream WebSocket URL (from env: JETSTREAM_URL, default: wss://jetstream2.us-west.bsky.network/subscribe) 87 URL string `yaml:"url"` 88 89 // BackfillEnabled controls whether backfill is enabled (from env: ATCR_BACKFILL_ENABLED, default: true) 90 BackfillEnabled bool `yaml:"backfill_enabled"` 91 92 // BackfillInterval is the backfill interval (from env: ATCR_BACKFILL_INTERVAL, default: 1h) 93 BackfillInterval time.Duration `yaml:"backfill_interval"` 94 95 // RelayEndpoint is the relay endpoint for sync API (from env: ATCR_RELAY_ENDPOINT, default: https://relay1.us-east.bsky.network) 96 RelayEndpoint string `yaml:"relay_endpoint"` 97} 98 99// AuthConfig defines authentication settings 100type AuthConfig struct { 101 // KeyPath is the JWT signing key path (from env: ATCR_AUTH_KEY_PATH, default: "/var/lib/atcr/auth/private-key.pem") 102 KeyPath string `yaml:"key_path"` 103 104 // CertPath is the JWT certificate path (from env: ATCR_AUTH_CERT_PATH, default: "/var/lib/atcr/auth/private-key.crt") 105 CertPath string `yaml:"cert_path"` 106 107 // TokenExpiration is the JWT expiration duration (from env: ATCR_TOKEN_EXPIRATION, default: 300s) 108 TokenExpiration time.Duration `yaml:"token_expiration"` 109 110 // ServiceName is the service name used for JWT issuer and service fields 111 // Derived from ATCR_SERVICE_NAME env var or extracted from base URL (e.g., "atcr.io") 112 ServiceName string `yaml:"service_name"` 113} 114 115// CredentialHelperConfig defines credential helper version and download settings 116type CredentialHelperConfig struct { 117 // Version is the latest credential helper version (from env: ATCR_CREDENTIAL_HELPER_VERSION) 118 // e.g., "v0.0.2" 119 Version string `yaml:"version"` 120 121 // TangledRepo is the Tangled repository URL for downloads (from env: ATCR_CREDENTIAL_HELPER_TANGLED_REPO) 122 // Default: "https://tangled.org/@evan.jarrett.net/at-container-registry" 123 TangledRepo string `yaml:"tangled_repo"` 124 125 // Checksums is a comma-separated list of platform:sha256 pairs (from env: ATCR_CREDENTIAL_HELPER_CHECKSUMS) 126 // e.g., "linux_amd64:abc123,darwin_arm64:def456" 127 Checksums map[string]string `yaml:"-"` 128} 129 130// LoadConfigFromEnv builds a complete configuration from environment variables 131// This follows the same pattern as the hold service (no config files, only env vars) 132func LoadConfigFromEnv() (*Config, error) { 133 cfg := &Config{ 134 Version: "0.1", 135 } 136 137 // Logging configuration 138 cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info") 139 140 // Server configuration 141 cfg.Server.Addr = getEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 142 cfg.Server.DebugAddr = getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 143 cfg.Server.DefaultHoldDID = os.Getenv("ATCR_DEFAULT_HOLD_DID") 144 if cfg.Server.DefaultHoldDID == "" { 145 return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required") 146 } 147 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 148 cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key") 149 cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry") 150 151 // Auto-detect base URL if not explicitly set 152 cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL") 153 if cfg.Server.BaseURL == "" { 154 cfg.Server.BaseURL = autoDetectBaseURL(cfg.Server.Addr) 155 } 156 157 // UI configuration 158 cfg.UI.Enabled = os.Getenv("ATCR_UI_ENABLED") != "false" 159 cfg.UI.DatabasePath = getEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db") 160 cfg.UI.SkipDBMigrations = os.Getenv("SKIP_DB_MIGRATIONS") == "true" 161 162 // Health and cache configuration 163 cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute) 164 cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute) 165 166 // Jetstream configuration 167 cfg.Jetstream.URL = getEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe") 168 cfg.Jetstream.BackfillEnabled = os.Getenv("ATCR_BACKFILL_ENABLED") != "false" 169 cfg.Jetstream.BackfillInterval = getDurationOrDefault("ATCR_BACKFILL_INTERVAL", 1*time.Hour) 170 cfg.Jetstream.RelayEndpoint = getEnvOrDefault("ATCR_RELAY_ENDPOINT", "https://relay1.us-east.bsky.network") 171 172 // Auth configuration 173 cfg.Auth.KeyPath = getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 174 cfg.Auth.CertPath = getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 175 176 // Parse token expiration (default: 300 seconds = 5 minutes) 177 expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 178 expirationSecs, err := strconv.Atoi(expirationStr) 179 if err != nil { 180 return nil, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err) 181 } 182 cfg.Auth.TokenExpiration = time.Duration(expirationSecs) * time.Second 183 184 // Derive service name from base URL or env var (used for JWT issuer and service) 185 cfg.Auth.ServiceName = getServiceName(cfg.Server.BaseURL) 186 187 // Credential helper configuration 188 cfg.CredentialHelper.Version = os.Getenv("ATCR_CREDENTIAL_HELPER_VERSION") 189 cfg.CredentialHelper.TangledRepo = getEnvOrDefault("ATCR_CREDENTIAL_HELPER_TANGLED_REPO", "https://tangled.org/@evan.jarrett.net/at-container-registry") 190 cfg.CredentialHelper.Checksums = parseChecksums(os.Getenv("ATCR_CREDENTIAL_HELPER_CHECKSUMS")) 191 192 // Build distribution configuration for compatibility with distribution library 193 distConfig, err := buildDistributionConfig(cfg) 194 if err != nil { 195 return nil, fmt.Errorf("failed to build distribution config: %w", err) 196 } 197 cfg.Distribution = distConfig 198 199 return cfg, nil 200} 201 202// buildDistributionConfig creates a distribution Configuration from our Config 203// This maintains compatibility with the distribution library 204func buildDistributionConfig(cfg *Config) (*configuration.Configuration, error) { 205 distConfig := &configuration.Configuration{} 206 207 // Version 208 distConfig.Version = configuration.MajorMinorVersion(0, 1) 209 210 // Logging 211 distConfig.Log = configuration.Log{ 212 Level: configuration.Loglevel(cfg.LogLevel), 213 Formatter: getEnvOrDefault("ATCR_LOG_FORMATTER", "text"), 214 Fields: map[string]any{ 215 "service": "atcr-appview", 216 }, 217 } 218 219 // HTTP server 220 httpSecret := os.Getenv("REGISTRY_HTTP_SECRET") 221 if httpSecret == "" { 222 // Generate a random 32-byte secret 223 randomBytes := make([]byte, 32) 224 if _, err := rand.Read(randomBytes); err != nil { 225 return nil, fmt.Errorf("failed to generate random secret: %w", err) 226 } 227 httpSecret = hex.EncodeToString(randomBytes) 228 } 229 230 distConfig.HTTP = configuration.HTTP{ 231 Addr: cfg.Server.Addr, 232 Secret: httpSecret, 233 Headers: map[string][]string{ 234 "X-Content-Type-Options": {"nosniff"}, 235 }, 236 Debug: configuration.Debug{ 237 Addr: cfg.Server.DebugAddr, 238 }, 239 } 240 241 // Storage (fake in-memory placeholder - all real storage is proxied) 242 distConfig.Storage = buildStorageConfig() 243 244 // Middleware (ATProto resolver) 245 distConfig.Middleware = buildMiddlewareConfig(cfg.Server.DefaultHoldDID, cfg.Server.BaseURL) 246 247 // Auth (use values from cfg.Auth) 248 realm := cfg.Server.BaseURL + "/auth/token" 249 250 distConfig.Auth = configuration.Auth{ 251 "token": configuration.Parameters{ 252 "realm": realm, 253 "service": cfg.Auth.ServiceName, 254 "issuer": cfg.Auth.ServiceName, 255 "rootcertbundle": cfg.Auth.CertPath, 256 "privatekey": cfg.Auth.KeyPath, 257 "expiration": int(cfg.Auth.TokenExpiration.Seconds()), 258 }, 259 } 260 261 // Health checks 262 distConfig.Health = buildHealthConfig() 263 264 return distConfig, nil 265} 266 267// autoDetectBaseURL determines the base URL for the service from the HTTP address 268func autoDetectBaseURL(httpAddr string) string { 269 // Auto-detect from HTTP addr 270 if httpAddr[0] == ':' { 271 // Just a port, assume localhost 272 // Use "127.0.0.1" per RFC 8252 (OAuth servers reject "localhost") 273 return fmt.Sprintf("http://127.0.0.1%s", httpAddr) 274 } 275 276 // Full address provided 277 return fmt.Sprintf("http://%s", httpAddr) 278} 279 280// buildStorageConfig creates a fake in-memory storage config 281// This is required for distribution validation but is never actually used 282// All storage is routed through middleware to ATProto (manifests) and hold services (blobs) 283func buildStorageConfig() configuration.Storage { 284 storage := configuration.Storage{} 285 286 // Use in-memory storage as a placeholder 287 storage["inmemory"] = configuration.Parameters{} 288 289 // Disable upload purging 290 // NOTE: Must use map[any]any for uploadpurging (not configuration.Parameters) 291 // because distribution's validation code does a type assertion to map[any]any 292 storage["maintenance"] = configuration.Parameters{ 293 "uploadpurging": map[any]any{ 294 "enabled": false, 295 "age": 7 * 24 * time.Hour, // 168h 296 "interval": 24 * time.Hour, // 24h 297 "dryrun": false, 298 }, 299 } 300 301 return storage 302} 303 304// buildMiddlewareConfig creates middleware configuration 305func buildMiddlewareConfig(defaultHoldDID string, baseURL string) map[string][]configuration.Middleware { 306 // Check test mode 307 testMode := os.Getenv("TEST_MODE") == "true" 308 309 return map[string][]configuration.Middleware{ 310 "registry": { 311 { 312 Name: "atproto-resolver", 313 Options: configuration.Parameters{ 314 "default_hold_did": defaultHoldDID, 315 "test_mode": testMode, 316 "base_url": baseURL, 317 }, 318 }, 319 }, 320 } 321} 322 323// buildHealthConfig creates health check configuration 324func buildHealthConfig() configuration.Health { 325 return configuration.Health{ 326 StorageDriver: configuration.StorageDriver{ 327 Enabled: true, 328 Interval: 10 * time.Second, 329 Threshold: 3, 330 }, 331 } 332} 333 334// getServiceName extracts service name from base URL or uses env var 335func getServiceName(baseURL string) string { 336 // Check env var first 337 if serviceName := os.Getenv("ATCR_SERVICE_NAME"); serviceName != "" { 338 return serviceName 339 } 340 341 // Try to extract from base URL 342 parsed, err := url.Parse(baseURL) 343 if err == nil && parsed.Hostname() != "" { 344 hostname := parsed.Hostname() 345 346 // Strip localhost/127.0.0.1 and use default 347 if hostname == "localhost" || hostname == "127.0.0.1" { 348 return "atcr.io" 349 } 350 351 return hostname 352 } 353 354 // Default fallback 355 return "atcr.io" 356} 357 358// getEnvOrDefault gets an environment variable or returns a default value 359func getEnvOrDefault(key, defaultValue string) string { 360 if val := os.Getenv(key); val != "" { 361 return val 362 } 363 return defaultValue 364} 365 366// getDurationOrDefault parses a duration from environment variable or returns default 367// Logs a warning if parsing fails 368func getDurationOrDefault(envKey string, defaultValue time.Duration) time.Duration { 369 envVal := os.Getenv(envKey) 370 if envVal == "" { 371 return defaultValue 372 } 373 374 parsed, err := time.ParseDuration(envVal) 375 if err != nil { 376 slog.Warn("Invalid duration, using default", "env_key", envKey, "env_value", envVal, "default", defaultValue) 377 return defaultValue 378 } 379 380 return parsed 381} 382 383// parseChecksums parses a comma-separated list of platform:sha256 pairs 384// e.g., "linux_amd64:abc123,darwin_arm64:def456" 385func parseChecksums(checksumsStr string) map[string]string { 386 checksums := make(map[string]string) 387 if checksumsStr == "" { 388 return checksums 389 } 390 391 pairs := strings.Split(checksumsStr, ",") 392 for _, pair := range pairs { 393 parts := strings.SplitN(strings.TrimSpace(pair), ":", 2) 394 if len(parts) == 2 { 395 platform := strings.TrimSpace(parts[0]) 396 hash := strings.TrimSpace(parts[1]) 397 if platform != "" && hash != "" { 398 checksums[platform] = hash 399 } 400 } 401 } 402 return checksums 403}