mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 02:34:17 +00:00
Seen in production on 2026-09-11: three cold pulls of a 22-layer image failed with BLOB_UNKNOWN for layers that exist. The hold had answered 403 "service token authentication failed: token has expired", and the same blobs served fine a minute later. Three things lined up. The registry middleware's validation cache kept a fetched service token for a flat 45 seconds regardless of its real remaining life, so a token fetched with 12 seconds left was still handed to the hold half a minute after it died. The registry JWT is stamped from the auth cache's expiry, which trailed the real exp by only 10 seconds, while distribution accepts a JWT for 60 seconds past its exp, so a client could hold an accepted JWT for most of a minute after the credential behind it was gone. And the hold's 403 was flattened to BLOB_UNKNOWN, so the client failed instead of re-authenticating. Now the validation cache bounds an entry by the token's exp minus a shared ServiceTokenSafetyMargin of 60 seconds, the same margin the auth cache and the JWT stamp use, chosen to equal distribution's leeway so the last instant a JWT is accepted is the service token's real exp. A PDS that grants less than the margin gets half its remaining life instead of an already-past deadline. When the hold rejects the service token as expired or missing, the appview drops both cached copies and returns a 401 challenge so Docker and crane re-run the token dance and retry; a genuine permission denial stays a 403, and a hold that is down still maps to blob unknown. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EvFJr4Dwz8p2NDAeXmgmBt
98 lines
4.4 KiB
Go
98 lines
4.4 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
|
|
"atcr.io/pkg/appview/readme"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"atcr.io/pkg/auth/oauth"
|
|
)
|
|
|
|
// PushWebhookDispatcher dispatches webhooks fired after a successful push.
|
|
// Today that covers the "push" trigger and the edge-triggered "quota" trigger
|
|
// (each user's storage usage is re-evaluated against per-webhook thresholds).
|
|
// Defined here (in storage) to avoid import cycles with the webhooks package.
|
|
type PushWebhookDispatcher interface {
|
|
DispatchForPush(ctx context.Context, event PushWebhookEvent)
|
|
DispatchForQuota(ctx context.Context, event QuotaWebhookEvent)
|
|
}
|
|
|
|
// QuotaWebhookEvent contains the data needed to evaluate and dispatch quota
|
|
// threshold webhooks for a user's storage on a hold.
|
|
type QuotaWebhookEvent struct {
|
|
UserDID string
|
|
UserHandle string
|
|
HoldDID string
|
|
HoldEndpoint string
|
|
}
|
|
|
|
// ManifestReferenceChecker checks if a manifest digest is referenced as a child
|
|
// of a manifest list (multi-arch image). Used to protect manifest list children
|
|
// from auto-removal when their parent list is still tagged.
|
|
type ManifestReferenceChecker interface {
|
|
IsManifestReferenced(did, digest string) (bool, error)
|
|
}
|
|
|
|
// PushWebhookEvent contains the data needed to dispatch a push webhook.
|
|
type PushWebhookEvent struct {
|
|
OwnerDID string
|
|
OwnerHandle string
|
|
PusherDID string
|
|
PusherHandle string
|
|
Repository string
|
|
Tag string
|
|
Digest string
|
|
MediaType string
|
|
HoldDID string
|
|
HoldEndpoint string
|
|
}
|
|
|
|
// HoldDIDLookup interface for querying and updating hold DIDs in manifests
|
|
type HoldDIDLookup interface {
|
|
GetLatestHoldDIDForRepo(did, repository string) (string, error)
|
|
UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error)
|
|
GetDistinctManifestHoldDIDs(did string) ([]string, error)
|
|
}
|
|
|
|
// RegistryContext bundles all the context needed for registry operations
|
|
// This includes both per-request data (DID, hold) and shared services
|
|
type RegistryContext struct {
|
|
// Per-request identity and routing information
|
|
// Owner = the user whose repository is being accessed
|
|
// Puller = the authenticated user making the request (from JWT Subject)
|
|
DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123")
|
|
Handle string // Owner's handle (e.g., "alice.bsky.social")
|
|
HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io" or "did:plc:abc123")
|
|
HoldURL string // Resolved HTTP URL for the hold service
|
|
PDSEndpoint string // Owner's PDS endpoint URL
|
|
Repository string // Image repository name (e.g., "debian")
|
|
ServiceToken string // Service token for hold authentication (from puller's PDS)
|
|
ATProtoClient *atproto.Client // Authenticated ATProto client for the owner
|
|
AuthMethod string // Auth method used ("oauth" or "app_password")
|
|
PullerDID string // Puller's DID - who is making the request (from JWT Subject)
|
|
PullerPDSEndpoint string // Puller's PDS endpoint URL
|
|
HasPushScope bool // Whether the JWT token has push scope (used to filter pull stats)
|
|
Anonymous bool // Request carries no puller identity (anonymous pull); hold decides via captain.Public
|
|
|
|
// InvalidateServiceToken drops every process-level cache holding the
|
|
// puller's service token for this hold. Called when the hold rejects the
|
|
// token as expired or invalid: without it the client's retry (prompted by
|
|
// the 401 we return) would be handed the very same dead token and fail
|
|
// again. Set by the registry middleware, which owns the per-process
|
|
// validation cache; nil on requests that never fetched a token (anonymous
|
|
// pulls) and in tests.
|
|
InvalidateServiceToken func()
|
|
|
|
// Per-request user preferences
|
|
AutoRemoveUntagged bool // Whether to auto-delete untagged manifests on tag overwrite
|
|
|
|
// Shared services (same for all requests)
|
|
Database HoldDIDLookup // Database for hold DID lookups
|
|
Authorizer auth.HoldAuthorizer // Hold access authorization
|
|
Refresher *oauth.Refresher // OAuth session manager
|
|
ReadmeFetcher *readme.Fetcher // README fetcher for repo pages
|
|
WebhookDispatcher PushWebhookDispatcher // Push webhook dispatcher (nil if not configured)
|
|
ManifestRefChecker ManifestReferenceChecker // Checks if digest is a manifest list child (nil-safe)
|
|
}
|