A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial commit

Evan Jarrett 85d0bd24

+8380
+30
.claude/settings.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "WebSearch", 5 + "WebFetch(domain:github.com)", 6 + "WebFetch(domain:pkg.go.dev)", 7 + "WebFetch(domain:distribution.github.io)", 8 + "Write(*)", 9 + "Edit(*)", 10 + "Bash(find:*)", 11 + "Bash(curl:*)", 12 + "Bash(sed:*)", 13 + "Bash(grep:*)", 14 + "Bash(gofmt:*)", 15 + "Bash(mkdir:*)", 16 + "Bash(golangci-lint run:*)", 17 + "Bash(go run:*)", 18 + "Bash(go install:*)", 19 + "Bash(go test:*)", 20 + "Bash(go build:*)", 21 + "Bash(go tool:*)", 22 + "Bash(go vet:*)", 23 + "Bash(go get:*)", 24 + "Bash(go mod:*)", 25 + "Bash(go get:*)" 26 + ], 27 + "deny": [], 28 + "ask": [] 29 + } 30 + }
+63
.env.example
··· 1 + # ATCR Hold Service Configuration 2 + # Copy this file to .env and fill in your values 3 + 4 + # ============================================================================== 5 + # Required Configuration 6 + # ============================================================================== 7 + 8 + # Hold service public URL (REQUIRED) 9 + # The hostname becomes the hold name/record key 10 + # Examples: https://hold1.atcr.io, http://127.0.0.1:8080 11 + HOLD_PUBLIC_URL=http://127.0.0.1:8080 12 + 13 + # ============================================================================== 14 + # Storage Configuration 15 + # ============================================================================== 16 + 17 + # Storage driver type (s3, filesystem) 18 + # Default: s3 19 + STORAGE_DRIVER=s3 20 + 21 + # For S3/Storj/Minio: 22 + AWS_ACCESS_KEY_ID=your_access_key 23 + AWS_SECRET_ACCESS_KEY=your_secret_key 24 + AWS_REGION=us-east-1 25 + S3_BUCKET=atcr-blobs 26 + 27 + # For Storj/Minio (optional - custom S3 endpoint): 28 + # S3_ENDPOINT=https://gateway.storjshare.io 29 + 30 + # For filesystem driver: 31 + # STORAGE_DRIVER=filesystem 32 + # STORAGE_ROOT_DIR=/var/lib/atcr/hold 33 + 34 + # ============================================================================== 35 + # Server Configuration 36 + # ============================================================================== 37 + 38 + # Server listen address (default: :8080) 39 + # HOLD_SERVER_ADDR=:8080 40 + 41 + # Allow public blob reads (pulls) without authentication 42 + # Writes (pushes) always require crew membership via PDS 43 + # Default: false 44 + HOLD_PUBLIC=false 45 + 46 + # ============================================================================== 47 + # Registration (REQUIRED) 48 + # ============================================================================== 49 + 50 + # Your ATProto DID (REQUIRED for registration) 51 + # Get your DID: https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social 52 + # 53 + # On first run with HOLD_CREW_OWNER set: 54 + # 1. Hold service will print an OAuth URL to the logs 55 + # 2. Visit the URL in your browser to authorize 56 + # 3. Hold service creates hold + crew records in your PDS 57 + # 4. Registration complete! 58 + # 59 + # On subsequent runs: 60 + # - Hold service checks if already registered 61 + # - Skips OAuth if records exist 62 + # 63 + HOLD_CREW_OWNER=did:plc:your-did-here
+21
.gitignore
··· 1 + # Binaries 2 + bin/ 3 + 4 + # Test artifacts 5 + .atcr-pids 6 + 7 + # OAuth tokens 8 + .atcr/ 9 + 10 + # Environment configuration 11 + .env 12 + 13 + # IDE 14 + .vscode/ 15 + .idea/ 16 + *.swp 17 + *.swo 18 + 19 + # OS 20 + .DS_Store 21 + Thumbs.db
+459
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + ATCR (ATProto Container Registry) is an OCI-compliant container registry that uses the AT Protocol for manifest storage and S3 for blob storage. This creates a decentralized container registry where manifests are stored in users' Personal Data Servers (PDS) while layers are stored in S3. 8 + 9 + ## Build Commands 10 + 11 + ```bash 12 + # Build all binaries 13 + go build -o atcr-registry ./cmd/registry 14 + go build -o atcr-hold ./cmd/hold 15 + go build -o docker-credential-atcr ./cmd/credential-helper 16 + 17 + # Run tests 18 + go test ./... 19 + 20 + # Run with race detector 21 + go test -race ./... 22 + 23 + # Update dependencies 24 + go mod tidy 25 + 26 + # Build Docker images 27 + docker build -t atcr.io/registry:latest . 28 + docker build -f Dockerfile.hold -t atcr.io/hold:latest . 29 + 30 + # Or use docker-compose 31 + docker-compose up -d 32 + 33 + # Run locally (AppView) 34 + export ATPROTO_DID=did:plc:your-did 35 + export ATPROTO_ACCESS_TOKEN=your-token 36 + ./atcr-registry serve config/config.yml 37 + 38 + # Run hold service (configure via env vars - see .env.example) 39 + export HOLD_PUBLIC_URL=http://127.0.0.1:8080 40 + export STORAGE_DRIVER=filesystem 41 + export STORAGE_ROOT_DIR=/tmp/atcr-hold 42 + export HOLD_CREW_OWNER=did:plc:your-did-here 43 + ./atcr-hold 44 + # Check logs for OAuth URL, visit in browser to complete registration 45 + ``` 46 + 47 + ## Architecture Overview 48 + 49 + ### Core Design 50 + 51 + ATCR uses **distribution/distribution** as a library and extends it through middleware to route different types of content to different storage backends: 52 + 53 + - **Manifests** → ATProto PDS (small JSON metadata, stored as `io.atcr.manifest` records) 54 + - **Blobs/Layers** → S3 or user-deployed storage (large binary data) 55 + - **Authentication** → ATProto OAuth with DPoP + Docker credential helpers 56 + 57 + ### Three-Component Architecture 58 + 59 + 1. **AppView** (`cmd/registry`) - OCI Distribution API server 60 + - Resolves identities (handle/DID → PDS endpoint) 61 + - Routes manifests to user's PDS 62 + - Routes blobs to storage endpoint (default or BYOS) 63 + - Validates OAuth tokens via PDS 64 + - Issues registry JWTs 65 + 66 + 2. **Hold Service** (`cmd/hold`) - Optional BYOS component 67 + - Lightweight HTTP server for presigned URLs 68 + - Supports S3, Storj, Minio, filesystem, etc. 69 + - Authorization based on PDS records (hold.public, crew records) 70 + - Auto-registration via OAuth 71 + - Configured entirely via environment variables 72 + 73 + 3. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth 74 + - Implements Docker credential helper protocol 75 + - ATProto OAuth flow with DPoP 76 + - Token caching and refresh 77 + - Exchanges OAuth token for registry JWT 78 + 79 + ### Request Flow 80 + 81 + #### Push with Default Storage 82 + ``` 83 + 1. Client: docker push atcr.io/alice/myapp:latest 84 + 2. HTTP Request → /v2/alice/myapp/manifests/latest 85 + 3. Registry Middleware (pkg/middleware/registry.go) 86 + → Resolves "alice" to DID and PDS endpoint 87 + → Queries alice's sailor profile for defaultHold 88 + → If not set, checks alice's io.atcr.hold records 89 + → Falls back to AppView's default_storage_endpoint 90 + → Stores DID/PDS/storage endpoint in context 91 + 4. Repository Middleware (pkg/middleware/repository.go) 92 + → Creates RoutingRepository 93 + → Returns ATProto ManifestStore for manifests 94 + → Returns ProxyBlobStore for blobs 95 + 5. Blob PUT → Resolved hold service (redirects to S3/storage) 96 + 6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdEndpoint) 97 + ``` 98 + 99 + #### Push with BYOS (Bring Your Own Storage) 100 + ``` 101 + 1. Client: docker push atcr.io/alice/myapp:latest 102 + 2. Registry Middleware resolves alice → did:plc:alice123 103 + 3. Hold discovery via findStorageEndpoint(): 104 + a. Check alice's sailor profile for defaultHold 105 + b. If not set, check alice's io.atcr.hold records 106 + c. Fall back to AppView's default_storage_endpoint 107 + 4. Found: alice's profile has defaultHold = "https://alice-storage.fly.dev" 108 + 5. Routing Repository returns ProxyBlobStore(alice-storage.fly.dev) 109 + 6. ProxyBlobStore calls alice-storage.fly.dev for presigned URL 110 + 7. Storage service validates alice's DID, generates S3 presigned URL 111 + 8. Client redirected to upload blob directly to alice's S3/Storj 112 + 9. Manifest stored in alice's PDS with holdEndpoint = "https://alice-storage.fly.dev" 113 + ``` 114 + 115 + #### Pull Flow 116 + ``` 117 + 1. Client: docker pull atcr.io/alice/myapp:latest 118 + 2. GET /v2/alice/myapp/manifests/latest 119 + 3. AppView fetches manifest from alice's PDS 120 + 4. Manifest contains holdEndpoint = "https://alice-storage.fly.dev" 121 + 5. Hold endpoint cached: (alice's DID, "myapp") → "https://alice-storage.fly.dev" 122 + 6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123 123 + 7. AppView checks cache, routes to hold from manifest (not re-discovered) 124 + 8. ProxyBlobStore calls alice-storage.fly.dev for presigned download URL 125 + 9. Client redirected to download blob directly from alice's S3 126 + ``` 127 + 128 + **Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold. 129 + 130 + ### Name Resolution 131 + 132 + Names follow the pattern: `atcr.io/<identity>/<image>:<tag>` 133 + 134 + Where `<identity>` can be: 135 + - **Handle**: `alice.bsky.social` → resolved via .well-known/atproto-did 136 + - **DID**: `did:plc:xyz123` → resolved via PLC directory 137 + 138 + Resolution happens in `pkg/atproto/resolver.go`: 139 + 1. Handle → DID (via DNS/HTTPS) 140 + 2. DID → PDS endpoint (via DID document) 141 + 142 + ### Middleware System 143 + 144 + ATCR uses two levels of middleware: 145 + 146 + #### 1. Registry Middleware (`pkg/middleware/registry.go`) 147 + - Wraps `distribution.Namespace` 148 + - Intercepts `Repository(name)` calls 149 + - Performs name resolution (alice → did:plc:xyz → pds.example.com) 150 + - Queries PDS for `io.atcr.hold` records to find storage endpoint 151 + - Stores resolved identity and storage endpoint in context 152 + 153 + #### 2. Repository Middleware (`pkg/middleware/repository.go`) 154 + - Wraps `distribution.Repository` 155 + - Returns custom `Manifests()` and `Blobs()` implementations 156 + - Routes manifests to ATProto, blobs to S3 or BYOS 157 + 158 + ### Authentication Architecture 159 + 160 + #### ATProto OAuth with DPoP 161 + 162 + ATCR implements the full ATProto OAuth specification with mandatory security features: 163 + 164 + **Required Components:** 165 + - **DPoP** (RFC 9449) - Cryptographic proof-of-possession for every request 166 + - **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange 167 + - **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception 168 + 169 + **Key Components** (`pkg/auth/`): 170 + 171 + 1. **OAuth Client** (`oauth/client.go`) - Handles authorization flow with DPoP 172 + 2. **DPoP Transport** (`oauth/transport.go`) - HTTP RoundTripper that auto-adds DPoP headers 173 + 3. **Token Storage** (`oauth/storage.go`) - Persists tokens and DPoP key in `~/.atcr/oauth-token.json` 174 + 4. **Token Validator** (`atproto/validator.go`) - Validates tokens via PDS `getSession` endpoint 175 + 5. **Exchange Handler** (`exchange/handler.go`) - Exchanges OAuth tokens for registry JWTs 176 + 177 + **Authentication Flow:** 178 + ``` 179 + 1. User runs: docker-credential-atcr configure 180 + 2. Helper generates ECDSA P-256 DPoP key 181 + 3. Resolve handle → DID → PDS endpoint 182 + 4. Discover OAuth server metadata from PDS 183 + 5. PAR request with DPoP header → get request_uri 184 + 6. Open browser for user authorization 185 + 7. Exchange code for token with DPoP proof 186 + 8. Save: access token, refresh token, DPoP key, DID, handle 187 + 188 + Later (docker push): 189 + 9. Docker calls credential helper 190 + 10. Helper loads token, refreshes if needed 191 + 11. Helper calls /auth/exchange with OAuth token + handle 192 + 12. AppView validates token via PDS getSession 193 + 13. AppView ensures sailor profile exists (creates with defaultHold if first login) 194 + 14. AppView issues registry JWT with validated DID 195 + 15. Helper returns JWT to Docker 196 + ``` 197 + 198 + **Security:** 199 + - Tokens validated against authoritative source (user's PDS) 200 + - No trust in client-provided identity information 201 + - DPoP binds tokens to specific client key 202 + - 15-minute token expiry for registry JWTs 203 + 204 + ### Key Components 205 + 206 + #### ATProto Integration (`pkg/atproto/`) 207 + 208 + **resolver.go**: DID and handle resolution 209 + - `ResolveIdentity()`: alice → did:plc:xyz → pds.example.com 210 + - `ResolveHandle()`: Uses .well-known/atproto-did 211 + - `ResolvePDS()`: Parses DID document for PDS endpoint 212 + 213 + **client.go**: ATProto PDS client 214 + - `PutRecord()`: Store manifest as ATProto record 215 + - `GetRecord()`: Retrieve manifest from PDS 216 + - `DeleteRecord()`: Remove manifest 217 + - Uses XRPC protocol (com.atproto.repo.*) 218 + 219 + **lexicon.go**: ATProto record schemas 220 + - `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdEndpoint` field) 221 + - `TagRecord`: Tag pointing to manifest digest 222 + - `HoldRecord`: Storage hold definition (for BYOS) 223 + - `HoldCrewRecord`: Hold crew membership/permissions 224 + - `SailorProfileRecord`: User profile with `defaultHold` preference 225 + - Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold`, `io.atcr.hold.crew`, `io.atcr.sailor.profile` 226 + 227 + **profile.go**: Sailor profile management 228 + - `EnsureProfile()`: Creates profile with default hold on first authentication 229 + - `GetProfile()`: Retrieves user's profile from PDS 230 + - `UpdateProfile()`: Updates user's profile 231 + 232 + **manifest_store.go**: Implements `distribution.ManifestService` 233 + - Stores OCI manifests as ATProto records 234 + - Digest-based addressing (sha256:abc123 → record key) 235 + - Converts between OCI and ATProto formats 236 + 237 + #### Storage Layer (`pkg/storage/`) 238 + 239 + **routing_repository.go**: Routes content by type 240 + - `Manifests()` → returns ATProto ManifestStore (caches instance for hold endpoint extraction) 241 + - `Blobs()` → checks hold cache for pull, uses discovery for push 242 + - Pull: Uses cached `holdEndpoint` from manifest (historical reference) 243 + - Push: Uses discovery-based endpoint from `findStorageEndpoint()` 244 + - Always returns ProxyBlobStore (routes to hold service) 245 + - Implements `distribution.Repository` interface 246 + 247 + **hold_cache.go**: In-memory hold endpoint cache 248 + - Caches `(DID, repository) → holdEndpoint` for pull operations 249 + - TTL: 10 minutes (covers typical pull operations) 250 + - Cleanup: Background goroutine runs every 5 minutes 251 + - **NOTE:** Simple in-memory cache for MVP. For production: use Redis or similar 252 + - Prevents expensive ATProto lookups on every blob request 253 + 254 + **s3_blob_store.go**: S3 blob storage wrapper 255 + - Wraps distribution's built-in S3 driver 256 + - Inherits full `distribution.BlobStore` interface 257 + - Used for default shared storage 258 + 259 + **proxy_blob_store.go**: External storage proxy 260 + - Calls user's storage service for presigned URLs 261 + - Issues HTTP redirects for blob uploads/downloads 262 + - Implements full `distribution.BlobStore` interface 263 + - Used when user has `io.atcr.hold` record 264 + 265 + #### Hold Service (`cmd/hold/`) 266 + 267 + Lightweight standalone service for BYOS (Bring Your Own Storage): 268 + 269 + **Architecture:** 270 + - Reuses distribution's storage driver factory 271 + - Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem 272 + - Authorization based on PDS records (hold.public field, crew records) 273 + - Generates presigned URLs (15min expiry) or proxies uploads/downloads 274 + 275 + **Endpoints:** 276 + - `POST /get-presigned-url` - Get download URL for blob 277 + - `POST /put-presigned-url` - Get upload URL for blob 278 + - `GET /blobs/{digest}` - Proxy download (fallback if no presigned URL support) 279 + - `PUT /blobs/{digest}` - Proxy upload (fallback) 280 + - `POST /register` - Manual registration endpoint 281 + - `GET /health` - Health check 282 + 283 + **Configuration:** Environment variables (see `.env.example`) 284 + - `HOLD_PUBLIC_URL` - Public URL of hold service (required) 285 + - `STORAGE_DRIVER` - Storage driver type (s3, filesystem) 286 + - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials 287 + - `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration 288 + - `HOLD_PUBLIC` - Allow public reads (default: false) 289 + - `HOLD_CREW_OWNER` - DID for auto-registration (optional) 290 + 291 + **Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc. 292 + 293 + ### ATProto Storage Model 294 + 295 + Manifests are stored as records with this structure: 296 + ```json 297 + { 298 + "$type": "io.atcr.manifest", 299 + "repository": "myapp", 300 + "digest": "sha256:abc123...", 301 + "holdEndpoint": "https://hold1.alice.com", 302 + "schemaVersion": 2, 303 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 304 + "config": { "digest": "sha256:...", "size": 1234 }, 305 + "layers": [ 306 + { "digest": "sha256:...", "size": 5678 } 307 + ], 308 + "createdAt": "2025-09-30T..." 309 + } 310 + ``` 311 + 312 + Record key = manifest digest (without algorithm prefix) 313 + Collection = `io.atcr.manifest` 314 + 315 + ### Sailor Profile System 316 + 317 + ATCR uses a "sailor profile" to manage user preferences for hold (storage) selection. The nautical theme reflects the architecture: 318 + - **Sailors** = Registry users 319 + - **Captains** = Hold owners 320 + - **Crew** = Hold members with access 321 + - **Holds** = Storage endpoints (BYOS) 322 + 323 + **Profile Record** (`io.atcr.sailor.profile`): 324 + ```json 325 + { 326 + "$type": "io.atcr.sailor.profile", 327 + "defaultHold": "https://hold1.alice.com", 328 + "createdAt": "2025-10-02T...", 329 + "updatedAt": "2025-10-02T..." 330 + } 331 + ``` 332 + 333 + **Profile Management:** 334 + - Created automatically on first authentication (OAuth or Basic Auth) 335 + - If AppView has `default_storage_endpoint` configured, profile gets that as `defaultHold` 336 + - Users can update their profile to change default hold (future: via UI) 337 + - Setting `defaultHold` to null opts out of defaults (use own holds or AppView default) 338 + 339 + **Hold Resolution Priority** (in `findStorageEndpoint()`): 340 + 1. **Profile's `defaultHold`** - User's explicit preference 341 + 2. **User's `io.atcr.hold` records** - User's own holds 342 + 3. **AppView's `default_storage_endpoint`** - Fallback default 343 + 344 + This ensures: 345 + - Users can join shared holds by setting their profile's `defaultHold` 346 + - Users can opt out of defaults (set `defaultHold` to null) 347 + - URL structure remains `atcr.io/<owner>/<image>` (ownership-based, not hold-based) 348 + - Hold choice is transparent infrastructure (like choosing an S3 region) 349 + 350 + ### Key Design Decisions 351 + 352 + 1. **No fork of distribution**: Uses distribution as library, extends via middleware 353 + 2. **Hybrid storage**: Manifests in ATProto (small, federated), blobs in S3 or BYOS (cheap, scalable) 354 + 3. **Content addressing**: Manifests stored by digest, blobs deduplicated globally 355 + 4. **ATProto-native**: Manifests are first-class ATProto records, discoverable via AT Protocol 356 + 5. **OCI compliant**: Fully compatible with Docker/containerd/podman 357 + 6. **Account-agnostic AppView**: Server validates any user's token, queries their PDS for config 358 + 7. **BYOS architecture**: Users can deploy their own storage service, AppView just routes 359 + 8. **OAuth with DPoP**: Full ATProto OAuth implementation with mandatory DPoP proofs 360 + 9. **Sailor profile system**: User preferences for hold selection, transparent to image ownership 361 + 10. **Historical hold references**: Manifests store `holdEndpoint` for immutable blob location tracking 362 + 363 + ### Configuration 364 + 365 + **AppView configuration** (`config/config.yml`): 366 + - S3 bucket settings under `storage.s3` 367 + - ATProto middleware under `middleware.repository` 368 + - Name resolver under `middleware.registry` 369 + - Default storage endpoint: `middleware.registry.options.default_storage_endpoint` 370 + - Auth token signing keys and expiration 371 + 372 + **Hold Service configuration** (environment variables): 373 + - Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*` 374 + - Authorization: Based on PDS records (`hold.public`, crew records) 375 + - Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC` 376 + - Auto-registration: `HOLD_CREW_OWNER` (optional) 377 + 378 + **Credential Helper**: 379 + - Token storage: `~/.atcr/oauth-token.json` 380 + - Contains: access token, refresh token, DPoP key (PEM), DID, handle 381 + 382 + Environment variables: 383 + - `ATPROTO_DID`: DID for authentication with PDS (AppView only) 384 + - `ATPROTO_ACCESS_TOKEN`: Access token for PDS operations (AppView only) 385 + 386 + ### Development Notes 387 + 388 + - Middleware is registered via `init()` functions in `pkg/middleware/` 389 + - Import `_ "atcr.io/pkg/middleware"` in main.go to register middleware 390 + - Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"` 391 + - Storage service reuses distribution's driver factory for multi-backend support 392 + - OAuth client uses `authelia.com/client/oauth2` for PAR support 393 + - DPoP proofs generated with `github.com/AxisCommunications/go-dpop` (auto-handles JWK) 394 + - Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity 395 + 396 + ### Testing Strategy 397 + 398 + When writing tests: 399 + - Mock ATProto client for manifest operations 400 + - Mock S3 driver for blob operations 401 + - Test name resolution independently 402 + - Integration tests require real PDS + S3 403 + 404 + ### Common Tasks 405 + 406 + **Adding a new ATProto record type**: 407 + 1. Define schema in `pkg/atproto/lexicon.go` 408 + 2. Add collection constant (e.g., `MyCollection = "io.atcr.my-type"`) 409 + 3. Add constructor function (e.g., `NewMyRecord()`) 410 + 4. Update client methods if needed 411 + 412 + **Modifying storage routing**: 413 + 1. Edit `pkg/storage/routing_repository.go` 414 + 2. Update `Blobs()` method to change routing logic 415 + 3. Consider context values: `storage.endpoint`, `atproto.did` 416 + 417 + **Changing name resolution**: 418 + 1. Modify `pkg/atproto/resolver.go` for DID/handle resolution 419 + 2. Update `pkg/middleware/registry.go` if changing routing logic 420 + 3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records 421 + 422 + **Implementing OAuth authentication**: 423 + - AppView: `pkg/auth/exchange/handler.go` - validates tokens via PDS getSession 424 + - Client: `pkg/auth/oauth/client.go` - OAuth + DPoP flow 425 + - Helper: `cmd/credential-helper/` - Docker credential protocol 426 + 427 + **Adding BYOS support for a user**: 428 + 1. User sets environment variables (storage credentials, public URL) 429 + 2. User runs hold service with `HOLD_CREW_OWNER` set - auto-registration via OAuth 430 + 3. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS 431 + 4. AppView automatically queries PDS and routes blobs to user's storage 432 + 5. No AppView changes needed - fully decentralized 433 + 434 + **Supporting a new storage backend**: 435 + 1. Ensure driver is registered in `cmd/hold/main.go` imports 436 + 2. Distribution supports: S3, Azure, GCS, Swift, filesystem, OSS 437 + 3. For custom drivers: implement `storagedriver.StorageDriver` interface 438 + 4. Add case to `buildStorageConfig()` in `cmd/hold/main.go` 439 + 5. Update `.env.example` with new driver's env vars 440 + 441 + ## Important Context Values 442 + 443 + When working with the codebase, these context values are used for routing: 444 + 445 + - `atproto.did` - Resolved DID for the user (e.g., `did:plc:alice123`) 446 + - `atproto.pds` - User's PDS endpoint (e.g., `https://bsky.social`) 447 + - `atproto.identity` - Original identity string (handle or DID) 448 + - `storage.endpoint` - Storage service URL (if user has `io.atcr.registry` record) 449 + - `auth.did` - Authenticated DID from validated token 450 + 451 + ## Documentation References 452 + 453 + - **BYOS Architecture**: See `docs/BYOS.md` for complete BYOS documentation 454 + - **OAuth Implementation**: See `docs/OAUTH.md` for OAuth/DPoP flow details 455 + - **ATProto Spec**: https://atproto.com/specs/oauth 456 + - **OCI Distribution Spec**: https://github.com/opencontainers/distribution-spec 457 + - **DPoP RFC**: https://datatracker.ietf.org/doc/html/rfc9449 458 + - **PAR RFC**: https://datatracker.ietf.org/doc/html/rfc9126 459 + - **PKCE RFC**: https://datatracker.ietf.org/doc/html/rfc7636
+48
Dockerfile
··· 1 + # Build stage 2 + FROM golang:1.24-alpine AS builder 3 + 4 + # Install build dependencies 5 + RUN apk add --no-cache git make 6 + 7 + # Set working directory 8 + WORKDIR /build 9 + 10 + # Copy go mod files 11 + COPY go.mod go.sum ./ 12 + 13 + # Download dependencies 14 + RUN go mod download 15 + 16 + # Copy source code 17 + COPY . . 18 + 19 + # Build the binary 20 + RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o atcr-registry ./cmd/registry 21 + 22 + # Runtime stage 23 + FROM alpine:latest 24 + 25 + # Install CA certificates for HTTPS 26 + RUN apk --no-cache add ca-certificates 27 + 28 + # Set working directory 29 + WORKDIR /app 30 + 31 + # Copy binary from builder 32 + COPY --from=builder /build/atcr-registry . 33 + 34 + # Copy default configuration 35 + COPY config/config.yml /etc/atcr/config.yml 36 + 37 + # Create directories for storage 38 + RUN mkdir -p /var/lib/atcr/blobs /var/lib/atcr/auth 39 + 40 + # Expose ports 41 + EXPOSE 5000 5001 42 + 43 + # Set environment variables 44 + ENV ATCR_CONFIG=/etc/atcr/config.yml 45 + 46 + # Run the registry 47 + ENTRYPOINT ["/app/atcr-registry"] 48 + CMD ["serve", "/etc/atcr/config.yml"]
+37
Dockerfile.hold
··· 1 + # Build stage 2 + FROM golang:1.24-alpine AS builder 3 + 4 + WORKDIR /app 5 + 6 + # Copy go mod files 7 + COPY go.mod go.sum ./ 8 + RUN go mod download 9 + 10 + # Copy source code 11 + COPY . . 12 + 13 + # Build the hold service 14 + RUN CGO_ENABLED=0 GOOS=linux go build -o atcr-hold ./cmd/hold 15 + 16 + # Runtime stage 17 + FROM alpine:latest 18 + 19 + RUN apk --no-cache add ca-certificates 20 + 21 + WORKDIR /root/ 22 + 23 + # Copy binary from builder 24 + COPY --from=builder /app/atcr-hold . 25 + 26 + # Copy default config 27 + COPY config/hold.yml /etc/atcr/hold.yml 28 + 29 + # Create directories for storage 30 + RUN mkdir -p /var/lib/atcr/hold 31 + 32 + # Expose default port 33 + EXPOSE 8080 34 + 35 + # Run the hold service 36 + ENTRYPOINT ["./atcr-hold"] 37 + CMD ["/etc/atcr/hold.yml"]
+244
README.md
··· 1 + # ATCR - ATProto Container Registry 2 + 3 + A container registry that uses the AT Protocol (ATProto) for manifest storage and S3 for blob storage. 4 + 5 + ## Overview 6 + 7 + ATCR is an OCI-compliant container registry that integrates with the AT Protocol ecosystem. It stores container image manifests as ATProto records in Personal Data Servers (PDS) while keeping the actual image layers in S3-compatible storage. 8 + 9 + ### Architecture 10 + 11 + - **Manifests**: Stored as ATProto records in user PDSs (small JSON metadata) 12 + - **Blobs/Layers**: Stored in S3 (large binary data) 13 + - **Name Resolution**: Supports both ATProto handles and DIDs 14 + - `atcr.io/alice.bsky.social/myimage:latest` 15 + - `atcr.io/did:plc:xyz123/myimage:latest` 16 + 17 + ## Features 18 + 19 + - OCI Distribution Spec compliant 20 + - ATProto-native manifest storage 21 + - S3 blob storage for container layers 22 + - DID/handle resolution 23 + - Decentralized manifest ownership 24 + 25 + ## Building 26 + 27 + ```bash 28 + # Build all binaries locally 29 + go build -o atcr-registry ./cmd/registry 30 + go build -o atcr-hold ./cmd/hold 31 + go build -o docker-credential-atcr ./cmd/credential-helper 32 + 33 + # Build Docker images 34 + docker build -t atcr.io/registry:latest . 35 + docker build -f Dockerfile.hold -t atcr.io/hold:latest . 36 + ``` 37 + 38 + ## Quick Start (Local Testing) 39 + 40 + **Automated setup:** 41 + ```bash 42 + # Run the test script (handles everything) 43 + ./test-local.sh 44 + ``` 45 + 46 + The script will: 47 + 1. Create necessary directories (`/var/lib/atcr/*`) 48 + 2. Build all binaries 49 + 3. Start registry and hold service 50 + 4. Show you how to test 51 + 52 + **Manual setup:** 53 + ```bash 54 + # 1. Create directories 55 + sudo mkdir -p /var/lib/atcr/{blobs,hold,auth} 56 + sudo chown -R $USER:$USER /var/lib/atcr 57 + 58 + # 2. Build binaries 59 + go build -o atcr-registry ./cmd/registry 60 + go build -o atcr-hold ./cmd/hold 61 + 62 + # 3. Configure environment 63 + cp .env.example .env 64 + # Edit .env - set ATPROTO_HANDLE and HOLD_PUBLIC_URL 65 + export $(cat .env | xargs) 66 + 67 + # 4. Start services 68 + # Terminal 1: 69 + ./atcr-registry serve config/config.yml 70 + # Terminal 2 (will prompt for OAuth): 71 + ./atcr-hold config/hold.yml 72 + # Follow OAuth URL in logs to authorize 73 + 74 + # 5. Test with Docker 75 + docker tag alpine:latest localhost:5000/alice/alpine:test 76 + docker push localhost:5000/alice/alpine:test 77 + docker pull localhost:5000/alice/alpine:test 78 + ``` 79 + 80 + ## Running 81 + 82 + ### Local Development 83 + 84 + **Configure environment:** 85 + ```bash 86 + # Copy and edit .env file 87 + cp .env.example .env 88 + # Edit .env with: 89 + # - ATPROTO_HANDLE (your Bluesky handle) 90 + # - HOLD_PUBLIC_URL (e.g., http://127.0.0.1:8080 or https://hold1.atcr.io) 91 + # - HOLD_AUTO_REGISTER=true 92 + 93 + # Load environment 94 + export $(cat .env | xargs) 95 + ``` 96 + 97 + **AppView (Registry):** 98 + ```bash 99 + ./atcr-registry serve config/config.yml 100 + ``` 101 + 102 + **Hold (Storage Service):** 103 + ```bash 104 + # Starts OAuth flow to register in your PDS 105 + ./atcr-hold config/hold.yml 106 + # Follow the OAuth URL in the logs to authorize 107 + ``` 108 + 109 + ### Docker 110 + 111 + **Run with Docker Compose:** 112 + ```bash 113 + docker-compose up -d 114 + ``` 115 + 116 + **Or run containers separately:** 117 + 118 + **AppView (Registry):** 119 + ```bash 120 + docker run -d \ 121 + --name atcr-registry \ 122 + -p 5000:5000 \ 123 + -e ATPROTO_DID=did:plc:your-did \ 124 + -e ATPROTO_ACCESS_TOKEN=your-access-token \ 125 + -e AWS_ACCESS_KEY_ID=your-aws-key \ 126 + -e AWS_SECRET_ACCESS_KEY=your-aws-secret \ 127 + -v $(pwd)/config/config.yml:/etc/atcr/config.yml \ 128 + atcr.io/registry:latest 129 + ``` 130 + 131 + **Hold (Storage Service):** 132 + ```bash 133 + docker run -d \ 134 + --name atcr-hold \ 135 + -p 8080:8080 \ 136 + -e AWS_ACCESS_KEY_ID=your-aws-key \ 137 + -e AWS_SECRET_ACCESS_KEY=your-aws-secret \ 138 + -v $(pwd)/config/hold.yml:/etc/atcr/hold.yml \ 139 + atcr.io/hold:latest 140 + ``` 141 + 142 + ### Kubernetes 143 + 144 + ```yaml 145 + apiVersion: apps/v1 146 + kind: Deployment 147 + metadata: 148 + name: atcr-registry 149 + spec: 150 + replicas: 3 151 + selector: 152 + matchLabels: 153 + app: atcr-registry 154 + template: 155 + metadata: 156 + labels: 157 + app: atcr-registry 158 + spec: 159 + containers: 160 + - name: registry 161 + image: atcr.io/registry:latest 162 + ports: 163 + - containerPort: 5000 164 + env: 165 + - name: ATPROTO_DID 166 + valueFrom: 167 + secretKeyRef: 168 + name: atcr-secrets 169 + key: did 170 + - name: ATPROTO_ACCESS_TOKEN 171 + valueFrom: 172 + secretKeyRef: 173 + name: atcr-secrets 174 + key: access-token 175 + volumeMounts: 176 + - name: config 177 + mountPath: /etc/atcr 178 + volumes: 179 + - name: config 180 + configMap: 181 + name: atcr-config 182 + ``` 183 + 184 + ## Configuration 185 + 186 + See `config/config.yml` for full configuration options. 187 + 188 + Key settings: 189 + - **storage.s3**: S3 bucket configuration for blob storage 190 + - **middleware.repository**: ATProto routing middleware 191 + - **middleware.registry**: Name resolution middleware 192 + 193 + ## Usage 194 + 195 + ### Pushing an Image 196 + 197 + ```bash 198 + # Tag your image 199 + docker tag myapp:latest atcr.io/alice/myapp:latest 200 + 201 + # Push to ATCR 202 + docker push atcr.io/alice/myapp:latest 203 + ``` 204 + 205 + ### Pulling an Image 206 + 207 + ```bash 208 + # Pull from ATCR 209 + docker pull atcr.io/alice/myapp:latest 210 + ``` 211 + 212 + ## Development 213 + 214 + ### Project Structure 215 + 216 + ``` 217 + atcr.io/ 218 + ├── cmd/registry/ # Main entrypoint 219 + ├── pkg/ 220 + │ ├── atproto/ # ATProto client and manifest store 221 + │ ├── storage/ # S3 blob store and routing 222 + │ ├── middleware/ # Registry and repository middleware 223 + │ └── server/ # HTTP handlers 224 + ├── config/ # Configuration files 225 + └── Dockerfile 226 + ``` 227 + 228 + ### Testing 229 + 230 + ```bash 231 + # Run tests 232 + go test ./... 233 + 234 + # Run with race detector 235 + go test -race ./... 236 + ``` 237 + 238 + ## License 239 + 240 + MIT 241 + 242 + ## Contributing 243 + 244 + Contributions welcome! Please open an issue or PR.
+101
SAILOR.md
··· 1 + User approved Claude's plan: 2 + ⎿ Implement Sailor Profile & Hold Discovery System 3 + 4 + Summary 5 + 6 + Add io.atcr.sailor.profile record type to manage user's default hold preference, and update manifest to store historical hold endpoint reference. This enables transparent hold 7 + routing while preserving image ownership semantics. 8 + 9 + Changes Required 10 + 11 + 1. Create Sailor Profile Lexicon 12 + 13 + File: lexicons/io/atcr/sailor/profile.json 14 + - New record type: io.atcr.sailor.profile 15 + - Fields: defaultHold (string, nullable), createdAt, updatedAt 16 + 17 + 2. Update Manifest Lexicon 18 + 19 + File: lexicons/io/atcr/manifest.json 20 + - Add holdEndpoint field (string, required) 21 + - This is historical reference (immutable per manifest) 22 + 23 + 3. Update Go Types 24 + 25 + File: pkg/atproto/lexicon.go 26 + - Add SailorProfileCollection = "io.atcr.sailor.profile" 27 + - Add SailorProfileRecord struct 28 + - Add NewSailorProfileRecord() constructor 29 + - Update ManifestRecord struct to include HoldEndpoint field 30 + 31 + 4. Create Profile Management 32 + 33 + File: pkg/atproto/profile.go (new file) 34 + - EnsureProfile(ctx, client, defaultHoldEndpoint) function 35 + - Logic: check if profile exists, create with default if not 36 + 37 + 5. Update Auth Handlers 38 + 39 + Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go 40 + - Call EnsureProfile() after token validation 41 + - Use authenticated client (has write access to user's PDS) 42 + - Pass AppView's default_hold_endpoint config 43 + 44 + 6. Update Hold Resolution 45 + 46 + File: pkg/middleware/registry.go 47 + - Update findStorageEndpoint() priority: 48 + a. Check io.atcr.sailor.profile.defaultHold 49 + b. If null (opted out): check user's io.atcr.hold, then AppView default 50 + c. If no profile: check user's io.atcr.hold, then AppView default 51 + 52 + 7. Store Hold in Manifest 53 + 54 + File: pkg/atproto/manifest_store.go 55 + - When creating manifest, include resolved holdEndpoint 56 + - Pass hold endpoint through context or parameter 57 + 58 + 8. Update Pull to Use Manifest Hold 59 + 60 + File: pkg/atproto/manifest_store.go and pkg/storage/routing_repository.go 61 + - On pull, extract holdEndpoint from manifest 62 + - Route blob requests to that hold (not via discovery) 63 + 64 + 9. Update Documentation 65 + 66 + Files: CLAUDE.md, docs/BYOS.md, .env.example 67 + - Document sailor profile concept 68 + - Explain hold resolution priority 69 + - Update examples for shared holds 70 + - Document how crew members configure profile 71 + 72 + Benefits 73 + 74 + - ✅ URL structure remains atcr.io/<owner>/<image> (ownership clear) 75 + - ✅ Hold is transparent infrastructure (like S3 region) 76 + - ✅ Supports personal, shared, and public holds 77 + - ✅ Historical reference in manifest (pull works even if defaults change) 78 + - ✅ User can opt-out (set defaultHold to null) 79 + - ✅ Future: UI for self-service profile management 80 + 81 + 82 + Progress Summary 83 + 84 + ✅ Completed: 85 + 1. Created io.atcr.sailor.profile lexicon 86 + 2. Updated io.atcr.manifest lexicon with holdEndpoint field 87 + 3. Updated Go types in pkg/atproto/lexicon.go 88 + 4. Created profile management in pkg/atproto/profile.go 89 + 5. Updated /auth/exchange handler to manage profile 90 + 91 + ⏳ In Progress: 92 + - Need to update /auth/token handler similarly (add defaultHoldEndpoint parameter and profile management) 93 + - Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not interface{} 94 + 95 + 🔜 Remaining: 96 + - Update findStorageEndpoint() for new priority logic (check profile → own hold → default) 97 + - Update manifest_store.go to include holdEndpoint when creating manifests 98 + - Update pull flow to use manifest holdEndpoint 99 + - Update documentation 100 + 101 + The architecture is solid - just need to finish the token handler update and fix the type issue in the config extraction. Would you like me to continue?
+460
SPEC.md
··· 1 + ATProto Container Registry (atcr.io) Implementation Plan 2 + 3 + Project Structure 4 + 5 + /home/data/atcr.io/ 6 + ├── cmd/ 7 + │ └── registry/ 8 + │ └── main.go # Entrypoint that imports distribution 9 + ├── pkg/ 10 + │ ├── atproto/ 11 + │ │ ├── client.go # ATProto client wrapper (using indigo) 12 + │ │ ├── manifest_store.go # Implements distribution.ManifestService 13 + │ │ ├── resolver.go # DID/handle resolution (alice → did:plc:...) 14 + │ │ └── lexicon.go # ATProto record schemas for manifests 15 + │ ├── storage/ 16 + │ │ ├── s3_blob_store.go # Wraps distribution's S3 driver for blobs 17 + │ │ └── routing_repository.go # Routes manifests→ATProto, blobs→S3 18 + │ ├── middleware/ 19 + │ │ ├── repository.go # Repository middleware registration 20 + │ │ └── registry.go # Registry middleware for name resolution 21 + │ └── server/ 22 + │ └── handler.go # HTTP wrapper for custom name resolution 23 + ├── config/ 24 + │ └── config.yml # Registry configuration 25 + ├── go.mod 26 + ├── go.sum 27 + ├── Dockerfile 28 + ├── README.md 29 + └── CLAUDE.md # Updated with architecture docs 30 + 31 + 32 + Implementation Steps 33 + 34 + Phase 1: Project Setup 35 + 36 + 1. Initialize Go module with github.com/distribution/distribution/v3 and github.com/bluesky-social/indigo 37 + 2. Create basic project structure 38 + 3. Set up cmd/registry/main.go that imports distribution and registers middleware 39 + 40 + Phase 2: Core ATProto Integration 41 + 42 + 4. Implement DID/handle resolver (pkg/atproto/resolver.go) 43 + - Resolve handles to DIDs (alice.bsky.social → did:plc:xyz) 44 + - Discover PDS endpoints from DID documents 45 + 5. Create ATProto client wrapper (pkg/atproto/client.go) 46 + - Wrap indigo SDK for manifest storage 47 + - Handle authentication with PDS 48 + 6. Design ATProto lexicon for manifest records (pkg/atproto/lexicon.go) 49 + - Define schema for storing OCI manifests as ATProto records 50 + 51 + Phase 3: Storage Layer 52 + 53 + 7. Implement ATProto manifest store (pkg/atproto/manifest_store.go) 54 + - Implements distribution.ManifestService 55 + - Stores/retrieves manifests from PDS 56 + 8. Implement S3 blob store wrapper (pkg/storage/s3_blob_store.go) 57 + - Wraps distribution's built-in S3 driver 58 + 9. Create routing repository (pkg/storage/routing_repository.go) 59 + - Returns ATProto store for Manifests() 60 + - Returns S3 store for Blobs() 61 + 62 + Phase 4: Middleware Layer 63 + 64 + 10. Implement repository middleware (pkg/middleware/repository.go) 65 + - Registers routing repository 66 + - Configurable via YAML 67 + 11. Implement registry/namespace middleware (pkg/middleware/registry.go) 68 + - Intercepts Repository(name) calls 69 + - Performs name resolution before repository creation 70 + 71 + Phase 5: HTTP Layer (if needed) 72 + 73 + 12. Create custom HTTP handler (pkg/server/handler.go) 74 + - Wraps distribution's HTTP handlers 75 + - Performs early name resolution: atcr.io/alice/myimage → resolve alice 76 + - Delegates to distribution handlers 77 + 78 + Phase 6: Configuration & Deployment 79 + 80 + 13. Create registry configuration (config/config.yml) 81 + 14. Create Dockerfile for building atcr-registry binary 82 + 16. Write README.md with usage instructions 83 + 84 + Phase 7: Documentation 85 + 86 + 17. Update CLAUDE.md with: 87 + - Architecture overview (ATProto for manifests, S3 for blobs) 88 + - Build/run/test commands 89 + - How name resolution works 90 + - Middleware registration flow 91 + - Key design decisions 92 + 93 + Key Technical Decisions 94 + 95 + ATProto Storage Design: 96 + - Manifests stored as ATProto records in user's PDS 97 + - Each image manifest is a record in a io.atcr.manifest collection 98 + - Record key = image digest (sha256:...) 99 + 100 + Name Resolution: 101 + - atcr.io/alice/myimage → resolve alice to DID → discover PDS 102 + - Support both handles (alice.bsky.social) and DIDs (did:plc:xyz) 103 + 104 + Blob Storage: 105 + - All layers/blobs in S3 (content-addressable by digest) 106 + - Manifests reference S3-stored blobs by digest 107 + - S3 provides cheap, durable blob storage 108 + 109 + Middleware Strategy: 110 + - Repository middleware for storage routing 111 + - Registry middleware (or HTTP wrapper) for name resolution 112 + - No fork of distribution core needed 113 + 114 + Perfect. To match Docker Hub/ghcr.io/gcr.io, here's what we need: 115 + 116 + ● Implementation Plan (Drop-in replacement for Docker Hub/ghcr.io) 117 + 118 + Flow 1: Standard Token Auth (Like Docker Hub) - PRIMARY 119 + 120 + # User experience 121 + docker login atcr.io -u alice.bsky.social -p <atproto-app-password> 122 + docker push atcr.io/alice/myapp:latest 123 + 124 + # Behind the scenes 125 + 1. docker login stores credentials locally 126 + 2. docker push → Registry returns 401 with WWW-Authenticate: Bearer realm="https://atcr.io/auth/token"... 127 + 3. Docker auto-calls /auth/token with Basic auth (alice.bsky.social:app-password) 128 + 4. Auth service validates against ATProto createSession 129 + 5. Returns JWT token with scope for alice/myapp 130 + 6. Docker uses JWT for manifest/blob uploads 131 + 7. Registry validates JWT signature and scope 132 + 133 + Components: 134 + - /auth/token endpoint (standalone service or embedded) 135 + - ATProto session validator (username/password → validate via PDS) 136 + - JWT issuer/signer 137 + - JWT validator middleware for registry 138 + 139 + Flow 2: Credential Helper (Like gcr.io) - ADVANCED 140 + 141 + # User experience 142 + docker-credential-atcr configure 143 + # Opens browser for ATProto OAuth 144 + docker push atcr.io/alice/myapp:latest 145 + # No manual login needed 146 + 147 + # Behind the scenes 148 + 1. Helper does OAuth flow → gets ATProto access token 149 + 2. Caches token securely 150 + 3. When Docker needs credentials, calls helper via stdin/stdout 151 + 4. Helper exchanges ATProto token for registry JWT at /auth/exchange 152 + 5. Returns JWT to Docker 153 + 6. Docker uses JWT for requests 154 + 155 + Components: 156 + - cmd/credential-helper/main.go - Standalone binary 157 + - ATProto OAuth client 158 + - Token exchange endpoint (/auth/exchange) 159 + - Secure token cache 160 + 161 + Architecture: 162 + 163 + pkg/auth/ 164 + ├── token/ 165 + │ ├── service.go # HTTP handler for /auth/token 166 + │ ├── claims.go # JWT claims structure 167 + │ ├── issuer.go # Signs JWTs 168 + │ └── validator.go # Validates JWTs (middleware for registry) 169 + ├── atproto/ 170 + │ ├── session.go # Validates username/password via ATProto 171 + │ └── oauth.go # OAuth flow implementation 172 + ├── exchange/ 173 + │ └── handler.go # /auth/exchange endpoint (OAuth → JWT) 174 + └── scope.go # Parses/validates Docker scopes 175 + 176 + cmd/ 177 + ├── registry/main.go # Registry server (existing) 178 + ├── auth/main.go # Standalone auth service (optional) 179 + └── credential-helper/ 180 + └── main.go # docker-credential-atcr binary 181 + 182 + Config: 183 + 184 + auth: 185 + token: 186 + realm: https://atcr.io/auth/token # Where Docker gets tokens 187 + service: atcr.io 188 + issuer: atcr.io 189 + rootcertbundle: /etc/atcr/token-signing.crt 190 + privatekey: /etc/atcr/token-signing.pem 191 + expiration: 300 192 + 193 + atproto: 194 + # Used by auth service to validate credentials 195 + pds_endpoint: https://bsky.social 196 + client_id: atcr-registry 197 + oauth_redirect: http://localhost:8888/callback 198 + 199 + ATProto OAuth Implementation Plan 200 + 201 + Architecture 202 + 203 + Dependencies: 204 + - authelia.com/client/oauth2 - OAuth + PAR support 205 + - github.com/AxisCommunications/go-dpop - DPoP proof generation (handles JWK automatically) 206 + - github.com/golang-jwt/jwt/v5 - JWT library (transitive via go-dpop) 207 + - Our existing pkg/atproto/resolver.go - ATProto identity resolution 208 + 209 + Implementation Components 210 + 211 + 1. OAuth Client (pkg/auth/oauth/client.go) - ~100 lines 212 + 213 + type Client struct { 214 + config *oauth2.Config 215 + dpopKey *ecdsa.PrivateKey 216 + resolver *atproto.Resolver 217 + clientID string // URL to our metadata document 218 + redirectURI string 219 + dpopNonce string // Server-provided nonce 220 + } 221 + 222 + func NewClient(clientID, redirectURI string) (*Client, error) 223 + func (c *Client) AuthorizeURL(handle string, scopes []string) (string, error) 224 + func (c *Client) Exchange(code string) (*Token, error) 225 + func (c *Client) addDPoPHeader(req *http.Request, method, url string) error 226 + 227 + Flow: 228 + 1. Generate ECDSA P-256 key for DPoP 229 + 2. Discover authorization server from handle/DID 230 + 3. Use authelia's PushedAuth() for PAR with DPoP header 231 + 4. Exchange code for token with DPoP proof 232 + 233 + 2. Authorization Server Discovery (pkg/auth/oauth/discovery.go) - ~30 lines 234 + 235 + type AuthServerMetadata struct { 236 + Issuer string `json:"issuer"` 237 + AuthorizationEndpoint string `json:"authorization_endpoint"` 238 + TokenEndpoint string `json:"token_endpoint"` 239 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 240 + DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"` 241 + } 242 + 243 + func DiscoverAuthServer(pdsEndpoint string) (*AuthServerMetadata, error) 244 + 245 + Implementation: 246 + - GET {pds}/.well-known/oauth-authorization-server 247 + - Parse JSON metadata 248 + - Validate required endpoints exist 249 + 250 + 3. Client Metadata Server (pkg/auth/oauth/metadata.go) - ~40 lines 251 + 252 + type ClientMetadata struct { 253 + ClientID string `json:"client_id"` 254 + RedirectURIs []string `json:"redirect_uris"` 255 + GrantTypes []string `json:"grant_types"` 256 + ResponseTypes []string `json:"response_types"` 257 + Scope string `json:"scope"` 258 + DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 259 + } 260 + 261 + func ServeMetadata(clientID string, redirectURIs []string) http.Handler 262 + 263 + Serves: https://atcr.io/oauth/client-metadata.json 264 + 265 + 4. Token Storage (pkg/auth/oauth/storage.go) - ~50 lines 266 + 267 + type TokenStore struct { 268 + AccessToken string 269 + RefreshToken string 270 + DPoPKey *ecdsa.PrivateKey // Persist for refresh 271 + ExpiresAt time.Time 272 + } 273 + 274 + func (s *TokenStore) Save(path string) error 275 + func LoadTokenStore(path string) (*TokenStore, error) 276 + 277 + Storage location: ~/.atcr/oauth-tokens.json 278 + 279 + 5. Credential Helper (cmd/credential-helper/main.go) - ~80 lines 280 + 281 + // Docker credential helper protocol 282 + // Reads JSON from stdin, writes to stdout 283 + 284 + func main() { 285 + if len(os.Args) < 2 { 286 + os.Exit(1) 287 + } 288 + 289 + switch os.Args[1] { 290 + case "get": 291 + handleGet() // Return credentials for registry 292 + case "store": 293 + handleStore() // Store credentials 294 + case "erase": 295 + handleErase() // Remove credentials 296 + } 297 + } 298 + 299 + func handleGet() { 300 + var request struct { 301 + ServerURL string `json:"ServerURL"` 302 + } 303 + json.NewDecoder(os.Stdin).Decode(&request) 304 + 305 + // Load token from storage 306 + // Exchange for registry JWT if needed 307 + // Output: {"Username": "oauth2", "Secret": "<jwt>"} 308 + } 309 + 310 + 6. OAuth Flow (cmd/credential-helper/oauth.go) - ~60 lines 311 + 312 + func RunOAuthFlow(handle string) (*TokenStore, error) { 313 + // 1. Start local HTTP server on :8888 314 + // 2. Open browser to authorization URL 315 + // 3. Wait for callback with code 316 + // 4. Exchange code for token 317 + // 5. Save token store 318 + // 6. Return token 319 + } 320 + 321 + func startCallbackServer() (chan string, *http.Server) 322 + 323 + Complete Flow Example 324 + 325 + User runs: 326 + docker-credential-atcr configure 327 + 328 + What happens: 329 + 330 + 1. Generate DPoP key (client.go) 331 + dpopKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 332 + 333 + 2. Resolve handle → DID → PDS (using our resolver) 334 + did, pds, _ := resolver.ResolveIdentity(ctx, "alice.bsky.social") 335 + 336 + 3. Discover auth server (discovery.go) 337 + metadata, _ := DiscoverAuthServer(pds) 338 + // Returns: PAR endpoint, token endpoint, etc. 339 + 340 + 4. Create PAR request with DPoP (client.go + go-dpop) 341 + // Generate DPoP proof for PAR endpoint 342 + claims := &dpop.ProofTokenClaims{ 343 + Method: dpop.POST, 344 + URL: metadata.PushedAuthorizationRequestEndpoint, 345 + RegisteredClaims: &jwt.RegisteredClaims{ 346 + IssuedAt: jwt.NewNumericDate(time.Now()), 347 + }, 348 + } 349 + dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey) 350 + 351 + // Use authelia for PAR 352 + config := &oauth2.Config{ 353 + ClientID: "https://atcr.io/oauth/client-metadata.json", 354 + Endpoint: oauth2.Endpoint{ 355 + AuthURL: metadata.AuthorizationEndpoint, 356 + TokenURL: metadata.TokenEndpoint, 357 + }, 358 + } 359 + 360 + // Create custom HTTP client that adds DPoP header 361 + client := &http.Client{ 362 + Transport: &dpopTransport{ 363 + base: http.DefaultTransport, 364 + dpopKey: dpopKey, 365 + }, 366 + } 367 + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) 368 + 369 + // PAR request (authelia handles this) 370 + authURL, parResp, _ := config.PushedAuth(ctx, state, 371 + oauth2.SetAuthURLParam("code_challenge", pkceChallenge), 372 + oauth2.SetAuthURLParam("code_challenge_method", "S256"), 373 + ) 374 + 375 + 5. Open browser, get code (oauth.go) 376 + exec.Command("open", authURL).Run() 377 + // User authorizes 378 + // Callback: http://localhost:8888?code=xyz&state=abc 379 + 380 + 6. Exchange code for token with DPoP (client.go + go-dpop) 381 + // Generate DPoP proof for token endpoint 382 + claims := &dpop.ProofTokenClaims{ 383 + Method: dpop.POST, 384 + URL: metadata.TokenEndpoint, 385 + RegisteredClaims: &jwt.RegisteredClaims{ 386 + IssuedAt: jwt.NewNumericDate(time.Now()), 387 + }, 388 + } 389 + dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey) 390 + 391 + // Exchange (with DPoP header added by our transport) 392 + token, _ := config.Exchange(ctx, code, 393 + oauth2.SetAuthURLParam("code_verifier", pkceVerifier), 394 + ) 395 + 396 + 7. Save token + DPoP key (storage.go) 397 + store := &TokenStore{ 398 + AccessToken: token.AccessToken, 399 + RefreshToken: token.RefreshToken, 400 + DPoPKey: dpopKey, 401 + ExpiresAt: token.Expiry, 402 + } 403 + store.Save("~/.atcr/oauth-tokens.json") 404 + 405 + Later, when docker push happens: 406 + docker push atcr.io/alice/myapp:latest 407 + 408 + 1. Docker calls credential helper: docker-credential-atcr get 409 + 2. Helper loads stored token 410 + 3. Helper calls /auth/exchange with OAuth token → gets registry JWT 411 + 4. Returns JWT to Docker 412 + 5. Docker uses JWT for push 413 + 414 + Directory Structure 415 + 416 + pkg/auth/oauth/ 417 + ├── client.go # OAuth client with DPoP integration 418 + ├── discovery.go # Authorization server discovery 419 + ├── metadata.go # Client metadata server 420 + ├── storage.go # Token persistence 421 + └── transport.go # HTTP transport that adds DPoP headers 422 + 423 + cmd/credential-helper/ 424 + ├── main.go # Docker credential helper protocol 425 + ├── oauth.go # OAuth flow (browser, callback) 426 + └── config.go # Configuration 427 + 428 + go.mod additions: 429 + authelia.com/client/oauth2 v0.25.0 430 + github.com/AxisCommunications/go-dpop v1.1.2 431 + 432 + Unified Model 433 + 434 + Every hold service requires HOLD_CREW_OWNER: 435 + - Owner's PDS has the io.atcr.hold record 436 + - Owner's PDS has all io.atcr.hold.crew records 437 + - Authorization is always governed by PDS records 438 + 439 + For "public" hold (like Tangled's public knot): 440 + - Owner creates hold with public: true 441 + - Anyone can push/pull without being crew 442 + - Owner can add crew records for special privileges/tracking if desired 443 + 444 + Config has emergency override: 445 + auth: 446 + # Emergency freeze: ignore public setting, restrict to crew only 447 + # Use this to stop abuse without changing PDS records 448 + freeze: false 449 + 450 + Authorization logic: 451 + 1. Check freeze in config → if true, skip to crew check 452 + 2. Query owner's PDS for io.atcr.hold record 453 + 3. If public: true → allow all operations (unless frozen) 454 + 4. If public: false OR frozen → query io.atcr.hold.crew records, check membership 455 + 456 + Remove from config: 457 + - allow_all (replaced by public: true in PDS) 458 + - allowed_dids (replaced by crew records in PDS) 459 + 460 + This way the hold owner at atcr.io can run a public hold at hold1.atcr.io that anyone can use, but can freeze it instantly if needed without touching PDS records.
+334
TESTING.md
··· 1 + # Local Testing Guide 2 + 3 + ## Quick Start 4 + 5 + ```bash 6 + ./test-local.sh 7 + ``` 8 + 9 + This automated script will: 10 + 1. Create storage directories 11 + 2. Build all binaries 12 + 3. Start both services 13 + 4. Show test commands 14 + 15 + ## Manual Testing Steps 16 + 17 + ### 1. Setup Directories 18 + 19 + ```bash 20 + sudo mkdir -p /var/lib/atcr/{blobs,hold,auth} 21 + sudo chown -R $USER:$USER /var/lib/atcr 22 + ``` 23 + 24 + ### 2. Build Binaries 25 + 26 + ```bash 27 + go build -o atcr-registry ./cmd/registry 28 + go build -o atcr-hold ./cmd/hold 29 + go build -o docker-credential-atcr ./cmd/credential-helper 30 + ``` 31 + 32 + ### 3. Configure Environment 33 + 34 + Create a `.env` file in the project root: 35 + 36 + ```bash 37 + cp .env.example .env 38 + ``` 39 + 40 + Edit `.env` with your credentials: 41 + 42 + ```env 43 + # Your ATProto handle 44 + ATPROTO_HANDLE=your-handle.bsky.social 45 + 46 + # Hold service public URL (hostname becomes the hold name) 47 + HOLD_PUBLIC_URL=http://127.0.0.1:8080 48 + 49 + # Enable OAuth registration on startup 50 + HOLD_AUTO_REGISTER=true 51 + ``` 52 + 53 + **Notes:** 54 + - Use your Bluesky handle (e.g., `alice.bsky.social`) 55 + - For localhost, use `127.0.0.1` instead of `localhost` for OAuth 56 + - The hostname from the URL becomes the hold name (e.g., `127.0.0.1` or `hold1.atcr.io`) 57 + 58 + **Load environment:** 59 + ```bash 60 + export $(cat .env | xargs) 61 + ``` 62 + 63 + ### 4. Start Services 64 + 65 + **Terminal 1 - Registry:** 66 + ```bash 67 + ./atcr-registry serve config/config.yml 68 + ``` 69 + 70 + **Terminal 2 - Hold:** 71 + ```bash 72 + ./atcr-hold config/hold.yml 73 + ``` 74 + 75 + ### 5. Start Services and OAuth Registration 76 + 77 + **Terminal 1 - Registry:** 78 + ```bash 79 + ./atcr-registry serve config/config.yml 80 + ``` 81 + 82 + **Terminal 2 - Hold (OAuth registration):** 83 + ```bash 84 + ./atcr-hold config/hold.yml 85 + ``` 86 + 87 + The hold service will start an OAuth flow. You'll see output like: 88 + 89 + ``` 90 + ================================================================================ 91 + OAUTH AUTHORIZATION REQUIRED 92 + ================================================================================ 93 + 94 + Please visit this URL to authorize the hold service: 95 + 96 + https://bsky.social/oauth/authorize?... 97 + 98 + Waiting for authorization... 99 + ================================================================================ 100 + ``` 101 + 102 + **Steps:** 103 + 1. Copy the OAuth URL from the logs 104 + 2. Open it in your browser 105 + 3. Sign in to Bluesky and authorize 106 + 4. The callback will complete automatically 107 + 5. Hold service registers in your PDS 108 + 109 + After successful OAuth, you'll see: 110 + ``` 111 + ✓ Created hold record: at://did:plc:.../io.atcr.hold/127.0.0.1 112 + ✓ Created crew record: at://did:plc:.../io.atcr.hold.crew/127.0.0.1-did:plc:... 113 + ================================================================================ 114 + REGISTRATION COMPLETE 115 + ================================================================================ 116 + Hold service is now registered and ready to use! 117 + ``` 118 + 119 + This creates two records in your PDS: 120 + - `io.atcr.hold` - Defines the storage endpoint URL 121 + - `io.atcr.hold.crew` - Grants you admin access 122 + 123 + ### 6. Test Docker Push/Pull 124 + 125 + **Test 1: Basic Push** 126 + ```bash 127 + # Tag an image 128 + docker tag alpine:latest localhost:5000/alice/alpine:test 129 + 130 + # Push to local registry 131 + docker push localhost:5000/alice/alpine:test 132 + ``` 133 + 134 + **Test 2: Pull** 135 + ```bash 136 + # Remove local image 137 + docker rmi localhost:5000/alice/alpine:test 138 + 139 + # Pull from registry 140 + docker pull localhost:5000/alice/alpine:test 141 + ``` 142 + 143 + **Test 3: Verify Storage** 144 + ```bash 145 + # Check manifests were stored in ATProto 146 + # (Check your PDS for io.atcr.manifest records) 147 + 148 + # Check blobs were stored locally 149 + ls -lh /var/lib/atcr/blobs/docker/registry/v2/ 150 + ``` 151 + 152 + ## OAuth Testing (Optional) 153 + 154 + ### Setup Credential Helper 155 + 156 + ```bash 157 + # Configure OAuth 158 + ./docker-credential-atcr configure 159 + 160 + # Follow the browser flow to authorize 161 + 162 + # Verify token was saved 163 + ls -la ~/.atcr/oauth-token.json 164 + ``` 165 + 166 + ### Configure Docker to Use Helper 167 + 168 + Edit `~/.docker/config.json`: 169 + ```json 170 + { 171 + "credHelpers": { 172 + "localhost:5000": "atcr" 173 + } 174 + } 175 + ``` 176 + 177 + ### Test with OAuth 178 + 179 + ```bash 180 + # Push should now use OAuth automatically 181 + docker push localhost:5000/alice/myapp:latest 182 + ``` 183 + 184 + ## Troubleshooting 185 + 186 + ### Registry won't start 187 + 188 + **Error:** `failed to create storage driver` 189 + ```bash 190 + # Check directory permissions 191 + ls -ld /var/lib/atcr/blobs 192 + # Should be owned by your user 193 + 194 + # Fix permissions 195 + sudo chown -R $USER:$USER /var/lib/atcr 196 + ``` 197 + 198 + **Error:** `address already in use` 199 + ```bash 200 + # Check what's using port 5000 201 + lsof -i :5000 202 + 203 + # Kill existing process 204 + kill $(lsof -t -i :5000) 205 + ``` 206 + 207 + ### Hold service won't start 208 + 209 + **Error:** `failed to create storage driver` 210 + ```bash 211 + # Check hold directory 212 + ls -ld /var/lib/atcr/hold 213 + sudo chown -R $USER:$USER /var/lib/atcr/hold 214 + ``` 215 + 216 + **Error:** `address already in use` 217 + ```bash 218 + # Check port 8080 219 + lsof -i :8080 220 + kill $(lsof -t -i :8080) 221 + ``` 222 + 223 + ### Docker push fails 224 + 225 + **Error:** `unauthorized: authentication required` 226 + - Check `ATPROTO_DID` and `ATPROTO_ACCESS_TOKEN` are set 227 + - Verify token is valid (not expired) 228 + - Check registry logs for auth errors 229 + 230 + **Error:** `denied: requested access to the resource is denied` 231 + - Check the identity in the image name matches your DID 232 + - Example: If your handle is `alice.bsky.social`, use: 233 + ```bash 234 + docker push localhost:5000/alice/myapp:test 235 + # NOT localhost:5000/bob/myapp:test 236 + ``` 237 + 238 + **Error:** `failed to resolve identity` 239 + - Check internet connection (needs to resolve DIDs) 240 + - Verify handle is correct 241 + - Try using DID directly instead of handle 242 + 243 + ### OAuth issues 244 + 245 + **Error:** `Failed to exchange token` 246 + - Ensure registry is running and accessible 247 + - Check `/auth/exchange` endpoint is responding 248 + - Verify OAuth token hasn't expired 249 + 250 + **Error:** `Token validation failed` 251 + - Token might be expired 252 + - Run `./docker-credential-atcr configure` again 253 + - Check PDS is accessible 254 + 255 + ## Verifying the Flow 256 + 257 + ### Check Registry is Running 258 + ```bash 259 + curl http://localhost:5000/v2/ 260 + # Should return: {} 261 + ``` 262 + 263 + ### Check Hold is Running 264 + ```bash 265 + curl http://localhost:8080/health 266 + # Should return: {"status":"ok"} 267 + ``` 268 + 269 + ### Check Auth Endpoint 270 + ```bash 271 + curl -v http://localhost:5000/v2/ 272 + # Should return 401 with WWW-Authenticate header 273 + ``` 274 + 275 + ### Inspect Stored Data 276 + 277 + **Manifests (in ATProto):** 278 + - Check your PDS web interface 279 + - Look for `io.atcr.manifest` collection records 280 + 281 + **Blobs (local filesystem):** 282 + ```bash 283 + # List blobs 284 + find /var/lib/atcr/blobs -type f 285 + 286 + # Check blob content (should be binary) 287 + ls -lh /var/lib/atcr/blobs/docker/registry/v2/blobs/sha256/ 288 + ``` 289 + 290 + ## Clean Up 291 + 292 + ### Stop Services 293 + ```bash 294 + # If using test script 295 + kill $(cat .atcr-pids) 296 + 297 + # Or manually 298 + pkill atcr-registry 299 + pkill atcr-hold 300 + ``` 301 + 302 + ### Remove Test Data 303 + ```bash 304 + # Remove all stored data 305 + sudo rm -rf /var/lib/atcr/* 306 + 307 + # Remove OAuth tokens 308 + rm -rf ~/.atcr/ 309 + ``` 310 + 311 + ### Reset Docker Config 312 + ```bash 313 + # Remove credential helper config 314 + # Edit ~/.docker/config.json and remove "credHelpers" section 315 + ``` 316 + 317 + ## Next Steps 318 + 319 + Once local testing works: 320 + 321 + 1. **Deploy to production:** 322 + - Use S3/Storj for blob storage 323 + - Deploy registry and hold to separate hosts 324 + - Configure DNS for `atcr.io` 325 + 326 + 2. **Enable BYOS:** 327 + - Users create `io.atcr.hold` records 328 + - Deploy their own hold service 329 + - AppView automatically routes to their storage 330 + 331 + 3. **Add monitoring:** 332 + - Registry metrics 333 + - Hold service metrics 334 + - Storage usage tracking
atcr-storage

This is a binary file and will not be displayed.

+158
cmd/credential-helper/main.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + ) 9 + 10 + // Docker credential helper protocol 11 + // https://github.com/docker/docker-credential-helpers 12 + 13 + // Credentials represents docker credentials 14 + type Credentials struct { 15 + ServerURL string `json:"ServerURL,omitempty"` 16 + Username string `json:"Username,omitempty"` 17 + Secret string `json:"Secret,omitempty"` 18 + } 19 + 20 + func main() { 21 + if len(os.Args) < 2 { 22 + fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure>\n") 23 + os.Exit(1) 24 + } 25 + 26 + command := os.Args[1] 27 + 28 + switch command { 29 + case "get": 30 + handleGet() 31 + case "store": 32 + handleStore() 33 + case "erase": 34 + handleErase() 35 + case "configure": 36 + handleConfigure() 37 + default: 38 + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) 39 + os.Exit(1) 40 + } 41 + } 42 + 43 + // handleGet retrieves credentials for the given server 44 + func handleGet() { 45 + var request Credentials 46 + if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { 47 + fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err) 48 + os.Exit(1) 49 + } 50 + 51 + // Load token from storage 52 + tokenPath := getTokenPath() 53 + token, err := loadToken(tokenPath) 54 + if err != nil { 55 + fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err) 56 + os.Exit(1) 57 + } 58 + 59 + // Check if token is expired and refresh if needed 60 + if token.IsExpired && token.RefreshToken != "" { 61 + if err := refreshToken(token); err != nil { 62 + fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err) 63 + os.Exit(1) 64 + } 65 + } 66 + 67 + // Exchange ATProto token for registry JWT 68 + registryJWT, err := exchangeForRegistryToken(token.AccessToken, request.ServerURL) 69 + if err != nil { 70 + fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err) 71 + os.Exit(1) 72 + } 73 + 74 + // Return credentials 75 + creds := Credentials{ 76 + ServerURL: request.ServerURL, 77 + Username: "oauth2", 78 + Secret: registryJWT, 79 + } 80 + 81 + if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { 82 + fmt.Fprintf(os.Stderr, "Error encoding response: %v\n", err) 83 + os.Exit(1) 84 + } 85 + } 86 + 87 + // handleStore stores credentials (Docker calls this after login) 88 + func handleStore() { 89 + var creds Credentials 90 + if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil { 91 + fmt.Fprintf(os.Stderr, "Error decoding credentials: %v\n", err) 92 + os.Exit(1) 93 + } 94 + 95 + // For OAuth flow, we don't actually store credentials from docker login 96 + // The credentials are managed through the OAuth flow 97 + // This is a no-op for us 98 + } 99 + 100 + // handleErase removes stored credentials 101 + func handleErase() { 102 + var request Credentials 103 + if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { 104 + fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err) 105 + os.Exit(1) 106 + } 107 + 108 + // Remove token file 109 + tokenPath := getTokenPath() 110 + if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) { 111 + fmt.Fprintf(os.Stderr, "Error removing token: %v\n", err) 112 + os.Exit(1) 113 + } 114 + } 115 + 116 + // handleConfigure runs the OAuth flow to get initial credentials 117 + func handleConfigure() { 118 + fmt.Println("ATCR Credential Helper Configuration") 119 + fmt.Println("=====================================") 120 + fmt.Println() 121 + 122 + // Ask for handle 123 + fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") 124 + var handle string 125 + if _, err := fmt.Scanln(&handle); err != nil { 126 + fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err) 127 + os.Exit(1) 128 + } 129 + 130 + // Run OAuth flow 131 + fmt.Println("\nStarting OAuth flow...") 132 + token, err := runOAuthFlow(handle) 133 + if err != nil { 134 + fmt.Fprintf(os.Stderr, "Error during OAuth flow: %v\n", err) 135 + os.Exit(1) 136 + } 137 + 138 + // Save token 139 + tokenPath := getTokenPath() 140 + if err := token.Save(tokenPath); err != nil { 141 + fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err) 142 + os.Exit(1) 143 + } 144 + 145 + fmt.Println("\nConfiguration complete!") 146 + fmt.Println("You can now use docker push/pull with atcr.io") 147 + } 148 + 149 + // getTokenPath returns the path to the token file 150 + func getTokenPath() string { 151 + homeDir, err := os.UserHomeDir() 152 + if err != nil { 153 + fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) 154 + os.Exit(1) 155 + } 156 + 157 + return filepath.Join(homeDir, ".atcr", "oauth-token.json") 158 + }
+176
cmd/credential-helper/oauth.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "os/exec" 8 + "runtime" 9 + "time" 10 + 11 + "atcr.io/pkg/atproto" 12 + "atcr.io/pkg/auth/oauth" 13 + ) 14 + 15 + const ( 16 + clientID = "http://localhost:8888/client-metadata.json" 17 + redirectURI = "http://localhost:8888/callback" 18 + ) 19 + 20 + // runOAuthFlow executes the OAuth flow with browser 21 + func runOAuthFlow(handle string) (*oauth.TokenStore, error) { 22 + // Create OAuth client 23 + client, err := oauth.NewClient(clientID, redirectURI) 24 + if err != nil { 25 + return nil, fmt.Errorf("failed to create OAuth client: %w", err) 26 + } 27 + 28 + // Initialize for the given handle 29 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 30 + defer cancel() 31 + 32 + if err := client.InitializeForHandle(ctx, handle); err != nil { 33 + return nil, fmt.Errorf("failed to initialize client: %w", err) 34 + } 35 + 36 + // Start local callback server 37 + codeChan := make(chan string, 1) 38 + errChan := make(chan error, 1) 39 + server := startCallbackServer(codeChan, errChan) 40 + defer server.Shutdown(context.Background()) 41 + 42 + // Also serve client metadata 43 + http.HandleFunc("/client-metadata.json", oauth.ServeMetadata( 44 + oauth.NewClientMetadata(clientID, []string{redirectURI}), 45 + )) 46 + 47 + // Generate authorization URL with PKCE 48 + state := generateState() 49 + authURL, codeVerifier, err := client.AuthorizeURL(state) 50 + if err != nil { 51 + return nil, fmt.Errorf("failed to generate auth URL: %w", err) 52 + } 53 + 54 + // Open browser 55 + fmt.Printf("Opening browser to: %s\n", authURL) 56 + if err := openBrowser(authURL); err != nil { 57 + fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL) 58 + } 59 + 60 + // Wait for callback 61 + var code string 62 + select { 63 + case code = <-codeChan: 64 + fmt.Println("Authorization successful!") 65 + case err := <-errChan: 66 + return nil, fmt.Errorf("authorization failed: %w", err) 67 + case <-time.After(5 * time.Minute): 68 + return nil, fmt.Errorf("authorization timed out") 69 + } 70 + 71 + // Exchange code for token 72 + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) 73 + defer cancel() 74 + 75 + token, err := client.Exchange(ctx, code, codeVerifier) 76 + if err != nil { 77 + return nil, fmt.Errorf("failed to exchange code: %w", err) 78 + } 79 + 80 + // Resolve handle to get DID 81 + resolver := atproto.NewResolver() 82 + did, _, err := resolver.ResolveIdentity(context.Background(), handle) 83 + if err != nil { 84 + return nil, fmt.Errorf("failed to resolve DID: %w", err) 85 + } 86 + 87 + // Create token store 88 + store := &oauth.TokenStore{ 89 + AccessToken: token.AccessToken, 90 + RefreshToken: token.RefreshToken, 91 + TokenType: token.TokenType, 92 + ExpiresAt: token.Expiry, 93 + Handle: handle, 94 + DID: did, 95 + } 96 + 97 + // Save DPoP key 98 + if err := store.SetDPoPKey(client.DPoPKey()); err != nil { 99 + return nil, fmt.Errorf("failed to save DPoP key: %w", err) 100 + } 101 + 102 + return store, nil 103 + } 104 + 105 + // startCallbackServer starts a local HTTP server to receive the OAuth callback 106 + func startCallbackServer(codeChan chan string, errChan chan error) *http.Server { 107 + mux := http.NewServeMux() 108 + 109 + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 110 + code := r.URL.Query().Get("code") 111 + errorParam := r.URL.Query().Get("error") 112 + 113 + if errorParam != "" { 114 + errChan <- fmt.Errorf("OAuth error: %s (%s)", 115 + errorParam, 116 + r.URL.Query().Get("error_description")) 117 + http.Error(w, "Authorization failed", http.StatusBadRequest) 118 + return 119 + } 120 + 121 + if code == "" { 122 + errChan <- fmt.Errorf("no code in callback") 123 + http.Error(w, "No code provided", http.StatusBadRequest) 124 + return 125 + } 126 + 127 + codeChan <- code 128 + w.Header().Set("Content-Type", "text/html") 129 + fmt.Fprintf(w, ` 130 + <html> 131 + <head><title>ATCR Authorization</title></head> 132 + <body> 133 + <h1>Authorization Successful!</h1> 134 + <p>You can close this window and return to the terminal.</p> 135 + </body> 136 + </html> 137 + `) 138 + }) 139 + 140 + server := &http.Server{ 141 + Addr: ":8888", 142 + Handler: mux, 143 + } 144 + 145 + go func() { 146 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 147 + errChan <- fmt.Errorf("callback server error: %w", err) 148 + } 149 + }() 150 + 151 + return server 152 + } 153 + 154 + // openBrowser opens the default browser to the given URL 155 + func openBrowser(url string) error { 156 + var cmd *exec.Cmd 157 + 158 + switch runtime.GOOS { 159 + case "darwin": 160 + cmd = exec.Command("open", url) 161 + case "linux": 162 + cmd = exec.Command("xdg-open", url) 163 + case "windows": 164 + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 165 + default: 166 + return fmt.Errorf("unsupported platform") 167 + } 168 + 169 + return cmd.Start() 170 + } 171 + 172 + // generateState generates a random state parameter 173 + func generateState() string { 174 + // Use the same UUID generation as we do elsewhere 175 + return fmt.Sprintf("state_%d", time.Now().UnixNano()) 176 + }
+119
cmd/credential-helper/token.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "time" 10 + 11 + "atcr.io/pkg/auth/oauth" 12 + ) 13 + 14 + // tokenData holds the token information 15 + type tokenData struct { 16 + *oauth.TokenStore 17 + IsExpired bool 18 + } 19 + 20 + // loadToken loads the token from disk 21 + func loadToken(path string) (*tokenData, error) { 22 + store, err := oauth.LoadTokenStore(path) 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + return &tokenData{ 28 + TokenStore: store, 29 + IsExpired: store.IsExpired(), 30 + }, nil 31 + } 32 + 33 + // refreshToken refreshes an expired token 34 + func refreshToken(token *tokenData) error { 35 + // Create OAuth client 36 + client, err := oauth.NewClient("http://localhost:8888/client-metadata.json", "http://localhost:8888/callback") 37 + if err != nil { 38 + return fmt.Errorf("failed to create OAuth client: %w", err) 39 + } 40 + 41 + // Load DPoP key 42 + dpopKey, err := token.GetDPoPKey() 43 + if err != nil { 44 + return fmt.Errorf("failed to load DPoP key: %w", err) 45 + } 46 + client.SetDPoPKey(dpopKey) 47 + 48 + // Initialize for the handle 49 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 50 + defer cancel() 51 + 52 + if err := client.InitializeForHandle(ctx, token.Handle); err != nil { 53 + return fmt.Errorf("failed to initialize client: %w", err) 54 + } 55 + 56 + // Refresh the token 57 + newToken, err := client.RefreshToken(ctx, token.RefreshToken) 58 + if err != nil { 59 + return fmt.Errorf("failed to refresh token: %w", err) 60 + } 61 + 62 + // Update token store 63 + token.AccessToken = newToken.AccessToken 64 + token.RefreshToken = newToken.RefreshToken 65 + token.ExpiresAt = newToken.Expiry 66 + token.IsExpired = false 67 + 68 + // Save updated token 69 + return token.Save(getTokenPath()) 70 + } 71 + 72 + // exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT 73 + func exchangeForRegistryToken(atprotoToken, registryURL string) (string, error) { 74 + // Call the registry's /auth/exchange endpoint 75 + // This endpoint validates the ATProto token and returns a registry JWT 76 + 77 + exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL) 78 + 79 + // Load token store to get DID/handle 80 + store, err := loadToken(getTokenPath()) 81 + if err != nil { 82 + return "", fmt.Errorf("failed to load token store: %w", err) 83 + } 84 + 85 + reqBody := map[string]interface{}{ 86 + "access_token": atprotoToken, 87 + "handle": store.Handle, // Required for PDS resolution and token validation 88 + "scope": []string{"repository:*:pull,push"}, 89 + } 90 + 91 + body, err := json.Marshal(reqBody) 92 + if err != nil { 93 + return "", fmt.Errorf("failed to marshal request: %w", err) 94 + } 95 + 96 + resp, err := http.Post(exchangeURL, "application/json", bytes.NewReader(body)) 97 + if err != nil { 98 + return "", fmt.Errorf("failed to call exchange endpoint: %w", err) 99 + } 100 + defer resp.Body.Close() 101 + 102 + if resp.StatusCode != http.StatusOK { 103 + return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode) 104 + } 105 + 106 + var result struct { 107 + Token string `json:"token"` 108 + AccessToken string `json:"access_token"` 109 + } 110 + 111 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 112 + return "", fmt.Errorf("failed to decode response: %w", err) 113 + } 114 + 115 + if result.Token != "" { 116 + return result.Token, nil 117 + } 118 + return result.AccessToken, nil 119 + }
+770
cmd/hold/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "net/url" 11 + "os" 12 + "strings" 13 + "time" 14 + 15 + "github.com/distribution/distribution/v3/configuration" 16 + storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 17 + "github.com/distribution/distribution/v3/registry/storage/driver/factory" 18 + 19 + "atcr.io/pkg/atproto" 20 + "atcr.io/pkg/auth/oauth" 21 + 22 + // Import storage drivers 23 + _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" 24 + _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" 25 + ) 26 + 27 + // Config represents the hold service configuration 28 + type Config struct { 29 + Version string `yaml:"version"` 30 + Storage StorageConfig `yaml:"storage"` 31 + Server ServerConfig `yaml:"server"` 32 + Registration RegistrationConfig `yaml:"registration"` 33 + } 34 + 35 + // RegistrationConfig defines auto-registration settings 36 + type RegistrationConfig struct { 37 + // OwnerDID is the owner's ATProto DID (from env: HOLD_CREW_OWNER) 38 + // If set, auto-registration is enabled 39 + OwnerDID string `yaml:"owner_did"` 40 + } 41 + 42 + // StorageConfig wraps distribution's storage configuration 43 + type StorageConfig struct { 44 + configuration.Storage `yaml:",inline"` 45 + } 46 + 47 + // ServerConfig defines server settings 48 + type ServerConfig struct { 49 + // Addr is the address to listen on (e.g., ":8080") 50 + Addr string `yaml:"addr"` 51 + 52 + // PublicURL is the public URL of this hold service (e.g., "https://hold.example.com") 53 + PublicURL string `yaml:"public_url"` 54 + 55 + // Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC) 56 + Public bool `yaml:"public"` 57 + 58 + // ReadTimeout for HTTP requests 59 + ReadTimeout time.Duration `yaml:"read_timeout"` 60 + 61 + // WriteTimeout for HTTP requests 62 + WriteTimeout time.Duration `yaml:"write_timeout"` 63 + } 64 + 65 + // HoldService provides presigned URLs for blob storage in a hold 66 + type HoldService struct { 67 + driver storagedriver.StorageDriver 68 + config *Config 69 + } 70 + 71 + // NewHoldService creates a new hold service 72 + func NewHoldService(cfg *Config) (*HoldService, error) { 73 + // Create storage driver from config 74 + ctx := context.Background() 75 + driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters()) 76 + if err != nil { 77 + return nil, fmt.Errorf("failed to create storage driver: %w", err) 78 + } 79 + 80 + return &HoldService{ 81 + driver: driver, 82 + config: cfg, 83 + }, nil 84 + } 85 + 86 + // GetPresignedURLRequest represents a request for a presigned download URL 87 + type GetPresignedURLRequest struct { 88 + DID string `json:"did"` 89 + Digest string `json:"digest"` 90 + } 91 + 92 + // GetPresignedURLResponse contains the presigned URL 93 + type GetPresignedURLResponse struct { 94 + URL string `json:"url"` 95 + ExpiresAt time.Time `json:"expires_at"` 96 + } 97 + 98 + // PutPresignedURLRequest represents a request for a presigned upload URL 99 + type PutPresignedURLRequest struct { 100 + DID string `json:"did"` 101 + Digest string `json:"digest"` 102 + Size int64 `json:"size"` 103 + } 104 + 105 + // PutPresignedURLResponse contains the presigned upload URL 106 + type PutPresignedURLResponse struct { 107 + URL string `json:"url"` 108 + ExpiresAt time.Time `json:"expires_at"` 109 + } 110 + 111 + // HandleGetPresignedURL handles requests for download URLs 112 + func (s *HoldService) HandleGetPresignedURL(w http.ResponseWriter, r *http.Request) { 113 + if r.Method != http.MethodPost { 114 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 115 + return 116 + } 117 + 118 + var req GetPresignedURLRequest 119 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 120 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 121 + return 122 + } 123 + 124 + // Validate DID authorization 125 + if !s.isAuthorized(req.DID) { 126 + http.Error(w, "forbidden: DID not authorized", http.StatusForbidden) 127 + return 128 + } 129 + 130 + // Generate presigned URL (15 minute expiry) 131 + ctx := context.Background() 132 + expiry := time.Now().Add(15 * time.Minute) 133 + 134 + // For now, construct direct URL to blob 135 + // In production, this would use driver-specific presigned URLs 136 + url, err := s.getDownloadURL(ctx, req.Digest) 137 + if err != nil { 138 + http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) 139 + return 140 + } 141 + 142 + resp := GetPresignedURLResponse{ 143 + URL: url, 144 + ExpiresAt: expiry, 145 + } 146 + 147 + w.Header().Set("Content-Type", "application/json") 148 + json.NewEncoder(w).Encode(resp) 149 + } 150 + 151 + // HandlePutPresignedURL handles requests for upload URLs 152 + func (s *HoldService) HandlePutPresignedURL(w http.ResponseWriter, r *http.Request) { 153 + if r.Method != http.MethodPost { 154 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 155 + return 156 + } 157 + 158 + var req PutPresignedURLRequest 159 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 160 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 161 + return 162 + } 163 + 164 + // Validate DID authorization 165 + if !s.isAuthorized(req.DID) { 166 + http.Error(w, "forbidden: DID not authorized", http.StatusForbidden) 167 + return 168 + } 169 + 170 + // Generate presigned upload URL (15 minute expiry) 171 + ctx := context.Background() 172 + expiry := time.Now().Add(15 * time.Minute) 173 + 174 + url, err := s.getUploadURL(ctx, req.Digest, req.Size) 175 + if err != nil { 176 + http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) 177 + return 178 + } 179 + 180 + resp := PutPresignedURLResponse{ 181 + URL: url, 182 + ExpiresAt: expiry, 183 + } 184 + 185 + w.Header().Set("Content-Type", "application/json") 186 + json.NewEncoder(w).Encode(resp) 187 + } 188 + 189 + // HandleProxyGet proxies a blob download through the service 190 + func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) { 191 + if r.Method != http.MethodGet { 192 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 193 + return 194 + } 195 + 196 + // Extract digest from path (e.g., /blobs/sha256:abc123) 197 + digest := r.URL.Path[len("/blobs/"):] 198 + if digest == "" { 199 + http.Error(w, "missing digest", http.StatusBadRequest) 200 + return 201 + } 202 + 203 + // Get DID from query param or header 204 + did := r.URL.Query().Get("did") 205 + if did == "" { 206 + did = r.Header.Get("X-ATCR-DID") 207 + } 208 + 209 + if !s.isAuthorized(did) { 210 + http.Error(w, "forbidden", http.StatusForbidden) 211 + return 212 + } 213 + 214 + // Read blob from storage 215 + ctx := r.Context() 216 + path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 217 + 218 + content, err := s.driver.GetContent(ctx, path) 219 + if err != nil { 220 + http.Error(w, "blob not found", http.StatusNotFound) 221 + return 222 + } 223 + 224 + w.Header().Set("Content-Type", "application/octet-stream") 225 + w.Write(content) 226 + } 227 + 228 + // HandleProxyPut proxies a blob upload through the service 229 + func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) { 230 + if r.Method != http.MethodPut { 231 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 232 + return 233 + } 234 + 235 + digest := r.URL.Path[len("/blobs/"):] 236 + if digest == "" { 237 + http.Error(w, "missing digest", http.StatusBadRequest) 238 + return 239 + } 240 + 241 + did := r.URL.Query().Get("did") 242 + if did == "" { 243 + did = r.Header.Get("X-ATCR-DID") 244 + } 245 + 246 + if !s.isAuthorized(did) { 247 + http.Error(w, "forbidden", http.StatusForbidden) 248 + return 249 + } 250 + 251 + // Write blob to storage 252 + ctx := r.Context() 253 + path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 254 + 255 + content, err := io.ReadAll(r.Body) 256 + if err != nil { 257 + http.Error(w, "failed to read body", http.StatusBadRequest) 258 + return 259 + } 260 + 261 + if err := s.driver.PutContent(ctx, path, content); err != nil { 262 + http.Error(w, "failed to store blob", http.StatusInternalServerError) 263 + return 264 + } 265 + 266 + w.WriteHeader(http.StatusCreated) 267 + } 268 + 269 + // isAuthorized checks if a DID is authorized to use this hold 270 + // Authorization is now based on: 271 + // - Hold record's "public" field (for reads) 272 + // - Crew records in PDS (for writes) 273 + // TODO: Query PDS to check hold.public and crew membership 274 + func (s *HoldService) isAuthorized(did string) bool { 275 + // For now, allow all requests 276 + // Real implementation should query PDS for hold record and crew records 277 + return true 278 + } 279 + 280 + // getDownloadURL generates a download URL for a blob 281 + func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) { 282 + // Check if blob exists 283 + path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 284 + _, err := s.driver.Stat(ctx, path) 285 + if err != nil { 286 + return "", fmt.Errorf("blob not found: %w", err) 287 + } 288 + 289 + // For drivers that support presigned URLs (S3), use those 290 + // For now, return a proxy URL through this service 291 + return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil 292 + } 293 + 294 + // getUploadURL generates an upload URL for a blob 295 + func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64) (string, error) { 296 + // For drivers that support presigned URLs (S3), use those 297 + // For now, return a proxy URL through this service 298 + return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil 299 + } 300 + 301 + // RegisterRequest represents a request to register this hold in a user's PDS 302 + type RegisterRequest struct { 303 + DID string `json:"did"` 304 + AccessToken string `json:"access_token"` 305 + PDSEndpoint string `json:"pds_endpoint"` 306 + } 307 + 308 + // RegisterResponse contains the registration result 309 + type RegisterResponse struct { 310 + HoldURI string `json:"hold_uri"` 311 + CrewURI string `json:"crew_uri"` 312 + Message string `json:"message"` 313 + } 314 + 315 + // HandleRegister registers this hold service in a user's PDS (manual endpoint) 316 + func (s *HoldService) HandleRegister(w http.ResponseWriter, r *http.Request) { 317 + if r.Method != http.MethodPost { 318 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 319 + return 320 + } 321 + 322 + var req RegisterRequest 323 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 324 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 325 + return 326 + } 327 + 328 + // Validate required fields 329 + if req.DID == "" || req.AccessToken == "" || req.PDSEndpoint == "" { 330 + http.Error(w, "missing required fields: did, access_token, pds_endpoint", http.StatusBadRequest) 331 + return 332 + } 333 + 334 + // Get public URL from config 335 + publicURL := s.config.Server.PublicURL 336 + if publicURL == "" { 337 + // Fallback to constructing URL from request 338 + scheme := "http" 339 + if r.TLS != nil { 340 + scheme = "https" 341 + } 342 + publicURL = fmt.Sprintf("%s://%s", scheme, r.Host) 343 + } 344 + 345 + // Derive hold name from URL 346 + holdName, err := extractHostname(publicURL) 347 + if err != nil { 348 + http.Error(w, fmt.Sprintf("failed to extract hostname: %v", err), http.StatusBadRequest) 349 + return 350 + } 351 + 352 + ctx := r.Context() 353 + 354 + // Create ATProto client with user's credentials 355 + client := atproto.NewClient(req.PDSEndpoint, req.DID, req.AccessToken) 356 + 357 + // Create HoldRecord 358 + holdRecord := atproto.NewHoldRecord(publicURL, req.DID, s.config.Server.Public) 359 + 360 + holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord) 361 + if err != nil { 362 + http.Error(w, fmt.Sprintf("failed to create hold record: %v", err), http.StatusInternalServerError) 363 + return 364 + } 365 + 366 + log.Printf("Created hold record: %s", holdResult.URI) 367 + 368 + // Create HoldCrewRecord for the owner 369 + crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, req.DID, "owner") 370 + 371 + crewRKey := fmt.Sprintf("%s-%s", holdName, req.DID) 372 + crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) 373 + if err != nil { 374 + http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError) 375 + return 376 + } 377 + 378 + log.Printf("Created crew record: %s", crewResult.URI) 379 + 380 + resp := RegisterResponse{ 381 + HoldURI: holdResult.URI, 382 + CrewURI: crewResult.URI, 383 + Message: fmt.Sprintf("Successfully registered hold service. Storage endpoint: %s", publicURL), 384 + } 385 + 386 + w.Header().Set("Content-Type", "application/json") 387 + json.NewEncoder(w).Encode(resp) 388 + } 389 + 390 + // HealthHandler handles health check requests 391 + func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) { 392 + w.Header().Set("Content-Type", "application/json") 393 + json.NewEncoder(w).Encode(map[string]string{ 394 + "status": "ok", 395 + }) 396 + } 397 + 398 + func main() { 399 + // Load configuration from environment variables 400 + cfg, err := loadConfigFromEnv() 401 + if err != nil { 402 + log.Fatalf("Failed to load config: %v", err) 403 + } 404 + 405 + // Create hold service 406 + service, err := NewHoldService(cfg) 407 + if err != nil { 408 + log.Fatalf("Failed to create hold service: %v", err) 409 + } 410 + 411 + // Auto-register if owner DID is set 412 + if cfg.Registration.OwnerDID != "" { 413 + if err := service.AutoRegister(); err != nil { 414 + log.Printf("WARNING: Auto-registration failed: %v", err) 415 + log.Printf("You can register manually later using the /register endpoint") 416 + } else { 417 + log.Printf("Successfully registered hold service in PDS") 418 + } 419 + } 420 + 421 + // Setup HTTP routes 422 + mux := http.NewServeMux() 423 + mux.HandleFunc("/health", service.HealthHandler) 424 + mux.HandleFunc("/register", service.HandleRegister) 425 + mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL) 426 + mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL) 427 + mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) { 428 + if r.Method == http.MethodGet { 429 + service.HandleProxyGet(w, r) 430 + } else if r.Method == http.MethodPut { 431 + service.HandleProxyPut(w, r) 432 + } else { 433 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 434 + } 435 + }) 436 + 437 + // Create server 438 + server := &http.Server{ 439 + Addr: cfg.Server.Addr, 440 + Handler: mux, 441 + ReadTimeout: cfg.Server.ReadTimeout, 442 + WriteTimeout: cfg.Server.WriteTimeout, 443 + } 444 + 445 + log.Printf("Starting hold service on %s", cfg.Server.Addr) 446 + if err := server.ListenAndServe(); err != nil { 447 + log.Fatalf("Server failed: %v", err) 448 + } 449 + } 450 + 451 + // loadConfigFromEnv loads all configuration from environment variables 452 + func loadConfigFromEnv() (*Config, error) { 453 + cfg := &Config{ 454 + Version: "0.1", 455 + } 456 + 457 + // Server configuration 458 + cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080") 459 + cfg.Server.PublicURL = os.Getenv("HOLD_PUBLIC_URL") 460 + if cfg.Server.PublicURL == "" { 461 + return nil, fmt.Errorf("HOLD_PUBLIC_URL is required") 462 + } 463 + cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" 464 + cfg.Server.ReadTimeout = 30 * time.Second 465 + cfg.Server.WriteTimeout = 30 * time.Second 466 + 467 + // Registration configuration (optional) 468 + cfg.Registration.OwnerDID = os.Getenv("HOLD_CREW_OWNER") 469 + 470 + // Storage configuration - build from env vars based on storage type 471 + storageType := getEnvOrDefault("STORAGE_DRIVER", "s3") 472 + var err error 473 + cfg.Storage, err = buildStorageConfig(storageType) 474 + if err != nil { 475 + return nil, fmt.Errorf("failed to build storage config: %w", err) 476 + } 477 + 478 + return cfg, nil 479 + } 480 + 481 + // buildStorageConfig creates storage configuration based on driver type 482 + func buildStorageConfig(driver string) (StorageConfig, error) { 483 + params := make(map[string]interface{}) 484 + 485 + switch driver { 486 + case "s3": 487 + // S3/Storj/Minio configuration from standard AWS env vars 488 + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") 489 + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") 490 + region := getEnvOrDefault("AWS_REGION", "us-east-1") 491 + bucket := os.Getenv("S3_BUCKET") 492 + endpoint := os.Getenv("S3_ENDPOINT") // For Storj/Minio 493 + 494 + if bucket == "" { 495 + return StorageConfig{}, fmt.Errorf("S3_BUCKET is required for S3 storage") 496 + } 497 + 498 + params["accesskey"] = accessKey 499 + params["secretkey"] = secretKey 500 + params["region"] = region 501 + params["bucket"] = bucket 502 + if endpoint != "" { 503 + params["regionendpoint"] = endpoint 504 + } 505 + 506 + case "filesystem": 507 + // Filesystem configuration 508 + rootDir := getEnvOrDefault("STORAGE_ROOT_DIR", "/var/lib/atcr/hold") 509 + params["rootdirectory"] = rootDir 510 + 511 + default: 512 + return StorageConfig{}, fmt.Errorf("unsupported storage driver: %s", driver) 513 + } 514 + 515 + // Build distribution Storage config 516 + storageCfg := configuration.Storage{} 517 + storageCfg[driver] = configuration.Parameters(params) 518 + 519 + return StorageConfig{Storage: storageCfg}, nil 520 + } 521 + 522 + // getEnvOrDefault gets an environment variable or returns a default value 523 + func getEnvOrDefault(key, defaultValue string) string { 524 + if val := os.Getenv(key); val != "" { 525 + return val 526 + } 527 + return defaultValue 528 + } 529 + 530 + // isHoldRegistered checks if a hold with the given public URL is already registered in the PDS 531 + func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) { 532 + // We need to query the PDS without authentication to check public records 533 + // ATProto records are publicly readable, so we can use an unauthenticated client 534 + client := atproto.NewClient(pdsEndpoint, did, "") 535 + 536 + // List all hold records for this DID 537 + records, err := client.ListRecords(ctx, atproto.HoldCollection, 100) 538 + if err != nil { 539 + return false, fmt.Errorf("failed to list hold records: %w", err) 540 + } 541 + 542 + // Check if any hold record matches our public URL 543 + for _, record := range records { 544 + var holdRecord atproto.HoldRecord 545 + if err := json.Unmarshal(record.Value, &holdRecord); err != nil { 546 + continue 547 + } 548 + 549 + if holdRecord.Endpoint == publicURL { 550 + return true, nil 551 + } 552 + } 553 + 554 + return false, nil 555 + } 556 + 557 + // AutoRegister registers this hold service in the owner's PDS 558 + // Checks if already registered first, then does OAuth if needed 559 + func (s *HoldService) AutoRegister() error { 560 + reg := &s.config.Registration 561 + publicURL := s.config.Server.PublicURL 562 + 563 + if publicURL == "" { 564 + return fmt.Errorf("HOLD_PUBLIC_URL not set") 565 + } 566 + 567 + if reg.OwnerDID == "" { 568 + return fmt.Errorf("HOLD_CREW_OWNER not set - required for registration") 569 + } 570 + 571 + ctx := context.Background() 572 + 573 + log.Printf("Checking registration status for DID: %s", reg.OwnerDID) 574 + 575 + // Resolve DID to PDS endpoint 576 + resolver := atproto.NewResolver() 577 + pdsEndpoint, err := resolver.ResolvePDS(ctx, reg.OwnerDID) 578 + if err != nil { 579 + return fmt.Errorf("failed to resolve PDS for DID: %w", err) 580 + } 581 + 582 + log.Printf("PDS endpoint: %s", pdsEndpoint) 583 + 584 + // Check if hold is already registered 585 + isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL) 586 + if err != nil { 587 + log.Printf("Warning: failed to check registration status: %v", err) 588 + log.Printf("Proceeding with OAuth registration...") 589 + } else if isRegistered { 590 + log.Printf("✓ Hold service already registered in PDS") 591 + log.Printf("Public URL: %s", publicURL) 592 + return nil 593 + } 594 + 595 + // Not registered, need to do OAuth 596 + log.Printf("Hold not registered, starting OAuth flow...") 597 + 598 + // Get handle from DID document 599 + handle, err := resolver.ResolveHandleFromDID(ctx, reg.OwnerDID) 600 + if err != nil { 601 + return fmt.Errorf("failed to get handle from DID: %w", err) 602 + } 603 + 604 + log.Printf("Resolved handle: %s", handle) 605 + log.Printf("Starting OAuth registration for hold service") 606 + log.Printf("Public URL: %s", publicURL) 607 + 608 + return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint) 609 + } 610 + 611 + // registerWithOAuth performs OAuth flow and registers the hold 612 + func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error { 613 + // Use 127.0.0.1 for localhost callback (works better than "localhost") 614 + callbackAddr := "127.0.0.1:8888" 615 + redirectURI := fmt.Sprintf("http://%s/callback", callbackAddr) 616 + 617 + // Create OAuth client 618 + oauthClient, err := oauth.NewClient("http://hold-service", redirectURI) 619 + if err != nil { 620 + return fmt.Errorf("failed to create OAuth client: %w", err) 621 + } 622 + 623 + // Initialize for the user's handle 624 + ctx := context.Background() 625 + if err := oauthClient.InitializeForHandle(ctx, handle); err != nil { 626 + return fmt.Errorf("failed to initialize OAuth: %w", err) 627 + } 628 + 629 + // Generate authorization URL 630 + state := "hold-registration" 631 + authURL, codeVerifier, err := oauthClient.AuthorizeURL(state) 632 + if err != nil { 633 + return fmt.Errorf("failed to generate auth URL: %w", err) 634 + } 635 + 636 + // Print the OAuth URL for user to visit 637 + log.Printf("\n" + strings.Repeat("=", 80)) 638 + log.Printf("OAUTH AUTHORIZATION REQUIRED") 639 + log.Printf(strings.Repeat("=", 80)) 640 + log.Printf("\nPlease visit this URL to authorize the hold service:\n") 641 + log.Printf(" %s\n", authURL) 642 + log.Printf("Waiting for authorization...") 643 + log.Printf(strings.Repeat("=", 80) + "\n") 644 + 645 + // Start temporary HTTP server for callback 646 + codeChan := make(chan string, 1) 647 + errChan := make(chan error, 1) 648 + 649 + mux := http.NewServeMux() 650 + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 651 + code := r.URL.Query().Get("code") 652 + receivedState := r.URL.Query().Get("state") 653 + 654 + if receivedState != state { 655 + errChan <- fmt.Errorf("invalid state parameter") 656 + http.Error(w, "Invalid state", http.StatusBadRequest) 657 + return 658 + } 659 + 660 + if code == "" { 661 + errChan <- fmt.Errorf("no authorization code received") 662 + http.Error(w, "No code", http.StatusBadRequest) 663 + return 664 + } 665 + 666 + w.Header().Set("Content-Type", "text/html") 667 + fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`) 668 + codeChan <- code 669 + }) 670 + 671 + server := &http.Server{ 672 + Addr: callbackAddr, 673 + Handler: mux, 674 + } 675 + 676 + go func() { 677 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 678 + errChan <- err 679 + } 680 + }() 681 + 682 + // Wait for callback or error 683 + var code string 684 + select { 685 + case code = <-codeChan: 686 + // Got the code, shutdown callback server 687 + server.Shutdown(context.Background()) 688 + case err := <-errChan: 689 + server.Shutdown(context.Background()) 690 + return err 691 + case <-time.After(5 * time.Minute): 692 + server.Shutdown(context.Background()) 693 + return fmt.Errorf("OAuth timeout - no response after 5 minutes") 694 + } 695 + 696 + log.Printf("Authorization received, exchanging code for token...") 697 + 698 + // Exchange code for token 699 + token, err := oauthClient.Exchange(ctx, code, codeVerifier) 700 + if err != nil { 701 + return fmt.Errorf("failed to exchange code: %w", err) 702 + } 703 + 704 + log.Printf("OAuth token obtained successfully") 705 + log.Printf("DID: %s", did) 706 + log.Printf("PDS: %s", pdsEndpoint) 707 + 708 + // Now register with the token 709 + return s.registerWithToken(publicURL, did, pdsEndpoint, token.AccessToken) 710 + } 711 + 712 + // registerWithToken registers the hold using an access token 713 + func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken string) error { 714 + // Derive hold name from URL (hostname) 715 + holdName, err := extractHostname(publicURL) 716 + if err != nil { 717 + return fmt.Errorf("failed to extract hostname from URL: %w", err) 718 + } 719 + 720 + log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did) 721 + 722 + ctx := context.Background() 723 + 724 + // Create ATProto client with owner's credentials 725 + client := atproto.NewClient(pdsEndpoint, did, accessToken) 726 + 727 + // Create HoldRecord 728 + holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public) 729 + 730 + // Use hostname as record key 731 + holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord) 732 + if err != nil { 733 + return fmt.Errorf("failed to create hold record: %w", err) 734 + } 735 + 736 + log.Printf("✓ Created hold record: %s", holdResult.URI) 737 + 738 + // Create HoldCrewRecord for the owner 739 + crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner") 740 + 741 + crewRKey := fmt.Sprintf("%s-%s", holdName, did) 742 + crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) 743 + if err != nil { 744 + return fmt.Errorf("failed to create crew record: %w", err) 745 + } 746 + 747 + log.Printf("✓ Created crew record: %s", crewResult.URI) 748 + 749 + log.Printf("\n" + strings.Repeat("=", 80)) 750 + log.Printf("REGISTRATION COMPLETE") 751 + log.Printf(strings.Repeat("=", 80)) 752 + log.Printf("Hold service is now registered and ready to use!") 753 + log.Printf(strings.Repeat("=", 80) + "\n") 754 + 755 + return nil 756 + } 757 + 758 + // extractHostname extracts the hostname from a URL to use as the hold name 759 + func extractHostname(urlStr string) (string, error) { 760 + u, err := url.Parse(urlStr) 761 + if err != nil { 762 + return "", err 763 + } 764 + // Remove port if present 765 + hostname := u.Hostname() 766 + if hostname == "" { 767 + return "", fmt.Errorf("no hostname in URL") 768 + } 769 + return hostname, nil 770 + }
+22
cmd/registry/main.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + 6 + "github.com/distribution/distribution/v3/registry" 7 + _ "github.com/distribution/distribution/v3/registry/auth/token" 8 + _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" 9 + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" 10 + _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" 11 + 12 + // Register our custom middleware 13 + _ "atcr.io/pkg/middleware" 14 + ) 15 + 16 + func main() { 17 + // Use distribution's built-in CLI 18 + // Our middleware will be automatically registered via init() 19 + if err := registry.RootCmd.Execute(); err != nil { 20 + os.Exit(1) 21 + } 22 + }
+217
cmd/registry/serve.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "os/signal" 9 + "syscall" 10 + "time" 11 + 12 + "github.com/distribution/distribution/v3/configuration" 13 + "github.com/distribution/distribution/v3/registry" 14 + "github.com/distribution/distribution/v3/registry/handlers" 15 + "github.com/spf13/cobra" 16 + 17 + "atcr.io/pkg/auth/exchange" 18 + "atcr.io/pkg/auth/token" 19 + ) 20 + 21 + var serveCmd = &cobra.Command{ 22 + Use: "serve <config>", 23 + Short: "Start the ATCR registry server", 24 + Long: "Start the ATCR registry server with authentication endpoints", 25 + Args: cobra.ExactArgs(1), 26 + RunE: serveRegistry, 27 + } 28 + 29 + func init() { 30 + // Replace the default serve command with our custom one 31 + for i, cmd := range registry.RootCmd.Commands() { 32 + if cmd.Name() == "serve" { 33 + registry.RootCmd.Commands()[i] = serveCmd 34 + break 35 + } 36 + } 37 + } 38 + 39 + func serveRegistry(cmd *cobra.Command, args []string) error { 40 + configPath := args[0] 41 + 42 + // Parse configuration 43 + fp, err := os.Open(configPath) 44 + if err != nil { 45 + return fmt.Errorf("failed to open config file: %w", err) 46 + } 47 + defer fp.Close() 48 + 49 + config, err := configuration.Parse(fp) 50 + if err != nil { 51 + return fmt.Errorf("failed to parse configuration: %w", err) 52 + } 53 + 54 + // Initialize auth keys if needed 55 + var issuer *token.Issuer 56 + if config.Auth["token"] != nil { 57 + if err := initializeAuthKeys(config); err != nil { 58 + return fmt.Errorf("failed to initialize auth keys: %w", err) 59 + } 60 + 61 + // Create token issuer for auth handlers 62 + issuer, err = createTokenIssuer(config) 63 + if err != nil { 64 + return fmt.Errorf("failed to create token issuer: %w", err) 65 + } 66 + } 67 + 68 + // Create registry app (returns http.Handler) 69 + ctx := context.Background() 70 + app := handlers.NewApp(ctx, config) 71 + 72 + // Create main HTTP mux 73 + mux := http.NewServeMux() 74 + 75 + // Mount registry at /v2/ 76 + mux.Handle("/v2/", app) 77 + 78 + // Mount auth endpoints if enabled 79 + if issuer != nil { 80 + // Extract default hold endpoint from middleware config 81 + defaultHoldEndpoint := extractDefaultHoldEndpoint(config) 82 + 83 + tokenHandler := token.NewHandler(issuer, defaultHoldEndpoint) 84 + tokenHandler.RegisterRoutes(mux) 85 + 86 + exchangeHandler := exchange.NewHandler(issuer, defaultHoldEndpoint) 87 + exchangeHandler.RegisterRoutes(mux) 88 + fmt.Println("Auth endpoints enabled at /auth/token and /auth/exchange") 89 + } 90 + 91 + // Create HTTP server 92 + server := &http.Server{ 93 + Addr: config.HTTP.Addr, 94 + Handler: mux, 95 + } 96 + 97 + // Handle graceful shutdown 98 + stop := make(chan os.Signal, 1) 99 + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) 100 + 101 + // Start server in goroutine 102 + errChan := make(chan error, 1) 103 + go func() { 104 + fmt.Printf("Starting registry server on %s\n", config.HTTP.Addr) 105 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 106 + errChan <- err 107 + } 108 + }() 109 + 110 + // Wait for shutdown signal or error 111 + select { 112 + case <-stop: 113 + fmt.Println("Shutting down registry server...") 114 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 115 + defer cancel() 116 + 117 + if err := server.Shutdown(shutdownCtx); err != nil { 118 + return fmt.Errorf("server shutdown error: %w", err) 119 + } 120 + case err := <-errChan: 121 + return fmt.Errorf("server error: %w", err) 122 + } 123 + 124 + return nil 125 + } 126 + 127 + // initializeAuthKeys creates the auth keys if they don't exist 128 + func initializeAuthKeys(config *configuration.Configuration) error { 129 + tokenParams, ok := config.Auth["token"] 130 + if !ok { 131 + return nil 132 + } 133 + 134 + privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 135 + issuerName := getStringParam(tokenParams, "issuer", "atcr.io") 136 + service := getStringParam(tokenParams, "service", "atcr.io") 137 + expirationSecs := getIntParam(tokenParams, "expiration", 300) 138 + 139 + // Create issuer (this will generate the key if it doesn't exist) 140 + _, err := token.NewIssuer( 141 + privateKeyPath, 142 + issuerName, 143 + service, 144 + time.Duration(expirationSecs)*time.Second, 145 + ) 146 + if err != nil { 147 + return fmt.Errorf("failed to initialize token issuer: %w", err) 148 + } 149 + 150 + fmt.Printf("Auth keys initialized at %s\n", privateKeyPath) 151 + return nil 152 + } 153 + 154 + // createTokenIssuer creates a token issuer for auth handlers 155 + func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, error) { 156 + tokenParams, ok := config.Auth["token"] 157 + if !ok { 158 + return nil, fmt.Errorf("token auth not configured") 159 + } 160 + 161 + privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 162 + issuerName := getStringParam(tokenParams, "issuer", "atcr.io") 163 + service := getStringParam(tokenParams, "service", "atcr.io") 164 + expirationSecs := getIntParam(tokenParams, "expiration", 300) 165 + 166 + return token.NewIssuer( 167 + privateKeyPath, 168 + issuerName, 169 + service, 170 + time.Duration(expirationSecs)*time.Second, 171 + ) 172 + } 173 + 174 + // Helper functions to extract values from config parameters 175 + func getStringParam(params configuration.Parameters, key, defaultValue string) string { 176 + if v, ok := params[key]; ok { 177 + if s, ok := v.(string); ok { 178 + return s 179 + } 180 + } 181 + return defaultValue 182 + } 183 + 184 + func getIntParam(params configuration.Parameters, key string, defaultValue int) int { 185 + if v, ok := params[key]; ok { 186 + if i, ok := v.(int); ok { 187 + return i 188 + } 189 + } 190 + return defaultValue 191 + } 192 + 193 + // extractDefaultHoldEndpoint extracts the default hold endpoint from middleware config 194 + func extractDefaultHoldEndpoint(config *configuration.Configuration) string { 195 + // Navigate through: middleware.registry[].options.default_storage_endpoint 196 + registryMiddleware, ok := config.Middleware["registry"] 197 + if !ok { 198 + return "" 199 + } 200 + 201 + // Find atproto-resolver middleware 202 + for _, mw := range registryMiddleware { 203 + // Check if this is the atproto-resolver 204 + if mw.Name != "atproto-resolver" { 205 + continue 206 + } 207 + 208 + // Extract options - options is configuration.Parameters which is map[string]interface{} 209 + if mw.Options != nil { 210 + if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok { 211 + return endpoint 212 + } 213 + } 214 + } 215 + 216 + return "" 217 + }
+56
config/config.yml
··· 1 + version: 0.1 2 + log: 3 + level: info 4 + formatter: text 5 + fields: 6 + service: atcr-registry 7 + 8 + # Storage is handled by external services: 9 + # - Manifests/Tags -> ATProto PDS (user's personal data server) 10 + # - Blobs/Layers -> Hold service (default or BYOS) 11 + # The AppView (registry) should be stateless with no local storage 12 + # 13 + # NOTE: The storage section below is required for distribution config validation 14 + # but is NOT actually used - all blob operations are routed through hold service 15 + storage: 16 + inmemory: {} 17 + 18 + http: 19 + addr: :5000 20 + headers: 21 + X-Content-Type-Options: [nosniff] 22 + debug: 23 + addr: :5001 24 + 25 + middleware: 26 + registry: 27 + # Name resolution middleware 28 + - name: atproto-resolver 29 + options: 30 + # Default hold service for blob storage 31 + # Users without their own hold will use this endpoint 32 + default_storage_endpoint: http://atcr-hold:8080 33 + 34 + # Authentication - all endpoints on port 5000 35 + auth: 36 + token: 37 + # Token service realm (where Docker gets tokens) 38 + realm: http://127.0.0.1:5000/auth/token 39 + service: atcr.io 40 + issuer: atcr.io 41 + 42 + # Certificate bundle for validating JWTs 43 + rootcertbundle: /var/lib/atcr/auth/private-key.crt 44 + 45 + # Private key for signing JWTs (used by custom auth handlers) 46 + privatekey: /var/lib/atcr/auth/private-key.pem 47 + 48 + # Token expiration in seconds (5 minutes) 49 + expiration: 300 50 + 51 + # Health check 52 + health: 53 + storagedriver: 54 + enabled: true 55 + interval: 10s 56 + threshold: 3
+16
config/hold.yml
··· 1 + # DEPRECATED: This config file is no longer used 2 + # 3 + # The hold service now loads all configuration from environment variables. 4 + # Please use .env.example as a reference and set environment variables instead. 5 + # 6 + # See .env.example for all available configuration options. 7 + # 8 + # Key changes: 9 + # - Storage config now uses standard env vars (AWS_ACCESS_KEY_ID, etc.) 10 + # - Authorization is now based on PDS records (hold.public, crew records) 11 + # - No more allow_all or allowed_dids config 12 + # 13 + # To run the hold service: 14 + # 1. Copy .env.example to .env 15 + # 2. Fill in your values 16 + # 3. Run: source .env && ./atcr-hold
credential-helper

This is a binary file and will not be displayed.

+34
docker-compose.yml
··· 1 + services: 2 + registry: 3 + build: 4 + context: . 5 + dockerfile: Dockerfile 6 + image: atcr-registry:latest 7 + container_name: atcr-registry 8 + ports: 9 + - "5000:5000" 10 + volumes: 11 + # Only auth keys (could be moved to secrets in production) 12 + - atcr-auth:/var/lib/atcr/auth 13 + restart: unless-stopped 14 + # The registry should be stateless - all storage is external: 15 + # - Manifests/Tags -> ATProto PDS 16 + # - Blobs/Layers -> Hold service 17 + # Future: Add read_only: true for production deployments 18 + 19 + hold: 20 + build: 21 + context: . 22 + dockerfile: Dockerfile.hold 23 + image: atcr-hold:latest 24 + container_name: atcr-hold 25 + ports: 26 + - "8080:8080" 27 + volumes: 28 + - atcr-hold:/var/lib/atcr/hold 29 + restart: unless-stopped 30 + 31 + volumes: 32 + atcr-blobs: 33 + atcr-hold: 34 + atcr-auth:
+464
docs/BYOS.md
··· 1 + # Bring Your Own Storage (BYOS) 2 + 3 + ## Overview 4 + 5 + ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. This allows users to: 6 + - Deploy their own storage service backed by S3/Storj/Minio/filesystem 7 + - Control who can use their storage (public or private) 8 + - Keep blob data in their own infrastructure while manifests remain in their ATProto PDS 9 + 10 + ## Architecture 11 + 12 + ``` 13 + ┌─────────────────────────────────────────────┐ 14 + │ ATCR AppView (API) │ 15 + │ - Manifests → ATProto PDS │ 16 + │ - Auth & token validation │ 17 + │ - Blob routing (issues redirects) │ 18 + │ - Profile management │ 19 + └─────────────────┬───────────────────────────┘ 20 + 21 + │ Hold discovery priority: 22 + │ 1. io.atcr.sailor.profile.defaultHold 23 + │ 2. io.atcr.hold records 24 + │ 3. AppView default_storage_endpoint 25 + 26 + ┌─────────────────────────────────────────────┐ 27 + │ User's PDS │ 28 + │ - io.atcr.sailor.profile (hold preference) │ 29 + │ - io.atcr.hold records (own holds) │ 30 + │ - io.atcr.manifest records (with holdEP) │ 31 + └─────────────────┬───────────────────────────┘ 32 + 33 + │ Redirects to hold 34 + 35 + ┌─────────────────────────────────────────────┐ 36 + │ Storage Service (Hold) │ 37 + │ - Blob storage (S3/Storj/Minio/filesystem) │ 38 + │ - Presigned URL generation │ 39 + │ - Authorization (DID-based) │ 40 + └─────────────────────────────────────────────┘ 41 + ``` 42 + 43 + ## ATProto Records 44 + 45 + ### io.atcr.sailor.profile 46 + 47 + **NEW:** User profile for hold selection preferences. Created automatically on first authentication. 48 + 49 + ```json 50 + { 51 + "$type": "io.atcr.sailor.profile", 52 + "defaultHold": "https://team-hold.example.com", 53 + "createdAt": "2025-10-02T12:00:00Z", 54 + "updatedAt": "2025-10-02T12:00:00Z" 55 + } 56 + ``` 57 + 58 + **Record key:** Always `"self"` (only one profile per user) 59 + 60 + **Behavior:** 61 + - Created automatically when user first authenticates (OAuth or Basic Auth) 62 + - If AppView has `default_storage_endpoint`, profile gets that as initial `defaultHold` 63 + - User can update to join shared holds or use their own hold 64 + - Set `defaultHold` to `null` to opt out of defaults (use own hold or AppView default) 65 + 66 + **This solves the multi-hold problem:** Users who are crew members of multiple holds can explicitly choose which one to use via their profile. 67 + 68 + ### io.atcr.hold 69 + 70 + Users create a hold record in their PDS to configure their own storage: 71 + 72 + ```json 73 + { 74 + "$type": "io.atcr.hold", 75 + "endpoint": "https://alice-storage.example.com", 76 + "owner": "did:plc:alice123", 77 + "public": false, 78 + "createdAt": "2025-10-01T12:00:00Z" 79 + } 80 + ``` 81 + 82 + ### io.atcr.hold.crew 83 + 84 + Hold owners can add crew members (for shared storage): 85 + 86 + ```json 87 + { 88 + "$type": "io.atcr.hold.crew", 89 + "hold": "at://did:plc:alice/io.atcr.hold/my-storage", 90 + "member": "did:plc:bob456", 91 + "role": "write", 92 + "addedAt": "2025-10-01T12:00:00Z" 93 + } 94 + ``` 95 + 96 + **Note:** Crew records are stored in the **hold owner's PDS**, not the crew member's PDS. This ensures the hold owner maintains full control over access. 97 + 98 + ## Storage Service 99 + 100 + ### Deployment 101 + 102 + The storage service is a lightweight HTTP server that: 103 + 1. Accepts presigned URL requests 104 + 2. Verifies DID authorization 105 + 3. Generates presigned URLs for S3/Storj/etc 106 + 4. Returns URLs to AppView for client redirect 107 + 108 + ### Configuration 109 + 110 + The hold service is configured entirely via environment variables. See `.env.example` for all options. 111 + 112 + **Required environment variables:** 113 + 114 + ```bash 115 + # Hold service public URL (REQUIRED) 116 + HOLD_PUBLIC_URL=https://storage.example.com 117 + 118 + # Storage driver type 119 + STORAGE_DRIVER=s3 120 + 121 + # For S3/Minio 122 + AWS_ACCESS_KEY_ID=your_access_key 123 + AWS_SECRET_ACCESS_KEY=your_secret_key 124 + AWS_REGION=us-east-1 125 + S3_BUCKET=my-blobs 126 + 127 + # For Storj (optional - custom S3 endpoint) 128 + # S3_ENDPOINT=https://gateway.storjshare.io 129 + 130 + # For filesystem storage 131 + # STORAGE_DRIVER=filesystem 132 + # STORAGE_ROOT_DIR=/var/lib/atcr-storage 133 + ``` 134 + 135 + **Authorization:** 136 + - Authorization is now based on PDS records, not local config 137 + - Public reads: controlled by `HOLD_PUBLIC` env var (stored in hold record) 138 + - Writes: controlled by `io.atcr.hold.crew` records in PDS 139 + 140 + ### Running 141 + 142 + ```bash 143 + # Build 144 + go build -o atcr-hold ./cmd/hold 145 + 146 + # Set environment variables (or use .env file) 147 + export HOLD_PUBLIC_URL=https://storage.example.com 148 + export STORAGE_DRIVER=s3 149 + export AWS_ACCESS_KEY_ID=... 150 + export AWS_SECRET_ACCESS_KEY=... 151 + export AWS_REGION=us-east-1 152 + export S3_BUCKET=my-blobs 153 + 154 + # Run 155 + ./atcr-hold 156 + ``` 157 + 158 + **Registration (required):** 159 + 160 + The hold service must be registered in a PDS to be discoverable by the AppView. 161 + 162 + **Standard registration workflow:** 163 + 164 + 1. Set `HOLD_CREW_OWNER` to your DID: 165 + ```bash 166 + export HOLD_CREW_OWNER=did:plc:your-did-here 167 + ``` 168 + 169 + 2. Start the hold service: 170 + ```bash 171 + ./atcr-hold 172 + ``` 173 + 174 + 3. **Check the logs** for the OAuth authorization URL: 175 + ``` 176 + ================================================================================ 177 + OAUTH AUTHORIZATION REQUIRED 178 + ================================================================================ 179 + 180 + Please visit this URL to authorize the hold service: 181 + 182 + https://bsky.app/authorize?client_id=... 183 + 184 + Waiting for authorization... 185 + ================================================================================ 186 + ``` 187 + 188 + 4. Visit the URL in your browser and authorize 189 + 190 + 5. The hold service will: 191 + - Exchange the authorization code for a token 192 + - Create `io.atcr.hold` record in your PDS 193 + - Create `io.atcr.hold.crew` record (making you the owner) 194 + - Save registration state 195 + 196 + 6. On subsequent runs, the service checks if already registered and skips OAuth 197 + 198 + **Alternative methods:** 199 + 200 + - **Manual API registration**: Call `POST /register` with your own OAuth token 201 + - **Completely manual**: Create PDS records yourself using any ATProto client 202 + 203 + ### Deploy to Fly.io 204 + 205 + ```bash 206 + # Create fly.toml 207 + cat > fly.toml <<EOF 208 + app = "my-atcr-hold" 209 + primary_region = "ord" 210 + 211 + [env] 212 + HOLD_PUBLIC_URL = "https://my-atcr-hold.fly.dev" 213 + HOLD_SERVER_ADDR = ":8080" 214 + STORAGE_DRIVER = "s3" 215 + AWS_REGION = "us-east-1" 216 + S3_BUCKET = "my-blobs" 217 + HOLD_PUBLIC = "false" 218 + 219 + [http_service] 220 + internal_port = 8080 221 + force_https = true 222 + auto_stop_machines = true 223 + auto_start_machines = true 224 + min_machines_running = 0 225 + 226 + [[vm]] 227 + cpu_kind = "shared" 228 + cpus = 1 229 + memory_mb = 256 230 + EOF 231 + 232 + # Deploy 233 + fly launch 234 + fly deploy 235 + 236 + # Set secrets 237 + fly secrets set AWS_ACCESS_KEY_ID=... 238 + fly secrets set AWS_SECRET_ACCESS_KEY=... 239 + fly secrets set HOLD_CREW_OWNER=did:plc:your-did-here 240 + 241 + # Check logs for OAuth URL on first run 242 + fly logs 243 + 244 + # Visit the OAuth URL shown in logs to authorize 245 + # The hold service will register itself in your PDS 246 + ``` 247 + 248 + ## Request Flow 249 + 250 + ### Push with BYOS 251 + 252 + 1. **Docker push** `atcr.io/alice/myapp:latest` 253 + 2. **AppView** resolves `alice` → `did:plc:alice123` 254 + 3. **AppView** discovers hold via priority logic: 255 + - Check alice's `io.atcr.sailor.profile` for `defaultHold` 256 + - If not set, check alice's `io.atcr.hold` records 257 + - Fall back to AppView's `default_storage_endpoint` 258 + 4. **Found:** `alice.profile.defaultHold = "https://team-hold.example.com"` 259 + 5. **AppView** → team-hold: POST `/put-presigned-url` 260 + ```json 261 + { 262 + "did": "did:plc:alice123", 263 + "digest": "sha256:abc123...", 264 + "size": 1048576 265 + } 266 + ``` 267 + 6. **Hold service**: 268 + - Verifies alice is authorized (checks crew records) 269 + - Generates S3 presigned upload URL (15min expiry) 270 + - Returns: `{"url": "https://s3.../blob?signature=..."}` 271 + 7. **AppView** → Docker: `307 Redirect` to presigned URL 272 + 8. **Docker** → S3: PUT blob directly (no proxy) 273 + 9. **Manifest** stored in alice's PDS with `holdEndpoint: "https://team-hold.example.com"` 274 + 275 + ### Pull with BYOS 276 + 277 + 1. **Docker pull** `atcr.io/alice/myapp:latest` 278 + 2. **AppView** fetches manifest from alice's PDS 279 + 3. **Manifest** contains `holdEndpoint: "https://team-hold.example.com"` 280 + 4. **AppView** caches: `(alice's DID, "myapp") → "https://team-hold.example.com"` (10min TTL) 281 + 5. **Docker** requests blobs: GET `/v2/alice/myapp/blobs/sha256:abc123` 282 + 6. **AppView** uses **cached hold from manifest** (not re-discovered) 283 + 7. **AppView** → team-hold: POST `/get-presigned-url` 284 + 8. **Hold service** returns presigned download URL 285 + 9. **AppView** → Docker: `307 Redirect` 286 + 10. **Docker** → S3: GET blob directly 287 + 288 + **Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from where they were originally pushed, even if alice later changes her profile's `defaultHold`. 289 + 290 + ## Default Registry 291 + 292 + The AppView can run its own storage service as the default: 293 + 294 + ### AppView config 295 + 296 + ```yaml 297 + middleware: 298 + - name: registry 299 + options: 300 + atproto-resolver: 301 + default_storage_endpoint: https://storage.atcr.io 302 + ``` 303 + 304 + ### Default hold service config 305 + 306 + ```bash 307 + # Accept any authenticated DID 308 + HOLD_PUBLIC=false # Requires authentication 309 + 310 + # Or allow public reads 311 + HOLD_PUBLIC=true # Public reads, auth required for writes 312 + ``` 313 + 314 + This provides free-tier shared storage for users who don't want to deploy their own. 315 + 316 + ## Storage Drivers Supported 317 + 318 + The storage service uses distribution's storage drivers: 319 + 320 + - **S3** - AWS S3, Minio, Storj (via S3 gateway) 321 + - **Filesystem** - Local disk (for testing) 322 + - **Azure** - Azure Blob Storage 323 + - **GCS** - Google Cloud Storage 324 + - **Swift** - OpenStack Swift 325 + - **OSS** - Alibaba Cloud OSS 326 + 327 + ## Quotas 328 + 329 + Quotas are NOT implemented in the storage service. Instead, use: 330 + 331 + - **S3**: Bucket policies, lifecycle rules 332 + - **Storj**: Project limits in Storj dashboard 333 + - **Minio**: Quota enforcement features 334 + - **Filesystem**: Disk quotas at OS level 335 + 336 + ## Security 337 + 338 + ### Authorization 339 + 340 + Authorization is now based on ATProto PDS records: 341 + 342 + - **Public reads**: Controlled by `hold.public` field in hold record (set via `HOLD_PUBLIC` env var) 343 + - **Writes**: Controlled by `io.atcr.hold.crew` records in PDS 344 + - **Owner**: User who created the hold record automatically gets crew owner role 345 + - **No local config**: Authorization state lives in PDS, not hold service config 346 + 347 + The hold service queries the PDS to check: 348 + 1. Hold record's `public` field for read authorization 349 + 2. Crew records for write authorization 350 + 351 + ### Presigned URLs 352 + 353 + - 15 minute expiry 354 + - Client uploads/downloads directly to storage 355 + - No data flows through AppView or hold service 356 + 357 + ### Private Holds 358 + 359 + Users can restrict access by: 360 + 1. Setting `HOLD_PUBLIC=false` (requires authentication for all operations) 361 + 2. Adding crew members via `io.atcr.hold.crew` records in PDS 362 + 363 + Only users with crew records can write to the hold. 364 + 365 + ## Example: Personal Storage 366 + 367 + Alice wants to use her own Storj account: 368 + 369 + 1. **Set environment variables**: 370 + ```bash 371 + export HOLD_PUBLIC_URL=https://alice-storage.fly.dev 372 + export HOLD_CREW_OWNER=did:plc:alice123 373 + export STORAGE_DRIVER=s3 374 + export AWS_ACCESS_KEY_ID=your_storj_access_key 375 + export AWS_SECRET_ACCESS_KEY=your_storj_secret_key 376 + export S3_ENDPOINT=https://gateway.storjshare.io 377 + export S3_BUCKET=alice-blobs 378 + ``` 379 + 380 + 2. **Deploy hold service** to Fly.io - auto-registration creates hold + crew record 381 + 382 + 3. **Push images** - AppView automatically routes to her storage 383 + 384 + ## Example: Team Hold 385 + 386 + A company wants shared storage for their team: 387 + 388 + 1. **Deploy hold service** with S3 credentials and auto-registration: 389 + ```bash 390 + export HOLD_PUBLIC_URL=https://company-hold.fly.dev 391 + export HOLD_CREW_OWNER=did:plc:admin 392 + export HOLD_PUBLIC=false 393 + export STORAGE_DRIVER=s3 394 + export AWS_ACCESS_KEY_ID=... 395 + export AWS_SECRET_ACCESS_KEY=... 396 + export S3_BUCKET=company-blobs 397 + ``` 398 + 399 + 2. **Hold service auto-registers** on first run, creating: 400 + - Hold record in admin's PDS 401 + - Crew record making admin the owner 402 + 403 + 3. **Admin adds crew members** via ATProto client or manually: 404 + ```bash 405 + # Using atproto client 406 + atproto put-record \ 407 + --collection io.atcr.hold.crew \ 408 + --rkey "company-did:plc:engineer1" \ 409 + --value '{ 410 + "$type": "io.atcr.hold.crew", 411 + "hold": "at://did:plc:admin/io.atcr.hold/company", 412 + "member": "did:plc:engineer1", 413 + "role": "write" 414 + }' 415 + ``` 416 + 417 + 4. **Team members set their profile** to use the shared hold: 418 + ```bash 419 + # Engineer updates their sailor profile 420 + atproto put-record \ 421 + --collection io.atcr.sailor.profile \ 422 + --rkey "self" \ 423 + --value '{ 424 + "$type": "io.atcr.sailor.profile", 425 + "defaultHold": "https://company-hold.fly.dev" 426 + }' 427 + ``` 428 + 429 + 5. **Hold service queries PDS** for crew records to authorize writes 430 + 6. **Engineers push/pull** using `atcr.io/engineer1/myapp` - blobs go to company hold 431 + 432 + ## Limitations 433 + 434 + 1. **No resume/partial uploads** - Storage service doesn't track upload state 435 + 2. **No advanced features** - Just basic put/get, no deduplication logic 436 + 3. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis) 437 + 4. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client) 438 + 439 + ## Future Improvements 440 + 441 + 1. **Automatic failover** - Multiple storage endpoints, fallback to default 442 + 2. **Storage analytics** - Track usage per DID 443 + 3. **Quota integration** - Optional quota tracking in storage service 444 + 4. **Direct presigned URL support** - S3 native presigned URLs (bypass proxy) 445 + 5. **Profile management UI** - Web interface for users to manage their sailor profile 446 + 6. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments 447 + 448 + ## Comparison to Default Storage 449 + 450 + | Feature | Default (Shared S3) | BYOS | 451 + |---------|---------------------|------| 452 + | Setup | None required | Deploy storage service | 453 + | Cost | Free (with quota) | User pays for S3/Storj | 454 + | Control | Limited | Full control | 455 + | Performance | Shared | Dedicated | 456 + | Quotas | Enforced by AppView | User managed | 457 + | Privacy | Blobs in shared bucket | Blobs in user's bucket | 458 + 459 + ## References 460 + 461 + - [ATProto Lexicon Spec](https://atproto.com/specs/lexicon) 462 + - [Distribution Storage Drivers](https://distribution.github.io/distribution/storage-drivers/) 463 + - [S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) 464 + - [Storj Documentation](https://docs.storj.io/)
+281
docs/OAUTH.md
··· 1 + # ATCR OAuth Implementation 2 + 3 + ## Overview 4 + 5 + ATCR now supports ATProto OAuth authentication via Docker credential helpers. This allows users to authenticate with their ATProto identity (Bluesky account) and use Docker push/pull commands seamlessly. 6 + 7 + ## Architecture 8 + 9 + ### Components 10 + 11 + 1. **OAuth Client** (`pkg/auth/oauth/`) 12 + - Full ATProto OAuth implementation with DPoP support 13 + - Uses `authelia.com/client/oauth2` for OAuth + PAR 14 + - Uses `github.com/AxisCommunications/go-dpop` for DPoP proof generation 15 + - Automatic authorization server discovery 16 + - PKCE support for security 17 + 18 + 2. **Credential Helper** (`cmd/credential-helper/`) 19 + - Standalone binary: `docker-credential-atcr` 20 + - Implements Docker credential helper protocol 21 + - Manages OAuth flow with browser 22 + - Stores tokens securely in `~/.atcr/oauth-token.json` 23 + 24 + 3. **Registry Integration** 25 + - `/auth/exchange` endpoint exchanges OAuth tokens for registry JWTs 26 + - Existing `/auth/token` endpoint for standard Docker auth 27 + 28 + ## Dependencies 29 + 30 + - `authelia.com/client/oauth2` - OAuth client with PAR support (2⭐, Authelia-backed) 31 + - `github.com/AxisCommunications/go-dpop` - DPoP implementation (10⭐, RFC 9449 compliant) 32 + - `github.com/golang-jwt/jwt/v5` - JWT library (transitive, 11k+⭐) 33 + 34 + ## Usage 35 + 36 + ### Setup 37 + 38 + 1. Build the credential helper: 39 + ```bash 40 + go build -o docker-credential-atcr ./cmd/credential-helper 41 + ``` 42 + 43 + 2. Install it in your PATH: 44 + ```bash 45 + sudo mv docker-credential-atcr /usr/local/bin/ 46 + ``` 47 + 48 + 3. Configure Docker to use it by editing `~/.docker/config.json`: 49 + ```json 50 + { 51 + "credsStore": "atcr" 52 + } 53 + ``` 54 + 55 + ### Configuration 56 + 57 + Run the OAuth flow: 58 + ```bash 59 + docker-credential-atcr configure 60 + ``` 61 + 62 + This will: 63 + 1. Prompt for your ATProto handle (e.g., `alice.bsky.social`) 64 + 2. Open your browser for OAuth authorization 65 + 3. Store the OAuth token and DPoP key in `~/.atcr/oauth-token.json` 66 + 67 + ### Using with Docker 68 + 69 + Once configured, use Docker normally: 70 + 71 + ```bash 72 + # Push an image 73 + docker push atcr.io/alice/myapp:latest 74 + 75 + # Pull an image 76 + docker pull atcr.io/alice/myapp:latest 77 + ``` 78 + 79 + The credential helper automatically: 80 + 1. Loads your stored OAuth token 81 + 2. Refreshes it if expired 82 + 3. Exchanges it for a registry JWT 83 + 4. Provides the JWT to Docker 84 + 85 + ## How It Works 86 + 87 + ### OAuth Flow 88 + 89 + 1. **User runs** `docker-credential-atcr configure` 90 + 2. **Resolve identity**: alice.bsky.social → DID → PDS endpoint 91 + 3. **Discover auth server**: GET `{pds}/.well-known/oauth-authorization-server` 92 + 4. **Generate DPoP key**: ECDSA P-256 key pair 93 + 5. **PAR request**: POST to PAR endpoint with DPoP header + PKCE challenge 94 + 6. **Open browser**: User authorizes on their PDS 95 + 7. **Receive code**: Callback to `localhost:8888/callback` 96 + 8. **Exchange code**: POST to token endpoint with DPoP header + PKCE verifier 97 + 9. **Save tokens**: Store OAuth token + DPoP key + DID/handle 98 + 99 + ### Docker Push/Pull Flow 100 + 101 + 1. **Docker needs credentials** for `atcr.io` 102 + 2. **Calls credential helper**: `docker-credential-atcr get` 103 + 3. **Helper loads token** from `~/.atcr/oauth-token.json` 104 + 4. **Refresh if needed**: Uses refresh token + DPoP if expired 105 + 5. **Exchange for registry JWT**: POST to `/auth/exchange` with OAuth token + handle 106 + 6. **Registry validates token**: Calls `getSession` on PDS to validate token 107 + 7. **Registry issues JWT**: Creates registry JWT with validated DID/handle 108 + 8. **Return to Docker**: `{"Username": "oauth2", "Secret": "<jwt>"}` 109 + 9. **Docker uses JWT**: For authentication to registry API 110 + 111 + ## Security 112 + 113 + ### DPoP (Demonstrating Proof-of-Possession) 114 + 115 + Every OAuth request includes a DPoP proof: 116 + - Unique JWT signed with ECDSA private key 117 + - Contains HTTP method, URL, timestamp, nonce 118 + - Public key (JWK) included in JWT header 119 + - Binds the token to the specific client 120 + 121 + ### PKCE (Proof Key for Code Exchange) 122 + 123 + - Code verifier generated locally 124 + - Code challenge sent in authorization request 125 + - Verifier sent in token exchange 126 + - Prevents authorization code interception 127 + 128 + ### Token Storage 129 + 130 + - Tokens stored in `~/.atcr/oauth-token.json` 131 + - File permissions: 0600 (owner read/write only) 132 + - DPoP key stored in PEM format 133 + - Refresh tokens for long-term access 134 + 135 + ## Implementation Details 136 + 137 + ### Code Structure 138 + 139 + ``` 140 + pkg/auth/oauth/ 141 + ├── client.go # OAuth client with DPoP 142 + ├── discovery.go # Authorization server discovery 143 + ├── metadata.go # Client metadata document 144 + ├── storage.go # Token persistence 145 + └── transport.go # DPoP HTTP transport 146 + 147 + pkg/auth/atproto/ 148 + ├── session.go # ATProto session validation (Basic auth) 149 + └── validator.go # OAuth token validation via getSession 150 + 151 + cmd/credential-helper/ 152 + ├── main.go # Docker credential helper protocol 153 + ├── oauth.go # OAuth flow orchestration 154 + └── token.go # Token management 155 + 156 + pkg/auth/exchange/ 157 + └── handler.go # OAuth → Registry JWT exchange 158 + ``` 159 + 160 + ### Key Classes 161 + 162 + **OAuth Client** (`pkg/auth/oauth/client.go`) 163 + - `NewClient()` - Create client with DPoP key 164 + - `InitializeForHandle()` - Discover auth server 165 + - `AuthorizeURL()` - Generate authorization URL with PAR + PKCE 166 + - `Exchange()` - Exchange code for token with DPoP 167 + - `RefreshToken()` - Refresh expired token with DPoP 168 + 169 + **DPoP Transport** (`pkg/auth/oauth/transport.go`) 170 + - Implements `http.RoundTripper` 171 + - Automatically adds DPoP header to all requests 172 + - Handles nonce management and retries 173 + - Used by OAuth client for all HTTP requests 174 + 175 + **Token Store** (`pkg/auth/oauth/storage.go`) 176 + - Persists OAuth tokens and DPoP key 177 + - PEM encoding for private key 178 + - Expiration checking 179 + - Secure file permissions 180 + 181 + **Token Validator** (`pkg/auth/atproto/validator.go`) 182 + - `ValidateToken()` - Validate token via PDS getSession 183 + - `ValidateTokenWithResolver()` - Auto-resolve PDS from handle 184 + - Returns validated DID and handle 185 + - Used by registry to verify OAuth tokens 186 + 187 + ## Testing 188 + 189 + ### Manual Testing 190 + 191 + 1. Configure the helper: 192 + ```bash 193 + ./docker-credential-atcr configure 194 + # Enter handle: alice.bsky.social 195 + # Browser opens for authorization 196 + # Token saved to ~/.atcr/oauth-token.json 197 + ``` 198 + 199 + 2. Test credential retrieval: 200 + ```bash 201 + echo '{"ServerURL": "atcr.io"}' | ./docker-credential-atcr get 202 + # Should return: {"Username":"oauth2","Secret":"<jwt>"} 203 + ``` 204 + 205 + 3. Test with Docker: 206 + ```bash 207 + docker push atcr.io/alice/test:latest 208 + ``` 209 + 210 + ### Integration Testing 211 + 212 + TODO: Add automated tests for: 213 + - OAuth flow with mock PDS 214 + - DPoP proof generation 215 + - Token exchange 216 + - Credential helper protocol 217 + 218 + ## Security Features 219 + 220 + ### OAuth Token Validation 221 + 222 + The registry validates ATProto OAuth tokens by calling `com.atproto.server.getSession` on the user's PDS. This ensures: 223 + - Token is valid and not expired 224 + - Token belongs to the claimed user 225 + - User's DID and handle are extracted from the PDS response 226 + - No trust in client-provided identity information 227 + 228 + **Flow:** 229 + 1. Client sends OAuth token + handle to `/auth/exchange` 230 + 2. Registry resolves handle → PDS endpoint 231 + 3. Registry calls `{pds}/xrpc/com.atproto.server.getSession` with token 232 + 4. PDS validates token and returns session info (DID, handle) 233 + 5. Registry uses validated DID/handle to issue registry JWT 234 + 235 + ## Future Improvements 236 + 237 + 1. **Token refresh in background** 238 + - Proactively refresh before expiry 239 + - Reduce latency on Docker commands 240 + 241 + 3. **Multiple account support** 242 + - Store tokens for multiple handles 243 + - Allow selecting which account to use 244 + 245 + 4. **Revocation support** 246 + - Implement token revocation 247 + - Clean up on logout 248 + 249 + 5. **Better error messages** 250 + - User-friendly OAuth error handling 251 + - Guide users through common issues 252 + 253 + ## Troubleshooting 254 + 255 + ### "Failed to resolve identity" 256 + - Check internet connection 257 + - Verify handle is correct (e.g., `alice.bsky.social`) 258 + - Ensure PDS is accessible 259 + 260 + ### "Authorization timed out" 261 + - Complete authorization within 5 minutes 262 + - Check if browser opened correctly 263 + - Try running `configure` again 264 + 265 + ### "Token expired" 266 + - Credential helper should auto-refresh 267 + - If persistent, run `configure` again 268 + - Check `~/.atcr/oauth-token.json` permissions 269 + 270 + ### "Failed to exchange token" 271 + - Ensure registry is running 272 + - Check `/auth/exchange` endpoint is accessible 273 + - Verify token hasn't been revoked 274 + 275 + ## References 276 + 277 + - [ATProto OAuth Specification](https://atproto.com/specs/oauth) 278 + - [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449) 279 + - [RFC 9126: PAR](https://datatracker.ietf.org/doc/html/rfc9126) 280 + - [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636) 281 + - [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
+82
go.mod
··· 1 + module atcr.io 2 + 3 + go 1.24.7 4 + 5 + require ( 6 + authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190 7 + github.com/AxisCommunications/go-dpop v1.1.2 8 + github.com/distribution/distribution/v3 v3.0.0 9 + github.com/distribution/reference v0.6.0 10 + github.com/golang-jwt/jwt/v5 v5.2.2 11 + github.com/google/uuid v1.6.0 12 + github.com/opencontainers/go-digest v1.0.0 13 + github.com/spf13/cobra v1.8.0 14 + ) 15 + 16 + require ( 17 + github.com/aws/aws-sdk-go v1.55.5 // indirect 18 + github.com/beorn7/perks v1.0.1 // indirect 19 + github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect 20 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 21 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 + github.com/coreos/go-systemd/v22 v22.5.0 // indirect 23 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 24 + github.com/docker/docker-credential-helpers v0.8.2 // indirect 25 + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect 26 + github.com/docker/go-metrics v0.0.1 // indirect 27 + github.com/felixge/httpsnoop v1.0.4 // indirect 28 + github.com/go-jose/go-jose/v4 v4.1.2 // indirect 29 + github.com/go-logr/logr v1.4.2 // indirect 30 + github.com/go-logr/stdr v1.2.2 // indirect 31 + github.com/gorilla/handlers v1.5.2 // indirect 32 + github.com/gorilla/mux v1.8.1 // indirect 33 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect 34 + github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect 35 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 36 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 37 + github.com/jmespath/go-jmespath v0.4.0 // indirect 38 + github.com/klauspost/compress v1.17.11 // indirect 39 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 40 + github.com/opencontainers/image-spec v1.1.0 // indirect 41 + github.com/prometheus/client_golang v1.20.5 // indirect 42 + github.com/prometheus/client_model v0.6.1 // indirect 43 + github.com/prometheus/common v0.60.1 // indirect 44 + github.com/prometheus/procfs v0.15.1 // indirect 45 + github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect 46 + github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect 47 + github.com/redis/go-redis/v9 v9.7.3 // indirect 48 + github.com/sirupsen/logrus v1.9.3 // indirect 49 + github.com/spf13/pflag v1.0.5 // indirect 50 + go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect 51 + go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect 52 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect 53 + go.opentelemetry.io/otel v1.32.0 // indirect 54 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect 55 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect 56 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect 57 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect 58 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect 59 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect 60 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect 61 + go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect 62 + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect 63 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect 64 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect 65 + go.opentelemetry.io/otel/log v0.8.0 // indirect 66 + go.opentelemetry.io/otel/metric v1.32.0 // indirect 67 + go.opentelemetry.io/otel/sdk v1.32.0 // indirect 68 + go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect 69 + go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect 70 + go.opentelemetry.io/otel/trace v1.32.0 // indirect 71 + go.opentelemetry.io/proto/otlp v1.3.1 // indirect 72 + golang.org/x/crypto v0.39.0 // indirect 73 + golang.org/x/net v0.37.0 // indirect 74 + golang.org/x/sync v0.15.0 // indirect 75 + golang.org/x/sys v0.33.0 // indirect 76 + golang.org/x/text v0.26.0 // indirect 77 + google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect 78 + google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect 79 + google.golang.org/grpc v1.68.0 // indirect 80 + google.golang.org/protobuf v1.35.1 // indirect 81 + gopkg.in/yaml.v2 v2.4.0 // indirect 82 + )
+248
go.sum
··· 1 + authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190 h1:5YfShMnyeIOFX5C1I7i6YrEpIfQCeeDBFTjau/iLfVU= 2 + authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190/go.mod h1:f0e/AQgp3qHJ2gSVnCheQZ4gTCm7BHasGpWrce36n9Q= 3 + github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak= 4 + github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc= 5 + github.com/AxisCommunications/go-dpop v1.1.2 h1:ICgk/8crE7pmWo5MML1kzyHF9wVJg6a78fW7rKxFavg= 6 + github.com/AxisCommunications/go-dpop v1.1.2/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 7 + github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 8 + github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 9 + github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= 10 + github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 11 + github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 12 + github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 13 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 14 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 15 + github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= 16 + github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= 17 + github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= 18 + github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 19 + github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 20 + github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 21 + github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 22 + github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 23 + github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 24 + github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 25 + github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 26 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 27 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 28 + github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 29 + github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 30 + github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 31 + github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 32 + github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 33 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 35 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 37 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 38 + github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= 39 + github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= 40 + github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 41 + github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 42 + github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= 43 + github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= 44 + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= 45 + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= 46 + github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= 47 + github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= 48 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 49 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 50 + github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= 51 + github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= 52 + github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 53 + github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 54 + github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 55 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 56 + github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 57 + github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 58 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 59 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 60 + github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 61 + github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 62 + github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 63 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 64 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 65 + github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 66 + github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 67 + github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 68 + github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 69 + github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 70 + github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 71 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 72 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 73 + github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 74 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 75 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 76 + github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= 77 + github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 78 + github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 79 + github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 80 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= 81 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= 82 + github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg= 83 + github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno= 84 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 85 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 86 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 87 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 88 + github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 89 + github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 90 + github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 91 + github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 92 + github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 93 + github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 94 + github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 95 + github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 96 + github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 97 + github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 98 + github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 99 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 100 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 101 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 102 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 103 + github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 104 + github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 105 + github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 106 + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 107 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 108 + github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 109 + github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 110 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 111 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 112 + github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 113 + github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 114 + github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 115 + github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 116 + github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 117 + github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 118 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 119 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 120 + github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 121 + github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 122 + github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= 123 + github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 124 + github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 125 + github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 126 + github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 127 + github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 128 + github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 129 + github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 130 + github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= 131 + github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= 132 + github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= 133 + github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 134 + github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 135 + github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= 136 + github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 137 + github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 138 + github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= 139 + github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= 140 + github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= 141 + github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= 142 + github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= 143 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 144 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 145 + github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 146 + github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 147 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 148 + github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 149 + github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 150 + github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 151 + github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 152 + github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 153 + github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 154 + github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 155 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 156 + github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 157 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 158 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 159 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 160 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 161 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 162 + go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= 163 + go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= 164 + go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= 165 + go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= 166 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= 167 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= 168 + go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= 169 + go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= 170 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= 171 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= 172 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= 173 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= 174 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= 175 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= 176 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= 177 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= 178 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= 179 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= 180 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= 181 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= 182 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= 183 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= 184 + go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= 185 + go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= 186 + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= 187 + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= 188 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= 189 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= 190 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= 191 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= 192 + go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= 193 + go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= 194 + go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= 195 + go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= 196 + go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= 197 + go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= 198 + go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= 199 + go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= 200 + go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= 201 + go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= 202 + go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= 203 + go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= 204 + go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= 205 + go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= 206 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 207 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 208 + golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 209 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 210 + golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 211 + golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 212 + golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 213 + golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 214 + golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 215 + golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 216 + golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 217 + golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 218 + golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 219 + golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 220 + golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 221 + golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 222 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 223 + golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 224 + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 226 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 227 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 228 + golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 229 + golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 230 + google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= 231 + google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= 232 + google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= 233 + google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= 234 + google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= 235 + google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= 236 + google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 237 + google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 238 + gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 239 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 240 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 241 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 242 + gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 243 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 244 + gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 245 + gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 246 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 247 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 248 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
hold

This is a binary file and will not be displayed.

+37
lexicons/io/atcr/hold.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Storage hold definition for Bring Your Own Storage (BYOS). Defines where blobs are stored.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["endpoint", "owner", "createdAt"], 12 + "properties": { 13 + "endpoint": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "URL of the hold service (e.g., 'https://hold1.example.com')" 17 + }, 18 + "owner": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the hold owner" 22 + }, 23 + "public": { 24 + "type": "boolean", 25 + "description": "Whether this hold allows public blob reads (pulls) without authentication. Writes always require crew membership.", 26 + "default": false 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime", 31 + "description": "Hold creation timestamp" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+42
lexicons/io/atcr/hold/crew.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.crew", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over access. Defines who can use a specific hold.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["hold", "member", "role", "createdAt"], 12 + "properties": { 13 + "hold": { 14 + "type": "string", 15 + "format": "at-uri", 16 + "description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')" 17 + }, 18 + "member": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the crew member who can use this hold" 22 + }, 23 + "role": { 24 + "type": "string", 25 + "description": "Member's role/permissions", 26 + "knownValues": ["owner", "write", "read"] 27 + }, 28 + "expiresAt": { 29 + "type": "string", 30 + "format": "datetime", 31 + "description": "Optional expiration for this membership" 32 + }, 33 + "createdAt": { 34 + "type": "string", 35 + "format": "datetime", 36 + "description": "Membership creation timestamp" 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+101
lexicons/io/atcr/manifest.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.manifest", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A container image manifest following OCI specification, stored in ATProto", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["repository", "digest", "mediaType", "schemaVersion", "config", "layers", "holdEndpoint", "createdAt"], 12 + "properties": { 13 + "repository": { 14 + "type": "string", 15 + "description": "Repository name (e.g., 'myapp'). Scoped to user's DID.", 16 + "maxLength": 255 17 + }, 18 + "digest": { 19 + "type": "string", 20 + "description": "Content digest (e.g., 'sha256:abc123...')" 21 + }, 22 + "holdEndpoint": { 23 + "type": "string", 24 + "format": "uri", 25 + "description": "Hold service endpoint where blobs are stored (e.g., 'https://hold1.bob.com'). Historical reference." 26 + }, 27 + "mediaType": { 28 + "type": "string", 29 + "description": "OCI media type", 30 + "knownValues": [ 31 + "application/vnd.oci.image.manifest.v1+json", 32 + "application/vnd.docker.distribution.manifest.v2+json" 33 + ] 34 + }, 35 + "schemaVersion": { 36 + "type": "integer", 37 + "description": "OCI schema version (typically 2)" 38 + }, 39 + "config": { 40 + "type": "ref", 41 + "ref": "#blobReference", 42 + "description": "Reference to image configuration blob" 43 + }, 44 + "layers": { 45 + "type": "array", 46 + "items": { 47 + "type": "ref", 48 + "ref": "#blobReference" 49 + }, 50 + "description": "Filesystem layers" 51 + }, 52 + "annotations": { 53 + "type": "object", 54 + "description": "Optional metadata annotations" 55 + }, 56 + "subject": { 57 + "type": "ref", 58 + "ref": "#blobReference", 59 + "description": "Optional reference to another manifest (for attestations, signatures)" 60 + }, 61 + "createdAt": { 62 + "type": "string", 63 + "format": "datetime", 64 + "description": "Record creation timestamp" 65 + } 66 + } 67 + } 68 + }, 69 + "blobReference": { 70 + "type": "object", 71 + "description": "Reference to a blob stored in S3 or external storage", 72 + "required": ["mediaType", "size", "digest"], 73 + "properties": { 74 + "mediaType": { 75 + "type": "string", 76 + "description": "MIME type of the blob" 77 + }, 78 + "size": { 79 + "type": "integer", 80 + "description": "Size in bytes" 81 + }, 82 + "digest": { 83 + "type": "string", 84 + "description": "Content digest (e.g., 'sha256:...')" 85 + }, 86 + "urls": { 87 + "type": "array", 88 + "items": { 89 + "type": "string", 90 + "format": "uri" 91 + }, 92 + "description": "Optional direct URLs to blob (for BYOS)" 93 + }, 94 + "annotations": { 95 + "type": "object", 96 + "description": "Optional metadata" 97 + } 98 + } 99 + } 100 + } 101 + }
+32
lexicons/io/atcr/sailor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.sailor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "User profile for ATCR registry. Stores preferences like default hold for blob storage.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "defaultHold": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "Default hold endpoint for blob storage. If null, user has opted out of defaults." 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "Profile creation timestamp" 22 + }, 23 + "updatedAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "Profile last updated timestamp" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+36
lexicons/io/atcr/tag.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.tag", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A named tag pointing to a specific manifest digest", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["repository", "tag", "manifestDigest", "createdAt"], 12 + "properties": { 13 + "repository": { 14 + "type": "string", 15 + "description": "Repository name (e.g., 'myapp'). Scoped to user's DID.", 16 + "maxLength": 255 17 + }, 18 + "tag": { 19 + "type": "string", 20 + "description": "Tag name (e.g., 'latest', 'v1.0.0', '12-slim')", 21 + "maxLength": 128 22 + }, 23 + "manifestDigest": { 24 + "type": "string", 25 + "description": "Digest of the manifest this tag points to (e.g., 'sha256:...')" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime", 30 + "description": "Tag creation timestamp" 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+184
pkg/atproto/client.go
··· 1 + package atproto 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + ) 11 + 12 + // Client wraps ATProto operations for the registry 13 + type Client struct { 14 + pdsEndpoint string 15 + did string 16 + accessToken string 17 + httpClient *http.Client 18 + } 19 + 20 + // NewClient creates a new ATProto client 21 + func NewClient(pdsEndpoint, did, accessToken string) *Client { 22 + return &Client{ 23 + pdsEndpoint: pdsEndpoint, 24 + did: did, 25 + accessToken: accessToken, 26 + httpClient: &http.Client{}, 27 + } 28 + } 29 + 30 + // Record represents a generic ATProto record 31 + type Record struct { 32 + URI string `json:"uri"` 33 + CID string `json:"cid"` 34 + Value json.RawMessage `json:"value"` 35 + } 36 + 37 + // PutRecord stores a record in the ATProto repository 38 + func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record interface{}) (*Record, error) { 39 + // Construct the record URI 40 + // Format: at://<did>/<collection>/<rkey> 41 + 42 + payload := map[string]interface{}{ 43 + "repo": c.did, 44 + "collection": collection, 45 + "rkey": rkey, 46 + "record": record, 47 + } 48 + 49 + body, err := json.Marshal(payload) 50 + if err != nil { 51 + return nil, fmt.Errorf("failed to marshal record: %w", err) 52 + } 53 + 54 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", c.pdsEndpoint) 55 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 56 + if err != nil { 57 + return nil, err 58 + } 59 + 60 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 61 + req.Header.Set("Content-Type", "application/json") 62 + 63 + resp, err := c.httpClient.Do(req) 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to put record: %w", err) 66 + } 67 + defer resp.Body.Close() 68 + 69 + if resp.StatusCode != http.StatusOK { 70 + bodyBytes, _ := io.ReadAll(resp.Body) 71 + return nil, fmt.Errorf("put record failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 72 + } 73 + 74 + var result Record 75 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 76 + return nil, fmt.Errorf("failed to decode response: %w", err) 77 + } 78 + 79 + return &result, nil 80 + } 81 + 82 + // GetRecord retrieves a record from the ATProto repository 83 + func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) { 84 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 85 + c.pdsEndpoint, c.did, collection, rkey) 86 + 87 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 88 + if err != nil { 89 + return nil, err 90 + } 91 + 92 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 93 + 94 + resp, err := c.httpClient.Do(req) 95 + if err != nil { 96 + return nil, fmt.Errorf("failed to get record: %w", err) 97 + } 98 + defer resp.Body.Close() 99 + 100 + if resp.StatusCode == http.StatusNotFound { 101 + return nil, fmt.Errorf("record not found") 102 + } 103 + 104 + if resp.StatusCode != http.StatusOK { 105 + bodyBytes, _ := io.ReadAll(resp.Body) 106 + return nil, fmt.Errorf("get record failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 107 + } 108 + 109 + var result Record 110 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 111 + return nil, fmt.Errorf("failed to decode response: %w", err) 112 + } 113 + 114 + return &result, nil 115 + } 116 + 117 + // DeleteRecord deletes a record from the ATProto repository 118 + func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error { 119 + payload := map[string]interface{}{ 120 + "repo": c.did, 121 + "collection": collection, 122 + "rkey": rkey, 123 + } 124 + 125 + body, err := json.Marshal(payload) 126 + if err != nil { 127 + return fmt.Errorf("failed to marshal delete request: %w", err) 128 + } 129 + 130 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", c.pdsEndpoint) 131 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 132 + if err != nil { 133 + return err 134 + } 135 + 136 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 137 + req.Header.Set("Content-Type", "application/json") 138 + 139 + resp, err := c.httpClient.Do(req) 140 + if err != nil { 141 + return fmt.Errorf("failed to delete record: %w", err) 142 + } 143 + defer resp.Body.Close() 144 + 145 + if resp.StatusCode != http.StatusOK { 146 + bodyBytes, _ := io.ReadAll(resp.Body) 147 + return fmt.Errorf("delete record failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 148 + } 149 + 150 + return nil 151 + } 152 + 153 + // ListRecords lists records in a collection 154 + func (c *Client) ListRecords(ctx context.Context, collection string, limit int) ([]Record, error) { 155 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d", 156 + c.pdsEndpoint, c.did, collection, limit) 157 + 158 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 159 + if err != nil { 160 + return nil, err 161 + } 162 + 163 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 164 + 165 + resp, err := c.httpClient.Do(req) 166 + if err != nil { 167 + return nil, fmt.Errorf("failed to list records: %w", err) 168 + } 169 + defer resp.Body.Close() 170 + 171 + if resp.StatusCode != http.StatusOK { 172 + bodyBytes, _ := io.ReadAll(resp.Body) 173 + return nil, fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 174 + } 175 + 176 + var result struct { 177 + Records []Record `json:"records"` 178 + } 179 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 180 + return nil, fmt.Errorf("failed to decode response: %w", err) 181 + } 182 + 183 + return result.Records, nil 184 + }
+273
pkg/atproto/lexicon.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + // Collection names for ATProto records 9 + const ( 10 + // ManifestCollection is the collection name for container manifests 11 + ManifestCollection = "io.atcr.manifest" 12 + 13 + // TagCollection is the collection name for image tags 14 + TagCollection = "io.atcr.tag" 15 + 16 + // HoldCollection is the collection name for storage holds (BYOS) 17 + HoldCollection = "io.atcr.hold" 18 + 19 + // HoldCrewCollection is the collection name for hold crew (membership) 20 + HoldCrewCollection = "io.atcr.hold.crew" 21 + 22 + // SailorProfileCollection is the collection name for user profiles 23 + SailorProfileCollection = "io.atcr.sailor.profile" 24 + ) 25 + 26 + // ManifestRecord represents a container image manifest stored in ATProto 27 + // This follows the OCI image manifest specification but stored as an ATProto record 28 + type ManifestRecord struct { 29 + // Type should be "io.atcr.manifest" 30 + Type string `json:"$type"` 31 + 32 + // Repository is the name of the repository (e.g., "myapp") 33 + Repository string `json:"repository"` 34 + 35 + // Digest is the content digest (e.g., "sha256:abc123...") 36 + Digest string `json:"digest"` 37 + 38 + // HoldEndpoint is the hold service endpoint where blobs are stored 39 + // This is a historical reference that doesn't change even if user's default hold changes 40 + HoldEndpoint string `json:"holdEndpoint"` 41 + 42 + // MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json") 43 + MediaType string `json:"mediaType"` 44 + 45 + // SchemaVersion is the OCI schema version (typically 2) 46 + SchemaVersion int `json:"schemaVersion"` 47 + 48 + // Config references the image configuration blob 49 + Config BlobReference `json:"config"` 50 + 51 + // Layers references the filesystem layers 52 + Layers []BlobReference `json:"layers"` 53 + 54 + // Annotations contains arbitrary metadata 55 + Annotations map[string]string `json:"annotations,omitempty"` 56 + 57 + // Subject references another manifest (for attestations, signatures, etc.) 58 + Subject *BlobReference `json:"subject,omitempty"` 59 + 60 + // CreatedAt timestamp 61 + CreatedAt time.Time `json:"createdAt"` 62 + } 63 + 64 + // BlobReference represents a reference to a blob (layer or config) 65 + // Blobs are stored in S3 and referenced by digest 66 + type BlobReference struct { 67 + // MediaType of the blob 68 + MediaType string `json:"mediaType"` 69 + 70 + // Digest is the content digest (e.g., "sha256:abc123...") 71 + Digest string `json:"digest"` 72 + 73 + // Size in bytes 74 + Size int64 `json:"size"` 75 + 76 + // URLs where the blob can be retrieved (S3 URLs) 77 + URLs []string `json:"urls,omitempty"` 78 + 79 + // Annotations for the blob 80 + Annotations map[string]string `json:"annotations,omitempty"` 81 + } 82 + 83 + // NewManifestRecord creates a new manifest record from OCI manifest JSON 84 + func NewManifestRecord(repository, digest string, ociManifest []byte) (*ManifestRecord, error) { 85 + // Parse the OCI manifest 86 + var ociData struct { 87 + SchemaVersion int `json:"schemaVersion"` 88 + MediaType string `json:"mediaType"` 89 + Config json.RawMessage `json:"config"` 90 + Layers []json.RawMessage `json:"layers"` 91 + Subject json.RawMessage `json:"subject,omitempty"` 92 + Annotations map[string]string `json:"annotations,omitempty"` 93 + } 94 + 95 + if err := json.Unmarshal(ociManifest, &ociData); err != nil { 96 + return nil, err 97 + } 98 + 99 + record := &ManifestRecord{ 100 + Type: ManifestCollection, 101 + Repository: repository, 102 + Digest: digest, 103 + MediaType: ociData.MediaType, 104 + SchemaVersion: ociData.SchemaVersion, 105 + Annotations: ociData.Annotations, 106 + CreatedAt: time.Now(), 107 + } 108 + 109 + // Parse config 110 + if err := json.Unmarshal(ociData.Config, &record.Config); err != nil { 111 + return nil, err 112 + } 113 + 114 + // Parse layers 115 + record.Layers = make([]BlobReference, len(ociData.Layers)) 116 + for i, layer := range ociData.Layers { 117 + if err := json.Unmarshal(layer, &record.Layers[i]); err != nil { 118 + return nil, err 119 + } 120 + } 121 + 122 + // Parse subject if present 123 + if len(ociData.Subject) > 0 { 124 + var subject BlobReference 125 + if err := json.Unmarshal(ociData.Subject, &subject); err != nil { 126 + return nil, err 127 + } 128 + record.Subject = &subject 129 + } 130 + 131 + return record, nil 132 + } 133 + 134 + // ToOCIManifest converts the manifest record back to OCI manifest JSON 135 + func (m *ManifestRecord) ToOCIManifest() ([]byte, error) { 136 + ociManifest := map[string]interface{}{ 137 + "schemaVersion": m.SchemaVersion, 138 + "mediaType": m.MediaType, 139 + "config": m.Config, 140 + "layers": m.Layers, 141 + } 142 + 143 + if m.Subject != nil { 144 + ociManifest["subject"] = m.Subject 145 + } 146 + 147 + if len(m.Annotations) > 0 { 148 + ociManifest["annotations"] = m.Annotations 149 + } 150 + 151 + return json.Marshal(ociManifest) 152 + } 153 + 154 + // TagRecord represents a tag pointing to a manifest 155 + type TagRecord struct { 156 + // Type should be "io.atcr.tag" 157 + Type string `json:"$type"` 158 + 159 + // Repository is the name of the repository 160 + Repository string `json:"repository"` 161 + 162 + // Tag is the tag name (e.g., "latest", "v1.0.0") 163 + Tag string `json:"tag"` 164 + 165 + // ManifestDigest is the digest of the manifest this tag points to 166 + ManifestDigest string `json:"manifestDigest"` 167 + 168 + // UpdatedAt timestamp 169 + UpdatedAt time.Time `json:"updatedAt"` 170 + } 171 + 172 + // NewTagRecord creates a new tag record 173 + func NewTagRecord(repository, tag, manifestDigest string) *TagRecord { 174 + return &TagRecord{ 175 + Type: TagCollection, 176 + Repository: repository, 177 + Tag: tag, 178 + ManifestDigest: manifestDigest, 179 + UpdatedAt: time.Now(), 180 + } 181 + } 182 + 183 + // HoldRecord represents a storage hold definition (BYOS) 184 + // Users create these records to define where their blobs should be stored 185 + type HoldRecord struct { 186 + // Type should be "io.atcr.hold" 187 + Type string `json:"$type"` 188 + 189 + // Endpoint is the URL of the hold service 190 + // e.g., "https://hold1.example.com" 191 + Endpoint string `json:"endpoint"` 192 + 193 + // Owner is the DID of the hold owner 194 + Owner string `json:"owner"` 195 + 196 + // Public controls whether this hold allows public blob reads (pulls) without auth 197 + // Writes always require crew membership 198 + Public bool `json:"public"` 199 + 200 + // CreatedAt timestamp 201 + CreatedAt time.Time `json:"createdAt"` 202 + } 203 + 204 + // NewHoldRecord creates a new hold record 205 + func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord { 206 + return &HoldRecord{ 207 + Type: HoldCollection, 208 + Endpoint: endpoint, 209 + Owner: owner, 210 + Public: public, 211 + CreatedAt: time.Now(), 212 + } 213 + } 214 + 215 + // HoldCrewRecord represents membership in a storage hold 216 + // Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control 217 + // Owner can add/remove crew members by creating/deleting these records in their own PDS 218 + type HoldCrewRecord struct { 219 + // Type should be "io.atcr.hold.crew" 220 + Type string `json:"$type"` 221 + 222 + // Hold is the AT URI of the hold record 223 + // e.g., "at://did:plc:owner/io.atcr.hold/hold1" 224 + Hold string `json:"hold"` 225 + 226 + // Member is the DID of the crew member 227 + Member string `json:"member"` 228 + 229 + // Role defines permissions: "owner", "write", "read" 230 + Role string `json:"role"` 231 + 232 + // AddedAt timestamp 233 + AddedAt time.Time `json:"createdAt"` 234 + } 235 + 236 + // NewHoldCrewRecord creates a new hold crew record 237 + func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord { 238 + return &HoldCrewRecord{ 239 + Type: HoldCrewCollection, 240 + Hold: hold, 241 + Member: member, 242 + Role: role, 243 + AddedAt: time.Now(), 244 + } 245 + } 246 + 247 + // SailorProfileRecord represents a user's profile with registry preferences 248 + // Stored in the user's PDS to configure default hold and other settings 249 + type SailorProfileRecord struct { 250 + // Type should be "io.atcr.sailor.profile" 251 + Type string `json:"$type"` 252 + 253 + // DefaultHold is the default hold endpoint for blob storage 254 + // If null/empty, user has opted out of defaults 255 + DefaultHold string `json:"defaultHold,omitempty"` 256 + 257 + // CreatedAt timestamp 258 + CreatedAt time.Time `json:"createdAt"` 259 + 260 + // UpdatedAt timestamp 261 + UpdatedAt time.Time `json:"updatedAt"` 262 + } 263 + 264 + // NewSailorProfileRecord creates a new sailor profile record 265 + func NewSailorProfileRecord(defaultHold string) *SailorProfileRecord { 266 + now := time.Now() 267 + return &SailorProfileRecord{ 268 + Type: SailorProfileCollection, 269 + DefaultHold: defaultHold, 270 + CreatedAt: now, 271 + UpdatedAt: now, 272 + } 273 + }
+170
pkg/atproto/manifest_store.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "strings" 8 + 9 + "github.com/distribution/distribution/v3" 10 + "github.com/opencontainers/go-digest" 11 + ) 12 + 13 + // ManifestStore implements distribution.ManifestService 14 + // It stores manifests in ATProto as records 15 + type ManifestStore struct { 16 + client *Client 17 + repository string 18 + holdEndpoint string // Hold service endpoint where blobs are stored (for push) 19 + did string // User's DID for cache key 20 + lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) 21 + } 22 + 23 + // NewManifestStore creates a new ATProto-backed manifest store 24 + func NewManifestStore(client *Client, repository string, holdEndpoint string, did string) *ManifestStore { 25 + return &ManifestStore{ 26 + client: client, 27 + repository: repository, 28 + holdEndpoint: holdEndpoint, 29 + did: did, 30 + } 31 + } 32 + 33 + // Exists checks if a manifest exists by digest 34 + func (s *ManifestStore) Exists(ctx context.Context, dgst digest.Digest) (bool, error) { 35 + rkey := digestToRKey(dgst) 36 + _, err := s.client.GetRecord(ctx, ManifestCollection, rkey) 37 + if err != nil { 38 + // If not found, return false without error 39 + if err.Error() == "record not found" { 40 + return false, nil 41 + } 42 + return false, err 43 + } 44 + return true, nil 45 + } 46 + 47 + // Get retrieves a manifest by digest 48 + func (s *ManifestStore) Get(ctx context.Context, dgst digest.Digest, options ...distribution.ManifestServiceOption) (distribution.Manifest, error) { 49 + rkey := digestToRKey(dgst) 50 + record, err := s.client.GetRecord(ctx, ManifestCollection, rkey) 51 + if err != nil { 52 + return nil, distribution.ErrManifestUnknownRevision{ 53 + Name: s.repository, 54 + Revision: dgst, 55 + } 56 + } 57 + 58 + var manifestRecord ManifestRecord 59 + if err := json.Unmarshal(record.Value, &manifestRecord); err != nil { 60 + return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err) 61 + } 62 + 63 + // Store the hold endpoint for subsequent blob requests during pull 64 + // The routing repository will cache this for concurrent blob fetches 65 + s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint 66 + 67 + // Convert back to OCI manifest 68 + ociManifest, err := manifestRecord.ToOCIManifest() 69 + if err != nil { 70 + return nil, fmt.Errorf("failed to convert to OCI manifest: %w", err) 71 + } 72 + 73 + // Parse the manifest based on media type 74 + // For now, we'll return the raw bytes wrapped in a manifest object 75 + // In a full implementation, you'd use distribution's manifest parsing 76 + return &rawManifest{ 77 + mediaType: manifestRecord.MediaType, 78 + payload: ociManifest, 79 + }, nil 80 + } 81 + 82 + // Put stores a manifest 83 + func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest, options ...distribution.ManifestServiceOption) (digest.Digest, error) { 84 + // Get the manifest payload 85 + _, payload, err := manifest.Payload() 86 + if err != nil { 87 + return "", err 88 + } 89 + 90 + // Calculate digest 91 + dgst := digest.FromBytes(payload) 92 + 93 + // Create manifest record 94 + manifestRecord, err := NewManifestRecord(s.repository, dgst.String(), payload) 95 + if err != nil { 96 + return "", fmt.Errorf("failed to create manifest record: %w", err) 97 + } 98 + 99 + // Set the hold endpoint where blobs are stored 100 + manifestRecord.HoldEndpoint = s.holdEndpoint 101 + 102 + // Store in ATProto 103 + rkey := digestToRKey(dgst) 104 + _, err = s.client.PutRecord(ctx, ManifestCollection, rkey, manifestRecord) 105 + if err != nil { 106 + return "", fmt.Errorf("failed to store manifest in ATProto: %w", err) 107 + } 108 + 109 + // Also handle tag if specified 110 + for _, option := range options { 111 + if tagOpt, ok := option.(distribution.WithTagOption); ok { 112 + tag := tagOpt.Tag 113 + tagRecord := NewTagRecord(s.repository, tag, dgst.String()) 114 + tagRKey := repositoryTagToRKey(s.repository, tag) 115 + _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord) 116 + if err != nil { 117 + return "", fmt.Errorf("failed to store tag in ATProto: %w", err) 118 + } 119 + } 120 + } 121 + 122 + return dgst, nil 123 + } 124 + 125 + // Delete removes a manifest 126 + func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error { 127 + rkey := digestToRKey(dgst) 128 + return s.client.DeleteRecord(ctx, ManifestCollection, rkey) 129 + } 130 + 131 + // digestToRKey converts a digest to an ATProto record key 132 + // ATProto rkeys must be valid strings, so we use the digest string without the algorithm prefix 133 + func digestToRKey(dgst digest.Digest) string { 134 + // Remove the algorithm prefix (e.g., "sha256:") 135 + return dgst.Encoded() 136 + } 137 + 138 + // repositoryTagToRKey converts a repository and tag to an ATProto record key 139 + // ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$ 140 + func repositoryTagToRKey(repository, tag string) string { 141 + // Combine repository and tag to create a unique key 142 + // Replace invalid characters: slashes become dashes 143 + key := fmt.Sprintf("%s_%s", repository, tag) 144 + 145 + // Replace / with - (slash not allowed in rkeys) 146 + key = strings.ReplaceAll(key, "/", "-") 147 + 148 + return key 149 + } 150 + 151 + // GetLastFetchedHoldEndpoint returns the hold endpoint from the most recently fetched manifest 152 + // This is used by the routing repository to cache the hold for blob requests 153 + func (s *ManifestStore) GetLastFetchedHoldEndpoint() string { 154 + return s.lastFetchedHoldEndpoint 155 + } 156 + 157 + // rawManifest is a simple implementation of distribution.Manifest 158 + type rawManifest struct { 159 + mediaType string 160 + payload []byte 161 + } 162 + 163 + func (m *rawManifest) References() []distribution.Descriptor { 164 + // TODO: Parse the manifest and return actual references 165 + return nil 166 + } 167 + 168 + func (m *rawManifest) Payload() (string, []byte, error) { 169 + return m.mediaType, m.payload, nil 170 + }
+95
pkg/atproto/profile.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + ) 8 + 9 + // Profile record key is always "self" per lexicon 10 + const ProfileRKey = "self" 11 + 12 + // EnsureProfile checks if a user's profile exists and creates it if needed 13 + // This should be called during authentication (OAuth exchange or token service) 14 + // If defaultHoldEndpoint is provided and profile doesn't exist, creates profile with that default 15 + func EnsureProfile(ctx context.Context, client *Client, defaultHoldEndpoint string) error { 16 + // Check if profile already exists 17 + profile, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey) 18 + if err == nil && profile != nil { 19 + // Profile exists, nothing to do 20 + return nil 21 + } 22 + 23 + // Profile doesn't exist 24 + // Only create if we have a default hold endpoint to set 25 + if defaultHoldEndpoint == "" { 26 + // No default configured, don't create empty profile 27 + return nil 28 + } 29 + 30 + // Create new profile with default hold 31 + newProfile := NewSailorProfileRecord(defaultHoldEndpoint) 32 + 33 + _, err = client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, newProfile) 34 + if err != nil { 35 + return fmt.Errorf("failed to create sailor profile: %w", err) 36 + } 37 + 38 + return nil 39 + } 40 + 41 + // GetProfile retrieves the user's profile from their PDS 42 + // Returns nil if profile doesn't exist 43 + func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, error) { 44 + record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey) 45 + if err != nil { 46 + // Check if it's a 404 (profile doesn't exist) 47 + if isNotFoundError(err) { 48 + return nil, nil 49 + } 50 + return nil, fmt.Errorf("failed to get profile: %w", err) 51 + } 52 + 53 + // Parse the profile record 54 + var profile SailorProfileRecord 55 + if err := json.Unmarshal(record.Value, &profile); err != nil { 56 + return nil, fmt.Errorf("failed to parse profile: %w", err) 57 + } 58 + 59 + return &profile, nil 60 + } 61 + 62 + // UpdateProfile updates the user's profile 63 + func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error { 64 + _, err := client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, profile) 65 + if err != nil { 66 + return fmt.Errorf("failed to update profile: %w", err) 67 + } 68 + return nil 69 + } 70 + 71 + // isNotFoundError checks if an error is a 404 not found error 72 + func isNotFoundError(err error) bool { 73 + // This is a simple check - in practice, you might need to parse the error more carefully 74 + if err == nil { 75 + return false 76 + } 77 + errStr := err.Error() 78 + return contains(errStr, "404") || contains(errStr, "not found") || contains(errStr, "RecordNotFound") 79 + } 80 + 81 + // contains checks if a string contains a substring (case-insensitive helper) 82 + func contains(s, substr string) bool { 83 + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && 84 + (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || 85 + findSubstring(s, substr))) 86 + } 87 + 88 + func findSubstring(s, substr string) bool { 89 + for i := 0; i <= len(s)-len(substr); i++ { 90 + if s[i:i+len(substr)] == substr { 91 + return true 92 + } 93 + } 94 + return false 95 + }
+243
pkg/atproto/resolver.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net" 9 + "net/http" 10 + "strings" 11 + ) 12 + 13 + // Resolver handles DID/handle resolution for ATProto 14 + type Resolver struct { 15 + httpClient *http.Client 16 + } 17 + 18 + // NewResolver creates a new DID/handle resolver 19 + func NewResolver() *Resolver { 20 + return &Resolver{ 21 + httpClient: &http.Client{}, 22 + } 23 + } 24 + 25 + // ResolveIdentity resolves a handle or DID to a DID and PDS endpoint 26 + // Input can be: 27 + // - Handle: "alice.bsky.social" or "alice" 28 + // - DID: "did:plc:xyz123abc" 29 + func (r *Resolver) ResolveIdentity(ctx context.Context, identity string) (did string, pdsEndpoint string, err error) { 30 + // Check if it's already a DID 31 + if strings.HasPrefix(identity, "did:") { 32 + did = identity 33 + pdsEndpoint, err = r.ResolvePDS(ctx, did) 34 + return did, pdsEndpoint, err 35 + } 36 + 37 + // Otherwise, resolve handle to DID 38 + did, err = r.ResolveHandle(ctx, identity) 39 + if err != nil { 40 + return "", "", fmt.Errorf("failed to resolve handle %s: %w", identity, err) 41 + } 42 + 43 + // Then resolve DID to PDS 44 + pdsEndpoint, err = r.ResolvePDS(ctx, did) 45 + if err != nil { 46 + return "", "", fmt.Errorf("failed to resolve PDS for DID %s: %w", did, err) 47 + } 48 + 49 + return did, pdsEndpoint, nil 50 + } 51 + 52 + // ResolveHandle resolves a handle to a DID using DNS TXT records or .well-known 53 + func (r *Resolver) ResolveHandle(ctx context.Context, handle string) (string, error) { 54 + // Normalize handle 55 + if !strings.Contains(handle, ".") { 56 + // Default to .bsky.social if no domain provided 57 + handle = handle + ".bsky.social" 58 + } 59 + 60 + // Try DNS TXT record first (faster) 61 + if did, err := r.resolveHandleViaDNS(handle); err == nil && did != "" { 62 + return did, nil 63 + } 64 + 65 + // Fall back to HTTPS .well-known method 66 + url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle) 67 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 68 + if err != nil { 69 + return "", err 70 + } 71 + 72 + resp, err := r.httpClient.Do(req) 73 + if err != nil { 74 + return "", fmt.Errorf("failed to fetch .well-known: %w", err) 75 + } 76 + defer resp.Body.Close() 77 + 78 + if resp.StatusCode == http.StatusOK { 79 + body, err := io.ReadAll(resp.Body) 80 + if err != nil { 81 + return "", err 82 + } 83 + did := strings.TrimSpace(string(body)) 84 + if strings.HasPrefix(did, "did:") { 85 + return did, nil 86 + } 87 + } 88 + 89 + return "", fmt.Errorf("could not resolve handle %s to DID", handle) 90 + } 91 + 92 + // resolveHandleViaDNS attempts to resolve handle via DNS TXT record at _atproto.<handle> 93 + func (r *Resolver) resolveHandleViaDNS(handle string) (string, error) { 94 + txtRecords, err := net.LookupTXT("_atproto." + handle) 95 + if err != nil { 96 + return "", err 97 + } 98 + 99 + // Look for a TXT record that starts with "did=" 100 + for _, record := range txtRecords { 101 + if strings.HasPrefix(record, "did=") { 102 + did := strings.TrimPrefix(record, "did=") 103 + if strings.HasPrefix(did, "did:") { 104 + return did, nil 105 + } 106 + } 107 + } 108 + 109 + return "", fmt.Errorf("no valid DID found in DNS TXT records") 110 + } 111 + 112 + // DIDDocument represents a simplified ATProto DID document 113 + type DIDDocument struct { 114 + ID string `json:"id"` 115 + AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` 116 + Service []struct { 117 + ID string `json:"id"` 118 + Type string `json:"type"` 119 + ServiceEndpoint string `json:"serviceEndpoint"` 120 + } `json:"service"` 121 + } 122 + 123 + // ResolvePDS resolves a DID to its PDS endpoint 124 + func (r *Resolver) ResolvePDS(ctx context.Context, did string) (string, error) { 125 + if !strings.HasPrefix(did, "did:") { 126 + return "", fmt.Errorf("invalid DID format: %s", did) 127 + } 128 + 129 + // Parse DID method 130 + parts := strings.Split(did, ":") 131 + if len(parts) < 3 { 132 + return "", fmt.Errorf("invalid DID format: %s", did) 133 + } 134 + 135 + method := parts[1] 136 + 137 + var resolverURL string 138 + switch method { 139 + case "plc": 140 + // Use PLC directory 141 + resolverURL = fmt.Sprintf("https://plc.directory/%s", did) 142 + case "web": 143 + // For did:web, convert to HTTPS URL 144 + domain := parts[2] 145 + resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) 146 + default: 147 + return "", fmt.Errorf("unsupported DID method: %s", method) 148 + } 149 + 150 + req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil) 151 + if err != nil { 152 + return "", err 153 + } 154 + 155 + resp, err := r.httpClient.Do(req) 156 + if err != nil { 157 + return "", fmt.Errorf("failed to fetch DID document: %w", err) 158 + } 159 + defer resp.Body.Close() 160 + 161 + if resp.StatusCode != http.StatusOK { 162 + return "", fmt.Errorf("DID resolution failed with status %d", resp.StatusCode) 163 + } 164 + 165 + var didDoc DIDDocument 166 + if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { 167 + return "", fmt.Errorf("failed to parse DID document: %w", err) 168 + } 169 + 170 + // Find PDS service endpoint 171 + for _, service := range didDoc.Service { 172 + if service.Type == "AtprotoPersonalDataServer" { 173 + return service.ServiceEndpoint, nil 174 + } 175 + } 176 + 177 + return "", fmt.Errorf("no PDS endpoint found in DID document") 178 + } 179 + 180 + // ResolveDIDDocument fetches the full DID document for a DID 181 + func (r *Resolver) ResolveDIDDocument(ctx context.Context, did string) (*DIDDocument, error) { 182 + if !strings.HasPrefix(did, "did:") { 183 + return nil, fmt.Errorf("invalid DID format: %s", did) 184 + } 185 + 186 + parts := strings.Split(did, ":") 187 + if len(parts) < 3 { 188 + return nil, fmt.Errorf("invalid DID format: %s", did) 189 + } 190 + 191 + method := parts[1] 192 + 193 + var resolverURL string 194 + switch method { 195 + case "plc": 196 + resolverURL = fmt.Sprintf("https://plc.directory/%s", did) 197 + case "web": 198 + domain := parts[2] 199 + resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) 200 + default: 201 + return nil, fmt.Errorf("unsupported DID method: %s", method) 202 + } 203 + 204 + req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil) 205 + if err != nil { 206 + return nil, err 207 + } 208 + 209 + resp, err := r.httpClient.Do(req) 210 + if err != nil { 211 + return nil, fmt.Errorf("failed to fetch DID document: %w", err) 212 + } 213 + defer resp.Body.Close() 214 + 215 + if resp.StatusCode != http.StatusOK { 216 + return nil, fmt.Errorf("DID resolution failed with status %d", resp.StatusCode) 217 + } 218 + 219 + var didDoc DIDDocument 220 + if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { 221 + return nil, fmt.Errorf("failed to parse DID document: %w", err) 222 + } 223 + 224 + return &didDoc, nil 225 + } 226 + 227 + // ResolveHandle extracts the handle from a DID's alsoKnownAs field 228 + func (r *Resolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) { 229 + didDoc, err := r.ResolveDIDDocument(ctx, did) 230 + if err != nil { 231 + return "", err 232 + } 233 + 234 + // Look for handle in alsoKnownAs (format: "at://handle.bsky.social") 235 + for _, aka := range didDoc.AlsoKnownAs { 236 + if strings.HasPrefix(aka, "at://") { 237 + handle := strings.TrimPrefix(aka, "at://") 238 + return handle, nil 239 + } 240 + } 241 + 242 + return "", fmt.Errorf("no handle found in DID document") 243 + }
+129
pkg/auth/atproto/session.go
··· 1 + package atproto 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + 11 + atprotoclient "atcr.io/pkg/atproto" 12 + ) 13 + 14 + // SessionValidator validates ATProto credentials 15 + type SessionValidator struct { 16 + resolver *atprotoclient.Resolver 17 + httpClient *http.Client 18 + } 19 + 20 + // NewSessionValidator creates a new ATProto session validator 21 + func NewSessionValidator() *SessionValidator { 22 + return &SessionValidator{ 23 + resolver: atprotoclient.NewResolver(), 24 + httpClient: &http.Client{}, 25 + } 26 + } 27 + 28 + // SessionResponse represents the response from createSession 29 + type SessionResponse struct { 30 + DID string `json:"did"` 31 + Handle string `json:"handle"` 32 + AccessJWT string `json:"accessJwt"` 33 + RefreshJWT string `json:"refreshJwt"` 34 + Email string `json:"email,omitempty"` 35 + AccessToken string `json:"access_token,omitempty"` // Alternative field name 36 + } 37 + 38 + // ValidateCredentials validates username and password against ATProto 39 + // Returns the user's DID and PDS endpoint if valid 40 + func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) { 41 + // Resolve identifier (handle or DID) to PDS endpoint 42 + resolvedDID, pds, err := v.resolver.ResolveIdentity(ctx, identifier) 43 + if err != nil { 44 + return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) 45 + } 46 + 47 + fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds) 48 + 49 + // Create session with the PDS 50 + fmt.Printf("DEBUG [atproto/session]: Creating session for %s at PDS %s\n", identifier, pds) 51 + sessionResp, err := v.createSession(ctx, pds, identifier, password) 52 + if err != nil { 53 + fmt.Printf("DEBUG [atproto/session]: Session creation failed: %v\n", err) 54 + return "", "", fmt.Errorf("authentication failed for %s at PDS %s: %w", identifier, pds, err) 55 + } 56 + 57 + fmt.Printf("DEBUG [atproto/session]: Session created successfully, DID=%s, Handle=%s, AccessJWT length=%d\n", 58 + sessionResp.DID, sessionResp.Handle, len(sessionResp.AccessJWT)) 59 + 60 + return sessionResp.DID, pds, nil 61 + } 62 + 63 + // CreateSessionAndGetToken creates a session and returns the DID, PDS endpoint, and access token 64 + func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, pdsEndpoint, accessToken string, err error) { 65 + // Resolve identifier to PDS endpoint 66 + did, pds, err := v.resolver.ResolveIdentity(ctx, identifier) 67 + if err != nil { 68 + return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) 69 + } 70 + 71 + // Create session 72 + sessionResp, err := v.createSession(ctx, pds, identifier, password) 73 + if err != nil { 74 + return "", "", "", fmt.Errorf("authentication failed: %w", err) 75 + } 76 + 77 + return sessionResp.DID, pds, sessionResp.AccessJWT, nil 78 + } 79 + 80 + // createSession calls com.atproto.server.createSession 81 + func (v *SessionValidator) createSession(ctx context.Context, pdsEndpoint, identifier, password string) (*SessionResponse, error) { 82 + payload := map[string]string{ 83 + "identifier": identifier, 84 + "password": password, 85 + } 86 + 87 + body, err := json.Marshal(payload) 88 + if err != nil { 89 + return nil, fmt.Errorf("failed to marshal request: %w", err) 90 + } 91 + 92 + url := fmt.Sprintf("%s/xrpc/com.atproto.server.createSession", pdsEndpoint) 93 + fmt.Printf("DEBUG [atproto/session]: POST %s\n", url) 94 + 95 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 96 + if err != nil { 97 + return nil, err 98 + } 99 + 100 + req.Header.Set("Content-Type", "application/json") 101 + 102 + resp, err := v.httpClient.Do(req) 103 + if err != nil { 104 + fmt.Printf("DEBUG [atproto/session]: HTTP request failed: %v\n", err) 105 + return nil, fmt.Errorf("failed to create session: %w", err) 106 + } 107 + defer resp.Body.Close() 108 + 109 + fmt.Printf("DEBUG [atproto/session]: Got HTTP status %d\n", resp.StatusCode) 110 + 111 + if resp.StatusCode == http.StatusUnauthorized { 112 + bodyBytes, _ := io.ReadAll(resp.Body) 113 + fmt.Printf("DEBUG [atproto/session]: Unauthorized response: %s\n", string(bodyBytes)) 114 + return nil, fmt.Errorf("invalid credentials") 115 + } 116 + 117 + if resp.StatusCode != http.StatusOK { 118 + bodyBytes, _ := io.ReadAll(resp.Body) 119 + fmt.Printf("DEBUG [atproto/session]: Error response: %s\n", string(bodyBytes)) 120 + return nil, fmt.Errorf("create session failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 121 + } 122 + 123 + var sessionResp SessionResponse 124 + if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { 125 + return nil, fmt.Errorf("failed to decode response: %w", err) 126 + } 127 + 128 + return &sessionResp, nil 129 + }
+90
pkg/auth/atproto/validator.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + 10 + mainAtproto "atcr.io/pkg/atproto" 11 + ) 12 + 13 + // TokenValidator validates ATProto OAuth access tokens 14 + type TokenValidator struct { 15 + httpClient *http.Client 16 + } 17 + 18 + // NewTokenValidator creates a new token validator 19 + func NewTokenValidator() *TokenValidator { 20 + return &TokenValidator{ 21 + httpClient: &http.Client{}, 22 + } 23 + } 24 + 25 + // SessionInfo represents the response from com.atproto.server.getSession 26 + type SessionInfo struct { 27 + DID string `json:"did"` 28 + Handle string `json:"handle"` 29 + Email string `json:"email,omitempty"` 30 + EmailConfirmed bool `json:"emailConfirmed,omitempty"` 31 + Active bool `json:"active,omitempty"` 32 + } 33 + 34 + // ValidateToken validates an ATProto OAuth access token by calling getSession 35 + // Returns the user's DID and handle if the token is valid 36 + func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken string) (*SessionInfo, error) { 37 + // Call com.atproto.server.getSession with the access token 38 + url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint) 39 + 40 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create request: %w", err) 43 + } 44 + 45 + // Add bearer token 46 + req.Header.Set("Authorization", "Bearer "+accessToken) 47 + 48 + resp, err := v.httpClient.Do(req) 49 + if err != nil { 50 + return nil, fmt.Errorf("failed to get session: %w", err) 51 + } 52 + defer resp.Body.Close() 53 + 54 + if resp.StatusCode == http.StatusUnauthorized { 55 + return nil, fmt.Errorf("invalid or expired token") 56 + } 57 + 58 + if resp.StatusCode != http.StatusOK { 59 + bodyBytes, _ := io.ReadAll(resp.Body) 60 + return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 61 + } 62 + 63 + var session SessionInfo 64 + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { 65 + return nil, fmt.Errorf("failed to decode session: %w", err) 66 + } 67 + 68 + // Validate required fields 69 + if session.DID == "" { 70 + return nil, fmt.Errorf("session response missing DID") 71 + } 72 + if session.Handle == "" { 73 + return nil, fmt.Errorf("session response missing handle") 74 + } 75 + 76 + return &session, nil 77 + } 78 + 79 + // ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint 80 + func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken string) (*SessionInfo, error) { 81 + // Resolve handle to PDS endpoint 82 + resolver := mainAtproto.NewResolver() 83 + _, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) 86 + } 87 + 88 + // Validate token against the PDS 89 + return v.ValidateToken(ctx, pdsEndpoint, accessToken) 90 + }
+134
pkg/auth/exchange/handler.go
··· 1 + package exchange 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + mainAtproto "atcr.io/pkg/atproto" 9 + "atcr.io/pkg/auth" 10 + "atcr.io/pkg/auth/atproto" 11 + "atcr.io/pkg/auth/token" 12 + ) 13 + 14 + // Handler handles /auth/exchange requests (OAuth token -> JWT token) 15 + type Handler struct { 16 + issuer *token.Issuer 17 + validator *atproto.TokenValidator 18 + defaultHoldEndpoint string 19 + } 20 + 21 + // NewHandler creates a new exchange handler 22 + func NewHandler(issuer *token.Issuer, defaultHoldEndpoint string) *Handler { 23 + return &Handler{ 24 + issuer: issuer, 25 + validator: atproto.NewTokenValidator(), 26 + defaultHoldEndpoint: defaultHoldEndpoint, 27 + } 28 + } 29 + 30 + // ExchangeRequest represents the request to exchange an OAuth token 31 + type ExchangeRequest struct { 32 + AccessToken string `json:"access_token"` // ATProto OAuth access token 33 + Handle string `json:"handle"` // User's handle (required for PDS resolution) 34 + Scope []string `json:"scope"` // Requested Docker scopes 35 + } 36 + 37 + // ExchangeResponse represents the response from /auth/exchange 38 + type ExchangeResponse struct { 39 + Token string `json:"token"` 40 + AccessToken string `json:"access_token"` 41 + ExpiresIn int `json:"expires_in"` 42 + } 43 + 44 + // ServeHTTP handles the exchange request 45 + func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 46 + if r.Method != http.MethodPost { 47 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 48 + return 49 + } 50 + 51 + var req ExchangeRequest 52 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 53 + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 54 + return 55 + } 56 + 57 + if req.AccessToken == "" { 58 + http.Error(w, "access_token is required", http.StatusBadRequest) 59 + return 60 + } 61 + 62 + // Validate the ATProto OAuth token via the PDS 63 + // We need the handle to resolve the PDS endpoint 64 + if req.Handle == "" { 65 + http.Error(w, "handle required to validate token", http.StatusBadRequest) 66 + return 67 + } 68 + 69 + session, err := h.validator.ValidateTokenWithResolver(r.Context(), req.Handle, req.AccessToken) 70 + if err != nil { 71 + http.Error(w, fmt.Sprintf("token validation failed: %v", err), http.StatusUnauthorized) 72 + return 73 + } 74 + 75 + // Use DID and handle from validated session 76 + did := session.DID 77 + handle := session.Handle 78 + 79 + // Parse and validate scopes 80 + access, err := auth.ParseScope(req.Scope) 81 + if err != nil { 82 + http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest) 83 + return 84 + } 85 + 86 + // Validate access permissions 87 + if err := auth.ValidateAccess(did, handle, access); err != nil { 88 + http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) 89 + return 90 + } 91 + 92 + // Ensure user profile exists (creates with default hold if needed) 93 + // Resolve PDS endpoint for profile management 94 + resolver := mainAtproto.NewResolver() 95 + _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), handle) 96 + if err != nil { 97 + // Log error but don't fail auth - profile management is not critical 98 + fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 99 + } else { 100 + // Create ATProto client with validated token 101 + atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, req.AccessToken) 102 + 103 + // Ensure profile exists (will create with default hold if not exists and default is configured) 104 + if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { 105 + // Log error but don't fail auth - profile management is not critical 106 + fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 107 + } 108 + } 109 + 110 + // Issue JWT token 111 + tokenString, err := h.issuer.Issue(did, access) 112 + if err != nil { 113 + http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) 114 + return 115 + } 116 + 117 + // Return response 118 + resp := ExchangeResponse{ 119 + Token: tokenString, 120 + AccessToken: tokenString, 121 + ExpiresIn: int(h.issuer.Expiration().Seconds()), 122 + } 123 + 124 + w.Header().Set("Content-Type", "application/json") 125 + if err := json.NewEncoder(w).Encode(resp); err != nil { 126 + http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) 127 + return 128 + } 129 + } 130 + 131 + // RegisterRoutes registers the exchange handler with the provided mux 132 + func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 133 + mux.Handle("/auth/exchange", h) 134 + }
+200
pkg/auth/oauth/client.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "crypto/ecdsa" 6 + "crypto/elliptic" 7 + "crypto/rand" 8 + "crypto/sha256" 9 + "encoding/base64" 10 + "fmt" 11 + "net/http" 12 + 13 + atprotoclient "atcr.io/pkg/atproto" 14 + "authelia.com/client/oauth2" 15 + ) 16 + 17 + // Client is an OAuth client for ATProto with DPoP support 18 + type Client struct { 19 + config *oauth2.Config 20 + dpopKey *ecdsa.PrivateKey 21 + dpopTransport *DPoPTransport 22 + resolver *atprotoclient.Resolver 23 + clientID string 24 + redirectURI string 25 + metadata *AuthServerMetadata 26 + } 27 + 28 + // NewClient creates a new OAuth client for ATProto 29 + func NewClient(clientID, redirectURI string) (*Client, error) { 30 + // Generate DPoP key 31 + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 32 + if err != nil { 33 + return nil, fmt.Errorf("failed to generate DPoP key: %w", err) 34 + } 35 + 36 + return &Client{ 37 + dpopKey: dpopKey, 38 + dpopTransport: NewDPoPTransport(http.DefaultTransport, dpopKey), 39 + resolver: atprotoclient.NewResolver(), 40 + clientID: clientID, 41 + redirectURI: redirectURI, 42 + }, nil 43 + } 44 + 45 + // InitializeForHandle discovers the authorization server for a given handle/DID 46 + func (c *Client) InitializeForHandle(ctx context.Context, handle string) error { 47 + // Resolve handle to DID and PDS 48 + _, pdsEndpoint, err := c.resolver.ResolveIdentity(ctx, handle) 49 + if err != nil { 50 + return fmt.Errorf("failed to resolve identity: %w", err) 51 + } 52 + 53 + // Discover authorization server metadata 54 + metadata, err := DiscoverAuthServer(ctx, pdsEndpoint) 55 + if err != nil { 56 + return fmt.Errorf("failed to discover authorization server: %w", err) 57 + } 58 + 59 + c.metadata = metadata 60 + 61 + // Configure OAuth2 client 62 + c.config = &oauth2.Config{ 63 + ClientID: c.clientID, 64 + Endpoint: oauth2.Endpoint{ 65 + AuthURL: metadata.AuthorizationEndpoint, 66 + TokenURL: metadata.TokenEndpoint, 67 + }, 68 + RedirectURL: c.redirectURI, 69 + Scopes: []string{"atproto"}, 70 + } 71 + 72 + return nil 73 + } 74 + 75 + // AuthorizeURL generates the authorization URL with PKCE 76 + func (c *Client) AuthorizeURL(state string) (authURL string, codeVerifier string, err error) { 77 + if c.config == nil { 78 + return "", "", fmt.Errorf("client not initialized - call InitializeForHandle first") 79 + } 80 + 81 + // Generate PKCE code verifier 82 + codeVerifier, err = generateCodeVerifier() 83 + if err != nil { 84 + return "", "", fmt.Errorf("failed to generate code verifier: %w", err) 85 + } 86 + 87 + // Generate code challenge 88 + codeChallenge := generateCodeChallenge(codeVerifier) 89 + 90 + // Use PAR (Pushed Authorization Request) if supported 91 + if c.metadata.PushedAuthorizationRequestEndpoint != "" { 92 + authURL, err = c.authorizeURLWithPAR(state, codeChallenge) 93 + if err != nil { 94 + return "", "", fmt.Errorf("PAR failed: %w", err) 95 + } 96 + } else { 97 + // Fallback to standard authorization 98 + authURL = c.config.AuthCodeURL(state, 99 + oauth2.SetAuthURLParam("code_challenge", codeChallenge), 100 + oauth2.SetAuthURLParam("code_challenge_method", "S256"), 101 + ) 102 + } 103 + 104 + return authURL, codeVerifier, nil 105 + } 106 + 107 + // authorizeURLWithPAR uses Pushed Authorization Request 108 + func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) { 109 + // Create HTTP client with DPoP transport 110 + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{ 111 + Transport: c.dpopTransport, 112 + }) 113 + 114 + // Use authelia's PushedAuth method 115 + authURL, _, err := c.config.PushedAuth(ctx, state, 116 + oauth2.SetAuthURLParam("code_challenge", codeChallenge), 117 + oauth2.SetAuthURLParam("code_challenge_method", "S256"), 118 + ) 119 + if err != nil { 120 + return "", err 121 + } 122 + 123 + return authURL.String(), nil 124 + } 125 + 126 + // Exchange exchanges an authorization code for an access token 127 + func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error) { 128 + if c.config == nil { 129 + return nil, fmt.Errorf("client not initialized") 130 + } 131 + 132 + // Create HTTP client with DPoP transport 133 + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ 134 + Transport: c.dpopTransport, 135 + }) 136 + 137 + // Exchange the code for a token 138 + token, err := c.config.Exchange(ctx, code, 139 + oauth2.SetAuthURLParam("code_verifier", codeVerifier), 140 + ) 141 + if err != nil { 142 + return nil, fmt.Errorf("failed to exchange code: %w", err) 143 + } 144 + 145 + return token, nil 146 + } 147 + 148 + // RefreshToken refreshes an access token using a refresh token 149 + func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error) { 150 + if c.config == nil { 151 + return nil, fmt.Errorf("client not initialized") 152 + } 153 + 154 + // Create HTTP client with DPoP transport 155 + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ 156 + Transport: c.dpopTransport, 157 + }) 158 + 159 + // Create a token source with the refresh token 160 + token := &oauth2.Token{ 161 + RefreshToken: refreshToken, 162 + } 163 + 164 + // Refresh the token 165 + newToken, err := c.config.TokenSource(ctx, token).Token() 166 + if err != nil { 167 + return nil, fmt.Errorf("failed to refresh token: %w", err) 168 + } 169 + 170 + return newToken, nil 171 + } 172 + 173 + // DPoPKey returns the DPoP private key 174 + func (c *Client) DPoPKey() *ecdsa.PrivateKey { 175 + return c.dpopKey 176 + } 177 + 178 + // SetDPoPKey sets the DPoP private key (useful when loading from storage) 179 + func (c *Client) SetDPoPKey(key *ecdsa.PrivateKey) { 180 + c.dpopKey = key 181 + c.dpopTransport = NewDPoPTransport(http.DefaultTransport, key) 182 + } 183 + 184 + // generateCodeVerifier generates a PKCE code verifier 185 + func generateCodeVerifier() (string, error) { 186 + // Generate 32 random bytes 187 + bytes := make([]byte, 32) 188 + if _, err := rand.Read(bytes); err != nil { 189 + return "", err 190 + } 191 + 192 + // Base64 URL encode 193 + return base64.RawURLEncoding.EncodeToString(bytes), nil 194 + } 195 + 196 + // generateCodeChallenge generates a PKCE code challenge from a verifier 197 + func generateCodeChallenge(verifier string) string { 198 + hash := sha256.Sum256([]byte(verifier)) 199 + return base64.RawURLEncoding.EncodeToString(hash[:]) 200 + }
+67
pkg/auth/oauth/discovery.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + ) 9 + 10 + // AuthServerMetadata represents the OAuth authorization server metadata 11 + // as defined in RFC 8414 12 + type AuthServerMetadata struct { 13 + Issuer string `json:"issuer"` 14 + AuthorizationEndpoint string `json:"authorization_endpoint"` 15 + TokenEndpoint string `json:"token_endpoint"` 16 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"` 17 + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` 18 + JWKsURI string `json:"jwks_uri,omitempty"` 19 + ScopesSupported []string `json:"scopes_supported,omitempty"` 20 + ResponseTypesSupported []string `json:"response_types_supported,omitempty"` 21 + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` 22 + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` 23 + DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` 24 + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` 25 + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` 26 + } 27 + 28 + // DiscoverAuthServer discovers the OAuth authorization server metadata 29 + // from the PDS endpoint using the well-known discovery endpoint 30 + func DiscoverAuthServer(ctx context.Context, pdsEndpoint string) (*AuthServerMetadata, error) { 31 + // Construct the well-known URL per RFC 8414 32 + discoveryURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", pdsEndpoint) 33 + 34 + req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil) 35 + if err != nil { 36 + return nil, fmt.Errorf("failed to create discovery request: %w", err) 37 + } 38 + 39 + client := &http.Client{} 40 + resp, err := client.Do(req) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to fetch authorization server metadata: %w", err) 43 + } 44 + defer resp.Body.Close() 45 + 46 + if resp.StatusCode != http.StatusOK { 47 + return nil, fmt.Errorf("authorization server discovery failed with status %d", resp.StatusCode) 48 + } 49 + 50 + var metadata AuthServerMetadata 51 + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { 52 + return nil, fmt.Errorf("failed to decode authorization server metadata: %w", err) 53 + } 54 + 55 + // Validate required fields 56 + if metadata.Issuer == "" { 57 + return nil, fmt.Errorf("authorization server metadata missing issuer") 58 + } 59 + if metadata.AuthorizationEndpoint == "" { 60 + return nil, fmt.Errorf("authorization server metadata missing authorization_endpoint") 61 + } 62 + if metadata.TokenEndpoint == "" { 63 + return nil, fmt.Errorf("authorization server metadata missing token_endpoint") 64 + } 65 + 66 + return &metadata, nil 67 + }
+50
pkg/auth/oauth/metadata.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + // ClientMetadata represents the OAuth client metadata document 9 + // This follows the ATProto OAuth client metadata specification 10 + type ClientMetadata struct { 11 + ClientID string `json:"client_id"` 12 + ClientName string `json:"client_name,omitempty"` 13 + ClientURI string `json:"client_uri,omitempty"` 14 + RedirectURIs []string `json:"redirect_uris"` 15 + GrantTypes []string `json:"grant_types"` 16 + ResponseTypes []string `json:"response_types"` 17 + Scope string `json:"scope"` 18 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 19 + ApplicationType string `json:"application_type"` 20 + DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 21 + } 22 + 23 + // NewClientMetadata creates a client metadata document for ATProto OAuth 24 + func NewClientMetadata(clientID string, redirectURIs []string) *ClientMetadata { 25 + return &ClientMetadata{ 26 + ClientID: clientID, 27 + ClientName: "ATCR Registry", 28 + ClientURI: "https://github.com/yourusername/atcr.io", 29 + RedirectURIs: redirectURIs, 30 + GrantTypes: []string{"authorization_code", "refresh_token"}, 31 + ResponseTypes: []string{"code"}, 32 + Scope: "atproto", 33 + TokenEndpointAuthMethod: "none", // Public client 34 + ApplicationType: "native", 35 + DPoPBoundAccessTokens: true, 36 + } 37 + } 38 + 39 + // ServeMetadata returns an HTTP handler that serves the client metadata JSON 40 + func ServeMetadata(metadata *ClientMetadata) http.HandlerFunc { 41 + return func(w http.ResponseWriter, r *http.Request) { 42 + w.Header().Set("Content-Type", "application/json") 43 + w.Header().Set("Access-Control-Allow-Origin", "*") 44 + 45 + if err := json.NewEncoder(w).Encode(metadata); err != nil { 46 + http.Error(w, "failed to encode metadata", http.StatusInternalServerError) 47 + return 48 + } 49 + } 50 + }
+97
pkg/auth/oauth/storage.go
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/x509" 6 + "encoding/json" 7 + "encoding/pem" 8 + "fmt" 9 + "os" 10 + "path/filepath" 11 + "time" 12 + ) 13 + 14 + // TokenStore represents persisted OAuth tokens and DPoP key 15 + type TokenStore struct { 16 + AccessToken string `json:"access_token"` 17 + RefreshToken string `json:"refresh_token,omitempty"` 18 + TokenType string `json:"token_type"` 19 + ExpiresAt time.Time `json:"expires_at"` 20 + DPoPKeyPEM string `json:"dpop_key_pem"` // ECDSA private key in PEM format 21 + DID string `json:"did,omitempty"` 22 + Handle string `json:"handle,omitempty"` 23 + } 24 + 25 + // Save persists the token store to a file 26 + func (s *TokenStore) Save(path string) error { 27 + // Ensure directory exists 28 + dir := filepath.Dir(path) 29 + if err := os.MkdirAll(dir, 0700); err != nil { 30 + return fmt.Errorf("failed to create token directory: %w", err) 31 + } 32 + 33 + // Marshal to JSON 34 + data, err := json.MarshalIndent(s, "", " ") 35 + if err != nil { 36 + return fmt.Errorf("failed to marshal token store: %w", err) 37 + } 38 + 39 + // Write to file with secure permissions 40 + if err := os.WriteFile(path, data, 0600); err != nil { 41 + return fmt.Errorf("failed to write token store: %w", err) 42 + } 43 + 44 + return nil 45 + } 46 + 47 + // LoadTokenStore loads a token store from a file 48 + func LoadTokenStore(path string) (*TokenStore, error) { 49 + data, err := os.ReadFile(path) 50 + if err != nil { 51 + return nil, fmt.Errorf("failed to read token store: %w", err) 52 + } 53 + 54 + var store TokenStore 55 + if err := json.Unmarshal(data, &store); err != nil { 56 + return nil, fmt.Errorf("failed to unmarshal token store: %w", err) 57 + } 58 + 59 + return &store, nil 60 + } 61 + 62 + // GetDPoPKey decodes the PEM-encoded DPoP private key 63 + func (s *TokenStore) GetDPoPKey() (*ecdsa.PrivateKey, error) { 64 + block, _ := pem.Decode([]byte(s.DPoPKeyPEM)) 65 + if block == nil { 66 + return nil, fmt.Errorf("failed to decode PEM block") 67 + } 68 + 69 + key, err := x509.ParseECPrivateKey(block.Bytes) 70 + if err != nil { 71 + return nil, fmt.Errorf("failed to parse EC private key: %w", err) 72 + } 73 + 74 + return key, nil 75 + } 76 + 77 + // SetDPoPKey encodes the DPoP private key as PEM 78 + func (s *TokenStore) SetDPoPKey(key *ecdsa.PrivateKey) error { 79 + keyBytes, err := x509.MarshalECPrivateKey(key) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal EC private key: %w", err) 82 + } 83 + 84 + pemBlock := &pem.Block{ 85 + Type: "EC PRIVATE KEY", 86 + Bytes: keyBytes, 87 + } 88 + 89 + s.DPoPKeyPEM = string(pem.EncodeToMemory(pemBlock)) 90 + return nil 91 + } 92 + 93 + // IsExpired checks if the access token is expired 94 + func (s *TokenStore) IsExpired() bool { 95 + // Add a 60 second buffer to refresh before actual expiry 96 + return time.Now().After(s.ExpiresAt.Add(-60 * time.Second)) 97 + }
+128
pkg/auth/oauth/transport.go
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "fmt" 6 + "net/http" 7 + "sync" 8 + "time" 9 + 10 + "github.com/AxisCommunications/go-dpop" 11 + "github.com/golang-jwt/jwt/v5" 12 + "github.com/google/uuid" 13 + ) 14 + 15 + // DPoPTransport is an HTTP RoundTripper that adds DPoP headers to requests 16 + type DPoPTransport struct { 17 + base http.RoundTripper 18 + dpopKey *ecdsa.PrivateKey 19 + nonce string 20 + mu sync.RWMutex // Protects nonce 21 + } 22 + 23 + // NewDPoPTransport creates a new DPoP transport with the given private key 24 + func NewDPoPTransport(base http.RoundTripper, dpopKey *ecdsa.PrivateKey) *DPoPTransport { 25 + if base == nil { 26 + base = http.DefaultTransport 27 + } 28 + return &DPoPTransport{ 29 + base: base, 30 + dpopKey: dpopKey, 31 + } 32 + } 33 + 34 + // RoundTrip implements http.RoundTripper 35 + func (t *DPoPTransport) RoundTrip(req *http.Request) (*http.Response, error) { 36 + // Clone the request to avoid modifying the original 37 + reqCopy := req.Clone(req.Context()) 38 + 39 + // Generate and add DPoP proof 40 + if err := t.addDPoPHeader(reqCopy); err != nil { 41 + return nil, fmt.Errorf("failed to add DPoP header: %w", err) 42 + } 43 + 44 + // Execute the request 45 + resp, err := t.base.RoundTrip(reqCopy) 46 + if err != nil { 47 + return nil, err 48 + } 49 + 50 + // Check for DPoP nonce in response 51 + if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" { 52 + t.mu.Lock() 53 + t.nonce = nonce 54 + t.mu.Unlock() 55 + } 56 + 57 + // If we get 401 with use_dpop_nonce error, retry with nonce 58 + if resp.StatusCode == http.StatusUnauthorized { 59 + wwwAuth := resp.Header.Get("WWW-Authenticate") 60 + if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" && wwwAuth != "" { 61 + // Update nonce and retry 62 + t.mu.Lock() 63 + t.nonce = nonce 64 + t.mu.Unlock() 65 + 66 + // Close the first response 67 + resp.Body.Close() 68 + 69 + // Retry with new nonce 70 + reqRetry := req.Clone(req.Context()) 71 + if err := t.addDPoPHeader(reqRetry); err != nil { 72 + return nil, fmt.Errorf("failed to add DPoP header on retry: %w", err) 73 + } 74 + return t.base.RoundTrip(reqRetry) 75 + } 76 + } 77 + 78 + return resp, nil 79 + } 80 + 81 + // addDPoPHeader generates and adds a DPoP proof header to the request 82 + func (t *DPoPTransport) addDPoPHeader(req *http.Request) error { 83 + // Read current nonce 84 + t.mu.RLock() 85 + nonce := t.nonce 86 + t.mu.RUnlock() 87 + 88 + // Create DPoP proof claims 89 + claims := &dpop.ProofTokenClaims{ 90 + RegisteredClaims: &jwt.RegisteredClaims{ 91 + ID: uuid.New().String(), 92 + IssuedAt: jwt.NewNumericDate(time.Now()), 93 + }, 94 + Method: dpop.HTTPVerb(req.Method), 95 + URL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path, 96 + } 97 + 98 + // Add nonce if we have one 99 + if nonce != "" { 100 + claims.Nonce = nonce 101 + } 102 + 103 + // Generate DPoP proof 104 + // go-dpop automatically adds the JWK to the header 105 + proofString, err := dpop.Create(jwt.SigningMethodES256, claims, t.dpopKey) 106 + if err != nil { 107 + return fmt.Errorf("failed to create DPoP proof: %w", err) 108 + } 109 + 110 + // Add DPoP header 111 + req.Header.Set("DPoP", proofString) 112 + 113 + return nil 114 + } 115 + 116 + // SetNonce manually sets the DPoP nonce (useful for initial requests) 117 + func (t *DPoPTransport) SetNonce(nonce string) { 118 + t.mu.Lock() 119 + defer t.mu.Unlock() 120 + t.nonce = nonce 121 + } 122 + 123 + // GetNonce returns the current DPoP nonce 124 + func (t *DPoPTransport) GetNonce() string { 125 + t.mu.RLock() 126 + defer t.mu.RUnlock() 127 + return t.nonce 128 + }
+81
pkg/auth/scope.go
··· 1 + package auth 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + ) 7 + 8 + // ParseScope parses Docker registry scope strings into AccessEntry structures 9 + // Scope format: "repository:alice/myapp:pull,push" 10 + // Multiple scopes can be provided 11 + func ParseScope(scopes []string) ([]AccessEntry, error) { 12 + var access []AccessEntry 13 + 14 + for _, scope := range scopes { 15 + if scope == "" { 16 + continue 17 + } 18 + 19 + parts := strings.Split(scope, ":") 20 + if len(parts) < 2 { 21 + return nil, fmt.Errorf("invalid scope format: %s", scope) 22 + } 23 + 24 + resourceType := parts[0] 25 + 26 + var name string 27 + var actions []string 28 + 29 + if len(parts) == 2 { 30 + // Format: "repository:alice/myapp" (no actions specified) 31 + name = parts[1] 32 + } else if len(parts) == 3 { 33 + // Format: "repository:alice/myapp:pull,push" 34 + name = parts[1] 35 + if parts[2] != "" { 36 + actions = strings.Split(parts[2], ",") 37 + } 38 + } else { 39 + return nil, fmt.Errorf("invalid scope format: %s", scope) 40 + } 41 + 42 + access = append(access, AccessEntry{ 43 + Type: resourceType, 44 + Name: name, 45 + Actions: actions, 46 + }) 47 + } 48 + 49 + return access, nil 50 + } 51 + 52 + // ValidateAccess checks if the requested access is allowed for the user 53 + // For ATCR, users can only push to repositories under their own handle/DID 54 + func ValidateAccess(userDID, userHandle string, access []AccessEntry) error { 55 + for _, entry := range access { 56 + if entry.Type != "repository" { 57 + continue 58 + } 59 + 60 + // Extract the owner from repository name (e.g., "alice/myapp" -> "alice") 61 + parts := strings.SplitN(entry.Name, "/", 2) 62 + if len(parts) < 1 { 63 + return fmt.Errorf("invalid repository name: %s", entry.Name) 64 + } 65 + 66 + repoOwner := parts[0] 67 + 68 + // Check if user is trying to access their own repository 69 + // They can use either their handle or DID 70 + if repoOwner != userHandle && repoOwner != userDID { 71 + // For push/delete operations, strict ownership check 72 + for _, action := range entry.Actions { 73 + if action == "push" || action == "delete" { 74 + return fmt.Errorf("user %s cannot %s to repository %s", userHandle, action, entry.Name) 75 + } 76 + } 77 + } 78 + } 79 + 80 + return nil 81 + }
+31
pkg/auth/token/claims.go
··· 1 + package token 2 + 3 + import ( 4 + "time" 5 + 6 + "atcr.io/pkg/auth" 7 + "github.com/golang-jwt/jwt/v5" 8 + ) 9 + 10 + // Claims represents the JWT claims for registry authentication 11 + // This follows the Docker Registry token specification 12 + type Claims struct { 13 + jwt.RegisteredClaims 14 + Access []auth.AccessEntry `json:"access,omitempty"` 15 + } 16 + 17 + // NewClaims creates a new Claims structure with standard fields 18 + func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry) *Claims { 19 + now := time.Now() 20 + return &Claims{ 21 + RegisteredClaims: jwt.RegisteredClaims{ 22 + Subject: subject, // User's DID 23 + Issuer: issuer, // "atcr.io" 24 + Audience: jwt.ClaimStrings{audience}, // Service name 25 + IssuedAt: jwt.NewNumericDate(now), 26 + NotBefore: jwt.NewNumericDate(now), 27 + ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), 28 + }, 29 + Access: access, 30 + } 31 + }
+152
pkg/auth/token/handler.go
··· 1 + package token 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + "time" 9 + 10 + mainAtproto "atcr.io/pkg/atproto" 11 + "atcr.io/pkg/auth" 12 + "atcr.io/pkg/auth/atproto" 13 + ) 14 + 15 + // Handler handles /auth/token requests 16 + type Handler struct { 17 + issuer *Issuer 18 + validator *atproto.SessionValidator 19 + defaultHoldEndpoint string 20 + } 21 + 22 + // NewHandler creates a new token handler 23 + func NewHandler(issuer *Issuer, defaultHoldEndpoint string) *Handler { 24 + return &Handler{ 25 + issuer: issuer, 26 + validator: atproto.NewSessionValidator(), 27 + defaultHoldEndpoint: defaultHoldEndpoint, 28 + } 29 + } 30 + 31 + // TokenResponse represents the response from /auth/token 32 + type TokenResponse struct { 33 + Token string `json:"token,omitempty"` // Legacy field 34 + AccessToken string `json:"access_token,omitempty"` // Standard field 35 + ExpiresIn int `json:"expires_in,omitempty"` 36 + IssuedAt string `json:"issued_at,omitempty"` 37 + } 38 + 39 + // ServeHTTP handles the token request 40 + func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 41 + fmt.Printf("DEBUG [token/handler]: Received %s request to %s\n", r.Method, r.URL.Path) 42 + 43 + // Only accept GET requests (per Docker spec) 44 + if r.Method != http.MethodGet { 45 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 46 + return 47 + } 48 + 49 + // Extract Basic auth credentials 50 + username, password, ok := r.BasicAuth() 51 + if !ok { 52 + fmt.Printf("DEBUG [token/handler]: No Basic auth credentials provided\n") 53 + w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 54 + http.Error(w, "authentication required", http.StatusUnauthorized) 55 + return 56 + } 57 + 58 + fmt.Printf("DEBUG [token/handler]: Got Basic auth for username=%s, password length=%d\n", username, len(password)) 59 + 60 + // Parse query parameters 61 + _ = r.URL.Query().Get("service") // service parameter - validated by issuer 62 + scopeParam := r.URL.Query().Get("scope") 63 + 64 + // Parse scopes 65 + var scopes []string 66 + if scopeParam != "" { 67 + scopes = strings.Split(scopeParam, " ") 68 + } 69 + 70 + access, err := auth.ParseScope(scopes) 71 + if err != nil { 72 + http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest) 73 + return 74 + } 75 + 76 + // Validate credentials against ATProto and get access token 77 + fmt.Printf("DEBUG [token/handler]: Validating credentials for %s\n", username) 78 + did, _, accessToken, err := h.validator.CreateSessionAndGetToken(r.Context(), username, password) 79 + if err != nil { 80 + fmt.Printf("DEBUG [token/handler]: Credential validation failed: %v\n", err) 81 + w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 82 + http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized) 83 + return 84 + } 85 + 86 + fmt.Printf("DEBUG [token/handler]: Credentials validated successfully, DID=%s, AccessToken length=%d\n", did, len(accessToken)) 87 + 88 + // Cache the access token for later use (e.g., when pushing manifests) 89 + // TTL of 2 hours (ATProto tokens typically last longer) 90 + auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) 91 + fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did) 92 + 93 + // Ensure user profile exists (creates with default hold if needed) 94 + // Resolve PDS endpoint for profile management 95 + resolver := mainAtproto.NewResolver() 96 + _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username) 97 + if err != nil { 98 + // Log error but don't fail auth - profile management is not critical 99 + fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 100 + } else { 101 + // Create ATProto client with validated token 102 + atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) 103 + 104 + // Ensure profile exists (will create with default hold if not exists and default is configured) 105 + if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { 106 + // Log error but don't fail auth - profile management is not critical 107 + fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 108 + } 109 + } 110 + 111 + // Validate that the user has permission for the requested access 112 + if err := auth.ValidateAccess(did, username, access); err != nil { 113 + fmt.Printf("DEBUG [token/handler]: Access validation failed: %v\n", err) 114 + http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) 115 + return 116 + } 117 + 118 + fmt.Printf("DEBUG [token/handler]: Access validated for DID=%s\n", did) 119 + 120 + // Issue JWT token 121 + tokenString, err := h.issuer.Issue(did, access) 122 + if err != nil { 123 + fmt.Printf("DEBUG [token/handler]: Failed to issue token: %v\n", err) 124 + http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) 125 + return 126 + } 127 + 128 + fmt.Printf("DEBUG [token/handler]: Issued JWT token (length=%d) for DID=%s\n", len(tokenString), did) 129 + fmt.Printf("DEBUG [token/handler]: JWT Token: %s\n", tokenString) 130 + 131 + // Return token response 132 + now := time.Now() 133 + expiresIn := int(h.issuer.expiration.Seconds()) 134 + 135 + resp := TokenResponse{ 136 + Token: tokenString, 137 + AccessToken: tokenString, 138 + ExpiresIn: expiresIn, 139 + IssuedAt: now.Format(time.RFC3339), 140 + } 141 + 142 + w.Header().Set("Content-Type", "application/json") 143 + if err := json.NewEncoder(w).Encode(resp); err != nil { 144 + http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) 145 + return 146 + } 147 + } 148 + 149 + // RegisterRoutes registers the token handler with the provided mux 150 + func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 151 + mux.Handle("/auth/token", h) 152 + }
+194
pkg/auth/token/issuer.go
··· 1 + package token 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/rsa" 6 + "crypto/x509" 7 + "crypto/x509/pkix" 8 + "encoding/base64" 9 + "encoding/pem" 10 + "fmt" 11 + "math/big" 12 + "os" 13 + "path/filepath" 14 + "strings" 15 + "time" 16 + 17 + "atcr.io/pkg/auth" 18 + "github.com/golang-jwt/jwt/v5" 19 + ) 20 + 21 + // Issuer handles JWT token creation and signing 22 + type Issuer struct { 23 + privateKey *rsa.PrivateKey 24 + publicKey *rsa.PublicKey 25 + certificate []byte // DER-encoded certificate 26 + issuer string 27 + service string 28 + expiration time.Duration 29 + } 30 + 31 + // NewIssuer creates a new JWT issuer 32 + func NewIssuer(privateKeyPath, issuer, service string, expiration time.Duration) (*Issuer, error) { 33 + privateKey, err := loadOrGenerateKey(privateKeyPath) 34 + if err != nil { 35 + return nil, fmt.Errorf("failed to load private key: %w", err) 36 + } 37 + 38 + // Load the certificate for x5c header 39 + certPath := strings.TrimSuffix(privateKeyPath, ".pem") + ".crt" 40 + certPEM, err := os.ReadFile(certPath) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to read certificate: %w", err) 43 + } 44 + 45 + // Parse PEM to get DER-encoded certificate 46 + block, _ := pem.Decode(certPEM) 47 + if block == nil || block.Type != "CERTIFICATE" { 48 + return nil, fmt.Errorf("failed to decode certificate PEM") 49 + } 50 + 51 + return &Issuer{ 52 + privateKey: privateKey, 53 + publicKey: &privateKey.PublicKey, 54 + certificate: block.Bytes, // DER-encoded certificate 55 + issuer: issuer, 56 + service: service, 57 + expiration: expiration, 58 + }, nil 59 + } 60 + 61 + // Issue creates and signs a new JWT token 62 + func (i *Issuer) Issue(subject string, access []auth.AccessEntry) (string, error) { 63 + claims := NewClaims(subject, i.issuer, i.service, i.expiration, access) 64 + 65 + fmt.Printf("DEBUG [token/issuer]: Creating token with issuer=%s, service=%s, subject=%s, access=%v\n", 66 + i.issuer, i.service, subject, access) 67 + 68 + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) 69 + 70 + // Add x5c header - embeds the certificate chain in the JWT 71 + // This is base64-encoded DER certificate(s) 72 + certChain := []string{ 73 + base64.StdEncoding.EncodeToString(i.certificate), 74 + } 75 + token.Header["x5c"] = certChain 76 + 77 + signedToken, err := token.SignedString(i.privateKey) 78 + if err != nil { 79 + return "", fmt.Errorf("failed to sign token: %w", err) 80 + } 81 + 82 + fmt.Printf("DEBUG [token/issuer]: Successfully signed token with x5c header\n") 83 + 84 + return signedToken, nil 85 + } 86 + 87 + // PublicKey returns the public key for token verification 88 + func (i *Issuer) PublicKey() *rsa.PublicKey { 89 + return i.publicKey 90 + } 91 + 92 + // Expiration returns the token expiration duration 93 + func (i *Issuer) Expiration() time.Duration { 94 + return i.expiration 95 + } 96 + 97 + // loadOrGenerateKey loads an existing RSA private key or generates a new one 98 + func loadOrGenerateKey(path string) (*rsa.PrivateKey, error) { 99 + // Try to load existing key 100 + if _, err := os.Stat(path); err == nil { 101 + keyData, err := os.ReadFile(path) 102 + if err != nil { 103 + return nil, fmt.Errorf("failed to read key file: %w", err) 104 + } 105 + 106 + block, _ := pem.Decode(keyData) 107 + if block == nil || block.Type != "RSA PRIVATE KEY" { 108 + return nil, fmt.Errorf("failed to decode PEM block containing private key") 109 + } 110 + 111 + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) 112 + if err != nil { 113 + return nil, fmt.Errorf("failed to parse private key: %w", err) 114 + } 115 + 116 + // Ensure certificate exists 117 + certPath := strings.TrimSuffix(path, ".pem") + ".crt" 118 + if _, err := os.Stat(certPath); os.IsNotExist(err) { 119 + // Certificate doesn't exist, generate it 120 + if err := generateCertificate(privateKey, certPath); err != nil { 121 + return nil, fmt.Errorf("failed to generate certificate: %w", err) 122 + } 123 + } 124 + 125 + return privateKey, nil 126 + } 127 + 128 + // Generate new key 129 + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 130 + if err != nil { 131 + return nil, fmt.Errorf("failed to generate private key: %w", err) 132 + } 133 + 134 + // Ensure directory exists 135 + dir := filepath.Dir(path) 136 + if err := os.MkdirAll(dir, 0700); err != nil { 137 + return nil, fmt.Errorf("failed to create key directory: %w", err) 138 + } 139 + 140 + // Save key to file 141 + keyBytes := x509.MarshalPKCS1PrivateKey(privateKey) 142 + keyPEM := pem.EncodeToMemory(&pem.Block{ 143 + Type: "RSA PRIVATE KEY", 144 + Bytes: keyBytes, 145 + }) 146 + 147 + if err := os.WriteFile(path, keyPEM, 0600); err != nil { 148 + return nil, fmt.Errorf("failed to write private key: %w", err) 149 + } 150 + 151 + // Also generate a self-signed certificate for the public key 152 + certPath := strings.TrimSuffix(path, ".pem") + ".crt" 153 + if err := generateCertificate(privateKey, certPath); err != nil { 154 + return nil, fmt.Errorf("failed to generate certificate: %w", err) 155 + } 156 + 157 + return privateKey, nil 158 + } 159 + 160 + // generateCertificate creates a self-signed certificate for JWT validation 161 + func generateCertificate(privateKey *rsa.PrivateKey, certPath string) error { 162 + // Create certificate template 163 + template := x509.Certificate{ 164 + SerialNumber: big.NewInt(1), 165 + Subject: pkix.Name{ 166 + Organization: []string{"ATCR"}, 167 + CommonName: "ATCR Token Signing Certificate", 168 + }, 169 + NotBefore: time.Now(), 170 + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years 171 + KeyUsage: x509.KeyUsageDigitalSignature, 172 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 173 + BasicConstraintsValid: true, 174 + } 175 + 176 + // Create self-signed certificate 177 + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) 178 + if err != nil { 179 + return fmt.Errorf("failed to create certificate: %w", err) 180 + } 181 + 182 + // Encode certificate to PEM 183 + certPEM := pem.EncodeToMemory(&pem.Block{ 184 + Type: "CERTIFICATE", 185 + Bytes: certBytes, 186 + }) 187 + 188 + // Write certificate to file 189 + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { 190 + return fmt.Errorf("failed to write certificate: %w", err) 191 + } 192 + 193 + return nil 194 + }
+64
pkg/auth/tokencache.go
··· 1 + package auth 2 + 3 + import ( 4 + "sync" 5 + "time" 6 + ) 7 + 8 + // TokenCacheEntry represents a cached access token 9 + type TokenCacheEntry struct { 10 + AccessToken string 11 + ExpiresAt time.Time 12 + } 13 + 14 + // TokenCache is a simple in-memory cache for ATProto access tokens 15 + type TokenCache struct { 16 + mu sync.RWMutex 17 + tokens map[string]*TokenCacheEntry 18 + } 19 + 20 + var globalTokenCache = &TokenCache{ 21 + tokens: make(map[string]*TokenCacheEntry), 22 + } 23 + 24 + // GetGlobalTokenCache returns the global token cache instance 25 + func GetGlobalTokenCache() *TokenCache { 26 + return globalTokenCache 27 + } 28 + 29 + // Set stores an access token for a DID 30 + func (tc *TokenCache) Set(did, accessToken string, ttl time.Duration) { 31 + tc.mu.Lock() 32 + defer tc.mu.Unlock() 33 + 34 + tc.tokens[did] = &TokenCacheEntry{ 35 + AccessToken: accessToken, 36 + ExpiresAt: time.Now().Add(ttl), 37 + } 38 + } 39 + 40 + // Get retrieves an access token for a DID 41 + func (tc *TokenCache) Get(did string) (string, bool) { 42 + tc.mu.RLock() 43 + defer tc.mu.RUnlock() 44 + 45 + entry, ok := tc.tokens[did] 46 + if !ok { 47 + return "", false 48 + } 49 + 50 + // Check if expired 51 + if time.Now().After(entry.ExpiresAt) { 52 + return "", false 53 + } 54 + 55 + return entry.AccessToken, true 56 + } 57 + 58 + // Delete removes a cached token 59 + func (tc *TokenCache) Delete(did string) { 60 + tc.mu.Lock() 61 + defer tc.mu.Unlock() 62 + 63 + delete(tc.tokens, did) 64 + }
+8
pkg/auth/types.go
··· 1 + package auth 2 + 3 + // AccessEntry represents access permissions for a resource 4 + type AccessEntry struct { 5 + Type string `json:"type"` // "repository" 6 + Name string `json:"name,omitempty"` // e.g., "alice/myapp" 7 + Actions []string `json:"actions,omitempty"` // e.g., ["pull", "push"] 8 + }
+190
pkg/middleware/registry.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "strings" 8 + 9 + "github.com/distribution/distribution/v3" 10 + registrymw "github.com/distribution/distribution/v3/registry/middleware/registry" 11 + "github.com/distribution/distribution/v3/registry/storage/driver" 12 + "github.com/distribution/reference" 13 + 14 + "atcr.io/pkg/atproto" 15 + "atcr.io/pkg/auth" 16 + "atcr.io/pkg/storage" 17 + ) 18 + 19 + func init() { 20 + // Register the name resolution middleware 21 + registrymw.Register("atproto-resolver", initATProtoResolver) 22 + } 23 + 24 + // NamespaceResolver wraps a namespace and resolves names 25 + type NamespaceResolver struct { 26 + distribution.Namespace 27 + resolver *atproto.Resolver 28 + defaultStorageEndpoint string 29 + } 30 + 31 + // initATProtoResolver initializes the name resolution middleware 32 + func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]interface{}) (distribution.Namespace, error) { 33 + resolver := atproto.NewResolver() 34 + 35 + // Get default storage endpoint from config (optional) 36 + defaultStorageEndpoint := "" 37 + if endpoint, ok := options["default_storage_endpoint"].(string); ok { 38 + defaultStorageEndpoint = endpoint 39 + } 40 + 41 + return &NamespaceResolver{ 42 + Namespace: ns, 43 + resolver: resolver, 44 + defaultStorageEndpoint: defaultStorageEndpoint, 45 + }, nil 46 + } 47 + 48 + // Repository resolves the repository name and delegates to underlying namespace 49 + // Handles names like: 50 + // - atcr.io/alice/myimage → resolve alice to DID 51 + // - atcr.io/did:plc:xyz123/myimage → use DID directly 52 + func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Named) (distribution.Repository, error) { 53 + // Extract the first part of the name (username or DID) 54 + repoPath := name.Name() 55 + parts := strings.SplitN(repoPath, "/", 2) 56 + 57 + if len(parts) < 2 { 58 + // No user specified, use default or return error 59 + return nil, fmt.Errorf("repository name must include user: %s", repoPath) 60 + } 61 + 62 + identity := parts[0] 63 + imageName := parts[1] 64 + 65 + // Resolve identity to DID and PDS 66 + did, pdsEndpoint, err := nr.resolver.ResolveIdentity(ctx, identity) 67 + if err != nil { 68 + return nil, fmt.Errorf("failed to resolve identity %s: %w", identity, err) 69 + } 70 + 71 + // Store resolved DID and PDS in context for downstream use 72 + ctx = context.WithValue(ctx, "atproto.did", did) 73 + ctx = context.WithValue(ctx, "atproto.pds", pdsEndpoint) 74 + ctx = context.WithValue(ctx, "atproto.identity", identity) 75 + 76 + fmt.Printf("DEBUG [registry/middleware]: Set context values: did=%s, pds=%s, identity=%s\n", did, pdsEndpoint, identity) 77 + 78 + // Query for storage endpoint - either user's hold or default hold service 79 + storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint) 80 + if storageEndpoint == "" { 81 + // This is a fatal configuration error - registry cannot function without a hold service 82 + return nil, fmt.Errorf("no storage endpoint configured: ensure default_storage_endpoint is set in middleware config") 83 + } 84 + ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint) 85 + fmt.Printf("DEBUG [registry/middleware]: Using storage endpoint: %s\n", storageEndpoint) 86 + 87 + // Create a new reference with identity/image format 88 + // Use the identity (or DID) as the namespace to ensure canonical format 89 + // This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path) 90 + canonicalName := fmt.Sprintf("%s/%s", identity, imageName) 91 + ref, err := reference.ParseNamed(canonicalName) 92 + if err != nil { 93 + return nil, fmt.Errorf("invalid image name %s: %w", imageName, err) 94 + } 95 + 96 + // Delegate to underlying namespace with modified name 97 + repo, err := nr.Namespace.Repository(ctx, ref) 98 + if err != nil { 99 + return nil, err 100 + } 101 + 102 + // Wrap the repository with our routing repository 103 + // Get the cached access token for this DID 104 + accessToken, ok := auth.GetGlobalTokenCache().Get(did) 105 + if !ok { 106 + fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s\n", did) 107 + accessToken = "" // Will fail on manifest push, but let it try 108 + } else { 109 + fmt.Printf("DEBUG [registry/middleware]: Using cached access token for DID=%s (length=%d)\n", did, len(accessToken)) 110 + } 111 + 112 + // This is where we inject ATProto + storage routing 113 + atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) 114 + 115 + // IMPORTANT: Use only the image name (not identity/image) for ATProto storage 116 + // ATProto records are scoped to the user's DID, so we don't need the identity prefix 117 + // Example: "evan.jarrett.net/debian" -> store as "debian" 118 + repositoryName := imageName 119 + 120 + fmt.Printf("DEBUG [registry/middleware]: Creating RoutingRepository for image=%s (ATProto repo name)\n", repositoryName) 121 + 122 + // Create routing repository - routes manifests to ATProto, blobs to hold service 123 + // The registry is stateless - no local storage is used 124 + // Pass storage endpoint and DID as parameters (can't use context as it gets lost) 125 + routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did) 126 + 127 + return routingRepo, nil 128 + } 129 + 130 + // Repositories delegates to underlying namespace 131 + func (nr *NamespaceResolver) Repositories(ctx context.Context, repos []string, last string) (int, error) { 132 + return nr.Namespace.Repositories(ctx, repos, last) 133 + } 134 + 135 + // Blobs delegates to underlying namespace 136 + func (nr *NamespaceResolver) Blobs() distribution.BlobEnumerator { 137 + return nr.Namespace.Blobs() 138 + } 139 + 140 + // BlobStatter delegates to underlying namespace 141 + func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter { 142 + return nr.Namespace.BlobStatter() 143 + } 144 + 145 + // findStorageEndpoint determines which hold endpoint to use for blob storage 146 + // Priority order: 147 + // 1. User's sailor profile defaultHold (if set) 148 + // 2. User's own hold record (io.atcr.hold) 149 + // 3. AppView's default hold endpoint 150 + // Returns the storage endpoint URL, or empty string if none configured 151 + func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string { 152 + // Create ATProto client (without auth - reading public records) 153 + client := atproto.NewClient(pdsEndpoint, did, "") 154 + 155 + // 1. Check for sailor profile 156 + profile, err := atproto.GetProfile(ctx, client) 157 + if err != nil { 158 + // Error reading profile (not a 404) - log and continue 159 + fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err) 160 + } 161 + 162 + if profile != nil && profile.DefaultHold != "" { 163 + // Profile exists with defaultHold set - use it 164 + return profile.DefaultHold 165 + } 166 + 167 + // 2. Profile doesn't exist or defaultHold is null/empty 168 + // Check for user's own hold records 169 + records, err := client.ListRecords(ctx, atproto.HoldCollection, 10) 170 + if err != nil { 171 + // Failed to query holds, use default 172 + return nr.defaultStorageEndpoint 173 + } 174 + 175 + // Find the first hold record 176 + for _, record := range records { 177 + var holdRecord atproto.HoldRecord 178 + if err := json.Unmarshal(record.Value, &holdRecord); err != nil { 179 + continue 180 + } 181 + 182 + // Return the endpoint from the first hold 183 + if holdRecord.Endpoint != "" { 184 + return holdRecord.Endpoint 185 + } 186 + } 187 + 188 + // 3. No profile defaultHold and no own hold records - use AppView default 189 + return nr.defaultStorageEndpoint 190 + }
+57
pkg/middleware/repository.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/distribution/distribution/v3" 8 + repositorymw "github.com/distribution/distribution/v3/registry/middleware/repository" 9 + 10 + "atcr.io/pkg/atproto" 11 + "atcr.io/pkg/storage" 12 + ) 13 + 14 + func init() { 15 + // Register the ATProto routing middleware 16 + repositorymw.Register("atproto-router", initATProtoRouter) 17 + } 18 + 19 + // initATProtoRouter initializes the ATProto routing middleware 20 + func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]interface{}) (distribution.Repository, error) { 21 + fmt.Printf("DEBUG [repository/middleware]: Initializing atproto-router for repo=%s\n", repo.Named().Name()) 22 + fmt.Printf("DEBUG [repository/middleware]: Context values: atproto.did=%v, atproto.pds=%v\n", 23 + ctx.Value("atproto.did"), ctx.Value("atproto.pds")) 24 + 25 + // Extract DID and PDS from context (set by registry middleware) 26 + did, ok := ctx.Value("atproto.did").(string) 27 + if !ok || did == "" { 28 + fmt.Printf("DEBUG [repository/middleware]: DID not found in context, ok=%v, did=%q\n", ok, did) 29 + return nil, fmt.Errorf("did is required for atproto-router middleware") 30 + } 31 + 32 + pdsEndpoint, ok := ctx.Value("atproto.pds").(string) 33 + if !ok || pdsEndpoint == "" { 34 + return nil, fmt.Errorf("pds is required for atproto-router middleware") 35 + } 36 + 37 + // For now, use empty access token (we'll add auth later) 38 + accessToken := "" 39 + 40 + // Create ATProto client 41 + atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) 42 + 43 + // Get repository name 44 + repoName := repo.Named().Name() 45 + 46 + // Get storage endpoint from context 47 + storageEndpoint, ok := ctx.Value("storage.endpoint").(string) 48 + if !ok || storageEndpoint == "" { 49 + return nil, fmt.Errorf("storage.endpoint not found in context") 50 + } 51 + 52 + // Create routing repository - no longer uses storage driver 53 + // All blobs are routed through hold service 54 + routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repoName, storageEndpoint, did) 55 + 56 + return routingRepo, nil 57 + }
+57
pkg/server/handler.go
··· 1 + package server 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + 7 + "atcr.io/pkg/atproto" 8 + ) 9 + 10 + // ATProtoHandler wraps an HTTP handler to provide name resolution 11 + // This is an optional layer if middleware doesn't provide enough control 12 + type ATProtoHandler struct { 13 + handler http.Handler 14 + resolver *atproto.Resolver 15 + } 16 + 17 + // NewATProtoHandler creates a new HTTP handler wrapper 18 + func NewATProtoHandler(handler http.Handler) *ATProtoHandler { 19 + return &ATProtoHandler{ 20 + handler: handler, 21 + resolver: atproto.NewResolver(), 22 + } 23 + } 24 + 25 + // ServeHTTP handles HTTP requests with name resolution 26 + func (h *ATProtoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 27 + // Parse the request path to extract user/image 28 + // OCI Distribution API paths look like: 29 + // /v2/<name>/manifests/<reference> 30 + // /v2/<name>/blobs/<digest> 31 + 32 + path := r.URL.Path 33 + 34 + // Check if this is a v2 API request 35 + if strings.HasPrefix(path, "/v2/") { 36 + // Extract the repository name 37 + parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/") 38 + if len(parts) >= 2 { 39 + // parts[0] might be username/DID 40 + // We could do early resolution here if needed 41 + // For now, we'll let the middleware handle it 42 + } 43 + } 44 + 45 + // Delegate to the underlying handler 46 + // The registry middleware will handle the actual resolution 47 + h.handler.ServeHTTP(w, r) 48 + } 49 + 50 + // Note: In the current architecture, most of the name resolution 51 + // is handled by the registry middleware. This HTTP handler wrapper 52 + // is here for cases where you need to intercept requests before 53 + // they reach the distribution handlers, such as for: 54 + // - Custom authentication based on DIDs 55 + // - Request rewriting 56 + // - Early validation 57 + // - Custom API endpoints beyond OCI spec
+98
pkg/storage/hold_cache.go
··· 1 + package storage 2 + 3 + import ( 4 + "sync" 5 + "time" 6 + ) 7 + 8 + // HoldCache caches hold endpoints for (DID, repository) pairs 9 + // This avoids expensive ATProto lookups on every blob request during pulls 10 + // 11 + // NOTE: This is a simple in-memory cache for MVP. For production deployments: 12 + // - Use Redis or similar for distributed caching 13 + // - Consider implementing cache size limits 14 + // - Monitor memory usage under high load 15 + type HoldCache struct { 16 + mu sync.RWMutex 17 + cache map[string]*holdCacheEntry 18 + } 19 + 20 + type holdCacheEntry struct { 21 + holdEndpoint string 22 + expiresAt time.Time 23 + } 24 + 25 + var globalHoldCache = &HoldCache{ 26 + cache: make(map[string]*holdCacheEntry), 27 + } 28 + 29 + func init() { 30 + // Start background cleanup goroutine 31 + go func() { 32 + ticker := time.NewTicker(5 * time.Minute) 33 + defer ticker.Stop() 34 + for range ticker.C { 35 + globalHoldCache.Cleanup() 36 + } 37 + }() 38 + } 39 + 40 + // GetGlobalHoldCache returns the global hold cache instance 41 + func GetGlobalHoldCache() *HoldCache { 42 + return globalHoldCache 43 + } 44 + 45 + // Set stores a hold endpoint for a (DID, repository) pair with a TTL 46 + func (c *HoldCache) Set(did, repository, holdEndpoint string, ttl time.Duration) { 47 + c.mu.Lock() 48 + defer c.mu.Unlock() 49 + 50 + key := did + ":" + repository 51 + c.cache[key] = &holdCacheEntry{ 52 + holdEndpoint: holdEndpoint, 53 + expiresAt: time.Now().Add(ttl), 54 + } 55 + } 56 + 57 + // Get retrieves a hold endpoint for a (DID, repository) pair 58 + // Returns empty string and false if not found or expired 59 + func (c *HoldCache) Get(did, repository string) (string, bool) { 60 + c.mu.RLock() 61 + defer c.mu.RUnlock() 62 + 63 + key := did + ":" + repository 64 + entry, ok := c.cache[key] 65 + if !ok { 66 + return "", false 67 + } 68 + 69 + // Check if expired 70 + if time.Now().After(entry.expiresAt) { 71 + // Don't delete here (would need write lock), let cleanup handle it 72 + return "", false 73 + } 74 + 75 + return entry.holdEndpoint, true 76 + } 77 + 78 + // Cleanup removes expired entries (called automatically every 5 minutes) 79 + func (c *HoldCache) Cleanup() { 80 + c.mu.Lock() 81 + defer c.mu.Unlock() 82 + 83 + now := time.Now() 84 + removed := 0 85 + for key, entry := range c.cache { 86 + if now.After(entry.expiresAt) { 87 + delete(c.cache, key) 88 + removed++ 89 + } 90 + } 91 + 92 + // Log cleanup stats for monitoring 93 + if removed > 0 || len(c.cache) > 100 { 94 + // Log if we removed entries OR if cache is growing large 95 + // This helps identify if cache size is becoming a concern 96 + println("Hold cache cleanup: removed", removed, "entries, remaining", len(c.cache)) 97 + } 98 + }
+402
pkg/storage/proxy_blob_store.go
··· 1 + package storage 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "sync" 11 + "time" 12 + 13 + "github.com/distribution/distribution/v3" 14 + "github.com/opencontainers/go-digest" 15 + ) 16 + 17 + // Global upload tracking (shared across all ProxyBlobStore instances) 18 + // This is necessary because distribution creates new repository/blob store instances per request 19 + var ( 20 + globalUploads = make(map[string]*ProxyBlobWriter) 21 + globalUploadsMu sync.RWMutex 22 + ) 23 + 24 + // ProxyBlobStore proxies blob requests to an external storage service 25 + type ProxyBlobStore struct { 26 + storageEndpoint string 27 + httpClient *http.Client 28 + did string 29 + } 30 + 31 + // NewProxyBlobStore creates a new proxy blob store 32 + func NewProxyBlobStore(storageEndpoint, did string) *ProxyBlobStore { 33 + return &ProxyBlobStore{ 34 + storageEndpoint: storageEndpoint, 35 + httpClient: &http.Client{}, 36 + did: did, 37 + } 38 + } 39 + 40 + // Stat returns the descriptor for a blob 41 + func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) { 42 + // For simplicity, we'll just check if we can get a download URL 43 + // In production, you'd want a dedicated stat endpoint 44 + url, err := p.getDownloadURL(ctx, dgst) 45 + if err != nil { 46 + return distribution.Descriptor{}, distribution.ErrBlobUnknown 47 + } 48 + 49 + // We don't have size info from the storage service 50 + // Return a minimal descriptor 51 + return distribution.Descriptor{ 52 + Digest: dgst, 53 + MediaType: "application/octet-stream", 54 + URLs: []string{url}, 55 + }, nil 56 + } 57 + 58 + // Get retrieves a blob 59 + func (p *ProxyBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) { 60 + url, err := p.getDownloadURL(ctx, dgst) 61 + if err != nil { 62 + return nil, err 63 + } 64 + 65 + // Download the blob 66 + resp, err := http.Get(url) 67 + if err != nil { 68 + return nil, err 69 + } 70 + defer resp.Body.Close() 71 + 72 + if resp.StatusCode != http.StatusOK { 73 + return nil, distribution.ErrBlobUnknown 74 + } 75 + 76 + return io.ReadAll(resp.Body) 77 + } 78 + 79 + // Open returns a reader for a blob 80 + func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) { 81 + url, err := p.getDownloadURL(ctx, dgst) 82 + if err != nil { 83 + return nil, err 84 + } 85 + 86 + // Download the blob 87 + resp, err := http.Get(url) 88 + if err != nil { 89 + return nil, err 90 + } 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + resp.Body.Close() 94 + return nil, distribution.ErrBlobUnknown 95 + } 96 + 97 + // Wrap in a ReadSeekCloser 98 + return &readSeekCloser{ 99 + ReadCloser: resp.Body, 100 + }, nil 101 + } 102 + 103 + // Put stores a blob 104 + func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) { 105 + // Calculate digest 106 + dgst := digest.FromBytes(content) 107 + 108 + // Get upload URL 109 + url, err := p.getUploadURL(ctx, dgst, int64(len(content))) 110 + if err != nil { 111 + return distribution.Descriptor{}, err 112 + } 113 + 114 + // Upload the blob 115 + req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content)) 116 + if err != nil { 117 + return distribution.Descriptor{}, err 118 + } 119 + req.Header.Set("Content-Type", "application/octet-stream") 120 + 121 + resp, err := p.httpClient.Do(req) 122 + if err != nil { 123 + return distribution.Descriptor{}, err 124 + } 125 + defer resp.Body.Close() 126 + 127 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 128 + return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d", resp.StatusCode) 129 + } 130 + 131 + return distribution.Descriptor{ 132 + Digest: dgst, 133 + Size: int64(len(content)), 134 + MediaType: mediaType, 135 + }, nil 136 + } 137 + 138 + // Delete removes a blob 139 + func (p *ProxyBlobStore) Delete(ctx context.Context, dgst digest.Digest) error { 140 + // Not implemented - storage service would need a delete endpoint 141 + return fmt.Errorf("delete not supported for proxy blob store") 142 + } 143 + 144 + // ServeBlob serves a blob via HTTP redirect 145 + func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error { 146 + // Get presigned download URL 147 + url, err := p.getDownloadURL(ctx, dgst) 148 + if err != nil { 149 + return err 150 + } 151 + 152 + // Redirect to presigned URL 153 + http.Redirect(w, r, url, http.StatusTemporaryRedirect) 154 + return nil 155 + } 156 + 157 + // Create returns a blob writer for uploading 158 + func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) { 159 + // Parse options 160 + var opts distribution.CreateOptions 161 + for _, option := range options { 162 + if err := option.Apply(&opts); err != nil { 163 + return nil, err 164 + } 165 + } 166 + 167 + // Create proxy blob writer 168 + writer := &ProxyBlobWriter{ 169 + store: p, 170 + ctx: ctx, 171 + options: opts, 172 + id: fmt.Sprintf("upload-%d", time.Now().UnixNano()), 173 + startedAt: time.Now(), 174 + } 175 + 176 + // Store in global uploads map for resume support 177 + globalUploadsMu.Lock() 178 + globalUploads[writer.id] = writer 179 + globalUploadsMu.Unlock() 180 + 181 + return writer, nil 182 + } 183 + 184 + // Resume returns a blob writer for resuming an upload 185 + func (p *ProxyBlobStore) Resume(ctx context.Context, id string) (distribution.BlobWriter, error) { 186 + // Retrieve upload from global map 187 + globalUploadsMu.RLock() 188 + writer, ok := globalUploads[id] 189 + globalUploadsMu.RUnlock() 190 + 191 + if !ok { 192 + return nil, distribution.ErrBlobUploadUnknown 193 + } 194 + 195 + return writer, nil 196 + } 197 + 198 + // getDownloadURL requests a presigned download URL from the storage service 199 + func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) { 200 + reqBody := map[string]interface{}{ 201 + "did": p.did, 202 + "digest": dgst.String(), 203 + } 204 + 205 + body, err := json.Marshal(reqBody) 206 + if err != nil { 207 + return "", err 208 + } 209 + 210 + url := fmt.Sprintf("%s/get-presigned-url", p.storageEndpoint) 211 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 212 + if err != nil { 213 + return "", err 214 + } 215 + req.Header.Set("Content-Type", "application/json") 216 + 217 + resp, err := p.httpClient.Do(req) 218 + if err != nil { 219 + return "", err 220 + } 221 + defer resp.Body.Close() 222 + 223 + if resp.StatusCode != http.StatusOK { 224 + return "", fmt.Errorf("failed to get download URL: status %d", resp.StatusCode) 225 + } 226 + 227 + var result struct { 228 + URL string `json:"url"` 229 + } 230 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 231 + return "", err 232 + } 233 + 234 + return result.URL, nil 235 + } 236 + 237 + // getUploadURL requests a presigned upload URL from the storage service 238 + func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) { 239 + reqBody := map[string]interface{}{ 240 + "did": p.did, 241 + "digest": dgst.String(), 242 + "size": size, 243 + } 244 + 245 + body, err := json.Marshal(reqBody) 246 + if err != nil { 247 + return "", err 248 + } 249 + 250 + url := fmt.Sprintf("%s/put-presigned-url", p.storageEndpoint) 251 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 252 + if err != nil { 253 + return "", err 254 + } 255 + req.Header.Set("Content-Type", "application/json") 256 + 257 + resp, err := p.httpClient.Do(req) 258 + if err != nil { 259 + return "", err 260 + } 261 + defer resp.Body.Close() 262 + 263 + if resp.StatusCode != http.StatusOK { 264 + return "", fmt.Errorf("failed to get upload URL: status %d", resp.StatusCode) 265 + } 266 + 267 + var result struct { 268 + URL string `json:"url"` 269 + } 270 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 271 + return "", err 272 + } 273 + 274 + return result.URL, nil 275 + } 276 + 277 + // ProxyBlobWriter implements distribution.BlobWriter for proxy uploads 278 + type ProxyBlobWriter struct { 279 + store *ProxyBlobStore 280 + ctx context.Context 281 + options distribution.CreateOptions 282 + buffer bytes.Buffer 283 + size int64 284 + closed bool 285 + id string 286 + startedAt time.Time 287 + } 288 + 289 + // ID returns the upload ID 290 + func (w *ProxyBlobWriter) ID() string { 291 + return w.id 292 + } 293 + 294 + // StartedAt returns when the upload started 295 + func (w *ProxyBlobWriter) StartedAt() time.Time { 296 + return w.startedAt 297 + } 298 + 299 + // Write writes data to the upload 300 + func (w *ProxyBlobWriter) Write(p []byte) (int, error) { 301 + if w.closed { 302 + return 0, fmt.Errorf("writer closed") 303 + } 304 + n, err := w.buffer.Write(p) 305 + w.size += int64(n) 306 + return n, err 307 + } 308 + 309 + // ReadFrom reads from a reader 310 + func (w *ProxyBlobWriter) ReadFrom(r io.Reader) (int64, error) { 311 + if w.closed { 312 + return 0, fmt.Errorf("writer closed") 313 + } 314 + return w.buffer.ReadFrom(r) 315 + } 316 + 317 + // Size returns the current size 318 + func (w *ProxyBlobWriter) Size() int64 { 319 + return w.size 320 + } 321 + 322 + // Commit finalizes the upload 323 + func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) { 324 + if w.closed { 325 + return distribution.Descriptor{}, fmt.Errorf("writer closed") 326 + } 327 + w.closed = true 328 + 329 + // Remove from global uploads map 330 + globalUploadsMu.Lock() 331 + delete(globalUploads, w.id) 332 + globalUploadsMu.Unlock() 333 + 334 + // Upload the buffered content 335 + content := w.buffer.Bytes() 336 + dgst := digest.FromBytes(content) 337 + 338 + // Verify digest matches 339 + if desc.Digest != "" && dgst != desc.Digest { 340 + return distribution.Descriptor{}, fmt.Errorf("digest mismatch") 341 + } 342 + 343 + // Get upload URL 344 + url, err := w.store.getUploadURL(ctx, dgst, int64(len(content))) 345 + if err != nil { 346 + return distribution.Descriptor{}, err 347 + } 348 + 349 + // Upload 350 + req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content)) 351 + if err != nil { 352 + return distribution.Descriptor{}, err 353 + } 354 + req.Header.Set("Content-Type", "application/octet-stream") 355 + 356 + resp, err := w.store.httpClient.Do(req) 357 + if err != nil { 358 + return distribution.Descriptor{}, err 359 + } 360 + defer resp.Body.Close() 361 + 362 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 363 + return distribution.Descriptor{}, fmt.Errorf("upload failed: status %d", resp.StatusCode) 364 + } 365 + 366 + return distribution.Descriptor{ 367 + Digest: dgst, 368 + Size: int64(len(content)), 369 + MediaType: desc.MediaType, 370 + }, nil 371 + } 372 + 373 + // Cancel cancels the upload 374 + func (w *ProxyBlobWriter) Cancel(ctx context.Context) error { 375 + w.closed = true 376 + 377 + // Remove from global uploads map 378 + globalUploadsMu.Lock() 379 + delete(globalUploads, w.id) 380 + globalUploadsMu.Unlock() 381 + 382 + return nil 383 + } 384 + 385 + // Close closes the writer 386 + // NOTE: For resumable uploads, we don't mark as closed here 387 + // Distribution calls Close() after each PATCH, but the upload may continue 388 + // Only Commit() and Cancel() actually finalize the upload 389 + func (w *ProxyBlobWriter) Close() error { 390 + // Don't set w.closed = true here - allow resuming 391 + return nil 392 + } 393 + 394 + // readSeekCloser wraps an io.ReadCloser to implement ReadSeekCloser 395 + type readSeekCloser struct { 396 + io.ReadCloser 397 + } 398 + 399 + func (r *readSeekCloser) Seek(offset int64, whence int) (int64, error) { 400 + // Not implemented - would need buffering or re-downloading 401 + return 0, fmt.Errorf("seek not supported") 402 + }
+95
pkg/storage/routing_repository.go
··· 1 + package storage 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "atcr.io/pkg/atproto" 9 + "github.com/distribution/distribution/v3" 10 + ) 11 + 12 + // RoutingRepository routes manifests to ATProto and blobs to external hold service 13 + // The registry (AppView) is stateless and NEVER stores blobs locally 14 + type RoutingRepository struct { 15 + distribution.Repository 16 + atprotoClient *atproto.Client 17 + repositoryName string 18 + storageEndpoint string // Hold service endpoint for blobs (from discovery for push) 19 + did string // User's DID for authorization 20 + manifestStore *atproto.ManifestStore // Cached manifest store instance 21 + } 22 + 23 + // NewRoutingRepository creates a new routing repository 24 + func NewRoutingRepository( 25 + baseRepo distribution.Repository, 26 + atprotoClient *atproto.Client, 27 + repoName string, 28 + storageEndpoint string, 29 + did string, 30 + ) *RoutingRepository { 31 + return &RoutingRepository{ 32 + Repository: baseRepo, 33 + atprotoClient: atprotoClient, 34 + repositoryName: repoName, 35 + storageEndpoint: storageEndpoint, 36 + did: did, 37 + } 38 + } 39 + 40 + // Manifests returns the ATProto-backed manifest service 41 + func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) { 42 + // Create or return cached manifest store 43 + if r.manifestStore == nil { 44 + r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did) 45 + } 46 + 47 + // After any manifest operation, cache the hold endpoint for blob fetches 48 + // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete 49 + go func() { 50 + time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete 51 + if holdEndpoint := r.manifestStore.GetLastFetchedHoldEndpoint(); holdEndpoint != "" { 52 + // Cache for 10 minutes - should cover typical pull operations 53 + GetGlobalHoldCache().Set(r.did, r.repositoryName, holdEndpoint, 10*time.Minute) 54 + fmt.Printf("DEBUG [storage/routing]: Cached hold endpoint: did=%s, repo=%s, hold=%s\n", 55 + r.did, r.repositoryName, holdEndpoint) 56 + } 57 + }() 58 + 59 + return r.manifestStore, nil 60 + } 61 + 62 + // Blobs returns a proxy blob store that routes to external hold service 63 + // The registry (AppView) NEVER stores blobs locally - all blobs go through hold service 64 + func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore { 65 + // For pull operations, check if we have a cached hold endpoint from a recent manifest fetch 66 + // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered 67 + holdEndpoint := r.storageEndpoint // Default to discovery-based endpoint 68 + 69 + if cachedHold, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok { 70 + // Use cached hold from manifest 71 + holdEndpoint = cachedHold 72 + fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n", 73 + r.did, r.repositoryName, cachedHold) 74 + } else { 75 + // No cached hold, use discovery-based endpoint (for push or first pull) 76 + fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n", 77 + r.did, r.repositoryName, holdEndpoint) 78 + } 79 + 80 + if holdEndpoint == "" { 81 + // This should never happen if middleware is configured correctly 82 + panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware") 83 + } 84 + 85 + // Always use proxy blob store - routes to external hold service 86 + return NewProxyBlobStore(holdEndpoint, r.did) 87 + } 88 + 89 + // Tags returns the tag service 90 + // Tags will be handled by ATProto as well 91 + func (r *RoutingRepository) Tags(ctx context.Context) distribution.TagService { 92 + // For now, delegate to the base repository 93 + // In a full implementation, this would also use ATProto 94 + return r.Repository.Tags(ctx) 95 + }
+54
pkg/storage/s3_blob_store.go
··· 1 + package storage 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/distribution/distribution/v3" 7 + "github.com/distribution/distribution/v3/registry/storage" 8 + "github.com/distribution/distribution/v3/registry/storage/driver" 9 + "github.com/distribution/reference" 10 + ) 11 + 12 + // S3BlobStore wraps distribution's blob store with S3 backend 13 + type S3BlobStore struct { 14 + distribution.BlobStore 15 + } 16 + 17 + // NewS3BlobStore creates a new S3-backed blob store 18 + func NewS3BlobStore(ctx context.Context, storageDriver driver.StorageDriver, repoName string) (*S3BlobStore, error) { 19 + // Create a registry instance with the S3 driver 20 + reg, err := storage.NewRegistry(ctx, storageDriver) 21 + if err != nil { 22 + return nil, err 23 + } 24 + 25 + // Parse the repository name into a Named reference 26 + named, err := reference.ParseNamed(repoName) 27 + if err != nil { 28 + return nil, err 29 + } 30 + 31 + // Get the repository 32 + repo, err := reg.Repository(ctx, named) 33 + if err != nil { 34 + return nil, err 35 + } 36 + 37 + // Get the blob store 38 + blobStore := repo.Blobs(ctx) 39 + 40 + return &S3BlobStore{ 41 + BlobStore: blobStore, 42 + }, nil 43 + } 44 + 45 + // Note: S3BlobStore inherits all methods from distribution.BlobStore 46 + // including: 47 + // - Stat(ctx, dgst) - Check if blob exists 48 + // - Get(ctx, dgst) - Retrieve blob 49 + // - Open(ctx, dgst) - Open blob for reading 50 + // - Put(ctx, mediaType, payload) - Store blob 51 + // - Create(ctx, options...) - Create blob writer 52 + // - Resume(ctx, id) - Resume blob upload 53 + // - ServeBlob(ctx, w, r, dgst) - Serve blob over HTTP 54 + // - Delete(ctx, dgst) - Delete blob
registry

This is a binary file and will not be displayed.

+88
test-local.sh
··· 1 + #!/bin/bash 2 + set -e 3 + 4 + echo "=== ATCR Local Testing Setup ===" 5 + echo 6 + 7 + # Colors for output 8 + GREEN='\033[0;32m' 9 + BLUE='\033[0;34m' 10 + NC='\033[0m' # No Color 11 + 12 + # Create directories 13 + echo -e "${BLUE}Creating storage directories...${NC}" 14 + sudo mkdir -p /var/lib/atcr/blobs 15 + sudo mkdir -p /var/lib/atcr/hold 16 + sudo mkdir -p /var/lib/atcr/auth 17 + sudo chown -R $USER:$USER /var/lib/atcr 18 + 19 + # Build binaries 20 + echo -e "${BLUE}Building binaries...${NC}" 21 + go build -o atcr-registry ./cmd/registry 22 + go build -o atcr-hold ./cmd/hold 23 + go build -o docker-credential-atcr ./cmd/credential-helper 24 + 25 + echo -e "${GREEN}✓ Binaries built${NC}" 26 + echo 27 + 28 + # Check if environment variables are set 29 + if [ -z "$ATPROTO_DID" ] || [ -z "$ATPROTO_ACCESS_TOKEN" ]; then 30 + echo -e "${BLUE}Setting up environment variables...${NC}" 31 + echo "Please enter your ATProto DID (e.g., did:plc:...):" 32 + read -r ATPROTO_DID 33 + echo "Please enter your ATProto access token:" 34 + read -rs ATPROTO_ACCESS_TOKEN 35 + echo 36 + export ATPROTO_DID 37 + export ATPROTO_ACCESS_TOKEN 38 + fi 39 + 40 + echo -e "${GREEN}✓ Environment configured${NC}" 41 + echo 42 + 43 + # Start services 44 + echo -e "${BLUE}Starting ATCR Registry (AppView)...${NC}" 45 + ./atcr-registry serve config/config.yml & 46 + REGISTRY_PID=$! 47 + echo "Registry PID: $REGISTRY_PID" 48 + 49 + echo -e "${BLUE}Starting Hold Service...${NC}" 50 + ./atcr-hold config/hold.yml & 51 + HOLD_PID=$! 52 + echo "Hold PID: $HOLD_PID" 53 + 54 + # Wait for services to start 55 + sleep 3 56 + 57 + echo 58 + echo -e "${GREEN}✓ Services started${NC}" 59 + echo 60 + echo "=== Services Running ===" 61 + echo "Registry (AppView): http://localhost:5000" 62 + echo "Hold Service: http://localhost:8080" 63 + echo 64 + echo "=== Test the setup ===" 65 + echo "1. Configure OAuth (optional):" 66 + echo " ./docker-credential-atcr configure" 67 + echo 68 + echo "2. Tag and push an image:" 69 + echo " docker tag alpine:latest localhost:5000/alice/alpine:test" 70 + echo " docker push localhost:5000/alice/alpine:test" 71 + echo 72 + echo "3. Pull the image:" 73 + echo " docker pull localhost:5000/alice/alpine:test" 74 + echo 75 + echo "=== Stop services ===" 76 + echo "Run: kill $REGISTRY_PID $HOLD_PID" 77 + echo 78 + echo "Or save PIDs to file:" 79 + echo "echo \"$REGISTRY_PID $HOLD_PID\" > .atcr-pids" 80 + echo "To stop later: kill \$(cat .atcr-pids)" 81 + echo "$REGISTRY_PID $HOLD_PID" > .atcr-pids 82 + 83 + # Keep script running 84 + echo 85 + echo "Press Ctrl+C to stop all services..." 86 + trap "kill $REGISTRY_PID $HOLD_PID 2>/dev/null; rm -f .atcr-pids; exit" INT TERM 87 + 88 + wait
+261
test-registry.sh
··· 1 + #!/bin/bash 2 + 3 + # ATCR Registry Test Script 4 + # Tests various registry operations with ATProto storage 5 + 6 + set -e # Exit on error 7 + 8 + # Configuration 9 + REGISTRY="127.0.0.1:5000" 10 + HANDLE="evan.jarrett.net" 11 + IMAGE_PREFIX="${REGISTRY}/${HANDLE}" 12 + 13 + # Colors for output 14 + GREEN='\033[0;32m' 15 + BLUE='\033[0;34m' 16 + YELLOW='\033[1;33m' 17 + RED='\033[0;31m' 18 + NC='\033[0m' # No Color 19 + 20 + # Helper functions 21 + log_test() { 22 + echo -e "\n${BLUE}========================================${NC}" 23 + echo -e "${BLUE}TEST: $1${NC}" 24 + echo -e "${BLUE}========================================${NC}" 25 + } 26 + 27 + log_success() { 28 + echo -e "${GREEN}✓ $1${NC}" 29 + } 30 + 31 + log_info() { 32 + echo -e "${YELLOW}ℹ $1${NC}" 33 + } 34 + 35 + log_error() { 36 + echo -e "${RED}✗ $1${NC}" 37 + } 38 + 39 + # Check if logged in 40 + check_login() { 41 + log_info "Checking Docker login status..." 42 + if ! docker login --help &>/dev/null; then 43 + log_error "Docker not available" 44 + exit 1 45 + fi 46 + } 47 + 48 + # Test 1: Multiple tags pointing to same manifest 49 + test_multiple_tags() { 50 + log_test "Multiple tags pointing to same manifest" 51 + 52 + log_info "Tagging debian:12-slim with multiple tags..." 53 + docker tag ${IMAGE_PREFIX}/debian:12-slim ${IMAGE_PREFIX}/debian:latest 54 + docker tag ${IMAGE_PREFIX}/debian:12-slim ${IMAGE_PREFIX}/debian:bookworm 55 + 56 + log_info "Pushing tags..." 57 + docker push ${IMAGE_PREFIX}/debian:latest 58 + docker push ${IMAGE_PREFIX}/debian:bookworm 59 + 60 + log_success "Multiple tags pushed successfully" 61 + log_info "All three tags should point to the same manifest digest" 62 + } 63 + 64 + # Test 2: Pull by digest 65 + test_pull_by_digest() { 66 + log_test "Pull by digest (immutable reference)" 67 + 68 + # Get the manifest digest 69 + log_info "Getting manifest digest..." 70 + DIGEST=$(docker inspect ${IMAGE_PREFIX}/debian:12-slim --format='{{index .RepoDigests 0}}' | cut -d'@' -f2) 71 + 72 + if [ -z "$DIGEST" ]; then 73 + log_error "Could not get digest, trying alternative method..." 74 + DIGEST="sha256:d6b33dcae4e2fea363cd63ed9fb43a91e71cc08a3ad3be87acaef4f53655e6a8" 75 + fi 76 + 77 + log_info "Digest: $DIGEST" 78 + 79 + log_info "Removing local image..." 80 + docker rmi ${IMAGE_PREFIX}/debian:12-slim || true 81 + 82 + log_info "Pulling by digest..." 83 + docker pull ${IMAGE_PREFIX}/debian@${DIGEST} 84 + 85 + log_success "Pull by digest successful" 86 + } 87 + 88 + # Test 3: Layer deduplication 89 + test_layer_deduplication() { 90 + log_test "Layer deduplication (shared layers)" 91 + 92 + log_info "Pulling debian:12 (larger variant)..." 93 + docker pull debian:12 94 + 95 + log_info "Tagging and pushing debian:12..." 96 + docker tag debian:12 ${IMAGE_PREFIX}/debian:12-full 97 + docker push ${IMAGE_PREFIX}/debian:12-full 98 + 99 + log_success "Image with shared layers pushed" 100 + log_info "Check logs - should see 'Layer already exists' or 'Mounted from'" 101 + } 102 + 103 + # Test 4: Multiple repositories 104 + test_multiple_repos() { 105 + log_test "Multiple repositories" 106 + 107 + log_info "Pulling alpine:latest..." 108 + docker pull alpine:latest 109 + 110 + log_info "Tagging alpine..." 111 + docker tag alpine:latest ${IMAGE_PREFIX}/alpine:latest 112 + docker tag alpine:latest ${IMAGE_PREFIX}/alpine:3 113 + 114 + log_info "Pushing alpine..." 115 + docker push ${IMAGE_PREFIX}/alpine:latest 116 + docker push ${IMAGE_PREFIX}/alpine:3 117 + 118 + log_success "Multiple repositories created" 119 + } 120 + 121 + # Test 5: Catalog API 122 + test_catalog_api() { 123 + log_test "Catalog API (list repositories)" 124 + 125 + log_info "Fetching repository catalog..." 126 + curl -s -u "${HANDLE}:${APP_PASSWORD}" \ 127 + http://${REGISTRY}/v2/_catalog | jq . 128 + 129 + log_success "Catalog API works" 130 + } 131 + 132 + # Test 6: List tags 133 + test_list_tags() { 134 + log_test "List tags for repository" 135 + 136 + log_info "Listing tags for debian repository..." 137 + curl -s -u "${HANDLE}:${APP_PASSWORD}" \ 138 + http://${REGISTRY}/v2/${HANDLE}/debian/tags/list | jq . 139 + 140 + log_info "Listing tags for alpine repository..." 141 + curl -s -u "${HANDLE}:${APP_PASSWORD}" \ 142 + http://${REGISTRY}/v2/${HANDLE}/alpine/tags/list | jq . 143 + 144 + log_success "Tag listing works" 145 + } 146 + 147 + # Test 7: Inspect manifest 148 + test_inspect_manifest() { 149 + log_test "Inspect manifest directly" 150 + 151 + log_info "Fetching manifest for debian:12-slim..." 152 + curl -s -u "${HANDLE}:${APP_PASSWORD}" \ 153 + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ 154 + http://${REGISTRY}/v2/${HANDLE}/debian/manifests/12-slim | jq . 155 + 156 + log_success "Manifest inspection works" 157 + } 158 + 159 + # Test 8: Re-pull after clearing cache 160 + test_repull() { 161 + log_test "Re-pull after clearing local cache" 162 + 163 + log_info "Removing all local ATCR images..." 164 + docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${REGISTRY}" | xargs -r docker rmi || true 165 + 166 + log_info "Pulling debian:latest from ATCR..." 167 + docker pull ${IMAGE_PREFIX}/debian:latest 168 + 169 + log_info "Pulling alpine:latest from ATCR..." 170 + docker pull ${IMAGE_PREFIX}/alpine:latest 171 + 172 + log_success "Re-pull from ATProto storage successful" 173 + 174 + log_info "Verifying images..." 175 + docker images | grep "${REGISTRY}" 176 + } 177 + 178 + # Test 9: Check ATProto records in logs 179 + test_check_logs() { 180 + log_test "Check ATProto records in logs" 181 + 182 + log_info "Recent manifest PUT operations:" 183 + docker logs atcr-registry 2>&1 | grep "Manifests()" | tail -5 || log_info "No manifest logs found" 184 + 185 + log_info "Recent tag operations:" 186 + docker logs atcr-registry 2>&1 | grep "debian_12-slim\|debian_latest\|alpine_latest" | tail -10 || log_info "No tag logs found" 187 + 188 + log_info "Using cached access token:" 189 + docker logs atcr-registry 2>&1 | grep "Using cached access token" | tail -3 || log_info "No token cache logs found" 190 + 191 + log_success "Log check complete" 192 + } 193 + 194 + # Test 10: HEAD request (check blob existence) 195 + test_head_request() { 196 + log_test "HEAD request (check blob existence)" 197 + 198 + BLOB_DIGEST="sha256:cde4222c36b887df35956e37385ad2fd5d32301ca9894363790a1430bf62f80f" 199 + 200 + log_info "Checking if blob exists: $BLOB_DIGEST" 201 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "${HANDLE}:${APP_PASSWORD}" \ 202 + -I http://${REGISTRY}/v2/${HANDLE}/debian/blobs/${BLOB_DIGEST}) 203 + 204 + if [ "$STATUS" = "200" ]; then 205 + log_success "Blob exists (HTTP $STATUS)" 206 + else 207 + log_error "Blob not found (HTTP $STATUS)" 208 + fi 209 + } 210 + 211 + # Main test runner 212 + main() { 213 + echo -e "${GREEN}" 214 + echo "╔═══════════════════════════════════════╗" 215 + echo "║ ATCR Registry Test Suite ║" 216 + echo "║ Testing ATProto + OCI Registry ║" 217 + echo "╚═══════════════════════════════════════╝" 218 + echo -e "${NC}" 219 + 220 + # Check for app password 221 + if [ -z "$APP_PASSWORD" ]; then 222 + log_error "APP_PASSWORD environment variable not set" 223 + echo "Usage: APP_PASSWORD='your-app-password' ./test-registry.sh" 224 + exit 1 225 + fi 226 + 227 + check_login 228 + 229 + # Run tests 230 + test_multiple_tags 231 + test_pull_by_digest 232 + test_layer_deduplication 233 + test_multiple_repos 234 + test_catalog_api 235 + test_list_tags 236 + test_inspect_manifest 237 + test_repull 238 + test_check_logs 239 + test_head_request 240 + 241 + echo -e "\n${GREEN}" 242 + echo "╔═══════════════════════════════════════╗" 243 + echo "║ All Tests Completed! ║" 244 + echo "╚═══════════════════════════════════════╝" 245 + echo -e "${NC}" 246 + 247 + log_info "Summary:" 248 + log_info "- Multiple tags pointing to same manifest ✓" 249 + log_info "- Pull by digest (immutable) ✓" 250 + log_info "- Layer deduplication ✓" 251 + log_info "- Multiple repositories ✓" 252 + log_info "- Catalog API ✓" 253 + log_info "- List tags ✓" 254 + log_info "- Manifest inspection ✓" 255 + log_info "- Re-pull from ATProto ✓" 256 + log_info "- ATProto record logging ✓" 257 + log_info "- Blob HEAD requests ✓" 258 + } 259 + 260 + # Run tests 261 + main "$@"