Files
at-container-registry/pkg/appview/config.go
T
Evan JarrettandClaude Opus 5 a7569a7717 registry: allow anonymous pull of public images
Credential-less pulls of public images. /auth/token issues a pull-only
token with an empty subject when no Basic auth is present; the
destination hold still enforces captain.Public, and push or delete always
challenges.

  - token.IsPullOnlyScope and AuthMethodAnonymous;
    Handler.issueAnonymousToken skips the authorizer gate and the
    service-auth pre-mint, since there is no identity to reconcile and no
    AppView-to-hold service token to bind. The token is still stamped
    with the resolved registry domain, so anonymous pull works on
    secondary front doors whose access controller demands their own
    audience.
  - auth.allow_anonymous_pull (default true) turns it fully off, restoring
    the previous always-challenge behavior. Mirrored into the deploy
    template, since the default means existing deploys pick this up.
  - RegistryContext.Anonymous is plumbed from the middleware.
  - ProxyBlobStore sends no Authorization header when the service token is
    empty, and returns 401 rather than 403 for anonymous denials so Docker
    prompts for credentials, including when a stale captain cache lets the
    request through and the hold says private.
  - BearerChallenge wraps the /v2/ subtree so a 401 raised deep in the
    stack via errcode.ServeJSON still carries WWW-Authenticate.
    Distribution's own scoped challenges are left alone.

IsPullOnlyScope allowlists the pull action instead of denylisting push and
delete. Distribution's actionSet.contains treats "*" as *every* action, so
a scope of `repository:victim/img:*` names neither denied string and would
have handed an unauthenticated caller a token valid for push and delete on
someone else's repository — clearing the authgate entirely, since anonymous
tokens deliberately skip it. Writes would still have failed further down
(no PDS credential), but the gate itself was bypassable. Now every
requested action must be exactly "pull". Covered by new claims tests.

Unresolvable identities return NAME_UNKNOWN instead of a bare error that
distribution renders as 500. This path was previously unreachable without
credentials; anonymous pull opens it to the internet, and a 5xx on
arbitrary input both misreports a bad request as a server fault and sends
clients that retry 5xx into a retry loop. That loop was real: in the auth
matrix, regclient spent 83s on a single case before this fix, and the
suite now runs in 5s.

Stat preserves an authorization verdict from getPresignedURL rather than
flattening it to ErrBlobUnknown. Distribution calls Stat before ServeBlob
on GET and HEAD, so without this an anonymous pull from a private hold
answered 404 and BearerChallenge had no 401 to annotate — the 401 path
above could never actually reach a client.

The auth matrix is updated to match: anonymous pull of the seeded public
repo now succeeds, anonymous push is denied against a real identity's
namespace (rather than an unresolvable one, which was testing name
resolution rather than authorization), and a new case pins the
NAME_UNKNOWN behavior for an unknown identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00

539 lines
22 KiB
Go

// 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/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."`
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 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
}
// 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."`
// Allows HTTP (not HTTPS) for DID resolution.
TestMode bool `yaml:"test_mode" comment:"Allows HTTP (not HTTPS) for DID resolution and uses transition:generic OAuth scope."`
// 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."`
}
// 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."`
}
// 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."`
}
// 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)."`
// AllowAnonymousPull permits credential-less Docker pulls. Per-hold privacy
// (captain.Public) still applies — a private hold rejects anonymous reads.
AllowAnonymousPull bool `yaml:"allow_anonymous_pull" comment:"Allow unauthenticated Docker pulls from public holds. Per-hold privacy (captain.Public) still applies. Default true."`
// 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 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."`
}
// 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.test_mode", false)
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{})
// 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")
// Health defaults
v.SetDefault("health.cache_ttl", "15m")
v.SetDefault("health.check_interval", "15m")
// 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")
v.SetDefault("auth.allow_anonymous_pull", true)
// 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)
cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry"
// 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, cfg.Server.TestMode)
// 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, testMode bool) map[string][]configuration.Middleware {
return map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"default_hold_did": defaultHoldDID,
"test_mode": testMode,
"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"
}