mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
347 lines
13 KiB
Go
347 lines
13 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/distribution/distribution/v3"
|
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
|
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
|
|
"github.com/distribution/distribution/v3/registry/storage/driver"
|
|
"github.com/distribution/reference"
|
|
|
|
"atcr.io/pkg/appview/storage"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"atcr.io/pkg/auth/oauth"
|
|
"atcr.io/pkg/auth/token"
|
|
)
|
|
|
|
// holdDIDKey is the context key for storing hold DID
|
|
const holdDIDKey contextKey = "hold.did"
|
|
|
|
// Global variables for initialization only
|
|
// These are set by main.go during startup and copied into NamespaceResolver instances.
|
|
// After initialization, request handling uses the NamespaceResolver's instance fields.
|
|
var (
|
|
globalRefresher *oauth.Refresher
|
|
globalDatabase storage.DatabaseMetrics
|
|
globalAuthorizer auth.HoldAuthorizer
|
|
globalReadmeCache storage.ReadmeCache
|
|
)
|
|
|
|
// SetGlobalRefresher sets the OAuth refresher instance during initialization
|
|
// Must be called before the registry starts serving requests
|
|
func SetGlobalRefresher(refresher *oauth.Refresher) {
|
|
globalRefresher = refresher
|
|
}
|
|
|
|
// SetGlobalDatabase sets the database instance during initialization
|
|
// Must be called before the registry starts serving requests
|
|
func SetGlobalDatabase(database storage.DatabaseMetrics) {
|
|
globalDatabase = database
|
|
}
|
|
|
|
// SetGlobalAuthorizer sets the authorizer instance during initialization
|
|
// Must be called before the registry starts serving requests
|
|
func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
|
|
globalAuthorizer = authorizer
|
|
}
|
|
|
|
// SetGlobalReadmeCache sets the readme cache instance during initialization
|
|
// Must be called before the registry starts serving requests
|
|
func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
|
|
globalReadmeCache = readmeCache
|
|
}
|
|
|
|
func init() {
|
|
// Register the name resolution middleware
|
|
registrymw.Register("atproto-resolver", initATProtoResolver)
|
|
}
|
|
|
|
// NamespaceResolver wraps a namespace and resolves names
|
|
type NamespaceResolver struct {
|
|
distribution.Namespace
|
|
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
|
|
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
|
|
testMode bool // If true, fallback to default hold when user's hold is unreachable
|
|
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
|
|
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
|
|
database storage.DatabaseMetrics // Metrics database (copied from global on init)
|
|
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
|
|
readmeCache storage.ReadmeCache // README cache (copied from global on init)
|
|
}
|
|
|
|
// initATProtoResolver initializes the name resolution middleware
|
|
func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]any) (distribution.Namespace, error) {
|
|
// Get default hold DID from config (required)
|
|
// Expected format: "did:web:hold01.atcr.io"
|
|
defaultHoldDID := ""
|
|
if holdDID, ok := options["default_hold_did"].(string); ok {
|
|
defaultHoldDID = holdDID
|
|
}
|
|
|
|
// Get base URL from config (for error messages)
|
|
baseURL := ""
|
|
if url, ok := options["base_url"].(string); ok {
|
|
baseURL = url
|
|
}
|
|
|
|
// Check test mode from options (passed via env var)
|
|
testMode := false
|
|
if tm, ok := options["test_mode"].(bool); ok {
|
|
testMode = tm
|
|
}
|
|
|
|
// Copy shared services from globals into the instance
|
|
// This avoids accessing globals during request handling
|
|
return &NamespaceResolver{
|
|
Namespace: ns,
|
|
defaultHoldDID: defaultHoldDID,
|
|
baseURL: baseURL,
|
|
testMode: testMode,
|
|
refresher: globalRefresher,
|
|
database: globalDatabase,
|
|
authorizer: globalAuthorizer,
|
|
readmeCache: globalReadmeCache,
|
|
}, nil
|
|
}
|
|
|
|
// authErrorMessage creates a user-friendly auth error with login URL
|
|
func (nr *NamespaceResolver) authErrorMessage(message string) error {
|
|
loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL)
|
|
fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL)
|
|
return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage)
|
|
}
|
|
|
|
// Repository resolves the repository name and delegates to underlying namespace
|
|
// Handles names like:
|
|
// - atcr.io/alice/myimage → resolve alice to DID
|
|
// - atcr.io/did:plc:xyz123/myimage → use DID directly
|
|
func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Named) (distribution.Repository, error) {
|
|
// Extract the first part of the name (username or DID)
|
|
repoPath := name.Name()
|
|
parts := strings.SplitN(repoPath, "/", 2)
|
|
|
|
if len(parts) < 2 {
|
|
// No user specified, use default or return error
|
|
return nil, fmt.Errorf("repository name must include user: %s", repoPath)
|
|
}
|
|
|
|
identityStr := parts[0]
|
|
imageName := parts[1]
|
|
|
|
// Resolve identity to DID, handle, and PDS endpoint
|
|
did, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, identityStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slog.Debug("Resolved identity", "component", "registry/middleware", "did", did, "pds", pdsEndpoint, "handle", handle)
|
|
|
|
// Query for hold DID - either user's hold or default hold service
|
|
holdDID := nr.findHoldDID(ctx, did, pdsEndpoint)
|
|
if holdDID == "" {
|
|
// This is a fatal configuration error - registry cannot function without a hold service
|
|
return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config")
|
|
}
|
|
ctx = context.WithValue(ctx, holdDIDKey, holdDID)
|
|
|
|
// Auto-reconcile crew membership on first push/pull
|
|
// This ensures users can push immediately after docker login without web sign-in
|
|
// EnsureCrewMembership is best-effort and logs errors without failing the request
|
|
if holdDID != "" && nr.refresher != nil {
|
|
slog.Debug("Auto-reconciling crew membership", "component", "registry/middleware", "did", did, "hold_did", holdDID)
|
|
client := atproto.NewClient(pdsEndpoint, did, "")
|
|
storage.EnsureCrewMembership(ctx, client, nr.refresher, holdDID)
|
|
}
|
|
|
|
// Get service token for hold authentication
|
|
var serviceToken string
|
|
if nr.refresher != nil {
|
|
var err error
|
|
serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
|
|
if err != nil {
|
|
slog.Error("Failed to get service token", "component", "registry/middleware", "did", did, "error", err)
|
|
slog.Error("User needs to re-authenticate via credential helper", "component", "registry/middleware")
|
|
return nil, nr.authErrorMessage("OAuth session expired")
|
|
}
|
|
}
|
|
|
|
// Create a new reference with identity/image format
|
|
// Use the identity (or DID) as the namespace to ensure canonical format
|
|
// This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path)
|
|
canonicalName := fmt.Sprintf("%s/%s", identityStr, imageName)
|
|
ref, err := reference.ParseNamed(canonicalName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid image name %s: %w", imageName, err)
|
|
}
|
|
|
|
// Delegate to underlying namespace with modified name
|
|
repo, err := nr.Namespace.Repository(ctx, ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get access token for PDS operations
|
|
// Try OAuth refresher first (for users who authorized via AppView OAuth)
|
|
// Fall back to Basic Auth token cache (for users who used app passwords)
|
|
var atprotoClient *atproto.Client
|
|
|
|
if nr.refresher != nil {
|
|
// Try OAuth flow first
|
|
session, err := nr.refresher.GetSession(ctx, did)
|
|
if err == nil {
|
|
// OAuth session available - use indigo's API client (handles DPoP automatically)
|
|
apiClient := session.APIClient()
|
|
atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
|
|
} else {
|
|
slog.Debug("OAuth refresh failed, falling back to Basic Auth", "component", "registry/middleware", "did", did, "error", err)
|
|
}
|
|
}
|
|
|
|
// Fall back to Basic Auth token cache if OAuth not available
|
|
if atprotoClient == nil {
|
|
accessToken, ok := auth.GetGlobalTokenCache().Get(did)
|
|
if !ok {
|
|
slog.Debug("No cached access token found (neither OAuth nor Basic Auth)", "component", "registry/middleware", "did", did)
|
|
accessToken = "" // Will fail on manifest push, but let it try
|
|
} else {
|
|
slog.Debug("Using Basic Auth access token", "component", "registry/middleware", "did", did, "token_length", len(accessToken))
|
|
}
|
|
atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
|
|
}
|
|
|
|
// IMPORTANT: Use only the image name (not identity/image) for ATProto storage
|
|
// ATProto records are scoped to the user's DID, so we don't need the identity prefix
|
|
// Example: "evan.jarrett.net/debian" -> store as "debian"
|
|
repositoryName := imageName
|
|
|
|
// Cache key is DID + repository name
|
|
cacheKey := did + ":" + repositoryName
|
|
|
|
// Check cache first and update service token
|
|
if cached, ok := nr.repositories.Load(cacheKey); ok {
|
|
cachedRepo := cached.(*storage.RoutingRepository)
|
|
// Always update the service token even for cached repos (token may have been renewed)
|
|
cachedRepo.Ctx.ServiceToken = serviceToken
|
|
return cachedRepo, nil
|
|
}
|
|
|
|
// Create routing repository - routes manifests to ATProto, blobs to hold service
|
|
// The registry is stateless - no local storage is used
|
|
// Bundle all context into a single RegistryContext struct
|
|
registryCtx := &storage.RegistryContext{
|
|
DID: did,
|
|
Handle: handle,
|
|
HoldDID: holdDID,
|
|
PDSEndpoint: pdsEndpoint,
|
|
Repository: repositoryName,
|
|
ServiceToken: serviceToken, // Cached service token from middleware validation
|
|
ATProtoClient: atprotoClient,
|
|
Database: nr.database,
|
|
Authorizer: nr.authorizer,
|
|
Refresher: nr.refresher,
|
|
ReadmeCache: nr.readmeCache,
|
|
}
|
|
routingRepo := storage.NewRoutingRepository(repo, registryCtx)
|
|
|
|
// Cache the repository
|
|
nr.repositories.Store(cacheKey, routingRepo)
|
|
|
|
return routingRepo, nil
|
|
}
|
|
|
|
// Repositories delegates to underlying namespace
|
|
func (nr *NamespaceResolver) Repositories(ctx context.Context, repos []string, last string) (int, error) {
|
|
return nr.Namespace.Repositories(ctx, repos, last)
|
|
}
|
|
|
|
// Blobs delegates to underlying namespace
|
|
func (nr *NamespaceResolver) Blobs() distribution.BlobEnumerator {
|
|
return nr.Namespace.Blobs()
|
|
}
|
|
|
|
// BlobStatter delegates to underlying namespace
|
|
func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
|
|
return nr.Namespace.BlobStatter()
|
|
}
|
|
|
|
// findHoldDID determines which hold DID to use for blob storage
|
|
// Priority order:
|
|
// 1. User's sailor profile defaultHold (if set)
|
|
// 2. User's own hold record (io.atcr.hold)
|
|
// 3. AppView's default hold DID
|
|
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
|
|
func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
|
|
// Create ATProto client (without auth - reading public records)
|
|
client := atproto.NewClient(pdsEndpoint, did, "")
|
|
|
|
// Check for sailor profile
|
|
profile, err := storage.GetProfile(ctx, client)
|
|
if err != nil {
|
|
// Error reading profile (not a 404) - log and continue
|
|
slog.Warn("Failed to read profile", "did", did, "error", err)
|
|
}
|
|
|
|
if profile != nil && profile.DefaultHold != "" {
|
|
// Profile exists with defaultHold set
|
|
// In test mode, verify it's reachable before using it
|
|
if nr.testMode {
|
|
if nr.isHoldReachable(ctx, profile.DefaultHold) {
|
|
return profile.DefaultHold
|
|
}
|
|
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold)
|
|
return nr.defaultHoldDID
|
|
}
|
|
return profile.DefaultHold
|
|
}
|
|
|
|
// Profile doesn't exist or defaultHold is null/empty
|
|
// Check for user's own hold records
|
|
records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
|
|
if err != nil {
|
|
// Failed to query holds, use default
|
|
return nr.defaultHoldDID
|
|
}
|
|
|
|
// Find the first hold record
|
|
for _, record := range records {
|
|
var holdRecord atproto.HoldRecord
|
|
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Return the endpoint from the first hold (normalize to DID if URL)
|
|
if holdRecord.Endpoint != "" {
|
|
return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
|
|
}
|
|
}
|
|
|
|
// No profile defaultHold and no own hold records - use AppView default
|
|
return nr.defaultHoldDID
|
|
}
|
|
|
|
// isHoldReachable checks if a hold service is reachable
|
|
// Used in test mode to fallback to default hold when user's hold is unavailable
|
|
func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string) bool {
|
|
// Try to fetch the DID document
|
|
hostname := strings.TrimPrefix(holdDID, "did:web:")
|
|
|
|
// Try HTTP first (local), then HTTPS
|
|
for _, scheme := range []string{"http", "https"} {
|
|
testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
|
|
client := atproto.NewClient("", "", "")
|
|
_, err := client.FetchDIDDocument(ctx, testURL)
|
|
if err == nil {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|