initial commit

This commit is contained in:
Evan Jarrett
2025-10-02 11:03:59 -05:00
commit 85d0bd2463
60 changed files with 8380 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:pkg.go.dev)",
"WebFetch(domain:distribution.github.io)",
"Write(*)",
"Edit(*)",
"Bash(find:*)",
"Bash(curl:*)",
"Bash(sed:*)",
"Bash(grep:*)",
"Bash(gofmt:*)",
"Bash(mkdir:*)",
"Bash(golangci-lint run:*)",
"Bash(go run:*)",
"Bash(go install:*)",
"Bash(go test:*)",
"Bash(go build:*)",
"Bash(go tool:*)",
"Bash(go vet:*)",
"Bash(go get:*)",
"Bash(go mod:*)",
"Bash(go get:*)"
],
"deny": [],
"ask": []
}
}
+63
View File
@@ -0,0 +1,63 @@
# ATCR Hold Service Configuration
# Copy this file to .env and fill in your values
# ==============================================================================
# Required Configuration
# ==============================================================================
# Hold service public URL (REQUIRED)
# The hostname becomes the hold name/record key
# Examples: https://hold1.atcr.io, http://127.0.0.1:8080
HOLD_PUBLIC_URL=http://127.0.0.1:8080
# ==============================================================================
# Storage Configuration
# ==============================================================================
# Storage driver type (s3, filesystem)
# Default: s3
STORAGE_DRIVER=s3
# For S3/Storj/Minio:
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
S3_BUCKET=atcr-blobs
# For Storj/Minio (optional - custom S3 endpoint):
# S3_ENDPOINT=https://gateway.storjshare.io
# For filesystem driver:
# STORAGE_DRIVER=filesystem
# STORAGE_ROOT_DIR=/var/lib/atcr/hold
# ==============================================================================
# Server Configuration
# ==============================================================================
# Server listen address (default: :8080)
# HOLD_SERVER_ADDR=:8080
# Allow public blob reads (pulls) without authentication
# Writes (pushes) always require crew membership via PDS
# Default: false
HOLD_PUBLIC=false
# ==============================================================================
# Registration (REQUIRED)
# ==============================================================================
# Your ATProto DID (REQUIRED for registration)
# Get your DID: https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social
#
# On first run with HOLD_CREW_OWNER set:
# 1. Hold service will print an OAuth URL to the logs
# 2. Visit the URL in your browser to authorize
# 3. Hold service creates hold + crew records in your PDS
# 4. Registration complete!
#
# On subsequent runs:
# - Hold service checks if already registered
# - Skips OAuth if records exist
#
HOLD_CREW_OWNER=did:plc:your-did-here
+21
View File
@@ -0,0 +1,21 @@
# Binaries
bin/
# Test artifacts
.atcr-pids
# OAuth tokens
.atcr/
# Environment configuration
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
+459
View File
@@ -0,0 +1,459 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
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.
## Build Commands
```bash
# Build all binaries
go build -o atcr-registry ./cmd/registry
go build -o atcr-hold ./cmd/hold
go build -o docker-credential-atcr ./cmd/credential-helper
# Run tests
go test ./...
# Run with race detector
go test -race ./...
# Update dependencies
go mod tidy
# Build Docker images
docker build -t atcr.io/registry:latest .
docker build -f Dockerfile.hold -t atcr.io/hold:latest .
# Or use docker-compose
docker-compose up -d
# Run locally (AppView)
export ATPROTO_DID=did:plc:your-did
export ATPROTO_ACCESS_TOKEN=your-token
./atcr-registry serve config/config.yml
# Run hold service (configure via env vars - see .env.example)
export HOLD_PUBLIC_URL=http://127.0.0.1:8080
export STORAGE_DRIVER=filesystem
export STORAGE_ROOT_DIR=/tmp/atcr-hold
export HOLD_CREW_OWNER=did:plc:your-did-here
./atcr-hold
# Check logs for OAuth URL, visit in browser to complete registration
```
## Architecture Overview
### Core Design
ATCR uses **distribution/distribution** as a library and extends it through middleware to route different types of content to different storage backends:
- **Manifests** → ATProto PDS (small JSON metadata, stored as `io.atcr.manifest` records)
- **Blobs/Layers** → S3 or user-deployed storage (large binary data)
- **Authentication** → ATProto OAuth with DPoP + Docker credential helpers
### Three-Component Architecture
1. **AppView** (`cmd/registry`) - OCI Distribution API server
- Resolves identities (handle/DID → PDS endpoint)
- Routes manifests to user's PDS
- Routes blobs to storage endpoint (default or BYOS)
- Validates OAuth tokens via PDS
- Issues registry JWTs
2. **Hold Service** (`cmd/hold`) - Optional BYOS component
- Lightweight HTTP server for presigned URLs
- Supports S3, Storj, Minio, filesystem, etc.
- Authorization based on PDS records (hold.public, crew records)
- Auto-registration via OAuth
- Configured entirely via environment variables
3. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth
- Implements Docker credential helper protocol
- ATProto OAuth flow with DPoP
- Token caching and refresh
- Exchanges OAuth token for registry JWT
### Request Flow
#### Push with Default Storage
```
1. Client: docker push atcr.io/alice/myapp:latest
2. HTTP Request → /v2/alice/myapp/manifests/latest
3. Registry Middleware (pkg/middleware/registry.go)
→ Resolves "alice" to DID and PDS endpoint
→ Queries alice's sailor profile for defaultHold
→ If not set, checks alice's io.atcr.hold records
→ Falls back to AppView's default_storage_endpoint
→ Stores DID/PDS/storage endpoint in context
4. Repository Middleware (pkg/middleware/repository.go)
→ Creates RoutingRepository
→ Returns ATProto ManifestStore for manifests
→ Returns ProxyBlobStore for blobs
5. Blob PUT → Resolved hold service (redirects to S3/storage)
6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdEndpoint)
```
#### Push with BYOS (Bring Your Own Storage)
```
1. Client: docker push atcr.io/alice/myapp:latest
2. Registry Middleware resolves alice → did:plc:alice123
3. Hold discovery via findStorageEndpoint():
a. Check alice's sailor profile for defaultHold
b. If not set, check alice's io.atcr.hold records
c. Fall back to AppView's default_storage_endpoint
4. Found: alice's profile has defaultHold = "https://alice-storage.fly.dev"
5. Routing Repository returns ProxyBlobStore(alice-storage.fly.dev)
6. ProxyBlobStore calls alice-storage.fly.dev for presigned URL
7. Storage service validates alice's DID, generates S3 presigned URL
8. Client redirected to upload blob directly to alice's S3/Storj
9. Manifest stored in alice's PDS with holdEndpoint = "https://alice-storage.fly.dev"
```
#### Pull Flow
```
1. Client: docker pull atcr.io/alice/myapp:latest
2. GET /v2/alice/myapp/manifests/latest
3. AppView fetches manifest from alice's PDS
4. Manifest contains holdEndpoint = "https://alice-storage.fly.dev"
5. Hold endpoint cached: (alice's DID, "myapp") → "https://alice-storage.fly.dev"
6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123
7. AppView checks cache, routes to hold from manifest (not re-discovered)
8. ProxyBlobStore calls alice-storage.fly.dev for presigned download URL
9. Client redirected to download blob directly from alice's S3
```
**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.
### Name Resolution
Names follow the pattern: `atcr.io/<identity>/<image>:<tag>`
Where `<identity>` can be:
- **Handle**: `alice.bsky.social` → resolved via .well-known/atproto-did
- **DID**: `did:plc:xyz123` → resolved via PLC directory
Resolution happens in `pkg/atproto/resolver.go`:
1. Handle → DID (via DNS/HTTPS)
2. DID → PDS endpoint (via DID document)
### Middleware System
ATCR uses two levels of middleware:
#### 1. Registry Middleware (`pkg/middleware/registry.go`)
- Wraps `distribution.Namespace`
- Intercepts `Repository(name)` calls
- Performs name resolution (alice → did:plc:xyz → pds.example.com)
- Queries PDS for `io.atcr.hold` records to find storage endpoint
- Stores resolved identity and storage endpoint in context
#### 2. Repository Middleware (`pkg/middleware/repository.go`)
- Wraps `distribution.Repository`
- Returns custom `Manifests()` and `Blobs()` implementations
- Routes manifests to ATProto, blobs to S3 or BYOS
### Authentication Architecture
#### ATProto OAuth with DPoP
ATCR implements the full ATProto OAuth specification with mandatory security features:
**Required Components:**
- **DPoP** (RFC 9449) - Cryptographic proof-of-possession for every request
- **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange
- **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception
**Key Components** (`pkg/auth/`):
1. **OAuth Client** (`oauth/client.go`) - Handles authorization flow with DPoP
2. **DPoP Transport** (`oauth/transport.go`) - HTTP RoundTripper that auto-adds DPoP headers
3. **Token Storage** (`oauth/storage.go`) - Persists tokens and DPoP key in `~/.atcr/oauth-token.json`
4. **Token Validator** (`atproto/validator.go`) - Validates tokens via PDS `getSession` endpoint
5. **Exchange Handler** (`exchange/handler.go`) - Exchanges OAuth tokens for registry JWTs
**Authentication Flow:**
```
1. User runs: docker-credential-atcr configure
2. Helper generates ECDSA P-256 DPoP key
3. Resolve handle → DID → PDS endpoint
4. Discover OAuth server metadata from PDS
5. PAR request with DPoP header → get request_uri
6. Open browser for user authorization
7. Exchange code for token with DPoP proof
8. Save: access token, refresh token, DPoP key, DID, handle
Later (docker push):
9. Docker calls credential helper
10. Helper loads token, refreshes if needed
11. Helper calls /auth/exchange with OAuth token + handle
12. AppView validates token via PDS getSession
13. AppView ensures sailor profile exists (creates with defaultHold if first login)
14. AppView issues registry JWT with validated DID
15. Helper returns JWT to Docker
```
**Security:**
- Tokens validated against authoritative source (user's PDS)
- No trust in client-provided identity information
- DPoP binds tokens to specific client key
- 15-minute token expiry for registry JWTs
### Key Components
#### ATProto Integration (`pkg/atproto/`)
**resolver.go**: DID and handle resolution
- `ResolveIdentity()`: alice → did:plc:xyz → pds.example.com
- `ResolveHandle()`: Uses .well-known/atproto-did
- `ResolvePDS()`: Parses DID document for PDS endpoint
**client.go**: ATProto PDS client
- `PutRecord()`: Store manifest as ATProto record
- `GetRecord()`: Retrieve manifest from PDS
- `DeleteRecord()`: Remove manifest
- Uses XRPC protocol (com.atproto.repo.*)
**lexicon.go**: ATProto record schemas
- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdEndpoint` field)
- `TagRecord`: Tag pointing to manifest digest
- `HoldRecord`: Storage hold definition (for BYOS)
- `HoldCrewRecord`: Hold crew membership/permissions
- `SailorProfileRecord`: User profile with `defaultHold` preference
- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold`, `io.atcr.hold.crew`, `io.atcr.sailor.profile`
**profile.go**: Sailor profile management
- `EnsureProfile()`: Creates profile with default hold on first authentication
- `GetProfile()`: Retrieves user's profile from PDS
- `UpdateProfile()`: Updates user's profile
**manifest_store.go**: Implements `distribution.ManifestService`
- Stores OCI manifests as ATProto records
- Digest-based addressing (sha256:abc123 → record key)
- Converts between OCI and ATProto formats
#### Storage Layer (`pkg/storage/`)
**routing_repository.go**: Routes content by type
- `Manifests()` → returns ATProto ManifestStore (caches instance for hold endpoint extraction)
- `Blobs()` → checks hold cache for pull, uses discovery for push
- Pull: Uses cached `holdEndpoint` from manifest (historical reference)
- Push: Uses discovery-based endpoint from `findStorageEndpoint()`
- Always returns ProxyBlobStore (routes to hold service)
- Implements `distribution.Repository` interface
**hold_cache.go**: In-memory hold endpoint cache
- Caches `(DID, repository) → holdEndpoint` for pull operations
- TTL: 10 minutes (covers typical pull operations)
- Cleanup: Background goroutine runs every 5 minutes
- **NOTE:** Simple in-memory cache for MVP. For production: use Redis or similar
- Prevents expensive ATProto lookups on every blob request
**s3_blob_store.go**: S3 blob storage wrapper
- Wraps distribution's built-in S3 driver
- Inherits full `distribution.BlobStore` interface
- Used for default shared storage
**proxy_blob_store.go**: External storage proxy
- Calls user's storage service for presigned URLs
- Issues HTTP redirects for blob uploads/downloads
- Implements full `distribution.BlobStore` interface
- Used when user has `io.atcr.hold` record
#### Hold Service (`cmd/hold/`)
Lightweight standalone service for BYOS (Bring Your Own Storage):
**Architecture:**
- Reuses distribution's storage driver factory
- Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem
- Authorization based on PDS records (hold.public field, crew records)
- Generates presigned URLs (15min expiry) or proxies uploads/downloads
**Endpoints:**
- `POST /get-presigned-url` - Get download URL for blob
- `POST /put-presigned-url` - Get upload URL for blob
- `GET /blobs/{digest}` - Proxy download (fallback if no presigned URL support)
- `PUT /blobs/{digest}` - Proxy upload (fallback)
- `POST /register` - Manual registration endpoint
- `GET /health` - Health check
**Configuration:** Environment variables (see `.env.example`)
- `HOLD_PUBLIC_URL` - Public URL of hold service (required)
- `STORAGE_DRIVER` - Storage driver type (s3, filesystem)
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials
- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration
- `HOLD_PUBLIC` - Allow public reads (default: false)
- `HOLD_CREW_OWNER` - DID for auto-registration (optional)
**Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc.
### ATProto Storage Model
Manifests are stored as records with this structure:
```json
{
"$type": "io.atcr.manifest",
"repository": "myapp",
"digest": "sha256:abc123...",
"holdEndpoint": "https://hold1.alice.com",
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": { "digest": "sha256:...", "size": 1234 },
"layers": [
{ "digest": "sha256:...", "size": 5678 }
],
"createdAt": "2025-09-30T..."
}
```
Record key = manifest digest (without algorithm prefix)
Collection = `io.atcr.manifest`
### Sailor Profile System
ATCR uses a "sailor profile" to manage user preferences for hold (storage) selection. The nautical theme reflects the architecture:
- **Sailors** = Registry users
- **Captains** = Hold owners
- **Crew** = Hold members with access
- **Holds** = Storage endpoints (BYOS)
**Profile Record** (`io.atcr.sailor.profile`):
```json
{
"$type": "io.atcr.sailor.profile",
"defaultHold": "https://hold1.alice.com",
"createdAt": "2025-10-02T...",
"updatedAt": "2025-10-02T..."
}
```
**Profile Management:**
- Created automatically on first authentication (OAuth or Basic Auth)
- If AppView has `default_storage_endpoint` configured, profile gets that as `defaultHold`
- Users can update their profile to change default hold (future: via UI)
- Setting `defaultHold` to null opts out of defaults (use own holds or AppView default)
**Hold Resolution Priority** (in `findStorageEndpoint()`):
1. **Profile's `defaultHold`** - User's explicit preference
2. **User's `io.atcr.hold` records** - User's own holds
3. **AppView's `default_storage_endpoint`** - Fallback default
This ensures:
- Users can join shared holds by setting their profile's `defaultHold`
- Users can opt out of defaults (set `defaultHold` to null)
- URL structure remains `atcr.io/<owner>/<image>` (ownership-based, not hold-based)
- Hold choice is transparent infrastructure (like choosing an S3 region)
### Key Design Decisions
1. **No fork of distribution**: Uses distribution as library, extends via middleware
2. **Hybrid storage**: Manifests in ATProto (small, federated), blobs in S3 or BYOS (cheap, scalable)
3. **Content addressing**: Manifests stored by digest, blobs deduplicated globally
4. **ATProto-native**: Manifests are first-class ATProto records, discoverable via AT Protocol
5. **OCI compliant**: Fully compatible with Docker/containerd/podman
6. **Account-agnostic AppView**: Server validates any user's token, queries their PDS for config
7. **BYOS architecture**: Users can deploy their own storage service, AppView just routes
8. **OAuth with DPoP**: Full ATProto OAuth implementation with mandatory DPoP proofs
9. **Sailor profile system**: User preferences for hold selection, transparent to image ownership
10. **Historical hold references**: Manifests store `holdEndpoint` for immutable blob location tracking
### Configuration
**AppView configuration** (`config/config.yml`):
- S3 bucket settings under `storage.s3`
- ATProto middleware under `middleware.repository`
- Name resolver under `middleware.registry`
- Default storage endpoint: `middleware.registry.options.default_storage_endpoint`
- Auth token signing keys and expiration
**Hold Service configuration** (environment variables):
- Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*`
- Authorization: Based on PDS records (`hold.public`, crew records)
- Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC`
- Auto-registration: `HOLD_CREW_OWNER` (optional)
**Credential Helper**:
- Token storage: `~/.atcr/oauth-token.json`
- Contains: access token, refresh token, DPoP key (PEM), DID, handle
Environment variables:
- `ATPROTO_DID`: DID for authentication with PDS (AppView only)
- `ATPROTO_ACCESS_TOKEN`: Access token for PDS operations (AppView only)
### Development Notes
- Middleware is registered via `init()` functions in `pkg/middleware/`
- Import `_ "atcr.io/pkg/middleware"` in main.go to register middleware
- Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"`
- Storage service reuses distribution's driver factory for multi-backend support
- OAuth client uses `authelia.com/client/oauth2` for PAR support
- DPoP proofs generated with `github.com/AxisCommunications/go-dpop` (auto-handles JWK)
- Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity
### Testing Strategy
When writing tests:
- Mock ATProto client for manifest operations
- Mock S3 driver for blob operations
- Test name resolution independently
- Integration tests require real PDS + S3
### Common Tasks
**Adding a new ATProto record type**:
1. Define schema in `pkg/atproto/lexicon.go`
2. Add collection constant (e.g., `MyCollection = "io.atcr.my-type"`)
3. Add constructor function (e.g., `NewMyRecord()`)
4. Update client methods if needed
**Modifying storage routing**:
1. Edit `pkg/storage/routing_repository.go`
2. Update `Blobs()` method to change routing logic
3. Consider context values: `storage.endpoint`, `atproto.did`
**Changing name resolution**:
1. Modify `pkg/atproto/resolver.go` for DID/handle resolution
2. Update `pkg/middleware/registry.go` if changing routing logic
3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records
**Implementing OAuth authentication**:
- AppView: `pkg/auth/exchange/handler.go` - validates tokens via PDS getSession
- Client: `pkg/auth/oauth/client.go` - OAuth + DPoP flow
- Helper: `cmd/credential-helper/` - Docker credential protocol
**Adding BYOS support for a user**:
1. User sets environment variables (storage credentials, public URL)
2. User runs hold service with `HOLD_CREW_OWNER` set - auto-registration via OAuth
3. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS
4. AppView automatically queries PDS and routes blobs to user's storage
5. No AppView changes needed - fully decentralized
**Supporting a new storage backend**:
1. Ensure driver is registered in `cmd/hold/main.go` imports
2. Distribution supports: S3, Azure, GCS, Swift, filesystem, OSS
3. For custom drivers: implement `storagedriver.StorageDriver` interface
4. Add case to `buildStorageConfig()` in `cmd/hold/main.go`
5. Update `.env.example` with new driver's env vars
## Important Context Values
When working with the codebase, these context values are used for routing:
- `atproto.did` - Resolved DID for the user (e.g., `did:plc:alice123`)
- `atproto.pds` - User's PDS endpoint (e.g., `https://bsky.social`)
- `atproto.identity` - Original identity string (handle or DID)
- `storage.endpoint` - Storage service URL (if user has `io.atcr.registry` record)
- `auth.did` - Authenticated DID from validated token
## Documentation References
- **BYOS Architecture**: See `docs/BYOS.md` for complete BYOS documentation
- **OAuth Implementation**: See `docs/OAUTH.md` for OAuth/DPoP flow details
- **ATProto Spec**: https://atproto.com/specs/oauth
- **OCI Distribution Spec**: https://github.com/opencontainers/distribution-spec
- **DPoP RFC**: https://datatracker.ietf.org/doc/html/rfc9449
- **PAR RFC**: https://datatracker.ietf.org/doc/html/rfc9126
- **PKCE RFC**: https://datatracker.ietf.org/doc/html/rfc7636
+48
View File
@@ -0,0 +1,48 @@
# Build stage
FROM golang:1.24-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git make
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o atcr-registry ./cmd/registry
# Runtime stage
FROM alpine:latest
# Install CA certificates for HTTPS
RUN apk --no-cache add ca-certificates
# Set working directory
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/atcr-registry .
# Copy default configuration
COPY config/config.yml /etc/atcr/config.yml
# Create directories for storage
RUN mkdir -p /var/lib/atcr/blobs /var/lib/atcr/auth
# Expose ports
EXPOSE 5000 5001
# Set environment variables
ENV ATCR_CONFIG=/etc/atcr/config.yml
# Run the registry
ENTRYPOINT ["/app/atcr-registry"]
CMD ["serve", "/etc/atcr/config.yml"]
+37
View File
@@ -0,0 +1,37 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the hold service
RUN CGO_ENABLED=0 GOOS=linux go build -o atcr-hold ./cmd/hold
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy binary from builder
COPY --from=builder /app/atcr-hold .
# Copy default config
COPY config/hold.yml /etc/atcr/hold.yml
# Create directories for storage
RUN mkdir -p /var/lib/atcr/hold
# Expose default port
EXPOSE 8080
# Run the hold service
ENTRYPOINT ["./atcr-hold"]
CMD ["/etc/atcr/hold.yml"]
+244
View File
@@ -0,0 +1,244 @@
# ATCR - ATProto Container Registry
A container registry that uses the AT Protocol (ATProto) for manifest storage and S3 for blob storage.
## Overview
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.
### Architecture
- **Manifests**: Stored as ATProto records in user PDSs (small JSON metadata)
- **Blobs/Layers**: Stored in S3 (large binary data)
- **Name Resolution**: Supports both ATProto handles and DIDs
- `atcr.io/alice.bsky.social/myimage:latest`
- `atcr.io/did:plc:xyz123/myimage:latest`
## Features
- OCI Distribution Spec compliant
- ATProto-native manifest storage
- S3 blob storage for container layers
- DID/handle resolution
- Decentralized manifest ownership
## Building
```bash
# Build all binaries locally
go build -o atcr-registry ./cmd/registry
go build -o atcr-hold ./cmd/hold
go build -o docker-credential-atcr ./cmd/credential-helper
# Build Docker images
docker build -t atcr.io/registry:latest .
docker build -f Dockerfile.hold -t atcr.io/hold:latest .
```
## Quick Start (Local Testing)
**Automated setup:**
```bash
# Run the test script (handles everything)
./test-local.sh
```
The script will:
1. Create necessary directories (`/var/lib/atcr/*`)
2. Build all binaries
3. Start registry and hold service
4. Show you how to test
**Manual setup:**
```bash
# 1. Create directories
sudo mkdir -p /var/lib/atcr/{blobs,hold,auth}
sudo chown -R $USER:$USER /var/lib/atcr
# 2. Build binaries
go build -o atcr-registry ./cmd/registry
go build -o atcr-hold ./cmd/hold
# 3. Configure environment
cp .env.example .env
# Edit .env - set ATPROTO_HANDLE and HOLD_PUBLIC_URL
export $(cat .env | xargs)
# 4. Start services
# Terminal 1:
./atcr-registry serve config/config.yml
# Terminal 2 (will prompt for OAuth):
./atcr-hold config/hold.yml
# Follow OAuth URL in logs to authorize
# 5. Test with Docker
docker tag alpine:latest localhost:5000/alice/alpine:test
docker push localhost:5000/alice/alpine:test
docker pull localhost:5000/alice/alpine:test
```
## Running
### Local Development
**Configure environment:**
```bash
# Copy and edit .env file
cp .env.example .env
# Edit .env with:
# - ATPROTO_HANDLE (your Bluesky handle)
# - HOLD_PUBLIC_URL (e.g., http://127.0.0.1:8080 or https://hold1.atcr.io)
# - HOLD_AUTO_REGISTER=true
# Load environment
export $(cat .env | xargs)
```
**AppView (Registry):**
```bash
./atcr-registry serve config/config.yml
```
**Hold (Storage Service):**
```bash
# Starts OAuth flow to register in your PDS
./atcr-hold config/hold.yml
# Follow the OAuth URL in the logs to authorize
```
### Docker
**Run with Docker Compose:**
```bash
docker-compose up -d
```
**Or run containers separately:**
**AppView (Registry):**
```bash
docker run -d \
--name atcr-registry \
-p 5000:5000 \
-e ATPROTO_DID=did:plc:your-did \
-e ATPROTO_ACCESS_TOKEN=your-access-token \
-e AWS_ACCESS_KEY_ID=your-aws-key \
-e AWS_SECRET_ACCESS_KEY=your-aws-secret \
-v $(pwd)/config/config.yml:/etc/atcr/config.yml \
atcr.io/registry:latest
```
**Hold (Storage Service):**
```bash
docker run -d \
--name atcr-hold \
-p 8080:8080 \
-e AWS_ACCESS_KEY_ID=your-aws-key \
-e AWS_SECRET_ACCESS_KEY=your-aws-secret \
-v $(pwd)/config/hold.yml:/etc/atcr/hold.yml \
atcr.io/hold:latest
```
### Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: atcr-registry
spec:
replicas: 3
selector:
matchLabels:
app: atcr-registry
template:
metadata:
labels:
app: atcr-registry
spec:
containers:
- name: registry
image: atcr.io/registry:latest
ports:
- containerPort: 5000
env:
- name: ATPROTO_DID
valueFrom:
secretKeyRef:
name: atcr-secrets
key: did
- name: ATPROTO_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: atcr-secrets
key: access-token
volumeMounts:
- name: config
mountPath: /etc/atcr
volumes:
- name: config
configMap:
name: atcr-config
```
## Configuration
See `config/config.yml` for full configuration options.
Key settings:
- **storage.s3**: S3 bucket configuration for blob storage
- **middleware.repository**: ATProto routing middleware
- **middleware.registry**: Name resolution middleware
## Usage
### Pushing an Image
```bash
# Tag your image
docker tag myapp:latest atcr.io/alice/myapp:latest
# Push to ATCR
docker push atcr.io/alice/myapp:latest
```
### Pulling an Image
```bash
# Pull from ATCR
docker pull atcr.io/alice/myapp:latest
```
## Development
### Project Structure
```
atcr.io/
├── cmd/registry/ # Main entrypoint
├── pkg/
│ ├── atproto/ # ATProto client and manifest store
│ ├── storage/ # S3 blob store and routing
│ ├── middleware/ # Registry and repository middleware
│ └── server/ # HTTP handlers
├── config/ # Configuration files
└── Dockerfile
```
### Testing
```bash
# Run tests
go test ./...
# Run with race detector
go test -race ./...
```
## License
MIT
## Contributing
Contributions welcome! Please open an issue or PR.
+101
View File
@@ -0,0 +1,101 @@
User approved Claude's plan:
⎿ Implement Sailor Profile & Hold Discovery System
Summary
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
routing while preserving image ownership semantics.
Changes Required
1. Create Sailor Profile Lexicon
File: lexicons/io/atcr/sailor/profile.json
- New record type: io.atcr.sailor.profile
- Fields: defaultHold (string, nullable), createdAt, updatedAt
2. Update Manifest Lexicon
File: lexicons/io/atcr/manifest.json
- Add holdEndpoint field (string, required)
- This is historical reference (immutable per manifest)
3. Update Go Types
File: pkg/atproto/lexicon.go
- Add SailorProfileCollection = "io.atcr.sailor.profile"
- Add SailorProfileRecord struct
- Add NewSailorProfileRecord() constructor
- Update ManifestRecord struct to include HoldEndpoint field
4. Create Profile Management
File: pkg/atproto/profile.go (new file)
- EnsureProfile(ctx, client, defaultHoldEndpoint) function
- Logic: check if profile exists, create with default if not
5. Update Auth Handlers
Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go
- Call EnsureProfile() after token validation
- Use authenticated client (has write access to user's PDS)
- Pass AppView's default_hold_endpoint config
6. Update Hold Resolution
File: pkg/middleware/registry.go
- Update findStorageEndpoint() priority:
a. Check io.atcr.sailor.profile.defaultHold
b. If null (opted out): check user's io.atcr.hold, then AppView default
c. If no profile: check user's io.atcr.hold, then AppView default
7. Store Hold in Manifest
File: pkg/atproto/manifest_store.go
- When creating manifest, include resolved holdEndpoint
- Pass hold endpoint through context or parameter
8. Update Pull to Use Manifest Hold
File: pkg/atproto/manifest_store.go and pkg/storage/routing_repository.go
- On pull, extract holdEndpoint from manifest
- Route blob requests to that hold (not via discovery)
9. Update Documentation
Files: CLAUDE.md, docs/BYOS.md, .env.example
- Document sailor profile concept
- Explain hold resolution priority
- Update examples for shared holds
- Document how crew members configure profile
Benefits
- ✅ URL structure remains atcr.io/<owner>/<image> (ownership clear)
- ✅ Hold is transparent infrastructure (like S3 region)
- ✅ Supports personal, shared, and public holds
- ✅ Historical reference in manifest (pull works even if defaults change)
- ✅ User can opt-out (set defaultHold to null)
- ✅ Future: UI for self-service profile management
Progress Summary
✅ Completed:
1. Created io.atcr.sailor.profile lexicon
2. Updated io.atcr.manifest lexicon with holdEndpoint field
3. Updated Go types in pkg/atproto/lexicon.go
4. Created profile management in pkg/atproto/profile.go
5. Updated /auth/exchange handler to manage profile
⏳ In Progress:
- Need to update /auth/token handler similarly (add defaultHoldEndpoint parameter and profile management)
- Fix compilation error in extractDefaultHoldEndpoint() - should use configuration.Middleware type not interface{}
🔜 Remaining:
- Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
- Update manifest_store.go to include holdEndpoint when creating manifests
- Update pull flow to use manifest holdEndpoint
- Update documentation
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
View File
@@ -0,0 +1,460 @@
ATProto Container Registry (atcr.io) Implementation Plan
Project Structure
/home/data/atcr.io/
├── cmd/
│ └── registry/
│ └── main.go # Entrypoint that imports distribution
├── pkg/
│ ├── atproto/
│ │ ├── client.go # ATProto client wrapper (using indigo)
│ │ ├── manifest_store.go # Implements distribution.ManifestService
│ │ ├── resolver.go # DID/handle resolution (alice → did:plc:...)
│ │ └── lexicon.go # ATProto record schemas for manifests
│ ├── storage/
│ │ ├── s3_blob_store.go # Wraps distribution's S3 driver for blobs
│ │ └── routing_repository.go # Routes manifests→ATProto, blobs→S3
│ ├── middleware/
│ │ ├── repository.go # Repository middleware registration
│ │ └── registry.go # Registry middleware for name resolution
│ └── server/
│ └── handler.go # HTTP wrapper for custom name resolution
├── config/
│ └── config.yml # Registry configuration
├── go.mod
├── go.sum
├── Dockerfile
├── README.md
└── CLAUDE.md # Updated with architecture docs
Implementation Steps
Phase 1: Project Setup
1. Initialize Go module with github.com/distribution/distribution/v3 and github.com/bluesky-social/indigo
2. Create basic project structure
3. Set up cmd/registry/main.go that imports distribution and registers middleware
Phase 2: Core ATProto Integration
4. Implement DID/handle resolver (pkg/atproto/resolver.go)
- Resolve handles to DIDs (alice.bsky.social → did:plc:xyz)
- Discover PDS endpoints from DID documents
5. Create ATProto client wrapper (pkg/atproto/client.go)
- Wrap indigo SDK for manifest storage
- Handle authentication with PDS
6. Design ATProto lexicon for manifest records (pkg/atproto/lexicon.go)
- Define schema for storing OCI manifests as ATProto records
Phase 3: Storage Layer
7. Implement ATProto manifest store (pkg/atproto/manifest_store.go)
- Implements distribution.ManifestService
- Stores/retrieves manifests from PDS
8. Implement S3 blob store wrapper (pkg/storage/s3_blob_store.go)
- Wraps distribution's built-in S3 driver
9. Create routing repository (pkg/storage/routing_repository.go)
- Returns ATProto store for Manifests()
- Returns S3 store for Blobs()
Phase 4: Middleware Layer
10. Implement repository middleware (pkg/middleware/repository.go)
- Registers routing repository
- Configurable via YAML
11. Implement registry/namespace middleware (pkg/middleware/registry.go)
- Intercepts Repository(name) calls
- Performs name resolution before repository creation
Phase 5: HTTP Layer (if needed)
12. Create custom HTTP handler (pkg/server/handler.go)
- Wraps distribution's HTTP handlers
- Performs early name resolution: atcr.io/alice/myimage → resolve alice
- Delegates to distribution handlers
Phase 6: Configuration & Deployment
13. Create registry configuration (config/config.yml)
14. Create Dockerfile for building atcr-registry binary
16. Write README.md with usage instructions
Phase 7: Documentation
17. Update CLAUDE.md with:
- Architecture overview (ATProto for manifests, S3 for blobs)
- Build/run/test commands
- How name resolution works
- Middleware registration flow
- Key design decisions
Key Technical Decisions
ATProto Storage Design:
- Manifests stored as ATProto records in user's PDS
- Each image manifest is a record in a io.atcr.manifest collection
- Record key = image digest (sha256:...)
Name Resolution:
- atcr.io/alice/myimage → resolve alice to DID → discover PDS
- Support both handles (alice.bsky.social) and DIDs (did:plc:xyz)
Blob Storage:
- All layers/blobs in S3 (content-addressable by digest)
- Manifests reference S3-stored blobs by digest
- S3 provides cheap, durable blob storage
Middleware Strategy:
- Repository middleware for storage routing
- Registry middleware (or HTTP wrapper) for name resolution
- No fork of distribution core needed
Perfect. To match Docker Hub/ghcr.io/gcr.io, here's what we need:
● Implementation Plan (Drop-in replacement for Docker Hub/ghcr.io)
Flow 1: Standard Token Auth (Like Docker Hub) - PRIMARY
# User experience
docker login atcr.io -u alice.bsky.social -p <atproto-app-password>
docker push atcr.io/alice/myapp:latest
# Behind the scenes
1. docker login stores credentials locally
2. docker push → Registry returns 401 with WWW-Authenticate: Bearer realm="https://atcr.io/auth/token"...
3. Docker auto-calls /auth/token with Basic auth (alice.bsky.social:app-password)
4. Auth service validates against ATProto createSession
5. Returns JWT token with scope for alice/myapp
6. Docker uses JWT for manifest/blob uploads
7. Registry validates JWT signature and scope
Components:
- /auth/token endpoint (standalone service or embedded)
- ATProto session validator (username/password → validate via PDS)
- JWT issuer/signer
- JWT validator middleware for registry
Flow 2: Credential Helper (Like gcr.io) - ADVANCED
# User experience
docker-credential-atcr configure
# Opens browser for ATProto OAuth
docker push atcr.io/alice/myapp:latest
# No manual login needed
# Behind the scenes
1. Helper does OAuth flow → gets ATProto access token
2. Caches token securely
3. When Docker needs credentials, calls helper via stdin/stdout
4. Helper exchanges ATProto token for registry JWT at /auth/exchange
5. Returns JWT to Docker
6. Docker uses JWT for requests
Components:
- cmd/credential-helper/main.go - Standalone binary
- ATProto OAuth client
- Token exchange endpoint (/auth/exchange)
- Secure token cache
Architecture:
pkg/auth/
├── token/
│ ├── service.go # HTTP handler for /auth/token
│ ├── claims.go # JWT claims structure
│ ├── issuer.go # Signs JWTs
│ └── validator.go # Validates JWTs (middleware for registry)
├── atproto/
│ ├── session.go # Validates username/password via ATProto
│ └── oauth.go # OAuth flow implementation
├── exchange/
│ └── handler.go # /auth/exchange endpoint (OAuth → JWT)
└── scope.go # Parses/validates Docker scopes
cmd/
├── registry/main.go # Registry server (existing)
├── auth/main.go # Standalone auth service (optional)
└── credential-helper/
└── main.go # docker-credential-atcr binary
Config:
auth:
token:
realm: https://atcr.io/auth/token # Where Docker gets tokens
service: atcr.io
issuer: atcr.io
rootcertbundle: /etc/atcr/token-signing.crt
privatekey: /etc/atcr/token-signing.pem
expiration: 300
atproto:
# Used by auth service to validate credentials
pds_endpoint: https://bsky.social
client_id: atcr-registry
oauth_redirect: http://localhost:8888/callback
ATProto OAuth Implementation Plan
Architecture
Dependencies:
- authelia.com/client/oauth2 - OAuth + PAR support
- github.com/AxisCommunications/go-dpop - DPoP proof generation (handles JWK automatically)
- github.com/golang-jwt/jwt/v5 - JWT library (transitive via go-dpop)
- Our existing pkg/atproto/resolver.go - ATProto identity resolution
Implementation Components
1. OAuth Client (pkg/auth/oauth/client.go) - ~100 lines
type Client struct {
config *oauth2.Config
dpopKey *ecdsa.PrivateKey
resolver *atproto.Resolver
clientID string // URL to our metadata document
redirectURI string
dpopNonce string // Server-provided nonce
}
func NewClient(clientID, redirectURI string) (*Client, error)
func (c *Client) AuthorizeURL(handle string, scopes []string) (string, error)
func (c *Client) Exchange(code string) (*Token, error)
func (c *Client) addDPoPHeader(req *http.Request, method, url string) error
Flow:
1. Generate ECDSA P-256 key for DPoP
2. Discover authorization server from handle/DID
3. Use authelia's PushedAuth() for PAR with DPoP header
4. Exchange code for token with DPoP proof
2. Authorization Server Discovery (pkg/auth/oauth/discovery.go) - ~30 lines
type AuthServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
}
func DiscoverAuthServer(pdsEndpoint string) (*AuthServerMetadata, error)
Implementation:
- GET {pds}/.well-known/oauth-authorization-server
- Parse JSON metadata
- Validate required endpoints exist
3. Client Metadata Server (pkg/auth/oauth/metadata.go) - ~40 lines
type ClientMetadata struct {
ClientID string `json:"client_id"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
Scope string `json:"scope"`
DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
}
func ServeMetadata(clientID string, redirectURIs []string) http.Handler
Serves: https://atcr.io/oauth/client-metadata.json
4. Token Storage (pkg/auth/oauth/storage.go) - ~50 lines
type TokenStore struct {
AccessToken string
RefreshToken string
DPoPKey *ecdsa.PrivateKey // Persist for refresh
ExpiresAt time.Time
}
func (s *TokenStore) Save(path string) error
func LoadTokenStore(path string) (*TokenStore, error)
Storage location: ~/.atcr/oauth-tokens.json
5. Credential Helper (cmd/credential-helper/main.go) - ~80 lines
// Docker credential helper protocol
// Reads JSON from stdin, writes to stdout
func main() {
if len(os.Args) < 2 {
os.Exit(1)
}
switch os.Args[1] {
case "get":
handleGet() // Return credentials for registry
case "store":
handleStore() // Store credentials
case "erase":
handleErase() // Remove credentials
}
}
func handleGet() {
var request struct {
ServerURL string `json:"ServerURL"`
}
json.NewDecoder(os.Stdin).Decode(&request)
// Load token from storage
// Exchange for registry JWT if needed
// Output: {"Username": "oauth2", "Secret": "<jwt>"}
}
6. OAuth Flow (cmd/credential-helper/oauth.go) - ~60 lines
func RunOAuthFlow(handle string) (*TokenStore, error) {
// 1. Start local HTTP server on :8888
// 2. Open browser to authorization URL
// 3. Wait for callback with code
// 4. Exchange code for token
// 5. Save token store
// 6. Return token
}
func startCallbackServer() (chan string, *http.Server)
Complete Flow Example
User runs:
docker-credential-atcr configure
What happens:
1. Generate DPoP key (client.go)
dpopKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
2. Resolve handle → DID → PDS (using our resolver)
did, pds, _ := resolver.ResolveIdentity(ctx, "alice.bsky.social")
3. Discover auth server (discovery.go)
metadata, _ := DiscoverAuthServer(pds)
// Returns: PAR endpoint, token endpoint, etc.
4. Create PAR request with DPoP (client.go + go-dpop)
// Generate DPoP proof for PAR endpoint
claims := &dpop.ProofTokenClaims{
Method: dpop.POST,
URL: metadata.PushedAuthorizationRequestEndpoint,
RegisteredClaims: &jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
// Use authelia for PAR
config := &oauth2.Config{
ClientID: "https://atcr.io/oauth/client-metadata.json",
Endpoint: oauth2.Endpoint{
AuthURL: metadata.AuthorizationEndpoint,
TokenURL: metadata.TokenEndpoint,
},
}
// Create custom HTTP client that adds DPoP header
client := &http.Client{
Transport: &dpopTransport{
base: http.DefaultTransport,
dpopKey: dpopKey,
},
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
// PAR request (authelia handles this)
authURL, parResp, _ := config.PushedAuth(ctx, state,
oauth2.SetAuthURLParam("code_challenge", pkceChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
5. Open browser, get code (oauth.go)
exec.Command("open", authURL).Run()
// User authorizes
// Callback: http://localhost:8888?code=xyz&state=abc
6. Exchange code for token with DPoP (client.go + go-dpop)
// Generate DPoP proof for token endpoint
claims := &dpop.ProofTokenClaims{
Method: dpop.POST,
URL: metadata.TokenEndpoint,
RegisteredClaims: &jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
// Exchange (with DPoP header added by our transport)
token, _ := config.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", pkceVerifier),
)
7. Save token + DPoP key (storage.go)
store := &TokenStore{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
DPoPKey: dpopKey,
ExpiresAt: token.Expiry,
}
store.Save("~/.atcr/oauth-tokens.json")
Later, when docker push happens:
docker push atcr.io/alice/myapp:latest
1. Docker calls credential helper: docker-credential-atcr get
2. Helper loads stored token
3. Helper calls /auth/exchange with OAuth token → gets registry JWT
4. Returns JWT to Docker
5. Docker uses JWT for push
Directory Structure
pkg/auth/oauth/
├── client.go # OAuth client with DPoP integration
├── discovery.go # Authorization server discovery
├── metadata.go # Client metadata server
├── storage.go # Token persistence
└── transport.go # HTTP transport that adds DPoP headers
cmd/credential-helper/
├── main.go # Docker credential helper protocol
├── oauth.go # OAuth flow (browser, callback)
└── config.go # Configuration
go.mod additions:
authelia.com/client/oauth2 v0.25.0
github.com/AxisCommunications/go-dpop v1.1.2
Unified Model
Every hold service requires HOLD_CREW_OWNER:
- Owner's PDS has the io.atcr.hold record
- Owner's PDS has all io.atcr.hold.crew records
- Authorization is always governed by PDS records
For "public" hold (like Tangled's public knot):
- Owner creates hold with public: true
- Anyone can push/pull without being crew
- Owner can add crew records for special privileges/tracking if desired
Config has emergency override:
auth:
# Emergency freeze: ignore public setting, restrict to crew only
# Use this to stop abuse without changing PDS records
freeze: false
Authorization logic:
1. Check freeze in config → if true, skip to crew check
2. Query owner's PDS for io.atcr.hold record
3. If public: true → allow all operations (unless frozen)
4. If public: false OR frozen → query io.atcr.hold.crew records, check membership
Remove from config:
- allow_all (replaced by public: true in PDS)
- allowed_dids (replaced by crew records in PDS)
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
View File
@@ -0,0 +1,334 @@
# Local Testing Guide
## Quick Start
```bash
./test-local.sh
```
This automated script will:
1. Create storage directories
2. Build all binaries
3. Start both services
4. Show test commands
## Manual Testing Steps
### 1. Setup Directories
```bash
sudo mkdir -p /var/lib/atcr/{blobs,hold,auth}
sudo chown -R $USER:$USER /var/lib/atcr
```
### 2. Build Binaries
```bash
go build -o atcr-registry ./cmd/registry
go build -o atcr-hold ./cmd/hold
go build -o docker-credential-atcr ./cmd/credential-helper
```
### 3. Configure Environment
Create a `.env` file in the project root:
```bash
cp .env.example .env
```
Edit `.env` with your credentials:
```env
# Your ATProto handle
ATPROTO_HANDLE=your-handle.bsky.social
# Hold service public URL (hostname becomes the hold name)
HOLD_PUBLIC_URL=http://127.0.0.1:8080
# Enable OAuth registration on startup
HOLD_AUTO_REGISTER=true
```
**Notes:**
- Use your Bluesky handle (e.g., `alice.bsky.social`)
- For localhost, use `127.0.0.1` instead of `localhost` for OAuth
- The hostname from the URL becomes the hold name (e.g., `127.0.0.1` or `hold1.atcr.io`)
**Load environment:**
```bash
export $(cat .env | xargs)
```
### 4. Start Services
**Terminal 1 - Registry:**
```bash
./atcr-registry serve config/config.yml
```
**Terminal 2 - Hold:**
```bash
./atcr-hold config/hold.yml
```
### 5. Start Services and OAuth Registration
**Terminal 1 - Registry:**
```bash
./atcr-registry serve config/config.yml
```
**Terminal 2 - Hold (OAuth registration):**
```bash
./atcr-hold config/hold.yml
```
The hold service will start an OAuth flow. You'll see output like:
```
================================================================================
OAUTH AUTHORIZATION REQUIRED
================================================================================
Please visit this URL to authorize the hold service:
https://bsky.social/oauth/authorize?...
Waiting for authorization...
================================================================================
```
**Steps:**
1. Copy the OAuth URL from the logs
2. Open it in your browser
3. Sign in to Bluesky and authorize
4. The callback will complete automatically
5. Hold service registers in your PDS
After successful OAuth, you'll see:
```
✓ Created hold record: at://did:plc:.../io.atcr.hold/127.0.0.1
✓ Created crew record: at://did:plc:.../io.atcr.hold.crew/127.0.0.1-did:plc:...
================================================================================
REGISTRATION COMPLETE
================================================================================
Hold service is now registered and ready to use!
```
This creates two records in your PDS:
- `io.atcr.hold` - Defines the storage endpoint URL
- `io.atcr.hold.crew` - Grants you admin access
### 6. Test Docker Push/Pull
**Test 1: Basic Push**
```bash
# Tag an image
docker tag alpine:latest localhost:5000/alice/alpine:test
# Push to local registry
docker push localhost:5000/alice/alpine:test
```
**Test 2: Pull**
```bash
# Remove local image
docker rmi localhost:5000/alice/alpine:test
# Pull from registry
docker pull localhost:5000/alice/alpine:test
```
**Test 3: Verify Storage**
```bash
# Check manifests were stored in ATProto
# (Check your PDS for io.atcr.manifest records)
# Check blobs were stored locally
ls -lh /var/lib/atcr/blobs/docker/registry/v2/
```
## OAuth Testing (Optional)
### Setup Credential Helper
```bash
# Configure OAuth
./docker-credential-atcr configure
# Follow the browser flow to authorize
# Verify token was saved
ls -la ~/.atcr/oauth-token.json
```
### Configure Docker to Use Helper
Edit `~/.docker/config.json`:
```json
{
"credHelpers": {
"localhost:5000": "atcr"
}
}
```
### Test with OAuth
```bash
# Push should now use OAuth automatically
docker push localhost:5000/alice/myapp:latest
```
## Troubleshooting
### Registry won't start
**Error:** `failed to create storage driver`
```bash
# Check directory permissions
ls -ld /var/lib/atcr/blobs
# Should be owned by your user
# Fix permissions
sudo chown -R $USER:$USER /var/lib/atcr
```
**Error:** `address already in use`
```bash
# Check what's using port 5000
lsof -i :5000
# Kill existing process
kill $(lsof -t -i :5000)
```
### Hold service won't start
**Error:** `failed to create storage driver`
```bash
# Check hold directory
ls -ld /var/lib/atcr/hold
sudo chown -R $USER:$USER /var/lib/atcr/hold
```
**Error:** `address already in use`
```bash
# Check port 8080
lsof -i :8080
kill $(lsof -t -i :8080)
```
### Docker push fails
**Error:** `unauthorized: authentication required`
- Check `ATPROTO_DID` and `ATPROTO_ACCESS_TOKEN` are set
- Verify token is valid (not expired)
- Check registry logs for auth errors
**Error:** `denied: requested access to the resource is denied`
- Check the identity in the image name matches your DID
- Example: If your handle is `alice.bsky.social`, use:
```bash
docker push localhost:5000/alice/myapp:test
# NOT localhost:5000/bob/myapp:test
```
**Error:** `failed to resolve identity`
- Check internet connection (needs to resolve DIDs)
- Verify handle is correct
- Try using DID directly instead of handle
### OAuth issues
**Error:** `Failed to exchange token`
- Ensure registry is running and accessible
- Check `/auth/exchange` endpoint is responding
- Verify OAuth token hasn't expired
**Error:** `Token validation failed`
- Token might be expired
- Run `./docker-credential-atcr configure` again
- Check PDS is accessible
## Verifying the Flow
### Check Registry is Running
```bash
curl http://localhost:5000/v2/
# Should return: {}
```
### Check Hold is Running
```bash
curl http://localhost:8080/health
# Should return: {"status":"ok"}
```
### Check Auth Endpoint
```bash
curl -v http://localhost:5000/v2/
# Should return 401 with WWW-Authenticate header
```
### Inspect Stored Data
**Manifests (in ATProto):**
- Check your PDS web interface
- Look for `io.atcr.manifest` collection records
**Blobs (local filesystem):**
```bash
# List blobs
find /var/lib/atcr/blobs -type f
# Check blob content (should be binary)
ls -lh /var/lib/atcr/blobs/docker/registry/v2/blobs/sha256/
```
## Clean Up
### Stop Services
```bash
# If using test script
kill $(cat .atcr-pids)
# Or manually
pkill atcr-registry
pkill atcr-hold
```
### Remove Test Data
```bash
# Remove all stored data
sudo rm -rf /var/lib/atcr/*
# Remove OAuth tokens
rm -rf ~/.atcr/
```
### Reset Docker Config
```bash
# Remove credential helper config
# Edit ~/.docker/config.json and remove "credHelpers" section
```
## Next Steps
Once local testing works:
1. **Deploy to production:**
- Use S3/Storj for blob storage
- Deploy registry and hold to separate hosts
- Configure DNS for `atcr.io`
2. **Enable BYOS:**
- Users create `io.atcr.hold` records
- Deploy their own hold service
- AppView automatically routes to their storage
3. **Add monitoring:**
- Registry metrics
- Hold service metrics
- Storage usage tracking
Executable
BIN
View File
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// Docker credential helper protocol
// https://github.com/docker/docker-credential-helpers
// Credentials represents docker credentials
type Credentials struct {
ServerURL string `json:"ServerURL,omitempty"`
Username string `json:"Username,omitempty"`
Secret string `json:"Secret,omitempty"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure>\n")
os.Exit(1)
}
command := os.Args[1]
switch command {
case "get":
handleGet()
case "store":
handleStore()
case "erase":
handleErase()
case "configure":
handleConfigure()
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
os.Exit(1)
}
}
// handleGet retrieves credentials for the given server
func handleGet() {
var request Credentials
if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
os.Exit(1)
}
// Load token from storage
tokenPath := getTokenPath()
token, err := loadToken(tokenPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err)
os.Exit(1)
}
// Check if token is expired and refresh if needed
if token.IsExpired && token.RefreshToken != "" {
if err := refreshToken(token); err != nil {
fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err)
os.Exit(1)
}
}
// Exchange ATProto token for registry JWT
registryJWT, err := exchangeForRegistryToken(token.AccessToken, request.ServerURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err)
os.Exit(1)
}
// Return credentials
creds := Credentials{
ServerURL: request.ServerURL,
Username: "oauth2",
Secret: registryJWT,
}
if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding response: %v\n", err)
os.Exit(1)
}
}
// handleStore stores credentials (Docker calls this after login)
func handleStore() {
var creds Credentials
if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding credentials: %v\n", err)
os.Exit(1)
}
// For OAuth flow, we don't actually store credentials from docker login
// The credentials are managed through the OAuth flow
// This is a no-op for us
}
// handleErase removes stored credentials
func handleErase() {
var request Credentials
if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
os.Exit(1)
}
// Remove token file
tokenPath := getTokenPath()
if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error removing token: %v\n", err)
os.Exit(1)
}
}
// handleConfigure runs the OAuth flow to get initial credentials
func handleConfigure() {
fmt.Println("ATCR Credential Helper Configuration")
fmt.Println("=====================================")
fmt.Println()
// Ask for handle
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
var handle string
if _, err := fmt.Scanln(&handle); err != nil {
fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err)
os.Exit(1)
}
// Run OAuth flow
fmt.Println("\nStarting OAuth flow...")
token, err := runOAuthFlow(handle)
if err != nil {
fmt.Fprintf(os.Stderr, "Error during OAuth flow: %v\n", err)
os.Exit(1)
}
// Save token
tokenPath := getTokenPath()
if err := token.Save(tokenPath); err != nil {
fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
os.Exit(1)
}
fmt.Println("\nConfiguration complete!")
fmt.Println("You can now use docker push/pull with atcr.io")
}
// getTokenPath returns the path to the token file
func getTokenPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
return filepath.Join(homeDir, ".atcr", "oauth-token.json")
}
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"context"
"fmt"
"net/http"
"os/exec"
"runtime"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
const (
clientID = "http://localhost:8888/client-metadata.json"
redirectURI = "http://localhost:8888/callback"
)
// runOAuthFlow executes the OAuth flow with browser
func runOAuthFlow(handle string) (*oauth.TokenStore, error) {
// Create OAuth client
client, err := oauth.NewClient(clientID, redirectURI)
if err != nil {
return nil, fmt.Errorf("failed to create OAuth client: %w", err)
}
// Initialize for the given handle
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.InitializeForHandle(ctx, handle); err != nil {
return nil, fmt.Errorf("failed to initialize client: %w", err)
}
// Start local callback server
codeChan := make(chan string, 1)
errChan := make(chan error, 1)
server := startCallbackServer(codeChan, errChan)
defer server.Shutdown(context.Background())
// Also serve client metadata
http.HandleFunc("/client-metadata.json", oauth.ServeMetadata(
oauth.NewClientMetadata(clientID, []string{redirectURI}),
))
// Generate authorization URL with PKCE
state := generateState()
authURL, codeVerifier, err := client.AuthorizeURL(state)
if err != nil {
return nil, fmt.Errorf("failed to generate auth URL: %w", err)
}
// Open browser
fmt.Printf("Opening browser to: %s\n", authURL)
if err := openBrowser(authURL); err != nil {
fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL)
}
// Wait for callback
var code string
select {
case code = <-codeChan:
fmt.Println("Authorization successful!")
case err := <-errChan:
return nil, fmt.Errorf("authorization failed: %w", err)
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authorization timed out")
}
// Exchange code for token
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
token, err := client.Exchange(ctx, code, codeVerifier)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
// Resolve handle to get DID
resolver := atproto.NewResolver()
did, _, err := resolver.ResolveIdentity(context.Background(), handle)
if err != nil {
return nil, fmt.Errorf("failed to resolve DID: %w", err)
}
// Create token store
store := &oauth.TokenStore{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
TokenType: token.TokenType,
ExpiresAt: token.Expiry,
Handle: handle,
DID: did,
}
// Save DPoP key
if err := store.SetDPoPKey(client.DPoPKey()); err != nil {
return nil, fmt.Errorf("failed to save DPoP key: %w", err)
}
return store, nil
}
// startCallbackServer starts a local HTTP server to receive the OAuth callback
func startCallbackServer(codeChan chan string, errChan chan error) *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
errorParam := r.URL.Query().Get("error")
if errorParam != "" {
errChan <- fmt.Errorf("OAuth error: %s (%s)",
errorParam,
r.URL.Query().Get("error_description"))
http.Error(w, "Authorization failed", http.StatusBadRequest)
return
}
if code == "" {
errChan <- fmt.Errorf("no code in callback")
http.Error(w, "No code provided", http.StatusBadRequest)
return
}
codeChan <- code
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `
<html>
<head><title>ATCR Authorization</title></head>
<body>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>
`)
})
server := &http.Server{
Addr: ":8888",
Handler: mux,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- fmt.Errorf("callback server error: %w", err)
}
}()
return server
}
// openBrowser opens the default browser to the given URL
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("unsupported platform")
}
return cmd.Start()
}
// generateState generates a random state parameter
func generateState() string {
// Use the same UUID generation as we do elsewhere
return fmt.Sprintf("state_%d", time.Now().UnixNano())
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"atcr.io/pkg/auth/oauth"
)
// tokenData holds the token information
type tokenData struct {
*oauth.TokenStore
IsExpired bool
}
// loadToken loads the token from disk
func loadToken(path string) (*tokenData, error) {
store, err := oauth.LoadTokenStore(path)
if err != nil {
return nil, err
}
return &tokenData{
TokenStore: store,
IsExpired: store.IsExpired(),
}, nil
}
// refreshToken refreshes an expired token
func refreshToken(token *tokenData) error {
// Create OAuth client
client, err := oauth.NewClient("http://localhost:8888/client-metadata.json", "http://localhost:8888/callback")
if err != nil {
return fmt.Errorf("failed to create OAuth client: %w", err)
}
// Load DPoP key
dpopKey, err := token.GetDPoPKey()
if err != nil {
return fmt.Errorf("failed to load DPoP key: %w", err)
}
client.SetDPoPKey(dpopKey)
// Initialize for the handle
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.InitializeForHandle(ctx, token.Handle); err != nil {
return fmt.Errorf("failed to initialize client: %w", err)
}
// Refresh the token
newToken, err := client.RefreshToken(ctx, token.RefreshToken)
if err != nil {
return fmt.Errorf("failed to refresh token: %w", err)
}
// Update token store
token.AccessToken = newToken.AccessToken
token.RefreshToken = newToken.RefreshToken
token.ExpiresAt = newToken.Expiry
token.IsExpired = false
// Save updated token
return token.Save(getTokenPath())
}
// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT
func exchangeForRegistryToken(atprotoToken, registryURL string) (string, error) {
// Call the registry's /auth/exchange endpoint
// This endpoint validates the ATProto token and returns a registry JWT
exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL)
// Load token store to get DID/handle
store, err := loadToken(getTokenPath())
if err != nil {
return "", fmt.Errorf("failed to load token store: %w", err)
}
reqBody := map[string]interface{}{
"access_token": atprotoToken,
"handle": store.Handle, // Required for PDS resolution and token validation
"scope": []string{"repository:*:pull,push"},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := http.Post(exchangeURL, "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("failed to call exchange endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode)
}
var result struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if result.Token != "" {
return result.Token, nil
}
return result.AccessToken, nil
}
+770
View File
@@ -0,0 +1,770 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/distribution/distribution/v3/configuration"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
// Import storage drivers
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
)
// Config represents the hold service configuration
type Config struct {
Version string `yaml:"version"`
Storage StorageConfig `yaml:"storage"`
Server ServerConfig `yaml:"server"`
Registration RegistrationConfig `yaml:"registration"`
}
// RegistrationConfig defines auto-registration settings
type RegistrationConfig struct {
// OwnerDID is the owner's ATProto DID (from env: HOLD_CREW_OWNER)
// If set, auto-registration is enabled
OwnerDID string `yaml:"owner_did"`
}
// StorageConfig wraps distribution's storage configuration
type StorageConfig struct {
configuration.Storage `yaml:",inline"`
}
// ServerConfig defines server settings
type ServerConfig struct {
// Addr is the address to listen on (e.g., ":8080")
Addr string `yaml:"addr"`
// PublicURL is the public URL of this hold service (e.g., "https://hold.example.com")
PublicURL string `yaml:"public_url"`
// Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC)
Public bool `yaml:"public"`
// ReadTimeout for HTTP requests
ReadTimeout time.Duration `yaml:"read_timeout"`
// WriteTimeout for HTTP requests
WriteTimeout time.Duration `yaml:"write_timeout"`
}
// HoldService provides presigned URLs for blob storage in a hold
type HoldService struct {
driver storagedriver.StorageDriver
config *Config
}
// NewHoldService creates a new hold service
func NewHoldService(cfg *Config) (*HoldService, error) {
// Create storage driver from config
ctx := context.Background()
driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
if err != nil {
return nil, fmt.Errorf("failed to create storage driver: %w", err)
}
return &HoldService{
driver: driver,
config: cfg,
}, nil
}
// GetPresignedURLRequest represents a request for a presigned download URL
type GetPresignedURLRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
}
// GetPresignedURLResponse contains the presigned URL
type GetPresignedURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// PutPresignedURLRequest represents a request for a presigned upload URL
type PutPresignedURLRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// PutPresignedURLResponse contains the presigned upload URL
type PutPresignedURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// HandleGetPresignedURL handles requests for download URLs
func (s *HoldService) HandleGetPresignedURL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req GetPresignedURLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization
if !s.isAuthorized(req.DID) {
http.Error(w, "forbidden: DID not authorized", http.StatusForbidden)
return
}
// Generate presigned URL (15 minute expiry)
ctx := context.Background()
expiry := time.Now().Add(15 * time.Minute)
// For now, construct direct URL to blob
// In production, this would use driver-specific presigned URLs
url, err := s.getDownloadURL(ctx, req.Digest)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
return
}
resp := GetPresignedURLResponse{
URL: url,
ExpiresAt: expiry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// HandlePutPresignedURL handles requests for upload URLs
func (s *HoldService) HandlePutPresignedURL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req PutPresignedURLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization
if !s.isAuthorized(req.DID) {
http.Error(w, "forbidden: DID not authorized", http.StatusForbidden)
return
}
// Generate presigned upload URL (15 minute expiry)
ctx := context.Background()
expiry := time.Now().Add(15 * time.Minute)
url, err := s.getUploadURL(ctx, req.Digest, req.Size)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
return
}
resp := PutPresignedURLResponse{
URL: url,
ExpiresAt: expiry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// HandleProxyGet proxies a blob download through the service
func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract digest from path (e.g., /blobs/sha256:abc123)
digest := r.URL.Path[len("/blobs/"):]
if digest == "" {
http.Error(w, "missing digest", http.StatusBadRequest)
return
}
// Get DID from query param or header
did := r.URL.Query().Get("did")
if did == "" {
did = r.Header.Get("X-ATCR-DID")
}
if !s.isAuthorized(did) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Read blob from storage
ctx := r.Context()
path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
content, err := s.driver.GetContent(ctx, path)
if err != nil {
http.Error(w, "blob not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(content)
}
// HandleProxyPut proxies a blob upload through the service
func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
digest := r.URL.Path[len("/blobs/"):]
if digest == "" {
http.Error(w, "missing digest", http.StatusBadRequest)
return
}
did := r.URL.Query().Get("did")
if did == "" {
did = r.Header.Get("X-ATCR-DID")
}
if !s.isAuthorized(did) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Write blob to storage
ctx := r.Context()
path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
content, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
if err := s.driver.PutContent(ctx, path, content); err != nil {
http.Error(w, "failed to store blob", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
// isAuthorized checks if a DID is authorized to use this hold
// Authorization is now based on:
// - Hold record's "public" field (for reads)
// - Crew records in PDS (for writes)
// TODO: Query PDS to check hold.public and crew membership
func (s *HoldService) isAuthorized(did string) bool {
// For now, allow all requests
// Real implementation should query PDS for hold record and crew records
return true
}
// getDownloadURL generates a download URL for a blob
func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) {
// Check if blob exists
path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
_, err := s.driver.Stat(ctx, path)
if err != nil {
return "", fmt.Errorf("blob not found: %w", err)
}
// For drivers that support presigned URLs (S3), use those
// For now, return a proxy URL through this service
return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil
}
// getUploadURL generates an upload URL for a blob
func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64) (string, error) {
// For drivers that support presigned URLs (S3), use those
// For now, return a proxy URL through this service
return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil
}
// RegisterRequest represents a request to register this hold in a user's PDS
type RegisterRequest struct {
DID string `json:"did"`
AccessToken string `json:"access_token"`
PDSEndpoint string `json:"pds_endpoint"`
}
// RegisterResponse contains the registration result
type RegisterResponse struct {
HoldURI string `json:"hold_uri"`
CrewURI string `json:"crew_uri"`
Message string `json:"message"`
}
// HandleRegister registers this hold service in a user's PDS (manual endpoint)
func (s *HoldService) HandleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate required fields
if req.DID == "" || req.AccessToken == "" || req.PDSEndpoint == "" {
http.Error(w, "missing required fields: did, access_token, pds_endpoint", http.StatusBadRequest)
return
}
// Get public URL from config
publicURL := s.config.Server.PublicURL
if publicURL == "" {
// Fallback to constructing URL from request
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
publicURL = fmt.Sprintf("%s://%s", scheme, r.Host)
}
// Derive hold name from URL
holdName, err := extractHostname(publicURL)
if err != nil {
http.Error(w, fmt.Sprintf("failed to extract hostname: %v", err), http.StatusBadRequest)
return
}
ctx := r.Context()
// Create ATProto client with user's credentials
client := atproto.NewClient(req.PDSEndpoint, req.DID, req.AccessToken)
// Create HoldRecord
holdRecord := atproto.NewHoldRecord(publicURL, req.DID, s.config.Server.Public)
holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create hold record: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Created hold record: %s", holdResult.URI)
// Create HoldCrewRecord for the owner
crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, req.DID, "owner")
crewRKey := fmt.Sprintf("%s-%s", holdName, req.DID)
crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Created crew record: %s", crewResult.URI)
resp := RegisterResponse{
HoldURI: holdResult.URI,
CrewURI: crewResult.URI,
Message: fmt.Sprintf("Successfully registered hold service. Storage endpoint: %s", publicURL),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// HealthHandler handles health check requests
func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func main() {
// Load configuration from environment variables
cfg, err := loadConfigFromEnv()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Create hold service
service, err := NewHoldService(cfg)
if err != nil {
log.Fatalf("Failed to create hold service: %v", err)
}
// Auto-register if owner DID is set
if cfg.Registration.OwnerDID != "" {
if err := service.AutoRegister(); err != nil {
log.Printf("WARNING: Auto-registration failed: %v", err)
log.Printf("You can register manually later using the /register endpoint")
} else {
log.Printf("Successfully registered hold service in PDS")
}
}
// Setup HTTP routes
mux := http.NewServeMux()
mux.HandleFunc("/health", service.HealthHandler)
mux.HandleFunc("/register", service.HandleRegister)
mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL)
mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL)
mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
service.HandleProxyGet(w, r)
} else if r.Method == http.MethodPut {
service.HandleProxyPut(w, r)
} else {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// Create server
server := &http.Server{
Addr: cfg.Server.Addr,
Handler: mux,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
log.Printf("Starting hold service on %s", cfg.Server.Addr)
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
// loadConfigFromEnv loads all configuration from environment variables
func loadConfigFromEnv() (*Config, error) {
cfg := &Config{
Version: "0.1",
}
// Server configuration
cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080")
cfg.Server.PublicURL = os.Getenv("HOLD_PUBLIC_URL")
if cfg.Server.PublicURL == "" {
return nil, fmt.Errorf("HOLD_PUBLIC_URL is required")
}
cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true"
cfg.Server.ReadTimeout = 30 * time.Second
cfg.Server.WriteTimeout = 30 * time.Second
// Registration configuration (optional)
cfg.Registration.OwnerDID = os.Getenv("HOLD_CREW_OWNER")
// Storage configuration - build from env vars based on storage type
storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
var err error
cfg.Storage, err = buildStorageConfig(storageType)
if err != nil {
return nil, fmt.Errorf("failed to build storage config: %w", err)
}
return cfg, nil
}
// buildStorageConfig creates storage configuration based on driver type
func buildStorageConfig(driver string) (StorageConfig, error) {
params := make(map[string]interface{})
switch driver {
case "s3":
// S3/Storj/Minio configuration from standard AWS env vars
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
region := getEnvOrDefault("AWS_REGION", "us-east-1")
bucket := os.Getenv("S3_BUCKET")
endpoint := os.Getenv("S3_ENDPOINT") // For Storj/Minio
if bucket == "" {
return StorageConfig{}, fmt.Errorf("S3_BUCKET is required for S3 storage")
}
params["accesskey"] = accessKey
params["secretkey"] = secretKey
params["region"] = region
params["bucket"] = bucket
if endpoint != "" {
params["regionendpoint"] = endpoint
}
case "filesystem":
// Filesystem configuration
rootDir := getEnvOrDefault("STORAGE_ROOT_DIR", "/var/lib/atcr/hold")
params["rootdirectory"] = rootDir
default:
return StorageConfig{}, fmt.Errorf("unsupported storage driver: %s", driver)
}
// Build distribution Storage config
storageCfg := configuration.Storage{}
storageCfg[driver] = configuration.Parameters(params)
return StorageConfig{Storage: storageCfg}, nil
}
// getEnvOrDefault gets an environment variable or returns a default value
func getEnvOrDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
// isHoldRegistered checks if a hold with the given public URL is already registered in the PDS
func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) {
// We need to query the PDS without authentication to check public records
// ATProto records are publicly readable, so we can use an unauthenticated client
client := atproto.NewClient(pdsEndpoint, did, "")
// List all hold records for this DID
records, err := client.ListRecords(ctx, atproto.HoldCollection, 100)
if err != nil {
return false, fmt.Errorf("failed to list hold records: %w", err)
}
// Check if any hold record matches our public URL
for _, record := range records {
var holdRecord atproto.HoldRecord
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
continue
}
if holdRecord.Endpoint == publicURL {
return true, nil
}
}
return false, nil
}
// AutoRegister registers this hold service in the owner's PDS
// Checks if already registered first, then does OAuth if needed
func (s *HoldService) AutoRegister() error {
reg := &s.config.Registration
publicURL := s.config.Server.PublicURL
if publicURL == "" {
return fmt.Errorf("HOLD_PUBLIC_URL not set")
}
if reg.OwnerDID == "" {
return fmt.Errorf("HOLD_CREW_OWNER not set - required for registration")
}
ctx := context.Background()
log.Printf("Checking registration status for DID: %s", reg.OwnerDID)
// Resolve DID to PDS endpoint
resolver := atproto.NewResolver()
pdsEndpoint, err := resolver.ResolvePDS(ctx, reg.OwnerDID)
if err != nil {
return fmt.Errorf("failed to resolve PDS for DID: %w", err)
}
log.Printf("PDS endpoint: %s", pdsEndpoint)
// Check if hold is already registered
isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL)
if err != nil {
log.Printf("Warning: failed to check registration status: %v", err)
log.Printf("Proceeding with OAuth registration...")
} else if isRegistered {
log.Printf("✓ Hold service already registered in PDS")
log.Printf("Public URL: %s", publicURL)
return nil
}
// Not registered, need to do OAuth
log.Printf("Hold not registered, starting OAuth flow...")
// Get handle from DID document
handle, err := resolver.ResolveHandleFromDID(ctx, reg.OwnerDID)
if err != nil {
return fmt.Errorf("failed to get handle from DID: %w", err)
}
log.Printf("Resolved handle: %s", handle)
log.Printf("Starting OAuth registration for hold service")
log.Printf("Public URL: %s", publicURL)
return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint)
}
// registerWithOAuth performs OAuth flow and registers the hold
func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error {
// Use 127.0.0.1 for localhost callback (works better than "localhost")
callbackAddr := "127.0.0.1:8888"
redirectURI := fmt.Sprintf("http://%s/callback", callbackAddr)
// Create OAuth client
oauthClient, err := oauth.NewClient("http://hold-service", redirectURI)
if err != nil {
return fmt.Errorf("failed to create OAuth client: %w", err)
}
// Initialize for the user's handle
ctx := context.Background()
if err := oauthClient.InitializeForHandle(ctx, handle); err != nil {
return fmt.Errorf("failed to initialize OAuth: %w", err)
}
// Generate authorization URL
state := "hold-registration"
authURL, codeVerifier, err := oauthClient.AuthorizeURL(state)
if err != nil {
return fmt.Errorf("failed to generate auth URL: %w", err)
}
// Print the OAuth URL for user to visit
log.Printf("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH AUTHORIZATION REQUIRED")
log.Printf(strings.Repeat("=", 80))
log.Printf("\nPlease visit this URL to authorize the hold service:\n")
log.Printf(" %s\n", authURL)
log.Printf("Waiting for authorization...")
log.Printf(strings.Repeat("=", 80) + "\n")
// Start temporary HTTP server for callback
codeChan := make(chan string, 1)
errChan := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
receivedState := r.URL.Query().Get("state")
if receivedState != state {
errChan <- fmt.Errorf("invalid state parameter")
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
if code == "" {
errChan <- fmt.Errorf("no authorization code received")
http.Error(w, "No code", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`)
codeChan <- code
})
server := &http.Server{
Addr: callbackAddr,
Handler: mux,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
// Wait for callback or error
var code string
select {
case code = <-codeChan:
// Got the code, shutdown callback server
server.Shutdown(context.Background())
case err := <-errChan:
server.Shutdown(context.Background())
return err
case <-time.After(5 * time.Minute):
server.Shutdown(context.Background())
return fmt.Errorf("OAuth timeout - no response after 5 minutes")
}
log.Printf("Authorization received, exchanging code for token...")
// Exchange code for token
token, err := oauthClient.Exchange(ctx, code, codeVerifier)
if err != nil {
return fmt.Errorf("failed to exchange code: %w", err)
}
log.Printf("OAuth token obtained successfully")
log.Printf("DID: %s", did)
log.Printf("PDS: %s", pdsEndpoint)
// Now register with the token
return s.registerWithToken(publicURL, did, pdsEndpoint, token.AccessToken)
}
// registerWithToken registers the hold using an access token
func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken string) error {
// Derive hold name from URL (hostname)
holdName, err := extractHostname(publicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname from URL: %w", err)
}
log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did)
ctx := context.Background()
// Create ATProto client with owner's credentials
client := atproto.NewClient(pdsEndpoint, did, accessToken)
// Create HoldRecord
holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public)
// Use hostname as record key
holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
if err != nil {
return fmt.Errorf("failed to create hold record: %w", err)
}
log.Printf("✓ Created hold record: %s", holdResult.URI)
// Create HoldCrewRecord for the owner
crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner")
crewRKey := fmt.Sprintf("%s-%s", holdName, did)
crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
return fmt.Errorf("failed to create crew record: %w", err)
}
log.Printf("✓ Created crew record: %s", crewResult.URI)
log.Printf("\n" + strings.Repeat("=", 80))
log.Printf("REGISTRATION COMPLETE")
log.Printf(strings.Repeat("=", 80))
log.Printf("Hold service is now registered and ready to use!")
log.Printf(strings.Repeat("=", 80) + "\n")
return nil
}
// extractHostname extracts the hostname from a URL to use as the hold name
func extractHostname(urlStr string) (string, error) {
u, err := url.Parse(urlStr)
if err != nil {
return "", err
}
// Remove port if present
hostname := u.Hostname()
if hostname == "" {
return "", fmt.Errorf("no hostname in URL")
}
return hostname, nil
}
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"os"
"github.com/distribution/distribution/v3/registry"
_ "github.com/distribution/distribution/v3/registry/auth/token"
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
// Register our custom middleware
_ "atcr.io/pkg/middleware"
)
func main() {
// Use distribution's built-in CLI
// Our middleware will be automatically registered via init()
if err := registry.RootCmd.Execute(); err != nil {
os.Exit(1)
}
}
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/distribution/distribution/v3/configuration"
"github.com/distribution/distribution/v3/registry"
"github.com/distribution/distribution/v3/registry/handlers"
"github.com/spf13/cobra"
"atcr.io/pkg/auth/exchange"
"atcr.io/pkg/auth/token"
)
var serveCmd = &cobra.Command{
Use: "serve <config>",
Short: "Start the ATCR registry server",
Long: "Start the ATCR registry server with authentication endpoints",
Args: cobra.ExactArgs(1),
RunE: serveRegistry,
}
func init() {
// Replace the default serve command with our custom one
for i, cmd := range registry.RootCmd.Commands() {
if cmd.Name() == "serve" {
registry.RootCmd.Commands()[i] = serveCmd
break
}
}
}
func serveRegistry(cmd *cobra.Command, args []string) error {
configPath := args[0]
// Parse configuration
fp, err := os.Open(configPath)
if err != nil {
return fmt.Errorf("failed to open config file: %w", err)
}
defer fp.Close()
config, err := configuration.Parse(fp)
if err != nil {
return fmt.Errorf("failed to parse configuration: %w", err)
}
// Initialize auth keys if needed
var issuer *token.Issuer
if config.Auth["token"] != nil {
if err := initializeAuthKeys(config); err != nil {
return fmt.Errorf("failed to initialize auth keys: %w", err)
}
// Create token issuer for auth handlers
issuer, err = createTokenIssuer(config)
if err != nil {
return fmt.Errorf("failed to create token issuer: %w", err)
}
}
// Create registry app (returns http.Handler)
ctx := context.Background()
app := handlers.NewApp(ctx, config)
// Create main HTTP mux
mux := http.NewServeMux()
// Mount registry at /v2/
mux.Handle("/v2/", app)
// Mount auth endpoints if enabled
if issuer != nil {
// Extract default hold endpoint from middleware config
defaultHoldEndpoint := extractDefaultHoldEndpoint(config)
tokenHandler := token.NewHandler(issuer, defaultHoldEndpoint)
tokenHandler.RegisterRoutes(mux)
exchangeHandler := exchange.NewHandler(issuer, defaultHoldEndpoint)
exchangeHandler.RegisterRoutes(mux)
fmt.Println("Auth endpoints enabled at /auth/token and /auth/exchange")
}
// Create HTTP server
server := &http.Server{
Addr: config.HTTP.Addr,
Handler: mux,
}
// Handle graceful shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
// Start server in goroutine
errChan := make(chan error, 1)
go func() {
fmt.Printf("Starting registry server on %s\n", config.HTTP.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
// Wait for shutdown signal or error
select {
case <-stop:
fmt.Println("Shutting down registry server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("server shutdown error: %w", err)
}
case err := <-errChan:
return fmt.Errorf("server error: %w", err)
}
return nil
}
// initializeAuthKeys creates the auth keys if they don't exist
func initializeAuthKeys(config *configuration.Configuration) error {
tokenParams, ok := config.Auth["token"]
if !ok {
return nil
}
privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
service := getStringParam(tokenParams, "service", "atcr.io")
expirationSecs := getIntParam(tokenParams, "expiration", 300)
// Create issuer (this will generate the key if it doesn't exist)
_, err := token.NewIssuer(
privateKeyPath,
issuerName,
service,
time.Duration(expirationSecs)*time.Second,
)
if err != nil {
return fmt.Errorf("failed to initialize token issuer: %w", err)
}
fmt.Printf("Auth keys initialized at %s\n", privateKeyPath)
return nil
}
// createTokenIssuer creates a token issuer for auth handlers
func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, error) {
tokenParams, ok := config.Auth["token"]
if !ok {
return nil, fmt.Errorf("token auth not configured")
}
privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
service := getStringParam(tokenParams, "service", "atcr.io")
expirationSecs := getIntParam(tokenParams, "expiration", 300)
return token.NewIssuer(
privateKeyPath,
issuerName,
service,
time.Duration(expirationSecs)*time.Second,
)
}
// Helper functions to extract values from config parameters
func getStringParam(params configuration.Parameters, key, defaultValue string) string {
if v, ok := params[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return defaultValue
}
func getIntParam(params configuration.Parameters, key string, defaultValue int) int {
if v, ok := params[key]; ok {
if i, ok := v.(int); ok {
return i
}
}
return defaultValue
}
// extractDefaultHoldEndpoint extracts the default hold endpoint from middleware config
func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
// Navigate through: middleware.registry[].options.default_storage_endpoint
registryMiddleware, ok := config.Middleware["registry"]
if !ok {
return ""
}
// Find atproto-resolver middleware
for _, mw := range registryMiddleware {
// Check if this is the atproto-resolver
if mw.Name != "atproto-resolver" {
continue
}
// Extract options - options is configuration.Parameters which is map[string]interface{}
if mw.Options != nil {
if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok {
return endpoint
}
}
}
return ""
}
+56
View File
@@ -0,0 +1,56 @@
version: 0.1
log:
level: info
formatter: text
fields:
service: atcr-registry
# Storage is handled by external services:
# - Manifests/Tags -> ATProto PDS (user's personal data server)
# - Blobs/Layers -> Hold service (default or BYOS)
# The AppView (registry) should be stateless with no local storage
#
# NOTE: The storage section below is required for distribution config validation
# but is NOT actually used - all blob operations are routed through hold service
storage:
inmemory: {}
http:
addr: :5000
headers:
X-Content-Type-Options: [nosniff]
debug:
addr: :5001
middleware:
registry:
# Name resolution middleware
- name: atproto-resolver
options:
# Default hold service for blob storage
# Users without their own hold will use this endpoint
default_storage_endpoint: http://atcr-hold:8080
# Authentication - all endpoints on port 5000
auth:
token:
# Token service realm (where Docker gets tokens)
realm: http://127.0.0.1:5000/auth/token
service: atcr.io
issuer: atcr.io
# Certificate bundle for validating JWTs
rootcertbundle: /var/lib/atcr/auth/private-key.crt
# Private key for signing JWTs (used by custom auth handlers)
privatekey: /var/lib/atcr/auth/private-key.pem
# Token expiration in seconds (5 minutes)
expiration: 300
# Health check
health:
storagedriver:
enabled: true
interval: 10s
threshold: 3
+16
View File
@@ -0,0 +1,16 @@
# DEPRECATED: This config file is no longer used
#
# The hold service now loads all configuration from environment variables.
# Please use .env.example as a reference and set environment variables instead.
#
# See .env.example for all available configuration options.
#
# Key changes:
# - Storage config now uses standard env vars (AWS_ACCESS_KEY_ID, etc.)
# - Authorization is now based on PDS records (hold.public, crew records)
# - No more allow_all or allowed_dids config
#
# To run the hold service:
# 1. Copy .env.example to .env
# 2. Fill in your values
# 3. Run: source .env && ./atcr-hold
BIN
View File
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
services:
registry:
build:
context: .
dockerfile: Dockerfile
image: atcr-registry:latest
container_name: atcr-registry
ports:
- "5000:5000"
volumes:
# Only auth keys (could be moved to secrets in production)
- atcr-auth:/var/lib/atcr/auth
restart: unless-stopped
# The registry should be stateless - all storage is external:
# - Manifests/Tags -> ATProto PDS
# - Blobs/Layers -> Hold service
# Future: Add read_only: true for production deployments
hold:
build:
context: .
dockerfile: Dockerfile.hold
image: atcr-hold:latest
container_name: atcr-hold
ports:
- "8080:8080"
volumes:
- atcr-hold:/var/lib/atcr/hold
restart: unless-stopped
volumes:
atcr-blobs:
atcr-hold:
atcr-auth:
+464
View File
@@ -0,0 +1,464 @@
# Bring Your Own Storage (BYOS)
## Overview
ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. This allows users to:
- Deploy their own storage service backed by S3/Storj/Minio/filesystem
- Control who can use their storage (public or private)
- Keep blob data in their own infrastructure while manifests remain in their ATProto PDS
## Architecture
```
┌─────────────────────────────────────────────┐
│ ATCR AppView (API) │
│ - Manifests → ATProto PDS │
│ - Auth & token validation │
│ - Blob routing (issues redirects) │
│ - Profile management │
└─────────────────┬───────────────────────────┘
│ Hold discovery priority:
│ 1. io.atcr.sailor.profile.defaultHold
│ 2. io.atcr.hold records
│ 3. AppView default_storage_endpoint
┌─────────────────────────────────────────────┐
│ User's PDS │
│ - io.atcr.sailor.profile (hold preference) │
│ - io.atcr.hold records (own holds) │
│ - io.atcr.manifest records (with holdEP) │
└─────────────────┬───────────────────────────┘
│ Redirects to hold
┌─────────────────────────────────────────────┐
│ Storage Service (Hold) │
│ - Blob storage (S3/Storj/Minio/filesystem) │
│ - Presigned URL generation │
│ - Authorization (DID-based) │
└─────────────────────────────────────────────┘
```
## ATProto Records
### io.atcr.sailor.profile
**NEW:** User profile for hold selection preferences. Created automatically on first authentication.
```json
{
"$type": "io.atcr.sailor.profile",
"defaultHold": "https://team-hold.example.com",
"createdAt": "2025-10-02T12:00:00Z",
"updatedAt": "2025-10-02T12:00:00Z"
}
```
**Record key:** Always `"self"` (only one profile per user)
**Behavior:**
- Created automatically when user first authenticates (OAuth or Basic Auth)
- If AppView has `default_storage_endpoint`, profile gets that as initial `defaultHold`
- User can update to join shared holds or use their own hold
- Set `defaultHold` to `null` to opt out of defaults (use own hold or AppView default)
**This solves the multi-hold problem:** Users who are crew members of multiple holds can explicitly choose which one to use via their profile.
### io.atcr.hold
Users create a hold record in their PDS to configure their own storage:
```json
{
"$type": "io.atcr.hold",
"endpoint": "https://alice-storage.example.com",
"owner": "did:plc:alice123",
"public": false,
"createdAt": "2025-10-01T12:00:00Z"
}
```
### io.atcr.hold.crew
Hold owners can add crew members (for shared storage):
```json
{
"$type": "io.atcr.hold.crew",
"hold": "at://did:plc:alice/io.atcr.hold/my-storage",
"member": "did:plc:bob456",
"role": "write",
"addedAt": "2025-10-01T12:00:00Z"
}
```
**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.
## Storage Service
### Deployment
The storage service is a lightweight HTTP server that:
1. Accepts presigned URL requests
2. Verifies DID authorization
3. Generates presigned URLs for S3/Storj/etc
4. Returns URLs to AppView for client redirect
### Configuration
The hold service is configured entirely via environment variables. See `.env.example` for all options.
**Required environment variables:**
```bash
# Hold service public URL (REQUIRED)
HOLD_PUBLIC_URL=https://storage.example.com
# Storage driver type
STORAGE_DRIVER=s3
# For S3/Minio
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
S3_BUCKET=my-blobs
# For Storj (optional - custom S3 endpoint)
# S3_ENDPOINT=https://gateway.storjshare.io
# For filesystem storage
# STORAGE_DRIVER=filesystem
# STORAGE_ROOT_DIR=/var/lib/atcr-storage
```
**Authorization:**
- Authorization is now based on PDS records, not local config
- Public reads: controlled by `HOLD_PUBLIC` env var (stored in hold record)
- Writes: controlled by `io.atcr.hold.crew` records in PDS
### Running
```bash
# Build
go build -o atcr-hold ./cmd/hold
# Set environment variables (or use .env file)
export HOLD_PUBLIC_URL=https://storage.example.com
export STORAGE_DRIVER=s3
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=us-east-1
export S3_BUCKET=my-blobs
# Run
./atcr-hold
```
**Registration (required):**
The hold service must be registered in a PDS to be discoverable by the AppView.
**Standard registration workflow:**
1. Set `HOLD_CREW_OWNER` to your DID:
```bash
export HOLD_CREW_OWNER=did:plc:your-did-here
```
2. Start the hold service:
```bash
./atcr-hold
```
3. **Check the logs** for the OAuth authorization URL:
```
================================================================================
OAUTH AUTHORIZATION REQUIRED
================================================================================
Please visit this URL to authorize the hold service:
https://bsky.app/authorize?client_id=...
Waiting for authorization...
================================================================================
```
4. Visit the URL in your browser and authorize
5. The hold service will:
- Exchange the authorization code for a token
- Create `io.atcr.hold` record in your PDS
- Create `io.atcr.hold.crew` record (making you the owner)
- Save registration state
6. On subsequent runs, the service checks if already registered and skips OAuth
**Alternative methods:**
- **Manual API registration**: Call `POST /register` with your own OAuth token
- **Completely manual**: Create PDS records yourself using any ATProto client
### Deploy to Fly.io
```bash
# Create fly.toml
cat > fly.toml <<EOF
app = "my-atcr-hold"
primary_region = "ord"
[env]
HOLD_PUBLIC_URL = "https://my-atcr-hold.fly.dev"
HOLD_SERVER_ADDR = ":8080"
STORAGE_DRIVER = "s3"
AWS_REGION = "us-east-1"
S3_BUCKET = "my-blobs"
HOLD_PUBLIC = "false"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256
EOF
# Deploy
fly launch
fly deploy
# Set secrets
fly secrets set AWS_ACCESS_KEY_ID=...
fly secrets set AWS_SECRET_ACCESS_KEY=...
fly secrets set HOLD_CREW_OWNER=did:plc:your-did-here
# Check logs for OAuth URL on first run
fly logs
# Visit the OAuth URL shown in logs to authorize
# The hold service will register itself in your PDS
```
## Request Flow
### Push with BYOS
1. **Docker push** `atcr.io/alice/myapp:latest`
2. **AppView** resolves `alice` → `did:plc:alice123`
3. **AppView** discovers hold via priority logic:
- Check alice's `io.atcr.sailor.profile` for `defaultHold`
- If not set, check alice's `io.atcr.hold` records
- Fall back to AppView's `default_storage_endpoint`
4. **Found:** `alice.profile.defaultHold = "https://team-hold.example.com"`
5. **AppView** → team-hold: POST `/put-presigned-url`
```json
{
"did": "did:plc:alice123",
"digest": "sha256:abc123...",
"size": 1048576
}
```
6. **Hold service**:
- Verifies alice is authorized (checks crew records)
- Generates S3 presigned upload URL (15min expiry)
- Returns: `{"url": "https://s3.../blob?signature=..."}`
7. **AppView** → Docker: `307 Redirect` to presigned URL
8. **Docker** → S3: PUT blob directly (no proxy)
9. **Manifest** stored in alice's PDS with `holdEndpoint: "https://team-hold.example.com"`
### Pull with BYOS
1. **Docker pull** `atcr.io/alice/myapp:latest`
2. **AppView** fetches manifest from alice's PDS
3. **Manifest** contains `holdEndpoint: "https://team-hold.example.com"`
4. **AppView** caches: `(alice's DID, "myapp") → "https://team-hold.example.com"` (10min TTL)
5. **Docker** requests blobs: GET `/v2/alice/myapp/blobs/sha256:abc123`
6. **AppView** uses **cached hold from manifest** (not re-discovered)
7. **AppView** → team-hold: POST `/get-presigned-url`
8. **Hold service** returns presigned download URL
9. **AppView** → Docker: `307 Redirect`
10. **Docker** → S3: GET blob directly
**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`.
## Default Registry
The AppView can run its own storage service as the default:
### AppView config
```yaml
middleware:
- name: registry
options:
atproto-resolver:
default_storage_endpoint: https://storage.atcr.io
```
### Default hold service config
```bash
# Accept any authenticated DID
HOLD_PUBLIC=false # Requires authentication
# Or allow public reads
HOLD_PUBLIC=true # Public reads, auth required for writes
```
This provides free-tier shared storage for users who don't want to deploy their own.
## Storage Drivers Supported
The storage service uses distribution's storage drivers:
- **S3** - AWS S3, Minio, Storj (via S3 gateway)
- **Filesystem** - Local disk (for testing)
- **Azure** - Azure Blob Storage
- **GCS** - Google Cloud Storage
- **Swift** - OpenStack Swift
- **OSS** - Alibaba Cloud OSS
## Quotas
Quotas are NOT implemented in the storage service. Instead, use:
- **S3**: Bucket policies, lifecycle rules
- **Storj**: Project limits in Storj dashboard
- **Minio**: Quota enforcement features
- **Filesystem**: Disk quotas at OS level
## Security
### Authorization
Authorization is now based on ATProto PDS records:
- **Public reads**: Controlled by `hold.public` field in hold record (set via `HOLD_PUBLIC` env var)
- **Writes**: Controlled by `io.atcr.hold.crew` records in PDS
- **Owner**: User who created the hold record automatically gets crew owner role
- **No local config**: Authorization state lives in PDS, not hold service config
The hold service queries the PDS to check:
1. Hold record's `public` field for read authorization
2. Crew records for write authorization
### Presigned URLs
- 15 minute expiry
- Client uploads/downloads directly to storage
- No data flows through AppView or hold service
### Private Holds
Users can restrict access by:
1. Setting `HOLD_PUBLIC=false` (requires authentication for all operations)
2. Adding crew members via `io.atcr.hold.crew` records in PDS
Only users with crew records can write to the hold.
## Example: Personal Storage
Alice wants to use her own Storj account:
1. **Set environment variables**:
```bash
export HOLD_PUBLIC_URL=https://alice-storage.fly.dev
export HOLD_CREW_OWNER=did:plc:alice123
export STORAGE_DRIVER=s3
export AWS_ACCESS_KEY_ID=your_storj_access_key
export AWS_SECRET_ACCESS_KEY=your_storj_secret_key
export S3_ENDPOINT=https://gateway.storjshare.io
export S3_BUCKET=alice-blobs
```
2. **Deploy hold service** to Fly.io - auto-registration creates hold + crew record
3. **Push images** - AppView automatically routes to her storage
## Example: Team Hold
A company wants shared storage for their team:
1. **Deploy hold service** with S3 credentials and auto-registration:
```bash
export HOLD_PUBLIC_URL=https://company-hold.fly.dev
export HOLD_CREW_OWNER=did:plc:admin
export HOLD_PUBLIC=false
export STORAGE_DRIVER=s3
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export S3_BUCKET=company-blobs
```
2. **Hold service auto-registers** on first run, creating:
- Hold record in admin's PDS
- Crew record making admin the owner
3. **Admin adds crew members** via ATProto client or manually:
```bash
# Using atproto client
atproto put-record \
--collection io.atcr.hold.crew \
--rkey "company-did:plc:engineer1" \
--value '{
"$type": "io.atcr.hold.crew",
"hold": "at://did:plc:admin/io.atcr.hold/company",
"member": "did:plc:engineer1",
"role": "write"
}'
```
4. **Team members set their profile** to use the shared hold:
```bash
# Engineer updates their sailor profile
atproto put-record \
--collection io.atcr.sailor.profile \
--rkey "self" \
--value '{
"$type": "io.atcr.sailor.profile",
"defaultHold": "https://company-hold.fly.dev"
}'
```
5. **Hold service queries PDS** for crew records to authorize writes
6. **Engineers push/pull** using `atcr.io/engineer1/myapp` - blobs go to company hold
## Limitations
1. **No resume/partial uploads** - Storage service doesn't track upload state
2. **No advanced features** - Just basic put/get, no deduplication logic
3. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis)
4. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client)
## Future Improvements
1. **Automatic failover** - Multiple storage endpoints, fallback to default
2. **Storage analytics** - Track usage per DID
3. **Quota integration** - Optional quota tracking in storage service
4. **Direct presigned URL support** - S3 native presigned URLs (bypass proxy)
5. **Profile management UI** - Web interface for users to manage their sailor profile
6. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments
## Comparison to Default Storage
| Feature | Default (Shared S3) | BYOS |
|---------|---------------------|------|
| Setup | None required | Deploy storage service |
| Cost | Free (with quota) | User pays for S3/Storj |
| Control | Limited | Full control |
| Performance | Shared | Dedicated |
| Quotas | Enforced by AppView | User managed |
| Privacy | Blobs in shared bucket | Blobs in user's bucket |
## References
- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
- [Distribution Storage Drivers](https://distribution.github.io/distribution/storage-drivers/)
- [S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html)
- [Storj Documentation](https://docs.storj.io/)
+281
View File
@@ -0,0 +1,281 @@
# ATCR OAuth Implementation
## Overview
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.
## Architecture
### Components
1. **OAuth Client** (`pkg/auth/oauth/`)
- Full ATProto OAuth implementation with DPoP support
- Uses `authelia.com/client/oauth2` for OAuth + PAR
- Uses `github.com/AxisCommunications/go-dpop` for DPoP proof generation
- Automatic authorization server discovery
- PKCE support for security
2. **Credential Helper** (`cmd/credential-helper/`)
- Standalone binary: `docker-credential-atcr`
- Implements Docker credential helper protocol
- Manages OAuth flow with browser
- Stores tokens securely in `~/.atcr/oauth-token.json`
3. **Registry Integration**
- `/auth/exchange` endpoint exchanges OAuth tokens for registry JWTs
- Existing `/auth/token` endpoint for standard Docker auth
## Dependencies
- `authelia.com/client/oauth2` - OAuth client with PAR support (2⭐, Authelia-backed)
- `github.com/AxisCommunications/go-dpop` - DPoP implementation (10⭐, RFC 9449 compliant)
- `github.com/golang-jwt/jwt/v5` - JWT library (transitive, 11k+⭐)
## Usage
### Setup
1. Build the credential helper:
```bash
go build -o docker-credential-atcr ./cmd/credential-helper
```
2. Install it in your PATH:
```bash
sudo mv docker-credential-atcr /usr/local/bin/
```
3. Configure Docker to use it by editing `~/.docker/config.json`:
```json
{
"credsStore": "atcr"
}
```
### Configuration
Run the OAuth flow:
```bash
docker-credential-atcr configure
```
This will:
1. Prompt for your ATProto handle (e.g., `alice.bsky.social`)
2. Open your browser for OAuth authorization
3. Store the OAuth token and DPoP key in `~/.atcr/oauth-token.json`
### Using with Docker
Once configured, use Docker normally:
```bash
# Push an image
docker push atcr.io/alice/myapp:latest
# Pull an image
docker pull atcr.io/alice/myapp:latest
```
The credential helper automatically:
1. Loads your stored OAuth token
2. Refreshes it if expired
3. Exchanges it for a registry JWT
4. Provides the JWT to Docker
## How It Works
### OAuth Flow
1. **User runs** `docker-credential-atcr configure`
2. **Resolve identity**: alice.bsky.social → DID → PDS endpoint
3. **Discover auth server**: GET `{pds}/.well-known/oauth-authorization-server`
4. **Generate DPoP key**: ECDSA P-256 key pair
5. **PAR request**: POST to PAR endpoint with DPoP header + PKCE challenge
6. **Open browser**: User authorizes on their PDS
7. **Receive code**: Callback to `localhost:8888/callback`
8. **Exchange code**: POST to token endpoint with DPoP header + PKCE verifier
9. **Save tokens**: Store OAuth token + DPoP key + DID/handle
### Docker Push/Pull Flow
1. **Docker needs credentials** for `atcr.io`
2. **Calls credential helper**: `docker-credential-atcr get`
3. **Helper loads token** from `~/.atcr/oauth-token.json`
4. **Refresh if needed**: Uses refresh token + DPoP if expired
5. **Exchange for registry JWT**: POST to `/auth/exchange` with OAuth token + handle
6. **Registry validates token**: Calls `getSession` on PDS to validate token
7. **Registry issues JWT**: Creates registry JWT with validated DID/handle
8. **Return to Docker**: `{"Username": "oauth2", "Secret": "<jwt>"}`
9. **Docker uses JWT**: For authentication to registry API
## Security
### DPoP (Demonstrating Proof-of-Possession)
Every OAuth request includes a DPoP proof:
- Unique JWT signed with ECDSA private key
- Contains HTTP method, URL, timestamp, nonce
- Public key (JWK) included in JWT header
- Binds the token to the specific client
### PKCE (Proof Key for Code Exchange)
- Code verifier generated locally
- Code challenge sent in authorization request
- Verifier sent in token exchange
- Prevents authorization code interception
### Token Storage
- Tokens stored in `~/.atcr/oauth-token.json`
- File permissions: 0600 (owner read/write only)
- DPoP key stored in PEM format
- Refresh tokens for long-term access
## Implementation Details
### Code Structure
```
pkg/auth/oauth/
├── client.go # OAuth client with DPoP
├── discovery.go # Authorization server discovery
├── metadata.go # Client metadata document
├── storage.go # Token persistence
└── transport.go # DPoP HTTP transport
pkg/auth/atproto/
├── session.go # ATProto session validation (Basic auth)
└── validator.go # OAuth token validation via getSession
cmd/credential-helper/
├── main.go # Docker credential helper protocol
├── oauth.go # OAuth flow orchestration
└── token.go # Token management
pkg/auth/exchange/
└── handler.go # OAuth → Registry JWT exchange
```
### Key Classes
**OAuth Client** (`pkg/auth/oauth/client.go`)
- `NewClient()` - Create client with DPoP key
- `InitializeForHandle()` - Discover auth server
- `AuthorizeURL()` - Generate authorization URL with PAR + PKCE
- `Exchange()` - Exchange code for token with DPoP
- `RefreshToken()` - Refresh expired token with DPoP
**DPoP Transport** (`pkg/auth/oauth/transport.go`)
- Implements `http.RoundTripper`
- Automatically adds DPoP header to all requests
- Handles nonce management and retries
- Used by OAuth client for all HTTP requests
**Token Store** (`pkg/auth/oauth/storage.go`)
- Persists OAuth tokens and DPoP key
- PEM encoding for private key
- Expiration checking
- Secure file permissions
**Token Validator** (`pkg/auth/atproto/validator.go`)
- `ValidateToken()` - Validate token via PDS getSession
- `ValidateTokenWithResolver()` - Auto-resolve PDS from handle
- Returns validated DID and handle
- Used by registry to verify OAuth tokens
## Testing
### Manual Testing
1. Configure the helper:
```bash
./docker-credential-atcr configure
# Enter handle: alice.bsky.social
# Browser opens for authorization
# Token saved to ~/.atcr/oauth-token.json
```
2. Test credential retrieval:
```bash
echo '{"ServerURL": "atcr.io"}' | ./docker-credential-atcr get
# Should return: {"Username":"oauth2","Secret":"<jwt>"}
```
3. Test with Docker:
```bash
docker push atcr.io/alice/test:latest
```
### Integration Testing
TODO: Add automated tests for:
- OAuth flow with mock PDS
- DPoP proof generation
- Token exchange
- Credential helper protocol
## Security Features
### OAuth Token Validation
The registry validates ATProto OAuth tokens by calling `com.atproto.server.getSession` on the user's PDS. This ensures:
- Token is valid and not expired
- Token belongs to the claimed user
- User's DID and handle are extracted from the PDS response
- No trust in client-provided identity information
**Flow:**
1. Client sends OAuth token + handle to `/auth/exchange`
2. Registry resolves handle → PDS endpoint
3. Registry calls `{pds}/xrpc/com.atproto.server.getSession` with token
4. PDS validates token and returns session info (DID, handle)
5. Registry uses validated DID/handle to issue registry JWT
## Future Improvements
1. **Token refresh in background**
- Proactively refresh before expiry
- Reduce latency on Docker commands
3. **Multiple account support**
- Store tokens for multiple handles
- Allow selecting which account to use
4. **Revocation support**
- Implement token revocation
- Clean up on logout
5. **Better error messages**
- User-friendly OAuth error handling
- Guide users through common issues
## Troubleshooting
### "Failed to resolve identity"
- Check internet connection
- Verify handle is correct (e.g., `alice.bsky.social`)
- Ensure PDS is accessible
### "Authorization timed out"
- Complete authorization within 5 minutes
- Check if browser opened correctly
- Try running `configure` again
### "Token expired"
- Credential helper should auto-refresh
- If persistent, run `configure` again
- Check `~/.atcr/oauth-token.json` permissions
### "Failed to exchange token"
- Ensure registry is running
- Check `/auth/exchange` endpoint is accessible
- Verify token hasn't been revoked
## References
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
- [RFC 9126: PAR](https://datatracker.ietf.org/doc/html/rfc9126)
- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
+82
View File
@@ -0,0 +1,82 @@
module atcr.io
go 1.24.7
require (
authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190
github.com/AxisCommunications/go-dpop v1.1.2
github.com/distribution/distribution/v3 v3.0.0
github.com/distribution/reference v0.6.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/opencontainers/go-digest v1.0.0
github.com/spf13/cobra v1.8.0
)
require (
github.com/aws/aws-sdk-go v1.55.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.60.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
go.opentelemetry.io/otel/log v0.8.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/sdk v1.32.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/grpc v1.68.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+248
View File
@@ -0,0 +1,248 @@
authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190 h1:5YfShMnyeIOFX5C1I7i6YrEpIfQCeeDBFTjau/iLfVU=
authelia.com/client/oauth2 v0.0.0-20250405043315-6378a9b2a190/go.mod h1:f0e/AQgp3qHJ2gSVnCheQZ4gTCm7BHasGpWrce36n9Q=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc=
github.com/AxisCommunications/go-dpop v1.1.2 h1:ICgk/8crE7pmWo5MML1kzyHF9wVJg6a78fW7rKxFavg=
github.com/AxisCommunications/go-dpop v1.1.2/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM=
github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0=
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg=
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho=
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U=
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc=
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w=
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk=
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4=
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI=
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU=
go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s=
go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk=
go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs=
go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Executable
BIN
View File
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"lexicon": 1,
"id": "io.atcr.hold",
"defs": {
"main": {
"type": "record",
"description": "Storage hold definition for Bring Your Own Storage (BYOS). Defines where blobs are stored.",
"key": "any",
"record": {
"type": "object",
"required": ["endpoint", "owner", "createdAt"],
"properties": {
"endpoint": {
"type": "string",
"format": "uri",
"description": "URL of the hold service (e.g., 'https://hold1.example.com')"
},
"owner": {
"type": "string",
"format": "did",
"description": "DID of the hold owner"
},
"public": {
"type": "boolean",
"description": "Whether this hold allows public blob reads (pulls) without authentication. Writes always require crew membership.",
"default": false
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Hold creation timestamp"
}
}
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"lexicon": 1,
"id": "io.atcr.hold.crew",
"defs": {
"main": {
"type": "record",
"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.",
"key": "any",
"record": {
"type": "object",
"required": ["hold", "member", "role", "createdAt"],
"properties": {
"hold": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')"
},
"member": {
"type": "string",
"format": "did",
"description": "DID of the crew member who can use this hold"
},
"role": {
"type": "string",
"description": "Member's role/permissions",
"knownValues": ["owner", "write", "read"]
},
"expiresAt": {
"type": "string",
"format": "datetime",
"description": "Optional expiration for this membership"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Membership creation timestamp"
}
}
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
{
"lexicon": 1,
"id": "io.atcr.manifest",
"defs": {
"main": {
"type": "record",
"description": "A container image manifest following OCI specification, stored in ATProto",
"key": "tid",
"record": {
"type": "object",
"required": ["repository", "digest", "mediaType", "schemaVersion", "config", "layers", "holdEndpoint", "createdAt"],
"properties": {
"repository": {
"type": "string",
"description": "Repository name (e.g., 'myapp'). Scoped to user's DID.",
"maxLength": 255
},
"digest": {
"type": "string",
"description": "Content digest (e.g., 'sha256:abc123...')"
},
"holdEndpoint": {
"type": "string",
"format": "uri",
"description": "Hold service endpoint where blobs are stored (e.g., 'https://hold1.bob.com'). Historical reference."
},
"mediaType": {
"type": "string",
"description": "OCI media type",
"knownValues": [
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json"
]
},
"schemaVersion": {
"type": "integer",
"description": "OCI schema version (typically 2)"
},
"config": {
"type": "ref",
"ref": "#blobReference",
"description": "Reference to image configuration blob"
},
"layers": {
"type": "array",
"items": {
"type": "ref",
"ref": "#blobReference"
},
"description": "Filesystem layers"
},
"annotations": {
"type": "object",
"description": "Optional metadata annotations"
},
"subject": {
"type": "ref",
"ref": "#blobReference",
"description": "Optional reference to another manifest (for attestations, signatures)"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Record creation timestamp"
}
}
}
},
"blobReference": {
"type": "object",
"description": "Reference to a blob stored in S3 or external storage",
"required": ["mediaType", "size", "digest"],
"properties": {
"mediaType": {
"type": "string",
"description": "MIME type of the blob"
},
"size": {
"type": "integer",
"description": "Size in bytes"
},
"digest": {
"type": "string",
"description": "Content digest (e.g., 'sha256:...')"
},
"urls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
},
"description": "Optional direct URLs to blob (for BYOS)"
},
"annotations": {
"type": "object",
"description": "Optional metadata"
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"lexicon": 1,
"id": "io.atcr.sailor.profile",
"defs": {
"main": {
"type": "record",
"description": "User profile for ATCR registry. Stores preferences like default hold for blob storage.",
"key": "literal:self",
"record": {
"type": "object",
"required": ["createdAt"],
"properties": {
"defaultHold": {
"type": "string",
"format": "uri",
"description": "Default hold endpoint for blob storage. If null, user has opted out of defaults."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Profile creation timestamp"
},
"updatedAt": {
"type": "string",
"format": "datetime",
"description": "Profile last updated timestamp"
}
}
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"lexicon": 1,
"id": "io.atcr.tag",
"defs": {
"main": {
"type": "record",
"description": "A named tag pointing to a specific manifest digest",
"key": "any",
"record": {
"type": "object",
"required": ["repository", "tag", "manifestDigest", "createdAt"],
"properties": {
"repository": {
"type": "string",
"description": "Repository name (e.g., 'myapp'). Scoped to user's DID.",
"maxLength": 255
},
"tag": {
"type": "string",
"description": "Tag name (e.g., 'latest', 'v1.0.0', '12-slim')",
"maxLength": 128
},
"manifestDigest": {
"type": "string",
"description": "Digest of the manifest this tag points to (e.g., 'sha256:...')"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Tag creation timestamp"
}
}
}
}
}
}
+184
View File
@@ -0,0 +1,184 @@
package atproto
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// Client wraps ATProto operations for the registry
type Client struct {
pdsEndpoint string
did string
accessToken string
httpClient *http.Client
}
// NewClient creates a new ATProto client
func NewClient(pdsEndpoint, did, accessToken string) *Client {
return &Client{
pdsEndpoint: pdsEndpoint,
did: did,
accessToken: accessToken,
httpClient: &http.Client{},
}
}
// Record represents a generic ATProto record
type Record struct {
URI string `json:"uri"`
CID string `json:"cid"`
Value json.RawMessage `json:"value"`
}
// PutRecord stores a record in the ATProto repository
func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record interface{}) (*Record, error) {
// Construct the record URI
// Format: at://<did>/<collection>/<rkey>
payload := map[string]interface{}{
"repo": c.did,
"collection": collection,
"rkey": rkey,
"record": record,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal record: %w", err)
}
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", c.pdsEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to put record: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("put record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result Record
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// GetRecord retrieves a record from the ATProto repository
func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) {
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
c.pdsEndpoint, c.did, collection, rkey)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get record: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("record not found")
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result Record
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// DeleteRecord deletes a record from the ATProto repository
func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error {
payload := map[string]interface{}{
"repo": c.did,
"collection": collection,
"rkey": rkey,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal delete request: %w", err)
}
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", c.pdsEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to delete record: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// ListRecords lists records in a collection
func (c *Client) ListRecords(ctx context.Context, collection string, limit int) ([]Record, error) {
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d",
c.pdsEndpoint, c.did, collection, limit)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to list records: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
Records []Record `json:"records"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result.Records, nil
}
+273
View File
@@ -0,0 +1,273 @@
package atproto
import (
"encoding/json"
"time"
)
// Collection names for ATProto records
const (
// ManifestCollection is the collection name for container manifests
ManifestCollection = "io.atcr.manifest"
// TagCollection is the collection name for image tags
TagCollection = "io.atcr.tag"
// HoldCollection is the collection name for storage holds (BYOS)
HoldCollection = "io.atcr.hold"
// HoldCrewCollection is the collection name for hold crew (membership)
HoldCrewCollection = "io.atcr.hold.crew"
// SailorProfileCollection is the collection name for user profiles
SailorProfileCollection = "io.atcr.sailor.profile"
)
// ManifestRecord represents a container image manifest stored in ATProto
// This follows the OCI image manifest specification but stored as an ATProto record
type ManifestRecord struct {
// Type should be "io.atcr.manifest"
Type string `json:"$type"`
// Repository is the name of the repository (e.g., "myapp")
Repository string `json:"repository"`
// Digest is the content digest (e.g., "sha256:abc123...")
Digest string `json:"digest"`
// HoldEndpoint is the hold service endpoint where blobs are stored
// This is a historical reference that doesn't change even if user's default hold changes
HoldEndpoint string `json:"holdEndpoint"`
// MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json")
MediaType string `json:"mediaType"`
// SchemaVersion is the OCI schema version (typically 2)
SchemaVersion int `json:"schemaVersion"`
// Config references the image configuration blob
Config BlobReference `json:"config"`
// Layers references the filesystem layers
Layers []BlobReference `json:"layers"`
// Annotations contains arbitrary metadata
Annotations map[string]string `json:"annotations,omitempty"`
// Subject references another manifest (for attestations, signatures, etc.)
Subject *BlobReference `json:"subject,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
}
// BlobReference represents a reference to a blob (layer or config)
// Blobs are stored in S3 and referenced by digest
type BlobReference struct {
// MediaType of the blob
MediaType string `json:"mediaType"`
// Digest is the content digest (e.g., "sha256:abc123...")
Digest string `json:"digest"`
// Size in bytes
Size int64 `json:"size"`
// URLs where the blob can be retrieved (S3 URLs)
URLs []string `json:"urls,omitempty"`
// Annotations for the blob
Annotations map[string]string `json:"annotations,omitempty"`
}
// NewManifestRecord creates a new manifest record from OCI manifest JSON
func NewManifestRecord(repository, digest string, ociManifest []byte) (*ManifestRecord, error) {
// Parse the OCI manifest
var ociData struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config json.RawMessage `json:"config"`
Layers []json.RawMessage `json:"layers"`
Subject json.RawMessage `json:"subject,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}
if err := json.Unmarshal(ociManifest, &ociData); err != nil {
return nil, err
}
record := &ManifestRecord{
Type: ManifestCollection,
Repository: repository,
Digest: digest,
MediaType: ociData.MediaType,
SchemaVersion: ociData.SchemaVersion,
Annotations: ociData.Annotations,
CreatedAt: time.Now(),
}
// Parse config
if err := json.Unmarshal(ociData.Config, &record.Config); err != nil {
return nil, err
}
// Parse layers
record.Layers = make([]BlobReference, len(ociData.Layers))
for i, layer := range ociData.Layers {
if err := json.Unmarshal(layer, &record.Layers[i]); err != nil {
return nil, err
}
}
// Parse subject if present
if len(ociData.Subject) > 0 {
var subject BlobReference
if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
return nil, err
}
record.Subject = &subject
}
return record, nil
}
// ToOCIManifest converts the manifest record back to OCI manifest JSON
func (m *ManifestRecord) ToOCIManifest() ([]byte, error) {
ociManifest := map[string]interface{}{
"schemaVersion": m.SchemaVersion,
"mediaType": m.MediaType,
"config": m.Config,
"layers": m.Layers,
}
if m.Subject != nil {
ociManifest["subject"] = m.Subject
}
if len(m.Annotations) > 0 {
ociManifest["annotations"] = m.Annotations
}
return json.Marshal(ociManifest)
}
// TagRecord represents a tag pointing to a manifest
type TagRecord struct {
// Type should be "io.atcr.tag"
Type string `json:"$type"`
// Repository is the name of the repository
Repository string `json:"repository"`
// Tag is the tag name (e.g., "latest", "v1.0.0")
Tag string `json:"tag"`
// ManifestDigest is the digest of the manifest this tag points to
ManifestDigest string `json:"manifestDigest"`
// UpdatedAt timestamp
UpdatedAt time.Time `json:"updatedAt"`
}
// NewTagRecord creates a new tag record
func NewTagRecord(repository, tag, manifestDigest string) *TagRecord {
return &TagRecord{
Type: TagCollection,
Repository: repository,
Tag: tag,
ManifestDigest: manifestDigest,
UpdatedAt: time.Now(),
}
}
// HoldRecord represents a storage hold definition (BYOS)
// Users create these records to define where their blobs should be stored
type HoldRecord struct {
// Type should be "io.atcr.hold"
Type string `json:"$type"`
// Endpoint is the URL of the hold service
// e.g., "https://hold1.example.com"
Endpoint string `json:"endpoint"`
// Owner is the DID of the hold owner
Owner string `json:"owner"`
// Public controls whether this hold allows public blob reads (pulls) without auth
// Writes always require crew membership
Public bool `json:"public"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
}
// NewHoldRecord creates a new hold record
func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord {
return &HoldRecord{
Type: HoldCollection,
Endpoint: endpoint,
Owner: owner,
Public: public,
CreatedAt: time.Now(),
}
}
// HoldCrewRecord represents membership in a storage hold
// Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control
// Owner can add/remove crew members by creating/deleting these records in their own PDS
type HoldCrewRecord struct {
// Type should be "io.atcr.hold.crew"
Type string `json:"$type"`
// Hold is the AT URI of the hold record
// e.g., "at://did:plc:owner/io.atcr.hold/hold1"
Hold string `json:"hold"`
// Member is the DID of the crew member
Member string `json:"member"`
// Role defines permissions: "owner", "write", "read"
Role string `json:"role"`
// AddedAt timestamp
AddedAt time.Time `json:"createdAt"`
}
// NewHoldCrewRecord creates a new hold crew record
func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord {
return &HoldCrewRecord{
Type: HoldCrewCollection,
Hold: hold,
Member: member,
Role: role,
AddedAt: time.Now(),
}
}
// SailorProfileRecord represents a user's profile with registry preferences
// Stored in the user's PDS to configure default hold and other settings
type SailorProfileRecord struct {
// Type should be "io.atcr.sailor.profile"
Type string `json:"$type"`
// DefaultHold is the default hold endpoint for blob storage
// If null/empty, user has opted out of defaults
DefaultHold string `json:"defaultHold,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
// UpdatedAt timestamp
UpdatedAt time.Time `json:"updatedAt"`
}
// NewSailorProfileRecord creates a new sailor profile record
func NewSailorProfileRecord(defaultHold string) *SailorProfileRecord {
now := time.Now()
return &SailorProfileRecord{
Type: SailorProfileCollection,
DefaultHold: defaultHold,
CreatedAt: now,
UpdatedAt: now,
}
}
+170
View File
@@ -0,0 +1,170 @@
package atproto
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/distribution/distribution/v3"
"github.com/opencontainers/go-digest"
)
// ManifestStore implements distribution.ManifestService
// It stores manifests in ATProto as records
type ManifestStore struct {
client *Client
repository string
holdEndpoint string // Hold service endpoint where blobs are stored (for push)
did string // User's DID for cache key
lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull)
}
// NewManifestStore creates a new ATProto-backed manifest store
func NewManifestStore(client *Client, repository string, holdEndpoint string, did string) *ManifestStore {
return &ManifestStore{
client: client,
repository: repository,
holdEndpoint: holdEndpoint,
did: did,
}
}
// Exists checks if a manifest exists by digest
func (s *ManifestStore) Exists(ctx context.Context, dgst digest.Digest) (bool, error) {
rkey := digestToRKey(dgst)
_, err := s.client.GetRecord(ctx, ManifestCollection, rkey)
if err != nil {
// If not found, return false without error
if err.Error() == "record not found" {
return false, nil
}
return false, err
}
return true, nil
}
// Get retrieves a manifest by digest
func (s *ManifestStore) Get(ctx context.Context, dgst digest.Digest, options ...distribution.ManifestServiceOption) (distribution.Manifest, error) {
rkey := digestToRKey(dgst)
record, err := s.client.GetRecord(ctx, ManifestCollection, rkey)
if err != nil {
return nil, distribution.ErrManifestUnknownRevision{
Name: s.repository,
Revision: dgst,
}
}
var manifestRecord ManifestRecord
if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err)
}
// Store the hold endpoint for subsequent blob requests during pull
// The routing repository will cache this for concurrent blob fetches
s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint
// Convert back to OCI manifest
ociManifest, err := manifestRecord.ToOCIManifest()
if err != nil {
return nil, fmt.Errorf("failed to convert to OCI manifest: %w", err)
}
// Parse the manifest based on media type
// For now, we'll return the raw bytes wrapped in a manifest object
// In a full implementation, you'd use distribution's manifest parsing
return &rawManifest{
mediaType: manifestRecord.MediaType,
payload: ociManifest,
}, nil
}
// Put stores a manifest
func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest, options ...distribution.ManifestServiceOption) (digest.Digest, error) {
// Get the manifest payload
_, payload, err := manifest.Payload()
if err != nil {
return "", err
}
// Calculate digest
dgst := digest.FromBytes(payload)
// Create manifest record
manifestRecord, err := NewManifestRecord(s.repository, dgst.String(), payload)
if err != nil {
return "", fmt.Errorf("failed to create manifest record: %w", err)
}
// Set the hold endpoint where blobs are stored
manifestRecord.HoldEndpoint = s.holdEndpoint
// Store in ATProto
rkey := digestToRKey(dgst)
_, err = s.client.PutRecord(ctx, ManifestCollection, rkey, manifestRecord)
if err != nil {
return "", fmt.Errorf("failed to store manifest in ATProto: %w", err)
}
// Also handle tag if specified
for _, option := range options {
if tagOpt, ok := option.(distribution.WithTagOption); ok {
tag := tagOpt.Tag
tagRecord := NewTagRecord(s.repository, tag, dgst.String())
tagRKey := repositoryTagToRKey(s.repository, tag)
_, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord)
if err != nil {
return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
}
}
}
return dgst, nil
}
// Delete removes a manifest
func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error {
rkey := digestToRKey(dgst)
return s.client.DeleteRecord(ctx, ManifestCollection, rkey)
}
// digestToRKey converts a digest to an ATProto record key
// ATProto rkeys must be valid strings, so we use the digest string without the algorithm prefix
func digestToRKey(dgst digest.Digest) string {
// Remove the algorithm prefix (e.g., "sha256:")
return dgst.Encoded()
}
// repositoryTagToRKey converts a repository and tag to an ATProto record key
// ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$
func repositoryTagToRKey(repository, tag string) string {
// Combine repository and tag to create a unique key
// Replace invalid characters: slashes become dashes
key := fmt.Sprintf("%s_%s", repository, tag)
// Replace / with - (slash not allowed in rkeys)
key = strings.ReplaceAll(key, "/", "-")
return key
}
// GetLastFetchedHoldEndpoint returns the hold endpoint from the most recently fetched manifest
// This is used by the routing repository to cache the hold for blob requests
func (s *ManifestStore) GetLastFetchedHoldEndpoint() string {
return s.lastFetchedHoldEndpoint
}
// rawManifest is a simple implementation of distribution.Manifest
type rawManifest struct {
mediaType string
payload []byte
}
func (m *rawManifest) References() []distribution.Descriptor {
// TODO: Parse the manifest and return actual references
return nil
}
func (m *rawManifest) Payload() (string, []byte, error) {
return m.mediaType, m.payload, nil
}
+95
View File
@@ -0,0 +1,95 @@
package atproto
import (
"context"
"encoding/json"
"fmt"
)
// Profile record key is always "self" per lexicon
const ProfileRKey = "self"
// EnsureProfile checks if a user's profile exists and creates it if needed
// This should be called during authentication (OAuth exchange or token service)
// If defaultHoldEndpoint is provided and profile doesn't exist, creates profile with that default
func EnsureProfile(ctx context.Context, client *Client, defaultHoldEndpoint string) error {
// Check if profile already exists
profile, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
if err == nil && profile != nil {
// Profile exists, nothing to do
return nil
}
// Profile doesn't exist
// Only create if we have a default hold endpoint to set
if defaultHoldEndpoint == "" {
// No default configured, don't create empty profile
return nil
}
// Create new profile with default hold
newProfile := NewSailorProfileRecord(defaultHoldEndpoint)
_, err = client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, newProfile)
if err != nil {
return fmt.Errorf("failed to create sailor profile: %w", err)
}
return nil
}
// GetProfile retrieves the user's profile from their PDS
// Returns nil if profile doesn't exist
func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, error) {
record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
if err != nil {
// Check if it's a 404 (profile doesn't exist)
if isNotFoundError(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to get profile: %w", err)
}
// Parse the profile record
var profile SailorProfileRecord
if err := json.Unmarshal(record.Value, &profile); err != nil {
return nil, fmt.Errorf("failed to parse profile: %w", err)
}
return &profile, nil
}
// UpdateProfile updates the user's profile
func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error {
_, err := client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, profile)
if err != nil {
return fmt.Errorf("failed to update profile: %w", err)
}
return nil
}
// isNotFoundError checks if an error is a 404 not found error
func isNotFoundError(err error) bool {
// This is a simple check - in practice, you might need to parse the error more carefully
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "404") || contains(errStr, "not found") || contains(errStr, "RecordNotFound")
}
// contains checks if a string contains a substring (case-insensitive helper)
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+243
View File
@@ -0,0 +1,243 @@
package atproto
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
)
// Resolver handles DID/handle resolution for ATProto
type Resolver struct {
httpClient *http.Client
}
// NewResolver creates a new DID/handle resolver
func NewResolver() *Resolver {
return &Resolver{
httpClient: &http.Client{},
}
}
// ResolveIdentity resolves a handle or DID to a DID and PDS endpoint
// Input can be:
// - Handle: "alice.bsky.social" or "alice"
// - DID: "did:plc:xyz123abc"
func (r *Resolver) ResolveIdentity(ctx context.Context, identity string) (did string, pdsEndpoint string, err error) {
// Check if it's already a DID
if strings.HasPrefix(identity, "did:") {
did = identity
pdsEndpoint, err = r.ResolvePDS(ctx, did)
return did, pdsEndpoint, err
}
// Otherwise, resolve handle to DID
did, err = r.ResolveHandle(ctx, identity)
if err != nil {
return "", "", fmt.Errorf("failed to resolve handle %s: %w", identity, err)
}
// Then resolve DID to PDS
pdsEndpoint, err = r.ResolvePDS(ctx, did)
if err != nil {
return "", "", fmt.Errorf("failed to resolve PDS for DID %s: %w", did, err)
}
return did, pdsEndpoint, nil
}
// ResolveHandle resolves a handle to a DID using DNS TXT records or .well-known
func (r *Resolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
// Normalize handle
if !strings.Contains(handle, ".") {
// Default to .bsky.social if no domain provided
handle = handle + ".bsky.social"
}
// Try DNS TXT record first (faster)
if did, err := r.resolveHandleViaDNS(handle); err == nil && did != "" {
return did, nil
}
// Fall back to HTTPS .well-known method
url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch .well-known: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
did := strings.TrimSpace(string(body))
if strings.HasPrefix(did, "did:") {
return did, nil
}
}
return "", fmt.Errorf("could not resolve handle %s to DID", handle)
}
// resolveHandleViaDNS attempts to resolve handle via DNS TXT record at _atproto.<handle>
func (r *Resolver) resolveHandleViaDNS(handle string) (string, error) {
txtRecords, err := net.LookupTXT("_atproto." + handle)
if err != nil {
return "", err
}
// Look for a TXT record that starts with "did="
for _, record := range txtRecords {
if strings.HasPrefix(record, "did=") {
did := strings.TrimPrefix(record, "did=")
if strings.HasPrefix(did, "did:") {
return did, nil
}
}
}
return "", fmt.Errorf("no valid DID found in DNS TXT records")
}
// DIDDocument represents a simplified ATProto DID document
type DIDDocument struct {
ID string `json:"id"`
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
Service []struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
} `json:"service"`
}
// ResolvePDS resolves a DID to its PDS endpoint
func (r *Resolver) ResolvePDS(ctx context.Context, did string) (string, error) {
if !strings.HasPrefix(did, "did:") {
return "", fmt.Errorf("invalid DID format: %s", did)
}
// Parse DID method
parts := strings.Split(did, ":")
if len(parts) < 3 {
return "", fmt.Errorf("invalid DID format: %s", did)
}
method := parts[1]
var resolverURL string
switch method {
case "plc":
// Use PLC directory
resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
case "web":
// For did:web, convert to HTTPS URL
domain := parts[2]
resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
default:
return "", fmt.Errorf("unsupported DID method: %s", method)
}
req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
if err != nil {
return "", err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
}
var didDoc DIDDocument
if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
return "", fmt.Errorf("failed to parse DID document: %w", err)
}
// Find PDS service endpoint
for _, service := range didDoc.Service {
if service.Type == "AtprotoPersonalDataServer" {
return service.ServiceEndpoint, nil
}
}
return "", fmt.Errorf("no PDS endpoint found in DID document")
}
// ResolveDIDDocument fetches the full DID document for a DID
func (r *Resolver) ResolveDIDDocument(ctx context.Context, did string) (*DIDDocument, error) {
if !strings.HasPrefix(did, "did:") {
return nil, fmt.Errorf("invalid DID format: %s", did)
}
parts := strings.Split(did, ":")
if len(parts) < 3 {
return nil, fmt.Errorf("invalid DID format: %s", did)
}
method := parts[1]
var resolverURL string
switch method {
case "plc":
resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
case "web":
domain := parts[2]
resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
default:
return nil, fmt.Errorf("unsupported DID method: %s", method)
}
req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
if err != nil {
return nil, err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
}
var didDoc DIDDocument
if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
return nil, fmt.Errorf("failed to parse DID document: %w", err)
}
return &didDoc, nil
}
// ResolveHandle extracts the handle from a DID's alsoKnownAs field
func (r *Resolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) {
didDoc, err := r.ResolveDIDDocument(ctx, did)
if err != nil {
return "", err
}
// Look for handle in alsoKnownAs (format: "at://handle.bsky.social")
for _, aka := range didDoc.AlsoKnownAs {
if strings.HasPrefix(aka, "at://") {
handle := strings.TrimPrefix(aka, "at://")
return handle, nil
}
}
return "", fmt.Errorf("no handle found in DID document")
}
+129
View File
@@ -0,0 +1,129 @@
package atproto
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
atprotoclient "atcr.io/pkg/atproto"
)
// SessionValidator validates ATProto credentials
type SessionValidator struct {
resolver *atprotoclient.Resolver
httpClient *http.Client
}
// NewSessionValidator creates a new ATProto session validator
func NewSessionValidator() *SessionValidator {
return &SessionValidator{
resolver: atprotoclient.NewResolver(),
httpClient: &http.Client{},
}
}
// SessionResponse represents the response from createSession
type SessionResponse struct {
DID string `json:"did"`
Handle string `json:"handle"`
AccessJWT string `json:"accessJwt"`
RefreshJWT string `json:"refreshJwt"`
Email string `json:"email,omitempty"`
AccessToken string `json:"access_token,omitempty"` // Alternative field name
}
// ValidateCredentials validates username and password against ATProto
// Returns the user's DID and PDS endpoint if valid
func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) {
// Resolve identifier (handle or DID) to PDS endpoint
resolvedDID, pds, err := v.resolver.ResolveIdentity(ctx, identifier)
if err != nil {
return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
}
fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds)
// Create session with the PDS
fmt.Printf("DEBUG [atproto/session]: Creating session for %s at PDS %s\n", identifier, pds)
sessionResp, err := v.createSession(ctx, pds, identifier, password)
if err != nil {
fmt.Printf("DEBUG [atproto/session]: Session creation failed: %v\n", err)
return "", "", fmt.Errorf("authentication failed for %s at PDS %s: %w", identifier, pds, err)
}
fmt.Printf("DEBUG [atproto/session]: Session created successfully, DID=%s, Handle=%s, AccessJWT length=%d\n",
sessionResp.DID, sessionResp.Handle, len(sessionResp.AccessJWT))
return sessionResp.DID, pds, nil
}
// CreateSessionAndGetToken creates a session and returns the DID, PDS endpoint, and access token
func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, pdsEndpoint, accessToken string, err error) {
// Resolve identifier to PDS endpoint
did, pds, err := v.resolver.ResolveIdentity(ctx, identifier)
if err != nil {
return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
}
// Create session
sessionResp, err := v.createSession(ctx, pds, identifier, password)
if err != nil {
return "", "", "", fmt.Errorf("authentication failed: %w", err)
}
return sessionResp.DID, pds, sessionResp.AccessJWT, nil
}
// createSession calls com.atproto.server.createSession
func (v *SessionValidator) createSession(ctx context.Context, pdsEndpoint, identifier, password string) (*SessionResponse, error) {
payload := map[string]string{
"identifier": identifier,
"password": password,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
url := fmt.Sprintf("%s/xrpc/com.atproto.server.createSession", pdsEndpoint)
fmt.Printf("DEBUG [atproto/session]: POST %s\n", url)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := v.httpClient.Do(req)
if err != nil {
fmt.Printf("DEBUG [atproto/session]: HTTP request failed: %v\n", err)
return nil, fmt.Errorf("failed to create session: %w", err)
}
defer resp.Body.Close()
fmt.Printf("DEBUG [atproto/session]: Got HTTP status %d\n", resp.StatusCode)
if resp.StatusCode == http.StatusUnauthorized {
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Printf("DEBUG [atproto/session]: Unauthorized response: %s\n", string(bodyBytes))
return nil, fmt.Errorf("invalid credentials")
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Printf("DEBUG [atproto/session]: Error response: %s\n", string(bodyBytes))
return nil, fmt.Errorf("create session failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var sessionResp SessionResponse
if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &sessionResp, nil
}
+90
View File
@@ -0,0 +1,90 @@
package atproto
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
mainAtproto "atcr.io/pkg/atproto"
)
// TokenValidator validates ATProto OAuth access tokens
type TokenValidator struct {
httpClient *http.Client
}
// NewTokenValidator creates a new token validator
func NewTokenValidator() *TokenValidator {
return &TokenValidator{
httpClient: &http.Client{},
}
}
// SessionInfo represents the response from com.atproto.server.getSession
type SessionInfo struct {
DID string `json:"did"`
Handle string `json:"handle"`
Email string `json:"email,omitempty"`
EmailConfirmed bool `json:"emailConfirmed,omitempty"`
Active bool `json:"active,omitempty"`
}
// ValidateToken validates an ATProto OAuth access token by calling getSession
// Returns the user's DID and handle if the token is valid
func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken string) (*SessionInfo, error) {
// Call com.atproto.server.getSession with the access token
url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add bearer token
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("invalid or expired token")
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var session SessionInfo
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("failed to decode session: %w", err)
}
// Validate required fields
if session.DID == "" {
return nil, fmt.Errorf("session response missing DID")
}
if session.Handle == "" {
return nil, fmt.Errorf("session response missing handle")
}
return &session, nil
}
// ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint
func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken string) (*SessionInfo, error) {
// Resolve handle to PDS endpoint
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle)
if err != nil {
return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
}
// Validate token against the PDS
return v.ValidateToken(ctx, pdsEndpoint, accessToken)
}
+134
View File
@@ -0,0 +1,134 @@
package exchange
import (
"encoding/json"
"fmt"
"net/http"
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/atproto"
"atcr.io/pkg/auth/token"
)
// Handler handles /auth/exchange requests (OAuth token -> JWT token)
type Handler struct {
issuer *token.Issuer
validator *atproto.TokenValidator
defaultHoldEndpoint string
}
// NewHandler creates a new exchange handler
func NewHandler(issuer *token.Issuer, defaultHoldEndpoint string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewTokenValidator(),
defaultHoldEndpoint: defaultHoldEndpoint,
}
}
// ExchangeRequest represents the request to exchange an OAuth token
type ExchangeRequest struct {
AccessToken string `json:"access_token"` // ATProto OAuth access token
Handle string `json:"handle"` // User's handle (required for PDS resolution)
Scope []string `json:"scope"` // Requested Docker scopes
}
// ExchangeResponse represents the response from /auth/exchange
type ExchangeResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
// ServeHTTP handles the exchange request
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req ExchangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
if req.AccessToken == "" {
http.Error(w, "access_token is required", http.StatusBadRequest)
return
}
// Validate the ATProto OAuth token via the PDS
// We need the handle to resolve the PDS endpoint
if req.Handle == "" {
http.Error(w, "handle required to validate token", http.StatusBadRequest)
return
}
session, err := h.validator.ValidateTokenWithResolver(r.Context(), req.Handle, req.AccessToken)
if err != nil {
http.Error(w, fmt.Sprintf("token validation failed: %v", err), http.StatusUnauthorized)
return
}
// Use DID and handle from validated session
did := session.DID
handle := session.Handle
// Parse and validate scopes
access, err := auth.ParseScope(req.Scope)
if err != nil {
http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest)
return
}
// Validate access permissions
if err := auth.ValidateAccess(did, handle, access); err != nil {
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
return
}
// Ensure user profile exists (creates with default hold if needed)
// Resolve PDS endpoint for profile management
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), handle)
if err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
} else {
// Create ATProto client with validated token
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, req.AccessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
}
}
// Issue JWT token
tokenString, err := h.issuer.Issue(did, access)
if err != nil {
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
return
}
// Return response
resp := ExchangeResponse{
Token: tokenString,
AccessToken: tokenString,
ExpiresIn: int(h.issuer.Expiration().Seconds()),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError)
return
}
}
// RegisterRoutes registers the exchange handler with the provided mux
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("/auth/exchange", h)
}
+200
View File
@@ -0,0 +1,200 @@
package oauth
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
atprotoclient "atcr.io/pkg/atproto"
"authelia.com/client/oauth2"
)
// Client is an OAuth client for ATProto with DPoP support
type Client struct {
config *oauth2.Config
dpopKey *ecdsa.PrivateKey
dpopTransport *DPoPTransport
resolver *atprotoclient.Resolver
clientID string
redirectURI string
metadata *AuthServerMetadata
}
// NewClient creates a new OAuth client for ATProto
func NewClient(clientID, redirectURI string) (*Client, error) {
// Generate DPoP key
dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
}
return &Client{
dpopKey: dpopKey,
dpopTransport: NewDPoPTransport(http.DefaultTransport, dpopKey),
resolver: atprotoclient.NewResolver(),
clientID: clientID,
redirectURI: redirectURI,
}, nil
}
// InitializeForHandle discovers the authorization server for a given handle/DID
func (c *Client) InitializeForHandle(ctx context.Context, handle string) error {
// Resolve handle to DID and PDS
_, pdsEndpoint, err := c.resolver.ResolveIdentity(ctx, handle)
if err != nil {
return fmt.Errorf("failed to resolve identity: %w", err)
}
// Discover authorization server metadata
metadata, err := DiscoverAuthServer(ctx, pdsEndpoint)
if err != nil {
return fmt.Errorf("failed to discover authorization server: %w", err)
}
c.metadata = metadata
// Configure OAuth2 client
c.config = &oauth2.Config{
ClientID: c.clientID,
Endpoint: oauth2.Endpoint{
AuthURL: metadata.AuthorizationEndpoint,
TokenURL: metadata.TokenEndpoint,
},
RedirectURL: c.redirectURI,
Scopes: []string{"atproto"},
}
return nil
}
// AuthorizeURL generates the authorization URL with PKCE
func (c *Client) AuthorizeURL(state string) (authURL string, codeVerifier string, err error) {
if c.config == nil {
return "", "", fmt.Errorf("client not initialized - call InitializeForHandle first")
}
// Generate PKCE code verifier
codeVerifier, err = generateCodeVerifier()
if err != nil {
return "", "", fmt.Errorf("failed to generate code verifier: %w", err)
}
// Generate code challenge
codeChallenge := generateCodeChallenge(codeVerifier)
// Use PAR (Pushed Authorization Request) if supported
if c.metadata.PushedAuthorizationRequestEndpoint != "" {
authURL, err = c.authorizeURLWithPAR(state, codeChallenge)
if err != nil {
return "", "", fmt.Errorf("PAR failed: %w", err)
}
} else {
// Fallback to standard authorization
authURL = c.config.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
}
return authURL, codeVerifier, nil
}
// authorizeURLWithPAR uses Pushed Authorization Request
func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) {
// Create HTTP client with DPoP transport
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{
Transport: c.dpopTransport,
})
// Use authelia's PushedAuth method
authURL, _, err := c.config.PushedAuth(ctx, state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
if err != nil {
return "", err
}
return authURL.String(), nil
}
// Exchange exchanges an authorization code for an access token
func (c *Client) Exchange(ctx context.Context, code, codeVerifier string) (*oauth2.Token, error) {
if c.config == nil {
return nil, fmt.Errorf("client not initialized")
}
// Create HTTP client with DPoP transport
ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{
Transport: c.dpopTransport,
})
// Exchange the code for a token
token, err := c.config.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", codeVerifier),
)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
return token, nil
}
// RefreshToken refreshes an access token using a refresh token
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error) {
if c.config == nil {
return nil, fmt.Errorf("client not initialized")
}
// Create HTTP client with DPoP transport
ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{
Transport: c.dpopTransport,
})
// Create a token source with the refresh token
token := &oauth2.Token{
RefreshToken: refreshToken,
}
// Refresh the token
newToken, err := c.config.TokenSource(ctx, token).Token()
if err != nil {
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
return newToken, nil
}
// DPoPKey returns the DPoP private key
func (c *Client) DPoPKey() *ecdsa.PrivateKey {
return c.dpopKey
}
// SetDPoPKey sets the DPoP private key (useful when loading from storage)
func (c *Client) SetDPoPKey(key *ecdsa.PrivateKey) {
c.dpopKey = key
c.dpopTransport = NewDPoPTransport(http.DefaultTransport, key)
}
// generateCodeVerifier generates a PKCE code verifier
func generateCodeVerifier() (string, error) {
// Generate 32 random bytes
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Base64 URL encode
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// generateCodeChallenge generates a PKCE code challenge from a verifier
func generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
+67
View File
@@ -0,0 +1,67 @@
package oauth
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// AuthServerMetadata represents the OAuth authorization server metadata
// as defined in RFC 8414
type AuthServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint,omitempty"`
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
JWKsURI string `json:"jwks_uri,omitempty"`
ScopesSupported []string `json:"scopes_supported,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"`
}
// DiscoverAuthServer discovers the OAuth authorization server metadata
// from the PDS endpoint using the well-known discovery endpoint
func DiscoverAuthServer(ctx context.Context, pdsEndpoint string) (*AuthServerMetadata, error) {
// Construct the well-known URL per RFC 8414
discoveryURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", pdsEndpoint)
req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create discovery request: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch authorization server metadata: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("authorization server discovery failed with status %d", resp.StatusCode)
}
var metadata AuthServerMetadata
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
return nil, fmt.Errorf("failed to decode authorization server metadata: %w", err)
}
// Validate required fields
if metadata.Issuer == "" {
return nil, fmt.Errorf("authorization server metadata missing issuer")
}
if metadata.AuthorizationEndpoint == "" {
return nil, fmt.Errorf("authorization server metadata missing authorization_endpoint")
}
if metadata.TokenEndpoint == "" {
return nil, fmt.Errorf("authorization server metadata missing token_endpoint")
}
return &metadata, nil
}
+50
View File
@@ -0,0 +1,50 @@
package oauth
import (
"encoding/json"
"net/http"
)
// ClientMetadata represents the OAuth client metadata document
// This follows the ATProto OAuth client metadata specification
type ClientMetadata struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
ClientURI string `json:"client_uri,omitempty"`
RedirectURIs []string `json:"redirect_uris"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
Scope string `json:"scope"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
ApplicationType string `json:"application_type"`
DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
}
// NewClientMetadata creates a client metadata document for ATProto OAuth
func NewClientMetadata(clientID string, redirectURIs []string) *ClientMetadata {
return &ClientMetadata{
ClientID: clientID,
ClientName: "ATCR Registry",
ClientURI: "https://github.com/yourusername/atcr.io",
RedirectURIs: redirectURIs,
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
Scope: "atproto",
TokenEndpointAuthMethod: "none", // Public client
ApplicationType: "native",
DPoPBoundAccessTokens: true,
}
}
// ServeMetadata returns an HTTP handler that serves the client metadata JSON
func ServeMetadata(metadata *ClientMetadata) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
if err := json.NewEncoder(w).Encode(metadata); err != nil {
http.Error(w, "failed to encode metadata", http.StatusInternalServerError)
return
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package oauth
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
)
// TokenStore represents persisted OAuth tokens and DPoP key
type TokenStore struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresAt time.Time `json:"expires_at"`
DPoPKeyPEM string `json:"dpop_key_pem"` // ECDSA private key in PEM format
DID string `json:"did,omitempty"`
Handle string `json:"handle,omitempty"`
}
// Save persists the token store to a file
func (s *TokenStore) Save(path string) error {
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create token directory: %w", err)
}
// Marshal to JSON
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal token store: %w", err)
}
// Write to file with secure permissions
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("failed to write token store: %w", err)
}
return nil
}
// LoadTokenStore loads a token store from a file
func LoadTokenStore(path string) (*TokenStore, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read token store: %w", err)
}
var store TokenStore
if err := json.Unmarshal(data, &store); err != nil {
return nil, fmt.Errorf("failed to unmarshal token store: %w", err)
}
return &store, nil
}
// GetDPoPKey decodes the PEM-encoded DPoP private key
func (s *TokenStore) GetDPoPKey() (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(s.DPoPKeyPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse EC private key: %w", err)
}
return key, nil
}
// SetDPoPKey encodes the DPoP private key as PEM
func (s *TokenStore) SetDPoPKey(key *ecdsa.PrivateKey) error {
keyBytes, err := x509.MarshalECPrivateKey(key)
if err != nil {
return fmt.Errorf("failed to marshal EC private key: %w", err)
}
pemBlock := &pem.Block{
Type: "EC PRIVATE KEY",
Bytes: keyBytes,
}
s.DPoPKeyPEM = string(pem.EncodeToMemory(pemBlock))
return nil
}
// IsExpired checks if the access token is expired
func (s *TokenStore) IsExpired() bool {
// Add a 60 second buffer to refresh before actual expiry
return time.Now().After(s.ExpiresAt.Add(-60 * time.Second))
}
+128
View File
@@ -0,0 +1,128 @@
package oauth
import (
"crypto/ecdsa"
"fmt"
"net/http"
"sync"
"time"
"github.com/AxisCommunications/go-dpop"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// DPoPTransport is an HTTP RoundTripper that adds DPoP headers to requests
type DPoPTransport struct {
base http.RoundTripper
dpopKey *ecdsa.PrivateKey
nonce string
mu sync.RWMutex // Protects nonce
}
// NewDPoPTransport creates a new DPoP transport with the given private key
func NewDPoPTransport(base http.RoundTripper, dpopKey *ecdsa.PrivateKey) *DPoPTransport {
if base == nil {
base = http.DefaultTransport
}
return &DPoPTransport{
base: base,
dpopKey: dpopKey,
}
}
// RoundTrip implements http.RoundTripper
func (t *DPoPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
reqCopy := req.Clone(req.Context())
// Generate and add DPoP proof
if err := t.addDPoPHeader(reqCopy); err != nil {
return nil, fmt.Errorf("failed to add DPoP header: %w", err)
}
// Execute the request
resp, err := t.base.RoundTrip(reqCopy)
if err != nil {
return nil, err
}
// Check for DPoP nonce in response
if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" {
t.mu.Lock()
t.nonce = nonce
t.mu.Unlock()
}
// If we get 401 with use_dpop_nonce error, retry with nonce
if resp.StatusCode == http.StatusUnauthorized {
wwwAuth := resp.Header.Get("WWW-Authenticate")
if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" && wwwAuth != "" {
// Update nonce and retry
t.mu.Lock()
t.nonce = nonce
t.mu.Unlock()
// Close the first response
resp.Body.Close()
// Retry with new nonce
reqRetry := req.Clone(req.Context())
if err := t.addDPoPHeader(reqRetry); err != nil {
return nil, fmt.Errorf("failed to add DPoP header on retry: %w", err)
}
return t.base.RoundTrip(reqRetry)
}
}
return resp, nil
}
// addDPoPHeader generates and adds a DPoP proof header to the request
func (t *DPoPTransport) addDPoPHeader(req *http.Request) error {
// Read current nonce
t.mu.RLock()
nonce := t.nonce
t.mu.RUnlock()
// Create DPoP proof claims
claims := &dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
ID: uuid.New().String(),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Method: dpop.HTTPVerb(req.Method),
URL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path,
}
// Add nonce if we have one
if nonce != "" {
claims.Nonce = nonce
}
// Generate DPoP proof
// go-dpop automatically adds the JWK to the header
proofString, err := dpop.Create(jwt.SigningMethodES256, claims, t.dpopKey)
if err != nil {
return fmt.Errorf("failed to create DPoP proof: %w", err)
}
// Add DPoP header
req.Header.Set("DPoP", proofString)
return nil
}
// SetNonce manually sets the DPoP nonce (useful for initial requests)
func (t *DPoPTransport) SetNonce(nonce string) {
t.mu.Lock()
defer t.mu.Unlock()
t.nonce = nonce
}
// GetNonce returns the current DPoP nonce
func (t *DPoPTransport) GetNonce() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.nonce
}
+81
View File
@@ -0,0 +1,81 @@
package auth
import (
"fmt"
"strings"
)
// ParseScope parses Docker registry scope strings into AccessEntry structures
// Scope format: "repository:alice/myapp:pull,push"
// Multiple scopes can be provided
func ParseScope(scopes []string) ([]AccessEntry, error) {
var access []AccessEntry
for _, scope := range scopes {
if scope == "" {
continue
}
parts := strings.Split(scope, ":")
if len(parts) < 2 {
return nil, fmt.Errorf("invalid scope format: %s", scope)
}
resourceType := parts[0]
var name string
var actions []string
if len(parts) == 2 {
// Format: "repository:alice/myapp" (no actions specified)
name = parts[1]
} else if len(parts) == 3 {
// Format: "repository:alice/myapp:pull,push"
name = parts[1]
if parts[2] != "" {
actions = strings.Split(parts[2], ",")
}
} else {
return nil, fmt.Errorf("invalid scope format: %s", scope)
}
access = append(access, AccessEntry{
Type: resourceType,
Name: name,
Actions: actions,
})
}
return access, nil
}
// ValidateAccess checks if the requested access is allowed for the user
// For ATCR, users can only push to repositories under their own handle/DID
func ValidateAccess(userDID, userHandle string, access []AccessEntry) error {
for _, entry := range access {
if entry.Type != "repository" {
continue
}
// Extract the owner from repository name (e.g., "alice/myapp" -> "alice")
parts := strings.SplitN(entry.Name, "/", 2)
if len(parts) < 1 {
return fmt.Errorf("invalid repository name: %s", entry.Name)
}
repoOwner := parts[0]
// Check if user is trying to access their own repository
// They can use either their handle or DID
if repoOwner != userHandle && repoOwner != userDID {
// For push/delete operations, strict ownership check
for _, action := range entry.Actions {
if action == "push" || action == "delete" {
return fmt.Errorf("user %s cannot %s to repository %s", userHandle, action, entry.Name)
}
}
}
}
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package token
import (
"time"
"atcr.io/pkg/auth"
"github.com/golang-jwt/jwt/v5"
)
// Claims represents the JWT claims for registry authentication
// This follows the Docker Registry token specification
type Claims struct {
jwt.RegisteredClaims
Access []auth.AccessEntry `json:"access,omitempty"`
}
// NewClaims creates a new Claims structure with standard fields
func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry) *Claims {
now := time.Now()
return &Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: subject, // User's DID
Issuer: issuer, // "atcr.io"
Audience: jwt.ClaimStrings{audience}, // Service name
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(expiration)),
},
Access: access,
}
}
+152
View File
@@ -0,0 +1,152 @@
package token
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/atproto"
)
// Handler handles /auth/token requests
type Handler struct {
issuer *Issuer
validator *atproto.SessionValidator
defaultHoldEndpoint string
}
// NewHandler creates a new token handler
func NewHandler(issuer *Issuer, defaultHoldEndpoint string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewSessionValidator(),
defaultHoldEndpoint: defaultHoldEndpoint,
}
}
// TokenResponse represents the response from /auth/token
type TokenResponse struct {
Token string `json:"token,omitempty"` // Legacy field
AccessToken string `json:"access_token,omitempty"` // Standard field
ExpiresIn int `json:"expires_in,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
}
// ServeHTTP handles the token request
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Printf("DEBUG [token/handler]: Received %s request to %s\n", r.Method, r.URL.Path)
// Only accept GET requests (per Docker spec)
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract Basic auth credentials
username, password, ok := r.BasicAuth()
if !ok {
fmt.Printf("DEBUG [token/handler]: No Basic auth credentials provided\n")
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
fmt.Printf("DEBUG [token/handler]: Got Basic auth for username=%s, password length=%d\n", username, len(password))
// Parse query parameters
_ = r.URL.Query().Get("service") // service parameter - validated by issuer
scopeParam := r.URL.Query().Get("scope")
// Parse scopes
var scopes []string
if scopeParam != "" {
scopes = strings.Split(scopeParam, " ")
}
access, err := auth.ParseScope(scopes)
if err != nil {
http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest)
return
}
// Validate credentials against ATProto and get access token
fmt.Printf("DEBUG [token/handler]: Validating credentials for %s\n", username)
did, _, accessToken, err := h.validator.CreateSessionAndGetToken(r.Context(), username, password)
if err != nil {
fmt.Printf("DEBUG [token/handler]: Credential validation failed: %v\n", err)
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
fmt.Printf("DEBUG [token/handler]: Credentials validated successfully, DID=%s, AccessToken length=%d\n", did, len(accessToken))
// Cache the access token for later use (e.g., when pushing manifests)
// TTL of 2 hours (ATProto tokens typically last longer)
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did)
// Ensure user profile exists (creates with default hold if needed)
// Resolve PDS endpoint for profile management
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username)
if err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
} else {
// Create ATProto client with validated token
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
}
}
// Validate that the user has permission for the requested access
if err := auth.ValidateAccess(did, username, access); err != nil {
fmt.Printf("DEBUG [token/handler]: Access validation failed: %v\n", err)
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
return
}
fmt.Printf("DEBUG [token/handler]: Access validated for DID=%s\n", did)
// Issue JWT token
tokenString, err := h.issuer.Issue(did, access)
if err != nil {
fmt.Printf("DEBUG [token/handler]: Failed to issue token: %v\n", err)
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("DEBUG [token/handler]: Issued JWT token (length=%d) for DID=%s\n", len(tokenString), did)
fmt.Printf("DEBUG [token/handler]: JWT Token: %s\n", tokenString)
// Return token response
now := time.Now()
expiresIn := int(h.issuer.expiration.Seconds())
resp := TokenResponse{
Token: tokenString,
AccessToken: tokenString,
ExpiresIn: expiresIn,
IssuedAt: now.Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError)
return
}
}
// RegisterRoutes registers the token handler with the provided mux
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("/auth/token", h)
}
+194
View File
@@ -0,0 +1,194 @@
package token
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"atcr.io/pkg/auth"
"github.com/golang-jwt/jwt/v5"
)
// Issuer handles JWT token creation and signing
type Issuer struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
certificate []byte // DER-encoded certificate
issuer string
service string
expiration time.Duration
}
// NewIssuer creates a new JWT issuer
func NewIssuer(privateKeyPath, issuer, service string, expiration time.Duration) (*Issuer, error) {
privateKey, err := loadOrGenerateKey(privateKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load private key: %w", err)
}
// Load the certificate for x5c header
certPath := strings.TrimSuffix(privateKeyPath, ".pem") + ".crt"
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("failed to read certificate: %w", err)
}
// Parse PEM to get DER-encoded certificate
block, _ := pem.Decode(certPEM)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("failed to decode certificate PEM")
}
return &Issuer{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
certificate: block.Bytes, // DER-encoded certificate
issuer: issuer,
service: service,
expiration: expiration,
}, nil
}
// Issue creates and signs a new JWT token
func (i *Issuer) Issue(subject string, access []auth.AccessEntry) (string, error) {
claims := NewClaims(subject, i.issuer, i.service, i.expiration, access)
fmt.Printf("DEBUG [token/issuer]: Creating token with issuer=%s, service=%s, subject=%s, access=%v\n",
i.issuer, i.service, subject, access)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
// Add x5c header - embeds the certificate chain in the JWT
// This is base64-encoded DER certificate(s)
certChain := []string{
base64.StdEncoding.EncodeToString(i.certificate),
}
token.Header["x5c"] = certChain
signedToken, err := token.SignedString(i.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
fmt.Printf("DEBUG [token/issuer]: Successfully signed token with x5c header\n")
return signedToken, nil
}
// PublicKey returns the public key for token verification
func (i *Issuer) PublicKey() *rsa.PublicKey {
return i.publicKey
}
// Expiration returns the token expiration duration
func (i *Issuer) Expiration() time.Duration {
return i.expiration
}
// loadOrGenerateKey loads an existing RSA private key or generates a new one
func loadOrGenerateKey(path string) (*rsa.PrivateKey, error) {
// Try to load existing key
if _, err := os.Stat(path); err == nil {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key file: %w", err)
}
block, _ := pem.Decode(keyData)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return nil, fmt.Errorf("failed to decode PEM block containing private key")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
// Ensure certificate exists
certPath := strings.TrimSuffix(path, ".pem") + ".crt"
if _, err := os.Stat(certPath); os.IsNotExist(err) {
// Certificate doesn't exist, generate it
if err := generateCertificate(privateKey, certPath); err != nil {
return nil, fmt.Errorf("failed to generate certificate: %w", err)
}
}
return privateKey, nil
}
// Generate new key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create key directory: %w", err)
}
// Save key to file
keyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: keyBytes,
})
if err := os.WriteFile(path, keyPEM, 0600); err != nil {
return nil, fmt.Errorf("failed to write private key: %w", err)
}
// Also generate a self-signed certificate for the public key
certPath := strings.TrimSuffix(path, ".pem") + ".crt"
if err := generateCertificate(privateKey, certPath); err != nil {
return nil, fmt.Errorf("failed to generate certificate: %w", err)
}
return privateKey, nil
}
// generateCertificate creates a self-signed certificate for JWT validation
func generateCertificate(privateKey *rsa.PrivateKey, certPath string) error {
// Create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"ATCR"},
CommonName: "ATCR Token Signing Certificate",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Create self-signed certificate
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Encode certificate to PEM
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
// Write certificate to file
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
return fmt.Errorf("failed to write certificate: %w", err)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package auth
import (
"sync"
"time"
)
// TokenCacheEntry represents a cached access token
type TokenCacheEntry struct {
AccessToken string
ExpiresAt time.Time
}
// TokenCache is a simple in-memory cache for ATProto access tokens
type TokenCache struct {
mu sync.RWMutex
tokens map[string]*TokenCacheEntry
}
var globalTokenCache = &TokenCache{
tokens: make(map[string]*TokenCacheEntry),
}
// GetGlobalTokenCache returns the global token cache instance
func GetGlobalTokenCache() *TokenCache {
return globalTokenCache
}
// Set stores an access token for a DID
func (tc *TokenCache) Set(did, accessToken string, ttl time.Duration) {
tc.mu.Lock()
defer tc.mu.Unlock()
tc.tokens[did] = &TokenCacheEntry{
AccessToken: accessToken,
ExpiresAt: time.Now().Add(ttl),
}
}
// Get retrieves an access token for a DID
func (tc *TokenCache) Get(did string) (string, bool) {
tc.mu.RLock()
defer tc.mu.RUnlock()
entry, ok := tc.tokens[did]
if !ok {
return "", false
}
// Check if expired
if time.Now().After(entry.ExpiresAt) {
return "", false
}
return entry.AccessToken, true
}
// Delete removes a cached token
func (tc *TokenCache) Delete(did string) {
tc.mu.Lock()
defer tc.mu.Unlock()
delete(tc.tokens, did)
}
+8
View File
@@ -0,0 +1,8 @@
package auth
// AccessEntry represents access permissions for a resource
type AccessEntry struct {
Type string `json:"type"` // "repository"
Name string `json:"name,omitempty"` // e.g., "alice/myapp"
Actions []string `json:"actions,omitempty"` // e.g., ["pull", "push"]
}
+190
View File
@@ -0,0 +1,190 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/distribution/distribution/v3"
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/reference"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/storage"
)
func init() {
// Register the name resolution middleware
registrymw.Register("atproto-resolver", initATProtoResolver)
}
// NamespaceResolver wraps a namespace and resolves names
type NamespaceResolver struct {
distribution.Namespace
resolver *atproto.Resolver
defaultStorageEndpoint string
}
// initATProtoResolver initializes the name resolution middleware
func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]interface{}) (distribution.Namespace, error) {
resolver := atproto.NewResolver()
// Get default storage endpoint from config (optional)
defaultStorageEndpoint := ""
if endpoint, ok := options["default_storage_endpoint"].(string); ok {
defaultStorageEndpoint = endpoint
}
return &NamespaceResolver{
Namespace: ns,
resolver: resolver,
defaultStorageEndpoint: defaultStorageEndpoint,
}, nil
}
// Repository resolves the repository name and delegates to underlying namespace
// Handles names like:
// - atcr.io/alice/myimage → resolve alice to DID
// - atcr.io/did:plc:xyz123/myimage → use DID directly
func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Named) (distribution.Repository, error) {
// Extract the first part of the name (username or DID)
repoPath := name.Name()
parts := strings.SplitN(repoPath, "/", 2)
if len(parts) < 2 {
// No user specified, use default or return error
return nil, fmt.Errorf("repository name must include user: %s", repoPath)
}
identity := parts[0]
imageName := parts[1]
// Resolve identity to DID and PDS
did, pdsEndpoint, err := nr.resolver.ResolveIdentity(ctx, identity)
if err != nil {
return nil, fmt.Errorf("failed to resolve identity %s: %w", identity, err)
}
// Store resolved DID and PDS in context for downstream use
ctx = context.WithValue(ctx, "atproto.did", did)
ctx = context.WithValue(ctx, "atproto.pds", pdsEndpoint)
ctx = context.WithValue(ctx, "atproto.identity", identity)
fmt.Printf("DEBUG [registry/middleware]: Set context values: did=%s, pds=%s, identity=%s\n", did, pdsEndpoint, identity)
// Query for storage endpoint - either user's hold or default hold service
storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint)
if storageEndpoint == "" {
// This is a fatal configuration error - registry cannot function without a hold service
return nil, fmt.Errorf("no storage endpoint configured: ensure default_storage_endpoint is set in middleware config")
}
ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint)
fmt.Printf("DEBUG [registry/middleware]: Using storage endpoint: %s\n", storageEndpoint)
// Create a new reference with identity/image format
// Use the identity (or DID) as the namespace to ensure canonical format
// This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path)
canonicalName := fmt.Sprintf("%s/%s", identity, imageName)
ref, err := reference.ParseNamed(canonicalName)
if err != nil {
return nil, fmt.Errorf("invalid image name %s: %w", imageName, err)
}
// Delegate to underlying namespace with modified name
repo, err := nr.Namespace.Repository(ctx, ref)
if err != nil {
return nil, err
}
// Wrap the repository with our routing repository
// Get the cached access token for this DID
accessToken, ok := auth.GetGlobalTokenCache().Get(did)
if !ok {
fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s\n", did)
accessToken = "" // Will fail on manifest push, but let it try
} else {
fmt.Printf("DEBUG [registry/middleware]: Using cached access token for DID=%s (length=%d)\n", did, len(accessToken))
}
// This is where we inject ATProto + storage routing
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
// IMPORTANT: Use only the image name (not identity/image) for ATProto storage
// ATProto records are scoped to the user's DID, so we don't need the identity prefix
// Example: "evan.jarrett.net/debian" -> store as "debian"
repositoryName := imageName
fmt.Printf("DEBUG [registry/middleware]: Creating RoutingRepository for image=%s (ATProto repo name)\n", repositoryName)
// Create routing repository - routes manifests to ATProto, blobs to hold service
// The registry is stateless - no local storage is used
// Pass storage endpoint and DID as parameters (can't use context as it gets lost)
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did)
return routingRepo, nil
}
// Repositories delegates to underlying namespace
func (nr *NamespaceResolver) Repositories(ctx context.Context, repos []string, last string) (int, error) {
return nr.Namespace.Repositories(ctx, repos, last)
}
// Blobs delegates to underlying namespace
func (nr *NamespaceResolver) Blobs() distribution.BlobEnumerator {
return nr.Namespace.Blobs()
}
// BlobStatter delegates to underlying namespace
func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
return nr.Namespace.BlobStatter()
}
// findStorageEndpoint determines which hold endpoint to use for blob storage
// Priority order:
// 1. User's sailor profile defaultHold (if set)
// 2. User's own hold record (io.atcr.hold)
// 3. AppView's default hold endpoint
// Returns the storage endpoint URL, or empty string if none configured
func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string {
// Create ATProto client (without auth - reading public records)
client := atproto.NewClient(pdsEndpoint, did, "")
// 1. Check for sailor profile
profile, err := atproto.GetProfile(ctx, client)
if err != nil {
// Error reading profile (not a 404) - log and continue
fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err)
}
if profile != nil && profile.DefaultHold != "" {
// Profile exists with defaultHold set - use it
return profile.DefaultHold
}
// 2. Profile doesn't exist or defaultHold is null/empty
// Check for user's own hold records
records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
if err != nil {
// Failed to query holds, use default
return nr.defaultStorageEndpoint
}
// Find the first hold record
for _, record := range records {
var holdRecord atproto.HoldRecord
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
continue
}
// Return the endpoint from the first hold
if holdRecord.Endpoint != "" {
return holdRecord.Endpoint
}
}
// 3. No profile defaultHold and no own hold records - use AppView default
return nr.defaultStorageEndpoint
}
+57
View File
@@ -0,0 +1,57 @@
package middleware
import (
"context"
"fmt"
"github.com/distribution/distribution/v3"
repositorymw "github.com/distribution/distribution/v3/registry/middleware/repository"
"atcr.io/pkg/atproto"
"atcr.io/pkg/storage"
)
func init() {
// Register the ATProto routing middleware
repositorymw.Register("atproto-router", initATProtoRouter)
}
// initATProtoRouter initializes the ATProto routing middleware
func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]interface{}) (distribution.Repository, error) {
fmt.Printf("DEBUG [repository/middleware]: Initializing atproto-router for repo=%s\n", repo.Named().Name())
fmt.Printf("DEBUG [repository/middleware]: Context values: atproto.did=%v, atproto.pds=%v\n",
ctx.Value("atproto.did"), ctx.Value("atproto.pds"))
// Extract DID and PDS from context (set by registry middleware)
did, ok := ctx.Value("atproto.did").(string)
if !ok || did == "" {
fmt.Printf("DEBUG [repository/middleware]: DID not found in context, ok=%v, did=%q\n", ok, did)
return nil, fmt.Errorf("did is required for atproto-router middleware")
}
pdsEndpoint, ok := ctx.Value("atproto.pds").(string)
if !ok || pdsEndpoint == "" {
return nil, fmt.Errorf("pds is required for atproto-router middleware")
}
// For now, use empty access token (we'll add auth later)
accessToken := ""
// Create ATProto client
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
// Get repository name
repoName := repo.Named().Name()
// Get storage endpoint from context
storageEndpoint, ok := ctx.Value("storage.endpoint").(string)
if !ok || storageEndpoint == "" {
return nil, fmt.Errorf("storage.endpoint not found in context")
}
// Create routing repository - no longer uses storage driver
// All blobs are routed through hold service
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repoName, storageEndpoint, did)
return routingRepo, nil
}
+57
View File
@@ -0,0 +1,57 @@
package server
import (
"net/http"
"strings"
"atcr.io/pkg/atproto"
)
// ATProtoHandler wraps an HTTP handler to provide name resolution
// This is an optional layer if middleware doesn't provide enough control
type ATProtoHandler struct {
handler http.Handler
resolver *atproto.Resolver
}
// NewATProtoHandler creates a new HTTP handler wrapper
func NewATProtoHandler(handler http.Handler) *ATProtoHandler {
return &ATProtoHandler{
handler: handler,
resolver: atproto.NewResolver(),
}
}
// ServeHTTP handles HTTP requests with name resolution
func (h *ATProtoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Parse the request path to extract user/image
// OCI Distribution API paths look like:
// /v2/<name>/manifests/<reference>
// /v2/<name>/blobs/<digest>
path := r.URL.Path
// Check if this is a v2 API request
if strings.HasPrefix(path, "/v2/") {
// Extract the repository name
parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
if len(parts) >= 2 {
// parts[0] might be username/DID
// We could do early resolution here if needed
// For now, we'll let the middleware handle it
}
}
// Delegate to the underlying handler
// The registry middleware will handle the actual resolution
h.handler.ServeHTTP(w, r)
}
// Note: In the current architecture, most of the name resolution
// is handled by the registry middleware. This HTTP handler wrapper
// is here for cases where you need to intercept requests before
// they reach the distribution handlers, such as for:
// - Custom authentication based on DIDs
// - Request rewriting
// - Early validation
// - Custom API endpoints beyond OCI spec
+98
View File
@@ -0,0 +1,98 @@
package storage
import (
"sync"
"time"
)
// HoldCache caches hold endpoints for (DID, repository) pairs
// This avoids expensive ATProto lookups on every blob request during pulls
//
// NOTE: This is a simple in-memory cache for MVP. For production deployments:
// - Use Redis or similar for distributed caching
// - Consider implementing cache size limits
// - Monitor memory usage under high load
type HoldCache struct {
mu sync.RWMutex
cache map[string]*holdCacheEntry
}
type holdCacheEntry struct {
holdEndpoint string
expiresAt time.Time
}
var globalHoldCache = &HoldCache{
cache: make(map[string]*holdCacheEntry),
}
func init() {
// Start background cleanup goroutine
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
globalHoldCache.Cleanup()
}
}()
}
// GetGlobalHoldCache returns the global hold cache instance
func GetGlobalHoldCache() *HoldCache {
return globalHoldCache
}
// Set stores a hold endpoint for a (DID, repository) pair with a TTL
func (c *HoldCache) Set(did, repository, holdEndpoint string, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
key := did + ":" + repository
c.cache[key] = &holdCacheEntry{
holdEndpoint: holdEndpoint,
expiresAt: time.Now().Add(ttl),
}
}
// Get retrieves a hold endpoint for a (DID, repository) pair
// Returns empty string and false if not found or expired
func (c *HoldCache) Get(did, repository string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := did + ":" + repository
entry, ok := c.cache[key]
if !ok {
return "", false
}
// Check if expired
if time.Now().After(entry.expiresAt) {
// Don't delete here (would need write lock), let cleanup handle it
return "", false
}
return entry.holdEndpoint, true
}
// Cleanup removes expired entries (called automatically every 5 minutes)
func (c *HoldCache) Cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
removed := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
removed++
}
}
// Log cleanup stats for monitoring
if removed > 0 || len(c.cache) > 100 {
// Log if we removed entries OR if cache is growing large
// This helps identify if cache size is becoming a concern
println("Hold cache cleanup: removed", removed, "entries, remaining", len(c.cache))
}
}
+402
View File
@@ -0,0 +1,402 @@
package storage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/distribution/distribution/v3"
"github.com/opencontainers/go-digest"
)
// Global upload tracking (shared across all ProxyBlobStore instances)
// This is necessary because distribution creates new repository/blob store instances per request
var (
globalUploads = make(map[string]*ProxyBlobWriter)
globalUploadsMu sync.RWMutex
)
// ProxyBlobStore proxies blob requests to an external storage service
type ProxyBlobStore struct {
storageEndpoint string
httpClient *http.Client
did string
}
// NewProxyBlobStore creates a new proxy blob store
func NewProxyBlobStore(storageEndpoint, did string) *ProxyBlobStore {
return &ProxyBlobStore{
storageEndpoint: storageEndpoint,
httpClient: &http.Client{},
did: did,
}
}
// Stat returns the descriptor for a blob
func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
// For simplicity, we'll just check if we can get a download URL
// In production, you'd want a dedicated stat endpoint
url, err := p.getDownloadURL(ctx, dgst)
if err != nil {
return distribution.Descriptor{}, distribution.ErrBlobUnknown
}
// We don't have size info from the storage service
// Return a minimal descriptor
return distribution.Descriptor{
Digest: dgst,
MediaType: "application/octet-stream",
URLs: []string{url},
}, nil
}
// Get retrieves a blob
func (p *ProxyBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) {
url, err := p.getDownloadURL(ctx, dgst)
if err != nil {
return nil, err
}
// Download the blob
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, distribution.ErrBlobUnknown
}
return io.ReadAll(resp.Body)
}
// Open returns a reader for a blob
func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) {
url, err := p.getDownloadURL(ctx, dgst)
if err != nil {
return nil, err
}
// Download the blob
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, distribution.ErrBlobUnknown
}
// Wrap in a ReadSeekCloser
return &readSeekCloser{
ReadCloser: resp.Body,
}, nil
}
// Put stores a blob
func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) {
// Calculate digest
dgst := digest.FromBytes(content)
// Get upload URL
url, err := p.getUploadURL(ctx, dgst, int64(len(content)))
if err != nil {
return distribution.Descriptor{}, err
}
// Upload the blob
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content))
if err != nil {
return distribution.Descriptor{}, err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := p.httpClient.Do(req)
if err != nil {
return distribution.Descriptor{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d", resp.StatusCode)
}
return distribution.Descriptor{
Digest: dgst,
Size: int64(len(content)),
MediaType: mediaType,
}, nil
}
// Delete removes a blob
func (p *ProxyBlobStore) Delete(ctx context.Context, dgst digest.Digest) error {
// Not implemented - storage service would need a delete endpoint
return fmt.Errorf("delete not supported for proxy blob store")
}
// ServeBlob serves a blob via HTTP redirect
func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
// Get presigned download URL
url, err := p.getDownloadURL(ctx, dgst)
if err != nil {
return err
}
// Redirect to presigned URL
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
return nil
}
// Create returns a blob writer for uploading
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
// Parse options
var opts distribution.CreateOptions
for _, option := range options {
if err := option.Apply(&opts); err != nil {
return nil, err
}
}
// Create proxy blob writer
writer := &ProxyBlobWriter{
store: p,
ctx: ctx,
options: opts,
id: fmt.Sprintf("upload-%d", time.Now().UnixNano()),
startedAt: time.Now(),
}
// Store in global uploads map for resume support
globalUploadsMu.Lock()
globalUploads[writer.id] = writer
globalUploadsMu.Unlock()
return writer, nil
}
// Resume returns a blob writer for resuming an upload
func (p *ProxyBlobStore) Resume(ctx context.Context, id string) (distribution.BlobWriter, error) {
// Retrieve upload from global map
globalUploadsMu.RLock()
writer, ok := globalUploads[id]
globalUploadsMu.RUnlock()
if !ok {
return nil, distribution.ErrBlobUploadUnknown
}
return writer, nil
}
// getDownloadURL requests a presigned download URL from the storage service
func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) {
reqBody := map[string]interface{}{
"did": p.did,
"digest": dgst.String(),
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/get-presigned-url", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get download URL: status %d", resp.StatusCode)
}
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.URL, nil
}
// getUploadURL requests a presigned upload URL from the storage service
func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) {
reqBody := map[string]interface{}{
"did": p.did,
"digest": dgst.String(),
"size": size,
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/put-presigned-url", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get upload URL: status %d", resp.StatusCode)
}
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.URL, nil
}
// ProxyBlobWriter implements distribution.BlobWriter for proxy uploads
type ProxyBlobWriter struct {
store *ProxyBlobStore
ctx context.Context
options distribution.CreateOptions
buffer bytes.Buffer
size int64
closed bool
id string
startedAt time.Time
}
// ID returns the upload ID
func (w *ProxyBlobWriter) ID() string {
return w.id
}
// StartedAt returns when the upload started
func (w *ProxyBlobWriter) StartedAt() time.Time {
return w.startedAt
}
// Write writes data to the upload
func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
if w.closed {
return 0, fmt.Errorf("writer closed")
}
n, err := w.buffer.Write(p)
w.size += int64(n)
return n, err
}
// ReadFrom reads from a reader
func (w *ProxyBlobWriter) ReadFrom(r io.Reader) (int64, error) {
if w.closed {
return 0, fmt.Errorf("writer closed")
}
return w.buffer.ReadFrom(r)
}
// Size returns the current size
func (w *ProxyBlobWriter) Size() int64 {
return w.size
}
// Commit finalizes the upload
func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
if w.closed {
return distribution.Descriptor{}, fmt.Errorf("writer closed")
}
w.closed = true
// Remove from global uploads map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
// Upload the buffered content
content := w.buffer.Bytes()
dgst := digest.FromBytes(content)
// Verify digest matches
if desc.Digest != "" && dgst != desc.Digest {
return distribution.Descriptor{}, fmt.Errorf("digest mismatch")
}
// Get upload URL
url, err := w.store.getUploadURL(ctx, dgst, int64(len(content)))
if err != nil {
return distribution.Descriptor{}, err
}
// Upload
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content))
if err != nil {
return distribution.Descriptor{}, err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := w.store.httpClient.Do(req)
if err != nil {
return distribution.Descriptor{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return distribution.Descriptor{}, fmt.Errorf("upload failed: status %d", resp.StatusCode)
}
return distribution.Descriptor{
Digest: dgst,
Size: int64(len(content)),
MediaType: desc.MediaType,
}, nil
}
// Cancel cancels the upload
func (w *ProxyBlobWriter) Cancel(ctx context.Context) error {
w.closed = true
// Remove from global uploads map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
return nil
}
// Close closes the writer
// NOTE: For resumable uploads, we don't mark as closed here
// Distribution calls Close() after each PATCH, but the upload may continue
// Only Commit() and Cancel() actually finalize the upload
func (w *ProxyBlobWriter) Close() error {
// Don't set w.closed = true here - allow resuming
return nil
}
// readSeekCloser wraps an io.ReadCloser to implement ReadSeekCloser
type readSeekCloser struct {
io.ReadCloser
}
func (r *readSeekCloser) Seek(offset int64, whence int) (int64, error) {
// Not implemented - would need buffering or re-downloading
return 0, fmt.Errorf("seek not supported")
}
+95
View File
@@ -0,0 +1,95 @@
package storage
import (
"context"
"fmt"
"time"
"atcr.io/pkg/atproto"
"github.com/distribution/distribution/v3"
)
// RoutingRepository routes manifests to ATProto and blobs to external hold service
// The registry (AppView) is stateless and NEVER stores blobs locally
type RoutingRepository struct {
distribution.Repository
atprotoClient *atproto.Client
repositoryName string
storageEndpoint string // Hold service endpoint for blobs (from discovery for push)
did string // User's DID for authorization
manifestStore *atproto.ManifestStore // Cached manifest store instance
}
// NewRoutingRepository creates a new routing repository
func NewRoutingRepository(
baseRepo distribution.Repository,
atprotoClient *atproto.Client,
repoName string,
storageEndpoint string,
did string,
) *RoutingRepository {
return &RoutingRepository{
Repository: baseRepo,
atprotoClient: atprotoClient,
repositoryName: repoName,
storageEndpoint: storageEndpoint,
did: did,
}
}
// Manifests returns the ATProto-backed manifest service
func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) {
// Create or return cached manifest store
if r.manifestStore == nil {
r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did)
}
// After any manifest operation, cache the hold endpoint for blob fetches
// We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete
go func() {
time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
if holdEndpoint := r.manifestStore.GetLastFetchedHoldEndpoint(); holdEndpoint != "" {
// Cache for 10 minutes - should cover typical pull operations
GetGlobalHoldCache().Set(r.did, r.repositoryName, holdEndpoint, 10*time.Minute)
fmt.Printf("DEBUG [storage/routing]: Cached hold endpoint: did=%s, repo=%s, hold=%s\n",
r.did, r.repositoryName, holdEndpoint)
}
}()
return r.manifestStore, nil
}
// Blobs returns a proxy blob store that routes to external hold service
// The registry (AppView) NEVER stores blobs locally - all blobs go through hold service
func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
// For pull operations, check if we have a cached hold endpoint from a recent manifest fetch
// This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered
holdEndpoint := r.storageEndpoint // Default to discovery-based endpoint
if cachedHold, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok {
// Use cached hold from manifest
holdEndpoint = cachedHold
fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n",
r.did, r.repositoryName, cachedHold)
} else {
// No cached hold, use discovery-based endpoint (for push or first pull)
fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n",
r.did, r.repositoryName, holdEndpoint)
}
if holdEndpoint == "" {
// This should never happen if middleware is configured correctly
panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware")
}
// Always use proxy blob store - routes to external hold service
return NewProxyBlobStore(holdEndpoint, r.did)
}
// Tags returns the tag service
// Tags will be handled by ATProto as well
func (r *RoutingRepository) Tags(ctx context.Context) distribution.TagService {
// For now, delegate to the base repository
// In a full implementation, this would also use ATProto
return r.Repository.Tags(ctx)
}
+54
View File
@@ -0,0 +1,54 @@
package storage
import (
"context"
"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/registry/storage"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/reference"
)
// S3BlobStore wraps distribution's blob store with S3 backend
type S3BlobStore struct {
distribution.BlobStore
}
// NewS3BlobStore creates a new S3-backed blob store
func NewS3BlobStore(ctx context.Context, storageDriver driver.StorageDriver, repoName string) (*S3BlobStore, error) {
// Create a registry instance with the S3 driver
reg, err := storage.NewRegistry(ctx, storageDriver)
if err != nil {
return nil, err
}
// Parse the repository name into a Named reference
named, err := reference.ParseNamed(repoName)
if err != nil {
return nil, err
}
// Get the repository
repo, err := reg.Repository(ctx, named)
if err != nil {
return nil, err
}
// Get the blob store
blobStore := repo.Blobs(ctx)
return &S3BlobStore{
BlobStore: blobStore,
}, nil
}
// Note: S3BlobStore inherits all methods from distribution.BlobStore
// including:
// - Stat(ctx, dgst) - Check if blob exists
// - Get(ctx, dgst) - Retrieve blob
// - Open(ctx, dgst) - Open blob for reading
// - Put(ctx, mediaType, payload) - Store blob
// - Create(ctx, options...) - Create blob writer
// - Resume(ctx, id) - Resume blob upload
// - ServeBlob(ctx, w, r, dgst) - Serve blob over HTTP
// - Delete(ctx, dgst) - Delete blob
Executable
BIN
View File
Binary file not shown.
Executable
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
set -e
echo "=== ATCR Local Testing Setup ==="
echo
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Create directories
echo -e "${BLUE}Creating storage directories...${NC}"
sudo mkdir -p /var/lib/atcr/blobs
sudo mkdir -p /var/lib/atcr/hold
sudo mkdir -p /var/lib/atcr/auth
sudo chown -R $USER:$USER /var/lib/atcr
# Build binaries
echo -e "${BLUE}Building binaries...${NC}"
go build -o atcr-registry ./cmd/registry
go build -o atcr-hold ./cmd/hold
go build -o docker-credential-atcr ./cmd/credential-helper
echo -e "${GREEN}✓ Binaries built${NC}"
echo
# Check if environment variables are set
if [ -z "$ATPROTO_DID" ] || [ -z "$ATPROTO_ACCESS_TOKEN" ]; then
echo -e "${BLUE}Setting up environment variables...${NC}"
echo "Please enter your ATProto DID (e.g., did:plc:...):"
read -r ATPROTO_DID
echo "Please enter your ATProto access token:"
read -rs ATPROTO_ACCESS_TOKEN
echo
export ATPROTO_DID
export ATPROTO_ACCESS_TOKEN
fi
echo -e "${GREEN}✓ Environment configured${NC}"
echo
# Start services
echo -e "${BLUE}Starting ATCR Registry (AppView)...${NC}"
./atcr-registry serve config/config.yml &
REGISTRY_PID=$!
echo "Registry PID: $REGISTRY_PID"
echo -e "${BLUE}Starting Hold Service...${NC}"
./atcr-hold config/hold.yml &
HOLD_PID=$!
echo "Hold PID: $HOLD_PID"
# Wait for services to start
sleep 3
echo
echo -e "${GREEN}✓ Services started${NC}"
echo
echo "=== Services Running ==="
echo "Registry (AppView): http://localhost:5000"
echo "Hold Service: http://localhost:8080"
echo
echo "=== Test the setup ==="
echo "1. Configure OAuth (optional):"
echo " ./docker-credential-atcr configure"
echo
echo "2. Tag and push an image:"
echo " docker tag alpine:latest localhost:5000/alice/alpine:test"
echo " docker push localhost:5000/alice/alpine:test"
echo
echo "3. Pull the image:"
echo " docker pull localhost:5000/alice/alpine:test"
echo
echo "=== Stop services ==="
echo "Run: kill $REGISTRY_PID $HOLD_PID"
echo
echo "Or save PIDs to file:"
echo "echo \"$REGISTRY_PID $HOLD_PID\" > .atcr-pids"
echo "To stop later: kill \$(cat .atcr-pids)"
echo "$REGISTRY_PID $HOLD_PID" > .atcr-pids
# Keep script running
echo
echo "Press Ctrl+C to stop all services..."
trap "kill $REGISTRY_PID $HOLD_PID 2>/dev/null; rm -f .atcr-pids; exit" INT TERM
wait
+261
View File
@@ -0,0 +1,261 @@
#!/bin/bash
# ATCR Registry Test Script
# Tests various registry operations with ATProto storage
set -e # Exit on error
# Configuration
REGISTRY="127.0.0.1:5000"
HANDLE="evan.jarrett.net"
IMAGE_PREFIX="${REGISTRY}/${HANDLE}"
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Helper functions
log_test() {
echo -e "\n${BLUE}========================================${NC}"
echo -e "${BLUE}TEST: $1${NC}"
echo -e "${BLUE}========================================${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_info() {
echo -e "${YELLOW} $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
# Check if logged in
check_login() {
log_info "Checking Docker login status..."
if ! docker login --help &>/dev/null; then
log_error "Docker not available"
exit 1
fi
}
# Test 1: Multiple tags pointing to same manifest
test_multiple_tags() {
log_test "Multiple tags pointing to same manifest"
log_info "Tagging debian:12-slim with multiple tags..."
docker tag ${IMAGE_PREFIX}/debian:12-slim ${IMAGE_PREFIX}/debian:latest
docker tag ${IMAGE_PREFIX}/debian:12-slim ${IMAGE_PREFIX}/debian:bookworm
log_info "Pushing tags..."
docker push ${IMAGE_PREFIX}/debian:latest
docker push ${IMAGE_PREFIX}/debian:bookworm
log_success "Multiple tags pushed successfully"
log_info "All three tags should point to the same manifest digest"
}
# Test 2: Pull by digest
test_pull_by_digest() {
log_test "Pull by digest (immutable reference)"
# Get the manifest digest
log_info "Getting manifest digest..."
DIGEST=$(docker inspect ${IMAGE_PREFIX}/debian:12-slim --format='{{index .RepoDigests 0}}' | cut -d'@' -f2)
if [ -z "$DIGEST" ]; then
log_error "Could not get digest, trying alternative method..."
DIGEST="sha256:d6b33dcae4e2fea363cd63ed9fb43a91e71cc08a3ad3be87acaef4f53655e6a8"
fi
log_info "Digest: $DIGEST"
log_info "Removing local image..."
docker rmi ${IMAGE_PREFIX}/debian:12-slim || true
log_info "Pulling by digest..."
docker pull ${IMAGE_PREFIX}/debian@${DIGEST}
log_success "Pull by digest successful"
}
# Test 3: Layer deduplication
test_layer_deduplication() {
log_test "Layer deduplication (shared layers)"
log_info "Pulling debian:12 (larger variant)..."
docker pull debian:12
log_info "Tagging and pushing debian:12..."
docker tag debian:12 ${IMAGE_PREFIX}/debian:12-full
docker push ${IMAGE_PREFIX}/debian:12-full
log_success "Image with shared layers pushed"
log_info "Check logs - should see 'Layer already exists' or 'Mounted from'"
}
# Test 4: Multiple repositories
test_multiple_repos() {
log_test "Multiple repositories"
log_info "Pulling alpine:latest..."
docker pull alpine:latest
log_info "Tagging alpine..."
docker tag alpine:latest ${IMAGE_PREFIX}/alpine:latest
docker tag alpine:latest ${IMAGE_PREFIX}/alpine:3
log_info "Pushing alpine..."
docker push ${IMAGE_PREFIX}/alpine:latest
docker push ${IMAGE_PREFIX}/alpine:3
log_success "Multiple repositories created"
}
# Test 5: Catalog API
test_catalog_api() {
log_test "Catalog API (list repositories)"
log_info "Fetching repository catalog..."
curl -s -u "${HANDLE}:${APP_PASSWORD}" \
http://${REGISTRY}/v2/_catalog | jq .
log_success "Catalog API works"
}
# Test 6: List tags
test_list_tags() {
log_test "List tags for repository"
log_info "Listing tags for debian repository..."
curl -s -u "${HANDLE}:${APP_PASSWORD}" \
http://${REGISTRY}/v2/${HANDLE}/debian/tags/list | jq .
log_info "Listing tags for alpine repository..."
curl -s -u "${HANDLE}:${APP_PASSWORD}" \
http://${REGISTRY}/v2/${HANDLE}/alpine/tags/list | jq .
log_success "Tag listing works"
}
# Test 7: Inspect manifest
test_inspect_manifest() {
log_test "Inspect manifest directly"
log_info "Fetching manifest for debian:12-slim..."
curl -s -u "${HANDLE}:${APP_PASSWORD}" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
http://${REGISTRY}/v2/${HANDLE}/debian/manifests/12-slim | jq .
log_success "Manifest inspection works"
}
# Test 8: Re-pull after clearing cache
test_repull() {
log_test "Re-pull after clearing local cache"
log_info "Removing all local ATCR images..."
docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${REGISTRY}" | xargs -r docker rmi || true
log_info "Pulling debian:latest from ATCR..."
docker pull ${IMAGE_PREFIX}/debian:latest
log_info "Pulling alpine:latest from ATCR..."
docker pull ${IMAGE_PREFIX}/alpine:latest
log_success "Re-pull from ATProto storage successful"
log_info "Verifying images..."
docker images | grep "${REGISTRY}"
}
# Test 9: Check ATProto records in logs
test_check_logs() {
log_test "Check ATProto records in logs"
log_info "Recent manifest PUT operations:"
docker logs atcr-registry 2>&1 | grep "Manifests()" | tail -5 || log_info "No manifest logs found"
log_info "Recent tag operations:"
docker logs atcr-registry 2>&1 | grep "debian_12-slim\|debian_latest\|alpine_latest" | tail -10 || log_info "No tag logs found"
log_info "Using cached access token:"
docker logs atcr-registry 2>&1 | grep "Using cached access token" | tail -3 || log_info "No token cache logs found"
log_success "Log check complete"
}
# Test 10: HEAD request (check blob existence)
test_head_request() {
log_test "HEAD request (check blob existence)"
BLOB_DIGEST="sha256:cde4222c36b887df35956e37385ad2fd5d32301ca9894363790a1430bf62f80f"
log_info "Checking if blob exists: $BLOB_DIGEST"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "${HANDLE}:${APP_PASSWORD}" \
-I http://${REGISTRY}/v2/${HANDLE}/debian/blobs/${BLOB_DIGEST})
if [ "$STATUS" = "200" ]; then
log_success "Blob exists (HTTP $STATUS)"
else
log_error "Blob not found (HTTP $STATUS)"
fi
}
# Main test runner
main() {
echo -e "${GREEN}"
echo "╔═══════════════════════════════════════╗"
echo "║ ATCR Registry Test Suite ║"
echo "║ Testing ATProto + OCI Registry ║"
echo "╚═══════════════════════════════════════╝"
echo -e "${NC}"
# Check for app password
if [ -z "$APP_PASSWORD" ]; then
log_error "APP_PASSWORD environment variable not set"
echo "Usage: APP_PASSWORD='your-app-password' ./test-registry.sh"
exit 1
fi
check_login
# Run tests
test_multiple_tags
test_pull_by_digest
test_layer_deduplication
test_multiple_repos
test_catalog_api
test_list_tags
test_inspect_manifest
test_repull
test_check_logs
test_head_request
echo -e "\n${GREEN}"
echo "╔═══════════════════════════════════════╗"
echo "║ All Tests Completed! ║"
echo "╚═══════════════════════════════════════╝"
echo -e "${NC}"
log_info "Summary:"
log_info "- Multiple tags pointing to same manifest ✓"
log_info "- Pull by digest (immutable) ✓"
log_info "- Layer deduplication ✓"
log_info "- Multiple repositories ✓"
log_info "- Catalog API ✓"
log_info "- List tags ✓"
log_info "- Manifest inspection ✓"
log_info "- Re-pull from ATProto ✓"
log_info "- ATProto record logging ✓"
log_info "- Blob HEAD requests ✓"
}
# Run tests
main "$@"