mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
The link was hardcoded beforefa34da0too — it pointed at bsky.app/profile/atcr.io. That commit was right to switch to a DID, since handles change and a stale handle link breaks silently, but the DID went into components/footer.html, a shared template. Every self-hoster's footer therefore links to the project's Bluesky account. Now ui.bluesky_profile, following source_url in the same footer exactly: config field with a default, plumbed through UIDependencies and PageData, and guarded with {{ with }} so an unset value omits the link rather than rendering something wrong. It takes a handle or a DID; the comment says to prefer a DID for the reasonfa34da0changed it. Defaulting to the project account matches source_url's logic — both name the upstream project rather than the operator — and self-hosters who want their own or none set one line. The aria-label switched to $.ClientShortName: `with` rebinds the dot, so the label would otherwise have silently rendered empty. Two things worth recording: * `{{ with }}` hides the link on an empty value, but you cannot get an empty value from the environment. Viper runs with AllowEmptyEnv(false), so an empty env var reads as unset and the default wins. Only "" in YAML works. That applies to every string field in this config, not just this one, and the comment now says so. * config-appview.example.yaml must NOT be regenerated with `config init`, despite what the checklist in CLAUDE.md says. The file is hand-curated well past the defaults, and regenerating replaces real Stripe price IDs with price_xxx placeholders and blanks registry_domains, managed_holds, theme and the tier names. Added by hand instead. Verified live both ways: the configured value renders, and `bluesky_profile: ""` in YAML drops the link while leaving the Source link intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh
569 lines
24 KiB
Go
569 lines
24 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."`
|
|
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 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."`
|
|
|
|
// 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 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")
|
|
v.SetDefault("ui.bluesky_profile", "did:plc:wfj5kyialpmcv2fzk6uqwsln")
|
|
|
|
// 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)
|
|
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"
|
|
}
|