A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}