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