mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
317 lines
9.0 KiB
Go
317 lines
9.0 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"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/distribution/distribution/v3/configuration"
|
|
)
|
|
|
|
// 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() (*configuration.Configuration, error) {
|
|
config := &configuration.Configuration{}
|
|
|
|
// Version
|
|
config.Version = configuration.MajorMinorVersion(0, 1)
|
|
|
|
// Logging
|
|
config.Log = buildLogConfig()
|
|
|
|
// HTTP server
|
|
httpConfig, err := buildHTTPConfig()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build HTTP config: %w", err)
|
|
}
|
|
config.HTTP = httpConfig
|
|
|
|
// Storage (fake in-memory placeholder - all real storage is proxied)
|
|
config.Storage = buildStorageConfig()
|
|
|
|
// Get base URL for error messages and auth config
|
|
baseURL := GetBaseURL(httpConfig.Addr)
|
|
|
|
// Middleware (ATProto resolver)
|
|
defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID")
|
|
if defaultHoldDID == "" {
|
|
return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
|
|
}
|
|
config.Middleware = buildMiddlewareConfig(defaultHoldDID, baseURL)
|
|
|
|
// Auth
|
|
authConfig, err := buildAuthConfig(baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build auth config: %w", err)
|
|
}
|
|
config.Auth = authConfig
|
|
|
|
// Health checks
|
|
config.Health = buildHealthConfig()
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// buildLogConfig creates logging configuration from environment variables
|
|
func buildLogConfig() configuration.Log {
|
|
level := GetEnvOrDefault("ATCR_LOG_LEVEL", "info")
|
|
formatter := GetEnvOrDefault("ATCR_LOG_FORMATTER", "text")
|
|
|
|
return configuration.Log{
|
|
Level: configuration.Loglevel(level),
|
|
Formatter: formatter,
|
|
Fields: map[string]any{
|
|
"service": "atcr-appview",
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildHTTPConfig creates HTTP server configuration from environment variables
|
|
func buildHTTPConfig() (configuration.HTTP, error) {
|
|
addr := GetEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
|
|
debugAddr := GetEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
|
|
|
|
// HTTP secret - only needed for multipart uploads in distribution's storage driver
|
|
// Since AppView is stateless and routes all storage through middleware, this isn't
|
|
// actually used, but we generate a random secret for defense in depth
|
|
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 configuration.HTTP{}, fmt.Errorf("failed to generate random secret: %w", err)
|
|
}
|
|
httpSecret = hex.EncodeToString(randomBytes)
|
|
}
|
|
|
|
return configuration.HTTP{
|
|
Addr: addr,
|
|
Secret: httpSecret,
|
|
Headers: map[string][]string{
|
|
"X-Content-Type-Options": {"nosniff"},
|
|
},
|
|
Debug: configuration.Debug{
|
|
Addr: debugAddr,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildAuthConfig creates authentication configuration from environment variables
|
|
func buildAuthConfig(baseURL string) (configuration.Auth, error) {
|
|
// Token configuration
|
|
privateKeyPath := GetEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem")
|
|
certPath := GetEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt")
|
|
|
|
// Token expiration in seconds (default: 5 minutes)
|
|
expirationStr := GetEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300")
|
|
expiration, err := strconv.Atoi(expirationStr)
|
|
if err != nil {
|
|
return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err)
|
|
}
|
|
|
|
// Auto-derive service name from base URL or use env var
|
|
serviceName := getServiceName(baseURL)
|
|
|
|
// Auto-derive realm from base URL
|
|
realm := baseURL + "/auth/token"
|
|
|
|
return configuration.Auth{
|
|
"token": configuration.Parameters{
|
|
"realm": realm,
|
|
"service": serviceName,
|
|
"issuer": serviceName,
|
|
"rootcertbundle": certPath,
|
|
"privatekey": privateKeyPath,
|
|
"expiration": expiration,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// buildHealthConfig creates health check configuration
|
|
func buildHealthConfig() configuration.Health {
|
|
return configuration.Health{
|
|
StorageDriver: configuration.StorageDriver{
|
|
Enabled: true,
|
|
Interval: 10 * time.Second,
|
|
Threshold: 3,
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetBaseURL determines the base URL for the service
|
|
// Priority: ATCR_BASE_URL env var, then derived from HTTP addr
|
|
func GetBaseURL(httpAddr string) string {
|
|
baseURL := os.Getenv("ATCR_BASE_URL")
|
|
if baseURL != "" {
|
|
return baseURL
|
|
}
|
|
|
|
// Auto-detect from HTTP addr
|
|
if httpAddr[0] == ':' {
|
|
// Just a port, assume localhost
|
|
return fmt.Sprintf("http://127.0.0.1%s", httpAddr)
|
|
}
|
|
|
|
// Full address provided
|
|
return fmt.Sprintf("http://%s", httpAddr)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetStringParam extracts a string parameter from configuration.Parameters
|
|
func GetStringParam(params configuration.Parameters, key, defaultValue string) string {
|
|
if v, ok := params[key]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// GetIntParam extracts an int parameter from configuration.Parameters
|
|
func GetIntParam(params configuration.Parameters, key string, defaultValue int) int {
|
|
if v, ok := params[key]; ok {
|
|
if i, ok := v.(int); ok {
|
|
return i
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// ExtractDefaultHoldDID extracts the default hold DID from middleware config
|
|
// Returns a DID (e.g., "did:web:hold01.atcr.io")
|
|
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
|
|
func ExtractDefaultHoldDID(config *configuration.Configuration) string {
|
|
// Navigate through: middleware.registry[].options.default_hold_did
|
|
registryMiddleware, ok := config.Middleware["registry"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
// Find atproto-resolver middleware
|
|
for _, mw := range registryMiddleware {
|
|
// Check if this is the atproto-resolver
|
|
if mw.Name != "atproto-resolver" {
|
|
continue
|
|
}
|
|
|
|
// Extract options - options is configuration.Parameters which is map[string]any
|
|
if mw.Options != nil {
|
|
if holdDID, ok := mw.Options["default_hold_did"].(string); ok {
|
|
return holdDID
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ExtractTestMode extracts the test_mode flag from middleware config
|
|
// Returns true if TEST_MODE=true, false otherwise
|
|
func ExtractTestMode(config *configuration.Configuration) bool {
|
|
// Navigate through: middleware.registry[].options.test_mode
|
|
registryMiddleware, ok := config.Middleware["registry"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
// Find atproto-resolver middleware
|
|
for _, mw := range registryMiddleware {
|
|
// Check if this is the atproto-resolver
|
|
if mw.Name != "atproto-resolver" {
|
|
continue
|
|
}
|
|
|
|
// Extract options - options is configuration.Parameters which is map[string]any
|
|
if mw.Options != nil {
|
|
if testMode, ok := mw.Options["test_mode"].(bool); ok {
|
|
return testMode
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|