mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16:54:15 +00:00
seamark.dev's /install and /settings/devices told users to pipe
seamark.dev/static/install.sh into bash. That file was the unmodified ATCR
script: it announced itself as the "ATCR Credential Helper Installer",
installed docker-credential-atcr, and finished by telling the user to configure
credHelpers for atcr.io, the wrong registry for that deployment. Anyone
following the documented setup ended up pointed at another service. The
templates hardcoded docker-credential-atcr, "atcr" and ~/.atcr/device.json
alongside a correctly themed {{ .RegistryURL }}.
The scripts are now rendered from config by a handler, rather than forked per
brand. A theme overlay was the alternative and was worse: it needed a full copy
of both install.sh and install.ps1 per brand, four scripts to keep in sync, and
the operator asked for these values to come from config.
credential_helper.name is the single knob. Docker resolves a credHelpers value
x by exec'ing docker-credential-x, so the credHelpers value, the binary suffix
and the config directory are genuinely one word, not three that can drift. It
is validated against a strict pattern because it is interpolated into a shell
script.
install.sh renders byte-identical to the deleted static file under the atcr
default, so existing installs are unaffected. install.ps1 differs by one line,
where a stale usage comment named a path the script is not served at.
Two behaviour changes worth noting: these two URLs drop from a one-year
Cache-Control to five minutes, since the body now depends on deployment config;
and credential_helper.tangled_repo becomes a real overridable default. It was
previously assigned over unconditionally and read by nothing, while the shipped
script used a different URL form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
602 lines
25 KiB
Go
602 lines
25 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/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."`
|
|
|
|
// 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 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-<name> binary suffix, and the ~/.<name> 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.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")
|
|
|
|
// 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, 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"
|
|
}
|