mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 08:30:29 +00:00
437 lines
16 KiB
Go
437 lines
16 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"
|
|
"time"
|
|
|
|
"github.com/distribution/distribution/v3/configuration"
|
|
"github.com/spf13/viper"
|
|
|
|
"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."`
|
|
Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
|
|
AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."`
|
|
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."`
|
|
|
|
// DID of the default hold service for blob storage.
|
|
DefaultHoldDID string `yaml:"default_hold_did" comment:"DID of the hold service for blob storage, e.g. \"did:web:hold01.atcr.io\" (REQUIRED)."`
|
|
|
|
// 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."`
|
|
|
|
// Path to P-256 private key for OAuth client authentication.
|
|
OAuthKeyPath string `yaml:"oauth_key_path" comment:"Path to P-256 private key for OAuth client authentication. Auto-generated on first run."`
|
|
|
|
// 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.
|
|
ManagedHolds []string `yaml:"managed_holds" comment:"DIDs of holds this appview manages billing for. Tier updates are pushed to these holds."`
|
|
}
|
|
|
|
// 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."`
|
|
}
|
|
|
|
// 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, tried in order on failure.
|
|
RelayEndpoints []string `yaml:"relay_endpoints" comment:"Relay endpoints for backfill, tried in order on failure."`
|
|
}
|
|
|
|
// AuthConfig defines authentication settings
|
|
type AuthConfig struct {
|
|
// RSA private key for signing registry JWTs.
|
|
KeyPath string `yaml:"key_path" comment:"RSA private key for signing registry JWTs issued to Docker clients."`
|
|
|
|
// X.509 certificate matching the JWT signing key.
|
|
CertPath string `yaml:"cert_path" comment:"X.509 certificate matching the JWT signing key."`
|
|
|
|
// TokenExpiration is the JWT expiration duration (5 minutes, not configurable)
|
|
TokenExpiration time.Duration `yaml:"-"`
|
|
|
|
// ServiceName is the service name used for JWT issuer and service fields.
|
|
// Derived from base URL hostname (e.g., "atcr.io")
|
|
ServiceName string `yaml:"-"`
|
|
}
|
|
|
|
// 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."`
|
|
}
|
|
|
|
// 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.default_hold_did", "")
|
|
v.SetDefault("server.test_mode", false)
|
|
v.SetDefault("server.client_name", "AT Container Registry")
|
|
v.SetDefault("server.client_short_name", "ATCR")
|
|
v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key")
|
|
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")
|
|
|
|
// 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.key_path", "/var/lib/atcr/auth/private-key.pem")
|
|
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", "")
|
|
|
|
// 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.OwnerBadge = true
|
|
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.ServiceName = deriveServiceName(cfg)
|
|
|
|
// 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 cfg.Server.DefaultHoldDID == "" {
|
|
return nil, fmt.Errorf("server.default_hold_did is required (env: ATCR_SERVER_DEFAULT_HOLD_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
|
|
}
|
|
|
|
// deriveServiceName extracts the JWT service name from the config.
|
|
func deriveServiceName(cfg *Config) string {
|
|
if len(cfg.Server.RegistryDomains) > 0 {
|
|
return cfg.Server.RegistryDomains[0]
|
|
}
|
|
return getServiceName(cfg.Server.BaseURL)
|
|
}
|
|
|
|
// 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.DefaultHoldDID, cfg.Server.BaseURL, cfg.Server.TestMode)
|
|
|
|
// Auth (use values from cfg.Auth)
|
|
// Realm always points to BaseURL where auth endpoints live
|
|
// Docker's WWW-Authenticate: realm="https://seamark.dev/auth/token",service="buoy.cr"
|
|
realm := cfg.Server.BaseURL + "/auth/token"
|
|
|
|
distConfig.Auth = configuration.Auth{
|
|
"token": configuration.Parameters{
|
|
"realm": realm,
|
|
"service": cfg.Auth.ServiceName,
|
|
"issuer": cfg.Auth.ServiceName,
|
|
"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,
|
|
},
|
|
}
|
|
|
|
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"
|
|
}
|