mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16:54:15 +00:00
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>
539 lines
22 KiB
Go
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"
|
|
}
|