// 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 Viper-based configuration with YAML // file support, environment variable overrides, and HTTP server setup for the AppView service. package appview import ( "crypto/rand" "encoding/hex" "fmt" "net/url" "os" "strings" "time" "github.com/distribution/distribution/v3/configuration" "github.com/spf13/viper" "atcr.io/pkg/appview/installscript" "atcr.io/pkg/appview/registryauth" "atcr.io/pkg/auth/token" "atcr.io/pkg/billing" "atcr.io/pkg/config" ) // Config represents the AppView service configuration type Config struct { Version string `yaml:"version" comment:"Configuration format version."` LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."` UI UIConfig `yaml:"ui" comment:"Web UI settings."` Health HealthConfig `yaml:"health" comment:"Health check and cache settings."` Leases LeasesConfig `yaml:"leases" comment:"Leader election for background workers. Required when running more than one AppView instance."` Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."` Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper branding and download settings."` Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."` Labeler LabelerRefConfig `yaml:"labeler" comment:"ATProto labeler for content moderation (DMCA takedowns)."` Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility // CredentialHelperBrand is CredentialHelper resolved and validated at load // time. Derived, not configured, so it stays out of the marshaled YAML. CredentialHelperBrand installscript.Brand `yaml:"-"` } // ServerConfig defines server settings type ServerConfig struct { // Listen address for the HTTP server. Addr string `yaml:"addr" comment:"Listen address, e.g. \":5000\" or \"127.0.0.1:5000\"."` // Public-facing URL for OAuth callbacks and JWT realm. BaseURL string `yaml:"base_url" comment:"Public-facing URL for OAuth callbacks and JWT realm. Auto-detected if empty."` // Display name shown on OAuth authorization screens. ClientName string `yaml:"client_name" comment:"Display name shown on OAuth authorization screens."` // Short name used in page titles and browser tabs. ClientShortName string `yaml:"client_short_name" comment:"Short name used in page titles and browser tabs."` // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display). RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."` // DIDs of holds this appview manages billing for. The first entry is also // the default blob-storage hold (used when a user has no hold selected). ManagedHolds []string `yaml:"managed_holds" comment:"DIDs of holds this appview manages billing for (REQUIRED). The first entry is the default blob-storage hold. Tier updates are pushed to these holds."` // Process-wide ceiling on memory held in blob upload buffers. Each // in-flight layer buffers up to 16MB, and Docker pushes five layers at a // time per client, so without a ceiling concurrent pushes are unbounded. // Writers block waiting for budget, which is backpressure on the client. UploadBufferBudgetMB int `yaml:"upload_buffer_budget_mb" comment:"Process-wide ceiling, in MB, on memory held in blob upload buffers. Pushes block (backpressure) rather than exceeding it. Raised to 16 MB (one buffer) if set lower."` // How long a blob upload may sit idle before it is reaped. UploadIdleTimeout time.Duration `yaml:"upload_idle_timeout" comment:"How long a blob upload may go without a write before it is treated as abandoned and cancelled. Measures inactivity, not age, so a slow push is never reaped."` } // PrimaryHoldDID returns the appview's default blob-storage hold, which is the // first managed hold. Empty if none configured. func (s ServerConfig) PrimaryHoldDID() string { if len(s.ManagedHolds) > 0 { return s.ManagedHolds[0] } return "" } // UIConfig defines web UI settings type UIConfig struct { // SQLite database path. DatabasePath string `yaml:"database_path" comment:"SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals."` // Visual theme name (e.g. "seamark"). Empty string uses default atcr.io branding. Theme string `yaml:"theme" comment:"Visual theme name (e.g. \"seamark\"). Empty uses default atcr.io branding."` // libSQL sync URL for embedded replicas. Works with Turso cloud or self-hosted libsql-server. // Leave empty for local-only SQLite mode (selfhost/dev). 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."` // Auth token for libSQL sync. Required if LibsqlSyncURL is set. LibsqlAuthToken string `yaml:"libsql_auth_token" comment:"Auth token for libSQL sync. Required if libsql_sync_url is set."` // How often to sync with the remote libSQL server. LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"How often to sync with remote libSQL server. Default: 60s."` // Source code URL displayed in the footer "Source" link. SourceURL string `yaml:"source_url" comment:"Source code URL displayed in the footer \"Source\" link. Defaults to the upstream ATCR project."` // Bluesky profile linked from the footer. Handle or DID; prefer a DID, // since handles change and a stale handle link breaks silently. BlueskyProfile string `yaml:"bluesky_profile" comment:"Bluesky handle or DID linked from the footer. Prefer a DID: handles can change. Defaults to the ATCR project account. Set to \"\" in YAML to hide the link (an empty env var will not do it: Viper treats it as unset)."` } // HealthConfig defines health check and cache settings type HealthConfig struct { // How long to cache hold health check results. CacheTTL time.Duration `yaml:"cache_ttl" comment:"How long to cache hold health check results."` // How often to refresh hold health checks. CheckInterval time.Duration `yaml:"check_interval" comment:"How often to refresh hold health checks."` } // LeasesConfig controls leader election for background workers. // // The Jetstream consumer, backfill worker, labeler subscribers and cleanup loop // must run on exactly one instance. Running two Jetstream consumers is not // merely wasteful: each aggregates hold stats in process memory and writes the // result to repository_stats as an absolute value, so two consumers overwrite // one another with partial sums, and every webhook fires twice. type LeasesConfig struct { // Enabled turns on database leader election. Leave it on unless you have a // specific reason not to; the cost is one small table and a renewal query // every renew_interval. Enabled bool `yaml:"enabled" comment:"Elect a single instance to run background workers. Required when running more than one AppView instance."` // TTL is how long a lease survives without renewal. TTL time.Duration `yaml:"ttl" comment:"How long a lease survives without renewal before another instance may take it. Must exceed any plausible clock skew between instances."` // RenewInterval is how often the holder extends its lease, and how often a // waiting instance retries. RenewInterval time.Duration `yaml:"renew_interval" comment:"How often the holder renews its lease, and how often waiting instances retry. Must be well under ttl."` } // JetstreamConfig defines ATProto Jetstream settings type JetstreamConfig struct { // Jetstream WebSocket endpoints, tried in order on failure. URLs []string `yaml:"urls" comment:"Jetstream WebSocket endpoints, tried in order on failure."` // Sync existing records from PDS on startup. BackfillEnabled bool `yaml:"backfill_enabled" comment:"Sync existing records from PDS on startup."` // How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup. BackfillInterval time.Duration `yaml:"backfill_interval" comment:"How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup."` // Relay endpoints for backfill — MUST support com.atproto.sync.listReposByCollection. Tried in order on failure. RelayEndpoints []string `yaml:"relay_endpoints" comment:"Endpoints used for backfill. MUST support com.atproto.sync.listReposByCollection. Tried in order on failure."` } // AuthConfig defines authentication settings type AuthConfig struct { // X.509 certificate matching the JWT signing key. CertPath string `yaml:"cert_path" comment:"X.509 certificate matching the JWT signing key (auto-generated on each boot from the JWT key in the database)."` // TokenExpiration is the JWT expiration duration (5 minutes, not configurable) TokenExpiration time.Duration `yaml:"-"` // Services is every registry domain that is a valid JWT audience, in // priority order. Each token is stamped with the front door the client // actually used, and each domain's access controller demands its own // audience. That is scoping, not a privilege boundary: every domain fronts // the same backend, so a client can obtain a token for any of them just by // handshaking there. // // This is derived from server.registry_domains rather than read directly, // because the two are not the same list: // // - Values are lowercased, port-stripped and deduplicated, so a // configured "127.0.0.1:5000" keys the same way as the port-stripped // r.Host it has to match. DomainRoutingMiddleware is handed this list // too, so both sides agree on one set of names. // - With no registry domains configured there is still exactly one valid // audience: the base URL hostname. That name cannot be written back // into registry_domains, because a non-empty list is what switches // DomainRoutingMiddleware on, and that would start rejecting /v2/ on // the only domain a single-domain deployment has. Services []string `yaml:"-"` } // PrimaryService is the first configured registry domain. It is the AppView's // own name — used as the registry JWT's issuer, one global signing identity — // and the audience given to requests arriving on a host that is not itself a // configured registry domain. Empty only on a Config that never went through // LoadConfig. func (a AuthConfig) PrimaryService() string { if len(a.Services) == 0 { return "" } return a.Services[0] } // CredentialHelperConfig defines credential helper download and branding settings type CredentialHelperConfig struct { // TangledRepo is the Tangled repository URL for downloads TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads. Defaults to the upstream ATCR release repo."` // Name is the credential helper brand for this deployment. Docker resolves // a credHelpers value "x" by executing "docker-credential-x", so this one // word is simultaneously the credHelpers value, the binary name suffix and // (by convention) the helper's config directory (~/.x). Rebranded // deployments set it so the install scripts and the install/settings pages // stop naming atcr's helper. Name string `yaml:"name" comment:"Credential helper brand: the docker credHelpers value, the docker-credential- binary suffix, and the ~/. config directory. Defaults to \"atcr\"."` } // LegalConfig defines legal page customization for self-hosted instances type LegalConfig struct { // Organization name for legal pages. Defaults to ClientName. CompanyName string `yaml:"company_name" comment:"Organization name for Terms of Service and Privacy Policy. Defaults to server.client_name."` // Governing law jurisdiction for legal terms. Jurisdiction string `yaml:"jurisdiction" comment:"Governing law jurisdiction for legal terms."` } // AIConfig defines AI-powered image advisor settings type AIConfig struct { // Anthropic API key for the AI Image Advisor feature. APIKey string `yaml:"api_key" comment:"Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback."` } // LabelerRefConfig defines the connection to an ATProto labeler service. type LabelerRefConfig struct { // DID of the labeler service for content moderation. The HTTP endpoint // is resolved at runtime via the labeler's #atproto_labeler service entry // in its DID document (plc.directory for did:plc, /.well-known/did.json // for did:web). DID string `yaml:"did" comment:"DID of the ATProto labeler (did:plc:... or did:web:...). Empty disables label filtering."` } // setDefaults registers all default values on the given Viper instance. func setDefaults(v *viper.Viper) { v.SetDefault("version", "0.1") v.SetDefault("log_level", "info") // Server defaults v.SetDefault("server.addr", ":5000") v.SetDefault("server.base_url", "") v.SetDefault("server.client_name", "AT Container Registry") v.SetDefault("server.client_short_name", "ATCR") v.SetDefault("server.registry_domains", []string{}) v.SetDefault("server.managed_holds", []string{}) v.SetDefault("server.upload_buffer_budget_mb", 512) v.SetDefault("server.upload_idle_timeout", "1h") // UI defaults v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db") v.SetDefault("ui.theme", "") v.SetDefault("ui.libsql_sync_url", "") v.SetDefault("ui.libsql_auth_token", "") v.SetDefault("ui.libsql_sync_interval", "60s") v.SetDefault("ui.source_url", "https://tangled.org/evan.jarrett.net/at-container-registry") v.SetDefault("ui.bluesky_profile", "did:plc:wfj5kyialpmcv2fzk6uqwsln") // Credential helper defaults. Registered so Viper binds the matching env // vars (ATCR_CREDENTIAL_HELPER_NAME, ATCR_CREDENTIAL_HELPER_TANGLED_REPO); // the real defaults are applied by installscript.NewBrand at load time. v.SetDefault("credential_helper.name", "") v.SetDefault("credential_helper.tangled_repo", "") // Health defaults v.SetDefault("health.cache_ttl", "15m") v.SetDefault("health.check_interval", "15m") // Lease defaults. On by default: a single instance is unaffected (it simply // always wins its own leases), while an operator who scales out without // reading the docs still gets correct behavior rather than duplicate // Jetstream consumers silently corrupting repository_stats. v.SetDefault("leases.enabled", true) v.SetDefault("leases.ttl", "60s") v.SetDefault("leases.renew_interval", "20s") // Jetstream defaults v.SetDefault("jetstream.urls", []string{ "wss://jetstream2.us-west.bsky.network/subscribe", "wss://jetstream1.us-west.bsky.network/subscribe", "wss://jetstream2.us-east.bsky.network/subscribe", "wss://jetstream1.us-east.bsky.network/subscribe", }) v.SetDefault("jetstream.backfill_enabled", true) v.SetDefault("jetstream.backfill_interval", "24h") v.SetDefault("jetstream.relay_endpoints", []string{ "https://relay1.us-east.bsky.network", "https://relay1.us-west.bsky.network", }) // Auth defaults v.SetDefault("auth.cert_path", "/var/lib/atcr/auth/private-key.crt") // Log shipper defaults v.SetDefault("log_shipper.batch_size", 100) v.SetDefault("log_shipper.flush_interval", "5s") // AI defaults v.SetDefault("ai.api_key", "") // Legal defaults v.SetDefault("legal.company_name", "") v.SetDefault("legal.jurisdiction", "") // Labeler defaults v.SetDefault("labeler.did", "") // Log formatter (used by distribution config, not in Config struct) v.SetDefault("log_formatter", "text") } // DefaultConfig returns a Config populated with all default values (no validation). func DefaultConfig() *Config { v := config.NewViper("ATCR", "") setDefaults(v) cfg := &Config{} _ = v.Unmarshal(cfg, config.UnmarshalOption()) return cfg } // ExampleYAML returns a fully-commented YAML configuration with default values. func ExampleYAML() ([]byte, error) { cfg := DefaultConfig() // Populate example billing tiers so operators see the structure cfg.Billing.Currency = "usd" cfg.Billing.SuccessURL = "{base_url}/settings/billing" cfg.Billing.CancelURL = "{base_url}/settings/billing" cfg.Billing.Tiers = []billing.BillingTierConfig{ {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1}, {Name: "bosun", Description: "More storage with scan-on-push", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true}, {Name: "quartermaster", Description: "Maximum storage for power users", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true}, } return config.MarshalCommentedYAML("ATCR AppView Configuration", cfg) } // LoadConfig builds a complete configuration using Viper layered loading: // defaults -> YAML file -> environment variables. // yamlPath is optional; empty string means env-only (backward compatible). func LoadConfig(yamlPath string) (*Config, error) { v := config.NewViper("ATCR", yamlPath) // Set defaults setDefaults(v) // Unmarshal into config struct cfg := &Config{} if err := v.Unmarshal(cfg, config.UnmarshalOption()); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } // Post-load: auto-detect base URL if not set if cfg.Server.BaseURL == "" { cfg.Server.BaseURL = autoDetectBaseURL(cfg.Server.Addr) } // Post-load: fixed values cfg.Auth.TokenExpiration = 5 * time.Minute cfg.Auth.Services = deriveServices(cfg) // Post-load: credential helper brand. NewBrand normalizes and validates, // because both fields are interpolated into the shell and PowerShell // install scripts we render at /static/install.{sh,ps1}. credBrand, err := installscript.NewBrand( cfg.CredentialHelper.Name, cfg.Server.ClientShortName, cfg.CredentialHelper.TangledRepo, ) if err != nil { return nil, fmt.Errorf("credential_helper.name: %w", err) } cfg.CredentialHelperBrand = credBrand cfg.CredentialHelper.Name = credBrand.Name cfg.CredentialHelper.TangledRepo = credBrand.ReleasesBaseURL // Post-load: CompanyName defaults to ClientName if cfg.Legal.CompanyName == "" { cfg.Legal.CompanyName = cfg.Server.ClientName } // Post-load: AI API key fallback to CLAUDE_API_KEY env if cfg.AI.APIKey == "" { cfg.AI.APIKey = os.Getenv("CLAUDE_API_KEY") } // Validation if len(cfg.Server.ManagedHolds) == 0 { return nil, fmt.Errorf("server.managed_holds is required (at least one hold DID; env: ATCR_SERVER_MANAGED_HOLDS)") } if cfg.Labeler.DID != "" && !strings.HasPrefix(cfg.Labeler.DID, "did:") { return nil, fmt.Errorf("labeler.did must be a DID (did:plc:... or did:web:...), got %q", cfg.Labeler.DID) } // Build distribution config (unchanged) distConfig, err := buildDistributionConfig(cfg, v) if err != nil { return nil, fmt.Errorf("failed to build distribution config: %w", err) } cfg.Distribution = distConfig return cfg, nil } // deriveServices returns every registry domain that is a valid JWT audience, // normalized to bare lowercase hostnames and deduplicated. The first entry is // the primary: the name advertised on any host that is not itself a configured // registry domain, and the fallback audience. // // Always returns at least one entry. With no registry_domains configured the // AppView serves the registry on its base URL, so that hostname is the only // service. func deriveServices(cfg *Config) []string { services := make([]string, 0, len(cfg.Server.RegistryDomains)) seen := make(map[string]bool, len(cfg.Server.RegistryDomains)) for _, domain := range cfg.Server.RegistryDomains { // Ports are stripped so these match the port-stripped r.Host that // DomainRoutingMiddleware and the access controller compare against. name := token.NormalizeService(domain) if name == "" || seen[name] { continue } seen[name] = true services = append(services, name) } if len(services) == 0 { return []string{token.NormalizeService(getServiceName(cfg.Server.BaseURL))} } return services } // buildDistributionConfig creates a distribution Configuration from our Config // This maintains compatibility with the distribution library func buildDistributionConfig(cfg *Config, v *viper.Viper) (*configuration.Configuration, error) { distConfig := &configuration.Configuration{} // Version distConfig.Version = configuration.MajorMinorVersion(0, 1) // Logging logFormatter := v.GetString("log_formatter") if logFormatter == "" { logFormatter = "text" } distConfig.Log = configuration.Log{ Level: configuration.Loglevel(cfg.LogLevel), Formatter: logFormatter, 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"}, }, } // Storage (fake in-memory placeholder - all real storage is proxied) distConfig.Storage = buildStorageConfig() // Middleware (ATProto resolver) distConfig.Middleware = buildMiddlewareConfig(cfg.Server.PrimaryHoldDID(), cfg.Server.BaseURL) // Auth (use values from cfg.Auth) // // Realm always points to BaseURL, where the auth endpoints live. Registry // domains also serve /auth/token directly (see DomainRoutingMiddleware), // but a single realm keeps the handshake identical everywhere. // // Service is per front door: a push to atcr.io is challenged with // service="atcr.io" and gets a JWT with that audience, even though the // realm is on the UI domain. Docker's WWW-Authenticate on atcr.io reads // realm="https://seamark.dev/auth/token",service="atcr.io". The issuer // stays global — one signing identity, many audiences. realm := cfg.Server.BaseURL + "/auth/token" distConfig.Auth = configuration.Auth{ registryauth.AuthType: configuration.Parameters{ "realm": realm, "services": cfg.Auth.Services, "issuer": cfg.Auth.PrimaryService(), "rootcertbundle": cfg.Auth.CertPath, "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, }, } // Enable manifest deletion. distribution v3.1.1's DeleteManifest handler // short-circuits with an UNSUPPORTED error unless app.deleteEnabled is set, // which comes from this flag (see registry/handlers/manifests.go). Without // it, `skopeo delete` / OCI DELETE returns "unsupported" before ever reaching // our ATProto-backed ManifestStore.Delete / TagStore.Untag. The companion // storage.EnableDelete option this appends only affects distribution's own // built-in store, which we replace via RoutingRepository, so it's a no-op. storage["delete"] = configuration.Parameters{ "enabled": true, } return storage } // buildMiddlewareConfig creates middleware configuration func buildMiddlewareConfig(defaultHoldDID string, baseURL string) map[string][]configuration.Middleware { return map[string][]configuration.Middleware{ "registry": { { Name: "atproto-resolver", Options: configuration.Parameters{ "default_hold_did": defaultHoldDID, "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 hostname func getServiceName(baseURL string) string { // 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" }