404 lines
14 KiB
Go
404 lines
14 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 environment-based configuration,
|
|
// middleware registration, and HTTP server setup for the AppView service.
|
|
package appview
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/distribution/distribution/v3/configuration"
|
|
)
|
|
|
|
// Config represents the AppView service configuration
|
|
type Config struct {
|
|
Version string `yaml:"version"`
|
|
LogLevel string `yaml:"log_level"`
|
|
Server ServerConfig `yaml:"server"`
|
|
UI UIConfig `yaml:"ui"`
|
|
Health HealthConfig `yaml:"health"`
|
|
Jetstream JetstreamConfig `yaml:"jetstream"`
|
|
Auth AuthConfig `yaml:"auth"`
|
|
CredentialHelper CredentialHelperConfig `yaml:"credential_helper"`
|
|
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
|
|
}
|
|
|
|
// ServerConfig defines server settings
|
|
type ServerConfig struct {
|
|
// Addr is the HTTP listen address (from env: ATCR_HTTP_ADDR, default: ":5000")
|
|
Addr string `yaml:"addr"`
|
|
|
|
// BaseURL is the public URL for OAuth/JWT realm (from env: ATCR_BASE_URL)
|
|
// Auto-detected from Addr if not set
|
|
BaseURL string `yaml:"base_url"`
|
|
|
|
// DefaultHoldDID is the default hold DID for blob storage (from env: ATCR_DEFAULT_HOLD_DID)
|
|
// REQUIRED - e.g., "did:web:hold01.atcr.io"
|
|
DefaultHoldDID string `yaml:"default_hold_did"`
|
|
|
|
// TestMode enables HTTP for local DID resolution and transition:generic scope (from env: TEST_MODE)
|
|
TestMode bool `yaml:"test_mode"`
|
|
|
|
// DebugAddr is the debug/pprof HTTP listen address (from env: ATCR_DEBUG_ADDR, default: ":5001")
|
|
DebugAddr string `yaml:"debug_addr"`
|
|
|
|
// OAuthKeyPath is the path to the OAuth client P-256 signing key (from env: ATCR_OAUTH_KEY_PATH, default: "/var/lib/atcr/oauth/client.key")
|
|
// Auto-generated on first run for production (non-localhost) deployments
|
|
OAuthKeyPath string `yaml:"oauth_key_path"`
|
|
|
|
// ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
|
|
// Shown in OAuth authorization screens
|
|
ClientName string `yaml:"client_name"`
|
|
}
|
|
|
|
// UIConfig defines web UI settings
|
|
type UIConfig struct {
|
|
// Enabled controls whether the web UI is enabled (from env: ATCR_UI_ENABLED, default: true)
|
|
Enabled bool `yaml:"enabled"`
|
|
|
|
// DatabasePath is the path to the UI SQLite database (from env: ATCR_UI_DATABASE_PATH, default: "/var/lib/atcr/ui.db")
|
|
DatabasePath string `yaml:"database_path"`
|
|
|
|
// SkipDBMigrations controls whether to skip running database migrations (from env: SKIP_DB_MIGRATIONS, default: false)
|
|
SkipDBMigrations bool `yaml:"skip_db_migrations"`
|
|
}
|
|
|
|
// HealthConfig defines health check and cache settings
|
|
type HealthConfig struct {
|
|
// CacheTTL is the hold health check cache TTL (from env: ATCR_HEALTH_CACHE_TTL, default: 15m)
|
|
CacheTTL time.Duration `yaml:"cache_ttl"`
|
|
|
|
// CheckInterval is the hold health check refresh interval (from env: ATCR_HEALTH_CHECK_INTERVAL, default: 15m)
|
|
CheckInterval time.Duration `yaml:"check_interval"`
|
|
}
|
|
|
|
// JetstreamConfig defines ATProto Jetstream settings
|
|
type JetstreamConfig struct {
|
|
// URL is the Jetstream WebSocket URL (from env: JETSTREAM_URL, default: wss://jetstream2.us-west.bsky.network/subscribe)
|
|
URL string `yaml:"url"`
|
|
|
|
// BackfillEnabled controls whether backfill is enabled (from env: ATCR_BACKFILL_ENABLED, default: true)
|
|
BackfillEnabled bool `yaml:"backfill_enabled"`
|
|
|
|
// BackfillInterval is the backfill interval (from env: ATCR_BACKFILL_INTERVAL, default: 1h)
|
|
BackfillInterval time.Duration `yaml:"backfill_interval"`
|
|
|
|
// RelayEndpoint is the relay endpoint for sync API (from env: ATCR_RELAY_ENDPOINT, default: https://relay1.us-east.bsky.network)
|
|
RelayEndpoint string `yaml:"relay_endpoint"`
|
|
}
|
|
|
|
// AuthConfig defines authentication settings
|
|
type AuthConfig struct {
|
|
// KeyPath is the JWT signing key path (from env: ATCR_AUTH_KEY_PATH, default: "/var/lib/atcr/auth/private-key.pem")
|
|
KeyPath string `yaml:"key_path"`
|
|
|
|
// CertPath is the JWT certificate path (from env: ATCR_AUTH_CERT_PATH, default: "/var/lib/atcr/auth/private-key.crt")
|
|
CertPath string `yaml:"cert_path"`
|
|
|
|
// TokenExpiration is the JWT expiration duration (from env: ATCR_TOKEN_EXPIRATION, default: 300s)
|
|
TokenExpiration time.Duration `yaml:"token_expiration"`
|
|
|
|
// ServiceName is the service name used for JWT issuer and service fields
|
|
// Derived from ATCR_SERVICE_NAME env var or extracted from base URL (e.g., "atcr.io")
|
|
ServiceName string `yaml:"service_name"`
|
|
}
|
|
|
|
// CredentialHelperConfig defines credential helper version and download settings
|
|
type CredentialHelperConfig struct {
|
|
// Version is the latest credential helper version (from env: ATCR_CREDENTIAL_HELPER_VERSION)
|
|
// e.g., "v0.0.2"
|
|
Version string `yaml:"version"`
|
|
|
|
// TangledRepo is the Tangled repository URL for downloads (from env: ATCR_CREDENTIAL_HELPER_TANGLED_REPO)
|
|
// Default: "https://tangled.org/@evan.jarrett.net/at-container-registry"
|
|
TangledRepo string `yaml:"tangled_repo"`
|
|
|
|
// Checksums is a comma-separated list of platform:sha256 pairs (from env: ATCR_CREDENTIAL_HELPER_CHECKSUMS)
|
|
// e.g., "linux_amd64:abc123,darwin_arm64:def456"
|
|
Checksums map[string]string `yaml:"-"`
|
|
}
|
|
|
|
// LoadConfigFromEnv builds a complete configuration from environment variables
|
|
// This follows the same pattern as the hold service (no config files, only env vars)
|
|
func LoadConfigFromEnv() (*Config, error) {
|
|
cfg := &Config{
|
|
Version: "0.1",
|
|
}
|
|
|
|
// Logging configuration
|
|
cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info")
|
|
|
|
// Server configuration
|
|
cfg.Server.Addr = getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
|
|
cfg.Server.DebugAddr = getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
|
|
cfg.Server.DefaultHoldDID = os.Getenv("ATCR_DEFAULT_HOLD_DID")
|
|
if cfg.Server.DefaultHoldDID == "" {
|
|
return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
|
|
}
|
|
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
|
|
cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
|
|
cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry")
|
|
|
|
// Auto-detect base URL if not explicitly set
|
|
cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
|
|
if cfg.Server.BaseURL == "" {
|
|
cfg.Server.BaseURL = autoDetectBaseURL(cfg.Server.Addr)
|
|
}
|
|
|
|
// UI configuration
|
|
cfg.UI.Enabled = os.Getenv("ATCR_UI_ENABLED") != "false"
|
|
cfg.UI.DatabasePath = getEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db")
|
|
cfg.UI.SkipDBMigrations = os.Getenv("SKIP_DB_MIGRATIONS") == "true"
|
|
|
|
// Health and cache configuration
|
|
cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute)
|
|
cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute)
|
|
|
|
// Jetstream configuration
|
|
cfg.Jetstream.URL = getEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe")
|
|
cfg.Jetstream.BackfillEnabled = os.Getenv("ATCR_BACKFILL_ENABLED") != "false"
|
|
cfg.Jetstream.BackfillInterval = getDurationOrDefault("ATCR_BACKFILL_INTERVAL", 1*time.Hour)
|
|
cfg.Jetstream.RelayEndpoint = getEnvOrDefault("ATCR_RELAY_ENDPOINT", "https://relay1.us-east.bsky.network")
|
|
|
|
// Auth configuration
|
|
cfg.Auth.KeyPath = getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem")
|
|
cfg.Auth.CertPath = getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt")
|
|
|
|
// Parse token expiration (default: 300 seconds = 5 minutes)
|
|
expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300")
|
|
expirationSecs, err := strconv.Atoi(expirationStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err)
|
|
}
|
|
cfg.Auth.TokenExpiration = time.Duration(expirationSecs) * time.Second
|
|
|
|
// Derive service name from base URL or env var (used for JWT issuer and service)
|
|
cfg.Auth.ServiceName = getServiceName(cfg.Server.BaseURL)
|
|
|
|
// Credential helper configuration
|
|
cfg.CredentialHelper.Version = os.Getenv("ATCR_CREDENTIAL_HELPER_VERSION")
|
|
cfg.CredentialHelper.TangledRepo = getEnvOrDefault("ATCR_CREDENTIAL_HELPER_TANGLED_REPO", "https://tangled.org/@evan.jarrett.net/at-container-registry")
|
|
cfg.CredentialHelper.Checksums = parseChecksums(os.Getenv("ATCR_CREDENTIAL_HELPER_CHECKSUMS"))
|
|
|
|
// Build distribution configuration for compatibility with distribution library
|
|
distConfig, err := buildDistributionConfig(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build distribution config: %w", err)
|
|
}
|
|
cfg.Distribution = distConfig
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// buildDistributionConfig creates a distribution Configuration from our Config
|
|
// This maintains compatibility with the distribution library
|
|
func buildDistributionConfig(cfg *Config) (*configuration.Configuration, error) {
|
|
distConfig := &configuration.Configuration{}
|
|
|
|
// Version
|
|
distConfig.Version = configuration.MajorMinorVersion(0, 1)
|
|
|
|
// Logging
|
|
distConfig.Log = configuration.Log{
|
|
Level: configuration.Loglevel(cfg.LogLevel),
|
|
Formatter: getEnvOrDefault("ATCR_LOG_FORMATTER", "text"),
|
|
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"},
|
|
},
|
|
Debug: configuration.Debug{
|
|
Addr: cfg.Server.DebugAddr,
|
|
},
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Auth (use values from cfg.Auth)
|
|
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,
|
|
"privatekey": cfg.Auth.KeyPath,
|
|
"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) map[string][]configuration.Middleware {
|
|
// Check test mode
|
|
testMode := os.Getenv("TEST_MODE") == "true"
|
|
|
|
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 or uses env var
|
|
func getServiceName(baseURL string) string {
|
|
// Check env var first
|
|
if serviceName := os.Getenv("ATCR_SERVICE_NAME"); serviceName != "" {
|
|
return serviceName
|
|
}
|
|
|
|
// Try to 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"
|
|
}
|
|
|
|
// getEnvOrDefault gets an environment variable or returns a default value
|
|
func getEnvOrDefault(key, defaultValue string) string {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// getDurationOrDefault parses a duration from environment variable or returns default
|
|
// Logs a warning if parsing fails
|
|
func getDurationOrDefault(envKey string, defaultValue time.Duration) time.Duration {
|
|
envVal := os.Getenv(envKey)
|
|
if envVal == "" {
|
|
return defaultValue
|
|
}
|
|
|
|
parsed, err := time.ParseDuration(envVal)
|
|
if err != nil {
|
|
slog.Warn("Invalid duration, using default", "env_key", envKey, "env_value", envVal, "default", defaultValue)
|
|
return defaultValue
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
// parseChecksums parses a comma-separated list of platform:sha256 pairs
|
|
// e.g., "linux_amd64:abc123,darwin_arm64:def456"
|
|
func parseChecksums(checksumsStr string) map[string]string {
|
|
checksums := make(map[string]string)
|
|
if checksumsStr == "" {
|
|
return checksums
|
|
}
|
|
|
|
pairs := strings.Split(checksumsStr, ",")
|
|
for _, pair := range pairs {
|
|
parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
|
|
if len(parts) == 2 {
|
|
platform := strings.TrimSpace(parts[0])
|
|
hash := strings.TrimSpace(parts[1])
|
|
if platform != "" && hash != "" {
|
|
checksums[platform] = hash
|
|
}
|
|
}
|
|
}
|
|
return checksums
|
|
}
|