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 8d39daa09d8d9d5066c9fb336c61ef5bfb8a0b1f 407 lines 16 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 Viper-based configuration with YAML 5// file support, environment variable overrides, and HTTP server setup for the AppView service. 6package appview 7 8import ( 9 "crypto/rand" 10 "encoding/hex" 11 "fmt" 12 "net/url" 13 "os" 14 "time" 15 16 "github.com/distribution/distribution/v3/configuration" 17 "github.com/spf13/viper" 18 19 "atcr.io/pkg/config" 20) 21 22// Config represents the AppView service configuration 23type Config struct { 24 Version string `yaml:"version" comment:"Configuration format version."` 25 LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` 26 LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` 27 Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."` 28 UI UIConfig `yaml:"ui" comment:"Web UI settings."` 29 Health HealthConfig `yaml:"health" comment:"Health check and cache settings."` 30 Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."` 31 Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` 32 CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."` 33 Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` 34 Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility 35} 36 37// ServerConfig defines server settings 38type ServerConfig struct { 39 // Listen address for the HTTP server. 40 Addr string `yaml:"addr" comment:"Listen address, e.g. \":5000\" or \"127.0.0.1:5000\"."` 41 42 // Public-facing URL for OAuth callbacks and JWT realm. 43 BaseURL string `yaml:"base_url" comment:"Public-facing URL for OAuth callbacks and JWT realm. Auto-detected if empty."` 44 45 // DID of the default hold service for blob storage. 46 DefaultHoldDID string `yaml:"default_hold_did" comment:"DID of the hold service for blob storage, e.g. \"did:web:hold01.atcr.io\" (REQUIRED)."` 47 48 // Allows HTTP (not HTTPS) for DID resolution. 49 TestMode bool `yaml:"test_mode" comment:"Allows HTTP (not HTTPS) for DID resolution and uses transition:generic OAuth scope."` 50 51 // Path to P-256 private key for OAuth client authentication. 52 OAuthKeyPath string `yaml:"oauth_key_path" comment:"Path to P-256 private key for OAuth client authentication. Auto-generated on first run."` 53 54 // Display name shown on OAuth authorization screens. 55 ClientName string `yaml:"client_name" comment:"Display name shown on OAuth authorization screens."` 56 57 // Short name used in page titles and browser tabs. 58 ClientShortName string `yaml:"client_short_name" comment:"Short name used in page titles and browser tabs."` 59 60 // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display). 61 RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."` 62} 63 64// UIConfig defines web UI settings 65type UIConfig struct { 66 // SQLite database path. 67 DatabasePath string `yaml:"database_path" comment:"SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals."` 68 69 // Visual theme name (e.g. "seamark"). Empty string uses default atcr.io branding. 70 Theme string `yaml:"theme" comment:"Visual theme name (e.g. \"seamark\"). Empty uses default atcr.io branding."` 71 72 // libSQL sync URL for embedded replicas. Works with Turso cloud or self-hosted libsql-server. 73 // Leave empty for local-only SQLite mode (selfhost/dev). 74 LibsqlSyncURL string `yaml:"libsql_sync_url" comment:"libSQL sync URL (libsql://...). Works with Turso cloud or self-hosted libsql-server. Leave empty for local-only SQLite."` 75 76 // Auth token for libSQL sync. Required if LibsqlSyncURL is set. 77 LibsqlAuthToken string `yaml:"libsql_auth_token" comment:"Auth token for libSQL sync. Required if libsql_sync_url is set."` 78 79 // How often to sync with the remote libSQL server. 80 LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"How often to sync with remote libSQL server. Default: 60s."` 81} 82 83// HealthConfig defines health check and cache settings 84type HealthConfig struct { 85 // How long to cache hold health check results. 86 CacheTTL time.Duration `yaml:"cache_ttl" comment:"How long to cache hold health check results."` 87 88 // How often to refresh hold health checks. 89 CheckInterval time.Duration `yaml:"check_interval" comment:"How often to refresh hold health checks."` 90} 91 92// JetstreamConfig defines ATProto Jetstream settings 93type JetstreamConfig struct { 94 // Jetstream WebSocket endpoints, tried in order on failure. 95 URLs []string `yaml:"urls" comment:"Jetstream WebSocket endpoints, tried in order on failure."` 96 97 // Sync existing records from PDS on startup. 98 BackfillEnabled bool `yaml:"backfill_enabled" comment:"Sync existing records from PDS on startup."` 99 100 // Relay endpoints for backfill, tried in order on failure. 101 RelayEndpoints []string `yaml:"relay_endpoints" comment:"Relay endpoints for backfill, tried in order on failure."` 102} 103 104// AuthConfig defines authentication settings 105type AuthConfig struct { 106 // RSA private key for signing registry JWTs. 107 KeyPath string `yaml:"key_path" comment:"RSA private key for signing registry JWTs issued to Docker clients."` 108 109 // X.509 certificate matching the JWT signing key. 110 CertPath string `yaml:"cert_path" comment:"X.509 certificate matching the JWT signing key."` 111 112 // TokenExpiration is the JWT expiration duration (5 minutes, not configurable) 113 TokenExpiration time.Duration `yaml:"-"` 114 115 // ServiceName is the service name used for JWT issuer and service fields. 116 // Derived from base URL hostname (e.g., "atcr.io") 117 ServiceName string `yaml:"-"` 118} 119 120// CredentialHelperConfig defines credential helper download settings 121type CredentialHelperConfig struct { 122 // TangledRepo is the Tangled repository URL for downloads 123 TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads."` 124} 125 126// LegalConfig defines legal page customization for self-hosted instances 127type LegalConfig struct { 128 // Organization name for legal pages. Defaults to ClientName. 129 CompanyName string `yaml:"company_name" comment:"Organization name for Terms of Service and Privacy Policy. Defaults to server.client_name."` 130 131 // Governing law jurisdiction for legal terms. 132 Jurisdiction string `yaml:"jurisdiction" comment:"Governing law jurisdiction for legal terms."` 133} 134 135// setDefaults registers all default values on the given Viper instance. 136func setDefaults(v *viper.Viper) { 137 v.SetDefault("version", "0.1") 138 v.SetDefault("log_level", "info") 139 140 // Server defaults 141 v.SetDefault("server.addr", ":5000") 142 v.SetDefault("server.base_url", "") 143 v.SetDefault("server.default_hold_did", "") 144 v.SetDefault("server.test_mode", false) 145 v.SetDefault("server.client_name", "AT Container Registry") 146 v.SetDefault("server.client_short_name", "ATCR") 147 v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key") 148 v.SetDefault("server.registry_domains", []string{}) 149 150 // UI defaults 151 v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db") 152 v.SetDefault("ui.theme", "") 153 v.SetDefault("ui.libsql_sync_url", "") 154 v.SetDefault("ui.libsql_auth_token", "") 155 v.SetDefault("ui.libsql_sync_interval", "60s") 156 157 // Health defaults 158 v.SetDefault("health.cache_ttl", "15m") 159 v.SetDefault("health.check_interval", "15m") 160 161 // Jetstream defaults 162 v.SetDefault("jetstream.urls", []string{ 163 "wss://jetstream2.us-west.bsky.network/subscribe", 164 "wss://jetstream1.us-west.bsky.network/subscribe", 165 "wss://jetstream2.us-east.bsky.network/subscribe", 166 "wss://jetstream1.us-east.bsky.network/subscribe", 167 }) 168 v.SetDefault("jetstream.backfill_enabled", true) 169 v.SetDefault("jetstream.relay_endpoints", []string{ 170 "https://relay1.us-east.bsky.network", 171 "https://relay1.us-west.bsky.network", 172 }) 173 174 // Auth defaults 175 v.SetDefault("auth.key_path", "/var/lib/atcr/auth/private-key.pem") 176 v.SetDefault("auth.cert_path", "/var/lib/atcr/auth/private-key.crt") 177 178 // Log shipper defaults 179 v.SetDefault("log_shipper.batch_size", 100) 180 v.SetDefault("log_shipper.flush_interval", "5s") 181 182 // Legal defaults 183 v.SetDefault("legal.company_name", "") 184 v.SetDefault("legal.jurisdiction", "") 185 186 // Log formatter (used by distribution config, not in Config struct) 187 v.SetDefault("log_formatter", "text") 188} 189 190// DefaultConfig returns a Config populated with all default values (no validation). 191func DefaultConfig() *Config { 192 v := config.NewViper("ATCR", "") 193 setDefaults(v) 194 195 cfg := &Config{} 196 _ = v.Unmarshal(cfg, config.UnmarshalOption()) 197 return cfg 198} 199 200// ExampleYAML returns a fully-commented YAML configuration with default values. 201func ExampleYAML() ([]byte, error) { 202 return config.MarshalCommentedYAML("ATCR AppView Configuration", DefaultConfig()) 203} 204 205// LoadConfig builds a complete configuration using Viper layered loading: 206// defaults -> YAML file -> environment variables. 207// yamlPath is optional; empty string means env-only (backward compatible). 208func LoadConfig(yamlPath string) (*Config, error) { 209 v := config.NewViper("ATCR", yamlPath) 210 211 // Set defaults 212 setDefaults(v) 213 214 // Unmarshal into config struct 215 cfg := &Config{} 216 if err := v.Unmarshal(cfg, config.UnmarshalOption()); err != nil { 217 return nil, fmt.Errorf("failed to unmarshal config: %w", err) 218 } 219 220 // Post-load: auto-detect base URL if not set 221 if cfg.Server.BaseURL == "" { 222 cfg.Server.BaseURL = autoDetectBaseURL(cfg.Server.Addr) 223 } 224 225 // Post-load: fixed values 226 cfg.Auth.TokenExpiration = 5 * time.Minute 227 cfg.Auth.ServiceName = deriveServiceName(cfg) 228 cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry" 229 230 // Post-load: CompanyName defaults to ClientName 231 if cfg.Legal.CompanyName == "" { 232 cfg.Legal.CompanyName = cfg.Server.ClientName 233 } 234 235 // Validation 236 if cfg.Server.DefaultHoldDID == "" { 237 return nil, fmt.Errorf("server.default_hold_did is required (env: ATCR_SERVER_DEFAULT_HOLD_DID)") 238 } 239 240 // Build distribution config (unchanged) 241 distConfig, err := buildDistributionConfig(cfg, v) 242 if err != nil { 243 return nil, fmt.Errorf("failed to build distribution config: %w", err) 244 } 245 cfg.Distribution = distConfig 246 247 return cfg, nil 248} 249 250// deriveServiceName extracts the JWT service name from the config. 251func deriveServiceName(cfg *Config) string { 252 if len(cfg.Server.RegistryDomains) > 0 { 253 return cfg.Server.RegistryDomains[0] 254 } 255 return getServiceName(cfg.Server.BaseURL) 256} 257 258// buildDistributionConfig creates a distribution Configuration from our Config 259// This maintains compatibility with the distribution library 260func buildDistributionConfig(cfg *Config, v *viper.Viper) (*configuration.Configuration, error) { 261 distConfig := &configuration.Configuration{} 262 263 // Version 264 distConfig.Version = configuration.MajorMinorVersion(0, 1) 265 266 // Logging 267 logFormatter := v.GetString("log_formatter") 268 if logFormatter == "" { 269 logFormatter = "text" 270 } 271 distConfig.Log = configuration.Log{ 272 Level: configuration.Loglevel(cfg.LogLevel), 273 Formatter: logFormatter, 274 Fields: map[string]any{ 275 "service": "atcr-appview", 276 }, 277 } 278 279 // HTTP server 280 httpSecret := os.Getenv("REGISTRY_HTTP_SECRET") 281 if httpSecret == "" { 282 // Generate a random 32-byte secret 283 randomBytes := make([]byte, 32) 284 if _, err := rand.Read(randomBytes); err != nil { 285 return nil, fmt.Errorf("failed to generate random secret: %w", err) 286 } 287 httpSecret = hex.EncodeToString(randomBytes) 288 } 289 290 distConfig.HTTP = configuration.HTTP{ 291 Addr: cfg.Server.Addr, 292 Secret: httpSecret, 293 Headers: map[string][]string{ 294 "X-Content-Type-Options": {"nosniff"}, 295 }, 296 } 297 298 // Storage (fake in-memory placeholder - all real storage is proxied) 299 distConfig.Storage = buildStorageConfig() 300 301 // Middleware (ATProto resolver) 302 distConfig.Middleware = buildMiddlewareConfig(cfg.Server.DefaultHoldDID, cfg.Server.BaseURL, cfg.Server.TestMode) 303 304 // Auth (use values from cfg.Auth) 305 // Realm always points to BaseURL where auth endpoints live 306 // Docker's WWW-Authenticate: realm="https://seamark.dev/auth/token",service="buoy.cr" 307 realm := cfg.Server.BaseURL + "/auth/token" 308 309 distConfig.Auth = configuration.Auth{ 310 "token": configuration.Parameters{ 311 "realm": realm, 312 "service": cfg.Auth.ServiceName, 313 "issuer": cfg.Auth.ServiceName, 314 "rootcertbundle": cfg.Auth.CertPath, 315 "privatekey": cfg.Auth.KeyPath, 316 "expiration": int(cfg.Auth.TokenExpiration.Seconds()), 317 }, 318 } 319 320 // Health checks 321 distConfig.Health = buildHealthConfig() 322 323 return distConfig, nil 324} 325 326// autoDetectBaseURL determines the base URL for the service from the HTTP address 327func autoDetectBaseURL(httpAddr string) string { 328 // Auto-detect from HTTP addr 329 if httpAddr[0] == ':' { 330 // Just a port, assume localhost 331 // Use "127.0.0.1" per RFC 8252 (OAuth servers reject "localhost") 332 return fmt.Sprintf("http://127.0.0.1%s", httpAddr) 333 } 334 335 // Full address provided 336 return fmt.Sprintf("http://%s", httpAddr) 337} 338 339// buildStorageConfig creates a fake in-memory storage config 340// This is required for distribution validation but is never actually used 341// All storage is routed through middleware to ATProto (manifests) and hold services (blobs) 342func buildStorageConfig() configuration.Storage { 343 storage := configuration.Storage{} 344 345 // Use in-memory storage as a placeholder 346 storage["inmemory"] = configuration.Parameters{} 347 348 // Disable upload purging 349 // NOTE: Must use map[any]any for uploadpurging (not configuration.Parameters) 350 // because distribution's validation code does a type assertion to map[any]any 351 storage["maintenance"] = configuration.Parameters{ 352 "uploadpurging": map[any]any{ 353 "enabled": false, 354 "age": 7 * 24 * time.Hour, // 168h 355 "interval": 24 * time.Hour, // 24h 356 "dryrun": false, 357 }, 358 } 359 360 return storage 361} 362 363// buildMiddlewareConfig creates middleware configuration 364func buildMiddlewareConfig(defaultHoldDID string, baseURL string, testMode bool) map[string][]configuration.Middleware { 365 return map[string][]configuration.Middleware{ 366 "registry": { 367 { 368 Name: "atproto-resolver", 369 Options: configuration.Parameters{ 370 "default_hold_did": defaultHoldDID, 371 "test_mode": testMode, 372 "base_url": baseURL, 373 }, 374 }, 375 }, 376 } 377} 378 379// buildHealthConfig creates health check configuration 380func buildHealthConfig() configuration.Health { 381 return configuration.Health{ 382 StorageDriver: configuration.StorageDriver{ 383 Enabled: true, 384 Interval: 10 * time.Second, 385 Threshold: 3, 386 }, 387 } 388} 389 390// getServiceName extracts service name from base URL hostname 391func getServiceName(baseURL string) string { 392 // Extract from base URL 393 parsed, err := url.Parse(baseURL) 394 if err == nil && parsed.Hostname() != "" { 395 hostname := parsed.Hostname() 396 397 // Strip localhost/127.0.0.1 and use default 398 if hostname == "localhost" || hostname == "127.0.0.1" { 399 return "atcr.io" 400 } 401 402 return hostname 403 } 404 405 // Default fallback 406 return "atcr.io" 407}