commit 85d0bd2463a26e3a613d665708ad38f5f79b1182 Author: Evan Jarrett Date: Thu Oct 2 11:03:59 2025 -0500 initial commit diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..84a1eb7 --- /dev/null +++ b/.claude/settings.json @@ -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": [] + } +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8796bd9 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f8beb7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..00346b2 --- /dev/null +++ b/CLAUDE.md @@ -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//:` + +Where `` 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//` (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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6d0153 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.hold b/Dockerfile.hold new file mode 100644 index 0000000..23c4abe --- /dev/null +++ b/Dockerfile.hold @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3c5054 --- /dev/null +++ b/README.md @@ -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. diff --git a/SAILOR.md b/SAILOR.md new file mode 100644 index 0000000..7262065 --- /dev/null +++ b/SAILOR.md @@ -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// (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? diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..cdff788 --- /dev/null +++ b/SPEC.md @@ -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 + 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": ""} + } + + 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. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..d79ae17 --- /dev/null +++ b/TESTING.md @@ -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 diff --git a/atcr-storage b/atcr-storage new file mode 100755 index 0000000..82578d6 Binary files /dev/null and b/atcr-storage differ diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go new file mode 100644 index 0000000..603bba0 --- /dev/null +++ b/cmd/credential-helper/main.go @@ -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 \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") +} diff --git a/cmd/credential-helper/oauth.go b/cmd/credential-helper/oauth.go new file mode 100644 index 0000000..2e9b3c9 --- /dev/null +++ b/cmd/credential-helper/oauth.go @@ -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, ` + + ATCR Authorization + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + `) + }) + + 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()) +} diff --git a/cmd/credential-helper/token.go b/cmd/credential-helper/token.go new file mode 100644 index 0000000..fabf326 --- /dev/null +++ b/cmd/credential-helper/token.go @@ -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 +} diff --git a/cmd/hold/main.go b/cmd/hold/main.go new file mode 100644 index 0000000..e1918a3 --- /dev/null +++ b/cmd/hold/main.go @@ -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, `

Authorization Successful!

You can close this window and return to the terminal.

`) + 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 +} diff --git a/cmd/registry/main.go b/cmd/registry/main.go new file mode 100644 index 0000000..c2c9f3b --- /dev/null +++ b/cmd/registry/main.go @@ -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) + } +} diff --git a/cmd/registry/serve.go b/cmd/registry/serve.go new file mode 100644 index 0000000..54dc389 --- /dev/null +++ b/cmd/registry/serve.go @@ -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 ", + 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 "" +} diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 0000000..2bfa34c --- /dev/null +++ b/config/config.yml @@ -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 diff --git a/config/hold.yml b/config/hold.yml new file mode 100644 index 0000000..73056c6 --- /dev/null +++ b/config/hold.yml @@ -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 diff --git a/credential-helper b/credential-helper new file mode 100755 index 0000000..5ea94dc Binary files /dev/null and b/credential-helper differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb203b6 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/BYOS.md b/docs/BYOS.md new file mode 100644 index 0000000..4ef73cd --- /dev/null +++ b/docs/BYOS.md @@ -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 <"}` +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":""} +``` + +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) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0ceffb9 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d4ffb9 --- /dev/null +++ b/go.sum @@ -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= diff --git a/hold b/hold new file mode 100755 index 0000000..74eda3e Binary files /dev/null and b/hold differ diff --git a/lexicons/io/atcr/hold.json b/lexicons/io/atcr/hold.json new file mode 100644 index 0000000..c4e7e93 --- /dev/null +++ b/lexicons/io/atcr/hold.json @@ -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" + } + } + } + } + } +} diff --git a/lexicons/io/atcr/hold/crew.json b/lexicons/io/atcr/hold/crew.json new file mode 100644 index 0000000..f759c84 --- /dev/null +++ b/lexicons/io/atcr/hold/crew.json @@ -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" + } + } + } + } + } +} diff --git a/lexicons/io/atcr/manifest.json b/lexicons/io/atcr/manifest.json new file mode 100644 index 0000000..78f9b6b --- /dev/null +++ b/lexicons/io/atcr/manifest.json @@ -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" + } + } + } + } +} diff --git a/lexicons/io/atcr/sailor/profile.json b/lexicons/io/atcr/sailor/profile.json new file mode 100644 index 0000000..63877da --- /dev/null +++ b/lexicons/io/atcr/sailor/profile.json @@ -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" + } + } + } + } + } +} diff --git a/lexicons/io/atcr/tag.json b/lexicons/io/atcr/tag.json new file mode 100644 index 0000000..4774292 --- /dev/null +++ b/lexicons/io/atcr/tag.json @@ -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" + } + } + } + } + } +} diff --git a/pkg/atproto/client.go b/pkg/atproto/client.go new file mode 100644 index 0000000..6260f74 --- /dev/null +++ b/pkg/atproto/client.go @@ -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://// + + 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 +} diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go new file mode 100644 index 0000000..ca29876 --- /dev/null +++ b/pkg/atproto/lexicon.go @@ -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, + } +} diff --git a/pkg/atproto/manifest_store.go b/pkg/atproto/manifest_store.go new file mode 100644 index 0000000..cd84a3f --- /dev/null +++ b/pkg/atproto/manifest_store.go @@ -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 +} diff --git a/pkg/atproto/profile.go b/pkg/atproto/profile.go new file mode 100644 index 0000000..0d98a99 --- /dev/null +++ b/pkg/atproto/profile.go @@ -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 +} diff --git a/pkg/atproto/resolver.go b/pkg/atproto/resolver.go new file mode 100644 index 0000000..f76602c --- /dev/null +++ b/pkg/atproto/resolver.go @@ -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. +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") +} diff --git a/pkg/auth/atproto/session.go b/pkg/auth/atproto/session.go new file mode 100644 index 0000000..2704526 --- /dev/null +++ b/pkg/auth/atproto/session.go @@ -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 +} diff --git a/pkg/auth/atproto/validator.go b/pkg/auth/atproto/validator.go new file mode 100644 index 0000000..686bd73 --- /dev/null +++ b/pkg/auth/atproto/validator.go @@ -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) +} diff --git a/pkg/auth/exchange/handler.go b/pkg/auth/exchange/handler.go new file mode 100644 index 0000000..62b050b --- /dev/null +++ b/pkg/auth/exchange/handler.go @@ -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) +} diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go new file mode 100644 index 0000000..cf0105d --- /dev/null +++ b/pkg/auth/oauth/client.go @@ -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[:]) +} diff --git a/pkg/auth/oauth/discovery.go b/pkg/auth/oauth/discovery.go new file mode 100644 index 0000000..2841b8f --- /dev/null +++ b/pkg/auth/oauth/discovery.go @@ -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 +} diff --git a/pkg/auth/oauth/metadata.go b/pkg/auth/oauth/metadata.go new file mode 100644 index 0000000..61d6388 --- /dev/null +++ b/pkg/auth/oauth/metadata.go @@ -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 + } + } +} diff --git a/pkg/auth/oauth/storage.go b/pkg/auth/oauth/storage.go new file mode 100644 index 0000000..c50262b --- /dev/null +++ b/pkg/auth/oauth/storage.go @@ -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)) +} diff --git a/pkg/auth/oauth/transport.go b/pkg/auth/oauth/transport.go new file mode 100644 index 0000000..c2d0877 --- /dev/null +++ b/pkg/auth/oauth/transport.go @@ -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 +} diff --git a/pkg/auth/scope.go b/pkg/auth/scope.go new file mode 100644 index 0000000..ef839ee --- /dev/null +++ b/pkg/auth/scope.go @@ -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 +} diff --git a/pkg/auth/token/claims.go b/pkg/auth/token/claims.go new file mode 100644 index 0000000..a5c5b20 --- /dev/null +++ b/pkg/auth/token/claims.go @@ -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, + } +} diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go new file mode 100644 index 0000000..59ebfbb --- /dev/null +++ b/pkg/auth/token/handler.go @@ -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) +} diff --git a/pkg/auth/token/issuer.go b/pkg/auth/token/issuer.go new file mode 100644 index 0000000..a8232e2 --- /dev/null +++ b/pkg/auth/token/issuer.go @@ -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 +} diff --git a/pkg/auth/tokencache.go b/pkg/auth/tokencache.go new file mode 100644 index 0000000..a50fd36 --- /dev/null +++ b/pkg/auth/tokencache.go @@ -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) +} diff --git a/pkg/auth/types.go b/pkg/auth/types.go new file mode 100644 index 0000000..e5ffa1c --- /dev/null +++ b/pkg/auth/types.go @@ -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"] +} diff --git a/pkg/middleware/registry.go b/pkg/middleware/registry.go new file mode 100644 index 0000000..5097449 --- /dev/null +++ b/pkg/middleware/registry.go @@ -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 +} diff --git a/pkg/middleware/repository.go b/pkg/middleware/repository.go new file mode 100644 index 0000000..7c34697 --- /dev/null +++ b/pkg/middleware/repository.go @@ -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 +} diff --git a/pkg/server/handler.go b/pkg/server/handler.go new file mode 100644 index 0000000..9d136fc --- /dev/null +++ b/pkg/server/handler.go @@ -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//manifests/ + // /v2//blobs/ + + 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 diff --git a/pkg/storage/hold_cache.go b/pkg/storage/hold_cache.go new file mode 100644 index 0000000..c1daa62 --- /dev/null +++ b/pkg/storage/hold_cache.go @@ -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)) + } +} diff --git a/pkg/storage/proxy_blob_store.go b/pkg/storage/proxy_blob_store.go new file mode 100644 index 0000000..d80010d --- /dev/null +++ b/pkg/storage/proxy_blob_store.go @@ -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") +} diff --git a/pkg/storage/routing_repository.go b/pkg/storage/routing_repository.go new file mode 100644 index 0000000..6aaf1f8 --- /dev/null +++ b/pkg/storage/routing_repository.go @@ -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) +} diff --git a/pkg/storage/s3_blob_store.go b/pkg/storage/s3_blob_store.go new file mode 100644 index 0000000..89a3c02 --- /dev/null +++ b/pkg/storage/s3_blob_store.go @@ -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 diff --git a/registry b/registry new file mode 100755 index 0000000..653afbc Binary files /dev/null and b/registry differ diff --git a/test-local.sh b/test-local.sh new file mode 100755 index 0000000..05e5e87 --- /dev/null +++ b/test-local.sh @@ -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 diff --git a/test-registry.sh b/test-registry.sh new file mode 100755 index 0000000..00dd85f --- /dev/null +++ b/test-registry.sh @@ -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 "$@"